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

C 版 · 速查手册 + 深度解析
语言 Rust C++ C 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 也没有异常与泛型,错误经由返回值 + 出参传递,内存遵守「谁分配谁释放」「malloc / free 成对」。
  • 所有模板代码均已通过最新稳定版工具链逐一验证:g++ 15.2(-std=c++23)、rustc 1.98(--edition 2024)、Python 3.14、gcc 15.2(-std=c2x -Wall -Wextra)。
PART A · CHEATSHEET

⚡ 速查手册

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

01

数组与字符串基础操作

深度解析 ↓

核心要点

  • 数组连续存储,a[i] 就是 *(a + i),按下标访问 O(1)(推导见 A1)
  • 数组作函数参数会退化为首元素指针,sizeof 当场失效;长度必须显式传入
  • 二维数组按行主序布局:要么传 int (*p)[n],要么扁平化成 a[r * n + c],不能写成 int **(详见 A2)
  • C 没有字符串类型:char * 以 '\0' 结尾,strlen 是 O(n),写操作必须自带目标容量
  • 拼接 / 复制一律用 snprintf 或手动拷贝并显式传容量;strcpy / strcat / sprintf 不知道目标有多大(详见 B2)
  • 标准库没有动态数组:尾部追加要用 realloc 自己实现,扩容策略与失败语义见 B1

复杂度速查

操作 / 场景时间空间说明
按下标访问 a[i]O(1)O(1)编译为基址 + i × sizeof(T),一次乘法一次偏移
sizeof(a) / sizeof(a[0])O(1)O(1)只对「真数组」有效;传参退化为指针后必然是错的
strlen(s)O(n)O(1)逐字节找 '\0';写进循环条件会退化为 O(n²)
按行扫描二维数组O(m·n)O(1)行主序,行内地址连续,缓存友好
中间插入 / 删除O(n)O(1)后续元素整体搬移,memmove 处理重叠
动态数组尾部追加均摊 O(1)O(n)realloc 按倍数扩容,几何级数摊还(推导见 B1)
复制 n 字节O(n)O(n)源目标可能重叠时用 memmove,memcpy 要求不重叠

模板代码

模板 1:方向数组遍历二维网格把行列偏移放进数组,一次循环枚举 4 / 8 连通邻居;网格按行优先扁平化,避免二维参数类型陷阱。
c
#include <stdbool.h>

// 4 向(上下左右)与 8 向(含对角线)的行、列偏移
static const int DR4[4] = {-1, 1, 0, 0};
static const int DC4[4] = {0, 0, -1, 1};
static const int DR8[8] = {-1, 1, 0, 0, -1, -1, 1, 1};
static const int DC8[8] = {0, 0, -1, 1, -1, 1, -1, 1};

// 收集 (r, c) 的合法邻居:网格是行主序的 m × n 矩阵。
// out_r / out_c 由调用方提供,容量至少 8;返回邻居个数。
int neighbors(int r, int c, int m, int n, bool diagonal, int *out_r, int *out_c) {
    const int *dr = diagonal ? DR8 : DR4;
    const int *dc = diagonal ? DC8 : DC4;
    int k = diagonal ? 8 : 4;
    int cnt = 0;
    for (int i = 0; i < k; ++i) {
        int nr = r + dr[i], nc = c + dc[i];
        if (nr >= 0 && nr < m && nc >= 0 && nc < n) {
            out_r[cnt] = nr;
            out_c[cnt] = nc;
            ++cnt;
        }
    }
    return cnt;
}
模板 2:字符串切分、拼接与数值转换不修改原串、无静态状态的手动切分;strtoll 必须检查 endptr 才算真正解析成功;拼接口一律显式传容量。
c
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 按分隔符切分:每段写入 out[i](容量 cap 字节),最多 top 段,返回段数。
// 空段跳过;超长段截断而不是溢出。out 由调用方提供二维缓冲区。
int split(const char *s, char delim, char out[][64], int top) {
    int cnt = 0;
    const char *p = s;
    while (cnt < top && *p != '\0') {
        const char *q = strchr(p, delim);
        size_t len = q ? (size_t)(q - p) : strlen(p);
        if (len > 63) len = 63;            // 截断保护,绝不越界写
        memcpy(out[cnt], p, len);
        out[cnt][len] = '\0';
        if (len > 0) ++cnt;                // 跳过空段
        if (q == NULL) break;
        p = q + 1;
    }
    return cnt;
}

// 严格解析整数:整串都必须是合法十进制数,否则返回 false(atoi 做不到这一点)
bool parse_int(const char *t, long long *out) {
    char *end = NULL;
    long long v = strtoll(t, &end, 10);
    if (end == t || *end != '\0') return false;   // 空串或残留非法字符
    *out = v;
    return true;
}

// 把整数数组拼成 "1,2,3";放不下就返回 -1,绝不截断
int join_ints(const long long *a, int n, char *buf, size_t cap) {
    size_t used = 0;
    if (cap == 0) return -1;
    buf[0] = '\0';
    for (int i = 0; i < n; ++i) {
        int w = snprintf(buf + used, cap - used, i ? ",%lld" : "%lld", a[i]);
        if (w < 0 || (size_t)w >= cap - used) return -1;
        used += (size_t)w;
    }
    return (int)used;
}
模板 3:原地删除元素(快慢指针雏形)O(n) 时间、O(1) 额外空间;C 里「返回新长度」比返回新数组更自然,前 k 个位置就是结果。
c
// 删除所有等于 val 的元素,返回新长度;nums[0..k-1] 即保留元素。
// 注意:C 的「数组」没有长度,n 必须由调用方给出。
int removeElement(int *nums, int n, int val) {
    int k = 0;                       // 慢指针:保留区间的下一个写入位置
    for (int i = 0; i < n; ++i)
        if (nums[i] != val)
            nums[k++] = nums[i];     // 快指针扫到的非目标值前移
    return k;
}
模板 4:原地反转(数组 / 字符串)数组用双指针对撞交换;C 字符串原地反转必须先用 strlen 定位 NUL,且只能按字节反转。
c
#include <stddef.h>
#include <string.h>

// 一、整型数组原地反转:双指针对撞交换
void reverseArray(int *a, int n) {
    for (int i = 0, j = n - 1; i < j; ++i, --j) {
        int t = a[i];
        a[i] = a[j];
        a[j] = t;
    }
}

// 二、C 字符串原地反转:长度由 strlen 给出,j 指向最后一个有效字节
// ⚠️ 这是字节级反转:对 UTF-8 中文会把多字节码点切碎成非法序列(详见 A3)
void reverseString(char *s) {
    if (s == NULL) return;
    for (size_t i = 0, j = strlen(s); i + 1 < j; ++i, --j) {
        char t = s[i];
        s[i] = s[j - 1];
        s[j - 1] = t;
    }
}

易错点

  • 数组传参后 sizeof 失真:void f(int a[10]) 的形参实际是 int *,函数内 sizeof(a) 得到指针大小(8 字节)而不是数组大小。长度必须单独传;宏 sizeof(a)/sizeof(a[0]) 只在实参那一层有效。
  • 字符串缓冲区溢出:strcpy / strcat / sprintf 只认 '\0' 不认容量,目标太小时静默踩烂相邻内存(UB)。必须用 snprintf(buf, sizeof buf, ...) 或手动 memcpy 并自己保证容量(详见 B2)。
  • strlen 写进循环条件:for (i = 0; i < strlen(s); ++i) 每轮重算一次长度,整体退化为 O(n²)。先 size_t n = strlen(s); 缓存。
  • atoi / atol 无法报错:"abc" 返回 0、溢出是 UB,与真正的 0 无法区分。要用 strtoll + endptr + 可选 errno 判断。
  • 有符号 / 无符号混用导致下溢:size_t n = strlen(s); for (int i = (int)n - 1; i >= 0; --i) 在 n == 0 时先算出 SIZE_MAX 再截断,行为依赖实现;正确写法是 for (size_t i = n; i-- > 0;) 或显式用 int 转换并单独判空(推导见 B3)。
  • 把二维数组强转成 int **:int a[3][4] 是一片连续的 12 个 int,int ** 是「指向指针的指针」,两者布局不同;强转后按 a[i][j] 解引用是 UB。应传 int (*p)[4] 或扁平化成一维加下标算术(详见 A2)。
02

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

深度解析 ↓

核心要点

  • 对撞双指针的前提是有序:和偏小移左、偏大移右,每一步都安全淘汰一端候选(推导见 A1)
  • 快慢指针「同向不同速」:去重、找中点、判环都靠固定步长差
  • 滑动窗口是双指针的特例:两端同向移动、窗口始终连续;右端扩张、左端按条件收缩(详见 A2)
  • 区间和用前缀和化为端点差 pre[j + 1] - pre[i];C 里 pre 要 malloc(n + 1) 且 pre[0] = 0
  • 差分与前缀和互为逆运算:一次区间加只改 d[l] 与 d[r + 1] 两个端点(详见 A3)
  • 滑窗成立的前提是元素非负(窗口和随右端单调不减);含负数时必须退回「前缀和 + 哈希表」(详见 B2)

复杂度速查

操作 / 场景时间空间说明
对撞双指针(有序数组)O(n)O(1)两个指针单向移动,总步数 ≤ 2n;若需先排序则 O(n log n)
不定长滑动窗口O(n)O(1)每个元素至多被右端加入、被左端移出各一次,摊还线性
定长滑动窗口O(n)O(1)每滑一步「进一个、出一个」,窗口长度不变
一维前缀和O(n) 预处理O(n)之后单次区间和 O(1);C 里要 malloc n+1 个 long long
前缀和 + 哈希表期望 O(n)O(n)把「区间和 = k」转成「两个前缀和相差 k」;哈希表要手写
差分数组区间加O(1) 每次O(n)只改两个端点;m 次修改后一次 O(n) 前缀和还原
二维前缀和O(m·n) 预处理O(m·n)容斥递推;C 里用扁平数组 r * n + c 存储

模板代码

模板 1:对撞双指针(有序数组两数之和)左右指针从两端逼近,依据当前和与目标的大小决定移动哪一侧;C 用 bool 返回 + 下标出参,未找到时不写脏出参。
c
#include <stdbool.h>
#include <stddef.h>

// 在升序数组 a(长度 n)中找两数之和等于 target,成功时写 *out_i / *out_j。
// 前提:a 已升序;失败返回 false 且不修改出参,调用方不会读到脏下标。
bool twoSumSorted(const long long *a, int n, long long target, int *out_i, int *out_j) {
    if (a == NULL || out_i == NULL || out_j == NULL || n < 2) return false;
    int l = 0, r = n - 1;
    while (l < r) {
        long long sum = a[l] + a[r];      // long long 防止两数相加溢出 32 位
        if (sum == target) {
            *out_i = l;
            *out_j = r;
            return true;
        }
        if (sum < target) ++l;            // 和偏小:左端太小,右移
        else --r;                         // 和偏大:右端太大,左移
    }
    return false;
}
模板 2:不定长滑动窗口(最短和为 target 的子数组)右端扩张、窗口满足条件后持续收缩取最短;前提元素全为正(含负数时单调性失效,详见 B2)。
c
#include <stddef.h>

// 求最短的连续子数组,使其元素和 >= target;不存在返回 0。
// 前提:a 中元素全为正且 target > 0;target <= 0 时空子数组已满足,约定返回 0。
int minSubArrayLen(long long target, const int *a, int n) {
    if (a == NULL || n <= 0 || target <= 0) return 0;
    int left = 0, best = n + 1;          // best 用 n+1 作哨兵,区分「没找到」
    long long sum = 0;
    for (int right = 0; right < n; ++right) {
        sum += a[right];                 // 右端扩张,把新元素纳入窗口
        while (sum >= target) {          // 已满足条件:收缩左端,寻找更短窗口
            int len = right - left + 1;
            if (len < best) best = len;
            sum -= a[left++];
        }
    }
    return best == n + 1 ? 0 : best;
}
模板 3:前缀和 + 手写哈希表(和为 K 的子数组)C 没有 unordered_map,用开放寻址表把 long long 前缀和映射到出现次数;线性探测、表长取 2 的幂。
c
#include <stddef.h>
#include <stdlib.h>

// 乘法散列:把任意 long long 键摊到 2 的幂个槽位上(无符号乘法回绕是定义良好的)。
static size_t slotOf(long long key, size_t mask) {
    return (size_t)((unsigned long long)key * 0x9e3779b97f4a7c15ULL) & mask;
}

// 统计元素和恰好等于 k 的连续子数组个数;内存分配失败返回 -1。
long long subarraySumK(const int *a, int n, long long k) {
    if (a == NULL || n <= 0) return 0;
    size_t cap = 2;
    while (cap < 2 * (size_t)n + 2) cap <<= 1;   // 保持负载 ≤ 1/2,线性探测才不退化
    long long *keys = malloc(cap * sizeof *keys); // 前缀和
    int *cnts = calloc(cap, sizeof *cnts);        // cnts[i] == 0 表示空槽(计数至少为 1)
    if (keys == NULL || cnts == NULL) { free(keys); free(cnts); return -1; }
    size_t mask = cap - 1;
    size_t z = slotOf(0, mask);                   // 空前缀和 0 先记为出现过一次
    keys[z] = 0;
    cnts[z] = 1;
    long long pre = 0, ans = 0;
    for (int i = 0; i < n; ++i) {
        pre += a[i];
        // 先查 pre - k:它之前出现过几次,就有几个以 i 结尾的合法区间
        size_t q = slotOf(pre - k, mask);
        while (cnts[q] != 0 && keys[q] != pre - k) q = (q + 1) & mask;
        if (cnts[q] != 0) ans += cnts[q];
        // 再把当前前缀和插入;先查后插,避免自己与自己配对
        size_t p = slotOf(pre, mask);
        while (cnts[p] != 0 && keys[p] != pre) p = (p + 1) & mask;
        keys[p] = pre;
        ++cnts[p];
    }
    free(keys);                                   // 临时表由本函数释放,调用方只拿到结果
    free(cnts);
    return ans;
}
模板 4:差分数组(区间批量加与还原)每次区间加只改 d[l]、d[r+1] 两个端点;全部改完后一次前缀和还原,再叠加到原数组。
c
#include <stdbool.h>
#include <stddef.h>
#include <stdlib.h>

// 对 base[0..n-1] 叠加 m 次闭区间加([l[i], r[i]] 每个元素加 v[i]),结果写入 out。
// out 由调用方提供;d 是本函数的临时内存,所有返回路径都会 free。
bool rangeAddRestore(const long long *base, int n, const int *l, const int *r,
                     const long long *v, int m, long long *out) {
    if (base == NULL || l == NULL || r == NULL || v == NULL || out == NULL) return false;
    if (n <= 0 || m < 0) return false;
    long long *d = calloc((size_t)n + 1, sizeof *d);  // 多留 d[n] 承接 r == n-1 的减标记
    if (d == NULL) return false;
    for (int i = 0; i < m; ++i) {
        if (l[i] < 0 || r[i] >= n || l[i] > r[i]) {   // 区间非法:干净地失败
            free(d);
            return false;
        }
        d[l[i]] += v[i];                              // 起点加
        d[r[i] + 1] -= v[i];                          // 终点之后减,绝不越界
    }
    long long cur = 0;
    for (int i = 0; i < n; ++i) {
        cur += d[i];                 // 前缀和 = 累计增量
        out[i] = base[i] + cur;      // 必须叠加原数组,差分本身只是增量标记
    }
    free(d);
    return true;
}

易错点

  • 无序数组上做对撞双指针:移动方向依赖「有序」才有单调依据,无序时结果随机出错;应先排序(O(n log n))或改用哈希表 / 前缀和方案(推导见 A1)。
  • 不定长窗口用 if 代替 while:求最短窗口时只收缩一次会漏掉更短的合法窗口,答案偏大;求最长窗口时一次收缩未必恢复合法,窗口状态错乱。收缩用 while,并先记录长度再移动左端(详见 A2)。
  • 前缀和下标偏移:区间 [i, j] 的和是 pre[j + 1] - pre[i](pre[0] = 0 表示空前缀);误写 pre[j] - pre[i] 会漏掉下标 i 的元素。C 里 pre 长度是 n + 1,少分配一格就是越界写 UB。
  • 前缀和溢出:int 累加 n 个较大元素极易溢出,而有符号溢出是 UB(不是回绕)。一律用 long long 累加;比较窗口和与 target 时也先转 64 位。
  • 差分还原语义写错:差分只记录「增量标记」,前缀和得到的是累计增量,必须叠加到原数组才是结果;且所有区间加都要在还原之前完成,中途还原只会看到半成品。
  • 临时数组与 VLA:用 int d[n] 这种变长数组既不是可移植选择(C11 起 VLA 是可选特性),又不受栈大小保证;一律 malloc + 显式 free,每个返回路径都要释放干净。
03

链表操作与快慢指针

深度解析 ↓

核心要点

  • 节点 = 数据域 + next 指针,内存不连续;C 里用 struct + malloc 建节点、free 回收(详见 A1)
  • 已持有前驱时插入 / 删除 O(1);按下标访问 O(n),没有随机访问
  • 虚拟头节点(dummy)统一「删头 / 插头」的边界情形;它是栈上局部变量,不需要也不允许 free(机制见 A1)
  • 反转三指针口诀:先存后继 → 再改指向 → 最后双指针一起前移
  • 快慢指针保持固定步长差:求中点、判环、定位环入口(推导见 A2)
  • 判环相遇后 head 与相遇点同步单步,必在环入口首次重合;C 里比较节点地址而不是值(详见 A1)

复杂度速查

操作 / 场景时间空间说明
访问第 k 个节点O(k)O(1)只能从头顺序走,无随机访问
已知前驱处插入 / 删除O(1)O(1)改指针即可,不搬移数据
迭代反转整条链表O(n)O(1)三指针原地改向,不分配新节点
Floyd 判环 + 找环入口O(n)O(1)相遇即有环;head 与相遇点同步单步定入口
快慢指针求中点O(n)O(1)快指针每次两步,到链尾时慢指针恰在中点
合并两条有序链表O(n+m)O(1)dummy + 尾指针原地拼接,不 new 节点
删除倒数第 N 个节点O(n)O(1)先后指针一次遍历;dummy 统一删头
遍历并释放整条链表O(n)O(1)必须先存 next 再 free,否则 use-after-free

模板代码

模板 1:反转单链表(迭代)三指针 prev / curr / nxt 原地改向,返回新头;只动 next 指针,不分配也不释放任何节点。
c
#include <stddef.h>

typedef struct Node {
    int val;
    struct Node *next;
} Node;

// 迭代反转,返回新头节点。节点所有权不变:谁 malloc 的仍由谁 free。
Node *reverseList(Node *head) {
    Node *prev = NULL;
    Node *curr = head;
    while (curr != NULL) {
        Node *nxt = curr->next;   // ① 先存后继,否则改向之后再也找不回剩余部分
        curr->next = prev;        // ② 反转指向
        prev = curr;              // ③ 两个指针一起前移
        curr = nxt;
    }
    return prev;                  // 原尾节点成为新头
}
模板 2:Floyd 判环 + 定位环入口快指针每次两步、慢指针每次一步;相遇后 head 与相遇点同步单步,首次重合处就是环入口。C 必须比较节点地址。
c
#include <stddef.h>

typedef struct Node {
    int val;
    struct Node *next;
} Node;

// 返回环入口节点;无环返回 NULL。只读链表,不修改指针、不分配内存。
Node *detectCycle(Node *head) {
    Node *slow = head;
    Node *fast = head;
    while (fast != NULL && fast->next != NULL) {   // 两个条件缺一不可
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast) {                        // 地址相同:两指针相遇,存在环
            Node *p = head;
            while (p != slow) {                    // 阶段二:同步单步
                p = p->next;
                slow = slow->next;
            }
            return p;                              // 首次重合处即环入口
        }
    }
    return NULL;                                   // 走到链尾:无环
}
模板 3:合并两个有序链表dummy 头节点 + 尾指针,原地摘链;只重排指针,不 malloc、不 free 节点。
c
#include <stddef.h>

typedef struct Node {
    int val;
    struct Node *next;
} Node;

// 合并两条升序链表,返回新头。dummy 在栈上,无需 free;
// 两个入参链表的节点所有权仍归调用方,本函数只改 next 指针。
Node *mergeTwoLists(Node *a, Node *b) {
    Node dummy = {0, NULL};          // 虚拟头节点:让「第一个节点」不再特殊
    Node *tail = &dummy;
    while (a != NULL && b != NULL) {
        if (a->val <= b->val) { tail->next = a; a = a->next; }
        else                  { tail->next = b; b = b->next; }
        tail = tail->next;
    }
    tail->next = (a != NULL) ? a : b;   // 剩余部分整段挂上,无需逐个搬运
    return dummy.next;
}
模板 4:dummy 删除倒数第 N 个节点先后指针一次遍历:fast 先走 n 步,再与 slow 同步走到链尾,slow->next 即目标;被删节点由本函数 free。
c
#include <stddef.h>
#include <stdlib.h>

typedef struct Node {
    int val;
    struct Node *next;
} Node;

// 删除倒数第 n 个节点,返回(可能变短的)新头;n 非法或越界时原样返回。
// 调用方把链表处置权交给本函数:被摘下的节点由本函数 free。
Node *removeNthFromEnd(Node *head, int n) {
    if (head == NULL || n <= 0) return head;
    Node dummy = {0, head};          // 栈上 dummy:删头也统一成「改前驱 next」
    Node *fast = &dummy;
    Node *slow = &dummy;
    for (int i = 0; i < n; ++i) {
        if (fast->next == NULL) return head;   // n 大于链表长度,不做删除
        fast = fast->next;
    }
    while (fast->next != NULL) {     // fast 在前,slow 始终落后 n 个节点
        fast = fast->next;
        slow = slow->next;
    }
    Node *victim = slow->next;       // 倒数第 n 个节点
    slow->next = victim->next;       // 摘链
    free(victim);                    // 所有权移交本函数:摘下即释放,避免泄漏
    return dummy.next;
}
模板 5:快慢指针求中点快指针每次两步、慢指针每次一步;快指针到链尾时慢指针恰在中点,偶数长度返回右中点。
c
#include <stddef.h>

typedef struct Node {
    int val;
    struct Node *next;
} Node;

// 返回中点节点;空链表返回 NULL,单节点返回它自己。
// 约定:偶数个节点返回「右中点」(第二个中间节点),与常见题解口径一致。
Node *middleNode(Node *head) {
    Node *slow = head;
    Node *fast = head;
    while (fast != NULL && fast->next != NULL) {   // 快指针每次两步
        slow = slow->next;                         // 慢指针每次一步
        fast = fast->next->next;
    }
    return slow;
}

易错点

  • 反转断链:先改 curr->next 再去读它保存后继,剩余链表从此不可达(泄漏且结果只剩一两个节点)。必须「先存后继、再改指向、最后前移」。
  • 快指针解引用空指针:fast->next->next 在 fast->next == NULL 时是未定义行为。循环条件必须同时判断 fast != NULL && fast->next != NULL,漏一个都会在偶数长度的链尾崩溃。
  • 用节点值判「同一节点」:Floyd 判环与找入口必须比较地址;链表里出现重复值时按值比较会提前误判相遇。C 里 slow == fast 比较的才是指针身份(详见 A1)。
  • 删头不用 dummy:删除首节点时必须特判并更新 head;dummy 把所有删除统一成「改前驱的 next」。注意 dummy 是栈变量,不能 free(&dummy),只有堆上 new 出来的节点才能释放(机制见 A1)。
  • 忽略返回值:C 按值传指针,reverseList(head); 忽略返回值等于什么都没做。凡是头节点可能改变的操作,调用方都必须写 head = f(head);。
  • 释放时机错位:遍历释放链表时必须先存 next 再 free 当前节点;节点还被别处引用时提前 free 会留下悬垂指针,之后任何解引用都是 UB。
04

栈、队列与哈希表

深度解析 ↓

核心要点

  • 栈(LIFO)只在栈顶 push / pop;C 里用「数组 + top 下标」,push 前必须判满(缓冲区写越界是 UB)(详见 A1)
  • 队列(FIFO)队尾入、队首出;用环形缓冲 + 取模,空与满必须能区分(留一格或另存 size)
  • 哈希表把键经哈希函数映射到桶:期望 O(1),冲突严重或哈希退化时最坏 O(n)(详见 A2)
  • 冲突处理两派:链地址法在桶上挂链、开放寻址在表内探测;C 里开放寻址不占额外指针,缓存更友好(详见 A2)
  • 负载因子超过阈值必须 rehash:单次 O(n)、摊还 O(1);C 里要重新 malloc 桶数组并逐键重新探测(推导见 B2)
  • C 没有 std::unordered_map:哈希函数、相等比较、探测循环、扩容全部自己写,换一种键类型几乎等于重写整张表

复杂度速查

操作 / 场景时间空间说明
数组栈 push / popO(1)O(n)只在栈顶操作;容量固定时先判满,动态扩容要自己 realloc
环形队列 enqueue / dequeueO(1)O(n)head / tail 取模,不搬移元素;空与满需另设判据
哈希表插入 / 查找 / 删除期望 O(1),最坏 O(n)O(n)冲突扎堆时线性探测退化为顺序扫描
哈希表 rehash单次 O(n),摊还 O(1)O(n)重建桶数组并把所有键重新插入
开放寻址表遍历所有键O(容量)O(1)必须扫过全部槽位,空槽也要检查
括号匹配O(n)O(n)每个字符至多进栈、出栈各一次

模板代码

模板 1:数组栈实现括号匹配定长数组 + top 下标,top 指向「下一个空位」;遇右括号弹栈比对,最后栈必须为空。全程不 malloc。
c
#include <stdbool.h>
#include <stddef.h>

#define STACK_CAP 128   // 定长栈容量:超限时明确返回 false,绝不做越界写

// 判断只含 ()[]{} 的字符串是否括号完全匹配;非括号字符一律视为不合法。
bool isValidBrackets(const char *s) {
    if (s == NULL) return false;
    char st[STACK_CAP];              // 栈分配上的数组,函数返回即自动回收
    int top = 0;                     // top = 下一个空位;栈顶元素是 st[top - 1]
    for (const char *p = s; *p != '\0'; ++p) {
        char c = *p;
        if (c == '(' || c == '[' || c == '{') {
            if (top == STACK_CAP) return false;   // 定长栈必须显式判满
            st[top++] = c;
        } else {
            char need;
            if (c == ')') need = '(';
            else if (c == ']') need = '[';
            else if (c == '}') need = '{';
            else return false;                    // 非括号字符
            if (top == 0 || st[top - 1] != need) return false;
            --top;                                // 匹配成功,弹出左括号
        }
    }
    return top == 0;                 // 栈空才算全部配对,别漏掉残余左括号
}
模板 2:手写开放寻址哈希表做频次统计键与计数都是 int,线性探测;计数为 0 表示空槽(存入的计数至少为 1)。表长取 2 的幂,负载压在 1/2 以下。
c
#include <stdbool.h>
#include <stddef.h>
#include <stdlib.h>

// 一次遍历统计每个整数的出现次数。结果写入 *out_keys / *out_cnts,不同键个数写 *out_m;
// 两个结果数组都是 malloc 的(容量 = *out_m),调用方负责分别 free。
bool countFreq(const int *a, int n, int **out_keys, int **out_cnts, int *out_m) {
    if (a == NULL || out_keys == NULL || out_cnts == NULL || out_m == NULL || n <= 0) return false;
    size_t cap = 8;
    while (cap < 2 * (size_t)n + 2) cap <<= 1;   // 负载 ≤ 1/2,线性探测才有空槽
    int *keys = malloc(cap * sizeof *keys);
    int *cnts = calloc(cap, sizeof *cnts);       // calloc 清零 = 所有槽位初始为空
    if (keys == NULL || cnts == NULL) { free(keys); free(cnts); return false; }
    size_t mask = cap - 1;
    for (int i = 0; i < n; ++i) {
        size_t j = (size_t)((unsigned)a[i] * 2654435761u) & mask;   // Knuth 乘法散列
        while (cnts[j] != 0 && keys[j] != a[i]) j = (j + 1) & mask; // 线性探测
        keys[j] = a[i];
        ++cnts[j];                               // 命中则计数加一,未命中则新键计数为 1
    }
    size_t m = 0;
    for (size_t j = 0; j < cap; ++j) if (cnts[j] != 0) ++m;
    int *ok = malloc(m * sizeof *ok);
    int *oc = malloc(m * sizeof *oc);
    if (ok == NULL || oc == NULL) { free(ok); free(oc); free(keys); free(cnts); return false; }
    size_t w = 0;
    for (size_t j = 0; j < cap; ++j) {
        if (cnts[j] != 0) {
            ok[w] = keys[j];
            oc[w] = cnts[j];
            ++w;
        }
    }
    free(keys);                                  // 临时哈希表由本函数释放
    free(cnts);
    *out_keys = ok;
    *out_cnts = oc;
    *out_m = (int)w;
    return true;
}
模板 3:两数之和(一次遍历哈希)边扫描边把「值 → 下标」存入哈希表,先查补数再插当前值,保证每个元素只用一次;C 用 bool 返回 + 下标出参。
c
#include <stdbool.h>
#include <stddef.h>
#include <stdlib.h>

// 找两个下标 i < j 使 a[i] + a[j] == target;成功返回 true 并写 *out_i / *out_j。
// 内部哈希表是临时内存,所有返回路径都会 free;调用方无需关心。
bool twoSum(const int *a, int n, int target, int *out_i, int *out_j) {
    if (a == NULL || out_i == NULL || out_j == NULL || n < 2) return false;
    size_t cap = 8;
    while (cap < 2 * (size_t)n + 2) cap <<= 1;   // 负载 ≤ 1/2
    long long *keys = malloc(cap * sizeof *keys);  // 存值,用 long long 防止减法溢出
    int *vals = malloc(cap * sizeof *vals);        // 存下标
    bool *used = calloc(cap, sizeof *used);        // 下标 0 合法,必须另设占用标记
    if (keys == NULL || vals == NULL || used == NULL) {
        free(keys); free(vals); free(used);
        return false;
    }
    size_t mask = cap - 1;
    for (int i = 0; i < n; ++i) {
        long long need = (long long)target - a[i];  // 先算需要配对的补数
        size_t j = (size_t)((unsigned long long)need * 0x9e3779b97f4a7c15ULL) & mask;
        while (used[j] && keys[j] != need) j = (j + 1) & mask;
        if (used[j]) {                              // 补数已出现过:找到答案
            *out_i = vals[j];
            *out_j = i;
            free(keys); free(vals); free(used);
            return true;
        }
        long long cur = (long long)a[i];
        size_t p = (size_t)((unsigned long long)cur * 0x9e3779b97f4a7c15ULL) & mask;
        while (used[p] && keys[p] != cur) p = (p + 1) & mask;
        if (!used[p]) {                             // 先查后插,保证元素不重复使用
            used[p] = true;
            keys[p] = cur;
            vals[p] = i;
        }
    }
    free(keys); free(vals); free(used);
    return false;
}

易错点

  • top 语义混乱:让 top 表示「下一个空位」时,空栈判 top == 0、栈顶是 st[top - 1];两种约定混用会读错元素或越界写(st[top] 在 top == STACK_CAP 时已是数组外)。
  • 括号匹配漏判收尾:只在中途比对括号、最后不检查栈空,"(" 会被误判为合法;反过来遇到右括号时栈空也必须立即失败,不能先弹再判。
  • 环形队列空满混淆:head == tail 既可能表示空也可能表示满,直接用 tail + 1 == head 判满还容易漏掉取模;稳妥做法是留一格空位或额外维护 size。
  • 开放寻址把 0 当空槽:0 是完全合法的键和值,用 0 表示空位会丢数据;必须另设 used 标记数组(本模板用「计数为 0」,仅因计数最小是 1 才成立)。
  • rehash 直接 memcpy 桶数组:扩容后掩码变了,每个键的正确槽位都变了,必须逐键重新探测插入;同时别忘了释放旧桶,任何 malloc 失败路径也要把已分配的部分 free 干净(推导见 B2)。
  • C 字符串不能直接当哈希键:== 比较的是指针,不是内容;要么自己写逐字符哈希 + strcmp,要么先把字符串编码成整数再入表。
05

堆与优先队列

深度解析 ↓

核心要点

  • 完全二叉树没有空洞,可直接用数组层序存储;0 基下标下父 (i - 1) / 2、左孩子 2i + 1、右孩子 2i + 2(详见 A1)
  • 堆序是弱序:只保证父子的大小关系,兄弟之间、整棵树都不是有序的
  • 两个基本动作:插入用上浮(sift-up),弹堆顶用下沉(sift-down),各自 O(log n)(详见 A2)
  • 自底向上建堆 O(n),优于逐个插入的 O(n log n);实现是从最后一个非叶节点倒序下沉(推导见 B1)
  • C 没有 priority_queue:小顶堆 / 大顶堆靠比较方向自己定,存结构体时比较字段要显式写清
  • 求前 K 大用小顶堆(堆顶是第 K 大、用于淘汰),求前 K 小用大顶堆;双堆中位数让两堆各存一半(详见 B2)

复杂度速查

操作 / 场景时间空间说明
取堆顶 peekO(1)O(1)根节点即当前最值
插入 pushO(log n)O(1)追加到数组末尾后上浮,最多走 log n 层
弹出堆顶 popO(log n)O(1)末尾元素补到根,再下沉修复
自底向上建堆O(n)O(1)从 n/2-1 倒序下沉,就地完成,比逐个插入快
堆排序O(n log n)O(1)反复弹出堆顶;就地但不稳定
Top-K(维护大小 k 的堆)O(n log k)O(k)遍历一次,堆只保留 k 个候选
合并 K 条有序链表O(N log k)O(k)小顶堆维护各链当前头,N 为总节点数
双堆求数据流中位数插入 O(log n),查询 O(1)O(n)大顶堆存较小一半,小顶堆存较大一半

模板代码

模板 1:手写上浮、下沉与自底向上建堆0 基大顶堆;上浮用于插入、下沉用于弹出堆顶;建堆从最后一个非叶节点倒序下沉,整体 O(n)。
c
#include <stddef.h>

// 0 基大顶堆:父 (i-1)/2,左孩子 2i+1,右孩子 2i+2。
// 堆序只要求「父 >= 子」,兄弟之间可以任意。
static void swapInt(int *a, int i, int j) {
    int t = a[i];
    a[i] = a[j];
    a[j] = t;
}

// 上浮:新元素从末尾向根走,用于插入。
void siftUp(int *a, int i) {
    while (i > 0) {
        int p = (i - 1) / 2;
        if (a[i] <= a[p]) break;    // 已满足堆序,停
        swapInt(a, i, p);
        i = p;
    }
}

// 下沉:根被末尾元素替换后向下修复,n 是当前堆的有效长度。
void siftDown(int *a, int i, int n) {
    while (1) {
        int l = 2 * i + 1, r = 2 * i + 2, big = i;
        if (l < n && a[l] > a[big]) big = l;   // 孩子下标必须先判界
        if (r < n && a[r] > a[big]) big = r;
        if (big == i) break;                   // 父已是三者最大
        swapInt(a, i, big);
        i = big;
    }
}

// 自底向上建堆(Floyd):从最后一个非叶节点倒着下沉,整体 O(n)。
void buildHeap(int *a, int n) {
    for (int i = n / 2 - 1; i >= 0; --i) siftDown(a, i, n);
}
模板 2:堆式 Top-K(前 K 大)维护大小为 k 的小顶堆,堆顶是当前第 k 大;新元素比堆顶大就替换,最后依次弹出即升序。
c
#include <stddef.h>
#include <stdlib.h>

// 小顶堆的上浮 / 下沉:Top-K 要淘汰的是最小值,所以用最小堆。
static void siftUpMin(int *a, int i) {
    while (i > 0) {
        int p = (i - 1) / 2;
        if (a[p] <= a[i]) break;
        int t = a[i]; a[i] = a[p]; a[p] = t;
        i = p;
    }
}

static void siftDownMin(int *a, int i, int n) {
    while (1) {
        int l = 2 * i + 1, r = 2 * i + 2, small = i;
        if (l < n && a[l] < a[small]) small = l;
        if (r < n && a[r] < a[small]) small = r;
        if (small == i) break;
        int t = a[i]; a[i] = a[small]; a[small] = t;
        i = small;
    }
}

// 返回前 k 大的元素(升序写入 out,out 容量至少 k);参数非法或分配失败返回 -1。
int topKLargest(const int *a, int n, int k, int *out) {
    if (a == NULL || out == NULL || k <= 0 || n < k) return -1;
    int *heap = malloc((size_t)k * sizeof *heap);   // 临时堆,函数返回前 free
    if (heap == NULL) return -1;
    int size = 0;
    for (int i = 0; i < n; ++i) {
        if (size < k) {                 // 堆未满:入堆并上浮
            heap[size] = a[i];
            siftUpMin(heap, size++);
        } else if (a[i] > heap[0]) {    // 比当前第 k 大还大:替换堆顶再下沉
            heap[0] = a[i];
            siftDownMin(heap, 0, size);
        }
    }
    for (int i = 0; i < k; ++i) {       // 小顶堆反复弹出,得到升序结果
        out[i] = heap[0];
        heap[0] = heap[--size];
        siftDownMin(heap, 0, size);
    }
    free(heap);
    return k;
}
模板 3:双堆求数据流中位数大顶堆存较小一半、小顶堆存较大一半;C 里只写一份小顶堆,较小一半取负存储即可当大顶堆用,两个堆容量都按 n 预留。
c
#include <stdbool.h>
#include <stddef.h>
#include <stdlib.h>

// 小顶堆上浮 / 下沉(0 基)。lo 存较小一半的相反数,于是 -lo[0] 就是较小一半的最大值。
static void siftUp(long long *a, int i) {
    for (int p; i > 0; i = p) {
        p = (i - 1) / 2;
        if (a[p] <= a[i]) break;
        long long t = a[i]; a[i] = a[p]; a[p] = t;
    }
}

static void siftDown(long long *a, int i, int n) {
    for (;;) {
        int l = 2 * i + 1, r = l + 1, m = i;
        if (l < n && a[l] < a[m]) m = l;
        if (r < n && a[r] < a[m]) m = r;
        if (m == i) break;
        long long t = a[i]; a[i] = a[m]; a[m] = t;
        i = m;
    }
}

// 依次读入 a[0..n-1],out[i] 为 a[0..i] 的中位数;两个堆是本函数的临时内存。
// 不变式:nl == nh 或 nl == nh + 1,中位数只与两个堆顶有关。
bool runningMedians(const int *a, int n, double *out) {
    if (a == NULL || out == NULL || n <= 0) return false;
    long long *lo = malloc((size_t)n * sizeof *lo), *hi = malloc((size_t)n * sizeof *hi);
    if (lo == NULL || hi == NULL) { free(lo); free(hi); return false; }
    int nl = 0, nh = 0;
    for (int i = 0; i < n; ++i) {
        lo[nl] = -(long long)a[i];
        siftUp(lo, nl++);                   // ① 新元素先放进较小一半
        if (nl > 1) {                       // ② lo 的堆顶是最大值,转给较大一半
            long long mx = -lo[0];
            lo[0] = lo[--nl];
            siftDown(lo, 0, nl);
            hi[nh] = mx;
            siftUp(hi, nh++);
        }
        if (nh > nl) {                      // ③ hi 多了就还一个最小值给 lo
            long long mn = hi[0];
            hi[0] = hi[--nh];
            siftDown(hi, 0, nh);
            lo[nl] = -mn;
            siftUp(lo, nl++);
        }
        out[i] = (nl > nh) ? (double)(-lo[0])
                           : ((double)(-lo[0]) + (double)hi[0]) / 2.0;
    }
    free(lo); free(hi);
    return true;
}
模板 4:合并 K 条有序链表(堆式 k 路归并)小顶堆维护每条链表当前头节点;每次弹出全局最小接到结果尾部,再把该链表下一个节点换进堆顶,O(N log k)。
c
#include <stddef.h>
#include <stdlib.h>

typedef struct Node {
    int val;
    struct Node *next;
} Node;

// 小顶堆下沉:堆里存的是各条链表当前的节点指针,按 val 比较(绝不能比较指针)。
static void siftDown(Node **h, int i, int n) {
    while (1) {
        int l = 2 * i + 1, r = 2 * i + 2, small = i;
        if (l < n && h[l]->val < h[small]->val) small = l;
        if (r < n && h[r]->val < h[small]->val) small = r;
        if (small == i) break;
        Node *t = h[i]; h[i] = h[small]; h[small] = t;
        i = small;
    }
}

// 合并 k 条升序链表,返回合并后的头;只重排 next 指针,不分配、不释放节点。
// heads 中允许出现空链表;全部为空返回 NULL。heap 是临时数组,退出前 free。
Node *mergeKLists(Node *const *heads, int k) {
    if (heads == NULL || k <= 0) return NULL;
    Node **heap = malloc((size_t)k * sizeof *heap);
    if (heap == NULL) return NULL;
    int size = 0;
    for (int i = 0; i < k; ++i)
        if (heads[i] != NULL) heap[size++] = heads[i];
    for (int i = size / 2 - 1; i >= 0; --i) siftDown(heap, i, size);  // O(k) 建堆
    Node dummy = {0, NULL};
    Node *tail = &dummy;
    while (size > 0) {
        Node *cur = heap[0];              // 当前全局最小节点
        tail->next = cur;
        tail = cur;
        if (cur->next != NULL) {          // 该链表还有后继:替换堆顶后下沉
            heap[0] = cur->next;
        } else {                          // 该链表已空:末尾节点补到堆顶
            heap[0] = heap[--size];
        }
        if (size > 0) siftDown(heap, 0, size);
    }
    tail->next = NULL;                    // 合并链表的尾部必须收口
    free(heap);
    return dummy.next;
}

易错点

  • 父子下标公式用错:0 基数组是父 (i - 1) / 2、左孩子 2i + 1、右孩子 2i + 2;把 1 基公式套进来会访问错位元素,越界后就是 UB,且结果往往「看起来接近正确」而极难排查。
  • 堆方向搞反:求前 K 大必须用小顶堆(堆顶是第 K 大,用来淘汰更小的值);用大顶堆会保留最小的 K 个,答案正好相反。
  • 用逐个 push 代替建堆:逐个插入是 O(n log n);自底向上建堆只需从最后一个非叶节点倒序下沉,总代价 O(n)(推导见 B1)。
  • 把堆当有序数组用:直接遍历堆的数组得到的是层序,不是有序序列;只有反复弹出堆顶才能得到有序结果(堆排序就是这么做的)。
  • 上浮 / 下沉边界漏判:上浮条件 i > 0;下沉时孩子下标要先 l < n 再取值。且 n 必须是当前堆大小——弹出后要先把 size 减一,再拿新的 size 下沉。
  • 双堆平衡与整数截断:较小一半的堆大小只能等于较大一半、或多 1;两堆顶用 int 相加再 / 2 会截断成整数,中位数必须用 double(或先转 64 位)。
06

二叉树遍历与递归思维

深度解析 ↓

核心要点

  • 前 / 中 / 后序是同一套 DFS 框架里代码的三个执行时机:前序=进入节点、后序=离开节点、中序=左子树处理完之后(详见 A1)
  • 后序位置天然适合「先求子问题、再合并结果」的分解思维;前序位置适合携带路径信息(详见 A2)
  • 层序遍历 = BFS + 队列;C 里手写数组队列,要按层处理就必须先固定本层节点数
  • 遍历思维用外部累加器,分解思维用返回值;C 里结果统一走「调用方缓冲区 + 长度出参」
  • BST 的中序序列严格递增;验证 BST 必须传递上下界,只比较直接父子会漏掉祖先约束(详见 A3)
  • 深树递归会耗尽系统栈:C 没有异常,爆栈直接 SIGSEGV,重要场景要改成显式栈(推导见 B1)

复杂度速查

操作 / 场景时间空间说明
前 / 中 / 后序遍历(递归)O(n)O(h)h 为树高:平衡树 O(log n),链状树 O(n)
层序遍历(BFS + 数组队列)O(n)O(w)w 为最大层宽;队列容量要能装下最宽一层
二叉树最大深度O(n)O(h)每个节点一次调用,后序位置合并
BST 验证(上下界)O(n)O(h)每个节点只访问一次;边界用 long long 防溢出
迭代中序遍历(显式栈)O(n)O(h)手动模拟系统栈,避免递归爆栈
由前序 + 中序重建O(n)O(n)中序里定位根:哈希 O(1),线性扫描则退化 O(n²)
节点计数 / 叶子计数O(n)O(h)任意遍历过程中累加即可

模板代码

模板 1:三种递归遍历(前 / 中 / 后)同一个 DFS 框架,访问语句换位置就是不同遍历;C 里用调用方数组 + 长度指针累积结果,不分配内存。
c
#include <stddef.h>

typedef struct TreeNode {
    int val;
    struct TreeNode *left;
    struct TreeNode *right;
} TreeNode;

// 三种遍历共用「调用方数组 + 写指针」的累积方式:*len 是已写入的元素个数。
// out 由调用方分配,容量至少为节点总数;遍历本身不做任何堆分配。
void preorder(TreeNode *root, int *out, int *len) {
    if (root == NULL) return;              // 空指针是递归的唯一出口
    out[(*len)++] = root->val;             // 前序位置:进入节点时访问
    preorder(root->left, out, len);
    preorder(root->right, out, len);
}

void inorder(TreeNode *root, int *out, int *len) {
    if (root == NULL) return;
    inorder(root->left, out, len);
    out[(*len)++] = root->val;             // 中序位置:左子树处理完之后
    inorder(root->right, out, len);
}

void postorder(TreeNode *root, int *out, int *len) {
    if (root == NULL) return;
    postorder(root->left, out, len);
    postorder(root->right, out, len);
    out[(*len)++] = root->val;             // 后序位置:离开节点时访问
}
模板 2:层序遍历(BFS + 手写队列,按层输出)用 malloc 的指针数组当队列;每轮先固定本层节点数 sz,才能正确分层。结果扁平写入 out,level_sizes 记录每层个数。
c
#include <stddef.h>
#include <stdlib.h>

typedef struct TreeNode {
    int val;
    struct TreeNode *left;
    struct TreeNode *right;
} TreeNode;

// 层序遍历:out 按层扁平写入节点值,level_sizes 记录每层节点数。
// 两个数组由调用方分配,容量都至少为节点总数 nn;返回层数,malloc 失败返回 -1。
int levelOrder(TreeNode *root, int nn, int *out, int *level_sizes) {
    if (root == NULL) return 0;                      // 空树 0 层
    if (nn <= 0 || out == NULL || level_sizes == NULL) return -1;
    TreeNode **q = malloc((size_t)nn * sizeof *q);   // 每个节点至多入队一次,nn 足够
    if (q == NULL) return -1;
    int head = 0, tail = 0, used = 0, levels = 0;
    q[tail++] = root;
    while (head < tail) {
        int sz = tail - head;               // 先固定本层节点数,内层只处理 sz 个
        level_sizes[levels] = sz;
        for (int i = 0; i < sz; ++i) {
            TreeNode *node = q[head++];
            out[used++] = node->val;
            if (node->left != NULL) q[tail++] = node->left;
            if (node->right != NULL) q[tail++] = node->right;
        }
        ++levels;
    }
    free(q);                                // 临时队列由本函数释放
    return levels;
}
模板 3:二叉树最大深度(分解思维)空树深度为 0;先递归求左右子树深度,再在后序位置合并——「先子问题、后合并」的标准形态。
c
#include <stddef.h>

typedef struct TreeNode {
    int val;
    struct TreeNode *left;
    struct TreeNode *right;
} TreeNode;

// 最大深度按「节点数」计:空树 0、单节点 1(LeetCode 104 口径)。
// 递归深度等于树高;链状树会占 O(n) 栈帧,工程上应改用模板 5 的显式栈。
int maxDepth(TreeNode *root) {
    if (root == NULL) return 0;                // 空树深度为 0
    int l = maxDepth(root->left);              // 先求左右子问题
    int r = maxDepth(root->right);
    return 1 + (l > r ? l : r);                // 后序位置:合并子问题结果
}
模板 4:BST 验证(上下界法)每个节点必须严格落在 (lo, hi) 开区间内;C 没有闭包,上下界直接作为递归参数传递,边界用 long long 避免 INT_MIN / INT_MAX 误判。
c
#include <limits.h>
#include <stdbool.h>
#include <stddef.h>

typedef struct TreeNode {
    int val;
    struct TreeNode *left;
    struct TreeNode *right;
} TreeNode;

// 上下界法:node 的所有左后代必须 < node,所有右后代必须 > node。
bool isValidBSTImpl(TreeNode *root, long long lo, long long hi) {
    if (root == NULL) return true;
    if (root->val <= lo || root->val >= hi) return false;   // 祖先约束在这里检查
    return isValidBSTImpl(root->left, lo, root->val) &&
           isValidBSTImpl(root->right, root->val, hi);
}

// 对外入口:整棵树的值域是全部 int,向外各放宽 1 得到开区间。
bool isValidBST(TreeNode *root) {
    return isValidBSTImpl(root, (long long)INT_MIN - 1, (long long)INT_MAX + 1);
}
模板 5:迭代中序遍历(显式栈)手动模拟系统栈:一路向左压栈,弹栈访问后转向右子树;外层 while 的两个条件缺一不可,用来规避深树递归爆栈。
c
#include <stddef.h>
#include <stdlib.h>

typedef struct TreeNode {
    int val;
    struct TreeNode *left;
    struct TreeNode *right;
} TreeNode;

// 显式栈中序遍历;cap 为 out 的容量(至少为节点总数)。
// 返回写入的节点数;参数非法或 malloc 失败返回 -1。栈是本函数的临时内存。
int inorderIterative(TreeNode *root, int *out, int cap) {
    if (root == NULL) return 0;
    if (out == NULL || cap <= 0) return -1;
    TreeNode **stack = malloc((size_t)cap * sizeof *stack);
    if (stack == NULL) return -1;
    int top = 0, cnt = 0;
    TreeNode *cur = root;
    while (cur != NULL || top > 0) {        // 两个条件缺一不可
        while (cur != NULL) {               // 一路向左压栈,最深不超过 cap
            stack[top++] = cur;
            cur = cur->left;
        }
        cur = stack[--top];                 // 弹出并访问:此时左子树已处理完
        out[cnt++] = cur->val;
        cur = cur->right;                   // 转向右子树
    }
    free(stack);
    return cnt;
}

易错点

  • 递归入口不判空:if (root == NULL) return; 必须是函数第一句;改成判断子节点是否为空再递归,会漏掉对空指针的解引用检查,叶子节点之后直接崩溃。
  • 迭代中序外层条件写残:只写 while (top > 0) 时根节点还没入栈就退出,结果为空;内层压栈完后必须 cur = cur->right,漏掉这一步会在左子树里死循环。
  • BST 验证只比较直接父子:left->val < root->val < right->val 只检查局部关系,祖先约束抓不到——层序 [10, 5, 15, null, null, 6, 20] 里的 6 在 10 的右子树中却小于 10。必须传上下界(推导见 B2)。
  • 层序遍历没固定本层大小:内层若用实时的 tail - head 作循环条件,会把下一层节点也卷进本层,分层结果完全错乱;必须先把 sz = tail - head 存下来。
  • 遍历结果数组越界:C 不知道数组长度,节点总数必须由调用方预算并作为容量传入;写满后继续写是 UB 而不是异常,函数必须先检查容量或由调用方保证。
  • 深树递归爆栈:系统栈通常只有几 MB,链状树几十万节点就会 SIGSEGV,而 C 没有异常可以捕获;关键路径改用显式栈(模板 5),递归版只用于深度可控的场景(推导见 B1)。
07

排序与二分搜索

深度解析 ↓

核心要点

  • 比较排序的时间下界是 O(n log n);计数 / 基数排序以「非比较」换速度,按值域计 O(n + k)
  • 稳定性 = 相等元素的相对次序不变;C 标准不规定 qsort 用哪种算法,也不保证稳定,多关键字排序要么在比较器里补 tie-breaker,要么改归并(详见 A3)
  • 二分的前提是「有序」或「单调谓词」:闭区间 [l, r] 配 l <= r、收缩到 mid ± 1;半开区间 [l, r) 配 l < r、用 r = mid 保留候选,两套写法不可混用(详见 A1;OI Wiki · 二分)
  • mid = l + (r - l) / 2 防 l + r 溢出;qsort / bsearch 的比较器必须满足严格弱序,不能写 return a - b
  • C 标准库没有 lower_bound:bsearch 只保证返回「任意一个」匹配元素,找左右边界必须手写二分(详见 A1)
  • 二分答案把最优化问题转成单调的可行性判定:在答案值域 [lo, hi] 上二分 check(mid),总代价 O(log V × check 的代价)(推导见 B3)

复杂度速查

操作 / 场景时间空间说明
有序数组二分查找O(log n)O(1)每轮候选减半,只需 l、r、mid 三个变量
lower_bound 左边界O(log n)O(1)找第一个 >= target 的位置,可能等于 n
二分答案O(log V × T_check)O(1)V 为答案值域跨度;check 必须单调、上界必须可行
qsort平均 O(n log n) / 最坏 O(n²)O(log n) 栈标准不规定算法与稳定性;比较器是 void * 版本
bsearchO(log n)O(1)要求数组已按同一比较器排好序,命中位置不唯一
快速排序(原地)平均 O(n log n) / 最坏 O(n²)O(log n) 平均栈原地、不稳定;有序输入会退化,随机基准可缓解
归并排序O(n log n)O(n)稳定;临时数组由函数内 malloc 或调用方提供
计数排序O(n + k)O(k)k 为值域大小;稳定,但只适用于整数键

模板代码

模板 1:闭区间二分查找在升序数组中用 [0, n-1] 查找目标值,命中返回下标、否则 -1;同一块给出 qsort / bsearch 共用的 void * 比较器写法。
c
#include <stddef.h>
#include <stdlib.h>

// 闭区间 [0, n-1]:命中返回下标,否则返回 -1
int binarySearch(const int *a, int n, int target) {
    if (a == NULL || n <= 0) return -1;
    int l = 0, r = n - 1;
    while (l <= r) {
        int mid = l + (r - l) / 2;          // 防 l + r 有符号溢出
        if (a[mid] == target) return mid;
        if (a[mid] < target) l = mid + 1;
        else r = mid - 1;
    }
    return -1;
}

// qsort / bsearch 共用的比较器:必须满足严格弱序
int cmpInt(const void *pa, const void *pb) {
    int x = *(const int *)pa, y = *(const int *)pb;
    return (x > y) - (x < y);               // 不写 x - y:相减可能溢出(UB)
}

// qsort:不保证稳定——相等元素的相对次序可能改变
void sortInts(int *a, int n) {
    qsort(a, (size_t)n, sizeof *a, cmpInt);
}

// bsearch:命中返回「任意一个」匹配元素的指针,重复键时位置不确定
const int *bsearchInt(const int *a, int n, int key) {
    if (a == NULL || n <= 0) return NULL;
    return bsearch(&key, a, (size_t)n, sizeof *a, cmpInt);
}
模板 2:左边界二分(lower_bound)返回第一个 >= target 的下标(可能等于 n);半开区间 [l, r) 配 r = mid 保留候选,是找「插入位置 / 左右边界」的通用写法。
c
#include <stdbool.h>

// 半开区间 [l, r):返回第一个 >= target 的下标,可能等于 n
int lowerBound(const int *a, int n, int target) {
    int l = 0, r = n;
    while (l < r) {
        int mid = l + (r - l) / 2;
        if (a[mid] < target) l = mid + 1;
        else r = mid;                       // mid 仍可能是答案,不能丢
    }
    return l;
}

// 精确查找:lower_bound + 相等校验;有重复键时天然返回第一个
bool contains(const int *a, int n, int target) {
    int i = lowerBound(a, n, target);
    return i < n && a[i] == target;
}
模板 3:二分答案(最小可行值)在答案值域 [lo, hi] 上二分最小可行值;C 没有闭包/模板,check 用函数指针 + const void * 上下文传状态,调用方须保证 hi 本身可行。
c
#include <stdbool.h>

// 判定函数类型:x 为待检验的答案,ctx 是只读上下文(由调用方保证生命周期)
typedef bool (*FeasibleFn)(long long x, const void *ctx);

// 在 [lo, hi] 上求最小可行值:谓词须「前假后真」
long long minFeasible(long long lo, long long hi, FeasibleFn check, const void *ctx) {
    while (lo < hi) {
        long long mid = lo + (hi - lo) / 2;
        if (check(mid, ctx)) hi = mid;      // mid 可行 → 答案在左半
        else lo = mid + 1;                  // mid 不可行 → 答案在右半
    }
    return lo;                              // 无可行值时返回 hi,故调用方须保证 hi 可行
}

// 示例:Koko 吃香蕉——速度为 speed 时能否在 limit 小时内吃完所有香蕉
typedef struct { const int *piles; int n; long long limit; } KokoCtx;

bool canFinish(long long speed, const void *ctx) {
    const KokoCtx *c = ctx;
    long long hours = 0;
    for (int i = 0; i < c->n; ++i) {
        hours += ((long long)c->piles[i] + speed - 1) / speed;   // 上取整
        if (hours > c->limit) return false;  // 提前截断,防累加溢出
    }
    return true;
}
模板 4:快速排序 partition(双指针划分)以区间首元素为基准、双指针从两端夹逼原地划分,返回基准的最终下标;先递归短边可把递归栈深压到 O(log n),但基准不随机化时有序输入仍退化为 O(n²)。
c
#include <stddef.h>

// 划分 [lo, hi]:以 a[lo] 为基准双指针夹逼,返回基准最终下标
static int partition(int *a, int lo, int hi) {
    int pivot = a[lo], i = lo, j = hi;
    while (i < j) {
        while (i < j && a[j] >= pivot) --j;  // 右侧找严格小于基准的元素
        while (i < j && a[i] <= pivot) ++i;  // 左侧找严格大于基准的元素
        if (i < j) { int t = a[i]; a[i] = a[j]; a[j] = t; }
    }
    a[lo] = a[i];                            // i == j,a[i] 必 <= pivot
    a[i] = pivot;                            // 基准归位
    return i;
}

// 原地快排:先递归短边、长边用循环处理,栈深 O(log n);最坏时间仍是 O(n²)
void quickSort(int *a, int n) {
    if (a == NULL || n <= 1) return;
    int lo = 0, hi = n - 1;
    while (lo < hi) {
        int p = partition(a, lo, hi);
        if (p - lo < hi - p) {
            quickSort(a + lo, p - lo);       // 短边递归(长度 p - lo)
            lo = p + 1;                      // 长边留给下一轮循环
        } else {
            quickSort(a + p + 1, hi - p);    // 短边在右侧(长度 hi - p)
            hi = p - 1;
        }
    }
}
模板 5:归并排序(稳定)对半切分、递归排序、线性合并;合并时用 <= 取左段优先以保证稳定,临时数组一次 malloc、所有返回路径统一 free。
c
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>

// 合并 a[lo, mid) 与 a[mid, hi):先写入 tmp,再整体拷回 a[lo, hi)
static void mergeRange(int *a, int *tmp, int lo, int mid, int hi) {
    int i = lo, j = mid, k = 0;
    while (i < mid && j < hi) {
        if (a[i] <= a[j]) tmp[k++] = a[i++];  // 取等号 → 稳定
        else tmp[k++] = a[j++];
    }
    while (i < mid) tmp[k++] = a[i++];
    while (j < hi) tmp[k++] = a[j++];
    memcpy(a + lo, tmp, (size_t)k * sizeof *a);
}

// 递归入口:区间用半开 [lo, hi)
static void mergeSortRange(int *a, int *tmp, int lo, int hi) {
    if (hi - lo <= 1) return;
    int mid = lo + (hi - lo) / 2;
    mergeSortRange(a, tmp, lo, mid);
    mergeSortRange(a, tmp, mid, hi);
    mergeRange(a, tmp, lo, mid, hi);
}

// 归并排序:稳定、恒 O(n log n);递归深度 O(log n),不会爆栈
bool mergeSort(int *a, int n) {
    if (a == NULL || n <= 0) return false;
    int *tmp = malloc((size_t)n * sizeof *tmp);
    if (tmp == NULL) return false;            // 分配失败:a 保持原样
    mergeSortRange(a, tmp, 0, n);
    free(tmp);
    return true;
}
模板 6:计数排序(值域小)对落在 [0, k) 的整数键做线性时间排序:统计频次 → 求前缀和 → 逆序回填;稳定,遇到值域外的元素直接失败返回。
c
#include <stdbool.h>
#include <stdlib.h>

// 计数排序:a 中元素必须落在 [0, k);结果写入 out(长度 n,由调用方提供)
bool countingSort(const int *a, int n, int k, int *out) {
    if (a == NULL || out == NULL || n < 0 || k <= 0) return false;
    int *cnt = calloc((size_t)k, sizeof *cnt);       // calloc 保证初值 0
    if (cnt == NULL) return false;
    for (int i = 0; i < n; ++i) {
        if (a[i] < 0 || a[i] >= k) { free(cnt); return false; }  // 值域外:拒绝
        ++cnt[a[i]];
    }
    for (int v = 1; v < k; ++v) cnt[v] += cnt[v - 1]; // 前缀和 → 元素的目标末位置
    for (int i = n - 1; i >= 0; --i)                  // 逆序回填 → 稳定
        out[--cnt[a[i]]] = a[i];
    free(cnt);
    return true;
}

易错点

  • 闭区间与半开区间混用:while (l <= r) 里写 r = mid,或 while (l < r) 里写 r = mid - 1,区间可能不收缩而死循环。规则只有一条:闭区间收缩到 mid ± 1,半开区间用 r = mid(详见 A1)。
  • mid 写成 (l + r) / 2:l、r 接近 INT_MAX 时相加溢出,而有符号溢出是 UB;统一写 l + (r - l) / 2。
  • 比较器用减法:return a - b 在两数异号或接近边界时会溢出;返回值只需符号正确,写 (x > y) - (x < y)。同时必须严格弱序——把 <= 当比较器会破坏 qsort 的前提。
  • 以为 qsort 稳定:标准只要求排好序,不保证相等元素的次序;需要稳定时改用归并 / 计数排序,或在比较器里补第二关键字(详见 A3)。
  • 把 bsearch 当 lower_bound 用:重复键时返回哪一个不确定,bsearch 也无法告诉你「第一个 >= target」的位置;边界必须手写二分(详见 A1)。
  • 快排对有序输入爆栈:首元素基准在已排序输入上每次只切掉一个元素,时间退化为 O(n²)、递归深度 O(n);先递归短边、长边循环,或随机化基准(推导见 B1)。
08

回溯与深度广度优先搜索

深度解析 ↓

核心要点

  • 回溯三要素:路径 path、当前选择列表、结束条件;做选择 → 递归 → 撤销必须严格成对、顺序对称(详见 A1)
  • 回溯的代价由解空间树大小决定(全排列 O(n!·n)、子集 O(2ⁿ·n)),剪枝越早越好,剪枝质量比常数优化重要(详见 B2)
  • DFS 用递归或显式栈,BFS 用队列;网格是隐式图——每格是节点、方向数组现算邻居,不需要存边(OI Wiki · DFS)
  • BFS 首次到达即最短(边权全为 1);「发现即标记」必须在入队时完成,否则同一节点会重复入队
  • C 里结果用 malloc 的扁平数组 + 行优先下标;path 是复用缓冲区,收集时要逐元素拷贝快照(详见 A1)
  • 递归深度:网格连通块的递归层数最坏 O(m·n),C 默认栈只有 1~8 MB,深网格应改显式栈或 BFS(推导见 B1)

复杂度速查

操作 / 场景时间空间说明
全排列回溯O(n!·n)O(n) 递归栈n! 个叶子,每个叶子拷贝 n 个元素;结果本身 O(n·n!)
子集 / 组合回溯O(2ⁿ·n)O(n)每个元素选 / 不选,共 2ⁿ 个分支节点
网格 DFS(flood fill)O(m·n)O(m·n) 最坏递归栈每格至多访问一次;蛇形连通块可递归 m·n 层
连通块计数O(m·n)O(m·n)每个未访问起点触发一次 DFS(OI Wiki · DFS)
网格 BFS 最短步数O(m·n)O(m·n)每格至多入队一次,首次到达即最短
多源 BFSO(m·n)O(m·n)所有源点先入队,一次扩散求最近源距离(OI Wiki · BFS)

模板代码

模板 1:回溯通用框架(全排列)路径 + 选择列表 + 结束条件,做选择与撤销严格成对;C 没有 vector,结果用 malloc 的扁平数组按行优先存放,每个叶子手动拷贝快照。
c
#include <stdbool.h>
#include <stdlib.h>

// 回溯:depth 为已选元素个数;res 是扁平数组,第 row 个排列占 [row*n, row*n+n)
static void backtrack(const int *nums, int n, bool *used, int *path, int depth,
                      int *res, int *rows) {
    if (depth == n) {                       // 结束条件:得到一个完整排列
        int base = *rows * n;
        for (int i = 0; i < n; ++i) res[base + i] = path[i];  // 手动拷贝快照
        ++*rows;
        return;                             // 必须 return,否则继续 for 会越界
    }
    for (int i = 0; i < n; ++i) {
        if (used[i]) continue;              // 已选过:剪枝
        used[i] = true;                     // 做选择
        path[depth] = nums[i];
        backtrack(nums, n, used, path, depth + 1, res, rows);
        used[i] = false;                    // 撤销选择,恢复现场
    }
}

// 全排列:返回解的个数(失败 -1);*out_res 由本函数 malloc,调用方负责 free
int permute(const int *nums, int n, int **out_res) {
    if (nums == NULL || out_res == NULL || n <= 0 || n > 9) return -1;  // 9! 上限防乘法溢出
    int fact = 1;
    for (int i = 2; i <= n; ++i) fact *= i;
    int *res = malloc((size_t)fact * (size_t)n * sizeof *res);
    bool *used = calloc((size_t)n, sizeof *used);
    int *path = malloc((size_t)n * sizeof *path);
    if (res == NULL || used == NULL || path == NULL) {
        free(res); free(used); free(path);  // 三次分配要统一回收
        return -1;
    }
    int rows = 0;
    backtrack(nums, n, used, path, 0, res, &rows);
    free(used);
    free(path);                             // 临时数组释放,res 交给调用方
    *out_res = res;
    return rows;
}
模板 2:网格 DFS(flood fill)把二维网格当隐式图,按行优先扁平化后用 r * n + c 定位;原地改写代替 visited,统计四连通块个数。
c
#include <stddef.h>

// 原地沉岛:网格按行优先扁平化,g[r * n + c] == '1' 表示陆地
static void dfsIsland(char *g, int m, int n, int r, int c) {
    if (r < 0 || r >= m || c < 0 || c >= n || g[r * n + c] != '1') return;
    g[r * n + c] = '0';                     // 标记已访问(沉岛)
    dfsIsland(g, m, n, r - 1, c);
    dfsIsland(g, m, n, r + 1, c);
    dfsIsland(g, m, n, r, c - 1);
    dfsIsland(g, m, n, r, c + 1);
}

// 统计四连通岛屿数量;会修改 g(需保留原图请先拷贝)
int numIslands(char *g, int m, int n) {
    if (g == NULL || m <= 0 || n <= 0) return 0;
    int cnt = 0;
    for (int r = 0; r < m; ++r)
        for (int c = 0; c < n; ++c)
            if (g[r * n + c] == '1') {
                ++cnt;                      // 新的连通块
                dfsIsland(g, m, n, r, c);
            }
    return cnt;
}
模板 3:BFS 最短步数队列 + 距离数组逐层推进,dist = -1 同时充当 visited;入队时立即置距离,首次弹出目标格即最短步数。
c
#include <stdlib.h>

// 4 向偏移:上、下、左、右
static const int DR[4] = {-1, 1, 0, 0};
static const int DC[4] = {0, 0, -1, 1};

// 网格最短步数:'0' 可走、'1' 障碍;返回 (sr,sc) 到 (tr,tc) 的最少步数,不可达 -1
int bfsSteps(const char *g, int m, int n, int sr, int sc, int tr, int tc) {
    if (g == NULL || m <= 0 || n <= 0 || sr < 0 || sr >= m || sc < 0 || sc >= n ||
        tr < 0 || tr >= m || tc < 0 || tc >= n) return -1;
    if (g[sr * n + sc] == '1' || g[tr * n + tc] == '1') return -1;
    int total = m * n;
    int *dist = malloc((size_t)total * sizeof *dist);   // -1 表示未访问
    int *q = malloc((size_t)total * sizeof *q);         // 每格最多入队一次
    if (dist == NULL || q == NULL) { free(dist); free(q); return -1; }
    for (int i = 0; i < total; ++i) dist[i] = -1;
    int head = 0, tail = 0;
    int start = sr * n + sc;
    dist[start] = 0;
    q[tail++] = start;                       // 入队即标记
    while (head < tail) {
        int cur = q[head++];
        if (cur == tr * n + tc) {            // 首次到达即最短
            int steps = dist[cur];
            free(dist); free(q);
            return steps;
        }
        int r = cur / n, c = cur % n;        // 扁平下标还原行列
        for (int k = 0; k < 4; ++k) {
            int nr = r + DR[k], nc = c + DC[k];
            if (nr < 0 || nr >= m || nc < 0 || nc >= n) continue;
            int ni = nr * n + nc;
            if (g[ni] == '1' || dist[ni] != -1) continue;   // 障碍或已访问
            dist[ni] = dist[cur] + 1;
            q[tail++] = ni;
        }
    }
    free(dist); free(q);                     // 不可达:两条路径统一释放
    return -1;
}

易错点

  • 撤销不对称:used[i] = true 之后若某条路径提前 return 却没恢复,后续分支会读到被污染的状态,结果丢解或错解。做选择与撤销必须紧贴递归调用、顺序严格对称(详见 A1)。
  • 结束条件后忘记 return:depth == n 时记录快照后必须立刻返回,否则循环继续执行,写出越界结果或重复解。
  • BFS 出队才标记:同一节点会被多个邻居重复入队,队列膨胀、复杂度退化;正确做法是入队时就写 dist / visited(详见 A2;OI Wiki · BFS)。
  • 网格越界判断顺序写反:必须先判 r、c 边界再访问 g[r * n + c];若用无符号类型算 r - 1,还会下溢成巨大值(详见 A2)。
  • 把 path 指针直接存进结果:所有行都指向同一块内存,最终得到的全是最后一个排列;必须逐元素拷贝 / memcpy 快照(详见 A1)。
  • 递归爆栈:1000×1000 的蛇形岛屿可递归 10⁶ 层,Linux 默认 8 MB 栈大约几万层就崩;大数据要改显式栈或 BFS(推导见 B1)。
09

动态规划模型与状态转移

深度解析 ↓

核心要点

  • DP 思维链固定为四步:定状态(dp[i] 到底表示什么)→ 写转移方程 → 设初始化边界 → 定遍历顺序(详见 A1;OI Wiki · 动态规划)
  • 两大实现范式:自顶向下记忆化(递归 + 缓存表)与自底向上递推(循环填表);C 里两者都要自己申请表并释放(详见 B2)
  • 可 DP 的两个前提:最优子结构(大问题的最优解由子问题最优解构成)与无后效性(当前状态确定后,后续与到达它的路径无关)(详见 A3)
  • 0-1 背包一维数组必须逆序更新容量,完全背包必须正序;方向写反就是换了一道题(推导见 B1)
  • 空间压缩:只依赖上一行 / 前一列时,二维表可滚动成一维,O(nm) → O(min(n, m))(详见 B1)
  • C 实现要点:二维表用一次 malloc 的行主序扁平数组,所有返回路径统一 free;答案可能超 int 时用 long long,INF + 1 要用安全值

复杂度速查

操作 / 场景时间空间说明
爬楼梯 / 斐波那契O(n)O(1)只依赖前两个状态,两个滚动变量即可
打家劫舍(选 / 不选)O(n)O(1)转移只依赖 dp[i-1] 与 dp[i-2]
0-1 背包O(nW)O(W)一维容量数组,内层容量逆序
完全背包O(nW)O(W)一维容量数组,内层容量正序
LCS(最长公共子序列)O(nm)O(nm),滚动后 O(min(n,m))相等取斜对角 +1,不等取上 / 左较大者
编辑距离O(nm)O(nm),滚动后 O(min(n,m))增、删、改三种操作取最小再加 1
LIS(朴素 DP)O(n²)O(n)每个元素向前找可接续者;可二分优化到 O(n log n)

模板代码

模板 1:一维 DP 骨架(爬楼梯 / 打家劫舍)状态只依赖前两项时,两个滚动变量就能把空间降到 O(1);C 里用 long long 避免 int 在 n = 47 附近溢出。
c
#include <stddef.h>

// 爬楼梯:dp[i] = dp[i-1] + dp[i-2],滚动变量 O(1) 空间
long long climbStairs(int n) {
    if (n <= 0) return 0;                  // 题目约定 n >= 1,这里只做兜底
    long long prev2 = 1, prev1 = 1;        // dp[0] = dp[1] = 1
    for (int i = 2; i <= n; ++i) {
        long long cur = prev1 + prev2;     // long long:n = 47 时 int 已溢出
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}

// 打家劫舍:选第 i 间就不能选 i-1,dp[i] = max(dp[i-1], dp[i-2] + nums[i])
long long rob(const int *nums, int n) {
    if (nums == NULL || n <= 0) return 0;
    long long prev2 = 0, prev1 = 0;        // dp[i-2]、dp[i-1]
    for (int i = 0; i < n; ++i) {
        long long take = prev2 + nums[i];  // 选:接 dp[i-2]
        long long skip = prev1;            // 不选:继承 dp[i-1]
        long long cur = take > skip ? take : skip;
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}
模板 2:0-1 背包(一维逆序)每件物品只能取一次;一维容量数组必须从大到小更新,才能保证 dp[j - w[i]] 仍是「上一件物品」的状态。
c
#include <stdlib.h>

// 0-1 背包:dp[j] = 容量不超过 j 时的最大价值;cap 为背包容量
long long knapsack01(const int *w, const int *v, int n, int cap) {
    if (w == NULL || v == NULL || n <= 0 || cap < 0) return 0;
    long long *dp = calloc((size_t)cap + 1, sizeof *dp);   // 求最大值,初值全 0
    if (dp == NULL) return -1;                             // -1 与合法答案 0 区分开
    for (int i = 0; i < n; ++i) {
        if (w[i] < 0 || w[i] > cap) continue;              // 装不下 / 非法重量
        for (int j = cap; j >= w[i]; --j) {                // 逆序:每件只用一次
            long long cand = dp[j - w[i]] + v[i];
            if (cand > dp[j]) dp[j] = cand;
        }
    }
    long long best = dp[cap];
    free(dp);                                              // 唯一出口前统一释放
    return best;
}
模板 3:编辑距离(二维扁平表)把「s 前 i 个字符转成 t 前 j 个字符」作为状态;C 里用一次 malloc 的扁平数组,下标写成 i * (m + 1) + j。
c
#include <limits.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

// 编辑距离:dp[i * (m + 1) + j] = s 前 i 个字符变成 t 前 j 个字符的最少操作数
int editDistance(const char *s, const char *t) {
    if (s == NULL || t == NULL) return -1;
    size_t ns = strlen(s), nt = strlen(t);
    if (ns > (size_t)INT_MAX || nt > (size_t)INT_MAX) return -1;  // 下标用 int,防截断
    int n = (int)ns, m = (int)nt;
    size_t cols = (size_t)m + 1;
    size_t rows = (size_t)n + 1;
    if (cols > (SIZE_MAX / sizeof(int)) / rows) return -1;  // 分配前防乘法溢出
    int *dp = malloc(rows * cols * sizeof *dp);
    if (dp == NULL) return -1;                     // 失败时没有已分配内存要回收
    for (int i = 0; i <= n; ++i) dp[(size_t)i * cols] = i;    // 删 i 个字符
    for (int j = 0; j <= m; ++j) dp[j] = j;                   // 插入 j 个字符
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            size_t cur = (size_t)i * cols + (size_t)j;
            size_t up = (size_t)(i - 1) * cols + (size_t)j;
            size_t left = cur - 1;
            size_t diag = up - 1;
            if (s[i - 1] == t[j - 1]) {
                dp[cur] = dp[diag];                // 字符相同,无需操作
            } else {
                int best = dp[up] < dp[left] ? dp[up] : dp[left];  // 删 / 插
                if (dp[diag] < best) best = dp[diag];              // 改
                dp[cur] = best + 1;
            }
        }
    }
    int ans = dp[(size_t)n * cols + m];
    free(dp);
    return ans;
}
模板 4:最长公共子序列(LCS)状态是「s 前 i 个字符与 t 前 j 个字符的 LCS 长度」:字符相等接斜对角 +1,不等取上 / 左较大者;二维扁平表同样可滚动成 O(min(n, m))。
c
#include <limits.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

// LCS:dp[i * (m + 1) + j] = s 前 i 个字符与 t 前 j 个字符的最长公共子序列长度
int lcsLength(const char *s, const char *t) {
    if (s == NULL || t == NULL) return -1;
    size_t ns = strlen(s), nt = strlen(t);
    if (ns > (size_t)INT_MAX || nt > (size_t)INT_MAX) return -1;
    int n = (int)ns, m = (int)nt;
    size_t cols = (size_t)m + 1;
    size_t rows = (size_t)n + 1;
    if (cols > (SIZE_MAX / sizeof(int)) / rows) return -1;  // 分配前防乘法溢出
    int *dp = calloc(rows * cols, sizeof *dp);  // 第 0 行 / 列天然为 0
    if (dp == NULL) return -1;
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            size_t cur = (size_t)i * cols + (size_t)j;
            if (s[i - 1] == t[j - 1]) {
                size_t diag = (size_t)(i - 1) * cols + (size_t)(j - 1);
                dp[cur] = dp[diag] + 1;              // 相等:接在斜对角之后
            } else {
                int up = dp[(size_t)(i - 1) * cols + (size_t)j];  // 上一行
                int left = dp[cur - 1];                            // 本行左边
                dp[cur] = up > left ? up : left;
            }
        }
    }
    int ans = dp[(size_t)n * cols + m];
    free(dp);
    return ans;
}

易错点

  • 0-1 背包内层写成正序:同一件物品会被反复选取,结果退化成完全背包;必须 for (j = cap; j >= w[i]; --j),下界写成 j >= w[i] 也顺带避免了负下标(推导见 B1)。
  • 初始化语义与无穷大取值写错:只求「不超过容量」的最大值才全部置 0;若要求「恰好装满」,除 dp[0] = 0 外其余应置 -INF,求最小值时边界置 +INF。且不能拿 INT_MAX / LLONG_MAX 直接参与加法(UB),惯用 INT_MAX / 2 或 LLONG_MAX / 2 当无穷大(推导见 A1)。
  • 二维表用 int **:C 的行主序扁平数组一次 malloc、一次 free,元素连续且缓存友好;int ** 要 N 次分配、地址不连续,还容易在失败路径泄漏(详见 A1)。
  • strlen 写进循环条件:for (i = 1; i <= strlen(s); ++i) 每轮重算 O(n),整体 O(n²);先存 size_t 再转 int,别忘截断检查。
  • 滚动数组方向写反:0-1 背包逆序、完全背包正序;二维压一维时若转移依赖 dp[i-1][j-1],要先临时保存左上角再覆盖(详见 B1)。
  • 状态定义含糊:dp[i] 是「以 i 结尾」还是「前 i 个」没写清,转移方程必然写错;先写定义、再写方程(详见 A1)。
10

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

深度解析 ↓

核心要点

  • 稀疏图默认用邻接表 O(n + m):C 里用手写前向星 head / to / nxt 三个数组;稠密图改用扁平邻接矩阵 w[u * n + v],查边 O(1)(详见 A1)
  • DFS 用递归或显式栈,BFS 用队列;两者都必须在「发现时」标记 visited。递归 DFS 深度可达 n,深图要换显式栈(详见 A2)
  • 拓扑排序 = Kahn 算法(入度表 + 队列):不断弹出入度为 0 的点并删去它的出边;输出顶点数 < n 就说明有环
  • Dijkstra 要求边权非负;C 没有优先队列,要么手写小顶堆做到 O((n + m) log n),要么用邻接矩阵 O(n²) 扫描(选型推导见 B3;OI Wiki · 最短路)
  • 并查集用 parent 数组:parent[i] < 0 表示根、绝对值是集合大小,配路径压缩 + 按大小合并后单次操作摊还 O(α(n))(推导见 B1;OI Wiki · 并查集)
  • 距离与边权和用 long long,不可达用 LLONG_MAX 表示但不要直接拿它做加法;所有手写结构都要在返回前 free

复杂度速查

操作 / 场景时间空间说明
邻接表存储—O(n + m)前向星三个数组;遍历某点邻居耗时 O(deg)
邻接矩阵存储—O(n²)查任意两点是否相邻 O(1),稠密图更省指针
DFS / BFS 遍历O(n + m)O(n)每个顶点、每条边各访问一次
Kahn 拓扑排序O(n + m)O(n)入度表 + 队列;输出数 == n 才无环(OI Wiki · 拓扑排序)
Dijkstra(手写二叉堆)O((n + m) log n)O(n + m)非负权;每次松弛可能入堆,懒删除
Dijkstra(邻接矩阵扫描)O(n²)O(n)稠密图 / 小图更简单,不需要堆
并查集 find / uniteO(α(n)) 摊还O(n)路径压缩 + 按大小合并,α 实际约等于常数
Bellman-Ford(负权兜底)O(n·m)O(n)允许负权边,可判负环(OI Wiki · 最短路)

模板代码

模板 1:邻接表构图 + DFS 遍历稀疏图的默认存储:head / to / nxt 前向星,头插法 O(1) 建边;DFS 用显式栈替代递归,避免深图爆栈,且入栈即标记。
c
#include <stdbool.h>
#include <stdlib.h>

// 判断有向图(边 u[i] -> v[i])中 s 能否到达 t;顶点编号须在 [0, n) 内
bool reachable(int n, const int *u, const int *v, int m, int s, int t) {
    if (u == NULL || v == NULL || n <= 0 || m < 0 || s < 0 || s >= n || t < 0 || t >= n)
        return false;
    size_t ecap = (size_t)(m > 0 ? m : 1);       // malloc(0) 可能返回 NULL,至少给 1
    int *head = malloc((size_t)n * sizeof *head); // head[x]:x 的第一条出边下标
    int *to = malloc(ecap * sizeof *to);          // to[e]:边 e 的终点
    int *nxt = malloc(ecap * sizeof *nxt);        // nxt[e]:下一条边的下标,-1 结束
    int *stk = malloc((size_t)n * sizeof *stk);   // 显式栈:每点最多入栈一次
    bool *vis = calloc((size_t)n, sizeof *vis);
    if (head == NULL || to == NULL || nxt == NULL || stk == NULL || vis == NULL) {
        free(head); free(to); free(nxt); free(stk); free(vis);
        return false;
    }
    for (int i = 0; i < n; ++i) head[i] = -1;
    for (int i = 0; i < m; ++i) {                 // 头插法建邻接表
        to[i] = v[i];
        nxt[i] = head[u[i]];
        head[u[i]] = i;
    }
    int top = 0;
    stk[top++] = s;
    vis[s] = true;                                // 入栈即标记
    while (top > 0) {
        int x = stk[--top];
        for (int e = head[x]; e != -1; e = nxt[e]) {
            int y = to[e];
            if (!vis[y]) {
                vis[y] = true;                    // 发现即标记,避免重复入栈
                stk[top++] = y;
            }
        }
    }
    bool ok = vis[t];
    free(head); free(to); free(nxt); free(stk); free(vis);  // 临时结构统一释放
    return ok;
}
模板 2:Kahn 拓扑排序(含判环)统计入度 → 入度为 0 的点入队 → 出队删边;最后比较输出个数与 n 判定有环,返回 bool 把「有环」和「成功」分开,避免用空数组表达失败的歧义。
c
#include <stdbool.h>
#include <stdlib.h>

// 拓扑排序:边 u[i] -> v[i];顶点编号须在 [0, n) 内;成功返回 true 并把拓扑序写入 out(长度 n,调用方提供)
bool topoSort(int n, const int *u, const int *v, int m, int *out) {
    if (u == NULL || v == NULL || out == NULL || n <= 0 || m < 0) return false;
    size_t ecap = (size_t)(m > 0 ? m : 1);
    int *head = malloc((size_t)n * sizeof *head);
    int *to = malloc(ecap * sizeof *to);
    int *nxt = malloc(ecap * sizeof *nxt);
    int *indeg = calloc((size_t)n, sizeof *indeg); // 入度表,初值 0
    int *q = malloc((size_t)n * sizeof *q);        // 队列:每个点至多入队一次
    if (head == NULL || to == NULL || nxt == NULL || indeg == NULL || q == NULL) {
        free(head); free(to); free(nxt); free(indeg); free(q);
        return false;
    }
    for (int i = 0; i < n; ++i) head[i] = -1;
    for (int i = 0; i < m; ++i) {                  // 建表 + 统计入度
        to[i] = v[i];
        nxt[i] = head[u[i]];
        head[u[i]] = i;
        ++indeg[v[i]];
    }
    int qh = 0, qt = 0;
    for (int i = 0; i < n; ++i)
        if (indeg[i] == 0) q[qt++] = i;            // 入度为 0 的先入队
    int cnt = 0;
    while (qh < qt) {
        int x = q[qh++];
        out[cnt++] = x;
        for (int e = head[x]; e != -1; e = nxt[e])
            if (--indeg[to[e]] == 0) q[qt++] = to[e];
    }
    bool ok = (cnt == n);                          // 输出不足 n 个 → 剩下的点卡在环上
    free(head); free(to); free(nxt); free(indeg); free(q);
    return ok;
}
模板 3:Dijkstra 最短路(手写小顶堆)邻接表 + 手写二叉堆:C 没有 priority_queue,堆里存 (distance, vertex),用「弹出值与当前 dist 不符就跳过」实现懒删除;只允许非负权。
c
#include <limits.h>
#include <stdbool.h>
#include <stdlib.h>

// C 没有 priority_queue:手写小顶堆,元素是 (dist, 顶点),按 dist 比较
typedef struct { long long d; int v; } HeapNode;

// 上浮:i 为刚插入元素的下标
static void heapUp(HeapNode *h, int i) {
    while (i > 0) {
        int p = (i - 1) / 2;
        if (h[p].d <= h[i].d) break;
        HeapNode t = h[p]; h[p] = h[i]; h[i] = t;
        i = p;
    }
}

// 下沉:n 为当前堆大小,i 为待下沉元素下标
static void heapDown(HeapNode *h, int n, int i) {
    for (;;) {
        int l = 2 * i + 1, r = l + 1, best = i;
        if (l < n && h[l].d < h[best].d) best = l;
        if (r < n && h[r].d < h[best].d) best = r;
        if (best == i) return;
        HeapNode t = h[i]; h[i] = h[best]; h[best] = t;
        i = best;
    }
}

// 单源最短路:边 u[i] -> v[i]、权 w[i],要求顶点编号在 [0, n)、w[i] >= 0;可达返回 true 并写 *out_d
bool dijkstra(int n, const int *u, const int *v, const int *w, int m, int s, int t,
              long long *out_d) {
    if (u == NULL || v == NULL || w == NULL || out_d == NULL || n <= 0 || m < 0 ||
        s < 0 || s >= n || t < 0 || t >= n) return false;
    size_t ecap = (size_t)(m > 0 ? m : 1);
    int *head = malloc((size_t)n * sizeof *head);
    int *to = malloc(ecap * sizeof *to);
    int *nxt = malloc(ecap * sizeof *nxt);
    int *wt = malloc(ecap * sizeof *wt);
    long long *dist = malloc((size_t)n * sizeof *dist);
    HeapNode *heap = malloc(((size_t)n + ecap) * sizeof *heap);  // 松弛成功才入堆,≤ n + m
    if (head == NULL || to == NULL || nxt == NULL || wt == NULL ||
        dist == NULL || heap == NULL) {
        free(head); free(to); free(nxt); free(wt); free(dist); free(heap);
        return false;
    }
    for (int i = 0; i < n; ++i) { head[i] = -1; dist[i] = LLONG_MAX; }
    for (int i = 0; i < m; ++i) {                  // 头插法建邻接表
        to[i] = v[i];
        wt[i] = w[i];
        nxt[i] = head[u[i]];
        head[u[i]] = i;
    }
    int hs = 0;
    dist[s] = 0;
    heap[hs].d = 0; heap[hs].v = s;
    heapUp(heap, hs++);
    while (hs > 0) {
        HeapNode cur = heap[0];
        heap[0] = heap[--hs];
        heapDown(heap, hs, 0);
        if (cur.d > dist[cur.v]) continue;         // 过期条目:懒删除
        for (int e = head[cur.v]; e != -1; e = nxt[e]) {
            int y = to[e];
            if (cur.d + wt[e] < dist[y]) {         // 松弛:非负权保证 cur.d 已是最终值
                dist[y] = cur.d + wt[e];
                heap[hs].d = dist[y]; heap[hs].v = y;
                heapUp(heap, hs++);
            }
        }
    }
    bool ok = dist[t] != LLONG_MAX;
    if (ok) *out_d = dist[t];
    free(head); free(to); free(nxt); free(wt); free(dist); free(heap);
    return ok;
}
模板 4:并查集(路径压缩 + 按大小合并)parent 数组用负数表示根(绝对值是集合大小),两步迭代实现路径压缩;初始化、合并、计数各一个函数,malloc 出的数组由调用方 free。
c
#include <stdbool.h>
#include <stddef.h>
#include <stdlib.h>

// 创建并查集:返回 malloc 的 parent 数组(长度 n,每个元素自成一集合),调用方负责 free
int *dsuInit(int n) {
    if (n <= 0) return NULL;
    int *parent = malloc((size_t)n * sizeof *parent);
    if (parent == NULL) return NULL;
    for (int i = 0; i < n; ++i) parent[i] = -1;   // 负数 = 根,绝对值 = 集合大小
    return parent;
}

// 找根:先沿 parent 上溯,再第二遍把路径上的点直接挂到根上(路径压缩)
static int findRoot(int *parent, int x) {
    int r = x;
    while (parent[r] >= 0) r = parent[r];
    while (x != r) {
        int p = parent[x];
        parent[x] = r;
        x = p;
    }
    return r;
}

// 合并 a、b 所在集合:已在同一集合返回 false;按大小合并保证树高 O(log n)
bool unite(int *parent, int a, int b) {
    int ra = findRoot(parent, a), rb = findRoot(parent, b);
    if (ra == rb) return false;
    if (parent[ra] > parent[rb]) { int t = ra; ra = rb; rb = t; }  // 负数越小集合越大
    parent[ra] += parent[rb];
    parent[rb] = ra;
    return true;
}

// 连通块个数:根(parent[i] < 0)的个数
int countSets(const int *parent, int n) {
    int cnt = 0;
    for (int i = 0; i < n; ++i)
        if (parent[i] < 0) ++cnt;
    return cnt;
}

易错点

  • 有向 / 无向加边混淆:无向图必须加 u→v 与 v→u 两条边(前向星要开 2m 空间),漏一条会错算连通性和最短路;有向图多加了反向边则拓扑结果出错。
  • visited 标记时机:迭代 DFS 要在入栈时、BFS 要在入队时标记;拖到出栈 / 出队才标记会让同一顶点被重复压入,队列膨胀甚至结果错乱(详见 A2)。
  • Dijkstra 用于负权边:存在负权时一个「已确定」的点可能被后续绕行推翻,贪心前提失效;负权请退回 Bellman-Ford(OI Wiki · 最短路)。
  • 无穷大直接参与加法:LLONG_MAX + w 会溢出成负数(UB),而且「不可达」的点被松弛后变成可达;要么先判 dist[x] == LLONG_MAX,要么用 LLONG_MAX / 2 当 INF(详见 A3)。
  • 并查集初始化语义错:parent[i] = i 与 parent[i] = -1 是两套实现,混用会让「根」的判断失效;路径压缩写成递归时,极端链可能爆栈,用模板里的两趟迭代更稳(详见 B1)。
  • 拓扑排序判环只看队列空:队列空了不代表排完,必须比较 cnt == n;有环时剩余顶点的入度永远减不到 0(详见 A2)。
PART B · DEEP DIVE

🔍 深度解析

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

01

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

速查 ↑

原理与机制

A1 · 索引即地址偏移:C 标准把数组定义为「同类型元素的连续非空序列」,并把 a[i] 规定为 *(a + i) 的语法糖——第 i 个元素的地址等于 (char *)a + i * sizeof(T),因此按下标访问是一次乘加,与数组多长无关,这是 O(1) 随机访问的全部秘密(cppreference · 数组)。由此能推出一个反直觉的推论:a[i] 等价于 i[a],因为下标运算符两侧都只参与加法;也能推出「数组名不是指针」——它只在除 sizeof、&、字符串字面量初始化之外的大多数表达式中退化为指向首元素的指针,所以 sizeof(a) 在定义的同一作用域里才是真实大小(comp.lang.c FAQ)。

A2 · 行主序与二维数组的真实类型:int a[3][4] 的类型不是「int 的二维数组」,而是「3 个元素、每个元素是 int[4]」的一维数组;它在内存里就是 12 个连续的 int,地址 &a[i][j] = 基址 + (i * 4 + j) * sizeof(int),这就是行主序(cppreference · 数组)。因此二维数组作参数只能退化成「指向 int[4] 的指针」,即 int (*p)[4];它与 int **p 完全不是一个类型——后者指向一片「指针数组」,每个指针再指向别处,按 p[i][j] 连续访问是未定义行为(cppreference · 指针)。工程上的两种通用做法:显式写 int (*p)[n](n 必须是编译期常量或 C11 可选 VLA 参数),或者干脆把矩阵扁平化成 int *a 加 m * n,自己用 r * n + c 算下标,可移植性最好。

A3 · 字符串是约定,不是类型:C 没有字符串类型,只有「以 '\0' 结尾的字符数组」这一约定,字符串函数靠扫描 NUL 判断边界,所以 strlen 是 O(n) 而非 O(1)(cppreference · strlen)。char、signed char、unsigned char 是三个不同类型,char 是否有符号由实现定义,把 char 直接当 0..255 的字节用会在个别平台翻车;需要按字节处理时应显式转成 unsigned char(SEI CERT C)。文本层面还要记住:UTF-8 是变长编码,strlen 数的是字节数,中文一个字通常 3 字节,按字节反转会把码点切碎成非法序列——这是 C 与「有 Unicode 字符串类型」的语言最大的分歧。

推导与权衡

B1 · 动态数组的摊还分析:C 标准库没有动态数组,只能自己维护 (data, len, cap) 三元组。尾部追加时若 len == cap 就把容量按倍数 k 扩容(realloc 搬迁):扩容发生在长度为 1, k, k², … 时,第 j 次搬迁约 k^(j-1) 个元素,n 次追加的总搬迁量 ≤ n·k/(k−1),平摊到每次是 O(1)(man7 · malloc(3))。k 的取值是取舍:k 越大扩容次数越少、但最坏浪费越多;主流实现取 1.5~2。必须记住 realloc 的失败语义——失败返回 NULL 且原内存块仍然有效,所以正确写法是 int *t = realloc(a, newcap * sizeof *t); if (t == NULL) { /* a 还没释放 */ } else { a = t; },直接 a = realloc(a, ...) 会丢掉原指针造成泄漏(man7 · realloc(3))。

B2 · 为什么 C 的字符串 API 如此危险:字符串函数把「容量」这个安全前提完全交给调用方,因为标准库选择「零开销 + 可与任何编码互操作」的设计:不存长度前缀、不做边界检查,代价是溢出与悬垂指针成为 C 的最高发事故(SEI CERT C)。snprintf 是标准库里少见的「温和」接口:它接受缓冲区容量、返回「本来需要写多少字节」,可以用 返回 >= cap 判断截断,因此拼接口一律该用它而不是 sprintf(man7 · snprintf(3))。注意它不是万能:格式串 %s 仍要求参数是合法 NUL 结尾串,且 snprintf 返回负值表示编码错误。

B3 · 边界检查的成本与 C 的取舍:C 不做越界检查,换来的是零运行时开销与可预测的机器码;安全性被交给工具链:AddressSanitizer 在对象周围插红区(redzone)并在影子内存里记录可用性,越界与 use-after-free 在下一次访问时就会被抓住(AddressSanitizer);Valgrind 用动态二进制翻译模拟 CPU,能查出未初始化读取与非法释放,代价是 10~50 倍变慢(Valgrind)。这不是「C 不安全」的抱怨,而是分工:语言给最小抽象,检查放在开发期工具里。同一枚硬币的反面是类型本身:size_t 的减法在 0 处会回绕成 SIZE_MAX(无符号回绕是定义良好的,但结果几乎总是错的),有符号溢出则是未定义行为,不会被任何「回绕」兜底——所以边界检查与类型检查在 C 里都外包给了程序员,工具链只能事后抓(cppreference · 未定义行为、GCC 告警选项)。

变体与扩展

  • 柔性数组成员:struct { size_t n; int a[]; } 让头信息与数据一次 malloc 分配(malloc(sizeof(struct S) + n * sizeof(int))),既省一次指针跳转,也省一次 free,是 C 里实现「带长度的数组」的标准手法。
  • 环形缓冲:定长数组 + head/tail 取模即可实现固定容量队列,读写都是 O(1),无需回收内存;「留一个空位区分满/空」与「另行维护 size」是最常见的两种实现取舍。
  • 前缀和 / 差分:对数组预处理一次 O(n),区间求和与区间加都能降到 O(1),是第 2 章的核心加速器;C 里前缀和数组要 malloc(n + 1) 并记得释放。
  • 网格即图:二维网格可视为隐式图——每个格子是节点、邻居由方向数组现算,无需存边;这就是模板 1 的方向数组,BFS / DFS 可直接套用。
  • 字符映射表:把 'a'..'z' 映射到 0..25 用长度 26 的计数数组,比哈希表省一个数量级的常数,是字符串计数类问题的惯用法。

经典练习

  • LeetCode 27 · 移除元素(原地删除,快慢指针雏形)
  • LeetCode 26 · 删除有序数组中的重复项
  • LeetCode 344 · 反转字符串(原地双指针对撞)
  • LeetCode 151 · 反转字符串中的单词(切分 + 拼接 + 反转)
  • LeetCode 88 · 合并两个有序数组(从后往前原地写,避免覆盖)
  • LeetCode 200 · 岛屿数量(二维网格 + 方向数组 + DFS/BFS)
  • LeetCode 73 · 矩阵置零(原地标记,注意首行首列的复用)

C 与其他语言对比与陷阱

语言关键差异与陷阱
C++• std::string / std::vector 自带长度与容量,但数组作参数同样退化成指针;std::array 才能保住长度
• operator[] 不做边界检查(UB),at() 才抛异常
• c_str() 返回的指针在字符串被修改或析构后失效
Rust• 切片 &[T] 自带长度,越界必然 panic,不会静默破坏内存
• str 保证 UTF-8 且不能按字节下标访问,&s[i..j] 的端点必须落在字符边界
• Vec 自动扩容与释放,没有 realloc 失败语义要处理
Python• list / str 自带长度,支持负索引,越界抛 IndexError
• str 不可变,s[i] 返回长度为 1 的新字符串
• 二维列表靠「列表的列表」实现,[[0]*n]*m 的别名共享是经典坑

延伸阅读

  • cppreference · 数组 — 数组类型的定义、退化规则、多维数组的行主序布局与 VLA 说明
  • cppreference · 指针 — 数组指针 int (*)[n] 与指针数组 int *[n] 的类型区别
  • cppreference · strlen — 字符数组与字符串的区别、NUL 结尾约定与 O(n) 复杂度
  • man7 · realloc(3) — 扩容、搬迁、失败时原块保留的语义与内存泄漏陷阱
  • GNU C 库手册 — glibc 的 malloc / realloc / free 实现模型、分配器调优与内存对齐
  • SEI CERT C — 数组越界、缓冲区溢出、有符号溢出的编码规则清单
  • comp.lang.c FAQ — 数组与指针的经典误解问答(含 a[i] == i[a] 等)
  • AddressSanitizer — 越界、use-after-free 的运行时检测原理与编译选项
  • Hello 算法 · 数组 — 中文图解「索引即地址偏移」与随机访问 O(1)
02

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

速查 ↑

原理与机制

A1 · 单调性带来的候选集裁剪:双指针能把 O(n²) 降成 O(n),靠的不是「多了一个变量」,而是数据自身的单调性让暴力候选集可以整块作废。以有序数组两数之和为例:若 nums[l] + nums[r] < target,则固定 r 时任何 l' < l 只会得到更小的和,于是「左端更小」的整行候选一次排除;反之 nums[l] + nums[r] > target 时 --r 砍掉整列。每一步至少排除一行或一列,候选表格收缩成一条阶梯,两个指针各自只朝一个方向移动、至多 n 步(OI Wiki · 双指针)。C 里对撞指针可以写下标 int l = 0, r = n - 1,也可以写 const int *p = a, *q = a + n - 1;但 n == 0 时 a + n - 1 已经落在数组之外,C 的指针算术只允许落在 [a, a + n](含一个越尾位置),所以函数入口必须先判 n <= 0 再走指针版(cppreference · 指针)。

A2 · 滑窗不变量的摊还代价:滑动窗口是双指针的「连续区间」特例,其正确性依赖一条不变量:窗口 [left, right] 始终满足(或始终不满足)某个能 O(1) 增量维护的条件,例如窗口和、窗口内不同字符数、字符频次。C 里维护窗口状态有两个惯用选择:字符集固定时用栈上定长数组 int cnt[128],访问 O(1) 且零分配;键域任意时只能手写哈希表,或先做坐标压缩再映射到数组。窗口「扩张一次、收缩若干次」看起来是嵌套循环,但两个端点都只向右推进、从不回退,总步数上界见 B1。另一个 C 专属陷阱是窗口和的类型:用 int 累加时,n 稍大就会在 2^31 附近触发有符号溢出,C 标准把这种情况定义为未定义行为而不是回绕,必须用 long long 或 int64_t 累加(OI Wiki · 双指针、cppreference · 未定义行为)。

A3 · 前缀和与差分互为逆运算:前缀和把「区间求和」这个本来 O(n) 的运算拆成两个端点的减法,依据是加法的结合律:S[0] = 0、S[i+1] = S[i] + a[i],于是区间 [l, r] 的和为 S[r+1] - S[l]。差分是它的逆运算:对原数组做「区间加 v」等价于在差分数组上只改 D[l] += v、D[r+1] -= v 两处,再对 D 求一次前缀和就还原出更新后的数组。C 里有两个必须记牢的边界:长度为 n 的原数组需要 n + 1 个前缀和元素(S[0] 代表空区间),这片内存要 malloc 并检查 NULL,且调用方负责 free;r + 1 == n 时写 D[n] 只有在差分数组开足 n + 1 个元素时才合法,若按 n 开就是典型越界 UB。用 calloc 可以直接拿到全零的差分数组,省去一次 memset(OI Wiki · 前缀和与差分、cppreference · calloc)。

推导与权衡

B1 · 总步数上界:滑动窗口的复杂度可以严格写成求和:right 从 0 走到 n−1 共 n 次扩张,left 全程只增不减、累计不超过 n 次收缩,每次扩张或收缩都是 O(1) 的增量更新,所以总操作数 ≤ 2n = O(n)——嵌套 while 并不等于 O(n²),判据是「内层循环的循环变量是否全局单调」。这个结论有两个前提:窗口条件必须允许「一直扩张到满足」的单调过程;窗口信息的增删必须是 O(1)。一旦窗口信息需要整段重建(例如每步都重新统计频次或比较整张表),内层代价升为 O(窗口长度),总复杂度退化为 O(n·窗口);C 里 strlen、整表 memcmp、逐字节扫描都会在循环体内埋下这种隐藏因子。反过来,若窗口信息是频次、不同键计数这类结构,要么用定长数组,要么手写哈希表来保证 O(1) 增删(OI Wiki · 双指针、OI Wiki · 哈希表)。

B2 · 负数输入下滑窗失效:滑窗不变量的前提是「右端右移只会让约束单调变化」,而一旦数组含负数,窗口和不再随右端单调递增,收缩条件失去依据,滑窗要么漏解要么死循环。正确替代是「前缀和 + 哈希表」:sum(l..r) = k 等价于 S[r+1] - S[l] = k,扫描时对每个 j 查询有多少个 i < j 满足 S[i] = S[j] - k,用「前缀和 → 出现次数」的映射一次遍历即可统计。C 没有 unordered_map:要么手写开放寻址哈希表(键为 long long,负载因子超阈值就 rehash),要么退一步「坐标压缩 + qsort + bsearch」得到确定的 O(n log n)。负数还意味着前缀和必须用 long long;差分元素同样可能为负,不能为省空间改用 unsigned 存——负增量会按模回绕,虽然无符号回绕是定义良好的,但结果已经错了,而 int 的累加溢出才是真正的 UB(OI Wiki · 前缀和与差分、OI Wiki · 哈希表)。

变体与扩展

  • 三数之和 / 多数之和:先 qsort 排序(比较函数写成 (a > b) - (a < b),避免 a - b 有符号溢出),再固定外层元素、对剩余区间对撞;去重要在「固定的数」和「移动的指针」两处同时跳过重复值,否则会产出重复三元组。
  • 「至多 K」转「恰好 K」:约束「恰好 K 个不同元素」通常不单调,但「至多 K 个」单调,于是 恰好 K = 至多 K − 至多 (K−1),两次滑窗相减即可;C 里把窗口逻辑抽成带参数 k 的静态函数复用,避免复制两份循环。
  • 定长窗口 + 频次数组:异位词、定长子串统计只需维护长度 k 的窗口;C 里用 int cnt[128] 计数,判断两个窗口频次是否相同不要每步 memcmp 整张表,而是维护「当前匹配的字符种类数」做到 O(1) 判定。
  • 二维前缀和:S[i+1][j+1] = a[i][j] + S[i][j+1] + S[i+1][j] − S[i][j],子矩阵和用容斥 O(1) 求出;C 里把 (m+1)×(n+1) 的表扁平化成一维 int64_t *s,分配前先用 SIZE_MAX / sizeof *s 检查乘法是否溢出。
  • 差分 + 前缀和组合:离线「先批量区间加、后统一查询」的最优解是差分,最后扫一遍前缀和还原;C 里注意区间右端点 r == n−1 时 D[n] 的写入边界,以及负增量对累加类型的溢出。

经典练习

  • LeetCode 167 · 两数之和 II - 输入有序数组(对撞双指针)
  • LeetCode 209 · 长度最小的子数组(不定长滑窗,收缩求最短)
  • LeetCode 3 · 无重复字符的最长子串(滑窗 + 频次)
  • LeetCode 560 · 和为 K 的子数组(前缀和 + 手写哈希表)
  • LeetCode 1109 · 航班预订统计(差分数组区间加)
  • LeetCode 15 · 三数之和(排序 + 固定外层 + 对撞,去重是难点)
  • LeetCode 713 · 乘积小于 K 的子数组(滑窗计数:以 right 结尾的合法左端个数)

C 与其他语言对比与陷阱

语言关键差异与陷阱
C++• vector::size() 返回无符号 size_t,与 int 下标混用有隐式转换陷阱,对撞指针建议统一用 int 或显式转换
• std::partial_sum 一行生成前缀和,unordered_map 做「前缀和 → 次数」平均 O(1),但迭代顺序不确定、最坏 O(n)
• 迭代器 end() 不能解引用,空区间要单独判,别写 *prev(v.end())
Rust• 切片自带长度,&nums[l..=r] 越界必 panic,不存在 C 的静默越界
• usize 做减法会下溢,空切片上 len - 1 在调试构建直接 panic,窗口收缩用 saturating_sub 或先判空
• HashMap 的 entry + or_insert 一次定位完成计数,iter().sum::<i64>() 天然防溢出
Python• 整数任意精度,前缀和不担心溢出;itertools.accumulate(a, initial=0) 一行得到含空前缀的序列
• dict / defaultdict(int) 直接当计数表,切片 a[l:r] 生成新列表,循环里大量切片会悄悄变成 O(n²)
• 负数输入下滑窗同样失效,必须换成前缀和 + 哈希,不能靠「窗口和单调」的直觉

延伸阅读

03

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

速查 ↑

原理与机制

A1 · 指针把离散节点串成逻辑序列:链表的本质是「每个节点单独分配、用 next 指针把地址无关的块串成逻辑顺序」,于是它放弃连续布局与随机访问,换来「已知前驱时只改指针就能 O(1) 增删」。C 里这个结构必须自己定义:struct ListNode { int val; struct ListNode *next; };,节点来自 malloc,「谁分配谁释放」必须写进接口注释——反转、合并这类操作重用已有节点、不做任何分配,而删除节点必须先把节点摘下链、再 free,否则就是泄漏;C 没有析构函数也没有 GC,泄漏不会报错,只会安静地增长。更关键的是哨兵节点:所有「删除第 N 个」「按值删除」「合并」的代码都有一类重复分支——目标恰好是头节点;在真实头节点前放一个栈上的哑节点 struct ListNode dummy; dummy.next = head;,就让每个真实节点都必有前驱,原来分三种情况处理的逻辑统一成「改前驱的 next」,结束时返回 dummy.next 即可。dummy 不能 malloc(忘 free 是常见的假泄漏),更不能把 &dummy 当结果返回——函数返回后它是悬垂指针,解引用即 UB;迭代反转里 prev = NULL 也是同一思想的延伸:NULL 终止了链,但每次访问前必须先判 cur != NULL(详见 B2)(cppreference · 结构体、cppreference · 指针)。

A2 · 快慢指针的固定步长差:快慢指针的全部性质来自一个恒定的相对速度:slow 每次 1 步、fast 每次 2 步,二者步长差恒为 1。无环时 fast 先到 NULL;有环时进入环后等价于「同一圆形跑道上后者每轮追近 1 步」,相对距离每步减 1,至多一圈内必然相遇。相遇点不是入口:设 head 到入口距离 a、入口到相遇点距离 b、环长 L = b + c(c 为相遇点沿环回到入口的距离),相遇时 slow 走了 a + b,fast 走了 a + b + kL,由 fast 速度是 slow 的两倍得 2(a + b) = a + b + kL,即 a = (k−1)L + c,也就是 a ≡ c (mod L)——所以把 slow 挪回 head、两指针同速单步前进,第 a 步时二者会同时到达入口。C 里比较的是节点地址(slow == fast)而不是 val,而且每一步都要先判 fast != NULL && fast->next != NULL,条件顺序不能反,否则解引用空指针是 UB(OI Wiki · 链表、cppreference · 指针)。

推导与权衡

B1 · Floyd 判环为什么是线性时间:环外部分 slow 走 a 步进环;fast 进环后相对速度为 1,追及距离不超过环长 L,故总步数 ≤ a + L ≤ 2n,时间 O(n)、空间只有两个指针 O(1)。步长差为什么通常取 1:若 fast 走 3 步、slow 走 1 步,相对速度为 2,当环长 L 为偶数时二者可能永远踩在相同奇偶性的位置上,相遇没有保证——一般地,相对步长 g 与 L 不互素时存在追不上的初相。另一个常被忽略的代价是 fast->next->next 每轮多一次解引用与空指针判断,cache miss 也更多,所以「2 倍速」是既保证正确、常数又最小的选择。C 里还可以在相遇后让 fast 绕一圈数出环长,但求入口仍然依赖上面的几何关系,不能只靠计数(OI Wiki · 链表、cppreference · 未定义行为)。

B2 · 链表与增长数组的取舍:链表把「插入 / 删除 O(1)」建立在一个昂贵前提上:你必须已经站在前驱节点上,而找到前驱本身是 O(n);它省下的搬移成本,又被每节点一次 malloc/free、指针域额外空间、遍历时随机跳转造成的 cache miss 吃掉。实测与工程经验都表明,除「已知位置反复增删」「O(1) 拼接」「节点地址必须长期稳定」三类场景外,连续的增长数组往往比链表更快:遍历 n 个元素时数组是顺序预取,链表是逐跳指针追逐。C 里这个取舍还多一层所有权风险:链表每节点独立分配,删除漏 free 就是泄漏、重复 free 就是堆破坏,而增长数组只有一次 malloc 与一次 free,代价是中间插入要 memmove 整体搬移。判断标准不是「链表听起来更高级」,而是访问模式里写操作占比、是否已持有前驱、以及节点地址是否需要稳定(Hello 算法 · 数组、man7 · malloc(3))。

变体与扩展

  • 双向链表与哨兵环:每个节点多一个 prev;插入必须「先接新节点两侧、再改邻居」,删除必须「先互连前后、再释放」,顺序颠倒会丢链或留下悬垂指针;头尾各放一个哨兵节点可消掉所有空指针特判。
  • 快慢指针家族:求中点(fast 到终点时 slow 在中点)、判断回文链表、重排链表、删除倒数第 N 个(fast 先走 N 步形成固定间距,再用 dummy 统一删除逻辑)。
  • 反转家族:迭代三指针 prev / cur / next 一次遍历完成;递归版代码短,但在长度 n 的链上占 O(n) 栈帧,C 不保证尾调用优化,长链会爆栈。
  • 合并与 k 路归并:两条链用 dummy 归并;k 条链用小顶堆维护各链当前最小结点(与第 5 章堆衔接),C 里堆元素是「值 + 结点指针」的结构体。
  • 静态链表 / 数组模拟:用 int next[MAXN] 存后继下标、-1 充当 NULL,一次分配、零节点 malloc、无外部碎片,适合嵌入式与固定规模场景,代价是容量上限写死、回收需要自由链表管理。

经典练习

  • LeetCode 206 · 反转链表(迭代三指针,注意先保存 next)
  • LeetCode 141 · 环形链表(Floyd 判环,注意空链与单节点)
  • LeetCode 142 · 环形链表 II(相遇后重置 slow 求入口)
  • LeetCode 876 · 链表的中间结点(快慢指针,偶数长度取后中点)
  • LeetCode 21 · 合并两个有序链表(dummy 哨兵统一头节点处理)
  • LeetCode 19 · 删除链表的倒数第 N 个结点(fast 先走 N 步 + dummy)
  • LeetCode 143 · 重排链表(中点 + 反转 + 合并三步组合)

C 与其他语言对比与陷阱

语言关键差异与陷阱
C++• 裸指针手写 struct ListNode 最灵活,但没有所有权工具时删除漏 delete 就是泄漏,unique_ptr 能自动释放但链式结构要小心递归析构爆栈
• std::list 是双向链表,插入与 splice 不使迭代器失效、仅被删元素失效,erase 自 C++11 起返回下一个有效迭代器
• 不要用取值相等判断「同一节点」,环检测必须比较地址
Rust• 单链表惯用 Option<Box<ListNode>>,所有权清晰;但 Box 无法构成环,带环结构需要 Rc<RefCell<..>>,而 Rc 环的引用计数永不为 0 会造成内存泄漏,须用 Weak 破环
• 借用检查器禁止同时持有多个 &mut,「一趟快慢删除」很难直写,惯用两遍扫描或递归绕过
• 官方文档明确建议多数场景优先 Vec / VecDeque,链表只在 splice 类场景有价值
Python• 用对象引用实现节点,判断「同一节点」必须用 is 而不是 ==(== 可能被重载)
• 带环 / 双向结构属于循环引用,引用计数无法回收,需要 gc 模块的循环垃圾回收器兜底
• 工程上多用 list / collections.deque 替代手写链表,deque 两端 O(1)

延伸阅读

04

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

速查 ↑

原理与机制

A1 · 受限线性表:LIFO 与 FIFO:栈和队列都是对「可访问位置」做限制的线性表:栈只允许在表尾(栈顶)插入与删除,于是最后进来的最先出去;队列一端入、另一端出,元素严格按到达顺序被处理。限制不是退化,而是把不变量变强:C 里数组栈只需要 int *data; size_t len, cap; 三元组,push 先检查 len == cap 再决定是否扩容;环形队列则需要 head、tail、size 与容量,head = (head + 1) % cap,空满判定要么额外维护 size,要么牺牲一个槽位((tail + 1) % cap == head 表示满)。容量取 2 的幂时 % cap 可以换成 & (cap - 1),但 size_t 的减法在 head == 0 时会回绕成 SIZE_MAX,所以环形队列里的「前一个位置」必须写成 (head + cap - 1) % cap(OI Wiki · 栈、cppreference · size_t)。

A2 · 哈希把「查找」变成「寻址」:哈希表用哈希函数把任意键压成整数,再对桶数取模得到下标,从而把「逐个键比较」换成「一次数组访问」。冲突不可避免:桶数有限而键空间无限,不同键可能落到同一桶或同一探测序列上,必须靠冲突处理策略兜底。C 里最省指针的是开放寻址:桶数组里直接存 {key, val, state},冲突时线性探测 (h + i) % M(M 为 2 的幂时可写成 & (M - 1),但必须先保证桶数是 2 的幂),删除时写墓碑(deleted 状态)而不是清空,否则会截断后续键的探测链;查找遇到 empty 停止、遇到墓碑继续。用「哨兵键」表示空位时,要保证键域里存在一个不可能出现的值,0 常被误当空键,于是 key = 0 的真实数据就丢了——更稳妥的是单独维护状态字节(OI Wiki · 哈希表、cppreference · 结构体)。

A3 · 手写容器的接口与所有权契约:C 的标准库没有容器,只有 malloc / free 与 qsort / bsearch,所以「栈 / 队列 / 哈希表」都必须以「结构体 + 一组函数」自己给出:初始化函数把指针与容量清零,析构函数释放内部缓冲区,插入函数在容量不足时 realloc 并以 bool 返回成败。这套约定必须写进头文件注释:谁分配谁释放、失败时是否保持原状态、返回的指针是否归调用方所有。因为 C 没有异常,失败只能靠返回值逐层上传,任何提前返回路径漏掉 free 都是泄漏;realloc 失败时原指针仍然有效,正确写法是先用临时指针接收再赋值,直接 p = realloc(p, n) 会丢掉旧块。接口层用 const 标注只读入参,用 size_t 表示长度与容量,可以避免大量 int 与无符号比较的隐式转换坑(cppreference · realloc、cppreference · 标准头文件总览)。

推导与权衡

B1 · 期望 O(1) 从哪来,最坏 O(n) 又怎么发生:在「简单均匀哈希」假设下,每个键独立、近似等概率地落入 M 个桶,n 个元素时桶内平均长度为 λ = n/M;查找先 O(1) 定位,再在桶内比较约 λ 次,总代价 O(1 + λ)。只要让 M 与 n 同阶(负载因子有上界,例如 0.75),λ 就是常数,查找期望 O(1)——注意是期望,不是最坏保证。若哈希函数退化,或攻击者按已知哈希构造大量同桶键,桶内退化成链表,查找就是 O(n)。C 里这个风险尤其具体:x % M 在 M 为 2 的幂时只用到键的低位,低位区分度差的键会大量撞桶;自己写的乘法哈希若中途用 int 累加,溢出还是未定义行为,必须全程用 uint32_t / uint64_t 的模 2^N 回绕(OI Wiki · 哈希表、cppreference · 未定义行为)。

B2 · 扩容摊还与结构选型:元素数超过阈值就要申请更大的桶数组并把全部元素重插(rehash),单次插入的代价可能 O(n);但从容量 c₀ 起按倍数增长,历次重建的总代价是 c₀ + 2c₀ + 4c₀ + … < 2·c_final = O(n) 的几何级数,摊到每次插入仍是 O(1)。选型上,开放寻址缓存友好、无指针开销,但对负载因子与墓碑比例敏感;拉链法删除简单、高负载更稳,却要为每个节点再付一次分配代价。C 里还有第三条路:键域不大时直接用数组(频次表、坐标压缩后的下标表),比任何哈希表都快;需要确定性有序时用「qsort 排序 + bsearch 查找」,每次 O(log n)、无哈希退化风险且缓存友好,代价是插入要 O(n) 重建。qsort 的比较函数必须满足严格弱序,返回 a - b 在差值溢出时是 UB,标准写法是 (a > b) - (a < b)(cppreference · realloc、cppreference · qsort)。

变体与扩展

  • 单调栈:栈内保持单调,用来求「下一个更大 / 更小元素」、柱状图最大矩形;C 里栈中存下标而不是值,才能方便地计算宽度与距离,栈用 size_t 数组模拟、零分配。
  • 单调队列:用数组模拟双端队列维护滑动窗口最值,队首出队、队尾维护单调性,均摊 O(1),与第 2 章滑动窗口直接配套;实现时 head / tail 两个下标向后走,容量不够时考虑循环或整体搬移。
  • 前缀和 + 哈希表:把「子数组和等于 K」转成「前缀和查表」,键是 long long 前缀和;这是第 2 章 B2 的 C 实现落点,哈希表可换成「排序 + 二分」。
  • 频次统计 → Top-K:先哈希计数,再把计数交给大小为 k 的堆(第 5 章)或桶排序;C 里键与计数用平行数组存,避免为每个条目单独 malloc。
  • LRU 缓存:哈希表负责 O(1) 定位、双向链表负责 O(1) 移动节点;C 里用头尾两个哨兵节点消掉边界特判,删除节点时先摘链再 free。

经典练习

  • LeetCode 20 · 有效的括号(数组栈 + 左括号入栈、右括号配对)
  • LeetCode 1 · 两数之和(边扫边查哈希表,键为值、值为下标)
  • LeetCode 49 · 字母异位词分组(排序串或 26 元计数编码作键)
  • LeetCode 155 · 最小栈(辅助栈同步维护当前最小值)
  • LeetCode 232 / 225 · 用栈实现队列 / 用队列实现栈(理解两种语义的差别)
  • LeetCode 739 · 每日温度(单调栈求下一个更大元素)
  • LeetCode 347 · 前 K 个高频元素(哈希计数 + 堆,衔接第 5 章)

C 与其他语言对比与陷阱

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

延伸阅读

05

堆与优先队列 · 深度解析

速查 ↑

原理与机制

A1 · 完全二叉树的数组表示:二叉堆是「完全二叉树 + 堆序性质」的组合。完全二叉树指「除最后一层外每层都填满、最后一层从左到右连续」,这个「没有空洞」的性质让它能按层序塞进连续数组:0 基下标下,结点 i 的左孩子是 2i + 1、右孩子是 2i + 2、父结点是 (i − 1) / 2;1 基写法则是 2i、2i + 1、i / 2,算术更整齐,不少教材与 OI Wiki 采用 1 基。C 里这意味着堆完全不需要节点指针与逐节点分配:typedef struct { int *a; size_t len, cap; } MinHeap; 一个结构体加一片连续内存就够了,扩容走 realloc,失败时原块仍然有效。堆序是弱序——只要求每个父结点不大于(小顶堆)两个孩子,兄弟之间、跨层之间没有任何约束,因此堆只保证根是全局最值,其余位置并不满足全序(OI Wiki · 二叉堆、cppreference · 数组)。

A2 · 上浮与下沉:两个基本动作:插入时把新元素放到数组末尾,再不断与父结点比较、交换直到不再违反堆序(上浮);删除堆顶时把末尾元素搬到根,再不断与两个孩子中「更优」的那个交换直到叶(下沉)。两者每轮让元素移动一层,完全二叉树高 ⌊log₂ n⌋,故 O(log n)。C 里有两个典型坑:其一,父结点公式 (i − 1) / 2 在 i == 0 时按无符号计算会先回绕,循环条件必须写成 while (i > 0) 再算父下标;其二,交换可以用「空穴」优化——先把待下沉元素取出,沿途只把孩子上移,最后再把元素填回,比较次数不变而赋值次数约减半。堆里存的是「可比较的元素」,C 没有模板也没有重载的比较运算符:要么把元素类型写死成 int / long long,要么走 void * + 比较函数指针的通用版本,后者每次比较多一次间接调用,热点循环里通常更慢(Hello 算法 · 堆、cppreference · size_t)。

推导与权衡

B1 · 自底向上建堆为什么是 O(n):把 n 个元素先随意放进数组,再从最后一个非叶结点(下标 n / 2 − 1)开始向前,对每个结点下沉一次。关键在「高度为 h 的结点约有 ⌈n / 2^(h+1)⌉ 个,而下沉代价最多 h」:叶子(h = 0)接近一半、完全不用动,越往上结点数越少、但下沉越深,总代价 Σ h·n/2^(h+1) = (n/2)·Σ h/2^h = O(n),因为级数 Σ h/2^h 收敛到 2。对比逐个插入的「上浮」路线:越深的元素越晚插入,累加是 Θ(n log n)。C 里建堆的循环起点要小心无符号:for (size_t i = n / 2; i-- > 0; ) 是「先递减再比较」的安全写法,若写成 for (size_t i = n / 2 - 1; i >= 0; --i),i >= 0 永远为真,循环在 i 回绕后越界访问(OI Wiki · 二叉堆、OI Wiki · 复杂度)。

B2 · 堆、排序数组与树的取舍:堆的全部优势是「O(1) 取最值 + O(log n) 插入删除」,代价是不支持高效查找任意元素或范围查询——找最值以外的具体值只能 O(n) 扫描。排序数组支持 O(log n) 二分查找、O(1) 取一端最值,但插入要 O(n) 搬移;平衡树(C 里只能手写或引第三方)所有操作 O(log n) 且天然有序。判据是算法形态:反复「取当前最值」、候选集动态变化(Top-K、任务调度、Dijkstra、k 路归并)用堆;需要频繁查找或有序遍历用排序数组或树。C 里还要把「没有 STL」折算进常数:Top-K 的两种实现——全量 qsort 是 O(n log n),只维护大小为 k 的堆是 O(n log k) 且额外空间 O(k),k 很小时堆明显更优;而 qsort 的比较函数必须满足严格弱序,返回 a - b 在差值溢出时是 UB,正确写法是 (a > b) - (a < b)(cppreference · qsort、cppreference · bsearch)。

变体与扩展

  • d 叉堆:每个结点有 d 个孩子,树更浅;下沉一层要比较 d 个孩子,但减少层数后总移动更少,d 与缓存行大小匹配(如 4 或 8)时常数更好,配合 decrease-key 时更明显。
  • 索引堆 / 带位置映射的堆:额外维护「元素 → 堆内下标」的 pos 数组,交换时同步更新,从而支持 decrease-key,是 Dijkstra 的标准组件;代价是每次交换多两次写,且接口复杂度明显上升。
  • 对顶堆(双堆):一个大顶堆存较小的一半、一个小顶堆存较大的一半,保持两堆大小差不超过 1,中位数 O(1)、插入 O(log n);C 里两个堆各自是独立的结构体,所有权互不混淆。
  • k 路归并:小顶堆维护每条有序链(或数组)当前的最小元素,弹出后推入该路的下一个,代价 O(N log k);C 里堆元素是「值 + 来源下标」的结构体,优先级相同时用序号打破平局以保证稳定。
  • 堆排序:先 O(n) 建大顶堆,再反复「堆顶与末尾交换、堆规模减一、新堆顶下沉」,就地 O(n log n)、不稳定;C 里 qsort 通常更快,堆排序胜在额外空间 O(1),且不受最坏输入影响。

经典练习

  • LeetCode 215 · 数组中的第 K 个最大元素(大小 k 的小顶堆,或快速选择)
  • LeetCode 347 · 前 K 个高频元素(哈希计数 + 堆 / 桶排序)
  • LeetCode 23 · 合并 K 个升序链表(小顶堆做 k 路归并)
  • LeetCode 295 · 数据流的中位数(对顶堆,注意两堆平衡时机)
  • LeetCode 703 · 数据流中的第 K 大元素(维持大小为 k 的堆)
  • LeetCode 1046 · 最后一块石头的重量(大顶堆模拟粉碎过程)
  • LeetCode 973 · 最接近原点的 K 个点(大顶堆维护 k 个最近点)

C 与其他语言对比与陷阱

语言关键差异与陷阱
C++• priority_queue 默认是大顶堆(比较器 less),小顶堆要写 priority_queue<T, vector<T>, greater<T>>
• 比较器的语义与 sort 一致,而 priority_queue 先弹出弱序中「最大」的元素,语义方向容易写反
• 适配器不提供迭代器,不能遍历或删除中间元素;就地建堆用 make_heap(O(N)),堆排序用 sort_heap
Rust• BinaryHeap 默认最大堆,最小堆用 BinaryHeap<Reverse<T>> 或自定义 Ord
• BinaryHeap::from(vec) 是 O(n) 建堆,逐个 push 才是 O(n log n),别写错
• 无 decrease-key,修改堆顶用 peek_mut()(改后 O(log n) 下沉),迭代顺序任意
Python• heapq 只提供最小堆的函数集,大顶堆靠取负存储(3.14 起另有 heapify_max / heappop_max 等最大堆函数)
• 没有 decrease-key 与任意删除,惯用惰性删除:弹出时跳过已失效条目
• 元素可以是元组,按字典序比较,常放 (priority, seq) 避免同优先级对象不可比;nlargest / nsmallest 在 k 较小时更高效

延伸阅读

06

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

速查 ↑

原理与机制

A1 · 一棵树只有一套骨架,差别在于代码插在哪里:二叉树在 C 里被写成自引用结构体 struct TreeNode { int val; struct TreeNode *left, *right; },空子树用 NULL 表示——没有哨兵、没有异常,判空就是递归唯一的出口。递归骨架永远是「进入本节点 → 递归左子树 → 递归右子树」这同一顺序,前序、中序、后序的差别只是把业务代码放在这三个时间点之一:刚进入节点时父节点信息最全、对子树一无所知;左子树返回后恰好能拿到有序的中序序列;两棵子树都返回后才能合并子问题的答案(Hello 算法 · 二叉树遍历)。C 没有闭包,所谓「把代码插在某个位置」不能像 Python 那样随手写个嵌套函数,只能显式选择:要么在遍历函数里直接写副作用(累加到出参指针或上下文结构体),要么传一个 void (*visit)(struct TreeNode *, void *) 函数指针——这是 C 版遍历代码与 C++/Python 版最直接的结构性差异(Hello 算法 · 二叉树)。

A2 · 遍历思维与分解思维:同一套骨架的两种用法:前序位置只能拿到父节点通过参数传下来的信息,天然对应「自顶向下」的遍历思维:一路维护路径、上下界、累加器,答案写在外部状态里;后序位置拿到的是左右子树的返回值,天然对应「自底向上」的分解思维:空树返回基线,当前节点的答案由左右子问题的答案合并而成(最大深度 = max(左, 右) + 1,直径 = 左深 + 右深,子树和 = 左和 + 右和 + 根)(Hello 算法 · 二叉树遍历)。C 里这两种思维的落地方式不同:遍历思维写 void traverse(struct TreeNode *root, struct Ctx *ctx),答案放进上下文结构体或出参指针——不能放全局变量,否则不可重入、多线程会串数据;分解思维写 int solve(struct TreeNode *root),纯返回值、没有共享可变状态,天然可重入(cppreference · 结构体)。判据只有一句「当前节点需要的信息来自哪里」:来自父节点(路径、边界、前缀和)就用前序 + 遍历;来自子树(高度、子树和、是否平衡)就用后序 + 分解。回溯里的 push / pop 恰好对应前序位置与后序位置,三种思维在同一棵树上相通。

A3 · 中序有序性,以及它为什么不够:BST 的定义是「左子树所有键 < 根 < 右子树所有键」,因此中序遍历得到严格递增序列(无重复键时),「验证 BST」可以退化成「检查中序序列是否严格递增」。但只比较直接父子是错的:[10,5,15,null,null,6,20] 里 6 是 15 的左孩子却小于 10,父子比较全部通过。正确做法是把祖先约束显式传下去:携带开区间 (lo, hi),进入左子树收紧上界、进入右子树收紧下界。C 里边界不能用 INT_MIN/INT_MAX 直接当哨兵(节点值恰好等于极值时会误判),应传「指针 + 是否无界」两个量,或把边界升到 long long 并用 ±10¹⁸ 之外的哨兵(Hello 算法 · 二叉搜索树)。中序法在 C 里则只需一个 struct TreeNode *prev,通过出参或上下文结构体在递归间传递,比维护全局变量干净得多(cppreference · 结构体)。

推导与权衡

B1 · O(n) 时间与 O(h) 空间:栈帧才是真正的瓶颈:每个节点恰好进入一次、做常数工作,时间恒为 Θ(n),与树的形状无关。空间由「递归还没返回的帧」决定,恰好等于树高 h:平衡树 O(log n),链状树 O(n);递归帧要保存返回地址、被调用者保存的寄存器、参数与局部变量,一帧通常 48~100 字节,而线程栈默认往往只有 1~8 MB,所以链状树几万到几十万层就会触发 SIGSEGV——C 标准不保证递归深度,SEI CERT 把「不受控的递归深度」明确列为资源耗尽风险(SEI CERT C)。解法是把隐式栈换成堆上的显式栈:struct TreeNode **stk = malloc(cap * sizeof *stk),前序「压右再压左」、中序「一路向左压栈、弹出即访问并转右」,每格只存一个指针(8 字节),空间同为 O(h) 但帧小一个数量级、也不受线程栈上限束缚。层序遍历改用堆上的数组队列后,空间由最宽一层 w 决定,满二叉树 w ≈ n/2,最坏仍是 O(n);要彻底摆脱深度限制只能上 Morris 遍历(O(1) 额外空间,代价是临时改写指针)(Hello 算法 · 二叉树遍历)。

B2 · BST 验证的两条路线与「祖先约束」的代价:范围法显式携带开区间 (lo, hi),向左收紧上界、向右收紧下界,逻辑直观、失败点可定位;中序法依赖「中序严格递增」,用 prev 与当前值比较,代码更短,但 prev 状态一旦漏更新就会把非法树判成合法。两者都是 O(n) 时间、O(h) 空间,本质都在捕捉祖先约束——范围法把它显式传递,中序法用全序把它摊平成相邻比较;而只比较父子关系的朴素写法必然漏判,反例就是 A3 里那棵树(Hello 算法 · 二叉搜索树)。C 实现的取舍在边界表示:节点值域接近 int 上下限时,范围法要么把边界升到 long long,要么用「边界指针 + 是否无界」两个字段避免哨兵冲突;中序法把 prev 做成 struct TreeNode ** 出参就不需要额外结构体。若还要 O(1) 额外空间,可用 Morris 中序临时改写树指针,但遍历期间树不可被并发读者访问(cppreference · 指针)。

变体与扩展

  • 迭代遍历的三套写法:前序用单栈「压右后左」;中序「一路向左压栈,弹出即访问并转右」;后序用双栈(前序变体再整体反转)或单栈加 lastVisited。C 里栈用 malloc 的 struct TreeNode ** 数组加 top 下标,容量不超过 h,函数返回前 free。
  • 由遍历序列重建树:前序 + 中序、中序 + 后序可唯一确定一棵二叉树;前序 + 后序不唯一。C 里先建一张「值 → 中序下标」的映射表(值域小时用数组,否则手写哈希表),把单次定位从 O(n) 降到 O(1),整体 O(n)。
  • 层序变体:分层输出、自底向上、锯齿形、右视图,共用同一个 BFS 骨架;C 里用定长数组加 head/tail 当队列,处理每一层前先记下本层宽度,避免下一层节点混进来。
  • Morris 遍历:借叶子空右指针临时搭一条回到祖先的线索,中序做到 O(1) 额外空间、O(n) 时间(每条边至多走三次),代价是遍历期间树结构被临时修改,不适用于并发或只读场景。
  • N 叉树与线索二叉树:前序、后序、层序都能推广到 N 叉树,「中序」对多叉树却没有统一定义;线索二叉树把空指针换成前驱/后继线索,让中序迭代彻底摆脱栈。

经典练习

  • LeetCode 144 / 94 / 145 · 二叉树的前序 / 中序 / 后序遍历(递归与显式栈各写一遍,感受帧大小差异)
  • LeetCode 102 · 二叉树的层序遍历(分层输出,队列 + 本层宽度)
  • LeetCode 104 · 二叉树的最大深度、LeetCode 226 · 翻转二叉树(分解思维入门)
  • LeetCode 98 · 验证二叉搜索树(范围法 vs 中序法,先写出漏判反例)
  • LeetCode 230 · 二叉搜索树中第 K 小的元素(中序计数的副产品)
  • LeetCode 543 · 二叉树的直径(后序合并 + 外部最大值)

C 与其他语言对比与陷阱

语言关键差异与陷阱
C++• 节点用 struct 配 new/delete 手动管理,递归 lambda 需要 std::function 才有额外开销
• 迭代遍历用 std::stack / std::queue 适配器,不必手写数组栈
• std::queue 不提供迭代器,无法用范围 for 遍历
Rust• 普通递归树用 Option<Box<TreeNode>>,要共享子节点或构环才需 Rc<RefCell<..>> 且容易泄漏
• 没有尾调用优化,链状深树递归照样爆栈
• 下标常用 usize,减 1 到空树时会下溢
Python• 默认递归上限约 1000 帧,链状树直接抛 RecursionError
• 中序维护 prev 靠 nonlocal,写起来轻但容易漏更新
• 层序队列用 collections.deque,list.pop(0) 是 O(n)

延伸阅读

07

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

速查 ↑

原理与机制

A1 · 二分是「谓词区间排除」,库函数只能查存在性:把「查找某个值」抽象成「在序列上找第一个使谓词 P 为真的位置」:每轮取 mid 判定 P(mid),然后丢掉一定不含答案的那一半。查找具体值、查找左右边界、二分答案,只是 P 的不同定义。手写二分只有两套自洽的区间约定:闭区间 [lo, hi] 配 while (lo <= hi)、收缩用 mid ± 1;半开区间 [lo, hi) 配 while (lo < hi)、收缩用 hi = mid,两套混用最常见的症状是区间不收缩导致死循环。两个 C 专属坑:(lo + hi) / 2 在 lo、hi 接近 INT_MAX 时发生有符号溢出(未定义行为),应写 lo + (hi - lo) / 2;hi = n - 1 时若 n 为 0,n - 1 先借无符号回绕,必须在函数入口判空(OI Wiki · 二分)。C 标准库只给了 bsearch:它按比较器在已排序数组中找「任意一个」相等元素并返回 void *,找不到返回 NULL——有重复值时返回哪一个未指定,也回答不了「第一个 ≥ x」「最后一个 ≤ x」这类问题,NULL 还无法区分「数组为空」与「没有命中」;所以库函数只适合存在性查询,边界必须手写 lower_bound / upper_bound(循环不变量是 [0, lo) 全部小于 key),bsearch 的 key 参数还是指针,通常要先构造临时变量再取地址(cppreference · bsearch)。

A2 · 快排的灵魂是划分的循环不变量:以区间末元素为基准的 Lomuto 划分维护「[lo, i] 内全部 ≤ pivot、(i, j) 内全部 > pivot、[j, hi) 尚未检查、a[hi] 是 pivot」这四条不变量;j 每前进一步要么把它并入左侧(交换到 ++i),要么并入右侧,扫描结束时把 pivot 换到 i + 1,基准就落在最终位置。快排递归两段,每轮至少固定一个元素,区间严格变小,必然终止(Hello 算法 · 快速排序)。C 的 qsort 把这件事完全隐藏了:它只认识 (base, nmemb, size, compar),交换元素时按 size 做字节搬运,比较时通过函数指针回调——每比较一次都是一次间接调用,编译器无法内联、也无法按元素类型向量化,所以泛型 qsort 往往比一份类型专用、可内联的手写排序慢数倍,这是「C 用运行时泛型换代码复用」的典型代价(cppreference · qsort)。

A3 · qsort 的比较器协议:严格弱序是不可谈判的前提:比较器签名固定为 int compar(const void *a, const void *b),返回值必须小于 0、等于 0、大于 0,分别表示 a 应在 b 之前、二者等价、a 应在 b 之后。它必须构成严格弱序:cmp(x, x) == 0(非自反)、反对称、传递,并且「等价」关系也传递;违反任意一条,qsort 的行为是未定义行为,而不只是「顺序不对」。最常见的 C 错误是 return a - b;:两个 int 相减可能溢出,溢出即 UB,正确写法是 return (a > b) - (a < b);。第二类错误是类型对不上——排序 char *arr[] 时比较器拿到的是 const char *const *,必须先解引用再 strcmp。第三,标准不保证 qsort 稳定:相等元素的相对次序会被打乱,凡「按多个键排序」「先按分数再按提交时间」的需求,都必须在比较器里补上原始下标作次级键,或改用归并排序(cppreference · qsort)。排序结构体时还要注意比较器只应读、不应写:把 const void * 强转掉 const 再赋值是 UB(man7 · qsort(3))。

推导与权衡

B1 · 快排的期望 O(n log n) 与最坏 O(n²) 推导:设某次划分把长度 n 的区间分成 k 与 n−1−k 两段,本轮代价 Θ(n),于是 T(n) = T(k) + T(n−1−k) + Θ(n)。基准随机时 k 在 0..n−1 上均匀分布,期望意义下递归树每层总代价 O(n)、期望深度 O(log n),总期望 O(n log n)。但若每次恰好挑到最值(例如对已排序数组固定取首元素为基准),划分退化成 0 : n−1,递推变成 T(n) = T(n−1) + Θ(n),总代价 O(n²),递归深度也达到 n——这是 C 里最容易踩的爆栈场景,必须先递归短边、长边用循环消掉,或随机化基准(Hello 算法 · 快速排序)。工程上还有两层加固:随机化基准 / 三数取中把坏输入变成概率事件;递归深度超阈值时转堆排序兜底最坏 O(n log n)(内省排序)。大量重复键时二路划分仍会退化,改用三路划分把序列切成「小于、等于、大于」三段、等值键一次归位,重复率高时接近线性。C 里手写随机基准要注意 rand() 的 RAND_MAX 可能只有 32767,取模会引入偏差且对大 n 覆盖不全,常自写 xorshift(SEI CERT C)。

B2 · 比较排序的下界与工程实现的混合策略:比较排序的时间下界是 Ω(n log n):n 个元素有 n! 种排列,每次「小于 / 等于 / 大于」的三路比较最多分出 3 个分支,决策树至少要有 n! 个叶子,高度 ≥ log₃(n!) = Θ(n log n);这也解释了为什么堆排序、归并排序、快排的期望复杂度都停在这个量级。计数排序之所以能 O(n + k) 突破下界,是因为它不做比较,直接拿键值当下标,代价是键必须是 [0, k) 的小整数且 k 不能远大于 n。在比较排序内部,稳定性与空间是硬取舍:归并稳定但需要 O(n) 临时缓冲,堆排原地但不稳定;qsort 标准既不保证稳定、也不承诺最坏 O(n log n),实现可自由选择算法——glibc 早期用归并排序(分配失败退回快排),2.37 起改为原地内省排序,正是「平均快、最坏有保证、能否额外分配」三者的权衡(Hello 算法 · 归并排序)。想把上下文(如全局排序次序表)传给比较器,C 标准没有 qsort_r:它是 GNU / POSIX 扩展且 GNU 与 BSD 参数顺序不同,可移植做法是把上下文与元素打包成结构体一起排序,或排序「下标数组」再按上下文查值(GNU C 库手册)。

B3 · 二分答案何时优于直接求解:当「判定候选答案是否可行」关于答案单调、且单次判定能在 O(n) 内完成时,可以放弃直接构造最优解,改为在答案值域 [lo, hi] 上二分:做 O(log V) 次判定,总代价是「判定代价 × log V」,通常写作 O(n log V)。它把「求最值」变成「判可行 + 找边界」,对「最少时间 / 最小容量 / 最小速度」这类问题往往比设计直接算法简单得多。C 里的四个坑:上界 hi 必须本身可行,否则循环会收敛到不可行端;答案可能超过 int,hi 与判定里的累加都要用 long long;INF 不能用 INT_MAX 参与加法(溢出是 UB),用 INT_MAX / 2 或 LLONG_MAX / 4;浮点答案用固定迭代次数(如 100 次)而不是 while (hi - lo > 1e-9),避免精度导致死循环(cppreference · bsearch)。与哈希查找的 O(1) 相比,二分 O(log n) 更慢,但省内存、无需额外结构,还天然支持「最近邻 / 边界 / 第 k 小」这类顺序查询,是只读有序数据的默认选择(OI Wiki · 二分)。

变体与扩展

  • 计数排序:值域 [0, k) 时用 calloc(k, sizeof(int)) 统计频次,做前缀和确定每类元素的落点,再从后往前回填即可稳定,时间 O(n + k);k 过大或键不是小整数就退化,且必须检查 calloc 是否返回 NULL。
  • 归并排序与逆序对:malloc 一块 n 元素的临时数组,合并时用 <= 优先取左段保证稳定;当右段元素先出列时,左段剩余元素个数就是跨段逆序对数,整体 O(n log n),临时缓冲在本函数内 free。
  • 三路划分(荷兰国旗):一次线性扫描把区间分成 < pivot、= pivot、> pivot 三段,等值键不再参与后续递归,是工程快排处理大量重复键的标准手段。
  • 边界的四种组合:闭 / 半开区间 × 左 / 右边界,四份代码只差「循环条件」与「收缩方向」两处;把它们并排写在一起对照一遍,比背四个模板更能避免混用。
  • 旋转有序数组与峰值查找:先判断 [lo, mid]、[mid, hi] 哪一半有序,再根据目标是否落在有序半边决定收缩方向;本质仍是「用一次比较排除一半」,与标准二分同构。

经典练习

  • LeetCode 704 · 二分查找(闭区间与半开区间两种写法都实现一遍)
  • LeetCode 34 · 在排序数组中查找元素的第一个和最后一个位置
  • LeetCode 33 · 搜索旋转排序数组
  • LeetCode 875 · 爱吃香蕉的珂珂(二分答案:判定函数 + 单调性)
  • LeetCode 912 · 排序数组(手写快排与归并,验证比较器契约)
  • LeetCode 面试题 51 · 数组中的逆序对(归并排序顺手计数)

C 与其他语言对比与陷阱

语言关键差异与陷阱
C++• std::sort 是内省排序,比较器返回 bool 且必须满足严格弱序,违反即 UB
• std::lower_bound / upper_bound 返回迭代器,要 distance 才得下标
• std::stable_sort 稳定但可能额外分配内存
Rust• sort 稳定、sort_unstable 通常更快且不额外分配
• binary_search 返回 Result,求边界要用 partition_point
• 下标是 usize,写 mid - 1 到 0 时会下溢,边界宜改用 i64
Python• sorted 与 list.sort 都是 Timsort,稳定且对部分有序数据接近线性
• bisect_left / bisect_right 对应 lower / upper bound
• 多用 key=(每元素只算一次),functools.cmp_to_key 明显更慢

延伸阅读

08

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

速查 ↑

原理与机制

A1 · 回溯 = 决策树上的 DFS + 显式状态回滚:任何回溯问题都能建模成一棵解空间树:根是初始状态,每条边是「做一个选择」,根到当前节点的路径是一个部分解,叶子是完整解。回溯沿这棵树深度优先地走:进入一层时修改 path、used 等状态,递归返回后必须把它们恢复成进入前的样子,否则兄弟分支会互相污染——这就是「DFS + 状态回滚」的本质,做选择与撤销必须紧贴递归调用、顺序严格对称(OI Wiki · 回溯法)。C 没有闭包,也没有引用捕获,所以 path、used、输出缓冲只能作为参数层层下传:void backtrack(int depth, int *path, bool *used, int *out_count, int *out)。写结果时尤其要小心:path 是复用缓冲区,每找到一个解都必须把当前内容快照出去,不能把栈上 path 的指针直接存进结果集——否则所有结果都指向同一块内存,最终只剩下最后一个解,而且函数返回后该指针立刻悬垂;正确做法是把 depth 个元素 memcpy 到二维数组 int (*out)[MAXN] 的一行,或写到扁平缓冲的 (*out_count) * n 偏移处(cppreference · memcpy)。

A2 · 网格与图的遍历细节:坐标、边界与标记时机:网格 BFS / DFS 的第一步是把 (r, c) 编码成一维下标 r * n + c,从「二维数组」退化成「连续内存 + 下标算术」,这样 visited、距离表都只需一次 malloc(n * m),也避开了 int ** 的布局陷阱。方向数组把 4 / 8 邻域的偏移放进两个常量数组,用一次循环枚举邻居,比手写四段 if 更不容易漏边界。C 里最危险的顺序错误是「先访问、后判边界」:必须先确认 0 <= nr && nr < rows && 0 <= nc && nc < cols,再读 grid[nr * n + nc];若坐标用 size_t 表示,r - 1 在 r == 0 时会回绕成巨大值,直接越界,所以网格坐标应使用 int 并在减法前判界(OI Wiki · BFS)。BFS 的队列用 malloc(n * m * sizeof(int)) 加 head / tail 两个下标即可,因为每个格子至多入队一次;visited 必须在入队时标记而不是出队时:晚标记会让同一格子被多个前驱重复入队,队列膨胀甚至指数级重复,这正是 BFS 求最短步数的前提——队列 FIFO 保证第一次到达某格子的层数就是最少步数(cppreference · malloc)。

推导与权衡

B1 · DFS 与 BFS:时间相同,空间形态不同:两者时间复杂度都是 O(V + E)(网格为 O(R·C)),因为每个顶点至多入栈/入队一次、每条边至多被检查一次。差异在空间:DFS 的空间由递归深度决定,最坏 O(V);在 C 里这就是真实的线程栈,一条 10⁶ 个顶点的链式图会直接 SIGSEGV,必须先估算深度——线程栈通常只有 1~8 MB,递归帧按几十字节计,约几万到几十万层就是极限,超过就换 malloc 的显式栈(存顶点下标的 int 数组即可,不必存整个帧),此时只受堆内存限制。BFS 的空间由队列最宽时同时驻留的顶点数决定,最坏 O(V),但元素只是一个 int,常数远小于递归帧,且逐层扩展时能较早发现目标、提前返回(OI Wiki · DFS)。选型经验:无权最短路 / 最少步数 / 层序 → BFS,存在性、连通块、枚举全部方案、判环与拓扑 → DFS(回溯);图很宽而目标离源点不远时,可用双向 BFS 把搜索宽度从 O(b^d) 降到约 O(b^(d/2))(SEI CERT C)。

B2 · 解空间树的规模与剪枝的真实收益:回溯的理论复杂度由解空间树规模决定:全排列是 O(n!·n)(n! 个叶子、每个叶子复制 O(n)),子集是 O(2ⁿ·n),含重复元素的组合还要更大。剪枝(提前 return 掉注定失败的分支)不改变这个最坏上界,但能大幅砍掉实际访问的节点数:N 皇后用「列、主对角线、副对角线」三个布尔数组做冲突检测,把每层候选从 n 个收缩到少数几个,实际搜索量相比暴力枚举呈数量级下降。工程原则是「越早剪越好」——把约束检查放在做选择之前,而不是进入下一层再发现非法;对组合类问题还有「同层去重」:先排序、同一层跳过相同值,把重复解整枝剪掉,代价是一次 O(n log n) 预处理(OI Wiki · 回溯法)。C 里这些剪枝状态(used、列 / 对角线标记)都是小数组,分配一次、递归间复用,恢复时「改回原值」即可;绝不能用函数内 static 或全局变量存深度,那样既不可重入,多组数据之间还会串状态(Hello 算法 · 回溯算法)。

变体与扩展

  • 多源 BFS:把所有源点先以距离 0 入队,再做一次普通 BFS,得到的就是「到最近源点的距离」;复杂度仍为 O(V+E),是「腐烂的橘子」「多工地扩散」这类问题的标准解法。
  • 0-1 BFS:边权只有 0 / 1 时,用双端队列替代普通队列,0 权扩展到的点压队首、1 权的点压队尾,O(V+E) 求出最短路,是 BFS 与 Dijkstra 之间的桥梁;C 里用 malloc(2n) 的数组加头尾指针模拟双端队列。
  • 优先队列 BFS(堆优化 Dijkstra):把 FIFO 队列换成按距离排序的最小堆,每次扩展当前代价最小的点,即退化为 Dijkstra(第 10 章详述),适用于非负权图。
  • 迭代加深 DFS(IDDFS):从深度 1 开始逐轮放宽深度上限做 DFS,空间只有 O(d),又具备 BFS 的「层序最优」性质,代价是浅层节点被重复访问,适合解很深但分支因子很大的搜索。
  • 回溯 + 记忆化:把 visited 升级成「状态 → 结论」的 memo 表(用 -1 表示未计算),同一状态不重复搜索,这是回溯通往动态规划的桥梁;C 里 memo 用 malloc + memset 初始化,且状态必须能编码成数组下标。

经典练习

  • LeetCode 46 · 全排列(回溯三要素:做选择、递归、撤销选择)
  • LeetCode 78 · 子集(「选或不选」的决策树,注意同层去重)
  • LeetCode 77 · 组合(用 start 收缩候选集)
  • LeetCode 200 · 岛屿数量(网格 DFS / BFS 洪水填充,两种队列写法对照)
  • LeetCode 111 · 二叉树的最小深度(BFS 层序,遇到叶子立即返回)
  • LeetCode 994 · 腐烂的橘子(多源 BFS,所有源点先入队)

C 与其他语言对比与陷阱

语言关键差异与陷阱
C++• 回溯参数用 const& 或引用避免深拷贝,path 交给 std::vector 自动管理
• std::vector<bool> 是位压缩特化,[] 返回代理对象,不能取地址
• 大图递归 DFS 同样可能爆栈,可换显式 std::stack
Rust• 递归回溯同时持有多个 &mut 会被借用检查器拒绝,需拆辅助函数或改下标访问
• VecDeque 是 BFS 的惯用队列,Vec 头删是 O(n)
• 网格坐标用 usize,算 -1 会下溢,宜显式用 isize
Python• 默认递归上限约 1000 帧,深递归要 sys.setrecursionlimit,官方警告设太高可能崩溃
• 收集结果必须用 path[:] 快照,直接存 path 会随后续修改而变化
• BFS 用 collections.deque 的 popleft(),list.pop(0) 是 O(n)

延伸阅读

09

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

速查 ↑

原理与机制

A1 · 状态、转移、边界、遍历顺序四件套:动态规划把问题拆成重叠子问题,用一张表存下每个子问题的答案,之后再遇到同一子问题直接查表——「用空间换时间」的全部含义就在这里。关键的第一步是定义状态:一组能唯一描述子问题、且不包含「怎么到达」的历史信息。状态定义好之后,转移方程才有意义:每一项都能对上一个具体决策(取或不取、删 / 插 / 替换),写不出方程通常说明状态定义错了,而不是题目太难;随后确定边界(空串、空集、容量 0)与遍历顺序(保证依赖的状态已算过,dp[i][j] 依赖左侧与上方就按 i 升序、j 升序填)。这四步缺一不可,工程上值得先用表格写下「状态含义 / 转移来源 / 边界值」三列再编码,能省掉大半调试时间(OI Wiki · 动态规划)。C 里这张表用扁平数组 + 行主序下标 dp[i * (m + 1) + j] 表达,具体的内存布局与分配细节见 B2(Hello 算法 · DP 解题思路)。

A2 · 记忆化与递推:DP 的两条实现路线:自顶向下(记忆化递归)从所求状态出发递归分解,天然贴合递推式的书写直觉,只会访问真正需要的状态;C 没有 lru_cache,记忆化要自己准备 memo 表并选好「未计算」哨兵——值域非负时用 -1,可能出现负数时另开一张 bool *seen,或者把不可达状态定义成一个绝不会作为答案的值。memo 必须作为参数随递归下传(int *memo),不能放在函数内 static(不可重入),也不能在每个递归帧各自 malloc(泄漏且失去记忆化的意义)。自底向上(递推)先把最小子问题填好,再按顺序把表填满:没有递归开销、便于空间压缩与滚动优化,但可能会算出用不到的状态;两条路线代码形态完全不同,转移方程却是同一份(cppreference · calloc)。工程上常见的做法是先用记忆化验证递推式正确,再按数据范围决定是否改写成递推(Hello 算法 · 动态规划入门)。

A3 · 最优子结构与无后效性:最优子结构指整体最优解由子问题的最优解组合而成——编辑距离的最优方案去掉最后一步操作后,仍是对两个更短前缀的最优方案;无后效性指状态一旦确定,未来的决策只依赖状态值,而与到达该状态的路径无关。C 里检验「状态够不够」有一个实用办法:把状态编码成一个(或几个)整数,然后问「同一个编码、不同历史,答案会不会不同」——会,就说明状态缺信息。0-1 背包必须带「前 i 件、容量 j」两个维度:只记容量就分不清第 i 件是否已经用过;编辑距离 / LCS 的两个下标就是「两个前缀的长度」这组无后效状态(Hello 算法 · 动态规划入门)。这两个条件是 DP 成立的「门槛」:不满足最优子结构时子问题答案拼不出原问题答案,不满足无后效性时必须把缺失的历史信息补进状态,否则转移就是错的;补维度会让状态数指数增长,这也解释了为什么很多问题需要「设计」而不是「硬套」状态(OI Wiki · 动态规划)。

推导与权衡

B1 · 空间压缩与背包的遍历方向:压缩的依据是「依赖集合的大小」:二维 0-1 背包的转移是 f[i][j] = max(f[i-1][j], f[i-1][j - w] + v),每个格子只依赖「上一行」,因此可以压成一维 f[j];压完之后 f[j - w] 读到的是上一行还是本行,必须由遍历方向决定——容量 j 从大到小枚举,f[j - w] 在被覆盖之前先被使用,读到的仍是上一行,语义不变;若从小到大枚举,f[j - w] 已被本行更新,等价于「第 i 件物品可以再取一次」,这正是完全背包。两者代码只差一个循环方向,本质差别是状态语义,必须想清楚再写。只依赖本行左侧时可以直接原地一维更新;依赖左上角时必须先临时保存旧值再覆盖,否则读到的是本行新值(Hello 算法 · 背包问题)。边界初始化同样由语义决定:「恰好装满」时 f[0] = 0、其余为负无穷(用 INT_MIN / 2 表示,防止加法溢出成 UB),「至多容量」时全部为 0;C 里这两种语义混用,答案会静默错成完全不同的值(cppreference · malloc)。

B2 · 两范式的取舍与表的内存布局:记忆化递归只访问需要的状态,适合状态稀疏或填表顺序难定的题,代价是函数调用、递归栈深度与 memo 哨兵初始化;递推没有递归开销、便于滚动压缩,但可能计算用不到的状态。取舍可以量化成两项:状态数 × 每状态代价,以及递归深度——深度超过约 10⁵ 就优先递推;状态稀疏且值域大(上亿)就不要 malloc 巨表,改用手写开放寻址哈希表做 memo;状态稠密(n、m 不超过几千)时 calloc 大表反而最快,一次解决清零与哨兵。C 里「二维表」有三种写法:int (*p)[M](M 为编译期常量,保持连续布局)、扁平 int *p + i * M + j(最通用,一次 malloc、一次 free)、int **p(指针数组,要多次 malloc、内存不连续、缓存差,失败路径还容易泄漏);标准 C 不允许变长数组做局部表,所以扁平数组是默认选择(cppreference · malloc)。只依赖上一行时用两行交替 + 交换 cur / prev 指针,比整行 memcpy 便宜;压缩后的数组长度与下标偏移差一位就会越界,必须对着转移方程逐项核对边界(Hello 算法 · DP 解题思路)。

变体与扩展

  • 完全背包:一维容量正序遍历,允许第 i 件物品被重复选取;求「最少硬币数」时把 max 换成 min,并把不可达状态初始化为一个极大值而非负无穷。
  • 多重背包:每种物品有数量上限,直接拆成若干件 0-1 物品会到 O(W·Σk),按 1、2、4、… 二进制拆分后只需 O(W·Σlog k) 组,再套 0-1 背包即可。
  • LIS 的 O(n²) 与 O(n log n):dp[i] 表示以第 i 个元素结尾的最长递增子序列长度,转移为对所有 j < i 且 a[j] < a[i] 取 max 再加 1;用「长度为 l 的递增子序列的最小结尾」数组 + 二分可优化到 O(n log n),数组用 malloc(n) 并在函数内释放。
  • 区间 DP / 状压 DP / 树形 DP:改动的是状态维度(区间端点、位掩码、子树根),转移本质不变;状压用 int 或 uint64_t 位掩码,n 通常不超过 20,否则状态数爆炸。
  • 滚动行与原地更新:只依赖上一行时用两行交替 + 指针交换;只依赖本行左侧时直接原地更新,但必须确认右侧信息不会在被用之前被覆盖(遍历方向要按依赖关系反推)。

经典练习

  • LeetCode 70 · 爬楼梯(一维递推的最小骨架)
  • LeetCode 198 · 打家劫舍(选 / 不选模型)
  • LeetCode 416 · 分割等和子集(0-1 背包的存在性版本)
  • LeetCode 322 · 零钱兑换(完全背包求最小个数)
  • LeetCode 300 · 最长递增子序列(O(n²) 与 O(n log n) 两版)
  • LeetCode 1143 · 最长公共子序列(二维字符串 DP)
  • LeetCode 72 · 编辑距离(三种操作的转移与边界)

C 与其他语言对比与陷阱

语言关键差异与陷阱
C++• vector<vector<int>> dp(n+1, vector<int>(m+1, 0)) 行与行之间不连续,按行主序访问才快
• 三值取最小要 #include <algorithm> 与 min({a, b, c})
• int 累加易溢出,答案大时改用 long long
Rust• usize 不能表示负数,容量逆序要写 (w..=cap).rev()
• vec![0i64; cap + 1] 初始化一维表,二维表注意外层是行、内层是列
• 记忆化用 HashMap 或 Vec<Option<_>>,哨兵靠 Option 表达而非魔数
Python• dp = [0] * (cap + 1) 一行建表,二维要用列表推导避免别名共享
• functools.lru_cache 做记忆化,参数必须可哈希
• 深递归要 sys.setrecursionlimit,否则抛 RecursionError

延伸阅读

10

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

速查 ↑

原理与机制

A1 · 邻接表用 head 与 next 两条数组表达:C 里最省事的图存储是「链式前向星」:int *head = malloc(n * sizeof *head) 全部初始化为 -1;每条边占一个槽位,存 to[e]、w[e] 与 next[e],加边写成 to[e] = v; w[e] = w; next[e] = head[u]; head[u] = e++;,遍历 u 的邻居就是 for (int e = head[u]; e != -1; e = next[e])。所有数组一次性分配,没有 per-node 的指针开销,空间 O(n + m)、遍历邻居 O(deg(u)),且槽位按插入顺序连续排布,缓存友好。这里有个 C 专属细节:calloc 只会清零(0),而要全 -1 必须自己循环,或 memset(head, 0xFF, n * sizeof *head)——C23 已强制补码表示,全 1 位即 -1。稠密图或需要 O(1) 查边时改用邻接矩阵:扁平 n * n 数组 + u * n + v 下标,同样避开 int **(OI Wiki · 图论概念)。边集数组把每条边存成 (u, v, w) 三元组,便于按边处理,是 Kruskal 的天然输入;三种表示怎么选,见 B3(cppreference · malloc)。

A2 · 遍历的不变量与 Kahn 拓扑排序:DFS 与 BFS 的核心不变量是「每个顶点至多被处理一次」,靠 bool *visited 或 int *dist(-1 表示未访问)维护;标记必须发生在入栈 / 入队时而不是出栈 / 出队时,否则有环图会让同一顶点被重复入队、队列指数膨胀。DFS 递归版沿路径深入,栈深等于路径长度;BFS 用 malloc(n * sizeof(int)) 的数组加 head / tail,因为每个顶点至多入队一次,队列长度不会超过 n。拓扑排序在完全相同的框架上加一张入度表:calloc(n, sizeof(int)) 统计每条边的终点入度,所有入度 0 的顶点入队,出队时把它的出边终点入度减 1,减到 0 再入队;队空后若处理的顶点数小于 n,剩下的顶点就在环上——输出序列长度本身就是判环证据(OI Wiki · 拓扑排序)。注意邻接表里「删除一条边」不便宜,但 Kahn 只需要遍历出边、不必真删,入度数组就是全部状态(cppreference · calloc)。

A3 · Dijkstra 的贪心正确性与手写二叉堆:Dijkstra 维护 dist[] 与「已确定集合」,每轮从优先队列取出暂定距离最小的未确定点 u,其 dist[u] 即可定死:所有边权非负,任何「先绕到别的未确定点、再回到 u」的路径长度都不小于当前暂定值,因此不存在更优解。C 没有 std::priority_queue,得手写存放 (dist, vertex) 的最小堆:struct HeapNode { long long d; int v; };,数组存储,sift_up / sift_down 只按 d 比较,孩子下标是 2i+1、2i+2。标准库也没有 decrease-key,惯用「延迟删除」:每次松弛成功就 push 一个新条目,弹出时若 node.d != dist[node.v] 直接丢弃;于是堆中条目可达 O(m)、总时间 O((n + m) log n)。INF 不能直接取 INT_MAX 再参与加法(有符号溢出是未定义行为),要用 INT_MAX / 2,或距离全程用 long long + LLONG_MAX / 4(OI Wiki · 最短路)。堆的下标算术与上浮下沉是第 5 章手写堆的直接复用,建堆用自底向上下沉可做到 O(n)(OI Wiki · 二叉堆)。

推导与权衡

B1 · 并查集的摊还复杂度 α(n):每个集合表示成一棵有根树,根就是代表元;find(x) 沿 parent 指针上溯到根,两个元素同根即同集合;union(a, b) 只需让一棵树的根指向另一棵的根。只用路径压缩、或只用按大小合并,单次操作的摊还复杂度都只能到 O(log n);两者结合后可证明摊还代价为 O(α(n)),α 是反阿克曼函数,n 小于 10⁶⁰⁰ 时 α(n) 也不超过 4,工程上可当作常数。C 实现只用两个数组:parent[i] = i、size[i] = 1 初始化(各 malloc(n * sizeof(int)),分配失败就返回);find 写成两趟迭代——先找根,再把路径上所有点直接挂到根上,避免递归在长链上爆栈;union 把小的树挂到大的树下并更新 size。必须强调这是摊还界:单次 find 最坏仍可能 O(log n),只是长序列平均下来极低(OI Wiki · 并查集)。推导的关键量是「路径压缩使树高近似对数、按大小合并保证树高不超过 log n,两者叠加后每次访问的代价被摊到后续操作上」,这也是摊还分析的典型范例(OI Wiki · 复杂度)。

B2 · Dijkstra 与 BFS 的关系:当所有边权都等于 1 时,Dijkstra 每轮取出的距离单调不减,优先队列退化成 FIFO,行为与 BFS 完全等价,复杂度从 O((n + m) log n) 降到 O(n + m);当边权只有 0 或 1 时,用双端队列实现 0-1 BFS(0 权边扩展到的点压队首、1 权边压队尾),同样 O(n + m)。反过来,只有边权非负才能用 Dijkstra;存在负权边时必须 Bellman-Ford / SPFA(O(n·m),能检测负环)。所以「求最短路」不是无条件选 Dijkstra,而是按权值域分层选型:无权图用 BFS,0-1 权用双端队列,非负权用堆优化 Dijkstra,任意权用 Bellman-Ford / Floyd(OI Wiki · 最短路)。C 里 BFS 的「队列」与 0-1 BFS 的「双端队列」都可以用一块 malloc 数组加头尾下标模拟,0-1 BFS 的数组容量取 2n 即可覆盖最坏入队次数(OI Wiki · BFS)。

B3 · 存储与实现的权衡:邻接矩阵查边 O(1),但空间 O(n²):n = 10⁵ 时就是 10¹⁰ 个元素,直接不可行;邻接表空间 O(n + m),遍历邻居 O(deg(u)),是稀疏图(m 远小于 n²)的默认选择,链式前向星是它在 C 里最紧凑的落地形式。Dijkstra 的实现也随稠密程度二选一:稠密图用邻接矩阵 + 线性扫描最小值,O(n²) 但常数小、无需堆,n 只有几千时反而更快;稀疏图用邻接表 + 二叉堆,O((n + m) log n)。理论上 Fibonacci 堆能把上界降到 O(m + n log n),但它实现复杂、隐藏常数大,C 里几乎没有实用价值。Kruskal 则走另一条路:边集数组按权 qsort,用并查集判断「加入这条边会不会成环」,O(m log m),代价集中在排序而不是逐点扩展——「按点扩展 vs 按边扩展」就是 Dijkstra 与 Kruskal 的根本分野(OI Wiki · 图论概念)。工程上还要算上初始化成本:邻接矩阵每次 calloc(n * n) 可能是几十 MB,而链式前向星只需 O(n + m) 的两三个数组(OI Wiki · 并查集)。

变体与扩展

  • 0-1 BFS:边权仅 0 / 1 时用双端队列,0 权入队首、1 权入队尾,O(n + m) 求出最短路,是从 BFS 到 Dijkstra 的中间形态。
  • Bellman-Ford 与 SPFA:允许负权边并检测负环;Bellman-Ford 做 n−1 轮对全部边的松弛,O(n·m),C 里用边集数组遍历最方便,第 n 轮仍能松弛即存在负环。
  • 多源 BFS:把所有源点一次性入队(距离 0)再扩散,得到「到最近源点的距离」,复杂度不变仍为 O(n + m),常用于网格扩散类问题。
  • Kruskal 最小生成树:边集按权 qsort 后依次尝试,find(u) == find(v) 说明会成环、跳过,否则 union 并计入答案,总 O(m log m),是并查集最经典的应用。
  • 带权 / 种类并查集:在 parent 之外再维护「到根的距离 / 相对关系」,处理带约束的连通性问题(食物链、除法求值),find 时同步更新关系量。

经典练习

  • LeetCode 200 · 岛屿数量(网格 DFS / BFS 数连通分量,与第 8 章对照)
  • LeetCode 207 · 课程表(Kahn 入度法判环)
  • LeetCode 210 · 课程表 II(输出任意一个合法拓扑序)
  • LeetCode 547 · 省份数量(并查集或 DFS,两种连通分量写法对照)
  • LeetCode 743 · 网络延迟时间(邻接表 + 手写二叉堆 Dijkstra)
  • LeetCode 133 · 克隆图(遍历过程中同步复制节点,防止重复复制)

C 与其他语言对比与陷阱

语言关键差异与陷阱
C++• priority_queue 默认大顶堆,最小堆要写 greater<>
• 没有 decrease-key,同样靠「重复入堆 + 过期条目跳过」
• pair 先比 first 再比 second,距离权重用 long long 防溢出
Rust• BinaryHeap 默认最大堆,最小堆用 BinaryHeap<Reverse<(d, v)>>
• 无 decrease-key,if d > dist[u] { continue; } 跳过过期项
• 下标 usize 相减易下溢,距离与哨兵宜用 u64 / i64
Python• heapq 天然最小堆,无需取负
• if d > dist[u]: continue 跳过过期条目
• 元组按字典序比较,(dist, node) 直接可用;浮点权注意 float('inf')

延伸阅读