双指针与滑动窗口模板:同向/反向指针与定长/变长窗口

双指针与滑动窗口两大高频技巧的模板化总结:同向双指针(快慢)、反向对撞指针、定长与变长滑动窗口、前缀和结合,覆盖 LeetCode 3/11/76/209 等经典题。

双指针与滑动窗口模板

双指针与滑动窗口是数组/字符串题里出现频率最高的两类技巧。它们本质都是「用两个指针维护一个扫描状态,把 O(n²) 的暴力降到 O(n)」。

  • 双指针:两个指针在数组上移动,分同向(快慢)与反向(对撞)。
  • 滑动窗口:双指针的特例——左右指针之间夹着的「连续区间」就是窗口,靠窗口内统计信息判断收缩。

更细致的滑动窗口四类变体见 数组与字符串分类题解,本文重点是双指针分类与窗口的定长/变长模板。

一、核心思想

暴力:枚举所有子区间 [i, j],需要 O(n²) 甚至 O(n³)
优化:利用单调性,用两个指针跳过不可能成为答案的区间
条件:随着 left 增大,right 的可行解区间单调移动(不回头)

三个关键问题:

  1. 指针怎么移动? 同向(一起向右)还是反向(相向而行)?
  2. 移动的依据? 当前状态满足/不满足某个条件,决定收缩还是扩张。
  3. 答案在哪一刻记录? 收缩前、收缩后还是扩张后?

二、同向双指针模板(快慢指针)

同向双指针:fast 负责探测,slow 负责维护有效区间的左端点。典型场景是原地去重与链表环检测。

模板:数组原地去重(LeetCode 26)

def remove_duplicates(nums):
    slow = 0  # 有效区间的最后一个位置
    for fast in range(1, len(nums)):
        if nums[fast] != nums[slow]:
            slow += 1
            nums[slow] = nums[fast]
    return slow + 1

模板:链表环检测(LeetCode 141)

public boolean hasCycle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast) return true;
    }
    return false;
}

复杂度:O(n) 时间,O(1) 空间。
适用场景:有序数组去重、移动零(LC 283)、链表环/环入口(LC 142)、删除链表倒数第 N 个节点(LC 19,快指针先走 N 步)。

三、反向双指针模板(对撞指针)

反向双指针:left 从最左,right 从最右,相向移动。要求问题对 left、right 两侧的移动方向有单调的收益判断。

模板:两数之和 II(有序数组,LeetCode 167)

def two_sum(numbers, target):
    left, right = 0, len(numbers) - 1
    while left < right:
        s = numbers[left] + numbers[right]
        if s == target:
            return [left + 1, right + 1]
        elif s < target:
            left += 1   # 和太小,左指针右移增大
        else:
            right -= 1  # 和太大,右指针左移减小
    return []

例题:盛最多水的容器(LeetCode 11)

核心观察:容器的容量 = min(height[left], height[right]) * (right - left)。短板决定上限,移动较长的一边不可能让容量变大,所以移动较短的一边。

int maxArea(vector<int>& height) {
    int left = 0, right = height.size() - 1, ans = 0;
    while (left < right) {
        int area = min(height[left], height[right]) * (right - left);
        ans = max(ans, area);
        if (height[left] < height[right]) left++;
        else right--;
    }
    return ans;
}

复杂度:O(n) 时间,O(1) 空间。
适用场景:有序两数之和、三数之和(LC 15)、盛水容器(LC 11)、接雨水(LC 42,先算两侧最高再对撞)、回文串判断(LC 125)。

对撞指针使用前提

单调性:在某一侧移动指针时,结果的变化方向是确定的。例如盛水容器——移动短板一侧,容器高度才可能上升;移动长板一侧,宽度减小且高度不可能增加,收益必降。没有这种单调性就不要用对撞指针。

四、滑动窗口模板

滑动窗口解决「连续子数组/子串」问题,核心是窗口内维护一个统计信息(计数、和、最值等)。

定长窗口模板(LC 643 / 239)

def fixed_window(nums, k):
    # 先凑出第一个窗口
    window_sum = sum(nums[:k])
    ans = window_sum
    for i in range(k, len(nums)):
        window_sum += nums[i] - nums[i - k]  # 右进左出
        ans = max(ans, window_sum)
    return ans

定长窗口是 O(n) 滑动,不需要收缩逻辑;若窗口内要维护最值,则配单调队列(LC 239 滑动窗口最大值)。

// LeetCode 239:单调队列维护窗口最大值
public int[] maxSlidingWindow(int[] nums, int k) {
    Deque<Integer> q = new ArrayDeque<>(); // 存下标,队首最大
    int[] ans = new int[nums.length - k + 1];
    for (int i = 0; i < nums.length; i++) {
        while (!q.isEmpty() && nums[q.peekLast()] <= nums[i]) q.pollLast();
        q.offerLast(i);
        if (q.peekFirst() <= i - k) q.pollFirst();   // 出窗口
        if (i >= k - 1) ans[i - k + 1] = nums[q.peekFirst()];
    }
    return ans;
}

变长窗口模板(LC 209 / 3)

def min_subarray_len(nums, target):
    # 最小可变窗口:找满足和 >= target 的最短子数组(LC 209)
    left = window_sum = 0
    ans = float('inf')
    for right in range(len(nums)):
        window_sum += nums[right]        # 1. 扩大窗口
        while window_sum >= target:      # 2. 收缩窗口直到不满足
            ans = min(ans, right - left + 1)
            window_sum -= nums[left]
            left += 1
    return 0 if ans == float('inf') else ans

模板三步走(以「无重复字符的最长子串」LC 3 为例):

def length_of_longest_substring(s):
    seen = set()
    left = ans = 0
    for right in range(len(s)):
        while s[right] in seen:          # 收缩:窗口内有重复就左移
            seen.remove(s[left])
            left += 1
        seen.add(s[right])               # 扩大:加入右端
        ans = max(ans, right - left + 1) # 更新:此时窗口合法
    return ans

变长窗口 - 最小覆盖子串(LC 76,模板完整版)

维护 need 与 valid,当 valid == len(need) 时窗口已覆盖全部目标字符,尝试收缩找最短。

from collections import Counter

def min_window(s, t):
    need = Counter(t)
    window = {}
    left = valid = 0
    start, length = 0, float('inf')

    for right in range(len(s)):
        c = s[right]
        if c in need:
            window[c] = window.get(c, 0) + 1
            if window[c] == need[c]:
                valid += 1

        while valid == len(need):        # 窗口已覆盖,尝试收缩
            if right - left + 1 < length:
                start, length = left, right - left + 1
            d = s[left]
            if d in need:
                if window[d] == need[d]:
                    valid -= 1
                window[d] -= 1
            left += 1

    return "" if length == float('inf') else s[start:start + length]

复杂度:均为 O(n) 时间(每个元素至多进出窗口一次),O(字符集) 空间。

五、前缀和 + 双指针/窗口结合

当窗口的「条件」是数值和,且元素可能为负数(此时 while 收缩不成立)时,改用前缀和 + 哈希表。

例题:和为 K 的子数组(LeetCode 560)

def subarray_sum(nums, k):
    prefix = {0: 1}   # 前缀和 -> 出现次数
    cur = ans = 0
    for num in nums:
        cur += num
        ans += prefix.get(cur - k, 0)  # 存在以某个左边界结尾的和为 cur-k
        prefix[cur] = prefix.get(cur, 0) + 1
    return ans

与双指针的分工:

场景推荐方法原因
全为正数,求满足和的区间滑动窗口(LC 209)窗口和随 right 单调增,可 while 收缩
存在负数,求满足和的区间前缀和 + 哈希表(LC 560)和不再单调,窗口法失效
有序数组找两数对撞指针(LC 167)利用有序单调性
无序数组找两数哈希表(LC 1)一次遍历记录已见

六、经典题速查表

题号题目技巧难度
LeetCode 3无重复字符的最长子串变长窗口Medium
LeetCode 11盛最多水的容器对撞指针Medium
LeetCode 15三数之和排序 + 对撞Medium
LeetCode 76最小覆盖子串变长窗口Hard
LeetCode 209长度最小的子数组变长窗口Medium
LeetCode 239滑动窗口最大值定长窗口 + 单调队列Hard
LeetCode 141/142环形链表快慢指针Easy/Medium
LeetCode 167两数之和 II对撞指针Medium
LeetCode 560和为 K 的子数组前缀和 + 哈希Medium
LeetCode 713乘积小于 K 的子数组变长窗口Medium

七、面试常见问题

Q: 什么时候用滑动窗口,什么时候用对撞指针?

  • 滑动窗口:要求连续子区间,且窗口内条件随扩大/收缩单调可判(多为非负数组)。
  • 对撞指针:有序性 + 两端单调收益,典型如两数之和、盛水容器。
  • 快慢指针:链表问题或原地数组操作(去重、移动零)。

Q: 收缩用 while 还是 if?
用 while。因为左指针可能要连续移动多步才能重新满足条件(如 LC 76 里要减去多个字符才让 valid 下降)。

Q: 窗口里统计什么?用什么数据结构?

  • 字符计数:哈希表,或定长数组 [26]/[128]/[256]。
  • 窗口最大值:单调队列。
  • 窗口元素去重:set(如 LC 3)。
  • 窗口内中位数:两个堆或有序集合(LC 480)。

Q: 为什么双指针能把 O(n²) 降为 O(n)?
每个指针最多从头走到尾各一次(同向)或各移动 n 次(对撞),总移动次数 O(n),配合单调性跳过了大量不可能区间。


相关文章:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页