算法

双指针 & 滑动窗口

两个下标协同移动,将 O(n²) 暴力降至 O(n),处理子数组与子串问题的利器

概览

双指针的核心:用两个下标 left / right 代替两层嵌套循环。关键在于找到一个「单调性」:当一个指针移动时,另一个指针只需要往同一方向走,不会回头。这样两个指针各自最多走 n 步,总复杂度 O(n)。

四种常见模式

对向双指针从两端向中间收缩,常用于有序数组的 Two Sum、三数之和、容器问题
同向双指针(快慢)fast 先跑,slow 标记有效位置,常用于原地删除、链表中点
定长滑动窗口窗口大小固定为 k,整体平移,常用于最大子数组和、平均值
变长滑动窗口右指针扩展,左指针按条件收缩,常用于最长/最短子串

核心思维:维护窗口不变量

每步操作前后,窗口(或区间)必须满足某个条件。右指针扩展时可能破坏条件,此时收缩左指针直到条件恢复。这个「扩展-收缩」节奏是变长滑动窗口的本质。

复杂度

时间 O(n)left 和 right 各自最多移动 n 步,合计 O(2n) = O(n)
空间 O(1) 或 O(k)对向/同向只需常数变量;滑动窗口可能需要哈希表/计数器 O(k)

对向双指针

left 从左端出发,right 从右端出发,根据当前两元素的值决定移动哪个指针,直到相遇。前提:数组通常已排序(或可排序)。

核心思想

以 Two Sum(有序数组)为例:若 nums[left] + nums[right] == target,找到答案;若和偏小,left++ 使和增大;若和偏大,right-- 使和减小。每步都能排除一个元素,共 n 步。

步骤(Two Sum 有序版)

  1. 初始化 left = 0,right = n - 1
  2. 计算 sum = nums[left] + nums[right]
  3. 若 sum == target,记录答案,退出
  4. 若 sum < target,left++(增大左边元素使和变大)
  5. 若 sum > target,right--(减小右边元素使和变小)
  6. left >= right 时退出(无解)

C++ 实现

// Two Sum II — sorted array (1-indexed output)
pair<int,int> twoSum(vector<int>& nums, int target) {
    int lo = 0, hi = (int)nums.size() - 1;
    while (lo < hi) {
        int sum = nums[lo] + nums[hi];
        if (sum == target) return {lo + 1, hi + 1};
        else if (sum < target) lo++;   // sum too small → move left up
        else                   hi--;   // sum too large → move right down
    }
    return {-1, -1};
}

适用场景

有序数组两数之和
left 从左,right 从右,按 sum 大小移动
三数之和(3Sum)
固定一个数,对剩余数组用对向双指针
盛水容器(LC 11)
面积 = min(h[l],h[r])×(r-l),移动较矮的那侧
回文验证
left 和 right 同时向中间走,逐一比较字符

常见错误

忘记排序对向双指针前提是有序,若题目未保证有序需先排序
3Sum 去重跳过重复元素:找到答案后 while(l<r&&nums[l]==nums[l-1])l++
指针越界while (left < right) 而非 left <= right,防止重复处理同一元素
练习:有序数组两数之和代码练习
测试用例(5 个)
用例 1
输入:4 9 2 7 11 15
期望:1 2
用例 2
输入:3 6 2 3 4
期望:1 3
用例 3
输入:2 -1 -3 2
期望:1 2
用例 4
输入:4 10 1 4 6 10
期望:2 3
用例 5
输入:2 2 1 1
期望:1 2

同向双指针(快慢指针)

fast 和 slow 从同一侧出发,fast 负责探路,slow 负责标记「有效区域」的末尾。fast 每步必走,slow 只在满足条件时才前进。

核心思想

以原地删除重复元素为例:slow 指向当前去重后数组的末尾;fast 遍历每个元素,若与 slow 所指不同,则将其写入 ++slow 位置。最终 slow+1 即为新长度。

步骤(原地删除重复)

  1. 初始化 slow = 0,fast 从 1 开始
  2. fast 扫描每个元素
  3. 若 nums[fast] != nums[slow],令 nums[++slow] = nums[fast]
  4. fast 到达末尾,返回 slow + 1

C++ 实现

// Remove Duplicates from Sorted Array (in-place)
int removeDuplicates(vector<int>& nums) {
    if (nums.empty()) return 0;
    int slow = 0;                        // tail of unique prefix
    for (int fast = 1; fast < (int)nums.size(); fast++) {
        if (nums[fast] != nums[slow])
            nums[++slow] = nums[fast];   // write new unique element
    }
    return slow + 1;                     // new length
}

适用场景

原地删除重复/指定值
slow 标记有效尾,fast 筛选有效元素写入
链表中点
fast 走两步,slow 走一步,fast 到头时 slow 在中点
链表环检测
fast/slow 如在环中必相遇(Floyd 判圈)
移动零
slow 收集非零,fast 遍历;最后补零

常见错误

slow 初始位置通常 slow=0(数组的第一个有效位置),fast=1 从第二个元素开始
返回长度新长度是 slow + 1,不是 slow
链表 fast 步长链表中点:fast 每次走两步(fast=fast->next->next),注意判空防崩溃
练习:删除有序数组中的重复项代码练习
测试用例(5 个)
用例 1
输入:5 1 1 2 3 3
期望:3
用例 2
输入:6 0 0 1 1 1 2
期望:3
用例 3
输入:3 1 2 3
期望:3
用例 4
输入:4 1 1 1 1
期望:1
用例 5
输入:1 5
期望:1

定长滑动窗口

窗口大小固定为 k,整体向右滑动。每次移入一个新元素(右侧),同时移出最旧元素(左侧),维护窗口内的聚合值(sum、max、freq 等)。

核心思想

先计算第一个窗口 [0, k-1] 的结果,然后循环:windowSum += nums[i] - nums[i-k]。这样每次只做 O(1) 的增量更新,而非重新计算,从而把 O(nk) 降到 O(n)。

步骤(最大子数组和,窗口大小 k)

  1. 计算初始窗口 [0, k-1] 的和,令 maxSum = windowSum
  2. i 从 k 到 n-1 循环(i 是新进入窗口的元素)
  3. windowSum += nums[i] - nums[i - k](加新元素,减最旧元素)
  4. maxSum = max(maxSum, windowSum)
  5. 返回 maxSum

C++ 实现

// Maximum sum of any subarray of size k
int maxSumWindow(vector<int>& nums, int k) {
    int windowSum = 0;
    for (int i = 0; i < k; i++) windowSum += nums[i]; // first window
    int maxSum = windowSum;
    for (int i = k; i < (int)nums.size(); i++) {
        windowSum += nums[i] - nums[i - k]; // slide: add right, drop left
        maxSum = max(maxSum, windowSum);
    }
    return maxSum;
}

适用场景

最大/最小子数组和(长度固定 k)
O(n) 累加和滑动
滑动窗口最大值(LC 239)
使用单调双端队列维护窗口内最大值,仍是 O(n)
字符串固定长度异位词(LC 438)
用频率数组 + 计数器判断两窗口是否相等

常见错误

初始窗口边界第一个窗口是 [0, k-1],从 i=k 开始滑动,不是从 i=1
移出元素下标移出的是 nums[i-k],不是 nums[i-k-1]
需要最大值时纯滑动窗口只能维护 sum,求窗口 max 需要单调双端队列(deque)
练习:大小为 k 的子数组最大和代码练习
测试用例(5 个)
用例 1
输入:5 3 2 1 5 1 3
期望:9
用例 2
输入:5 2 3 1 5 2 4
期望:7
用例 3
输入:4 1 -1 -3 -2 -4
期望:-1
用例 4
输入:3 3 1 2 3
期望:6
用例 5
输入:6 2 5 1 3 2 4 6
期望:10

变长滑动窗口

窗口大小不固定,右指针持续扩张,左指针按照「窗口是否违反约束」来收缩。两个指针合计移动 O(n) 次,整体 O(n)。

核心思想

right 每次向右扩一位,将 s[right] 加入窗口;若窗口违反约束(如出现重复字符),不断 left++ 直到窗口合法;此时 [left, right] 是以 right 结尾的最长合法窗口,更新答案。

步骤(最长无重复字符子串)

  1. 初始化 left = 0,freq 计数器,ans = 0
  2. right 从 0 到 n-1 循环,将 s[right] 加入 freq
  3. 若 freq[s[right]] > 1(出现重复),收缩:freq[s[left]]--, left++
  4. 更新 ans = max(ans, right - left + 1)
  5. 返回 ans

C++ 实现

// Longest Substring Without Repeating Characters
int lengthOfLongestSubstring(string s) {
    unordered_map<char, int> freq;
    int ans = 0, left = 0;
    for (int right = 0; right < (int)s.size(); right++) {
        freq[s[right]]++;
        while (freq[s[right]] > 1) {   // duplicate → shrink from left
            freq[s[left]]--;
            left++;
        }
        ans = max(ans, right - left + 1);
    }
    return ans;
}

常见约束类型

无重复字符freq[c] > 1 时收缩(最长无重复子串 LC 3)
至多 k 种不同字符distinct > k 时收缩(LC 340 变体)
子数组和 >= targetsum >= target 时尝试收缩取最小长度(LC 209)
覆盖所有字符未覆盖字符数 > 0 时扩展,否则收缩取最小(最小覆盖子串 LC 76)

常见错误

收缩条件收缩应在 while 而非 if 里——一次收缩可能不够,要收缩到合法为止
更新答案时机收缩完成后(窗口合法时)才更新答案,而非加入元素后立刻更新
最小长度问题找最小时初始 ans = INT_MAX,找最大时 ans = 0
练习:最长无重复字符子串代码练习
测试用例(5 个)
用例 1
输入:abcabcbb
期望:3
用例 2
输入:bbbbb
期望:1
用例 3
输入:pwwkew
期望:3
用例 4
输入:abcde
期望:5
用例 5
输入:dvdf
期望:3

LeetCode 题型

按模式分类的高频双指针题目。

对向双指针

167. 两数之和 II
排序数组,对向收缩
15. 三数之和
排序 + 枚举第一个 + 对向双指针去重
11. 盛最多水的容器
移动较矮的板
42. 接雨水
左右最大高度预处理或双指针 O(1) 空间

同向双指针

26. 删除有序数组中的重复项
slow/fast 原地去重
27. 移除元素
slow 收集非目标值,fast 遍历
283. 移动零
slow 收集非零,最后补零
876. 链表的中间结点
fast 走两步,slow 走一步

定长滑动窗口

643. 子数组最大平均数 I
固定 k,滑动 sum/k
1343. 大小为 K 且平均值大于等于阈值的子数组数目
固定 k,统计满足条件的窗口
239. 滑动窗口最大值
单调双端队列,O(n)

变长滑动窗口

3. 无重复字符的最长子串
freq 计数器,收缩到无重复
76. 最小覆盖子串
计数器+need,收缩取最小
209. 长度最小的子数组
sum>=target 时收缩
438. 找到字符串中所有字母异位词
固定窗口 + 频率比较