数据结构与算法:精炼参考手册

Python 版 · 速查手册 + 深度解析
模块

📖 如何使用本书

本书是一份精炼的数据结构与算法参考:覆盖 6 种基础数据结构与高频解法模板,共 10 章。每个主题都给出 Rust / C++ / C / Python 四种语言的惯用实现,并标注常见陷阱。

内容分为两个模块 —— ⚡ 速查手册:每个主题一页,含核心要点、复杂度速查、模板代码与易错点,适合工程师快速查阅;🔍 深度解析:讲解原理与设计权衡,并对比四种语言的实现细节,适合系统学习与复习。

模块与操作

  • 模块按钮(顶部):切换「⚡ 速查 / 🔍 深度解析 / ☰ 全部」视图,快速定位到需要的区域;章节卡片右上角还有「深度解析 ↓ / 速查 ↑」互跳链接。
  • 语言按钮(顶部):在当前版本与另外三个语言版本之间跳转(Rust ⇄ C++ ⇄ C ⇄ Python)。
  • 主题按钮:点击右上角 ☀ / ☾ 在深色与浅色主题间切换(默认跟随系统)。
  • 复制按钮(每个代码块右上角):一键复制模板代码;左侧目录会随滚动自动高亮。
  • 全局搜索:使用浏览器自带的 Ctrl+F 即可全文检索。

语言与运行环境

  • C++ 代码基于 C++23,工程写法:每个代码块自包含——块首按需显式包含标准头文件(按字母序),全程使用 std:: 限定(不引入全局命名空间导入、不依赖编译器扩展的万能头文件),可直接用于实际项目。
  • Rust 代码基于 2024 Edition(Rust 1.85+),仅使用标准库(std::collections 等)。
  • Python 代码基于 Python 3.14(最新稳定版),仅使用标准库(collections / heapq / bisect / functools)。
  • C 代码基于 C23(ISO/IEC 9899:2024),仅使用 C 标准库,不依赖编译器扩展与 POSIX 扩展(个别条目如 strtok_r 会单独注明);C 标准库没有动态数组、哈希表、二叉堆等容器,本书给出可复用的手写实现(增长数组、开放寻址哈希表、二叉堆、邻接表)并显式说明所有权与释放责任;C 没有异常与泛型,错误经由返回值 + 出参传递。
  • 所有模板代码均已通过最新稳定版工具链逐一验证:g++ 15.2(-std=c++23)、rustc 1.98(--edition 2024)、Python 3.14、gcc 15.2(-std=c2x)。
PART A · CHEATSHEET

⚡ 速查手册

每个主题一页速查:核心要点、复杂度、模板代码与易错点。适合快速查阅与面试前回忆。

01

数组与字符串基础操作

深度解析 ↓

💡 数组是一段连续内存上的随机访问容器,字符串是「字符序列 + 文本语义」的封装;前者的灵魂是 O(1) 按下标寻址,后者的坑多来自各语言的编码与可变性差异。

核心要点

  • 数组连续存储,按下标访问是 O(1)
  • 动态数组尾部追加均摊 O(1),中间插删 O(n)
  • 二维网格在 C 系语言中按行主序存储,用方向数组统一邻居遍历(详见 B1)
  • 字符串可变性:C++/Rust 可变,Python str 不可变
  • Rust str 是 UTF-8,索引是字节不是字符
  • 循环内拼接字符串易退化为 O(n²),应缓冲后合并(详见 B2)

复杂度速查

操作 / 场景时间空间说明
按下标访问 a[i]O(1)O(1)连续内存,地址 = base + i×size
动态数组尾部追加均摊 O(1)O(1)扩容单次 O(n),均摊后 O(1)
中间插入 / 删除O(n)O(1)需搬移后续元素
线性遍历O(n)O(1)逐元素访问
二维网格全遍历O(m·n)O(1)m 行 n 列
朴素子串查找O(n·m)O(1)更快的 KMP 为 O(n+m)
原地反转O(n)O(1)双指针对撞交换

模板代码

模板 1:方向数组遍历二维网格用偏移数组统一处理 4/8 邻域,消除重复的分支判断。
python
# 4 向:上 下 左 右;8 向:再含对角线
DIR4 = [(-1, 0), (1, 0), (0, -1), (0, 1)]
DIR8 = [(-1, -1), (-1, 0), (-1, 1), (0, -1),
        (0, 1), (1, -1), (1, 0), (1, 1)]

def neighbors(grid, r, c, diagonal=False):
    """返回网格中 (r, c) 的所有合法邻居坐标"""
    if not grid:                            # 空网格守卫,避免 grid[0] 越界
        return []
    rows, cols = len(grid), len(grid[0])
    res = []
    for dr, dc in (DIR8 if diagonal else DIR4):
        nr, nc = r + dr, c + dc
        if 0 <= nr < rows and 0 <= nc < cols:
            res.append((nr, nc))
    return res
模板 2:字符串分割、拼接与数值转换安全的「切分 → 逐段处理 → 合并」范式,避免 O(n²) 拼接。注意三语言对非法段的容错语义并不一致——Rust 版用 unwrap_or(0) 静默归零,C++ stoll 抛 std::invalid_argument,Python int() 抛 ValueError;如需统一行为,应显式选择「静默跳过」或「遇错报错」策略。
python
def parse_ints(s: str) -> list:
    """把逗号分隔的整数字符串解析为整数列表(非法段抛 ValueError)"""
    return [int(t) for t in s.split(',') if t.strip()]

def join_ints(v) -> str:
    """把整数列表合并为逗号分隔字符串(O(n) 拼接)"""
    return ','.join(str(x) for x in v)
模板 3:原地删除元素(快慢指针雏形)O(n) 时间、O(1) 额外空间地移除满足条件的元素,并返回新长度。
python
def remove_element(nums: list, val: int) -> int:
    """原地移除所有等于 val 的元素,返回新长度"""
    k = 0
    for i in range(len(nums)):
        if nums[i] != val:
            nums[k] = nums[i]
            k += 1
    return k
模板 4:原地反转(数组 / 字符串)数组用双指针对撞交换做到 O(n) 时间、O(1) 额外空间;字符串因可变性与编码差异,三语言做法不同——反转不能在字节层面随意进行(详见 A5)。
python
def reverse_list(nums: list) -> None:
    """数组原地反转:双指针对撞,O(1) 额外空间"""
    i, j = 0, len(nums) - 1
    while i < j:
        nums[i], nums[j] = nums[j], nums[i]
        i += 1
        j -= 1

def reverse_string(s: str) -> str:
    """str 不可变,无法原地反转,返回新串(等价于切片 s[::-1])"""
    return ''.join(reversed(s))

易错点

  • 越界(off-by-one):a[n]、s[len(s)] 是最常见的错误;当上界是长度时循环条件应写 < n 而非 <= n,多语言中越界后果不同(C++ 未定义行为、Rust panic、Python 抛 IndexError)。
  • Rust 字符串索引是字节:s[i] 直接编译不通过;&s[i..j] 是字节切片且端点必须落在 UTF-8 字符边界上,否则运行时 panic;要取「第 i 个字符」应 s.chars().nth(i) 或先 collect::<Vec<char>>()。
  • 字符串反转不能按字节:C++ std::string 与 Rust 字节容器按字节反转会把多字节 UTF-8 码点翻面成非法序列(呼应上一条「Rust 字符串索引是字节」);Rust 的安全写法是 s.chars().rev().collect::<String>()(产生新串、O(n) 分配),Python 用 ''.join(reversed(s)),需要真·原地反转时只能对纯 ASCII 的字节数组操作。
  • C++ size_t 无符号陷阱:真正会出问题的是索引变量保持无符号的写法 for (size_t i = v.size() - 1; i >= 0; --i)——v 为空时 v.size() - 1 在无符号域下溢为 SIZE_MAX,而 i >= 0 对无符号恒真,循环便越界访问或永不结束。修复:先把上界转为有符号再比较,for (int i = (int)v.size() - 1; i >= 0; --i);或改用反向迭代器 for (auto it = v.rbegin(); it != v.rend(); ++it)。反过来,若写成 for (int i = v.size() - 1; i >= 0; --i),v.size() - 1 同样在无符号域下溢,但赋给 int 后的结果依赖实现(常见平台截断为 -1,循环体不执行、并不越界)——因此不能指望这种「顺手转型」来规避下溢,应显式转型。
  • Python 循环内拼接字符串:s += t 每次产生新对象,累积是 O(n²);应 parts.append(t) 后 ''.join(parts)。
  • 二维数组初始化别名:[[0] * n] * m 得到的是同一行的 m 个引用,改一行会波及所有行;应写 [[0] * n for _ in range(m)]。
  • 扩容导致失效:C++ push_back 触发扩容会使全部迭代器/引用失效,Rust 在借用未结束时不能可变修改 Vec,遍历中扩容需特别当心。
02

双指针、滑动窗口与前缀和

深度解析 ↓

💡 用两个下标在「有序性」或「单调性」的支撑下协同移动,把暴力枚举的 O(n²) 扫描压成一次线性扫描;而前缀和与差分则把「区间」问题转化为「端点」问题,使区间求和与区间加在预处理后都能 O(1) 完成。

核心要点

  • 对撞双指针的前提是有序:和偏小移左、偏大移右(推导见 B1)
  • 快慢指针「同向不同速」,用于去重、找中点、判环
  • 滑动窗口是双指针特例:两端同向移动,窗口始终连续
  • 不定长窗口收缩用 while,定长窗口每步一进一出(复杂度见 B2)
  • 前缀和把区间和化为端点差:pre[j+1] - pre[i](推导见 B1)
  • 差分与前缀和互为逆运算:区间加转成两个端点标记;全零基线时前缀和得累计增量,由原数组构造则得更新后的原数组(见 B1)

复杂度速查

操作 / 场景时间空间说明
对撞双指针(有序数组)O(n)O(1)单次扫描;若需先排序则 O(n log n)
快慢指针(数组 / 链表)O(n)O(1)每步常数操作,无需随机访问
定长滑动窗口O(n)O(1)每滑一步「进一元素、出一元素」
不定长滑动窗口O(n)O(1)~O(k)每元素至多进出各一次,摊还线性
一维前缀和O(n) 预处理O(n)之后单次区间求和 O(1)
二维前缀和O(mn) 预处理O(mn)容斥递推;查询子矩阵和 O(1)
差分数组区间加O(1) 每次修改O(n)全部修改后一次 O(n) 前缀和得累计增量(或由原数组构造得更新后数组)

模板代码

模板 1:对撞双指针(有序数组两数之和)左右指针从两端向中间逼近,依据当前和与目标的大小决定移动哪一侧。未找到时约定返回「空结果」——Rust / Python 返回 None,C++ 返回空 vector<int>{}。
python
def two_sum_sorted(nums: list, target: int):
    """在有序数组 nums 中找两数之和等于 target,返回其下标(0-based);未找到返回 None。"""
    l, r = 0, len(nums) - 1
    while l < r:
        s = nums[l] + nums[r]
        if s == target:
            return (l, r)
        if s < target:
            l += 1          # 和太小,左指针右移
        else:
            r -= 1          # 和太大,右指针左移
    return None
模板 2:不定长滑动窗口(求最短满足条件的窗口)右端扩张、窗口满足条件后持续左端收缩,得到最短的合法窗口。前提:nums 元素全为正且 target > 0(滑窗单调性前提,负数场景见 B2);当 target ≤ 0 时,空子数组的和为 0 已满足 >= target,约定直接返回长度 0。
python
def min_subarray_len(target: int, nums: list) -> int:
    """求最短的连续子数组,使其元素和 >= target;不存在则返回 0。
    前提:nums 元素全为正且 target > 0。"""
    if target <= 0:                 # 空子数组之和为 0,已满足 >= target
        return 0
    left = 0
    total = 0
    best = float("inf")
    for right, x in enumerate(nums):
        total += x                          # 右端扩张,纳入新元素
        while total >= target:              # 窗口已满足条件,尝试收缩
            best = min(best, right - left + 1)
            total -= nums[left]             # 移出左端元素
            left += 1
    return 0 if best == float("inf") else best
模板 3:前缀和 + 哈希表(和为 K 的子数组)用哈希表记录每个前缀和出现的次数,一次遍历统计以当前位置结尾的合法子数组数。
python
from collections import defaultdict

def subarray_sum(nums: list, k: int) -> int:
    """统计元素和恰好等于 k 的连续子数组个数。"""
    cnt = defaultdict(int)
    cnt[0] = 1                      # 空前缀的和为 0,出现一次
    pre = ans = 0
    for x in nums:
        pre += x                    # 当前前缀和
        ans += cnt[pre - k]         # 累加此前前缀和 pre - k 的出现次数
        cnt[pre] += 1
    return ans
模板 4:差分数组(区间批量加与还原)每次区间加只改两个端点。差分对象有两种构造方式:由原数组构造(from_slice)时,build() 返回「原数组经全部区间加后的结果」;以全零基线构造(new)时,build() 返回的是各位置的累计增量(即所有区间加作用在全零原数组上的结果,LeetCode 1109 场景即此类,原数组全零时累计增量即最终数组)。注意:build() 本身只做一次前缀和,不会自动叠加调用方的原数组——以零基线构造却忘记叠加原数组,是本章 A5 点名的易错点。
python
class Diff:
    """一维差分数组:支持 O(1) 区间加、O(n) 前缀和还原。"""

    def __init__(self, n: int):
        self.diff = [0] * (n + 1)   # 长度 n + 1:diff[r + 1] 至多落到下标 n
        self.n = n

    @classmethod
    def from_slice(cls, a: list) -> "Diff":
        """由原数组构造:build() 返回「原数组经全部区间加后的结果」。"""
        obj = cls(len(a))
        for i, x in enumerate(a):
            obj.diff[i] += x        # diff[i] 记 a[i] 的起点
            obj.diff[i + 1] -= x    # 抵消 a[i],使前缀和回到 a[i+1]
        return obj

    def add(self, l: int, r: int, v: int) -> None:
        """将区间 [l, r](0-based,闭区间)内每个元素加上 v。"""
        self.diff[l] += v
        self.diff[r + 1] -= v       # 在 r + 1 处打上减法标记

    def build(self) -> list:
        """对差分数组求前缀和:全零基线时为累计增量;由原数组构造时为更新后的原数组。"""
        a = []
        cur = 0
        for i in range(self.n):
            cur += self.diff[i]
            a.append(cur)
        return a

易错点

  • 无序数组上做对撞双指针:移动方向依赖「有序」才有单调依据,无序时结果错误。应先排序(或改用哈希表 / 前缀和方案)。(推导见 B1)
  • 不定长窗口用 if 代替 while:是否循环收缩取决于「一步能否达到/恢复目标」,须按「求最短 / 求最长」分别理解。求最短窗口时需尽可能多收缩——用 if 只收缩一次会漏掉更短的合法窗口,导致答案偏大;求最长窗口时需收缩到重新合法——用 if 一次收缩未必恢复合法,窗口可能仍不满足条件。(分析见 B2)
  • 前缀和下标偏移:区间 [i, j] 的和应写 pre[j+1] - pre[i](pre[0]=0 表示空前缀);误写成 pre[j] - pre[i] 会漏掉下标 i 的元素。
  • 前缀和溢出:大量元素累加极易超出 32 位整数范围,应使用 long long / i64(Python 整数无此限制)。
  • 差分「还原」语义与忘记叠加原数组:差分序列只记录「增量标记」。以全零基线构造时,一次前缀和得到的是各位置的累计增量,须叠加到原数组才是「原数组经区间加的结果」(原数组全零时累计增量即最终数组);只有由原数组构造的差分,其前缀和才直接是更新后的原数组。此外所有修改必须在还原前完成,否则看到的是中间态。
  • 窗口长度与左指针越界:窗口长度是 right - left + 1(闭区间),定长窗口要先填满再计答案;收缩时需保证 left <= right,否则会出现负长度。
03

链表操作与快慢指针

深度解析 ↓

💡 链表用「节点 + 指针」把离散内存串成逻辑序列:牺牲随机访问换取已知前驱下的 O(1) 增删;而快慢指针则利用两个定速节点的步长差,一趟走完就能求中点、判环并定位环入口。

核心要点

  • 节点 = 数据域 + 指针域,靠指针串联、非连续存储(本章以单向链表为核心;双向链表的指针更正顺序见 B3)
  • 已持有前驱时插 / 删 O(1),但按下标访问为 O(n)
  • 虚拟头节点(dummy)统一「删头 / 插头」的边界情形(机制见 B1)
  • 快慢指针:固定步长差,可求中点、判环、定环入口(推导见 B1)
  • 反转三指针,口诀:先存后继 → 再改向 → 最后双指针前移
  • 判环相遇后,head 与相遇点同步单步走,必在环入口相遇(推导见 B1)

复杂度速查

操作 / 场景时间空间说明
访问第 k 个元素O(k)O(1)只能从头顺序走,无随机访问
已知前驱处插入 / 删除O(1)O(1)改指针即可,不搬移元素
按值查找O(n)O(1)需顺序扫描全部节点
反转整条链表(迭代)O(n)O(1)三指针原地反转
Floyd 判环O(n)O(1)快慢指针相遇即存在环
找环入口O(n)O(1)判环后再同步走一段
快慢指针求中点O(n)O(1)快指针到终点时慢指针恰在中点
合并两个有序链表O(n+m)O(1)dummy + 尾指针,原地拼接
删除倒数第 N 个节点O(n)O(1)dummy 头节点统一边界

模板代码

模板 1:反转单链表(迭代)三指针原地反转,是链表题最通用的基础动作。
python
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def reverse_list(head: "ListNode") -> "ListNode":
    prev, curr = None, head
    while curr:
        nxt = curr.next     # 先存后继
        curr.next = prev    # 反转指针
        prev, curr = curr, nxt
    return prev             # prev 即新头
模板 2:快慢指针判环 + 定位环入口(Floyd)快指针每次两步、慢指针每次一步;相遇后即可判定有环,再同步走可找到环入口。
python
def detect_cycle(head: "ListNode") -> "ListNode":
    slow = fast = head
    while fast and fast.next:          # 阶段一:判环
        slow = slow.next
        fast = fast.next.next
        if slow is fast:               # 用身份 is 判同一节点
            break
    else:
        return None                    # 无环
    slow = head                        # 阶段二:找入口
    while slow is not fast:
        slow = slow.next
        fast = fast.next
    return slow
模板 3:合并两个有序链表虚拟头节点 + 尾指针,原地把较小者不断接到结果链尾部。
python
def merge_two_lists(l1: "ListNode", l2: "ListNode") -> "ListNode":
    dummy = ListNode(0)     # 虚拟头节点
    tail = dummy
    while l1 and l2:
        if l1.val <= l2.val:
            tail.next, l1 = l1, l1.next
        else:
            tail.next, l2 = l2, l2.next
        tail = tail.next
    tail.next = l1 or l2    # 拼接剩余
    return dummy.next
模板 4:dummy head 删除模式(删除倒数第 N 个节点)用虚拟头节点消除「删除首节点需特判」的分支;先量长度,再走到待删节点的前驱。
python
def remove_nth_from_end(head: "ListNode", n: int) -> "ListNode":
    dummy = ListNode(0, head)       # 虚拟头节点
    length, p = 0, head
    while p:
        length += 1
        p = p.next
    prev = dummy
    for _ in range(length - n):
        prev = prev.next
    prev.next = prev.next.next      # 摘除目标节点
    return dummy.next
模板 5:快慢指针求中点快指针每次两步、慢指针每次一步,快指针走到链尾时慢指针恰在中点。约定:偶数个节点时返回「右中点」(第二个中间节点),三语言实现均与此约定保持一致。
python
def middle_node(head: "ListNode") -> "ListNode":
    """返回链表中点节点;偶数长度返回「右中点」(第二个中间节点)。"""
    slow = fast = head
    while fast and fast.next:      # 快指针每次两步
        slow = slow.next           # 慢指针每次一步
        fast = fast.next.next
    return slow

易错点

  • 反转断链:先改 curr.next 再去取 curr.next 保存后继,会丢失剩余部分。必须先存 next,再反转指向,最后双指针前移。
  • 快指针空指针:判环循环必须同时校验 fast 与 fast.next 非空,否则 fast.next.next 会解引用空指针。C++/Python 写 while (fast && fast->next)/while fast and fast.next。
  • 用值判「同一节点」:Floyd 判环与找入口必须比较节点身份(指针 / is / Rc::ptr_eq),用值相等会在出现重复值时误判。(详见 B5)
  • 删头未用 dummy:删除首节点时若无虚拟头节点,必须特判并更新 head;用 dummy 后所有删除都统一为「改前驱的 next」。(机制见 B1)
  • 把 vector 的迭代器规则套到 std::list:std::list 插入 / splice 不使已有迭代器失效,仅被删元素失效——与 std::vector 扩容时「全部失效」相反。(详见 B5)
  • Rust 想用 Option<Box<>> 造环:Option<Box<ListNode>> 无法构成环,双向 / 带环链表需 Rc<RefCell<..>>;而 Rc 双向环会内存泄漏,须用 Weak 破环。(详见 B5)
04

栈、队列与哈希表

深度解析 ↓

💡 栈后进先出、队列先进先出,二者都是受限线性表;哈希表用空间换时间,把「按键查找」从线性比较降到期望 O(1)。

核心要点

  • 栈(LIFO):只在栈顶 push/pop,天然解决「最近优先」类问题
  • 队列(FIFO):队尾入、队首出,双端队列两端增删均近似 O(1)
  • 哈希表:键经哈希函数映射到桶,平均 O(1),最坏 O(n)
  • 冲突处理两派:链地址法(拉链)与开放寻址(探测)
  • 负载因子超阈值触发扩容,单次 O(n)、摊还 O(1)
  • 需要有序或范围查询时,改用平衡树(O(log n))

复杂度速查

操作 / 场景时间空间说明
栈 push / pop(栈顶)O(1)O(n)仅在表尾操作
队列 enqueue / dequeueO(1)O(n)双端队列两端近似 O(1)
双端队列两端增删O(1)O(n)中间插入仍是 O(n)
哈希表 插入 / 查找 / 删除期望 O(1),最坏 O(n)O(n)冲突或哈希退化时变慢
哈希表扩容(rehash)单次 O(n),摊还 O(1)O(n)重建桶数组并重插
有序映射(map / BTreeMap)O(log n)O(n)有序遍历与范围查询

模板代码

模板 1:括号匹配(栈)用栈记录待匹配的左括号,遇右括号时栈顶必须对应。
python
def is_valid(s: str) -> bool:
    """判断括号字符串是否合法匹配"""
    pairs = {')': '(', ']': '[', '}': '{'}
    stack = []
    for c in s:
        if c in pairs:                # 是右括号
            if not stack or stack[-1] != pairs[c]:
                return False
            stack.pop()
        else:
            stack.append(c)           # 左括号入栈
    return not stack                  # 栈空才算完全匹配
模板 2:哈希表频次统计一次遍历统计每个元素的出现次数,是计数类问题的通用底座。
python
from collections import Counter

def count_freq(nums: list) -> dict:
    """统计每个元素出现的次数"""
    # Counter 是 dict 子类,专用于计数;等价于手动 freq[x] += 1
    return dict(Counter(nums))
模板 3:两数之和(一次遍历)边遍历边把「值 → 下标」存入哈希表,把 O(n²) 暴力降为 O(n)。
python
def two_sum(nums: list, target: int) -> list:
    """返回和为 target 的两个下标(每个元素只用一次)"""
    seen = {}                         # 值 -> 下标
    for i, x in enumerate(nums):
        need = target - x
        if need in seen:              # 先查
            return [seen[need], i]
        seen[x] = i                   # 后插,避免元素配到自己
    return []

易错点

  • C++ unordered_map 最坏 O(n) 与迭代器失效:哈希冲突严重(或被刻意构造)时全部键挤进同一桶,查找退化为 O(n);rehash 会使所有迭代器失效(但元素引用/指针仍有效),边遍历边插入要格外小心。需要有序时改用 map/set(O(log n))。
  • Python 可变对象作 key:d[[1, 2]] = 1 会抛 TypeError: unhashable type: 'list'——list/dict/set 没有稳定的 __hash__。改用 tuple 或 frozenset 作键。
  • list.pop(0) 是 O(n):从表头删除要搬移后面全部元素,用它当队列会退化成 O(n²)。队列一律用 collections.deque 的 popleft()。
  • 误把哈希表当有序结构:C++ unordered_map 迭代顺序完全无保证;Python dict 虽保插入顺序(3.7+ 语言保证),但它仍是映射而非有序容器,排序仍须显式 sorted。
  • Rust entry API 的所有权细节:map.entry(k).or_insert(0) 会把键 k 移动进表并返回 &mut V;若默认值需计算,用 or_insert_with(|| ...) 避免无谓构造。
05

堆与优先队列

深度解析 ↓

💡 堆是用「完全二叉树 + 数组层序存储」实现的优先队列:堆顶恒为全局最值,插入 / 删除都是 O(log n),自底向上建堆只要 O(n)。

核心要点

  • 完全二叉树无空洞,可用数组层序存储,父子关系由下标算术推出
  • 0 基数组:父 (i-1)/2,左孩子 2i+1,右孩子 2i+2
  • 堆序性质是弱序:只约束父子,不约束兄弟
  • 两个基本动作:插入用上浮(sift-up),删除堆顶用下沉(sift-down)
  • 自底向上建堆 O(n),优于逐个插入的 O(n log n)
  • 三语言默认方向不同:Rust/C++ 默认最大堆,Python 默认最小堆

复杂度速查

操作 / 场景时间空间说明
取堆顶 peek / topO(1)O(1)根结点即最值
插入 pushO(log n)O(1)追加到末尾后上浮,最多 log n 层
弹出堆顶 popO(log n)O(1)末尾元素补根后下沉
自底向上建堆 build-heapO(n)O(1)Floyd 方法,就地完成
逐个插入建堆O(n log n)O(1)每个元素上浮 O(log n)
堆排序O(n log n)O(1)就地、不稳定
Top-K(维护大小 k 的堆)O(n log k)O(k)遍历一次,堆只留 k 个
合并 K 个有序链表(k 路归并)O(N log k)O(k)小顶堆维护各链表当前头结点,N 为总节点数、k 为链表数
双堆求数据流中位数插入 O(log n),查询 O(1)O(n)两堆各存一半元素

模板代码

模板 1:手工上浮 + 下沉 + 建堆(理解原理)在数组上手写大顶堆的上浮、下沉与自底向上建堆——上浮用于插入、下沉用于删除堆顶,看清 O(n) 建堆的原理。
python
def sift_up(a: list, i: int) -> None:
    """大顶堆上浮:a[i] 与父结点比较,比父大就交换上移"""
    while i > 0:
        p = (i - 1) // 2  # 父结点下标
        if a[i] <= a[p]:
            break  # 已满足堆序
        a[i], a[p] = a[p], a[i]
        i = p


def sift_down(a: list, i: int, n: int) -> None:
    """大顶堆下沉:a[i] 与较大的孩子交换,直到底部或满足堆序"""
    while True:
        l, r, largest = 2 * i + 1, 2 * i + 2, i
        if l < n and a[l] > a[largest]:
            largest = l
        if r < n and a[r] > a[largest]:
            largest = r
        if largest == i:
            break
        a[i], a[largest] = a[largest], a[i]
        i = largest


def build_heap(a: list) -> None:
    """自底向上建堆,整体 O(n)"""
    n = len(a)
    for i in range(n // 2 - 1, -1, -1):  # n//2-1 是最后一个非叶结点
        sift_down(a, i, n)
模板 2:标准库 Top-K求前 K 大就用大小为 K 的最小堆;求前 K 小就用大小为 K 的最大堆(此处演示前 K 大)。三种语言均在返回前排序,统一按升序输出,便于对照。
python
import heapq


def top_k_largest(nums: list, k: int) -> list:
    """返回前 k 大的元素:维护大小为 k 的最小堆,O(n log k)"""
    heap = []
    for x in nums:
        heapq.heappush(heap, x)
        if len(heap) > k:
            heapq.heappop(heap)  # 弹出最小值
    return sorted(heap)  # 统一按升序返回(堆序本身不保证有序)
模板 3:双堆求数据流中位数用一个大顶堆存较小的一半、一个小顶堆存较大的一半,取中位数 O(1)。
python
import heapq


class MedianFinder:
    """双堆维护数据流中位数"""
    def __init__(self):
        self.left = []   # 大顶堆(取负存储):较小的一半
        self.right = []  # 最小堆:较大的一半

    def add_num(self, num: int) -> None:
        heapq.heappush(self.left, -num)                        # 先放入较小的一半
        heapq.heappush(self.right, -heapq.heappop(self.left))  # left 最大值交给 right
        if len(self.right) > len(self.left):                   # 平衡两堆
            heapq.heappush(self.left, -heapq.heappop(self.right))

    def find_median(self) -> float:
        if len(self.left) > len(self.right):
            return -self.left[0]
        return (-self.left[0] + self.right[0]) / 2.0
模板 4:合并 K 个有序链表用小顶堆维护 k 条有序链表各自的当前头结点;每次弹出堆顶(当前全局最小)接到结果尾部,并把该链表的下一个结点推入堆中。堆中每个元素存为 (结点值, 所属链表标识),用标识在平局时定位回原链表。总代价 O(N log k)(N 为总节点数、k 为链表数)。
python
import heapq


def merge_k_lists(lists: list) -> list:
    """合并 k 条升序序列:小顶堆存 (结点值, 所属链表下标),O(N log k)"""
    heap = []
    pos = [0] * len(lists)
    for i, lst in enumerate(lists):
        if lst:
            heap.append((lst[0], i))  # 各链表头结点入堆
    heapq.heapify(heap)  # 一次 O(k) 建堆
    res = []
    while heap:
        val, i = heapq.heappop(heap)  # 弹出当前全局最小
        res.append(val)               # 接到结果尾部
        pos[i] += 1
        if pos[i] < len(lists[i]):
            heapq.heappush(heap, (lists[i][pos[i]], i))  # 推入该链表下一个结点
    return res

易错点

  • 三语言默认方向不同:Rust BinaryHeap 与 C++ priority_queue 默认都是最大堆,而 Python heapq 默认是最小堆。跨语言迁移时最容易把方向写反——Python 做最大堆要取负存储,或使用 3.14 起新增的 _max 系列函数。
  • C++ 比较器语义与出队顺序要分开看:比较器 comp(a,b) 的语义与 std::sort 一致——comp(a,b) == true 表示 a 在弱序中排在 b 之前;但 priority_queue 先输出弱序中"最大 / 最后"的元素,于是弱序里"靠前"的元素反而后出队。默认 less 得到大顶堆(top() 是最大元),要小顶堆得传 greater。切勿把方向写反,否则 top() 取错——注意这正是「语义与 sort 相同、出队方向相反」两件事,不要把二者混为一谈。
  • Python heapq 没有 decrease-key / 任意删除:想「改优先级」或「删中间元素」时,惯用惰性删除——入堆时带上版本号或有效标记,出堆时发现过期项就跳过,而不是试图修改堆内已有元素。
  • Rust 堆内元素不得改变相对顺序:元素一旦入堆,就不得再改动其排序(否则是逻辑错误,行为未定义);需要修改堆顶时用 peek_mut(),改动后会自动 O(log n) 下沉修复。
  • 别用逐个 push 当建堆:那样子是 O(n log n)。heapify / make_heap / BinaryHeap::from(vec) 才是 O(n) 的自底向上建堆。
  • 堆 ≠ 有序:对堆做迭代 / 遍历得到的顺序是任意的,只有反复 pop 才能得到有序序列。
06

二叉树遍历与递归思维

深度解析 ↓

💡 二叉树的一切递归解法都可归结为「在正确的位置做正确的事」:前序位置自顶向下传递信息,后序位置自底向上合并子问题;遍历与分解,是同一枚硬币的两面。

核心要点

  • 前/中/后序是同一套 DFS 框架里,代码的三个执行时机
  • 前序=进入节点;后序=离开节点;中序=左子树处理完之后
  • 后序位置天然适合「合并子问题结果」的分解思维
  • 层序遍历=BFS+队列;要分层必须预先固定本层节点数
  • 遍历思维用外部变量累积,分解思维用返回值传递
  • BST 中序序列严格递增;验证 BST 必须传递上下界

复杂度速查

操作 / 场景时间空间说明
前/中/后序遍历(递归)O(n)O(h)h 为树高:平衡树 O(log n)、链状树 O(n)
前/中序迭代(显式栈)O(n)O(h)手动模拟系统栈,避免爆栈
后序迭代(双栈 / 反转)O(n)O(h)前序变体,最后整体反转结果
层序遍历(BFS 队列)O(n)O(w)w 为最大层宽,满二叉树约 n/2
BST 查找 / 插入 / 删除O(h)O(1)~O(h)最坏退化为链,O(n)
BST 验证(范围 / 中序)O(n)O(h)范围法显式传 (lo, hi) 上下界(详见 B2);每节点访问一次
由前序+中序重建O(n)O(n)用哈希表 O(1) 定位中序下标

模板代码

模板 1:三种递归遍历(前 / 中 / 后)同一个 DFS 框架,代码插入位置不同即为不同遍历。
python
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def preorder(root, out):
    if root is None:
        return
    out.append(root.val)          # 前序位置
    preorder(root.left, out)
    preorder(root.right, out)

def inorder(root, out):
    if root is None:
        return
    inorder(root.left, out)
    out.append(root.val)          # 中序位置
    inorder(root.right, out)

def postorder(root, out):
    if root is None:
        return
    postorder(root.left, out)
    postorder(root.right, out)
    out.append(root.val)          # 后序位置
模板 2:层序遍历(BFS + 队列,按层返回)必须先记录本层节点数 sz,才能正确分层。
python
from collections import deque

def level_order(root):
    res = []
    if root is None:
        return res
    q = deque([root])
    while q:
        level = []
        for _ in range(len(q)):        # 固定本层节点数
            node = q.popleft()
            level.append(node.val)
            if node.left:
                q.append(node.left)
            if node.right:
                q.append(node.right)
        res.append(level)
    return res
模板 3:分解思维模板(二叉树最大深度)先取子问题答案,再在后序位置合并——分解思维的标准形态。此处「深度」对齐 LeetCode 104 的节点数口径(空树 0、单节点 1),与术语表按边数计的「高度」口径相差 1(见 A2 口径说明)。
python
def max_depth(root):
    if root is None:            # 空树深度为 0
        return 0
    l = max_depth(root.left)    # 先求左右子树
    r = max_depth(root.right)
    return 1 + max(l, r)        # 后序位置:合并子问题
模板 4:BST 验证(中序法)BST 的中序序列必严格递增;维护前驱值 prev 逐一比较。
python
def is_valid_bst(root):
    prev = None
    def inorder(node):
        nonlocal prev
        if node is None:
            return True
        if not inorder(node.left):
            return False
        if prev is not None and node.val <= prev:   # 非严格递增
            return False
        prev = node.val
        return inorder(node.right)
    return inorder(root)
模板 5:迭代中序遍历(显式栈)手动模拟系统栈,规避深树递归爆栈;对应 A5 的「迭代中序栈写法」易错点。
python
def inorder_traversal(root):
    out, stack = [], []
    cur = root
    while cur or stack:            # 外层双条件缺一不可
        while cur:                 # 一路向左压栈
            stack.append(cur)
            cur = cur.left
        cur = stack.pop()          # 弹栈
        out.append(cur.val)        # 访问(中序位置)
        cur = cur.right            # 转向右子树
    return out

易错点

  • 递归终止条件写错:入口必须判空(if (!root) return;)。若写成判断子节点而非当前节点,会漏处理叶子或访问空指针崩溃。
  • 迭代中序遍历的栈写法:外层条件必须是 while (cur || !stack.empty()),不能只写 while (!stack.empty())(否则根节点未入栈);内层一路 cur = cur->left 压栈,弹栈后务必 cur = cur->right,否则死循环。可对照模板 5。
  • BST 验证只比较直接父子节点:node.left.val < node.val < node.right.val 是经典错误——它只检查了局部父子关系,无法捕捉祖先约束。反例(层序 [10,5,15,null,null,6,20]):
  • Python 递归深度:默认上限约 1000 帧(实际可用约 993),链状树(深度 = n)会抛 RecursionError;可 sys.setrecursionlimit(...)(谨慎)或改用迭代。
  • Rust 节点所有权:访问 Option<Rc<RefCell<TreeNode>>> 的子节点必须 borrow();同一节点的两次可变借用会运行时 panic,深递归也有爆栈风险。
  • 层序遍历未固定本层大小:内层循环若直接用实时的 q.size() 作条件,会一次遍历多层,分层结果混乱。
07

排序与二分搜索

深度解析 ↓

💡 排序把无序序列整理为有序(工程库用「混合策略」在平均性能、最坏保证、稳定性与空间之间取平衡);二分搜索则在单调序列或单调谓词上每轮排除一半候选区间,以 O(log n) 定位值或边界。

核心要点

  • 比较排序下界 O(n log n);计数/基数等非比较排序另按值域计
  • 稳定性=相等元素的相对次序保持不变,是多关键字排序的前提
  • 快排平均 O(n log n)、最坏 O(n²),原地但不稳定
  • 归并排序稳定、恒 O(n log n),代价是 O(n) 额外空间
  • 二分前提是单调;闭区间与半开区间写法不可混用
  • mid = l + (r - l) / 2 防溢出;比较器必须是严格弱序 / 全序

复杂度速查

算法时间(平均 / 最坏)空间稳定性
快速排序O(n log n) / O(n²)O(log n) 栈否
归并排序O(n log n) / O(n log n)O(n)是
堆排序O(n log n) / O(n log n)O(1)否
计数排序O(n + k) / O(n + k)O(k)是
插入排序O(n²) / O(n²)(最好 O(n))O(1)是
C++ std::sort(Introsort)O(n log n) / O(n log n)O(log n)否
Rust sort(driftsort,稳定)O(n log n) / O(n log n)O(n)是
Rust sort_unstable(ipnsort)O(n log n) / O(n log n)O(1)否
Python sorted/list.sort(Timsort)O(n log n) / O(n log n)O(n)是

模板代码

模板 1:闭区间二分查找在有序数组中查找目标值的下标,[l, r] 左闭右闭,最通用的入门写法。
python
def binary_search(a: list, target) -> int:
    l, r = 0, len(a) - 1
    while l <= r:
        mid = l + (r - l) // 2
        if a[mid] == target:
            return mid
        if a[mid] < target:
            l = mid + 1
        else:
            r = mid - 1
    return -1
模板 2:左边界二分(lower_bound)返回第一个 ≥ target 的下标(可能等于 n);谓词 nums[i] < target 左真右假,用半开区间 [l, r) 实现。
python
from bisect import bisect_left, bisect_right


def lower_bound(a: list, target) -> int:
    # 手写半开区间实现;等价于标准库 bisect_left(a, target)
    l, r = 0, len(a)
    while l < r:
        mid = l + (r - l) // 2
        if a[mid] < target:
            l = mid + 1
        else:
            r = mid
    return l


def upper_bound(a: list, target) -> int:
    # 首个 > target 的下标,等价于 bisect_right(a, target)
    return bisect_right(a, target)


def lower_bound_stdlib(a: list, target) -> int:
    # 标准库一行版,与手写 lower_bound 结果一致
    return bisect_left(a, target)
模板 3:二分答案(最小可行值)在答案值域 [lo, hi] 上二分求最小可行值——check 须关于答案单调(存在一个分界点,其前不可行、其后可行),check 为单次判定,故总复杂度 = O(log V × check 的代价)(V 为答案值域跨度);典型应用见 B3 的「爱吃香蕉的珂珂」(LeetCode 875)。边界提醒:若区间内不存在可行值,循环将收敛到 hi,故调用方需保证 hi 本身可行。
python
def min_feasible(lo: int, hi: int, check) -> int:
    # 求最小可行值:在单调谓词上二分答案
    # 前提:答案具有单调性(check 在值域上呈「前假后真」)
    # 总复杂度 = O(log V × check 的代价),V 为答案值域跨度
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if check(mid):
            hi = mid      # mid 可行 → 答案 ≤ mid
        else:
            lo = mid + 1  # mid 不可行 → 答案 > mid
    return lo             # 收敛于最小可行值;无可行值时返回 hi(调用方须保证 hi 可行)
模板 4:快速排序 partition(双指针划分)以区间首元素为基准,双指针 i、j 从两端向中间夹逼,区间内原地重排;返回基准的最终下标。本模板未随机化基准,在「已排序」或「大量重复元素」输入下会退化为 O(n²),生产环境应改用随机基准或三数取中(另见 B3 三路划分)。
python
def partition(a: list, lo: int, hi: int) -> int:
    pivot = a[lo]
    i, j = lo, hi
    while i < j:
        while i < j and a[j] >= pivot:  # 右指针找小于基准
            j -= 1
        while i < j and a[i] <= pivot:  # 左指针找大于基准
            i += 1
        a[i], a[j] = a[j], a[i]
    a[lo], a[i] = a[i], a[lo]           # 基准归位
    return i


def quick_sort(a: list, lo: int, hi: int) -> None:
    if lo >= hi:
        return
    p = partition(a, lo, hi)
    quick_sort(a, lo, p - 1)
    quick_sort(a, p + 1, hi)


def quick_sort_array(a: list) -> None:
    # 顶层入口:排序整个数组
    quick_sort(a, 0, len(a) - 1)
模板 5:归并排序(稳定)对半切分后递归排序、线性合并;合并时用 <= 取左段优先,保证排序稳定。
python
def merge_sort(a: list, lo: int, hi: int) -> None:
    if hi - lo <= 1:
        return
    mid = lo + (hi - lo) // 2
    merge_sort(a, lo, mid)
    merge_sort(a, mid, hi)
    tmp = []
    i, j = lo, mid
    while i < mid and j < hi:           # 合并两个有序段
        if a[i] <= a[j]:                # 取等号 → 稳定
            tmp.append(a[i])
            i += 1
        else:
            tmp.append(a[j])
            j += 1
    tmp.extend(a[i:mid])
    tmp.extend(a[j:hi])
    a[lo:hi] = tmp


def merge_sort_array(a: list) -> None:
    # 顶层入口:排序整个数组
    merge_sort(a, 0, len(a))
模板 6:计数排序(值域小)对取值落在 [0, k) 的整数键做线性时间排序——统计频次 → 求前缀和 → 逆序回填;时间 O(n + k)、空间 O(k)、稳定(逆序回填保证相等键的相对次序不变)。
python
def counting_sort_array(a: list, k: int) -> list:
    # 计数排序:元素须落在值域 [0, k)
    # 时间 O(n + k)、空间 O(k)、稳定
    cnt = [0] * k
    for x in a:
        cnt[x] += 1                        # 统计频次
    for i in range(1, k):
        cnt[i] += cnt[i - 1]               # 前缀和:cnt[v] = 元素数 ≤ v
    out = [0] * len(a)
    for x in reversed(a):
        cnt[x] -= 1                        # 逆序回填 → 稳定
        out[cnt[x]] = x
    return out

易错点

  • 二分死循环:while (l <= r) 与 r = mid 混用,或 while (l < r) 与 r = mid - 1 混用,会使区间不收缩而卡死;闭区间配 l <= r,不满足时收缩为 l = mid + 1 / r = mid - 1;半开区间配 l < r,用 r = mid 保留候选。
  • mid 溢出:(l + r) / 2 在 l、r 接近整型上限时相加溢出为负;统一写 l + (r - l) / 2。
  • 比较器违反严格弱序:C++ std::sort 要求 comp 非自反且可传递,写成 <= 会触发未定义行为(结果错乱甚至崩溃);Rust 比较函数须构成全序,否则顺序未指定甚至 panic。
  • 忽视稳定性:用不稳定排序做多关键字排序会打乱相等元素的相对次序;需要稳定时改用归并、C++ stable_sort 或 Python 的 Timsort。
  • 标准库语义误用:lower_bound/bisect_left 返回的是「插入位置」而非「是否命中」,需自行判断 i < n && a[i] == target;Rust binary_search 返回 Result,Err(i) 才是插入点。
  • 计数排序值域过大:数组需开到 O(k),k 很大时空间爆炸;且只适用于能作为下标的整数键。
08

回溯与深度广度优先搜索

深度解析 ↓

💡 回溯是"带撤销操作"的决策树深度优先搜索;DFS 用递归/栈一条路走到底,BFS 用队列一层层向外扩散,无权图上的最短步数首选 BFS。

核心要点

  • 回溯三要素:路径 path、选择列表、结束条件,缺一不可
  • 回溯三步必须对称:做选择 → 递归 → 撤销选择
  • 剪枝越早越好:提前 return 掉不可能的子树,是回溯效率的关键
  • DFS 用递归或显式栈;BFS 用队列,实现方式与数据结构强绑定
  • BFS 按层推进,天然给出无权图的最短步数(最少边数)
  • 同层去重:候选集合先排序,靠"同层跳过相同前驱"避免重复解

复杂度速查

操作 / 场景时间空间说明
回溯(全排列 / 子集)O(n!·n) / O(2ⁿ·n)O(n) 递归栈由解空间树节点数决定;系数 n 为每节点记录解的拷贝代价
回溯 + 剪枝通常远小于理论上界O(n)实际复杂度取决于剪枝质量
网格 DFS(flood fill)O(R·C)O(R·C) 最坏递归栈每个格子最多访问一次
连通块计数O(R·C)O(R·C)每个未访问起点触发一次 DFS
图 / 网格 BFS 最短路O(V+E) / O(R·C)O(V) / O(R·C)无权图最短步数
多源 BFSO(V+E)O(V)所有源点先入队,一次扩散

模板代码

模板 1:回溯通用框架(全排列)路径 + 选择列表 + 结束条件,做选择与撤销严格成对;子集 / 组合把 used 换成 start(for i in start..n、递归传 i+1)即可复用同一框架。
python
# 全排列:回溯通用框架(路径 path + 选择列表 + 结束条件)
def permute(nums: list) -> list:
    res, path = [], []
    used = [False] * len(nums)

    def backtrack() -> None:
        if len(path) == len(nums):   # 结束条件
            res.append(path[:])      # 记录快照(浅拷贝)
            return
        for i in range(len(nums)):
            if used[i]:
                continue             # 已选过则剪枝
            used[i] = True           # 做选择
            path.append(nums[i])
            backtrack()              # 进入下一层决策
            path.pop()               # 撤销选择(恢复现场)
            used[i] = False

    backtrack()
    return res
模板 2:网格 DFS(flood fill)把二维网格当隐式图,原地改写代替 visited,统计连通块个数。
python
# 网格 DFS:flood fill,统计连通块(岛屿)数量
def num_islands(grid: list) -> int:
    if not grid or not grid[0]:
        return 0
    rows, cols = len(grid), len(grid[0])

    def dfs(r: int, c: int) -> None:
        # 越界或非陆地则返回(先判边界,再访问 grid)
        if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != '1':
            return
        grid[r][c] = '0'              # 标记为已访问(沉岛)
        dfs(r + 1, c)                 # 四方向扩展
        dfs(r - 1, c)
        dfs(r, c + 1)
        dfs(r, c - 1)

    count = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1':     # 新连通块
                count += 1
                dfs(r, c)
    return count
模板 3:BFS 最短步数队列 + visited,按层推进;入队时立即标记,首次到达即最短(空网格返回 -1)。
python
from collections import deque

# BFS 最短步数:grid 中 0 可走、1 为障碍,返回最少的移动次数
def shortest_path(grid: list) -> int:
    if not grid or not grid[0]:
        return -1                     # 空网格守卫,避免 grid[0] 越界
    rows, cols = len(grid), len(grid[0])
    if grid[0][0] == 1 or grid[rows - 1][cols - 1] == 1:
        return -1
    visited = [[False] * cols for _ in range(rows)]
    q = deque([(0, 0)])
    visited[0][0] = True          # 入队时立即标记
    steps = 0
    dirs = [(1, 0), (-1, 0), (0, 1), (0, -1)]

    while q:
        for _ in range(len(q)):   # 按层推进:当前层共 len(q) 个节点
            r, c = q.popleft()
            if r == rows - 1 and c == cols - 1:
                return steps       # 首次到达即为最短
            for dr, dc in dirs:
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols \
                        and grid[nr][nc] == 0 and not visited[nr][nc]:
                    visited[nr][nc] = True  # 入队时标记
                    q.append((nr, nc))
        steps += 1                 # 一层结束,步数 +1
    return -1

易错点

  • 忘记撤销选择:path / used 未恢复现场时,后续分支会读到被污染的状态,结果错乱或丢解。做选择与撤销选择必须严格成对、顺序对称。
  • visited 标记时机(入队 vs 出队):BFS 应在入队时标记。若留到出队时才标记,同一节点会被多个邻居重复入队,导致队列膨胀、性能急剧下降甚至超时;只有完全不做 visited 判断时才可能真正死循环——这是 BFS 最常见的性能陷阱。
  • 同层去重条件写错:候选集合含重复元素时,必须先排序,再按场景选用对应的同层去重判定式(两式机制不同,不可混用):
  • 子集 / 组合(start 收缩候选集型,无 used 数组):跳过同层重复前驱用 i > start && nums[i] == nums[i-1];
  • 排列(used 标记型,如全排列 II):用 i > 0 && nums[i] == nums[i-1] && !used[i-1](注意此处是 i > 0,而非子集 / 组合的 i > start)。
  • Python 递归深度:网格 DFS 最坏递归深度可达 R·C,超过默认递归上限(约 1000)会抛 RecursionError;可用 sys.setrecursionlimit 提高上限,但官方警告设得过高可能导致崩溃,超大网格建议改写为迭代栈。
  • Rust 深递归栈溢出:栈空间有限(子线程尤其可用 RUST_MIN_STACK 调整),深递归回溯 / 网格 DFS 可能触发 stack overflow。工程上优先用显式 Vec 栈迭代改写,或控制递归深度。
  • 网格越界访问:判断顺序必须先测边界再访问 grid[r][c],否则越界读取触发 panic / 未定义行为;Rust 中用 usize 做 -1 会下溢,应转 isize 或先判 r > 0。
09

动态规划模型与状态转移

深度解析 ↓

💡 把问题拆成有重叠的子问题,用一张表记下每个子问题的答案,让每种状态只计算一次,从而把指数级暴力搜索降为多项式复杂度。

核心要点

  • DP 思维链:先定状态 → 再写转移方程 → 再设初始化 → 最后定遍历顺序
  • 两大实现范式:自顶向下记忆化、自底向上递推
  • 可 DP 的前提:最优子结构 + 无后效性
  • 0-1 背包一维数组须逆序,完全背包一维数组须正序
  • 空间压缩:用滚动数组把二维表压成一维

复杂度速查

模型时间空间说明
爬楼梯 / 斐波那契O(n)O(1)(滚动变量)只依赖前两个状态
打家劫舍(选/不选)O(n)O(1)(滚动变量)只依赖前两个状态
0-1 背包O(nW)O(W)一维数组,内层容量逆序
完全背包O(nW)O(W)一维数组,内层容量正序
LIS(朴素 DP)O(n²)O(n)每个元素向前扫描可接续者;可用二分 + 贪心优化至 O(n log n)
LCSO(nm)O(nm) / O(min(n,m))二维表 / 滚动行
编辑距离O(nm)O(nm) / O(min(n,m))增、删、改三种操作取最小

模板代码

模板 1:一维 DP 通用骨架(爬楼梯 / 打家劫舍)状态只依赖前两项时,用两个滚动变量即可把空间降到 O(1)。
python
def climb_stairs(n: int) -> int:
    # 爬楼梯:dp[i] = dp[i-1] + dp[i-2],滚动变量 O(1) 空间
    prev2, prev1 = 1, 1  # dp[0], dp[1]
    for _ in range(2, n + 1):
        prev2, prev1 = prev1, prev1 + prev2
    return prev1

def rob(nums: list) -> int:
    # 打家劫舍:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
    prev2, prev1 = 0, 0  # dp[i-2], dp[i-1]
    for x in nums:
        prev2, prev1 = prev1, max(prev1, prev2 + x)
    return prev1
模板 2:0-1 背包(一维逆序)每件物品只能取一次,一维容量数组必须从大到小更新,保证用的是「上一件物品」的状态。
python
def knapsack_01(weights: list, values: list, cap: int) -> int:
    # dp[j] 表示容量至多为 j(不超过 j)时的最大价值;内层容量逆序遍历
    dp = [0] * (cap + 1)
    for w, v in zip(weights, values):
        for j in range(cap, w - 1, -1):  # 逆序:保证每件物品只用一次
            dp[j] = max(dp[j], dp[j - w] + v)
    return dp[cap]
模板 3:编辑距离(二维)把「s 前 i 个字符转成 t 前 j 个字符」作为状态,转移对增、删、改三种操作取最小。
python
def edit_distance(s: str, t: str) -> int:
    # dp[i][j]:s 前 i 个字符转成 t 前 j 个字符的最少操作数
    n, m = len(s), len(t)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        dp[i][0] = i            # 删除 i 个字符
    for j in range(m + 1):
        dp[0][j] = j            # 插入 j 个字符
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if s[i - 1] == t[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]        # 字符相同,无需操作
            else:
                dp[i][j] = 1 + min(dp[i - 1][j],   # 删除
                                   dp[i][j - 1],   # 插入
                                   dp[i - 1][j - 1])  # 替换
    return dp[n][m]
模板 4:最长公共子序列(LCS)把「s 前 i 个字符与 t 前 j 个字符的最长公共子序列长度」作为状态:两字符相等时接在斜对角状态之后 +1,不等时取「上」「左」两状态中的较大值。转移仅依赖上一行与当前行的前一列,二维表也可压成滚动行,把空间降到 O(min(n, m))。
python
def lcs(s: str, t: str) -> int:
    # dp[i][j]:s 前 i 个字符与 t 前 j 个字符的最长公共子序列长度
    # 转移:s[i-1]==t[j-1] → dp[i-1][j-1] + 1;否则 max(dp[i-1][j], dp[i][j-1]);可用滚动行压至 O(min(n,m)) 空间
    n, m = len(s), len(t)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if s[i - 1] == t[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1              # 相等,接在斜对角之后
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])   # 不等,取上下较大者
    return dp[n][m]

易错点

  • 0-1 背包内层写成正序:会让同一件物品被反复选取,结果退化成完全背包;一维写法必须「容量从大到小」,且下界写成 j >= w[i](写 j >= 0 会因 j - w[i] 越界)。
  • 完全背包内层写成逆序:每件物品就只会被选一次,与题意相反;正序遍历才能让 dp[j-w] 取到「本件已选」的新值。
  • 初始化边界选错:若要求「恰好装满」,除 dp[0] = 0 外其余初值应设为 -∞(表示不可达);若只求「不超过容量」的最大价值,则全部初始化为 0。求最小值时用 +∞,且加法前要防溢出。
  • 无穷大取值不当:用 INT_MAX 直接参与 +1 会溢出成负数,惯用 INT_MAX / 2 或 1e18 之类的安全值。
  • 状态定义含糊:若 dp[i] 的含义(是「以 i 结尾」还是「前 i 个」)没定清,转移方程就容易写错;先写清定义再写方程。
  • 记忆化状态设计不当(缓存未命中):自顶向下时若状态参数带有冗余维度(把与结果无关的量也塞进被缓存的参数),同一子问题会被当作不同状态反复计算,缓存命中率骤降、白白变慢;此外递归层次过深可能触发栈溢出(Python 尤甚),需 sys.setrecursionlimit 或改为自底向上递推。
10

图遍历、最短路径与并查集

深度解析 ↓

💡 图遍历从源点出发系统访问所有可达顶点(DFS 一条路走到底、BFS 逐层扩散);Dijkstra 用「贪心取最近点 + 优先队列」在非负权图上求最短路;并查集用「代表元 + 路径压缩」近 O(1) 地维护动态连通性。

核心要点

  • 邻接表是稀疏图默认存储;邻接矩阵适合稠密图与 O(1) 查边
  • DFS 用栈/递归,BFS 用队列;都在「发现时」标记 visited
  • 边权均为 1 时,BFS 天然给出最短路(呼应第 8 章)
  • 拓扑排序 = Kahn(入度表 + 队列),输出长度 < n 即有环
  • Dijkstra 要求边权非负,用优先队列取当前最近点
  • 并查集维护连通性,路径压缩 + 按大小合并近 O(1)

复杂度速查

算法 / 操作时间空间说明
邻接表存储—O(n + m)遍历某点邻居耗时 O(deg)
邻接矩阵存储—O(n²)查任意两点是否相邻为 O(1)
DFS / BFS 遍历O(n + m)O(n)n 点 m 边,每点每边各访问一次
Kahn 拓扑排序O(n + m)O(n)入度表 + 队列,结果长度 == n 才无环
Dijkstra(二叉堆)O((n + m) log n)O(n + m)非负权;每次松弛可能入堆
并查集 find / unionO(α(n)) 摊还O(n)α 为反阿克曼函数,实际约等于 O(1)

模板代码

模板 1:邻接表构图 + DFS 遍历稀疏图的默认存储与最基础的遍历骨架。
python
def build_adj(n, edges):
    """邻接表:adj[u] 存 u 的所有邻居;无向边两个方向都要加。"""
    adj = [[] for _ in range(n)]
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)  # 有向图删掉这一行
    return adj

def dfs(adj, start):
    """迭代式 DFS:入栈即标记 visited,避免同一顶点重复入栈。"""
    visited = [False] * len(adj)
    order = []
    stack = [start]
    visited[start] = True
    while stack:
        u = stack.pop()
        order.append(u)
        for v in adj[u]:
            if not visited[v]:
                visited[v] = True  # 关键:发现即标记
                stack.append(v)
    return order

# 连通分量数 = 对所有未访问点各启动一次 dfs 的次数
模板 2:Kahn 拓扑排序(含判环)有向图上按依赖关系排序;输出序列长度小于 n 即说明存在环。返回语义三语言对照:Rust / Python 用 None 表示有环(无合法拓扑序),C++ 用空数组表示有环并在注释中约定「空返回 = 有环,调用方须结合节点数 n 判断」;因 n = 0 时「合法空拓扑序」与「有环返回值」在 C++ 中不可区分,务必按题意(节点数已知)判定。
python
from collections import deque

def topo_sort(n, adj):
    """Kahn 拓扑排序:返回拓扑序;长度 < n 说明图中存在环。
    adj[u] 存 u 指向的顶点(出边)。"""
    indeg = [0] * n
    for u in range(n):
        for v in adj[u]:
            indeg[v] += 1  # 统计入度
    q = deque(v for v in range(n) if indeg[v] == 0)
    order = []
    while q:
        u = q.popleft()
        order.append(u)
        for v in adj[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)  # 入度归零即可入队
    return order if len(order) == n else None  # None 表示有环
模板 3:Dijkstra 最短路(邻接表 + 优先队列)非负权图上的单源最短路;边权全为 1 时可退化为 BFS。
python
import heapq

def dijkstra(n, adj, src):
    """Dijkstra:返回从 src 出发到各点的最短距离(不可达为 inf)。
    adj[u] 存 (邻点, 边权);要求边权非负。"""
    INF = float('inf')
    dist = [INF] * n
    dist[src] = 0
    pq = [(0, src)]  # heapq 是天然小顶堆
    while pq:
        d, u = heapq.heappop(pq)
        if d > dist[u]:
            continue  # 过期条目,延迟删除
        for v, w in adj[u]:
            nd = d + w
            if nd < dist[v]:  # 松弛
                dist[v] = nd
                heapq.heappush(pq, (nd, v))
    return dist
模板 4:并查集(路径压缩 + 按大小合并)维护动态连通性;两个优化合用使单次操作摊还 O(α(n))。
python
class DSU:
    """并查集:路径压缩 + 按大小合并,摊还 O(α(n))。"""
    def __init__(self, n):
        self.parent = list(range(n))  # parent[i] = i
        self.size = [1] * n

    def find(self, x):
        # 迭代式 find:顺路路径减半,返回代表元(根)
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]  # 路径减半
            x = self.parent[x]
        return x

    def union(self, a, b):
        # 合并两集合;若本就在同一集合返回 False
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False
        if self.size[ra] < self.size[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra          # 小树接到大树上
        self.size[ra] += self.size[rb]
        return True

易错点

  • 重边与自环:邻接表允许同一邻居出现多次,求最短路通常无碍;但拓扑排序里若存在自环 u→u,其入度永远减不到 0,会被正确判为有环——编码前需按题意确认自环是否合法。
  • 有向 / 无向加边混淆:无向图必须加 u→v 与 v→u 两条边,漏加一条会漏连通、错算分量数;有向图多加了反向边则拓扑排序与最短路结果都会错。
  • Dijkstra 用于负权边:一旦存在负权,一个「已确定」的顶点可能被后续更短的绕行推翻,贪心前提失效,须改用 Bellman-Ford。
  • visited 标记时机:BFS 与迭代 DFS 应在入队 / 入栈时标记,若拖到出队 / 出栈时才标记,同一顶点会被重复压入,复杂度退化甚至结果错乱。
  • 并查集初始化与合并写法:初始化须令 parent[i] = i(每元素自成一集合,根即自身)、size[i] = 1;1-indexed 图要开 n + 1 空间,漏设 parent[i] = i(如按默认 0 值初始化)会让 find 在上溯时提前撞上错误的「自环」并返回错误的代表元,或把本不同集合的元素误判为同集合。此外递归 find 简洁但长链可能爆栈,迭代「路径减半」更稳;合并时若忘记按大小 / 秩合并,树会退化成链,find 最坏变 O(n)。
  • 拓扑排序判环依据:不能只看「队列空了」,应比较输出顶点数是否等于 n;少于 n 说明剩余顶点都卡在环上。
PART B · DEEP DIVE

🔍 深度解析

原理解释、复杂度推导、实现权衡与语言细节。适合系统学习与复习。

01

数组与字符串基础操作 · 深度解析

速查 ↑

原理与机制

数组的最底层模型是一段连续内存:元素类型相同、宽度固定,第 i 个元素的地址可由 base + i × sizeof(T) 直接算出。正是这种「地址可计算」的性质,让按下标访问 a[i] 成为 O(1)——代价只是一次乘法与一次偏移,与数组长度无关(Hello 算法 · 数组)。代价是普通数组长度不可变;动态数组(C++ std::vector、Rust Vec、Python list)在其上封装出容量(capacity)与长度(length)两个量:容量是已申请的内存能容纳多少元素,长度是当前实际元素个数,当长度超过容量时重新分配、把元素搬迁到更大的内存块(Rust std · Vec, cppreference · std::vector)。

多维数组在内存里同样是线性的:C 系语言采用行主序(row-major),a[i][j] 的地址为 base + (i × cols + j) × sizeof(T),因此顺序按行扫描比按列扫描更缓存友好。需注意这是 C 系语言的约定,Fortran / MATLAB 等采用列主序(column-major),二者不可混同。二维网格(grid)本质上就是一个二维数组,其「邻居」关系并不存储为边,而是用方向数组(一组行偏移、列偏移)在运行时算出来——把上下左右或含对角线的偏移放进数组,用一次循环即可枚举全部邻居,这正是 4/8 连通遍历的统一写法(OI Wiki · DFS 与洪水填充)。

字符串则是「字符序列 + 文本语义」的封装,但三语言落地差异极大。C++ std::string 是可变且内存连续的字符容器,data()/c_str() 可拿到底层指针,能与 C 风格 char[] 平滑互操作(cppreference · std::basic_string)。Rust 有 String(可增长、拥有所有权、保证 UTF-8)与 &str(UTF-8 字符串切片、借用)两种类型,二者都保证内容为合法 UTF-8(The Rust Book)。Python 的 str 是不可变的 Unicode 码点序列,任何看似「修改」的操作都返回新对象(Python docs · Built-in Types)。理解这一层的差异,是后面所有字符串技巧的前提。

推导与权衡

动态数组尾部追加的均摊 O(1) 来自摊还分析(amortized analysis)。设增长策略为「容量不足时按倍数 k > 1 扩容」:扩容发生在长度达到 1, k, k², … 时,第 j 次扩容需要拷贝约 k^{j-1} 个元素。n 次追加的总拷贝代价约为 Σ k^{i-1} < n·k/(k−1) = O(n),平摊到每次追加即 O(1)(Rust std · Vec)。Rust 官方文档在 Vec::push 的「Time complexity」小节逐字写明「Takes amortized *O*(1) time」,并紧接着指出「若 push 后长度超过容量,则需 *O*(capacity) 时间来把元素拷贝到更大的分配块」——即最坏单次代价为 O(capacity)(原文逐字为「*O*(*capacity*) time is taken to copy the vector's elements to a larger allocation」)。增长因子的选择是一种权衡:因子越大(如 2×)扩容次数越少,但最坏情况浪费的内存越多;主流 C++ 实现用 1.5×–2×,而 Rust 文档在「Guarantees」一节强调「无论采用何种策略都保证均摊 O(1) push」。若已知规模,用 with_capacity / reserve 预留空间可把扩容次数降到 0。

字符串可变性则是另一种根本权衡。不可变字符串(Python、Java)可以安全地被共享、缓存哈希、当作字典键,代价是「修改」必然复制——Python 文档明确警告「通过重复拼接来构建序列,其运行时成本呈二次方增长」,并给出线性方案:先收集到列表再 ''.join(),或写入 io.StringIO(Python docs)。可变字符串(C++/Rust)支持原地修改、内存更省,但把「何时会复制/失效」的责任交给程序员:C++ 需自管容量与迭代器失效,Rust 需在借用规则下小心 String 与 &str 的边界。选择哪一侧,取决于访问模式是「读多写少、需要共享」还是「频繁原地改写」。

变体与扩展

  • 环形缓冲 / 双端队列:VecDeque、std::deque、collections.deque 在数组之上支持两端 O(1) 增删,弥补普通数组头部操作 O(n) 的短板。
  • 前缀和 / 差分:对数组预处理一次 O(n),即可把区间求和、区间加降为 O(1),是数组类问题的核心加速器(详见第 2 章)。
  • 网格即图:二维网格可视为隐式图——每个格子是节点,邻居由方向数组现算而成,无需存储边,BFS/DFS 可直接套用。
  • 字符到下标的映射:把 'a'..'z' 映射到 0..25 用长度 26 的计数数组,是字符串计数类问题最省空间的惯用法。
  • 字符串匹配的进化:从朴素 O(n·m) 到 KMP / Z 函数 O(n+m) 再到字符串哈希,是同一问题在时间复杂度上的层层优化。

经典练习

  • LeetCode 27 · 移除元素(原地删除,快慢指针雏形)
  • LeetCode 26 · 删除有序数组中的重复项
  • LeetCode 344 · 反转字符串(原地双指针对撞)
  • LeetCode 151 · 反转字符串中的单词(分割 + 拼接 + 反转)
  • LeetCode 200 · 岛屿数量(二维网格 + 方向数组 + DFS/BFS)
  • LeetCode 73 · 矩阵置零(二维数组原地标记)

三语言对比与陷阱

语言关键差异与陷阱
Rust• s[i] 编译不过
• &s[i..j] 是字节切片,端点须落在 char 边界否则 panic。要区分拥有所有权的 String 与借用的 &str。Vec 越界索引会 panic,安全取值用 get() 返回 Option
• String 与 Vec<u8> 之间需显式转换。反转字符串须用 chars().rev(),不能对字节原地反转。
C++• std::string/std::vector 连续且可变
• operator[] 不做边界检查,at() 才抛异常。size() 返回无符号 size_t,与 int 混用会隐式转换引发下溢。push_back 触发扩容会让全部迭代器/引用失效。c_str() 返回的指针在字符串被修改后失效。std::string 反转按字节进行,仅对 ASCII 安全。
Python• str 不可变,s[i] 返回长度为 1 的 str(没有独立 char 类型)
• 循环内 += 拼接是 O(n²),应 ''.join()。[[0]*n]*m 只是共享同一行的 m 个引用。切片 a[::-1] 返回新列表而非原地反转,s[::-1] 亦返回新串。

延伸阅读

02

双指针、滑动窗口与前缀和 · 深度解析

速查 ↑

原理与机制

双指针之所以能把 O(n²) 降为 O(n),关键在单调性带来的「候选集裁剪」。以有序数组两数之和为例:设当前 nums[l] + nums[r] 偏小,那么对于固定的 r,任何 l' < l 只会得到更小的和,因此 l 右侧之外的下标组合都可一次性排除——每一步都确定性地「砍掉一整行候选」(OI Wiki · 双指针、USACO Guide · Two Pointers)。由于每个指针只单向移动、至多走 n 步,总操作数是线性的。

滑动窗口是双指针的连续区间特例,它的不变量是「窗口 [left, right] 始终满足(或不满足)某个可增量维护的条件」。之所以内层 while 不破坏线性复杂度,是因为摊还分析:left 只会单调右移、永不回退,整个循环中 left 累加的移动次数不超过 n,与 right 的 n 次扩张相加,总工作量仍是 O(n)——嵌套循环不等于 O(n²)(OI Wiki · 双指针)。前提是窗口信息(和、频次、不同元素数)能 O(1) 增量更新。

前缀和则走了另一条路:用 O(n) 预处理把「任意区间求和」这一包含 O(n) 个工作量的运算,转化成两个端点的减法。它成立的根本原因是加法的可结合性,使 ∑_{k=l}^{r} a_k = S_r - S_{l-1}(OI Wiki · 前缀和 & 差分)。差分是其逆运算:对序列的「区间内每项加 v」等价于在差分序列上只改 D_l += v、D_{r+1} -= v 两处,因为对差分序列求前缀和会把相邻项之差复原,从而得到更新后的序列。需要强调的是,差分序列本身只承载「增量标记」:以全零基线构造时,其前缀和是各位置的累计增量(对应原数组全零的场景,如 LeetCode 1109),须叠加到原数组才等价于「原数组经区间加的结果」;只有由原数组构造差分(如模板 4 的 from_slice),其前缀和才直接是更新后的原数组。

推导与权衡

滑动窗口的摊还复杂度可形式化:记第 i 轮扩张使 right 前进 1,收缩使 left 前进若干,全部轮次中 right 总计前进 n,left 总计前进 n,故总步数 ≤ 2n,即 O(n)。这一结论只对「left 单调不回退」的写法成立;若窗口条件不具单调性(例如目标约束无法用增量维护),则退回暴力枚举。因此模板选择的第一判据是:窗口信息的增删是否为 O(1)。

前缀和与滑窗互为替代:当数组含负数时,「和随右端单调不减」的前提被破坏,滑窗失效,此时应改用「前缀和 + 哈希表」统计满足 pre[j+1] - pre[i] = k 的方案(OI Wiki · 前缀和 & 差分)。差分与线段树/树状数组也存在权衡:若修改与查询交错在线进行,一次前缀和还原不够,需要树状数组等支持动态区间加与查询的结构;差分只在「先批量改、后统一查」的离线场景下最优。前缀和的代价是 O(n) 额外空间,在内存敏感或仅需单次查询时不划算。

变体与扩展

  • 三数之和 / 多数之和:排序后固定一个(或数个)外层元素,对剩余区间用对撞双指针,把 O(n³) 降到 O(n²)。
  • 「至多 K」技巧:恰好 K = 至多 K − 至多 (K−1),把难维护的「恰好」约束拆成两个可单调滑窗的「至多」问题。
  • 定长窗口 + 计数结构:固定宽度窗口配合频次表(如异位词、固定长度子串统计),或用切片 windows(k) / 循环直接维护。
  • 二维前缀和与子矩阵:由容斥递推 S[i][j] = A[i][j] + S[i-1][j] + S[i][j-1] - S[i-1][j-1];其「每维只取 0/1」的特例即高维前缀和(又称 SOS DP,子集和 DP),与本章主题关联较弱,此处仅作指认(OI Wiki · 前缀和 & 差分)。
  • 差分 + 前缀和组合:区间批量加后用一次前缀和还原,是「差分」最典型的用法;树上前缀和/差分把它推广到树的路径与子树(本章从略)。

经典练习

  • LeetCode 167 · 两数之和 II - 输入有序数组(对撞双指针)
  • LeetCode 26 · 删除有序数组中的重复项(快慢指针)
  • LeetCode 209 · 长度最小的子数组(不定长滑窗,收缩求最短)
  • LeetCode 3 · 无重复字符的最长子串(不定长滑窗 + 频次)
  • LeetCode 560 · 和为 K 的子数组(前缀和 + 哈希表)
  • LeetCode 1109 · 航班预订统计(差分数组区间加)

三语言对比与陷阱

语言关键差异与陷阱
Rust• 不可变借用可并存,&nums[l] 与 &nums[r] 能同时读取,无需复制
• 切片 .windows(k) 天然生成定长窗口
• HashMap::entry + or_insert 一次定位即可完成计数(Rust std · HashMap)
• usize 做减法会下溢,r = len-1 应先 saturating_sub,窗口收缩时注意 left <= right
• 无返回值时用 Option / 空集合表达「未找到」。
C++• vector::size() 返回无符号 size_t,与 int 混用会有隐式转换陷阱,建议显式 (int) 转换
• 累加量用 long long 防溢出
• unordered_map 平均 O(1) 但不保证遍历顺序,需有序时用 map
• std::partial_sum 可一行生成前缀和(cppreference · std::partial_sum)
• 「未找到」常以空 vector / 迭代器 end() 表达,须在调用侧判空。
Python• 整数无溢出,无需担心 long long
• itertools.accumulate(a, initial=0) 一行得到前缀和(含空前缀)(Python docs · itertools.accumulate)
• dict / defaultdict(int) 天然做计数
• 注意 str 不可变、切片生成新对象,循环内避免大量字符串拼接
• 「未找到」用 None 表达,注意与合法返回值 0 / 空列表区分。

延伸阅读

03

链表操作与快慢指针 · 深度解析

速查 ↑

原理与机制

链表的本质是用指针把离散分配的节点串成逻辑序列:每个节点除了数据域,还持有指向后继(双向链表再加指向前驱)的指针。它牺牲了数组的连续存储与随机访问,换取「已知前驱时以 O(1) 改指针完成增删」这一特性 (OI Wiki · 链表)。

虚拟头节点(dummy) 是关键不变量:在真实头节点前接一个哨兵节点后,每个真实节点都必有前驱,于是「删除 / 插入首节点」不再需要单独特判,所有位置的处理逻辑统一为「改前驱的 next」。这也是合并、删除倒数第 N 个节点等模板能写得简洁的原因。

快慢指针 的机制是固定步长差:fast 每次走两步、slow 每次走一步,相对速度恒为 1。若链表无环,fast 会先抵达 null;若有环,则相当于快指针在有限跑道内追及慢指针,必然相遇。相遇点并不等于环入口。设 head 到环入口距离为 a,入口到相遇点距离为 b,环长为 L = b + c(c 为相遇点沿环回到入口的距离)。相遇时 slow 走了 a+b,fast 走了 a+b+kL(k 为多绕的整圈数),由 fast 速度是 slow 两倍得 2(a+b) = a+b+kL,即 a = kL − b = (k−1)L + c;取 k=1 得 a = c——head 到入口的距离等于相遇点到入口的距离。故将 slow 重置到 head、两指针同步单步前进,必在环入口相遇 (OI Wiki · 链表, OI Wiki · 链表(快慢指针判环))。

推导与权衡

复杂度推导:顺序访问 O(n) 源于必须沿指针逐跳;插入 / 删除 O(1) 的前提是已持有前驱指针——若需先查找目标,则整体退化为 O(n)。Floyd 判环为 O(n):相遇发生在 fast 进环后 O(L) 步内(L 为环长,进环后二者相对速度为 1),慢指针在进环前至多走 O(a),故整体遍历 O(n);指针只前进不回退,空间仅两个指针,故时空为 O(n)/O(1)。

链表 vs 动态数组:链表单次增删省去搬移,但节点分散在堆上,遍历时指针 chasing 造成大量 cache miss;每节点还要额外存储指针、付出分配器开销。因此工程实践中,动态数组常常反超链表——这正是 Rust 官方在其 LinkedList 文档中直接给出的立场:「几乎总是更好用 Vec 或 VecDeque,因为基于数组的容器通常更快、更省内存,并且能更好地利用 CPU 缓存」 (Rust std · LinkedList, Rust std::collections)。与之呼应,Python 也用「引用计数 + 循环 GC」的对象模型承担链表语义,并惯例性建议用 list / deque 替代手写链表。

何时才该用链表:需要频繁在任意已知位置 O(1) 插入 / 删除;需要 O(1) 的 splice / merge 拼接;元素很大且不愿搬移;或需要节点地址长期稳定的场景(侵入式链表、内核对象、LRU 缓存、侵入式双向环)。反之,随机访问密集、纯遍历、内存受限时应选动态数组。

变体与扩展

  • 结构变体:循环链表、带头节点链表、静态链表(用数组模拟指针,规避堆分配);双向链表在每个节点上多一个 prev 指针,插入 / 删除须按固定顺序更正指针——插入先接新节点两侧、再改邻居(node.prev = p; node.next = p.next; 然后 p.next.prev = node; p.next = node;),删除先互连前后、再释放(p.prev.next = p.next; p.next.prev = p.prev; 再释放 p)。顺序颠倒会丢链或留下悬垂指针;另需注意,若 p.next 为空(尾插 / 尾删场景)需特判。
  • 快慢指针家族:求中点、判断回文链表、重排链表、删除倒数第 N 个(快指针先走 N 步形成固定间距)。
  • 反转家族:区间反转、k 个一组反转、两两交换节点。
  • 合并家族:合并 k 个有序链表(配合堆)、链表归并排序;dummy head 在归并中同样用于统一首节点处理。
  • Rust 惯用替代:用 Vec / arena(数组下标模拟指针)绕开 Rc<RefCell<..>> 与借用检查器约束;队列型需求用 VecDeque (Rust std::collections)。

经典练习

  • 反转入门:LeetCode 206 · 反转链表
  • 判环与找环入口:LeetCode 141 · 环形链表、LeetCode 142 · 环形链表 II
  • 求中点:LeetCode 876 · 链表的中间结点
  • 合并有序链表:LeetCode 21 · 合并两个有序链表
  • dummy 删除:LeetCode 19 · 删除链表的倒数第 N 个结点
  • 组合技巧:LeetCode 143 · 重排链表(中点 + 反转 + 合并)

三语言对比与陷阱

语言关键差异与陷阱
Rust• 单链表惯用 Option<Box<ListNode>>,所有权清晰、无 GC
• 但无法用 Box 构成环,带环 / 双向需 Rc<RefCell<..>>,且 Rc 双向环的引用计数永不为 0 会内存泄漏,须用 Weak 破环 (The Rust Book · Reference Cycles)
• 借用检查器禁止同时持有 &mut 与 &,故「一趟快慢删除」难写,惯用两遍 / 递归绕过
• 官方建议多数场景用 Vec / VecDeque。
C++• 裸指针手写 struct ListNode 最灵活
• std::list 是双向链表,插入与 splice 不使迭代器失效、仅被删元素失效,与 std::vector 扩容时「全部失效」相反 (cppreference · std::list, cppreference · list::erase)
• erase 自 C++11 起返回下一个有效迭代器,可用于循环中安全删除。
Python• 用对象引用(self.next)实现,判「同一节点」应用 is 而非 ==
• 双向环属循环引用,引用计数无法回收,需依赖 gc 模块的循环垃圾回收器 (Python docs · gc)
• 工程上多用 list / collections.deque 替代手写链表。

延伸阅读

04

栈、队列与哈希表 · 深度解析

速查 ↑

原理与机制

栈与队列都是受限的线性表:限制访问位置,换来更强的结构不变量。栈只允许在表尾(栈顶)插入与删除,于是「当前容器内最后进来的最先出去」,即 LIFO(后进先出)(OI Wiki · 栈);队列一端入、另一端出,元素按到达顺序被处理,即 FIFO(先进先出)。这种「最近优先」与「最早优先」的语义分野,决定了它们各自的主场:栈适合「最近未处理完」的问题(括号匹配、递归改写为迭代、表达式求值中的运算符栈、单调栈),队列适合「按到达顺序公平推进」的任务(BFS 逐层扩展、缓冲区与调度)。双端队列(deque)是二者的超集,两端都能 O(1) 增删,常以分段连续缓冲实现,从而避开单块内存整体扩容的开销 (cppreference · std::deque)。

哈希表的核心是「把任意键压成一个整数,再映射到桶(bucket)数组的下标」,让「按键查找」脱离线性比较 (OI Wiki · 哈希表)。流程分三步:①哈希函数把键转成散列值(如 key % M、对字符串用多项式滚动哈希);②取模映射到桶下标;③冲突处理,即不同键落到同一桶时的应对。主流策略有两类:

- 链地址法(拉链法 / separate chaining):每个桶挂一条链表(或树)存所有落在此处的键值对,查找时先定位桶再在链内比对。删除简单、对高负载因子较鲁棒。 - 开放寻址法(open addressing):冲突时按探测序列(线性探测、二次探测、双重哈希)在数组里另找空位;缓存友好、无指针开销,但删除通常需要墓碑标记,且负载因子偏高时性能陡降。

哈希表维护负载因子 α = 元素数 / 桶数,一旦超过阈值(如 0.75)就扩容(rehash):申请更大的桶数组,把全部元素重新插入。这一「用空间换时间 + 定期重建」的设计,是「单次可能很慢、均摊很快」的典型来源。

推导与权衡

为什么期望 O(1)? 在「简单均匀哈希」假设下——哈希函数把每个键近似等概率、独立地撒进 m 个桶——n 个元素时每个桶的期望长度 λ = n/m。查找需先 O(1) 定位桶,再在桶内做约 λ 次比较,总代价 ≈ O(1 + λ)。只要令桶数与元素数成正比,使负载因子有上界(如 m ≥ 4n/3,则 λ ≤ 3/4),λ 就是常数,于是期望查找为 O(1)。关键在「期望」二字:这是平均意义下的结论,而非最坏保证。若所有键恰好落入同一桶(哈希退化或遭刻意构造),桶内退化成一条长链,查找即为 O(n)——这正是 C++ unordered_map 文档标注「平均常数、最坏线性」的原因 (cppreference · std::unordered_map)。

为什么扩容是摊还 O(1)? 设从容量 c₀ 起步、每次翻倍。一次插入本身 O(1),但触发扩容时要重建并重插当前全部元素,单次代价 O(当前元素数)。把连续 n 次插入触发的扩容代价累加:c₀ + 2c₀ + 4c₀ + … < 2·c_final = O(n),是几何级数——总量与元素数同阶,均摊到每一次插入仍是 O(1)。所以「偶尔一次 O(n)、长期看来 O(1)」并不矛盾。

如何权衡? 其一,哈希表 vs 平衡树:哈希表平均更快,但迭代顺序不可控、不支持有序遍历与范围查询、最坏退化到 O(n);平衡树(map/set、Rust BTreeMap)稳定 O(log n) 且天然有序。要做「按序输出」「找前驱后继」就必须用树。其二,链地址法 vs 开放寻址:前者在高负载因子下更稳、删除方便,后者缓存局部性好、无额外指针,但删除复杂、对负载因子更敏感。选型本质是「时间—空间」与「确定性—期望」两组权衡的取舍。

变体与扩展

  • 单调栈:保持栈内元素单调,用于「下一个更大/更小元素」、柱状图最大矩形等区间决策问题。
  • 单调队列:用双端队列维护滑动窗口最值,与第 2 章滑动窗口配套。
  • 前缀和 + 哈希表:把「子数组和等于 K」转成「前缀和查表」,是哈希表与第 2 章前缀和的组合技。
  • 频次统计 → Top-K:先哈希计数,再交给堆或桶排序取前 K(与第 5 章堆衔接)。
  • LRU 缓存:哈希表 + 双向链表,get/put 均为 O(1),体现「哈希定位 + 链表维护顺序」的组合。
  • 有序替代:需要有序时用 BTreeMap/std::map/sortedcontainers。

经典练习

  • LeetCode 20 · 有效的括号(栈:模板 1 的直接应用)
  • LeetCode 1 · 两数之和(哈希:模板 3,一次遍历)
  • LeetCode 49 · 字母异位词分组(哈希:以排序串或计数元组作键)
  • LeetCode 347 · 前 K 个高频元素(频次统计 + 堆/桶)
  • LeetCode 155 · 最小栈(辅助栈维护当前最小值)
  • LeetCode 232 / 225 · 用栈实现队列 / 用队列实现栈(理解两类语义差异)

三语言对比与陷阱

语言关键差异与陷阱
Rust• 栈直接用 Vec(push/pop)
• 队列用 VecDeque
• HashMap 默认使用抗哈希洪泛的随机哈希器,故迭代顺序每次运行可能不同
• 计数惯用 map.entry(k).or_insert(0),注意键会被移动
• 无有序哈希表,有序需求改用 BTreeMap。
C++• stack/queue 是容器适配器(默认底层 deque),并非独立容器
• unordered_map 平均 O(1)、最坏 O(n),迭代顺序无保证,rehash 使迭代器失效
• 有序需求用 map/set(红黑树,O(log n))
• deque 两端 O(1) 但中间插入 O(n)。
Python• dict/set 内建哈希
• dict 自 3.7 起保证插入顺序(语言规范级),但语义上仍是映射
• 可变对象(list/dict/set)不可作键,否则抛 TypeError
• 队列用 collections.deque,切勿用 list.pop(0)
• Counter/defaultdict 可简化计数。

延伸阅读

05

堆与优先队列 · 深度解析

速查 ↑

原理与机制

二叉堆本质是「完全二叉树 + 堆序性质」的组合。完全二叉树指除最后一层外每层都填满、且最后一层结点靠左连续排列的二叉树。正因为「连续、无空洞」,它可以被紧凑地存进一个数组:按从上到下、从左到右的层序编号,结点之间无需任何指针,父子关系可直接由下标算术推出。以 0 基数组为例,下标 i 的左孩子是 2i+1、右孩子是 2i+2、父结点是 ⌊(i-1)/2⌋(OI Wiki · 二叉堆)。若改用 1 基(不少教材与 OI Wiki 的写法),则是左孩子 2i、右孩子 2i+1、父结点 ⌊i/2⌋,算术更简洁;Python 的 heapq 采用 0 基,并明确按 heap[k] <= heap[2k+1]、heap[k] <= heap[2k+2] 维护最小堆(Python · heapq)。

堆序性质是弱序:大顶堆要求每个父结点 ≥ 两个孩子,小顶堆反之;但兄弟之间、跨层结点之间没有任何约束。因此堆只保证一件事——根结点是全局最值,其余元素仅满足「局部父子有序」。完全二叉树的高度为 ⌊log₂ n⌋,这正是所有 O(log n) 操作的来源。

维护堆序靠两个基本动作(OI Wiki · 二叉堆):上浮(sift-up / percolate-up)用于插入——把新元素放到数组末尾,再不断与父结点比较、交换直到不再违反堆序;下沉(sift-down / percolate-down)用于删除堆顶——把末尾元素补到根,再不断与两个孩子中「更优」的那个交换直到到底。二者每轮都让元素上移 / 下移一层,故最多 O(log n) 轮。

推导与权衡

为什么建堆能是 O(n),而不是逐个插入的 O(n log n)? 关键在于「自底向上 + 下沉」的代价分布。把 n 个元素先按任意顺序放进完全二叉树,随后从最后一个非叶结点起,逐层向前对每个结点做一次下沉(OI Wiki · 二叉堆)。观察一个事实:高度为 h 的结点最多下沉 h 层,而高度为 h 的结点约有 ⌈n / 2^(h+1)⌉ 个——接近一半的结点是叶子(h=0,无需下沉),往上每加一层结点数就减半。于是总代价为

Σ_{h=0}^{⌊log₂ n⌋} ⌈n / 2^(h+1)⌉ · h = n · Σ_h h / 2^(h+1) = O(n),

因为级数 Σ h / 2^h 收敛到常数 2(UW CSE 332 · Priority Queues and Heaps)。换句话说,「下沉」的总工作量由接近底层的海量浅结点主导,而接近顶层的少数深结点贡献的是几何衰减的小量,整体线性。相反,逐个插入走的是「上浮」,元素越靠底层越深、代价越大,累加得 Θ(n log n)。

权衡:堆 vs 排序数组。 堆支持 O(1) 取最值、O(log n) 插入与删除,但不支持高效地查找任意元素或做范围查询(只能 O(n) 扫描)。排序数组支持 O(1) 取一端最值、O(log n) 二分查找任意值,但插入要 O(n) 搬移。判据是:若算法只是反复「取当前最值」且候选集动态变化(Top-K、优先队列、Dijkstra、任务调度),用堆;若需要频繁查找 / 范围查询 / 有序遍历,则用平衡 BST(如 Rust BTreeMap、C++ std::map,全操作 O(log n))。

堆排序(简述):先 O(n) 建大顶堆,再反复把堆顶(当前最大)与末尾元素交换、堆规模减一并对新堆顶下沉,最终得到升序序列;整体 O(n log n)、就地、但不稳定。

变体与扩展

  • d 叉堆(d-ary heap):每个结点有 d 个孩子,树更浅,常配合需要 decrease-key 的场景以摊薄下沉代价。
  • 索引堆 / 带位置映射的堆:额外维护「元素 → 堆内下标」映射,从而支持 decrease-key(Dijkstra 常用),代价是更高的实现复杂度。
  • 对顶堆(双堆技巧):一个大顶堆 + 一个小顶堆,用于「动态第 k 大 / 数据流中位数」,插入 O(log n)、查询 O(1)(OI Wiki · 二叉堆)。
  • k 路归并(多路合并):用一个小顶堆维护 k 条有序序列各自的当前最小元素,反复「弹出堆顶 → 续接到结果尾部 → 推入该序列的下一个元素」,即可把 k 路合并为一路,代价 O(N log k)(见模板 4)。
  • 元组打破平局:元素存为 (priority, seq, item),用单调序号在优先级相同时比较,既保证稳定顺序,又避免不可比对象直接比较而报错(Python · heapq)。
  • 其他堆族:左偏树、斐波那契堆等提供更好的摊还合并 / 插入复杂度,但工程中因常数与实现成本常被普通二叉堆取代。

经典练习

  • LeetCode 215 · 数组中的第 K 个最大元素(堆或快速选择)
  • LeetCode 347 · 前 K 个高频元素(哈希计数 + 堆)
  • LeetCode 23 · 合并 K 个升序链表(小顶堆做 k 路归并,见模板 4)
  • LeetCode 295 · 数据流的中位数(对顶堆)
  • LeetCode 703 · 数据流中的第 K 大元素(维持大小为 k 的堆)
  • LeetCode 1046 · 最后一块石头的重量(堆模拟)

三语言对比与陷阱

语言关键差异与陷阱
Rust• 默认 最大堆(Rust std · BinaryHeap)
• 最小堆用 BinaryHeap<Reverse<T>> 或自定义 Ord。BinaryHeap::new() 得到空的最大堆,没有 vec![...] 等价宏——要堆化已知集合用 BinaryHeap::from(vec) / .collect()(O(n)),而非逐个 push(O(n log n))。push 的最坏代价是 O(log n),但官方文档指出其期望代价为 O(1),只有靠近升序的插入才会摊还退化到 O(log n)。堆内元素被改序属逻辑错误
• 改堆顶用 peek_mut()(改后 O(log n) 下沉)。迭代顺序任意,要升序用 into_sorted_vec()。
C++• priority_queue<T> 默认 大顶堆(比较器 less)
• 小顶堆需 priority_queue<T, vector<T>, greater<T>>(cppreference · priority_queue)。比较器 comp(a,b) 的语义与 std::sort 一致(为真表示 a 在弱序中排在 b 之前),但 priority_queue 先弹出弱序中"最大 / 最后"的元素,故小顶堆需传 greater——把「比较器语义」与「出队顺序」分开看,切勿写反导致 top() 取错。适配器不提供迭代器,无法遍历或删除中间元素。就地建堆用 std::make_heap(最多 3N 次比较,O(N)),堆排序用 sort_heap(cppreference · make_heap)。
Python• heapq 只提供 最小堆
• 最大堆取负存储,或 3.14 起使用 heapify_max / heappush_max / heappop_max 等 _max 函数(Python · heapq)。无 decrease-key / 任意删除,用惰性删除。堆元素可为元组,按字典序比较,是「优先级 + 计数」防比较失败的常见技巧。nlargest / nsmallest 在 n 较小或 k 较小时更高效。

延伸阅读

06

二叉树遍历与递归思维 · 深度解析

速查 ↑

原理与机制

为什么同一份框架能产生三种遍历? 递归遍历的骨架是固定的:

节点被访问的顺序其实固定不变(总是先左子树、后右子树,即一条绕树外沿移动的指针)。前/中/后序的差别,只在于你把业务代码插在了哪个位置——也就是在哪个「时间点」处理当前节点 (labuladong · 二叉树的递归/层序遍历)。

- 前序位置(刚进入节点):此刻对子树一无所知,只能拿到父节点通过函数参数传下来的信息,天然对应「自顶向下」的状态传递。 - 中序位置(左子树处理完、右子树未开始):对 BST 而言,此刻序列天然有序,这是「中序有序性」的来源 (OI Wiki · 二叉搜索树 & 平衡树, Hello 算法 · 二叉树遍历)。 - 后序位置(即将离开节点):左右子树都已处理完,可以通过函数返回值拿到完整的子问题结果,天然对应「自底向上」的合并 (labuladong · 二叉树算法思想(纲领篇))。

与「做选择 / 撤销选择」的对应:回溯算法本质就是在遍历一棵决策树。进入节点(前序)时把当前选择压入路径,离开节点(后序)时把它弹出——于是 backtrack { path.push(选择); backtrack(...); path.pop(); } 中的 push/pop,正是前序位置与后序位置的操作。与「子问题合并」的对应:分治与动态规划把问题拆成左右两个子问题,先递归取回答案(发生在后序位置之前),再在后序位置把两个答案合并成原问题答案。最大深度、节点计数、判断平衡、树直径都遵循这个模式。经典排序也印证了这一点:快速排序是「先分区、再递归两半」,可视作前序遍历;归并排序是「先递归两半、再合并」,可视作后序遍历 (labuladong · 二叉树算法思想(纲领篇))。

推导与权衡

复杂度推导:每个节点恰好被访问一次,故时间恒为 O(n)。空间是递归调用栈的深度,等于树高 h:平衡树为 O(log n),退化为链时 O(n)。迭代法把系统栈换成显式容器,空间同为 O(h),但能规避语言栈溢出(Python 的 RecursionError、C++/Rust 的栈溢出),代价是代码更繁琐 (Hello 算法 · 二叉树遍历)。层序遍历的空间由最大层宽 w 决定,满二叉树约 n/2,故最坏 O(n)。

遍历思维 vs 分解思维的取舍:遍历思维写 void traverse(...) 配合外部变量累积答案,对应回溯/DFS 框架;分解思维写一个有返回值的 solve(root),用子问题的答案推导原问题,对应分治/动态规划。二者不是题型分类,而是同一道题的两条实现路径 (labuladong · 二叉树算法思想(纲领篇))。取舍判据是「当前节点需要的信息来自哪里」:若来自父节点(如自顶向下的路径、边界约束),前序 + 遍历思维更自然;若来自子节点(如子树高度、子树和),后序 + 分解思维更自然。工程上,分解思维没有共享可变状态,更不容易出错,是需要子树信息时的首选。

BST 验证的两种权衡:范围传递法显式携带 (lo, hi) 上下界,走到左子树收紧上界、走到右子树收紧下界,逻辑直观、失败点可定位 (GeeksforGeeks · Tree Traversals);中序法依赖「严格递增」性质,代码更短,但 prev 状态在跳跃式写法中容易维护错误。两者都是 O(n) 时间、O(h) 空间;若需 O(1) 额外空间,还可用 Morris 遍历临时改写树指针。值得强调:两种方法之所以可靠,正是因为它们都捕捉了祖先约束(范围法显式传递上下界,中序法用全序把祖先约束摊平成相邻比较),而只比较直接父子关系的 naive 写法必然漏判(反例见 A5 第 3 条)。

变体与扩展

  • 迭代实现:前序用单栈(先压右、再压左);中序用「一路向左压栈、弹栈后转右」(见模板 5);后序用双栈(前序变体后反转)或单栈加 lastVisited 指针。
  • 由遍历序列重建树:前序 + 中序、中序 + 后序可唯一确定一棵二叉树——前序/后序提供根,中序划分左右子树;前序 + 后序不唯一。配合哈希表定位中序下标,可做到 O(n)。
  • 层序变体:分层返回、自底向上、锯齿形(Zigzag)、右视图(每层最右节点),均基于同一 BFS 骨架。
  • Morris 遍历:借叶子空指针临时搭「桥」,中序做到 O(1) 额外空间,代价是遍历期间临时修改树结构。
  • 推广到 N 叉树:前序、后序、层序都能推广,但「中序」对多叉树没有统一定义。

经典练习

  • 三种基础遍历:LeetCode 144 · 二叉树的前序遍历、LeetCode 94 · 二叉树的中序遍历、LeetCode 145 · 二叉树的后序遍历
  • 层序遍历:LeetCode 102 · 二叉树的层序遍历
  • 分解思维入门:LeetCode 104 · 二叉树的最大深度、LeetCode 226 · 翻转二叉树
  • BST 性质:LeetCode 98 · 验证二叉搜索树、LeetCode 230 · 二叉搜索树中第 K 小的元素
  • 综合提升:LeetCode 543 · 二叉树的直径、LeetCode 101 · 对称二叉树

三语言对比与陷阱

语言关键差异与陷阱
Rust• 节点用 Option<Rc<RefCell<TreeNode>>>
• 访问子节点须 borrow()/borrow_mut(),同一节点两次可变借用会 panic
• 无尾调用优化,深树递归有爆栈风险,可用数组下标模拟堆式树规避 (Rust · std::rc::Rc, Rust · std::cell::RefCell)
C++• 指针树需 struct TreeNode 与手动内存管理
• 迭代遍历用 std::stack / std::queue 适配器 (cppreference · std::stack, cppreference · std::queue)
• 递归 lambda 需 std::function,有额外调用开销
Python• 默认递归上限约 1000 帧,链状树抛 RecursionError,可 sys.setrecursionlimit(谨慎)或改迭代 (Python · sys.setrecursionlimit)
• 中序法维护 prev 需 nonlocal
• 无尾递归优化

延伸阅读

07

排序与二分搜索 · 深度解析

速查 ↑

原理与机制

快速排序的核心是 partition(划分)的不变量。以区间首元素为基准 pivot,用双指针 i、j 从两端向中间夹逼:j 停在第一个「小于 pivot」的元素,i 停在第一个「大于 pivot」的元素,交换二者使小的靠左、大的靠右。循环不变量是:任意时刻 [lo, i) 内的元素均 ≤ pivot,(j, hi] 内的元素均 ≥ pivot;当 i 与 j 相遇,把基准换到相遇处,此时「左段 ≤ pivot ≤ 右段」且基准落到最终位置。因为每轮至少固定一个基准、两个子区间都严格变小,递归必然终止;平均每次把区间大致对半划分,递归深度为 O(log n),总代价 O(n log n)。归并排序则是标准分治:把序列对半切、递归排好两半,再线性合并;合并时用 <= 让左段元素优先出列,从而保证相等元素的相对次序不变(稳定)。

二分搜索可以用 「谓词区间排除」的统一视角 理解:把「找某个元素」抽象为「找第一个使谓词 P 为真的位置」。只要 P 在序列上呈「前假后真」的单调形态,就能二分——每轮取中点 mid 判定 P(mid),据其真假丢弃一半候选区间。查找具体值、查找左/右边界,乃至「二分答案」,都只是 P 的不同定义而已。折半之所以是对数:区间长度从 n 收缩到约 n/2,k 轮后长度 ≤ n/2^k,令其小于 1 得 k ≈ log₂n;这要求每次判定为 O(1)(或已知代价),否则总代价要乘以判定开销。

推导与权衡

为什么工程库都用混合策略? 纯快排在「已排序」「大量重复元素」等输入上会退化为 O(n²),而真实数据往往带有这些模式。C++ std::sort 采用内省排序(Introsort):以快排为主,当递归深度超过阈值时切换为堆排序以保证 O(n log n) 最坏,小区间改用插入排序减少常数 (cppreference · std::sort)。Rust 的 sort 当前基于 driftsort(融合快排的平均快与归并的最坏保证及部分有序检测,稳定),sort_unstable 则基于模式击败(pattern-defeating)的自适应快排变形(Rust 1.81 起实现为 ipnsort),后者不稳定但通常更快 (Rust std · slice;Rust 1.81.0 发布公告)。Python 使用 Timsort(归并+插入),对部分有序数据接近线性且稳定 (Python · Sorting HOWTO)。这组事实说明:不存在单一最优算法,工程库的做法是在「平均性能、最坏保证、稳定性、额外空间」之间取舍——需要稳定就用归并系,需要原地就用堆排或快排系。

二分答案何时优于直接求解? 当「判定某候选解是否可行」能在 O(n)(或 O(n log n))内完成、且可行性关于答案单调时,可以放弃直接构造最优解,改为在答案值域 [lo, hi] 上二分枚举:做 O(log V) 次判定,总代价为「判定代价 × log V」(对应模板 3)。它把「求最值」转化为「判可行」,常比设计直接算法简单得多(典型如「爱吃香蕉的珂珂」)。代价与限制:必须存在可快速验证的单调谓词;若谓词不单调或单次判定过慢,则不如直接求解。相比哈希查找 O(1),二分虽慢但省内存,且天然支持「最近邻 / 边界」类查询。

变体与扩展

  • 计数排序:值域小([0, k))时用数组统计频次、再对频次做前缀和以确定各元素位置,达到 O(n + k) 且稳定(实现见模板 6);仅适用于可作下标的整数键,且 k 不能过大,当 k = O(n) 时即为线性。
  • 二分答案 / 最小化最大值:把优化问题转成可行性判定,在答案值域上二分(如「爱吃香蕉的珂珂」、「分割数组的最大值」,模板见模板 3)。
  • lower_bound / upper_bound / equal_range:用一对边界得到「值域区间」,常配合计数与去重使用。
  • 旋转有序数组查找:先判断 [l, mid] 与 [mid, r] 哪半边有序,再据目标是否落在有序半边决定收缩方向。
  • 三路划分(荷兰国旗问题):把序列分成 <、=、> 三段,在大量重复元素时避免退化,是工程快排处理等值键的关键技巧。

经典练习

  • 标准二分查找:LeetCode 704 · 二分查找
  • 查找左右边界:LeetCode 34 · 在排序数组中查找元素的第一个和最后一个位置
  • 旋转有序数组:LeetCode 33 · 搜索旋转排序数组
  • 二分答案:LeetCode 875 · 爱吃香蕉的珂珂
  • 手写排序:LeetCode 912 · 排序数组
  • 归并应用:LeetCode 88 · 合并两个有序数组

三语言对比与陷阱

语言关键差异与陷阱
Rust• sort 稳定、sort_unstable 不稳定但通常更快,且不额外分配
• usize 做减法易下溢,边界计算建议用 i64 或先判空
• 标准库二分用 slice::binary_search(返回 Result)与 partition_point(返回首个谓词为假的下标)。
C++• std::sort 为内省排序,要求随机访问迭代器且比较器满足严格弱序,否则未定义行为
• 二分用 std::lower_bound/upper_bound,返回的是迭代器,配 distance 得下标
• std::stable_sort 提供稳定排序但可能额外分配。
Python• sorted 返回新列表、list.sort 原地排序、均稳定
• 优先用 key=(每元素只调用一次),避免 functools.cmp_to_key(较慢)
• 二分用 bisect_left/bisect_right,bisect.insort 的查找为 O(log n) 但插入为 O(n)。

延伸阅读

08

回溯与深度广度优先搜索 · 深度解析

速查 ↑

原理与机制

回溯 = 决策树 DFS + 状态恢复。 任何回溯问题都能建模成一棵解空间树(决策树):根节点是初始状态,每条边代表"做一个选择",从根到某节点的路径代表一个部分解,叶子节点是一个完整解 (Hello 算法 · 回溯算法)。回溯算法沿这棵树做深度优先遍历:在当前节点枚举所有合法选择,选中一个就"往下走"一层,到达结束条件时记录解;若某条分支走不通或已枚举完,就"退回来"撤销刚才的选择,换下一个分支 (OI Wiki · 回溯法)。

这就决定了回溯的骨架是固定的三步:做选择 → 递归进入下一层 → 撤销选择。之所以强调"撤销",是因为 path、used 等状态在整棵树上被共享复用——只有把状态恢复成进入本层之前的样子,兄弟分支才不会互相污染。这也解释了为什么回溯本质上就是"DFS + 显式的状态回滚"。

BFS 与最短路的等价性。 BFS 从源点出发,按"到源点的距离"由近及远逐层扩展:先访问距离为 0 的源点,再访问距离为 1 的全部邻居,再访问距离为 2……由于队列先进先出,节点出队顺序的距离单调不减,因此每个节点第一次被访问时,所走的路径必定是边数最少的路径 (OI Wiki · BFS)。这正是"无权图最短步数用 BFS"的理论依据 (OI Wiki · BFS)。类比"火焰扩散":每一轮火圈向外扩张一格,被点燃的节点距源点的距离恰好等于轮次。DFS 则不具备该性质——它沿一条路走到底再回溯,找到的路径不保证最短 (OI Wiki · DFS);其递归框架、遍历顺序与 O(n+m) 复杂度另可参考 (OI Wiki · 深度优先搜索)。

推导与权衡

DFS 与 BFS 的空间与适用性。 两者时间复杂度同为 O(V+E)(网格为 O(R·C)),差异主要在空间利用形态与能否求最短路:

- DFS 的空间由递归深度决定,最坏 O(V)(空间复杂度含栈空间)。它适合回答"是否存在路径""有几个连通块""枚举所有解""判环 / 拓扑排序"这类问题。递归实现简洁,但深度大时受栈空间限制,且不保证最短。 - BFS 的空间由最宽一层的宽度决定,也是 O(V)。它的队列通常同时持有大量待扩展节点,常数比 DFS 的递归栈更大,但在无权图最短步数 / 层次遍历上是唯一正确的选择。多源 BFS(所有源点先入队,一次扩散)在"腐烂的橘子"类扩散问题中尤为高效。

选型经验:求最短路 / 最短步数 → BFS;求存在性、连通性、枚举所有方案、拓扑关系 → DFS(回溯);图很宽而目标不远时,可用双向 BFS 从两端同时搜索,把搜索宽度从 O(b^d) 降到约 O(b^(d/2))。

剪枝对复杂度的实际影响。 回溯的理论复杂度由解空间树规模决定:全排列是 O(n!),子集是 O(2ⁿ),含重复元素的组合进一步膨胀。剪枝(提前 return 掉注定失败的分支)不改变最坏情况上界,但能大幅砍掉实际访问的节点数:例如 N 皇后通过"列 / 两条对角线"冲突检测,把每层候选从 N 个收缩到远少于 N 个,实际搜索量相比暴力枚举呈数量级下降 (Hello 算法 · 回溯算法)。工程原则是:越早剪越好——把约束检查放在"做选择之前",而不是等进入下一层再发现非法。

变体与扩展

  • 多源 BFS:把所有源点一次性入队再开始扩散,用于"多个起点同时传播"问题(如腐烂的橘子),复杂度不变仍为 O(V+E) (OI Wiki · BFS)。
  • 0-1 BFS:边权只有 0 / 1 时,用双端队列替代普通队列——0 权边扩展到的点放队首、1 权边放队尾,在 O(V+E) 内求出最短路,是普通 BFS 与 Dijkstra 之间的桥梁 (OI Wiki · BFS 的应用)。
  • 优先队列 BFS(= 堆优化 Dijkstra):把队列换成优先队列,每次取代价最小的节点扩展,即退化为 Dijkstra 算法 (OI Wiki · BFS)。
  • 迭代加深 DFS(IDDFS):限制深度做多轮 DFS,兼顾 BFS 的"层序最优"与 DFS 的"省空间"。
  • 回溯 + 记忆化:把 visited 升级为"状态 → 结论"的 memo,同一状态不重复搜索,是回溯通往动态规划的桥梁(本章各语言回溯模板的 used 与 DP 的状态数组是同源思想)。

经典练习

  • 全排列:LeetCode 46 · 全排列(回溯三要素的入门模板)
  • 子集:LeetCode 78 · 子集("选或不选"与同层去重)
  • 组合:LeetCode 77 · 组合(按 <code>start</code> 收缩候选集)
  • 网格连通块:LeetCode 200 · 岛屿数量(网格 DFS / BFS flood fill)
  • 最短步数:LeetCode 111 · 二叉树的最小深度(BFS 层序,遇叶即返回)
  • 多源 BFS:LeetCode 994 · 腐烂的橘子(所有源点先入队)

三语言对比与陷阱

语言关键差异与陷阱
Rust• 递归回溯常在借用检查器下同时借用多个 &mut,须用辅助 fn 显式传参(如本章模板)或用索引代替引用
• VecDeque 是 BFS 的惯用队列
• usize 做 -1 会下溢,网格坐标宜转 isize
• 深递归易 stack overflow,必要时改迭代
C++• 回溯参数用 const& / 引用避免深拷贝
• vector<bool> 是位压缩特化,[] 返回代理引用,不能取地址、跨线程不安全,需要真 bool 时改用 vector<char>
• 大图递归 DFS 可能爆栈,可用显式 stack
• std::queue 默认基于 std::deque,不支持 begin()/end(),无法用范围 for 遍历 (cppreference · std::queue)
Python• 默认递归深度约 1000,深递归须 sys.setrecursionlimit,但官方警告设得过高可能崩溃 (Python docs · sys)
• 记录快照用切片 path[:]
• BFS 队列用 collections.deque 的 popleft()(O(1)),切勿用 list.pop(0)(O(n))(Python docs · deque)

延伸阅读

09

动态规划模型与状态转移 · 深度解析

速查 ↑

原理与机制

动态规划(Dynamic Programming, DP)的核心思想是避免重复计算:把一个大问题拆解成若干重叠子问题,用一个查找表记下每个子问题的答案,之后再遇到同一子问题就直接查表返回 (OI Wiki · 动态规划)。

它成立要满足两个条件。其一是最优子结构:整体最优解可由子问题的最优解组合而成——例如最短编辑距离的最优方案,去掉最后一步操作后仍是对更短前缀的最优方案 (Stanford CS161 · Guide to Dynamic Programming)。其二是无后效性:一旦某个状态确定,未来的决策只依赖当前状态的值,而与到达该状态的路径无关。直觉上,只要子问题的答案「够用」,就不必记住它是怎么算出来的——这正是「用空间换时间」的落脚点。

从暴力搜索到状态表的推导体现在斐波那契数列上:朴素递归 f(n) = f(n-1) + f(n-2) 会反复求解相同的子问题,运行时间约为 O(2ⁿ);而子问题其实只有 O(n) 个,于是把每个算过的 f(k) 存进数组,复杂度立刻降到 O(n)——以 f(29) 为例,函数调用次数从上百万次降到 57 次 (OI Wiki · 动态规划)。这就是 DP 的思维链:定义状态 → 写出状态转移方程 → 确定边界初始化 → 决定遍历顺序。

推导与权衡

DP 有两条实现路线,取舍取决于问题形态 (OI Wiki · 动态规划):自顶向下(记忆化递归)从所求状态出发递归分解,天然贴合递推式的书写直觉,只会访问真正需要的状态,但依赖递归栈、且每个子问题带有函数调用开销;自底向上(递推)先把最小子问题填好,再按顺序把表填满,没有递归开销、便于空间压缩与滚动优化,但可能会算出用不到的状态。工程上,若状态空间稀疏或难以确定填表顺序,记忆化更省事(Python 可用 functools.lru_cache 一行实现,见 Python 文档 · functools);若追求极致性能与空间,则递推更优。

空间压缩的推导:以 0-1 背包为例,二维状态 f[i][j] 的转移 f[i][j] = max(f[i-1][j], f[i-1][j-w_i] + v_i) 只依赖上一行 i-1。既然历史行用完即弃,就可以只保留一维数组 f[j]。但这样一来 f[j-w_i] 究竟是「上一行」还是「本行」的值必须先想清楚:要让 f[j-w_i] 仍对应 f[i-1][j-w_i],容量 j 就必须从大到小枚举,使 f[j-w_i] 在被覆盖前先被使用 (OI Wiki · 背包问题, OI Wiki · 背包 DP)。反过来,若把容量改成从小到大枚举,f[j-w_i] 就会取到本行已被更新的值,等价于「允许第 i 件物品再取一次」——这恰好就是完全背包。两种背包仅一维数组的遍历方向之差,原因正在于此。

变体与扩展

  • 完全背包:与 0-1 背包共用转移式,仅一维容量改为正序遍历 (OI Wiki · 背包 DP)。
  • 多重背包:每种物品有数量上限,可拆成 0-1 背包求解;用二进制拆分把 k 件压缩成 O(log k) 组,复杂度由 O(W·Σkᵢ) 降至 O(W·Σlog kᵢ) (OI Wiki · 背包问题)。
  • 分组 / 混合背包:物品按组互斥(每组最多选一)或同时存在多种取法,只需在每组内套用 0-1 逻辑、组间顺序枚举即可。
  • LIS(最长递增子序列):状态定义 dp[i] = 以第 i 个元素结尾的最长递增子序列长度,转移为 dp[i] = max(dp[j]) + 1(对所有满足 j < i 且 a[j] < a[i] 的 j),答案为 max(dp[i]),朴素实现 O(n²)。可进一步借助二分与贪心维护「长度为 l 的递增子序列的最小结尾值」数组,把时间优化到 O(n log n) (OI Wiki · 基础动态规划(含 LIS))。
  • 空间维度的扩展:滚动数组 / 滚动行把二维压一维;区间 DP、树形 DP、状压 DP 则是改变「状态维度」而非改变转移本质。

经典练习

  • 入门递推:LeetCode 70 · 爬楼梯
  • 选/不选模型:LeetCode 198 · 打家劫舍
  • 0-1 背包:LeetCode 416 · 分割等和子集
  • 完全背包:LeetCode 322 · 零钱兑换
  • 最长递增子序列:LeetCode 300 · 最长递增子序列
  • 最长公共子序列:LeetCode 1143 · 最长公共子序列
  • 二维字符串 DP:LeetCode 72 · 编辑距离

三语言对比与陷阱

语言关键差异与陷阱
Rust• 下标用 usize,无法表示负数,容量逆序要写 (w..=cap).rev() 而非 for j in 0..w
• 初始化惯用 `vec![0i64
• cap+1] / vec![vec![0
• m+1]
• n+1]`(注意外层是行、内层是列)
• 累加和易溢出,按需选 i64/i128 而非 i32
C++• 二维表初始化写作 vector<vector<int>> dp(n+1, vector<int>(m+1, 0))
• 三值取最小要 #include <algorithm> 并用初始化列表 min({a,b,c})
• int 求和可能溢出,必要时用 long long
• size() 返回无符号数,与 int 混用需留意
Python• 惯用 dp = [0] * (cap + 1)
• 记忆化递归用 functools.lru_cache(maxsize=None)(注意被缓存的参数必须可哈希)
• dp 用 -inf 表示不可达时需 float('-inf')
• 深递归要 sys.setrecursionlimit,否则报 RecursionError

延伸阅读

10

图遍历、最短路径与并查集 · 深度解析

速查 ↑

原理与机制

图的存储决定遍历效率。邻接表为每个顶点维护邻居列表,空间 O(n + m),遍历某点所有邻居耗时 O(deg(u)),是稀疏图(m ≪ n²)的默认选择。邻接矩阵用 n×n 二维数组存边,「u、v 是否相邻」查询为 O(1),但空间 O(n²),适合稠密图或需频繁查边的场景。边集数组把每条边存成 (u, v, w) 三元组,便于「按边遍历」,是 Kruskal 之类算法的天然输入。

DFS 与 BFS 是两种系统遍历手段。DFS 用栈(或递归)沿一条路径深入到底再回溯,天然适配连通分量、判环、拓扑排序;BFS 用队列逐层扩散,第一次到达某点的层数即最短路步数(边权为 1 时)。两者的核心不变量都是 visited:顶点一旦被首次发现即标记,保证每点至多入栈 / 入队一次,总复杂度 O(n + m)。标记时机至关重要——应在入栈 / 入队时标记。

Kahn 拓扑排序维护「入度表 + 队列」:所有入度为 0 的顶点先入队,每取出一个顶点就把它的所有出边终点入度减 1,减到 0 者入队。由于只有无环有向图(DAG)才能排空全部顶点,输出序列长度小于 n 即证明存在环 (OI Wiki · 拓扑排序)。

Dijkstra 的贪心正确性直觉:算法维护「已确定集合」与「暂定距离」。每轮从优先队列取出暂定距离最小的未确定顶点 u,其距离即可定死为最终最短路。直觉在于:所有边权非负,任何从源点先绕道其他未确定点、再回到 u 的路径,长度只会 ≥ u 当前的暂定距离,故不存在更优解 (OI Wiki · 最短路)。

并查集的「代表元」模型:每个集合表示为一棵有根树,根节点即该集合的代表元(identifier)。find(x) 沿 parent 指针上溯到根,两元素同根即同集合;union(a, b) 只需让一棵树的根指向另一棵树的根。整个结构仅用一个 parent 数组即可表达,这是它极简又高效的关键 (OI Wiki · 并查集)。

推导与权衡

为什么 α(n) 近似常数? 单用路径压缩、或单用按大小合并,摊还复杂度都只能到 O(log n);两者结合后,可证明单次操作摊还代价为 O(α(n)),α 是反阿克曼函数——它增长极慢:当 n < 10⁶⁰⁰ 时 α(n) 都不超过 4,故工程上可放心当作近似 O(1) (OI Wiki · 并查集)。注意这是摊还结论:单次操作最坏仍可能 O(log n),只是长序列平均下来极低。

Dijkstra 与 BFS 的关系。 当所有边权都等于 1 时,Dijkstra 每次取出的都是当前层或下一层的点,其行为退化为「先进先出」的 BFS,复杂度从 O((n + m) log n) 降到 O(n + m)(呼应第 8 章的 BFS)。进一步地,若边权只取 0 或 1,可用双端队列(0 权入队首、1 权入队尾)实现 0-1 BFS,同样 O(n + m)。

实现权衡。 稠密图用邻接矩阵 + 线性扫描求最小值,Dijkstra 为 O(n²),比堆版更省常数;稀疏图则用邻接表 + 二叉堆,O(m log n)。理论上 Fibonacci 堆能把上界降到 O(m + n log n),但它实现复杂、隐藏常数大,实践中二叉堆更常用 (OI Wiki · 最短路(堆优化))。存储选择同理:邻接表省空间、遍历邻居快,邻接矩阵查边快但吃 O(n²) 空间——按图的稠密程度二选一。

变体与扩展

  • 0-1 BFS / 双端队列 BFS:边权仅为 0 或 1 时,用双端队列把 0 权放队首、1 权放队尾,O(n + m) 求最短路。
  • Bellman-Ford / SPFA:允许负权边并能检测负环,代价是 O(n·m),比 Dijkstra 慢。
  • 多源 BFS:把多个源点一次性全部入队,即可求「到最近源点的距离」,常用于网格最短距离问题。
  • 双向 BFS / 双向 Dijkstra:从起点与终点同时扩展,显著缩小搜索空间。
  • Kruskal 最小生成树:按边权排序,用并查集判断两端是否已连通来决定是否加入,是并查集的经典应用。
  • 带权 / 种类并查集:在 parent 之外维护「到根的关系量」,用于处理带约束的连通性问题。

经典练习

  • LeetCode 200 · 岛屿数量——DFS / BFS 洪泛统计连通分量
  • LeetCode 207 · 课程表——Kahn 拓扑排序判环
  • LeetCode 210 · 课程表 II——输出任意合法的拓扑序
  • LeetCode 547 · 省份数量——并查集或 DFS 数连通分量,可与 200 对照
  • LeetCode 743 · 网络延迟时间——Dijkstra 求单源最短路
  • LeetCode 133 · 克隆图——在图遍历过程中同步复制节点

三语言对比与陷阱

语言关键差异与陷阱
Rust• BinaryHeap 默认最大堆,求最短路须用 BinaryHeap<Reverse<(dist, node)>>
• 标准库无 decrease-key,靠「过期条目 + `if d > dist[u] { continue
• }`」做延迟删除
• usize 相减易下溢,距离用 u64 / i64 更稳
C++• priority_queue 默认大顶堆,小顶堆写 greater<>
• 同样只有 lazy deletion,故也会重复入堆
• pair 默认先比 first(距离)再比 second,天然契合 Dijkstra
• 权重用 long long 防溢出
Python• heapq 是天然最小堆,无需取负
• 同样用 if d > dist[i]: continue 跳过过期项
• 元组按字典序比较,(dist, node) 即可直接用
• 权重为浮点时注意 float('inf') 与精度问题

延伸阅读