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 这个数之前出现过吗"。

two_sum.pyPython
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 判圈):快指针每次走两步、慢指针走一步,若有环,快指针迟早从后面追上慢指针。

2 3 5 8 11 有序数组,target = 13 left right nums[left] + nums[right] = 2 + 11 = 13 = target ✓ 命中 和 > target ⇒ right 左移(减小) 和 < target ⇒ left 右移(增大) 和 = target ⇒ 命中返回
图 3.1有序数组对撞双指针:每一步比较 nums[left]+nums[right] 与 target,只移动一个指针。注意:每次比较都排除了一整行或一整列的候选,所以总步数是 O(n) 而非 O(n²)。

worked example:有序数组两数之和(对撞双指针)

two_sum_sorted.pyPython
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 章节点)

has_cycle.pyPython
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²)。

窗口分两类。定长窗口:宽度固定,右进一个、左出一个,整体右滑。可变窗口:宽度随条件伸缩,本节的例子属于这一类。

a b c a b left right right 处的 a 与窗口内 a 重复 ⇒ left 跳到旧 a 之后,窗口收缩 两指针都只右移,合计 ≤ 2n 步 ⇒ O(n)
图 3.2可变滑动窗口:右指针把新字符纳入窗口,遇到重复字符时左指针向右收缩到不再重复。注意:左指针永不回退,这是 O(n) 而非 O(n²) 的根因。

worked example:无重复字符的最长子串

给字符串 s,求最长的、不含重复字符的连续子串长度。用可变窗口加哈希表记录每个字符最后一次出现的位置。

longest_unique.pyPython
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 的子数组。

a 2 4 1 3 prefix 0 2 6 7 10 区间 [1,2] 之和 prefix[3] - prefix[1] = 7 - 2 = 5
图 3.3前缀和:区间 [i, j] 之和等于 prefix[j+1] - prefix[i],两条竖线之间的"高度差"就是区间和。注意:prefix 比原数组长 1,并以 prefix[0]=0 起手,正是为了让区间从下标 0 开始时减法也成立。

worked example:和为 k 的子数组个数(前缀和 + 哈希)

给整数数组 nums(含负数)与整数 k,求和恰好为 k 的连续子数组个数。这里复用 §3.1 的哈希思想——把"是否存在某个前缀和"的查找压到 O(1)。

subarray_sum_k.pyPython
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 的连续子数组",三条路的取舍:

表 3.1 · 求"和为 k 的连续子数组"的方案对比
方案时间复杂度适用条件取舍
暴力双循环 O(n²) 任意元素(含负数) 枚举所有 (i, j) 求和,思路直白但 n ≥ 10⁴ 即超时。
前缀和 + 哈希 O(n) 任意元素(含负数) 通用解。一遍扫描配哈希查 cur - k,不依赖元素符号。
滑动窗口 O(n) 仅当元素全为正 窗口收缩要有意义,需"加元素和单调增、减元素和单调减"。

选定前缀和 + 哈希作为通用解。为什么滑动窗口要求元素全为正?滑窗的正确性依赖"收缩窗口必然减小区间和"这个单调性——只有元素全为正时,从左端踢出一个元素才一定让和变小。一旦混入负数或零,踢出元素可能让和不降反升,收缩这个动作失去意义,窗口无法判断该不该停。所以含负数时退回前缀和 + 哈希。

自测

  1. 对撞双指针的前提是什么?
  2. 滑动窗口为什么是 O(n) 而非 O(n²)?
  3. 前缀和把哪一种操作从 O(n) 变成 O(1)?
  4. 求"和为 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 时记录长度。