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

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

📖 如何使用本书

本书是一份精炼的数据结构与算法参考:覆盖 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 邻域,消除重复的分支判断。
cpp
#include <utility>
#include <vector>

// 方向数组:4 向(上下左右)与 8 向(含对角线)
const int dx4[4] = {-1, 1, 0, 0};
const int dy4[4] = {0, 0, -1, 1};
const int dx8[8] = {-1, 1, 0, 0, -1, -1, 1, 1};
const int dy8[8] = {0, 0, -1, 1, -1, 1, -1, 1};

// 收集 (r, c) 的合法邻居坐标
std::vector<std::pair<int, int>> neighbors(const std::vector<std::vector<int>>& g, int r, int c, bool diagonal) {
    if (g.empty()) return {};               // 空网格守卫,避免 g[0] 越界
    int m = (int)g.size(), n = (int)g[0].size();
    const int* dx = diagonal ? dx8 : dx4;
    const int* dy = diagonal ? dy8 : dy4;
    int k = diagonal ? 8 : 4;
    std::vector<std::pair<int, int>> res;
    for (int i = 0; i < k; ++i) {
        int nr = r + dx[i], nc = c + dy[i];
        if (nr >= 0 && nr < m && nc >= 0 && nc < n)
            res.push_back({nr, nc});
    }
    return res;
}
模板 2:字符串分割、拼接与数值转换安全的「切分 → 逐段处理 → 合并」范式,避免 O(n²) 拼接。注意三语言对非法段的容错语义并不一致——Rust 版用 unwrap_or(0) 静默归零,C++ stoll 抛 std::invalid_argument,Python int() 抛 ValueError;如需统一行为,应显式选择「静默跳过」或「遇错报错」策略。
cpp
#include <cstddef>
#include <sstream>
#include <string>
#include <vector>

// 按分隔符切分字符串(跳过空段)
std::vector<std::string> split(const std::string& s, char delim) {
    std::vector<std::string> res;
    std::istringstream iss(s);
    std::string cur;
    while (std::getline(iss, cur, delim))
        if (!cur.empty()) res.push_back(cur);
    return res;
}

// 段内字符串转整数;非法段会抛 std::invalid_argument
std::vector<long long> parseInts(const std::string& s, char delim = ',') {
    std::vector<long long> res;
    for (const std::string& t : split(s, delim))
        res.push_back(std::stoll(t));
    return res;
}

std::string joinInts(const std::vector<long long>& v, char delim = ',') {
    std::ostringstream oss;
    for (size_t i = 0; i < v.size(); ++i) {
        if (i) oss << delim;
        oss << v[i];
    }
    return oss.str();
}
模板 3:原地删除元素(快慢指针雏形)O(n) 时间、O(1) 额外空间地移除满足条件的元素,并返回新长度。
cpp
#include <vector>

// 移除所有等于 val 的元素,返回新长度
int removeElement(std::vector<int>& nums, int val) {
    int k = 0; // 慢指针:保留区间的下一个写入位置
    for (int i = 0; i < (int)nums.size(); ++i)
        if (nums[i] != val)
            nums[k++] = nums[i];
    return k;
}
模板 4:原地反转(数组 / 字符串)数组用双指针对撞交换做到 O(n) 时间、O(1) 额外空间;字符串因可变性与编码差异,三语言做法不同——反转不能在字节层面随意进行(详见 A5)。
cpp
#include <string>
#include <utility>
#include <vector>

// 一、数组:双指针对撞交换(对应标准库 reverse(nums.begin(), nums.end()))
void reverseArray(std::vector<int>& nums) {
    for (int i = 0, j = (int)nums.size() - 1; i < j; ++i, --j)
        std::swap(nums[i], nums[j]);
}

// 二、字符串:std::string 可变且连续,可按字符级对撞交换(等价 reverse(s.begin(), s.end()))
void reverseString(std::string& s) {
    for (int i = 0, j = (int)s.size() - 1; i < j; ++i, --j)
        std::swap(s[i], s[j]);
}

// ⚠️ std::string 的反转是「字节级」的:对纯 ASCII 安全;
//    含多字节 UTF-8 时会把码点切断成非法序列,需先按码点切分再反转。

易错点

  • 越界(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>{}。
cpp
#include <vector>

// 在有序数组 nums 中找两数之和等于 target,返回其下标(0-based)。
// 未找到时返回空 vector(表示「未找到」)。
std::vector<int> twoSumSorted(const std::vector<long long>& nums, long long target) {
    int l = 0, r = (int)nums.size() - 1;
    while (l < r) {
        long long sum = nums[l] + nums[r];
        if (sum == target) return {l, r};
        if (sum < target) ++l;   // 和太小,左指针右移
        else --r;                // 和太大,右指针左移
    }
    return {};
}
模板 2:不定长滑动窗口(求最短满足条件的窗口)右端扩张、窗口满足条件后持续左端收缩,得到最短的合法窗口。前提:nums 元素全为正且 target > 0(滑窗单调性前提,负数场景见 B2);当 target ≤ 0 时,空子数组的和为 0 已满足 >= target,约定直接返回长度 0。
cpp
#include <algorithm>
#include <vector>

// 求最短的连续子数组,使其元素和 >= target;不存在则返回 0。
// 前提:nums 元素全为正且 target > 0。
int minSubArrayLen(long long target, const std::vector<int>& nums) {
    if (target <= 0) return 0;   // 空子数组之和为 0,已满足 >= target
    int n = (int)nums.size(), left = 0, best = n + 1;
    long long sum = 0;
    for (int right = 0; right < n; ++right) {
        sum += nums[right];                 // 右端扩张,纳入新元素
        while (sum >= target) {             // 窗口已满足条件,尝试收缩
            best = std::min(best, right - left + 1);
            sum -= nums[left++];            // 移出左端元素
        }
    }
    return best == n + 1 ? 0 : best;
}
模板 3:前缀和 + 哈希表(和为 K 的子数组)用哈希表记录每个前缀和出现的次数,一次遍历统计以当前位置结尾的合法子数组数。
cpp
#include <unordered_map>
#include <vector>

// 统计元素和恰好等于 k 的连续子数组个数。
long long subarraySum(const std::vector<long long>& nums, long long k) {
    std::unordered_map<long long, long long> cnt;
    cnt[0] = 1;                      // 空前缀的和为 0,出现一次
    long long pre = 0, ans = 0;
    for (long long x : nums) {
        pre += x;                    // 当前前缀和
        auto it = cnt.find(pre - k); // 之前出现过前缀和 pre - k
        if (it != cnt.end()) ans += it->second;
        ++cnt[pre];
    }
    return ans;
}
模板 4:差分数组(区间批量加与还原)每次区间加只改两个端点。差分对象有两种构造方式:由原数组构造(from_slice)时,build() 返回「原数组经全部区间加后的结果」;以全零基线构造(new)时,build() 返回的是各位置的累计增量(即所有区间加作用在全零原数组上的结果,LeetCode 1109 场景即此类,原数组全零时累计增量即最终数组)。注意:build() 本身只做一次前缀和,不会自动叠加调用方的原数组——以零基线构造却忘记叠加原数组,是本章 A5 点名的易错点。
cpp
#include <vector>

// 一维差分数组:支持 O(1) 区间加、O(n) 前缀和还原。
struct Diff {
    std::vector<long long> d; // 长度 n + 1:d[r + 1] 至多落到下标 n
    int n;
    // 以全零基线构造:build() 返回各位置的累计增量。
    explicit Diff(int n_) : d(n_ + 1, 0), n(n_) {}
    // 由原数组构造:build() 返回「原数组经全部区间加后的结果」。
    static Diff fromSlice(const std::vector<long long>& a) {
        Diff df((int)a.size());
        for (int i = 0; i < (int)a.size(); ++i) {
            df.d[i] += a[i];         // d[i] 记 a[i] 的起点
            df.d[i + 1] -= a[i];     // 抵消 a[i],使前缀和回到 a[i+1]
        }
        return df;
    }
    // 将区间 [l, r](0-based,闭区间)内每个元素加上 v。
    void add(int l, int r, long long v) {
        d[l] += v;
        d[r + 1] -= v;   // 在 r + 1 处打上减法标记
    }
    // 对差分数组求前缀和:全零基线时为累计增量;由原数组构造时为更新后的原数组。
    std::vector<long long> build() const {
        std::vector<long long> a(n);
        long long cur = 0;
        for (int i = 0; i < n; ++i) a[i] = cur += d[i];
        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:反转单链表(迭代)三指针原地反转,是链表题最通用的基础动作。
cpp
struct ListNode {
    int val;
    ListNode* next;
    explicit ListNode(int x) : val(x), next(nullptr) {}
    ListNode(int x, ListNode* n) : val(x), next(n) {} // 供 dummy 头节点使用
};

// 迭代反转:三指针 prev / curr / nxt
ListNode* reverseList(ListNode* head) {
    ListNode* prev = nullptr;
    ListNode* curr = head;
    while (curr) {
        ListNode* nxt = curr->next; // 先保存后继,防断链
        curr->next = prev;          // 反转指向
        prev = curr;                // prev 前移
        curr = nxt;                 // curr 前移
    }
    return prev;                    // prev 即新头
}
模板 2:快慢指针判环 + 定位环入口(Floyd)快指针每次两步、慢指针每次一步;相遇后即可判定有环,再同步走可找到环入口。
cpp
// 返回环入口节点;无环返回 nullptr
ListNode* detectCycle(ListNode* head) {
    ListNode* slow = head;
    ListNode* fast = head;
    while (fast && fast->next) {           // 阶段一:判环
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast) break;           // 相遇 -> 有环
    }
    if (!fast || !fast->next) return nullptr; // 走到链尾 -> 无环
    slow = head;                           // 阶段二:找入口
    while (slow != fast) {
        slow = slow->next;
        fast = fast->next;
    }
    return slow;
}
模板 3:合并两个有序链表虚拟头节点 + 尾指针,原地把较小者不断接到结果链尾部。
cpp
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
    ListNode dummy(0);          // 虚拟头节点(栈上)
    ListNode* tail = &dummy;
    while (l1 && 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 ? l1 : l2;  // 拼接剩余
    return dummy.next;
}
模板 4:dummy head 删除模式(删除倒数第 N 个节点)用虚拟头节点消除「删除首节点需特判」的分支;先量长度,再走到待删节点的前驱。
cpp
ListNode* removeNthFromEnd(ListNode* head, int n) {
    ListNode dummy(0, head);        // 虚拟头节点,统一删头情形
    int len = 0;
    for (ListNode* p = head; p; p = p->next) ++len;
    ListNode* prev = &dummy;
    for (int i = 0; i < len - n; ++i) prev = prev->next;
    prev->next = prev->next->next;  // 摘除目标节点
    return dummy.next;
}
模板 5:快慢指针求中点快指针每次两步、慢指针每次一步,快指针走到链尾时慢指针恰在中点。约定:偶数个节点时返回「右中点」(第二个中间节点),三语言实现均与此约定保持一致。
cpp
// 返回链表中点节点;偶数长度返回「右中点」(第二个中间节点)
ListNode* middleNode(ListNode* head) {
    ListNode* slow = head;
    ListNode* fast = head;
    while (fast && 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:括号匹配(栈)用栈记录待匹配的左括号,遇右括号时栈顶必须对应。
cpp
#include <stack>
#include <string>
#include <unordered_map>

// 判断括号字符串是否合法匹配
bool isValid(const std::string& s) {
    std::unordered_map<char, char> pairs = {{')', '('}, {']', '['}, {'}', '{'}};
    std::stack<char> st;
    for (char c : s) {
        auto it = pairs.find(c);
        if (it != pairs.end()) {                 // 是右括号
            if (st.empty() || st.top() != it->second) return false;
            st.pop();
        } else {
            st.push(c);                          // 左括号入栈
        }
    }
    return st.empty();                           // 栈空才算完全匹配
}
模板 2:哈希表频次统计一次遍历统计每个元素的出现次数,是计数类问题的通用底座。
cpp
#include <unordered_map>
#include <vector>

// 统计每个元素出现的次数
std::unordered_map<int, int> countFreq(const std::vector<int>& nums) {
    std::unordered_map<int, int> freq;
    for (int x : nums) {
        ++freq[x];   // 键不存在时值被默认初始化为 0
    }
    return freq;
}
模板 3:两数之和(一次遍历)边遍历边把「值 → 下标」存入哈希表,把 O(n²) 暴力降为 O(n)。
cpp
#include <unordered_map>
#include <vector>

// 返回和为 target 的两个下标(每个元素只用一次)
std::vector<int> twoSum(const std::vector<int>& nums, int target) {
    std::unordered_map<int, int> seen; // 值 -> 下标
    for (int i = 0; i < (int)nums.size(); ++i) {
        int need = target - nums[i];
        auto it = seen.find(need);
        if (it != seen.end()) return {it->second, i}; // 找到补数
        seen[nums[i]] = 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) 建堆的原理。
cpp
#include <utility>
#include <vector>

// 大顶堆:siftUp + siftDown + 原地建堆(0 基下标)
void siftUp(std::vector<int>& a, int i) {
    while (i > 0) {
        int p = (i - 1) / 2; // 父结点下标
        if (a[i] <= a[p]) break; // 已满足堆序
        std::swap(a[i], a[p]);
        i = p;
    }
}

void siftDown(std::vector<int>& a, int i, int n) {
    while (true) {
        int l = 2 * i + 1, r = 2 * i + 2, largest = i;
        if (l < n && a[l] > a[largest]) largest = l;
        if (r < n && a[r] > a[largest]) largest = r;
        if (largest == i) break; // 已满足堆序
        std::swap(a[i], a[largest]);
        i = largest;
    }
}

// 自底向上建堆,整体 O(n)
void buildHeap(std::vector<int>& a) {
    int n = (int)a.size();
    for (int i = n / 2 - 1; i >= 0; --i) siftDown(a, i, n);
}
模板 2:标准库 Top-K求前 K 大就用大小为 K 的最小堆;求前 K 小就用大小为 K 的最大堆(此处演示前 K 大)。三种语言均在返回前排序,统一按升序输出,便于对照。
cpp
#include <functional>
#include <queue>
#include <vector>

// 返回前 k 大的元素:维护大小为 k 的小顶堆,O(n log k)
std::vector<int> topKLargest(const std::vector<int>& nums, int k) {
    std::priority_queue<int, std::vector<int>, std::greater<int>> heap; // 小顶堆
    for (int x : nums) {
        heap.push(x);
        if ((int)heap.size() > k) heap.pop(); // 弹出最小值
    }
    std::vector<int> res;
    while (!heap.empty()) { res.push_back(heap.top()); heap.pop(); } // 依次弹出即升序
    return res;
}
模板 3:双堆求数据流中位数用一个大顶堆存较小的一半、一个小顶堆存较大的一半,取中位数 O(1)。
cpp
#include <functional>
#include <queue>
#include <vector>

// 双堆维护数据流中位数
class MedianFinder {
    std::priority_queue<int> left_;                                  // 大顶堆:较小的一半
    std::priority_queue<int, std::vector<int>, std::greater<int>> right_;      // 小顶堆:较大的一半
public:
    void addNum(int num) {
        left_.push(num);                          // 先放入较小的一半
        right_.push(left_.top()); left_.pop();    // left 最大值交给 right
        if ((int)right_.size() > (int)left_.size()) { // 平衡两堆
            left_.push(right_.top()); right_.pop();
        }
    }
    double findMedian() const {
        if (left_.size() > right_.size()) return left_.top();
        return (left_.top() + right_.top()) / 2.0;
    }
};
模板 4:合并 K 个有序链表用小顶堆维护 k 条有序链表各自的当前头结点;每次弹出堆顶(当前全局最小)接到结果尾部,并把该链表的下一个结点推入堆中。堆中每个元素存为 (结点值, 所属链表标识),用标识在平局时定位回原链表。总代价 O(N log k)(N 为总节点数、k 为链表数)。
cpp
#include <functional>
#include <queue>
#include <utility>
#include <vector>

// 合并 k 条升序序列:小顶堆存 (结点值, 所属链表下标),O(N log k)
std::vector<int> mergeKLists(const std::vector<std::vector<int>>& lists) {
    using P = std::pair<int, int>;                       // (结点值, 链表下标)
    std::priority_queue<P, std::vector<P>, std::greater<P>> heap;  // 小顶堆
    std::vector<int> pos(lists.size(), 0);
    for (int i = 0; i < (int)lists.size(); ++i)
        if (!lists[i].empty()) heap.push({lists[i][0], i}); // 各链表头结点入堆
    std::vector<int> res;
    while (!heap.empty()) {
        auto [val, i] = heap.top(); heap.pop();     // 弹出当前全局最小
        res.push_back(val);                         // 接到结果尾部
        if (++pos[i] < (int)lists[i].size())
            heap.push({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 框架,代码插入位置不同即为不同遍历。
cpp
#include <vector>

struct TreeNode {
    int val;
    TreeNode *left, *right;
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

void preorder(TreeNode* root, std::vector<int>& out) {
    if (!root) return;
    out.push_back(root->val);          // 前序位置
    preorder(root->left, out);
    preorder(root->right, out);
}

void inorder(TreeNode* root, std::vector<int>& out) {
    if (!root) return;
    inorder(root->left, out);
    out.push_back(root->val);          // 中序位置
    inorder(root->right, out);
}

void postorder(TreeNode* root, std::vector<int>& out) {
    if (!root) return;
    postorder(root->left, out);
    postorder(root->right, out);
    out.push_back(root->val);          // 后序位置
}
模板 2:层序遍历(BFS + 队列,按层返回)必须先记录本层节点数 sz,才能正确分层。
cpp
#include <queue>
#include <vector>

std::vector<std::vector<int>> levelOrder(TreeNode* root) {
    std::vector<std::vector<int>> res;
    if (!root) return res;
    std::queue<TreeNode*> q;
    q.push(root);
    while (!q.empty()) {
        int sz = q.size();                // 固定本层节点数
        std::vector<int> level;
        for (int i = 0; i < sz; ++i) {
            TreeNode* node = q.front(); q.pop();
            level.push_back(node->val);
            if (node->left)  q.push(node->left);
            if (node->right) q.push(node->right);
        }
        res.push_back(level);
    }
    return res;
}
模板 3:分解思维模板(二叉树最大深度)先取子问题答案,再在后序位置合并——分解思维的标准形态。此处「深度」对齐 LeetCode 104 的节点数口径(空树 0、单节点 1),与术语表按边数计的「高度」口径相差 1(见 A2 口径说明)。
cpp
#include <algorithm>

int maxDepth(TreeNode* root) {
    if (!root) return 0;                     // 空树深度为 0
    int l = maxDepth(root->left);            // 先求左右子树
    int r = maxDepth(root->right);
    return 1 + std::max(l, r);                    // 后序位置:合并子问题
}
模板 4:BST 验证(中序法)BST 的中序序列必严格递增;维护前驱值 prev 逐一比较。
cpp
#include <functional>

bool isValidBST(TreeNode* root) {
    TreeNode* prev = nullptr;
    std::function<bool(TreeNode*)> inorder = [&](TreeNode* node) -> bool {
        if (!node) return true;
        if (!inorder(node->left)) return false;
        if (prev && node->val <= prev->val) return false;  // 非严格递增
        prev = node;
        return inorder(node->right);
    };
    return inorder(root);
}
模板 5:迭代中序遍历(显式栈)手动模拟系统栈,规避深树递归爆栈;对应 A5 的「迭代中序栈写法」易错点。
cpp
#include <stack>
#include <vector>

std::vector<int> inorderTraversal(TreeNode* root) {
    std::vector<int> out;
    std::stack<TreeNode*> stk;
    TreeNode* cur = root;
    while (cur || !stk.empty()) {      // 外层双条件缺一不可
        while (cur) {                  // 一路向左压栈
            stk.push(cur);
            cur = cur->left;
        }
        cur = stk.top(); stk.pop();    // 弹栈
        out.push_back(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] 左闭右闭,最通用的入门写法。
cpp
#include <vector>

int binarySearch(const std::vector<int>& a, int target) {
    int l = 0, r = (int)a.size() - 1;
    while (l <= r) {
        int mid = l + (r - l) / 2; // 防 l + r 溢出
        if (a[mid] == target) return mid;
        else if (a[mid] < target) l = mid + 1;
        else r = mid - 1;
    }
    return -1;
}
模板 2:左边界二分(lower_bound)返回第一个 ≥ target 的下标(可能等于 n);谓词 nums[i] < target 左真右假,用半开区间 [l, r) 实现。
cpp
#include <vector>

int lowerBound(const std::vector<int>& a, int target) {
    int l = 0, r = (int)a.size(); // 半开区间 [l, r)
    while (l < r) {
        int mid = l + (r - l) / 2;
        if (a[mid] < target) l = mid + 1;
        else r = mid; // 保留 mid 作为候选
    }
    return l; // 首个 >= target 的位置(= n 表示全部更小)
}
模板 3:二分答案(最小可行值)在答案值域 [lo, hi] 上二分求最小可行值——check 须关于答案单调(存在一个分界点,其前不可行、其后可行),check 为单次判定,故总复杂度 = O(log V × check 的代价)(V 为答案值域跨度);典型应用见 B3 的「爱吃香蕉的珂珂」(LeetCode 875)。边界提醒:若区间内不存在可行值,循环将收敛到 hi,故调用方需保证 hi 本身可行。
cpp
// 求最小可行值:在单调谓词上二分答案
// 前提:答案具有单调性(check 在值域上呈「前假后真」)
// 总复杂度 = O(log V × check 的代价),V 为答案值域跨度
template <class F>
long long minFeasible(long long lo, long long hi, F check) {
    while (lo < hi) {
        long long 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 三路划分)。
cpp
#include <utility>
#include <vector>

int partition(std::vector<int>& a, int lo, int hi) {
    int pivot = a[lo];
    int i = lo, j = hi;
    while (i < j) {
        while (i < j && a[j] >= pivot) --j; // 右指针找小于基准
        while (i < j && a[i] <= pivot) ++i; // 左指针找大于基准
        std::swap(a[i], a[j]);
    }
    std::swap(a[lo], a[i]); // 基准归位
    return i;
}

void quickSort(std::vector<int>& a, int lo, int hi) {
    if (lo >= hi) return;
    int p = partition(a, lo, hi);
    quickSort(a, lo, p - 1);
    quickSort(a, p + 1, hi);
}

void quickSort(std::vector<int>& a) { quickSort(a, 0, (int)a.size() - 1); } // 顶层入口
模板 5:归并排序(稳定)对半切分后递归排序、线性合并;合并时用 <= 取左段优先,保证排序稳定。
cpp
#include <vector>

void mergeSort(std::vector<int>& a, int lo, int hi) {
    if (hi - lo <= 1) return;
    int mid = lo + (hi - lo) / 2;
    mergeSort(a, lo, mid);
    mergeSort(a, mid, hi);
    std::vector<int> tmp;
    tmp.reserve(hi - lo);
    int i = lo, j = mid;
    while (i < mid && j < hi) {           // 合并两个有序段
        if (a[i] <= a[j]) tmp.push_back(a[i++]); // 取等号 → 稳定
        else tmp.push_back(a[j++]);
    }
    while (i < mid) tmp.push_back(a[i++]);
    while (j < hi) tmp.push_back(a[j++]);
    for (int k = 0; k < (int)tmp.size(); ++k) a[lo + k] = tmp[k];
}

void mergeSort(std::vector<int>& a) { mergeSort(a, 0, (int)a.size()); } // 顶层入口
模板 6:计数排序(值域小)对取值落在 [0, k) 的整数键做线性时间排序——统计频次 → 求前缀和 → 逆序回填;时间 O(n + k)、空间 O(k)、稳定(逆序回填保证相等键的相对次序不变)。
cpp
#include <vector>

// 计数排序:元素须落在值域 [0, k)
// 时间 O(n + k)、空间 O(k)、稳定
std::vector<int> countingSort(const std::vector<int>& a, int k) {
    std::vector<int> cnt(k, 0), out(a.size());
    for (int x : a) ++cnt[x];                          // 统计频次
    for (int i = 1; i < k; ++i) cnt[i] += cnt[i - 1];  // 前缀和
    for (int i = (int)a.size() - 1; i >= 0; --i) {
        out[--cnt[a[i]]] = a[i];                       // 逆序回填 → 稳定
    }
    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)即可复用同一框架。
cpp
#include <vector>

// 全排列:回溯通用框架(路径 path + 选择列表 + 结束条件)
void backtrack(const std::vector<int>& nums, std::vector<bool>& used,
               std::vector<int>& path, std::vector<std::vector<int>>& res) {
    if ((int)path.size() == (int)nums.size()) { // 结束条件
        res.push_back(path);                    // 记录快照
        return;
    }
    for (int i = 0; i < (int)nums.size(); ++i) {
        if (used[i]) continue;                  // 已选过则剪枝
        used[i] = true;                         // 做选择
        path.push_back(nums[i]);
        backtrack(nums, used, path, res);       // 进入下一层决策
        path.pop_back();                        // 撤销选择(恢复现场)
        used[i] = false;
    }
}

std::vector<std::vector<int>> permute(std::vector<int> nums) {
    std::vector<std::vector<int>> res;
    std::vector<int> path;
    std::vector<bool> used(nums.size(), false);
    backtrack(nums, used, path, res);
    return res;
}
模板 2:网格 DFS(flood fill)把二维网格当隐式图,原地改写代替 visited,统计连通块个数。
cpp
#include <vector>

// 网格 DFS:flood fill,统计连通块(岛屿)数量
void dfs(std::vector<std::vector<char>>& grid, int r, int c) {
    int rows = (int)grid.size(), cols = (int)grid[0].size();
    if (r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] != '1')
        return;                        // 越界或非陆地则返回
    grid[r][c] = '0';                  // 标记为已访问(沉岛)
    dfs(grid, r + 1, c);               // 四方向扩展
    dfs(grid, r - 1, c);
    dfs(grid, r, c + 1);
    dfs(grid, r, c - 1);
}

int numIslands(std::vector<std::vector<char>> grid) {
    if (grid.empty()) return 0;
    int rows = (int)grid.size(), cols = (int)grid[0].size(), count = 0;
    for (int r = 0; r < rows; ++r)
        for (int c = 0; c < cols; ++c)
            if (grid[r][c] == '1') {   // 新连通块
                ++count;
                dfs(grid, r, c);
            }
    return count;
}
模板 3:BFS 最短步数队列 + visited,按层推进;入队时立即标记,首次到达即最短(空网格返回 -1)。
cpp
#include <queue>
#include <utility>
#include <vector>

// BFS 最短步数:grid 中 0 可走、1 为障碍,返回最少的移动次数
int shortestPath(std::vector<std::vector<int>> grid) {
    if (grid.empty() || grid[0].empty()) return -1; // 空网格守卫,避免 grid[0] 越界
    int rows = (int)grid.size(), cols = (int)grid[0].size();
    if (grid[0][0] == 1 || grid[rows - 1][cols - 1] == 1) return -1;
    std::vector<std::vector<bool>> visited(rows, std::vector<bool>(cols, false));
    std::queue<std::pair<int, int>> q;
    q.push({0, 0});
    visited[0][0] = true;                 // 入队时立即标记
    int steps = 0;
    int dirs[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};

    while (!q.empty()) {
        int levelSize = (int)q.size();    // 按层推进
        for (int i = 0; i < levelSize; ++i) {
            auto [r, c] = q.front();
            q.pop();
            if (r == rows - 1 && c == cols - 1) return steps; // 首次到达即最短
            for (auto& d : dirs) {
                int nr = r + d[0], nc = c + d[1];
                if (nr >= 0 && nr < rows && nc >= 0 && nc < cols &&
                    grid[nr][nc] == 0 && !visited[nr][nc]) {
                    visited[nr][nc] = true;   // 入队时标记,防止重复入队
                    q.push({nr, nc});
                }
            }
        }
        ++steps;                          // 一层结束,步数 +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)。
cpp
#include <algorithm>
#include <vector>

// 爬楼梯:dp[i] = dp[i-1] + dp[i-2],滚动变量 O(1) 空间
long long climbStairs(int n) {
    long long prev2 = 1, prev1 = 1; // dp[0], dp[1]
    for (int i = 2; i <= n; ++i) {
        long long cur = prev1 + prev2;
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}

// 打家劫舍:选/不选,dp[i] = max(dp[i-1], dp[i-2] + nums[i])
int rob(const std::vector<int>& nums) {
    int prev2 = 0, prev1 = 0; // dp[i-2], dp[i-1]
    for (int x : nums) {
        int cur = std::max(prev1, prev2 + x);
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}
模板 2:0-1 背包(一维逆序)每件物品只能取一次,一维容量数组必须从大到小更新,保证用的是「上一件物品」的状态。
cpp
#include <algorithm>
#include <cstddef>
#include <vector>

// dp[j] 表示容量至多为 j(不超过 j)时的最大价值;内层容量逆序遍历
long long knapsack01(const std::vector<int>& w, const std::vector<int>& v, int W) {
    std::vector<long long> dp(W + 1, 0);
    for (size_t i = 0; i < w.size(); ++i)
        for (int j = W; j >= w[i]; --j)        // 逆序:避免同一物品被重复选取
            dp[j] = std::max(dp[j], dp[j - w[i]] + v[i]);
    return dp[W];
}
模板 3:编辑距离(二维)把「s 前 i 个字符转成 t 前 j 个字符」作为状态,转移对增、删、改三种操作取最小。
cpp
#include <algorithm>
#include <string>
#include <vector>

// dp[i][j]:s 前 i 个字符转成 t 前 j 个字符的最少操作数
int editDistance(const std::string& s, const std::string& t) {
    int n = s.size(), m = t.size();
    std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1, 0));
    for (int i = 0; i <= n; ++i) dp[i][0] = i; // 删除 i 个字符
    for (int j = 0; j <= m; ++j) dp[0][j] = j; // 插入 j 个字符
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            if (s[i - 1] == t[j - 1])
                dp[i][j] = dp[i - 1][j - 1];             // 字符相同,无需操作
            else
                dp[i][j] = 1 + std::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))。
cpp
#include <algorithm>
#include <string>
#include <vector>

// 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)) 空间
int lcs(const std::string& s, const std::string& t) {
    int n = s.size(), m = t.size();
    std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1, 0));
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            if (s[i - 1] == t[j - 1])
                dp[i][j] = dp[i - 1][j - 1] + 1;            // 相等,接在斜对角之后
            else
                dp[i][j] = std::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 遍历稀疏图的默认存储与最基础的遍历骨架。
cpp
#include <stack>
#include <utility>
#include <vector>

// 邻接表:adj[u] 存 u 的所有邻居;无向边两个方向都要加
std::vector<std::vector<int>> buildAdj(int n, const std::vector<std::pair<int,int>>& edges) {
    std::vector<std::vector<int>> adj(n);
    for (auto [u, v] : edges) {
        adj[u].push_back(v);
        adj[v].push_back(u); // 有向图删掉这一行
    }
    return adj;
}

// 迭代式 DFS:入栈即标记 visited,避免同一顶点重复入栈
std::vector<int> dfs(const std::vector<std::vector<int>>& adj, int start) {
    std::vector<bool> visited(adj.size(), false);
    std::vector<int> order;
    std::stack<int> st;
    st.push(start);
    visited[start] = true;
    while (!st.empty()) {
        int u = st.top(); st.pop();
        order.push_back(u);
        for (int v : adj[u]) {
            if (!visited[v]) {
                visited[v] = true; // 关键:发现即标记
                st.push(v);
            }
        }
    }
    return order;
}
// 连通分量数 = 对所有未访问点各启动一次 dfs 的次数
模板 2:Kahn 拓扑排序(含判环)有向图上按依赖关系排序;输出序列长度小于 n 即说明存在环。返回语义三语言对照:Rust / Python 用 None 表示有环(无合法拓扑序),C++ 用空数组表示有环并在注释中约定「空返回 = 有环,调用方须结合节点数 n 判断」;因 n = 0 时「合法空拓扑序」与「有环返回值」在 C++ 中不可区分,务必按题意(节点数已知)判定。
cpp
#include <queue>
#include <vector>

// Kahn 拓扑排序:返回拓扑序;长度 < n 说明图中存在环
// adj[u] 存 u 指向的顶点(出边)
std::vector<int> topoSort(int n, const std::vector<std::vector<int>>& adj) {
    std::vector<int> indeg(n, 0);
    for (int u = 0; u < n; ++u)
        for (int v : adj[u]) indeg[v]++; // 统计入度
    std::queue<int> q;
    for (int v = 0; v < n; ++v)
        if (indeg[v] == 0) q.push(v);
    std::vector<int> order;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);
        for (int v : adj[u]) {
            if (--indeg[v] == 0) q.push(v); // 入度归零即可入队
        }
    }
    if ((int)order.size() == n) return order;
    return {}; // 有环(无合法拓扑序),返回空数组
    // 约定:空返回表示有环,调用方须结合节点数 n 判断——
    // 仅凭返回值无法区分「有环」与「n == 0 时的合法空拓扑序」,二者均为空。
    // 如需强语义,可改返回 std::optional<vector<int>>(C++17)或 pair<bool, vector<int>>。
}
模板 3:Dijkstra 最短路(邻接表 + 优先队列)非负权图上的单源最短路;边权全为 1 时可退化为 BFS。
cpp
#include <climits>
#include <functional>
#include <queue>
#include <utility>
#include <vector>

// Dijkstra:返回从 src 出发到各点的最短距离(不可达为 INF)
// adj[u] 存 {邻点, 边权};要求边权非负
const long long INF = LLONG_MAX / 2;
std::vector<long long> dijkstra(int n, const std::vector<std::vector<std::pair<int,long long>>>& adj, int src) {
    std::vector<long long> dist(n, INF);
    dist[src] = 0;
    // 小顶堆:greater<> 让优先队列取最小值
    std::priority_queue<std::pair<long long,int>, std::vector<std::pair<long long,int>>, std::greater<>> pq;
    pq.push({0, src});
    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue; // 过期条目,延迟删除
        for (auto [v, w] : adj[u]) {
            if (d + w < dist[v]) { // 松弛
                dist[v] = d + w;
                pq.push({dist[v], v});
            }
        }
    }
    return dist;
}
模板 4:并查集(路径压缩 + 按大小合并)维护动态连通性;两个优化合用使单次操作摊还 O(α(n))。
cpp
#include <numeric>
#include <utility>
#include <vector>

// 并查集:路径压缩 + 按大小合并,摊还 O(α(n))
struct DSU {
    std::vector<int> parent, sz;
    explicit DSU(int n) : parent(n), sz(n, 1) {
        std::iota(parent.begin(), parent.end(), 0); // parent[i] = i
    }
    // 迭代式 find:顺路路径减半
    int find(int x) {
        while (parent[x] != x) {
            parent[x] = parent[parent[x]]; // 路径减半
            x = parent[x];
        }
        return x;
    }
    // 合并两集合;若本就在同一集合返回 false
    bool unite(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;
        if (sz[ra] < sz[rb]) std::swap(ra, rb);
        parent[rb] = ra;      // 小树接到大树上
        sz[ra] += sz[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') 与精度问题

延伸阅读