cv.cm

编码 · 算法

双指针与滑动窗口技巧详解

阅读约 7 分钟

简短回答

双指针是一种让两个下标在数组或字符串中移动的技巧,通常是从两端向中间靠拢,或沿同一方向以不同速度前进,使每个元素只被访问常数次。滑动窗口是它的同向变体:在左右两个指针之间维护一段连续区间,窗口扩大和缩小时同步更新一个运行中的值。两者常常能把对数对或子数组的 O(n²) 暴力搜索降为 O(n)。

这些技巧解决什么问题

许多数组和字符串问题问的是数对或连续区间:和为目标值的两个数、不含重复字符的最长子串、和不小于 k 的最短子数组。检查每一个数对或每一段区间,代价是 O(n²) 甚至更高。双指针利用数据的某种性质(比如有序,或者窗口每次只需做很小的更新),一次排除大量候选,从而避免这种代价。

模式指针移动方式典型问题
两端相向left 从 0 开始,right 从末尾开始,两者相向移动有序数组中的两数之和、回文判断、盛最多水的容器
快慢指针两者都从头开始,一个每步都动,另一个只在满足条件时才动原地去重、划分、链表判环
滑动窗口right 扩大窗口,left 收缩窗口满足某个条件的最长或最短子串、子数组

两端指针:有序数组中的两数之和

在有序列表 [1, 3, 4, 6, 8, 11] 中找出和为 10 的两个数。从最小和最大的数开始。如果和太小,增大它的唯一办法是把 left 右移;如果和太大,就把 right 左移。

步骤left(值)right(值)和操作
10 (1)5 (11)12太大,right -= 1
20 (1)4 (8)9太小,left += 1
31 (3)4 (8)11太大,right -= 1
41 (3)3 (6)9太小,left += 1
52 (4)3 (6)10找到:4 + 6
def pair_with_sum(nums, target):
    left, right = 0, len(nums) - 1
    while left < right:
        s = nums[left] + nums[right]
        if s == target:
            return left, right
        if s < target:
            left += 1
        else:
            right -= 1
    return None
print(pair_with_sum([1, 3, 4, 6, 8, 11], 10))   # (2, 3)
print(pair_with_sum([1, 3, 4, 6, 8, 11], 2))    # None

每一步都让一个指针向内移动,所以最多 n − 1 步:时间 O(n),空间 O(1)。同样的写法还可用来判断字符串是否为回文、原地反转数组,并且嵌套在外层循环中时,能把三数之和从 O(n³) 降到 O(n²)。

快慢指针:原地过滤

慢指针标记下一个保留元素应放的位置;快指针扫描每个元素。这样可以在不使用额外内存的情况下,为有序列表去重。

def dedupe_sorted(nums):
    """Remove duplicates in place; return the new length."""
    if not nums:
        return 0
    slow = 0
    for fast in range(1, len(nums)):
        if nums[fast] != nums[slow]:
            slow += 1
            nums[slow] = nums[fast]
    return slow + 1
a = [1, 1, 2, 3, 3, 3, 5]
k = dedupe_sorted(a)
print(k, a[:k])   # 4 [1, 2, 3, 5]

在链表上,快指针每步走两个节点,慢指针每步走一个。如果两者相遇,说明链表有环(Floyd 判圈算法);当快指针到达末尾时,慢指针正好在中点。

滑动窗口:无重复字符的最长子串

滑动窗口始终保证区间 s[left:right + 1] 是合法的。让 right 每次前进一个字符。当新字符破坏了规则,就前移 left,直到窗口重新合法。边走边记录最优的窗口。

def longest_unique(s):
    last_seen = {}          # char -> last index
    left = best = 0
    for right, ch in enumerate(s):
        if ch in last_seen and last_seen[ch] >= left:
            left = last_seen[ch] + 1   # jump past the repeat
        last_seen[ch] = right
        best = max(best, right - left + 1)
    return best
print(longest_unique("abcabcbb"))   # 3  ("abc")
print(longest_unique("pwwkew"))     # 3  ("wke")
print(longest_unique(""))           # 0

窗口分为两种。固定大小的窗口含 k 个元素,每次加入右侧进来的元素,并移除左侧离开的元素,例如用来计算移动平均。可变大小的窗口则通过扩大和收缩来满足某个条件,如上面的例子。

def min_len_subarray(nums, k):
    """Shortest subarray with sum >= k (non-negative nums)."""
    left = total = 0
    best = float("inf")
    for right, x in enumerate(nums):
        total += x
        while total >= k:
            best = min(best, right - left + 1)
            total -= nums[left]
            left += 1
    return 0 if best == float("inf") else best
print(min_len_subarray([2, 3, 1, 2, 4, 3], 7))   # 2  ([4, 3])

时间与空间复杂度

问题暴力解法双指针 / 窗口额外空间
有序输入的两数之和O(n²)O(n)O(1)
有序数组去重O(n),使用新列表O(n),原地O(1)
最长无重复子串O(n³) 或 O(n²)O(n)O(k),k 为不同字符数
和 ≥ k 的最短子数组O(n²)O(n)O(1)

即使带有嵌套的 while 循环,滑动窗口仍是 O(n) 的原因在于:left 和 right 各自累计最多前移 n 次,所以总工作量至多 2n 步。

常见误区

  • 在无序数据上使用两端指针。只有因为数组有序,决定移动 left 还是 right 才是正确的。
  • 数字可能为负时仍使用基于和的滑动窗口。此时收缩窗口不一定会让和变小;应改用前缀和加哈希表。
  • 窗口长度差一:闭区间 left..right 的窗口含有 right - left + 1 个元素。
  • 忘记以 left < right 作为终止条件,会让单个元素与自己配成一对。
  • 让 left 往回走。在子串的例子中,last_seen[ch] >= left 这个检查可防止跳回旧位置。

何时使用,以及典型面试题

当输入有序(或能低成本排序)并且要找数对,或者需要原地修改数组时,就用双指针。当题目提到连续子数组或子串,并要求在窗口增长时条件呈可预测变化的前提下求最长、最短、最大或计数,就用滑动窗口。如果数据无序又要找数对,哈希集合通常更简单,同样是 O(n)。

  • Two Sum II(有序输入)、3Sum、Container With Most Water、Trapping Rain Water。
  • Valid Palindrome、Reverse String、Move Zeroes、Remove Duplicates from Sorted Array。
  • Longest Substring Without Repeating Characters、Minimum Window Substring、Sliding Window Maximum(配合双端队列)。
  • Linked List Cycle、Middle of the Linked List。

在面试之外,同样的思路还出现在合并有序文件、跟踪未确认数据包窗口的网络协议(TCP 滑动窗口)、统计最近 N 秒内事件数的限流器,以及时间序列的移动平均中。

常见问题

什么是双指针技巧?

这是一种用两个下标按简单规则移动来扫描数组或字符串的方法,使每个元素只被处理常数次。它常能把 O(n²) 的数对搜索降为 O(n)。

双指针和滑动窗口有什么区别?

滑动窗口是双指针的一种,两个下标沿同一方向移动,两者之间的元素构成当前的子数组或子串。两端相向的双指针看的是数对,而不是区间。

滑动窗口有嵌套循环,为什么仍是 O(n)?

内层循环只会前移 left,而在整个运行过程中 left 最多移动 n 次。加上 right 的 n 次移动,总共至多 2n 步。

双指针要求数组有序吗?

两端相向的两数之和模式需要。快慢指针和滑动窗口不需要,但基于和的窗口要求数字非负。

全部编码教程