双指针与滑动窗口精讲
High Contrast
Dark Mode
Light Mode
Sepia
Forest
15 min read2,919 words

双指针与滑动窗口精讲

简介

在算法设计与问题求解中,双指针与滑动窗口是两种极其高效且应用广泛的编程范式。它们通过巧妙地维护一个或多个指针(通常是数组或链表中的索引)来遍历数据结构,从而避免不必要的重复计算,将许多原本需要 O(n²) 时间复杂度的问题优化至 O(n) 或 O(n log n)。双指针技术通过两个指针的协同移动来缩小搜索空间,而滑动窗口则可以看作双指针的一种特殊形式,它维护一个满足特定条件的连续区间。掌握这两种技术,是解决字符串、数组、链表相关面试题目的关键,也是从暴力解法迈向高效算法的必经之路。本章将系统性地讲解双指针的三种核心范式与滑动窗口的两种框架,并通过经典例题剖析其内在逻辑与实现细节。

核心概念

双指针的三种范式

双指针技术并非单一方法,而是根据指针的移动方式和相互关系,可以分为三种主要范式,每种范式都有其独特的应用场景和解题逻辑。

  1. 对撞指针:两个指针分别初始化在序列(通常是数组或字符串)的两端,然后向中间移动,直到它们相遇或交错。这种模式常用于处理有序数组的问题,例如两数之和、三数之和、反转字符串等。其核心思想是利用序列的有序性,通过比较指针所指元素之和与目标值的大小,决定移动哪个指针,从而高效地缩小搜索范围。

  2. 快慢指针:两个指针从序列的同一端(通常是起始位置)开始移动,但移动速度不同(例如,一个每次移动一步,另一个每次移动两步)。这种模式主要用于链表相关的问题,如检测链表中是否存在环、寻找链表的中间节点、寻找环的入口等。快指针的“追赶”或“领先”特性使得我们可以在一次遍历中获取到链表的特殊位置信息。

  3. 分离指针:两个指针分别遍历两个不同的序列或同一序列的不同部分。这种模式常用于处理归并比较类问题,例如合并两个有序数组、判断一个字符串是否是另一个字符串的子序列等。每个指针负责追踪一个序列的进度,根据条件独立移动。

滑动窗口框架

滑动窗口是双指针技术的一种高级应用,专门用于解决数组/字符串的连续子区间问题。它通过维护一个窗口(由左指针 left 和右指针 right 定义的区间),动态地扩展和收缩这个窗口来寻找最优解。滑动窗口主要分为两种类型:

下面的流程图清晰地展示了可变长度滑动窗口算法的核心工作流程:

graph TD A["初始化 left = 0, right = 0"] --> B{"右指针 right 遍历数组"} B --> C["将 nums[right] 加入窗口
更新窗口状态"] C --> D{"当前窗口是否满足条件?"} D -- 否 --> E["移动左指针 left 收缩窗口
更新窗口状态"] E --> D D -- 是 --> F["更新最优解答案"] F --> G{"右指针 right 是否到末尾?"} G -- 否 --> B G -- 是 --> H["算法结束,返回答案"]

实战示例

例题一:盛最多水的容器(对撞指针)

问题描述:给定一个长度为 n 的整数数组 height,其中 height[i] 表示第 i 条垂直线的高度。找出其中两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

解题思路:这是一个典型的对撞指针问题。容器的盛水量由 min(height[left], height[right]) * (right - left) 决定。初始时,left 指向最左端,right 指向最右端,这样宽度最大。为了寻找可能更大的面积,我们需要移动高度较小的那个指针,因为移动高度较高的指针只会减小宽度,而高度(由较矮的边决定)可能不会增加甚至减少。

def maxArea(height):
"""
使用对撞指针求解盛最多水容器问题。
:type height: List[int]
:rtype: int
"""
left, right = 0, len(height) - 1  # 初始化对撞指针
max_water = 0
while left < right:
# 计算当前左右指针构成容器的容量
width = right - left
current_height = min(height[left], height[right])
current_water = width * current_height
# 更新最大容量
max_water = max(max_water, current_water)
# 移动高度较小的一侧指针,试图找到更高的“短板”
if height[left] < height[right]:
left += 1
else:
right -= 1
return max_water
# 测试用例
if __name__ == "__main__":
test_height = [1, 8, 6, 2, 5, 4, 8, 3, 7]
result = maxArea(test_height)
print(f"数组 {test_height} 能盛放的最大水量为: {result}")  # 输出应为 49

例题二:无重复字符的最长子串(可变长度滑动窗口)

问题描述:给定一个字符串 s,请你找出其中不含有重复字符的 最长子串 的长度。

解题思路:使用可变长度滑动窗口。用一个集合 char_set 来记录当前窗口内出现的字符。右指针 right 不断向右移动,尝试将新字符加入窗口。如果新字符不在集合中,则加入并更新最大长度。如果新字符已在集合中(表示出现重复),则移动左指针 left,并从集合中移除 s[left] 对应的字符,直到重复字符被移出窗口为止。

def lengthOfLongestSubstring(s):
"""
使用滑动窗口寻找无重复字符的最长子串长度。
:type s: str
:rtype: int
"""
char_set = set()  # 哈希集合,用于记录窗口内字符
left = 0
max_length = 0
for right in range(len(s)):  # 右指针遍历整个字符串
# 当遇到重复字符时,收缩左指针
while s[right] in char_set:
char_set.remove(s[left])  # 移除左指针指向的字符
left += 1  # 左指针右移
# 将当前右指针字符加入窗口
char_set.add(s[right])
# 更新最大长度。窗口为 [left, right],长度为 right - left + 1
max_length = max(max_length, right - left + 1)
return max_length
# 测试用例
if __name__ == "__main__":
test_str = "abcabcbb"
result = lengthOfLongestSubstring(test_str)
print(f"字符串 '{test_str}' 中无重复字符的最长子串长度为: {result}")  # 输出应为 3

例题三:最小覆盖子串(可变长度滑动窗口进阶)

问题描述:给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 ""

解题思路:这是滑动窗口的经典难题。我们需要维护两个哈希表:need 记录 t 中每个字符需要的数量,window 记录当前窗口中对应字符的数量。此外,用一个变量 valid 记录当前窗口中满足 need 要求(即数量足够)的字符种类数。算法步骤:1. 右移 right 扩大窗口,更新 windowvalid。2. 当 valid 等于 need 的大小时,说明窗口已覆盖 t 所有字符,尝试右移 left 收缩窗口以寻找更小的可行子串,同时更新答案。

from collections import defaultdict
def minWindow(s, t):
"""
寻找覆盖字符串t所有字符的最小窗口。
:type s: str
:type t: str
:rtype: str
"""
need = defaultdict(int)
window = defaultdict(int)
# 初始化 need 字典,记录 t 中每个字符所需的数量
for ch in t:
need[ch] += 1
left, right = 0, 0
valid = 0  # 记录窗口中满足 need 条件的字符种类数
start, length = 0, float('inf')  # 记录最小子串的起始位置和长度
while right < len(s):
# c 是将移入窗口的字符
c = s[right]
# 右移窗口
right += 1
# 进行窗口内数据的一系列更新
if c in need:
window[c] += 1
if window[c] == need[c]:  # 该字符数量已达到要求
valid += 1
# 判断左侧窗口是否要收缩(当前窗口已包含 t 所有字符)
while valid == len(need):
# 在这里更新最小覆盖子串
if right - left < length:
start = left
length = right - left
# d 是将移出窗口的字符
d = s[left]
# 左移窗口
left += 1
# 进行窗口内数据的一系列更新
if d in need:
if window[d] == need[d]:  # 移出前该字符数量刚好满足,移出后就不满足了
valid -= 1
window[d] -= 1
# 返回结果
return "" if length == float('inf') else s[start:start+length]
# 测试用例
if __name__ == "__main__":
s = "ADOBECODEBANC"
t = "ABC"
result = minWindow(s, t)
print(f"在字符串 '{s}' 中覆盖 '{t}' 的最小子串为: '{result}'")  # 输出应为 "BANC"

对比分析

下表总结了双指针三种范式与滑动窗口两种框架的核心特点、适用场景及复杂度对比,帮助读者在解题时快速选择合适的方法。

模式 核心思想 典型应用场景 时间复杂度 空间复杂度 关键技巧
对撞指针 从序列两端向中间扫描,根据比较结果移动指针。 有序数组的两数/三数之和、反转数组/字符串、盛水容器。 O(n) O(1) 利用有序性,移动较小(或较大)一侧的指针。
快慢指针 两个指针同向但不同速移动,用于探测特定位置或状态。 链表判环、找中点、找环入口、寻找重复数。 O(n) O(1) 快指针速度是慢指针的2倍,用于找中点或判环。
分离指针 两个指针独立遍历不同序列或同一序列的不同逻辑部分。 合并有序数组、判断子序列、数组交集。 O(m+n) O(1) 或 O(min(m,n)) 每个指针负责一个序列,根据条件推进。
固定长度滑动窗口 维护一个长度固定的窗口,滑动并高效更新窗口属性。 大小为k的子数组最大/平均值、字符串的定长排列判断。 O(n) O(1) 或 O(k) 先计算初始窗口值,然后滑动时加减边界元素。
可变长度滑动窗口 动态调整窗口大小,使其满足特定条件(如无重复、覆盖目标)。 无重复字符最长子串、最小覆盖子串、乘积小于K的子数组。 O(n) O(字符集大小) 右指针扩窗,左指针在条件不满足时缩窗,实时更新答案。

最佳实践

  1. 先思考暴力解法,再优化:面对新问题时,先思考一个 O(n²) 的暴力解法(通常是双层循环)。这能帮助你理清问题的本质,并清晰地看到双指针或滑动窗口是如何通过避免重复计算来优化性能的。
  2. 明确指针定义与移动条件:在编码前,必须清晰地定义每个指针的含义(例如,left 指向窗口左边界,right 指向待扩展元素),并严格规定它们在何种条件下移动。这是写出正确代码的基础。
  3. 善用哈希表辅助窗口:对于可变长度滑动窗口问题,尤其是涉及字符计数或状态记录时(如“最小覆盖子串”、“找到字符串中所有字母异位词”),配合使用哈希表(Python 中的 dictdefaultdict)来记录窗口内元素的状态是极其高效和通用的做法。
  4. 注意边界条件与初始状态:仔细处理指针的初始位置(通常是 0)、循环终止条件(while left < right 还是 while right < n),以及窗口为空或初始状态下的答案更新逻辑。这些细节往往是 Bug 的高发区。
  5. 在收缩窗口时更新答案:对于寻找“最小窗口”类问题,答案通常在收缩窗口(移动左指针)的过程中更新。而对于寻找“最大窗口”类问题,答案通常在扩展窗口后(移动右指针后)更新。理解这一点对编写正确逻辑至关重要。

小结

双指针与滑动窗口是算法工程师必须熟练掌握的核心技巧。对撞指针擅长处理有序序列的搜索问题,快慢指针是解决链表问题的利器,而分离指针则用于多序列的合并与比较。滑动窗口,特别是可变长度窗口,是解决子数组/子字符串问题的通用高效框架,其核心在于“右指针扩展,左指针收缩”的动态平衡过程。通过理解每种模式的内在逻辑,熟记其代码模板,并辅以足够的练习,我们就能在面对复杂的数组、字符串和链表问题时,快速识别出适用模式并给出优雅高效的解决方案。下一节:二分查找及其变体