Chapter 03
线性结构套路
第 02 章给了数组、链表、哈希表的性质——这一章用它们组合出第一类套路:在数组与字符串上,靠维护一个不变量(invariant)把朴素的 O(n²) 压到 O(n)。
本章 schema
- 双指针靠单调性保证不漏解。
- 滑动窗口让左右指针各最多走 n 步。
- 前缀和把区间和变成两点之差。
- 哈希表用空间换 O(1) 查找。
§3.1哈希表套路
把"某个值是否出现过"的查找从 O(n) 降到均摊 O(1),后面三个套路都靠它兜底。
先讲哈希表,因为双指针、滑动窗口、前缀和都会复用它。
哈希表把"查找某值是否出现过"从 O(n) 降到均摊 O(1)。代价是 O(n) 额外空间,且最坏情况退化到 O(n)——当哈希函数把所有键映射到同一个桶(哈希冲突),查找重新变成线性扫描。面试里默认键分布良好,均摊 O(1) 成立;但"最坏 O(n)"这一句在白板上要能说出来。
Python 的 dict 和 set 就是哈希表。x in d、d[x]、d[x] = v 均摊都是 O(1)。
worked example:两数之和(无序数组,一遍扫描)
给一个无序数组 nums 和目标 target,返回两个下标,使两数之和等于 target。朴素做法是双重循环 O(n²)。哈希表把内层查找换成 O(1):一边遍历一边问"target - x 这个数之前出现过吗"。
def two_sum(nums: list[int], target: int) -> list[int]:
seen: dict[int, int] = {} # 值 -> 该值出现的下标
for i, x in enumerate(nums):
need = target - x # 要凑成 target,还差这个数
if need in seen: # 这个差之前出现过 -> 找到一对
return [seen[need], i]
seen[x] = i # 没找到,把当前值登记进哈希表
return [] # 无解
print(two_sum([2, 7, 11, 15], 9)) # [0, 1]
print(two_sum([3, 2, 4], 6)) # [1, 2]
L2 seen 记录"值 → 下标",登记的是已经走过的元素。
L4 对当前 x,要凑成 target 还差 need = target - x。
L5 查表问 need 是否已出现——O(1)。出现过就立刻得到答案,无需回头扫描。
L8 关键顺序:先查后登记。把 x 放进表之前先查 need,避免同一个元素和自己配对。
遍历一次,每步 O(1),总复杂度 O(n) 时间、O(n) 空间。
§3.2双指针
用两个指针从两端或一前一后扫,靠题目的单调性决定哪个指针移动。
双指针有两个常见变体。对撞指针要求数组有序:左指针 left 从头、右指针 right 从尾向中间夹逼,靠"和太大就让 right 左移、和太小就让 left 右移"的单调性保证不漏掉任何解。快慢指针则用两个步长不同的指针检测链表环(Floyd 判圈):快指针每次走两步、慢指针走一步,若有环,快指针迟早从后面追上慢指针。
nums[left]+nums[right] 与 target,只移动一个指针。注意:每次比较都排除了一整行或一整列的候选,所以总步数是 O(n) 而非 O(n²)。worked example:有序数组两数之和(对撞双指针)
def two_sum_sorted(nums: list[int], target: int) -> list[int]:
left, right = 0, len(nums) - 1 # 两端各放一个指针
while left < right:
s = nums[left] + nums[right] # 当前两端之和
if s == target:
return [left, right] # 命中
elif s > target:
right -= 1 # 和太大,缩小:右指针左移
else:
left += 1 # 和太小,增大:左指针右移
return [] # 无解
print(two_sum_sorted([2, 3, 5, 8, 11], 13)) # [0, 4]
print(two_sum_sorted([1, 2, 3, 4], 100)) # []
L7 和太大:唯一能减小的动作是 right -= 1(因为数组有序,左移右指针必然换上更小的数)。
L9 和太小:唯一能增大的动作是 left += 1。两个指针只朝中间走,合计移动 ≤ n 步,故 O(n)。
worked example:快慢指针判断链表是否有环(复用 02 章节点)
class ListNode: # 复用第 02 章的链表节点
def __init__(self, val: int = 0, nxt: "ListNode | None" = None):
self.val = val
self.next = nxt
def has_cycle(head: ListNode | None) -> bool:
slow = fast = head
while fast is not None and fast.next is not None:
slow = slow.next # 慢指针走一步
fast = fast.next.next # 快指针走两步
if slow is fast: # 快指针追上慢指针 -> 有环
return True
return False # 走到 None -> 无环
# 构造 1 -> 2 -> 3 -> 2(回到第二个节点) 的环
a, b, c = ListNode(1), ListNode(2), ListNode(3)
a.next, b.next, c.next = b, c, b
print(has_cycle(a)) # True
print(has_cycle(ListNode(1, ListNode(2)))) # False
L8 循环条件检查 fast 和 fast.next,因为快指针一次跳两步,要保证两步都不踩空。
L11 用 is 比较节点身份而非值。若存在环,快指针绕回后必从后方追上慢指针;若无环,fast 先到达 None。
对撞双指针为什么必须先排序?把一个无序数组直接套对撞指针,哪一步会出错?
展开:为何无序不行
对撞指针的正确性建立在单调性上:「和太大就移右指针、和太小就移左指针」这条规则之所以不漏解,是因为有序数组里"右指针左移 ⇒ 和单调减小"、"左指针右移 ⇒ 和单调增大"恒成立。
数组无序时这个保证消失。假设当前和大于 target,规则让右指针左移;但无序数组里左移后的新元素可能比原来更大,和不降反升,被跳过的右端元素却再也访问不到——于是漏掉本应命中的配对。无序数组求两数之和应改用 §3.1 的哈希表一遍扫描,那条路不依赖顺序。
§3.3滑动窗口
用可伸缩的 [left, right] 窗口维护满足条件的连续子区间。
滑动窗口维护一个连续区间 [left, right]。右指针扩张、纳入新元素;一旦窗口违反条件,左指针收缩、踢出旧元素。关键观察:两个指针都只朝右单调移动,各自最多走 n 步,所以总操作 O(n),而非看似的 O(n²)。
窗口分两类。定长窗口:宽度固定,右进一个、左出一个,整体右滑。可变窗口:宽度随条件伸缩,本节的例子属于这一类。
worked example:无重复字符的最长子串
给字符串 s,求最长的、不含重复字符的连续子串长度。用可变窗口加哈希表记录每个字符最后一次出现的位置。
def length_of_longest_substring(s: str) -> int:
last: dict[str, int] = {} # 字符 -> 最后一次出现的下标
left = 0 # 窗口左边界
best = 0
for right, ch in enumerate(s): # right 单调扩张
if ch in last and last[ch] >= left:
left = last[ch] + 1 # 重复字符在窗口内 -> 收缩左边界到它之后
last[ch] = right # 更新该字符最新位置
best = max(best, right - left + 1) # 当前窗口宽度
return best
print(length_of_longest_substring("abcabcbb")) # 3 ("abc")
print(length_of_longest_substring("bbbbb")) # 1 ("b")
print(length_of_longest_substring("pwwkew")) # 3 ("wke")
L6 两个条件缺一不可:ch in last 说明这个字符见过;last[ch] >= left 说明它还在当前窗口内。只有同时满足才需要收缩。
L7 收缩时把 left 直接跳到旧位置之后,而非逐格右移——一步到位,但 left 仍然单调不减。
L9 窗口宽度是 right - left + 1,每步更新最优解。
right 走 n 步,left 总共也只前进 ≤ n 步,故 O(n) 时间、O(min(n, 字符集大小)) 空间。
§3.4前缀和
预处理一遍前缀和后,任意区间和都化为两个端点的差,O(1) 取出。
定义 prefix[i] = a[0] + a[1] + ... + a[i-1](prefix[0] = 0,长度比原数组多 1)。那么区间 [i, j] 的和就是 sum(i, j) = prefix[j+1] - prefix[i]——一次减法,O(1)。预处理是 O(n),之后每次区间求和都是常数时间。
前缀和配哈希表能解"和为 k 的连续子数组个数":边扫边把见过的前缀和计数,遇到当前前缀和 cur 时,查表里有多少个 cur - k,每个都对应一个和为 k 的子数组。
[i, j] 之和等于 prefix[j+1] - prefix[i],两条竖线之间的"高度差"就是区间和。注意:prefix 比原数组长 1,并以 prefix[0]=0 起手,正是为了让区间从下标 0 开始时减法也成立。worked example:和为 k 的子数组个数(前缀和 + 哈希)
给整数数组 nums(含负数)与整数 k,求和恰好为 k 的连续子数组个数。这里复用 §3.1 的哈希思想——把"是否存在某个前缀和"的查找压到 O(1)。
def subarray_sum(nums: list[int], k: int) -> int:
count = 0
cur = 0 # 当前前缀和
seen: dict[int, int] = {0: 1} # 前缀和 -> 出现次数;空前缀和 0 先垫一个
for x in nums:
cur += x # 把当前元素累进前缀和
need = cur - k # 若某个旧前缀和等于 need,二者之差就是 k
count += seen.get(need, 0) # 有几个这样的旧前缀和,就有几个子数组
seen[cur] = seen.get(cur, 0) + 1 # 登记当前前缀和
return count
print(subarray_sum([1, 1, 1], 2)) # 2
print(subarray_sum([1, 2, 3], 3)) # 2
print(subarray_sum([1, -1, 0], 0)) # 3
L4 {0: 1} 必须预置:它表示"空前缀和为 0 出现过一次",让"从下标 0 开始就和为 k 的子数组"也能被计入。
L7 区间和 = cur - 某个旧前缀和。要让这个差等于 k,旧前缀和必须等于 cur - k。
L8 先查后登记:在把 cur 写进表之前查 need,保证统计的是当前位置之前的前缀和。
一遍扫描、每步 O(1) 查表,总复杂度 O(n) 时间、O(n) 空间。含负数也成立——前缀和与哈希都不要求元素为正。
§3.5备选方案对比
针对同一道题面"求和为 k 的连续子数组",三条路的取舍:
| 方案 | 时间复杂度 | 适用条件 | 取舍 |
|---|---|---|---|
| 暴力双循环 | O(n²) | 任意元素(含负数) | 枚举所有 (i, j) 求和,思路直白但 n ≥ 10⁴ 即超时。 |
| 前缀和 + 哈希 | O(n) | 任意元素(含负数) | 通用解。一遍扫描配哈希查 cur - k,不依赖元素符号。 |
| 滑动窗口 | O(n) | 仅当元素全为正 | 窗口收缩要有意义,需"加元素和单调增、减元素和单调减"。 |
选定前缀和 + 哈希作为通用解。为什么滑动窗口要求元素全为正?滑窗的正确性依赖"收缩窗口必然减小区间和"这个单调性——只有元素全为正时,从左端踢出一个元素才一定让和变小。一旦混入负数或零,踢出元素可能让和不降反升,收缩这个动作失去意义,窗口无法判断该不该停。所以含负数时退回前缀和 + 哈希。
自测
- 对撞双指针的前提是什么?
- 滑动窗口为什么是 O(n) 而非 O(n²)?
- 前缀和把哪一种操作从 O(n) 变成 O(1)?
- 求"和为 k 的子数组",当数组含负数时滑动窗口为什么失效?
展开:参考答案
1. 数组(或其映射后的量)必须有序。对撞指针靠"移右指针和减小、移左指针和增大"的单调性保证不漏解,单调性来自有序。
2. 左右指针都只朝右单调移动,各自最多走 n 步,合计 ≤ 2n 次操作。虽然有两层逻辑(扩张、收缩),但收缩的总次数受限于扩张次数,不会对每个 right 重新扫一遍 left。
3. 把"求任意区间和"从 O(n)(逐个累加)变成 O(1)(prefix[j+1] - prefix[i] 一次减法)。
4. 滑窗收缩的正确性依赖"踢出左端元素必然减小区间和"。元素全为正时成立;含负数或零时,踢出元素可能使和不降反升,窗口无法判断收缩到何处停,统计就会出错。改用前缀和 + 哈希。
最小覆盖子串
给字符串 s 和 t,求 s 中最短的连续子串,使其包含 t 的所有字符(含重复次数)。例如 s = "ADOBECODEBANC"、t = "ABC",答案是 "BANC"。这是滑动窗口的进阶:窗口内需覆盖 t 的全部字符。
展开:提示
用哈希表 need 记录 t 中每个字符的所需次数,再维护一个计数 missing 表示窗口还缺多少字符。右指针扩张时,若纳入的字符还被需要就让 missing 减一;当 missing == 0 时窗口已覆盖 t,此时左指针尽量收缩以缩短长度,并在每次满足覆盖时更新最优解。关键不变量:窗口收缩到刚好仍覆盖 t 时记录长度。