双指针与滑动窗口:高效处理连续区间

双指针与滑动窗口:高效处理连续区间

摘要

数组和字符串中的连续区间问题,经常需要寻找满足条件的最长子数组、最短子数组、目标和区间或不重复子串。直接枚举所有左右边界通常需要平方级时间,而双指针和滑动窗口可以利用区间移动规律,把很多问题优化到线性复杂度。

本文从左右指针和窗口不变量开始,分别介绍相向双指针、同向双指针、固定长度窗口和可变长度窗口,并通过有序数组两数之和、最长无重复子串、最短满足和子数组、固定窗口最大值等案例说明适用条件。文章还会重点讨论负数、重复元素、窗口收缩时机和边界测试。

一、背景与问题

1. 连续区间问题的共同结构

给定数组:

text 复制代码
[2, 3, 1, 2, 4, 3]

可能需要回答:

  • 是否存在两个数之和等于目标值?
  • 和至少为 target 的最短连续子数组是什么?
  • 不包含重复字符的最长子串有多长?
  • 长度为 k 的每个窗口最大值是多少?

这些问题的共同点是:答案与两个边界有关,区间内部通常还具有可维护的状态。

2. 暴力枚举为什么慢

如果枚举所有左边界和右边界,长度为 n 的数组有 O(n²) 个连续区间。每个区间再计算总和、重复字符或最大值,可能进一步增加复杂度。

双指针的目标不是机械地使用两个变量,而是证明:

text 复制代码
当某个边界向一个方向移动后,不需要回退

只有在这个单调性成立时,才能把平方级枚举压缩为线性扫描。

二、核心概念

1. 左指针和右指针

常见写法是:

text 复制代码
left = 0
for right in range(len(values)):
    # 把 right 纳入当前范围
    while condition_is_invalid():
        left += 1
  • right 负责扩展当前范围;
  • left 负责收缩当前范围;
  • left 和 right 通常只向右移动。

2. 窗口

区间 [left, right] 及其状态称为窗口。窗口状态可以是:

  • 元素和;
  • 计数器;
  • 字符频数;
  • 集合;
  • 单调队列;
  • 当前最大值或最小值。

每次移动边界后,都要更新状态,保持不变量成立。

3. 固定长度和可变长度窗口

类型 特点 常见问题
固定长度窗口 窗口长度始终为 k 每个长度为 k 的区间最大和
可变长度窗口 根据条件扩张和收缩 最短满足和区间、最长合法区间

固定窗口通常每次加入一个元素、移除一个元素;可变窗口需要明确何时收缩以及何时更新答案。

4. 相向双指针

相向双指针从数组两端向中间移动,常用于有序数组:

text 复制代码
left →                 ← right
[  ...  ...  ...  ...  ...  ]

如果当前和太小,移动 left 可以让和增大;如果当前和太大,移动 right 可以让和减小。这个判断依赖数组已经有序。

5. 窗口不变量

算法运行过程中,需要始终明确窗口代表什么。例如:

  • left 到 right 内没有重复字符;
  • 当前窗口和等于窗口内所有元素之和;
  • 窗口长度始终不超过 k;
  • 单调队列保存当前窗口中仍可能成为最大值的元素。

不变量是证明算法正确性和排查边界错误的关键。

三、工作原理

1. 双指针为什么可以达到线性复杂度

以同向窗口为例:

text 复制代码
right 最多向右移动 n 次
left 最多向右移动 n 次
总移动次数最多 2n

虽然代码中有嵌套的 while,但 left 不会回退,因此总循环次数仍然是 O(n),而不是每个 right 都重新从头移动 left。

2. 什么时候可以收缩窗口

窗口收缩通常需要一个单调条件。例如数组元素非负时,窗口和随着右边界扩展不会减少;当窗口和已经达到目标后,继续右移左边界只会让窗口更短或让窗口和变小,因此可以不断收缩并更新答案。

如果数组中允许负数,这个单调性就不成立,简单的滑动窗口可能漏掉答案。

3. 相向指针依赖有序性

对于有序数组:

text 复制代码
sum < target → left 右移
sum > target → right 左移
sum = target → 找到答案

如果数组无序,移动某个指针无法推断和一定变大或变小,此时应考虑哈希表、排序后保留索引,或其他算法。

4. 单调队列维护窗口最值

固定窗口最大值不能通过每次重新扫描窗口得到,否则复杂度为 O(nk)。单调递减队列可以维护候选下标:

  • 新元素进入时,删除队尾比它小的元素;
  • 窗口右移时,删除已经离开窗口的队首下标;
  • 队首始终是当前窗口最大值的下标。

每个元素最多入队和出队一次,因此总复杂度为 O(n)。

四、实战示例

下面的示例使用 Python 3.10+,只依赖标准库,各代码块按顺序运行。

1. 有序数组中的两数之和

python 复制代码
def two_sum_sorted(numbers, target):
    left, right = 0, len(numbers) - 1

    while left < right:
        current = numbers[left] + numbers[right]
        if current == target:
            return left, right
        if current < target:
            left += 1
        else:
            right -= 1

    return None


numbers = [1, 2, 4, 7, 11, 15]
assert two_sum_sorted(numbers, 15) == (2, 4)
assert two_sum_sorted(numbers, 3) == (0, 1)
assert two_sum_sorted(numbers, 100) is None

下标是从 0 开始的。该算法要求输入数组非递减有序;如果输入未排序,不能直接使用上述移动规则。

2. 无序数组中的两数之和

无序数组更适合用哈希表记录已经看过的数:

python 复制代码
def two_sum(numbers, target):
    seen = {}

    for index, value in enumerate(numbers):
        complement = target - value
        if complement in seen:
            return seen[complement], index
        seen[value] = index

    return None


assert two_sum([4, 2, 7, 1], 9) == (1, 2)
assert two_sum([3, 3], 6) == (0, 1)
assert two_sum([1, 2, 3], 10) is None

排序加双指针和哈希表各有取舍:排序可能改变原始下标并需要 O(n log n) 时间,哈希表通常用 O(n) 额外空间换取线性时间。

3. 最长无重复字符子串

维护每个字符最近一次出现的位置。当右指针遇到重复字符时,把左指针移动到重复字符上一次位置之后:

python 复制代码
def longest_unique_substring(text):
    last_seen = {}
    left = 0
    best_length = 0
    best_range = (0, 0)

    for right, char in enumerate(text):
        if char in last_seen and last_seen[char] >= left:
            left = last_seen[char] + 1

        last_seen[char] = right
        current_length = right - left + 1

        if current_length > best_length:
            best_length = current_length
            best_range = (left, right + 1)

    start, end = best_range
    return best_length, text[start:end]


assert longest_unique_substring("abcabcbb") == (3, "abc")
assert longest_unique_substring("bbbbb") == (1, "b")
assert longest_unique_substring("") == (0, "")
assert longest_unique_substring("abba") == (2, "ab")

last_seen[char] >= left 很重要。如果重复字符上一次出现的位置已经在窗口左侧,就不能把左指针向后移。

4. 和至少为 target 的最短子数组

当数组元素全部为非负数时,可以使用可变长度窗口:

python 复制代码
def min_subarray_len(target, numbers):
    left = 0
    window_sum = 0
    best = len(numbers) + 1

    for right, value in enumerate(numbers):
        if value < 0:
            raise ValueError("该实现要求数组元素非负")

        window_sum += value
        while window_sum >= target:
            best = min(best, right - left + 1)
            window_sum -= numbers[left]
            left += 1

    return 0 if best == len(numbers) + 1 else best


assert min_subarray_len(7, [2, 3, 1, 2, 4, 3]) == 2
assert min_subarray_len(4, [1, 4, 4]) == 1
assert min_subarray_len(20, [1, 2, 3]) == 0

先扩展右边界使窗口满足条件,再不断右移左边界寻找当前右边界下的最短合法窗口。由于元素非负,收缩后窗口和不会增加,因此不会漏掉更优解。

5. 固定长度窗口最大和

固定窗口每次只需要加上新元素,再减去离开的元素:

python 复制代码
def max_window_sum(numbers, k):
    if k <= 0:
        raise ValueError("窗口长度必须为正数")
    if k > len(numbers):
        raise ValueError("窗口长度不能超过数组长度")

    window_sum = sum(numbers[:k])
    best = window_sum

    for right in range(k, len(numbers)):
        window_sum += numbers[right]
        window_sum -= numbers[right - k]
        best = max(best, window_sum)

    return best


assert max_window_sum([2, 1, 5, 1, 3, 2], 3) == 9
assert max_window_sum([-3, -1, -2], 2) == -3

如果每次都对长度为 k 的区间调用 sum,复杂度是 O(nk);使用增量维护后是 O(n)。

6. 单调队列求固定窗口最大值

队列中保存下标而不是数值,才能判断元素是否已经离开窗口:

python 复制代码
from collections import deque


def max_sliding_window(numbers, k):
    if k <= 0:
        raise ValueError("窗口长度必须为正数")
    if k > len(numbers):
        raise ValueError("窗口长度不能超过数组长度")

    candidates = deque()
    result = []

    for right, value in enumerate(numbers):
        left = right - k + 1

        while candidates and candidates[0] < left:
            candidates.popleft()

        while candidates and numbers[candidates[-1]] <= value:
            candidates.pop()

        candidates.append(right)

        if right >= k - 1:
            result.append(numbers[candidates[0]])

    return result


values = [1, 3, -1, -3, 5, 3, 6, 7]
assert max_sliding_window(values, 3) == [3, 3, 5, 5, 6, 7]
assert max_sliding_window([2, 2, 2], 2) == [2, 2]

使用 <= 删除队尾相同值时,会保留更新位置更靠后的下标;如果改用 <,也可以得到正确最大值,但队列中可能保留更多重复候选。

7. 验证指针算法的边界

python 复制代码
def brute_max_window(numbers, k):
    return [
        max(numbers[start:start + k])
        for start in range(len(numbers) - k + 1)
    ]


test_cases = [
    ([], 1),
    ([5], 1),
    ([1, 2, 3], 1),
    ([1, 2, 3], 3),
    ([3, 3, 3], 2),
    ([-5, -2, -8, -1], 2),
]

for values, k in test_cases:
    if not values:
        continue
    assert max_sliding_window(values, k) == brute_max_window(values, k)

try:
    max_sliding_window([1, 2], 0)
except ValueError:
    pass
else:
    raise AssertionError("应拒绝零长度窗口")

try:
    min_subarray_len(3, [2, -1, 2])
except ValueError:
    pass
else:
    raise AssertionError("应拒绝不满足前提的负数输入")

print("双指针与滑动窗口边界测试通过")

边界测试要覆盖空数组、单元素、全相同值、全负数、窗口长度为 1 和窗口长度等于数组长度等情况。

五、常见问题与实践建议

1. 忘记算法前提

有序数组两数之和要求数组有序;"和至少为目标值"的简单滑动窗口要求元素非负。输入不满足前提时,代码可能返回错误结果而不是抛出异常。

2. 收缩窗口的时机错误

可变窗口通常是先扩展右边界,再在条件满足时收缩左边界。如果在条件不满足时收缩,可能跳过答案;如果只收缩一次,可能没有找到当前右边界下的最优窗口。

3. 重复字符导致左指针回退

处理最长无重复子串时,左指针只能向右移动。要判断字符上次出现位置是否仍在当前窗口内,不能直接无条件赋值。

4. 把下标和值混在一起

单调队列需要根据下标判断过期元素,根据下标访问值。如果只保存值,遇到重复值时无法区分哪个元素应该离开窗口。

5. 负数破坏窗口和的单调性

窗口和中存在负数时,右边界扩展不一定使和增加,左边界收缩也不一定使和减少。此时应考虑前缀和加单调队列、哈希表或其他方法。

6. 用排序改变了原始下标

排序后的双指针适合只返回数值或可以同步保存原始下标的场景。如果题目要求原数组位置,排序前要把值和原下标绑定。

7. 只测正常输入

双指针代码的错误经常出现在空输入、重复值、全负数、目标值刚好满足和窗口边界。应把这些用例加入自动化测试,而不是只手工运行一个示例。

8. 误把两个指针当成万能优化

双指针不是把两个索引放进代码就能获得线性复杂度。必须能够证明指针不会回退,或者窗口状态具有可增量维护的性质。

六、进阶思考

1. 前缀和与负数数组

对于包含负数的数组,寻找和至少为 target 的最短子数组,可以使用前缀和与单调队列。令 prefix[i] 表示前 i 个元素的和,区间 [left, right) 的和为:

text 复制代码
prefix[right] - prefix[left]

通过维护递增的前缀和下标,可以在 O(n) 时间内处理负数情况。这是滑动窗口思想在单调性失效后的扩展。

2. 两个窗口状态同时维护

有些问题需要同时维护频数和窗口长度,例如"最多替换 k 个字符后的最长重复字符子串"。窗口状态越复杂,越需要明确哪些变量在左移和右移时如何更新。

3. 二分答案与窗口判定

当问题是"满足某个条件的最短长度"或"最大长度",可以先猜一个长度,再使用双指针判断该长度是否可行,结合二分查找答案。前提是可行性具有单调性。

4. 单调队列是更一般的窗口结构

单调队列不仅可以求固定窗口最大值,也可以求最小值、维护带权候选或配合动态规划优化转移。关键是删除永远不可能成为答案的候选。

5. 复杂度证明要计算指针总移动

分析滑动窗口时,不要只看代码缩进。分别计算每个指针最多移动多少次,若每个元素最多进入和离开状态常数次,通常就能得到 O(n) 的总复杂度。

结论

双指针和滑动窗口适合处理具有连续区间结构的问题。相向双指针依赖有序性,同向可变窗口依赖条件的单调性,固定窗口可以通过增量更新避免重复计算,单调队列则可以在线性时间内维护窗口最值。

使用这些技巧前,先明确输入前提、窗口不变量和指针移动规则。遇到负数、无序数据或需要恢复路径时,不要强行套用滑动窗口,应改用前缀和、哈希表、排序或其他算法。下一步可以继续学习二分查找,把双指针中的单调性推广到答案空间。

参考资料

相关推荐
代钦塔拉1 小时前
【C++/Qt 进阶】设计模式系列(2):工厂模式——从游戏角色到披萨店
c++·算法·设计模式
思茂信息1 小时前
电磁仿真学涉及哪些算法?
人工智能·算法·数学建模·matlab·emc·电磁仿真·仿真辐射
开开心心就好1 小时前
文件被占用删不掉?解锁工具强制删除
java·运维·服务器·开发语言·前端·pdf·ocr
糖果豆豆2 小时前
让 AI 自查三遍,它只会把错的说得更像对的——检查的人和被检查的,是同一个
算法·架构
云和数据.ChenGuang2 小时前
python大小整数范围解析
java·开发语言·python
fensnote2 小时前
西门子 S7 PLC 内部寄存器读写 Demo(C 语言)
c语言·开发语言
benchmark_cc3 小时前
量化项目启动时,应该先设计数据接口还是先编写策略?从系统架构角度做出正确选择
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
2601_962885723 小时前
如何用 Python 判断一只股票“追涨”还是“抄底”更有效?
android·开发语言·python
denlaku3 小时前
JDK 15 新特性详解
java·开发语言