文章

数据结构与算法面试题 —— 从底层原理到手撕代码的深度问答

覆盖数组、链表、栈、队列、哈希表、树、堆、图八大数据结构,排序、查找、动态规划、回溯、贪心五大算法体系,附 C++ STL 对照和高频手撕题

数据结构与算法面试题 —— 从底层原理到手撕代码的深度问答

数据结构和算法是面试的”硬通货”——不管你做什么方向,面试官都会考。但很多人的复习方式是”刷 500 道 LeetCode”,刷完也记不住几道。

这篇文章换个思路:先把底层原理讲透(”为什么”),再给面试回答模板(”怎么答”),最后附手撕代码(”怎么写”)。理解了原理,题目换个壳你也能做出来。


第一部分:数据结构

Q1:数组和链表的区别?什么时候用哪个?

记忆点:数组连续内存随机访问 O(1) 但插删 O(n);链表非连续内存插删 O(1) 但访问 O(n)。数组缓存友好,链表灵活。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
              数组                    链表
内存          连续                    非连续(节点通过指针串联)
随机访问      O(1) arr[i]             O(n) 要从头遍历
头部插入      O(n) 要移动后续元素      O(1) 改指针
尾部插入      O(1) 摊销(动态数组)    O(1)(如果有尾指针)
中间插入      O(n)                    O(1)(已知位置时)
删除          O(n)                    O(1)(已知位置时)
内存开销      紧凑(只存数据)         每个节点额外存指针(8字节/指针)
缓存友好      ✅ 连续内存预取高效      ❌ 随机跳转 cache miss

C++ STL 对应:
  数组 → std::vector(动态数组,最常用)
         std::array(固定大小数组)
  链表 → std::list(双向链表)
         std::forward_list(单向链表)

选择原则:
  ├── 频繁随机访问、很少插删 → vector
  ├── 频繁在中间/头部插删 → list
  ├── 不确定 → 默认 vector(缓存友好性带来的优势通常碾压链表)
  └── 实际工程中 vector 用得远比 list 多

Q2:vector 的底层原理?扩容机制?

记忆点:vector 底层是动态数组,size 是当前元素数,capacity 是已分配空间。满了时扩容为原来的 2 倍(MSVC)或 1.5 倍(GCC),扩容需要重新分配+拷贝所有元素,所以 push_back 摊销 O(1)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
vector 的内存布局:

  [begin]                    [end]          [end_of_storage]
    │                          │                │
    ▼                          ▼                ▼
    ┌──┬──┬──┬──┬──┬──────────────────────────┐
    │10│20│30│40│50│        未使用空间          │
    └──┴──┴──┴──┴──┴──────────────────────────┘
    ←── size = 5 ──→
    ←────────── capacity = 10 ────────────────→

扩容过程(capacity 不够时):
  1. 分配新内存(旧 capacity × 增长因子)
  2. 把旧元素移动/拷贝到新内存
  3. 释放旧内存
  4. 更新指针

  这就是为什么:
  ├── push_back 均摊 O(1),最坏 O(n)
  ├── 扩容后旧的迭代器/指针全部失效!
  └── 如果能预知大小,先 reserve() 避免扩容

面试追问:为什么增长因子是 1.5 或 2?
  2 倍:简单,扩容次数最少,但内存浪费最多可达 50%
  1.5 倍:浪费少,但扩容更频繁
  关键:旧内存无法被后续扩容复用
    假设从 1 开始扩容:1, 2, 4, 8, 16, 32, 64...
    当需要 64 时,前面释放的 1+2+4+8+16+32=63 < 64,无法复用
    用 1.5 倍:1, 1, 2, 3, 4, 6, 9, 13...
    1+1+2+3+4+6 = 17 > 13,旧内存可以复用!

Q3:哈希表的原理?解决冲突的方法?

记忆点:哈希表 = 数组 + 哈希函数 + 冲突解决。哈希函数把 key 映射到数组下标。冲突解决主要两种:链地址法(每个桶挂链表)和开放寻址法(冲突了往后找空位)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
哈希表工作原理:

  key "Alice" → hash("Alice") → 42 → 42 % 8 = 2 → 存到 bucket[2]

  bucket 数组:
  [0]  → null
  [1]  → null
  [2]  → ("Alice", 90) → ("Charlie", 85)  ← 冲突!链地址法
  [3]  → ("Bob", 80)
  [4]  → null
  ...

冲突解决方案:

  ① 链地址法(Separate Chaining)—— std::unordered_map 用的
     每个桶是一个链表(或红黑树,Java HashMap 在链表长度 >8 时转红黑树)
     优点:简单,删除方便
     缺点:链表长时退化为 O(n)

  ② 开放寻址法(Open Addressing)
     冲突了就往后找空位
     ├── 线性探测:hash+1, hash+2, hash+3... (容易聚集)
     ├── 二次探测:hash+1², hash+2², hash+3²... (减少聚集)
     └── 双重哈希:hash1 + i*hash2 (最均匀)
     优点:缓存友好(数据在连续内存中)
     缺点:删除复杂(需要惰性删除标记)

  装载因子 = 元素数 / 桶数
  装载因子越高 → 冲突越多 → 性能越差
  通常 > 0.75 就扩容(rehash)
  扩容 = 新建更大的数组 + 把所有元素重新哈希

C++ STL:
  std::unordered_map  → 链地址法
  std::unordered_set  → 链地址法
  时间复杂度:平均 O(1),最坏 O(n)

Q4:红黑树是什么?为什么 STL 的 map/set 用它?

记忆点:红黑树是自平衡二叉搜索树,通过”红黑着色+旋转”保证树高不超过 2log(n)。比 AVL 树旋转次数少(插入最多 2 次旋转),适合频繁插删的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
红黑树的五条性质:
  1. 每个节点是红色或黑色
  2. 根节点是黑色
  3. 叶子节点(NIL)是黑色
  4. 红色节点的子节点必须是黑色(不能两个红连续)
  5. 从根到每个叶子的路径上,黑色节点数量相同(黑高相等)

  这五条性质保证了:最长路径不超过最短路径的 2 倍
  → 树高 ≤ 2log₂(n+1) → 查找/插入/删除都是 O(log n)

红黑树 vs AVL 树 vs B+树:
                红黑树          AVL 树          B+树
平衡性         宽松(2倍)     严格(高度差≤1) N叉平衡
查找           O(log n)       O(log n) 略快    O(log_m n)
插入旋转       最多 2 次       最多 2 次        不旋转,分裂
删除旋转       最多 3 次       O(log n) 次      不旋转,合并
用途           STL map/set    数据库索引(少见)  数据库/文件系统

为什么 STL 选红黑树不选 AVL?
  → STL 的 map/set 需要频繁插删
  → AVL 删除时可能旋转 O(log n) 次,红黑树最多 3 次
  → 红黑树在插删频繁时综合性能更好

Q5:堆(Heap)的原理?和优先队列的关系?

记忆点:堆是完全二叉树,用数组存储。最大堆:父 ≥ 子;最小堆:父 ≤ 子。插入和删除堆顶都是 O(log n)。C++ 的 priority_queue 底层就是堆。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
最大堆的数组存储:

  逻辑结构(树):          数组存储:
        90                  [90, 80, 70, 50, 60, 30, 40]
       /  \                  0   1   2   3   4   5   6
      80   70
     / \   / \
    50 60 30 40

  父子关系(0-indexed):
    父节点:(i - 1) / 2
    左孩子:2 * i + 1
    右孩子:2 * i + 2

堆的核心操作:

  上浮(Sift Up)—— 插入新元素后调整:
    把新元素放在末尾 → 和父节点比较 → 比父大就交换 → 重复
    时间:O(log n)

  下沉(Sift Down)—— 删除堆顶后调整:
    把末尾元素放到堆顶 → 和较大的孩子比较 → 比孩子小就交换 → 重复
    时间:O(log n)

  建堆(Heapify):
    从最后一个非叶子节点开始,依次下沉
    时间:O(n)!不是 O(n log n)!

C++ STL:
  std::priority_queue<int>  → 最大堆(默认)
  std::priority_queue<int, vector<int>, greater<int>>  → 最小堆
  std::make_heap / push_heap / pop_heap → 在 vector 上操作堆

典型应用:
  ├── Top-K 问题(维护大小为 K 的堆)
  ├── 堆排序
  ├── 合并 K 个有序链表
  ├── 中位数(一个最大堆 + 一个最小堆)
  └── Dijkstra 最短路径

Q6:栈和队列的应用场景?

记忆点:栈 LIFO(后进先出)——函数调用、括号匹配、表达式求值、DFS;队列 FIFO(先进先出)——BFS、任务调度、缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
栈的经典应用:
  ├── 函数调用栈(局部变量、返回地址)
  ├── 括号匹配:( [ { } ] ) → 遇左括号压栈,遇右括号弹栈匹配
  ├── 表达式求值:中缀转后缀(逆波兰)
  ├── 单调栈:下一个更大/更小元素
  ├── DFS(深度优先搜索)的迭代实现
  └── 撤销操作(Ctrl+Z)

队列的经典应用:
  ├── BFS(广度优先搜索)
  ├── 任务调度(FIFO 调度队列)
  ├── 滑动窗口最大值(单调队列/双端队列)
  ├── 消息队列
  └── 缓冲区(生产者-消费者模型)

C++ STL:
  std::stack<T>    → 默认基于 deque
  std::queue<T>    → 默认基于 deque
  std::deque<T>    → 双端队列(头尾都能 O(1) 插删)

面试高频:用两个栈实现队列 / 用两个队列实现栈

Q7:图的存储方式?邻接矩阵 vs 邻接表?

记忆点:邻接矩阵查边 O(1) 但空间 O(V²),适合稠密图;邻接表空间 O(V+E),适合稀疏图(大多数实际图都是稀疏的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
邻接矩阵:
  V 个顶点 → V×V 的二维数组
  matrix[i][j] = 1 表示 i→j 有边

  0  1  2  3
  ┌──┬──┬──┬──┐
0 │ 0│ 1│ 1│ 0│     0 → 1
1 │ 0│ 0│ 1│ 0│     0 → 2
2 │ 0│ 0│ 0│ 1│     1 → 2
3 │ 0│ 0│ 0│ 0│     2 → 3
  └──┴──┴──┴──┘

邻接表:
  每个顶点存一个链表/vector,记录它的邻居
  0: [1, 2]
  1: [2]
  2: [3]
  3: []

对比:
              邻接矩阵          邻接表
空间          O(V²)             O(V+E)
查边          O(1)              O(度)
遍历邻居      O(V)              O(度)
加边          O(1)              O(1)
适合          稠密图(E≈V²)       稀疏图(E<<V²)
实现          vector<vector<int>>   vector<vector<int>> 或 unordered_map
1
2
3
4
5
6
7
8
9
10
// C++ 邻接表的常用写法
// 方式 1:vector<vector<int>>(最常用)
int n = 5;
vector<vector<int>> adj(n);
adj[0].push_back(1);  // 0 → 1
adj[0].push_back(2);  // 0 → 2

// 方式 2:带权图
vector<vector<pair<int,int>>> adj(n);  // (邻居, 权重)
adj[0].push_back({1, 10});  // 0 →(权重10)→ 1

第二部分:排序算法

Q8:常见排序算法的比较?

记忆点:快排平均最快 O(n log n) 但最坏 O(n²);归并稳定 O(n log n) 但需要额外空间;堆排原地 O(n log n) 但缓存不友好。小数据量直接插入排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
算法          平均       最坏       空间     稳定   特点
─────────────────────────────────────────────────────
冒泡排序      O(n²)     O(n²)      O(1)    ✅     教学用,实际不用
选择排序      O(n²)     O(n²)      O(1)    ❌     每轮选最小
插入排序      O(n²)     O(n²)      O(1)    ✅     小数据量很快,近乎有序时 O(n)
─────────────────────────────────────────────────────
快速排序      O(nlogn)  O(n²)      O(logn) ❌     实际最快,缓存友好
归并排序      O(nlogn)  O(nlogn)   O(n)    ✅     稳定,适合外排序/链表排序
堆排序        O(nlogn)  O(nlogn)   O(1)    ❌     原地,但缓存不友好
─────────────────────────────────────────────────────
计数排序      O(n+k)    O(n+k)     O(k)    ✅     整数,范围 k 不大时
基数排序      O(d·n)    O(d·n)     O(n)    ✅     整数,d 是位数
桶排序        O(n)      O(n²)      O(n)    ✅     均匀分布时
─────────────────────────────────────────────────────

C++ STL:
  std::sort()       → 内省排序(快排+堆排+插入排序的混合)
  std::stable_sort() → 归并排序(稳定)
  std::partial_sort() → 堆排序(只排前 K 个)

Q9:快速排序的原理?怎么优化?

记忆点:选一个 pivot,把比它小的放左边、大的放右边(分区),然后递归处理左右两部分。最坏情况出现在每次 pivot 选到最大/最小值。优化:随机选 pivot、三数取中、小区间用插入排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 基础快排
void quickSort(vector<int>& arr, int left, int right) {
    if (left >= right) return;

    int pivot = arr[left + (right - left) / 2];  // 取中间元素作 pivot
    int i = left, j = right;

    while (i <= j) {
        while (arr[i] < pivot) i++;
        while (arr[j] > pivot) j--;
        if (i <= j) {
            swap(arr[i], arr[j]);
            i++;
            j--;
        }
    }

    quickSort(arr, left, j);
    quickSort(arr, i, right);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
快排的优化手段:
  ├── 随机 pivot:避免最坏情况 O(n²)
  │   int idx = left + rand() % (right - left + 1);
  │   swap(arr[idx], arr[left]);
  ├── 三数取中:取 left, mid, right 三个数的中位数
  ├── 小区间切换插入排序:区间 < 16 时用插入排序更快
  ├── 三路划分:处理大量重复元素
  │   [< pivot | == pivot | > pivot]
  └── 尾递归优化:先递归短的一半

面试追问:为什么快排比归并快(同样 O(nlogn))?
  → 快排是原地排序,内存访问连续,缓存友好
  → 归并排序需要额外 O(n) 空间和拷贝
  → 快排的常数因子更小

Q10:归并排序的原理?

记忆点:分治——把数组对半分,递归排序左右两半,然后合并两个有序数组。合并是 O(n),递归深度 O(log n),总共 O(n log n)。稳定排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void mergeSort(vector<int>& arr, int left, int right) {
    if (left >= right) return;

    int mid = left + (right - left) / 2;
    mergeSort(arr, left, mid);
    mergeSort(arr, mid + 1, right);

    // 合并两个有序区间 [left, mid] 和 [mid+1, right]
    vector<int> temp;
    int i = left, j = mid + 1;
    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j]) temp.push_back(arr[i++]);  // ≤ 保证稳定性
        else temp.push_back(arr[j++]);
    }
    while (i <= mid) temp.push_back(arr[i++]);
    while (j <= right) temp.push_back(arr[j++]);

    for (int k = 0; k < temp.size(); k++) {
        arr[left + k] = temp[k];
    }
}
1
2
3
4
5
归并排序的特殊用途:
  ├── 链表排序(链表归并不需要额外空间!用 slow/fast 找中点)
  ├── 外部排序(数据太大放不进内存时,分块排序后归并)
  ├── 求逆序对数量(归并时统计)
  └── 需要稳定排序时的首选

第三部分:查找算法

Q11:二分查找及其变体?

记忆点:有序数组中查找,每次排除一半,O(log n)。关键是边界处理——left < right 还是 left <= right,返回 left 还是 right。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 标准二分:查找 target 是否存在
int binarySearch(vector<int>& arr, int target) {
    int left = 0, right = arr.size() - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;  // 防溢出
        if (arr[mid] == target) return mid;
        else if (arr[mid] < target) left = mid + 1;
        else right = mid - 1;
    }
    return -1;  // 没找到
}

// 变体 1:查找第一个 >= target 的位置(lower_bound)
int lowerBound(vector<int>& arr, int target) {
    int left = 0, right = arr.size();
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] < target) left = mid + 1;
        else right = mid;
    }
    return left;
}

// 变体 2:查找第一个 > target 的位置(upper_bound)
int upperBound(vector<int>& arr, int target) {
    int left = 0, right = arr.size();
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] <= target) left = mid + 1;
        else right = mid;
    }
    return left;
}
1
2
3
4
5
6
7
8
9
10
11
12
C++ STL 对应:
  std::lower_bound(begin, end, target)  → 第一个 >= target
  std::upper_bound(begin, end, target)  → 第一个 > target
  std::binary_search(begin, end, target) → 是否存在

二分查找的扩展应用(面试高频):
  ├── 旋转排序数组中查找
  ├── 在排序矩阵中查找
  ├── 求平方根(二分逼近)
  ├── 找峰值元素
  ├── "能力检测"型二分:最小化最大值、最大化最小值
  └── 记住:只要能把问题转化为"有序 + 排除一半"就能用二分

Q12:BFS 和 DFS 的区别和实现?

记忆点:DFS 用栈(或递归),走到底再回头,适合路径/连通性问题;BFS 用队列,逐层扩展,适合最短路径问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// DFS —— 递归(最直觉)
void dfs(vector<vector<int>>& adj, int node, vector<bool>& visited) {
    visited[node] = true;
    // 处理 node
    for (int neighbor : adj[node]) {
        if (!visited[neighbor]) {
            dfs(adj, neighbor, visited);
        }
    }
}

// DFS —— 迭代(用栈)
void dfsIterative(vector<vector<int>>& adj, int start) {
    vector<bool> visited(adj.size(), false);
    stack<int> s;
    s.push(start);

    while (!s.empty()) {
        int node = s.top(); s.pop();
        if (visited[node]) continue;
        visited[node] = true;
        // 处理 node
        for (int neighbor : adj[node]) {
            if (!visited[neighbor]) s.push(neighbor);
        }
    }
}

// BFS —— 用队列
void bfs(vector<vector<int>>& adj, int start) {
    vector<bool> visited(adj.size(), false);
    queue<int> q;
    q.push(start);
    visited[start] = true;

    while (!q.empty()) {
        int node = q.front(); q.pop();
        // 处理 node
        for (int neighbor : adj[node]) {
            if (!visited[neighbor]) {
                visited[neighbor] = true;
                q.push(neighbor);
            }
        }
    }
}
1
2
3
4
5
6
7
BFS vs DFS 选择:
  最短路径(无权图)→ BFS
  所有路径/排列组合 → DFS + 回溯
  连通性判断        → BFS 或 DFS 都行
  拓扑排序          → BFS(Kahn 算法)或 DFS
  二叉树层序遍历    → BFS
  二叉树前/中/后序  → DFS

第四部分:动态规划

Q13:动态规划的核心思想?怎么判断一道题该用 DP?

记忆点:DP = 记忆化的穷举。如果一个问题有”重叠子问题”和”最优子结构”,就可以用 DP。做法:定义状态 → 写状态转移方程 → 确定初始值和遍历顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
判断是否用 DP 的三个特征:
  1. 重叠子问题:同一个子问题被多次计算
     例:fib(5) = fib(4) + fib(3),fib(4) = fib(3) + fib(2)
     fib(3) 被计算了两次

  2. 最优子结构:大问题的最优解包含子问题的最优解
     例:最短路径的子路径也是最短的

  3. 无后效性:当前状态确定后,未来的决策不受过去的影响
     例:到达 (i,j) 后,从 (i,j) 到终点的最短路径和之前怎么到 (i,j) 无关

DP 解题模板:
  Step 1:定义状态 dp[i] 或 dp[i][j] 表示什么
  Step 2:写出状态转移方程
  Step 3:确定初始条件(base case)
  Step 4:确定遍历顺序(确保计算 dp[i] 时依赖的 dp[j] 已经算过)
  Step 5:(可选)空间优化(滚动数组)

Q14:经典 DP 题解析

爬楼梯 —— DP 入门

1
2
3
4
5
Q: 一次可以爬 1 或 2 级台阶,到第 n 级有多少种方法?

状态:dp[i] = 到第 i 级的方法数
转移:dp[i] = dp[i-1] + dp[i-2](从 i-1 爬 1 级 或 从 i-2 爬 2 级)
初始:dp[0] = 1, dp[1] = 1
1
2
3
4
5
6
7
8
9
10
int climbStairs(int n) {
    if (n <= 1) return 1;
    int prev2 = 1, prev1 = 1;  // 空间优化:只需记住前两个
    for (int i = 2; i <= n; i++) {
        int curr = prev1 + prev2;
        prev2 = prev1;
        prev1 = curr;
    }
    return prev1;
}

背包问题 —— DP 的灵魂

1
2
3
4
5
6
7
Q: N 个物品,每个有重量 w[i] 和价值 v[i],背包容量 W,求最大价值。

0-1 背包(每个物品只能选一次):
  状态:dp[i][j] = 前 i 个物品,容量 j 时的最大价值
  转移:dp[i][j] = max(dp[i-1][j],              // 不选第 i 个
                       dp[i-1][j-w[i]] + v[i])   // 选第 i 个
  初始:dp[0][*] = 0
1
2
3
4
5
6
7
8
9
10
11
int knapsack01(vector<int>& w, vector<int>& v, int W) {
    int n = w.size();
    // 空间优化:一维 DP(逆序遍历容量!)
    vector<int> dp(W + 1, 0);
    for (int i = 0; i < n; i++) {
        for (int j = W; j >= w[i]; j--) {  // 逆序!保证每个物品只选一次
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    return dp[W];
}
1
2
3
4
5
6
7
8
9
为什么 0-1 背包要逆序遍历容量?
  正序:dp[j-w[i]] 可能已经被本轮更新过
       → 相当于同一个物品用了多次 → 变成完全背包了
  逆序:dp[j-w[i]] 还是上一轮的值
       → 保证每个物品只用一次

完全背包(每个物品可以选无限次):
  → 只需要把内层循环改为正序!
  for (int j = w[i]; j <= W; j++)  // 正序

最长公共子序列 (LCS)

1
2
3
4
5
6
7
8
Q: 两个字符串的最长公共子序列长度。

状态:dp[i][j] = text1[0..i-1] 和 text2[0..j-1] 的 LCS 长度
转移:
  if text1[i-1] == text2[j-1]:
    dp[i][j] = dp[i-1][j-1] + 1       // 相同字符,都取
  else:
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])  // 跳过其中一个
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int longestCommonSubsequence(string& a, string& b) {
    int m = a.size(), n = b.size();
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (a[i-1] == b[j-1]) {
                dp[i][j] = dp[i-1][j-1] + 1;
            } else {
                dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
            }
        }
    }
    return dp[m][n];
}

Q15:DP 的常见类型和套路?

记忆点:按状态定义分类——线性 DP、区间 DP、树形 DP、状压 DP、数位 DP。面试 80% 是线性 DP 和背包变形。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
常见 DP 类型:

① 线性 DP(最常考)
   状态沿一个维度递推
   ├── 爬楼梯 / 斐波那契
   ├── 最长递增子序列(LIS)
   ├── 最大子数组和
   └── 打家劫舍

② 背包 DP
   ├── 0-1 背包:每个物品选一次
   ├── 完全背包:每个物品选无限次
   ├── 多重背包:每个物品有数量限制
   └── 变形:目标和、零钱兑换、分割等和子集

③ 二维 DP / 网格 DP
   ├── 最小路径和
   ├── 不同路径
   └── 编辑距离

④ 区间 DP
   ├── 戳气球
   ├── 矩阵链乘法
   └── 最长回文子序列

⑤ 树形 DP
   ├── 二叉树的直径
   ├── 树的最大路径和
   └── 打家劫舍 III

⑥ 状态压缩 DP
   用二进制表示状态
   ├── 旅行商问题(TSP)
   └── 位运算优化状态

第五部分:经典算法思想

Q16:贪心算法的核心?和 DP 什么区别?

记忆点:贪心每一步选当前最优,不回头;DP 考虑所有子问题再选全局最优。贪心更快但只在满足”贪心选择性质”时正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
贪心 vs 动态规划:
  贪心:局部最优 → 希望导致全局最优(不一定,需要证明)
  DP:  考虑所有可能 → 保证全局最优(一定正确)

  例子:找零钱
    硬币 [1, 5, 10, 25],找 41 分
    贪心:25 + 10 + 5 + 1 = 4 枚 ✅ 正好是最优
    硬币 [1, 3, 4],找 6 分
    贪心:4 + 1 + 1 = 3 枚 ❌ 最优是 3 + 3 = 2 枚

经典贪心题:
  ├── 活动选择(按结束时间排序)
  ├── 跳跃游戏
  ├── 分配糖果
  ├── 区间调度(无重叠区间)
  ├── 加油站
  └── Huffman 编码

Q17:回溯算法的模板?

记忆点:回溯 = DFS + 剪枝。在决策树上穷举所有可能,走不通就回退(撤销选择)。模板:选择列表 → 做选择 → 递归 → 撤销选择。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 回溯模板
void backtrack(路径, 选择列表) {
    if (满足结束条件) {
        result.push_back(路径);
        return;
    }

    for (选择 : 选择列表) {
        if (不合法) continue;   // 剪枝
        做选择;                  // 路径.push_back(选择)
        backtrack(路径, 选择列表);
        撤销选择;                // 路径.pop_back()
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 实例:全排列
void permute(vector<int>& nums, vector<int>& path,
             vector<bool>& used, vector<vector<int>>& result) {
    if (path.size() == nums.size()) {
        result.push_back(path);
        return;
    }

    for (int i = 0; i < nums.size(); i++) {
        if (used[i]) continue;          // 剪枝:已使用的跳过
        used[i] = true;                 // 做选择
        path.push_back(nums[i]);
        permute(nums, path, used, result);
        path.pop_back();                // 撤销选择
        used[i] = false;
    }
}
1
2
3
4
5
6
7
8
经典回溯题:
  ├── 全排列 / 全排列 II(有重复)
  ├── 子集 / 组合
  ├── N 皇后
  ├── 数独求解
  ├── 单词搜索
  ├── 括号生成
  └── 电话号码的字母组合

Q18:分治算法?

记忆点:分治 = 分解 + 解决 + 合并。把大问题分成子问题,递归解决子问题,合并结果。归并排序和快排就是分治。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
分治的三步:
  1. Divide:把问题分成 2 个(或多个)子问题
  2. Conquer:递归解决子问题
  3. Combine:合并子问题的解

经典分治:
  ├── 归并排序:分两半 → 排序 → 合并
  ├── 快速排序:选 pivot 分区 → 分别排序
  ├── 二分查找:排除一半
  ├── 求最大子数组和(分治解法)
  ├── 求逆序对(归并排序变形)
  ├── 最近点对问题
  └── 大整数乘法(Karatsuba)

主定理(Master Theorem)—— 分析分治时间复杂度:
  T(n) = aT(n/b) + O(n^d)

  ├── a < b^d → O(n^d)          合并主导
  ├── a = b^d → O(n^d · log n)  平衡
  └── a > b^d → O(n^(log_b(a))) 递归主导

  归并排序:T(n) = 2T(n/2) + O(n) → a=2, b=2, d=1 → O(n log n)

第六部分:高频手撕题

Q19:反转链表

记忆点:三个指针 prev/curr/next,每次把 curr->next 指向 prev,然后三个都前进一步。

1
2
3
4
5
6
7
8
9
10
11
12
13
ListNode* reverseList(ListNode* head) {
    ListNode* prev = nullptr;
    ListNode* curr = head;

    while (curr) {
        ListNode* next = curr->next;  // 先保存下一个
        curr->next = prev;            // 反转指针
        prev = curr;                  // prev 前进
        curr = next;                  // curr 前进
    }

    return prev;  // prev 现在是新的头
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
图示:
  原始:1 → 2 → 3 → 4 → null

  第 1 步:null ← 1   2 → 3 → 4 → null
                prev curr

  第 2 步:null ← 1 ← 2   3 → 4 → null
                     prev curr

  第 3 步:null ← 1 ← 2 ← 3   4 → null
                          prev curr

  第 4 步:null ← 1 ← 2 ← 3 ← 4
                               prev  curr=null

  返回 prev = 4

Q20:判断链表是否有环

记忆点:快慢指针。快指针每次走 2 步,慢指针每次走 1 步。如果有环,快的一定会追上慢的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
bool hasCycle(ListNode* head) {
    ListNode* slow = head;
    ListNode* fast = head;

    while (fast && fast->next) {
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast) return true;  // 相遇 = 有环
    }
    return false;  // fast 到头了 = 无环
}

// 追问:找到环的入口?
ListNode* detectCycle(ListNode* head) {
    ListNode* slow = head;
    ListNode* fast = head;

    while (fast && fast->next) {
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast) {
            // 相遇后:slow 回到头部,两个都走 1 步,再次相遇就是入口
            slow = head;
            while (slow != fast) {
                slow = slow->next;
                fast = fast->next;
            }
            return slow;
        }
    }
    return nullptr;
}

Q21:二叉树的遍历(递归+迭代)

记忆点:前序(根左右)、中序(左根右)、后序(左右根)、层序(BFS)。递归简单但要能写迭代版。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 前序遍历 —— 递归
void preorder(TreeNode* root, vector<int>& result) {
    if (!root) return;
    result.push_back(root->val);  // 根
    preorder(root->left, result);  // 左
    preorder(root->right, result); // 右
}

// 前序遍历 —— 迭代(栈)
vector<int> preorderIterative(TreeNode* root) {
    vector<int> result;
    stack<TreeNode*> s;
    if (root) s.push(root);

    while (!s.empty()) {
        TreeNode* node = s.top(); s.pop();
        result.push_back(node->val);
        // 先右后左(因为栈是后进先出,先进的右边后处理)
        if (node->right) s.push(node->right);
        if (node->left) s.push(node->left);
    }
    return result;
}

// 中序遍历 —— 迭代
vector<int> inorderIterative(TreeNode* root) {
    vector<int> result;
    stack<TreeNode*> s;
    TreeNode* curr = root;

    while (curr || !s.empty()) {
        while (curr) {          // 一路向左
            s.push(curr);
            curr = curr->left;
        }
        curr = s.top(); s.pop();
        result.push_back(curr->val);  // 访问
        curr = curr->right;           // 转右子树
    }
    return result;
}

// 层序遍历 —— BFS
vector<vector<int>> levelOrder(TreeNode* root) {
    vector<vector<int>> result;
    if (!root) return result;

    queue<TreeNode*> q;
    q.push(root);

    while (!q.empty()) {
        int size = q.size();
        vector<int> level;
        for (int i = 0; i < size; i++) {
            TreeNode* node = q.front(); q.pop();
            level.push_back(node->val);
            if (node->left) q.push(node->left);
            if (node->right) q.push(node->right);
        }
        result.push_back(level);
    }
    return result;
}

Q22:最长递增子序列 (LIS)

记忆点:dp[i] = 以 nums[i] 结尾的 LIS 长度。O(n²) 的 DP 解法面试够用。O(n log n) 解法用贪心+二分(维护一个”尾部最小值”数组)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// O(n²) DP
int lengthOfLIS(vector<int>& nums) {
    int n = nums.size();
    vector<int> dp(n, 1);  // 每个元素自身就是长度 1 的子序列

    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[j] < nums[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
    }
    return *max_element(dp.begin(), dp.end());
}

// O(n log n) 贪心 + 二分
int lengthOfLIS_fast(vector<int>& nums) {
    vector<int> tails;  // tails[i] = 长度为 i+1 的 LIS 的最小末尾

    for (int num : nums) {
        auto it = lower_bound(tails.begin(), tails.end(), num);
        if (it == tails.end()) {
            tails.push_back(num);  // 比所有都大,延长 LIS
        } else {
            *it = num;  // 替换,让末尾尽可能小
        }
    }
    return tails.size();
}

Q23:Top-K 问题

记忆点:求最大的 K 个数用大小为 K 的最小堆;求最小的 K 个数用大小为 K 的最大堆。也可以用快速选择(Quick Select)O(n) 平均。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 方法 1:最小堆(O(n log K))
vector<int> topK(vector<int>& nums, int k) {
    // 最小堆(堆顶是最小的)
    priority_queue<int, vector<int>, greater<int>> minHeap;

    for (int num : nums) {
        minHeap.push(num);
        if (minHeap.size() > k) {
            minHeap.pop();  // 弹出最小的,留下的就是最大的 K 个
        }
    }

    vector<int> result;
    while (!minHeap.empty()) {
        result.push_back(minHeap.top());
        minHeap.pop();
    }
    return result;
}

// 方法 2:快速选择(O(n) 平均)
int partition(vector<int>& nums, int left, int right) {
    int pivot = nums[right];
    int i = left;
    for (int j = left; j < right; j++) {
        if (nums[j] >= pivot) {  // 降序分区
            swap(nums[i], nums[j]);
            i++;
        }
    }
    swap(nums[i], nums[right]);
    return i;
}

void quickSelect(vector<int>& nums, int left, int right, int k) {
    if (left >= right) return;
    int pos = partition(nums, left, right);
    if (pos == k) return;
    else if (pos < k) quickSelect(nums, pos + 1, right, k);
    else quickSelect(nums, left, pos - 1, k);
}
1
2
3
4
5
Top-K 方法对比:
              时间       空间     特点
排序          O(nlogn)   O(1)    最简单但最慢
最小堆        O(nlogK)   O(K)    适合数据流(在线算法)
快速选择      O(n) 平均  O(1)    最快但不保证有序

Q24:合并 K 个有序链表

记忆点:用最小堆存每个链表的头节点,每次取最小的出来,再把它的下一个节点放进堆。O(N log K)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ListNode* mergeKLists(vector<ListNode*>& lists) {
    // 最小堆:按节点值排序
    auto cmp = [](ListNode* a, ListNode* b) {
        return a->val > b->val;
    };
    priority_queue<ListNode*, vector<ListNode*>, decltype(cmp)> pq(cmp);

    // 把每个链表的头节点放入堆
    for (auto head : lists) {
        if (head) pq.push(head);
    }

    ListNode dummy(0);
    ListNode* tail = &dummy;

    while (!pq.empty()) {
        ListNode* node = pq.top(); pq.pop();
        tail->next = node;
        tail = tail->next;
        if (node->next) pq.push(node->next);
    }

    return dummy.next;
}

Q25:最短路径算法

记忆点:单源最短路用 Dijkstra(非负权,O(E log V))或 Bellman-Ford(可负权,O(VE));全源最短路用 Floyd(O(V³))。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Dijkstra —— 优先队列优化
vector<int> dijkstra(vector<vector<pair<int,int>>>& adj, int src) {
    int n = adj.size();
    vector<int> dist(n, INT_MAX);
    dist[src] = 0;

    // (距离, 节点)
    priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
    pq.push({0, src});

    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue;  // 已经有更短的路径了

        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});
            }
        }
    }
    return dist;
}
1
2
3
4
5
6
最短路径算法选择:
  ├── 无权图 → BFS(O(V+E))
  ├── 非负权、单源 → Dijkstra(O(E log V))
  ├── 有负权、单源 → Bellman-Ford(O(VE))
  ├── 有负权、检测负环 → Bellman-Ford
  └── 全源最短路 → Floyd-Warshall(O(V³))

第七部分:STL 数据结构速查

Q26:C++ STL 容器一览

记忆点:sequence 容器保持插入顺序(vector/deque/list);associative 容器按 key 排序(map/set);unordered 容器用哈希表(unordered_map/unordered_set)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
容器              底层结构       查找      插入      删除      有序
─────────────────────────────────────────────────────────────────
vector            动态数组       O(n)      O(1)*尾   O(n)      否
deque             分段数组       O(n)      O(1)头尾  O(n)      否
list              双向链表       O(n)      O(1)      O(1)      否
forward_list      单向链表       O(n)      O(1)      O(1)      否
─────────────────────────────────────────────────────────────────
set               红黑树         O(logn)   O(logn)   O(logn)   ✅
map               红黑树         O(logn)   O(logn)   O(logn)   ✅
multiset          红黑树         O(logn)   O(logn)   O(logn)   ✅
multimap          红黑树         O(logn)   O(logn)   O(logn)   ✅
─────────────────────────────────────────────────────────────────
unordered_set     哈希表         O(1)*     O(1)*     O(1)*     ❌
unordered_map     哈希表         O(1)*     O(1)*     O(1)*     ❌
─────────────────────────────────────────────────────────────────
stack             适配器(deque)  —         O(1)      O(1)      —
queue             适配器(deque)  —         O(1)      O(1)      —
priority_queue    堆(vector)     O(1)top   O(logn)   O(logn)   部分

* = 平均情况,最坏 O(n)

怎么选:
  ├── 随机访问多 → vector
  ├── 需要有序 + 范围查询 → set/map(红黑树)
  ├── 只要查找快、不要求有序 → unordered_set/unordered_map
  ├── 头尾都要快速插入 → deque
  ├── 频繁在中间插删(且有迭代器)→ list
  └── Top-K / 优先级 → priority_queue

Q27:迭代器失效总结

记忆点:vector 插入/删除会导致插入/删除点之后的迭代器失效(扩容时全部失效);map/set 只有被删除元素的迭代器失效;unordered 系列 rehash 时全部失效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
vector:
  push_back → 未扩容:end() 失效;扩容:全部失效
  insert/erase → 插入/删除点之后全部失效

deque:
  头尾插入 → 迭代器失效但引用不失效
  中间插入/删除 → 全部失效

list:
  插入 → 不影响任何迭代器
  删除 → 只有被删除元素的迭代器失效

map/set:
  插入 → 不影响
  删除 → 只有被删除元素的迭代器失效

unordered_*:
  插入 → 可能触发 rehash → 全部失效
  删除 → 只有被删除元素失效(不触发 rehash)

安全的删除方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 错误:删除后迭代器失效
for (auto it = vec.begin(); it != vec.end(); ++it) {
    if (*it == target) vec.erase(it);  // it 失效了!下一次 ++it 是未定义行为
}

// ✅ 正确:erase 返回下一个有效迭代器
for (auto it = vec.begin(); it != vec.end(); ) {
    if (*it == target) it = vec.erase(it);
    else ++it;
}

// ✅ 也可以用 erase-remove 惯用法
vec.erase(remove(vec.begin(), vec.end(), target), vec.end());

// ✅ C++20 更简洁
std::erase(vec, target);

第八部分:复杂度分析

Q28:时间复杂度怎么算?

记忆点:看最深层循环的执行次数。常见复杂度排序:O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) < O(n!)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
常见代码模式 → 复杂度:

O(1)       常数操作:arr[i], hash.find(key)
O(log n)   每次减半:二分查找、平衡树操作
O(n)       单层循环:遍历数组
O(n log n) 排序:快排、归并排序
O(n²)      双层循环:冒泡排序、暴力两数之和
O(2ⁿ)      指数:所有子集、递归斐波那契(不记忆化)
O(n!)      阶乘:全排列

直觉判断法:
  n ≤ 10      → O(n!) 或 O(2ⁿ) 可以接受
  n ≤ 20      → O(2ⁿ) 可以
  n ≤ 1000    → O(n²) 可以
  n ≤ 10⁶     → O(n log n) 可以
  n ≤ 10⁸     → 只能 O(n)
  n > 10⁸     → 需要 O(log n) 或 O(1)

Q29:空间复杂度?

记忆点:额外使用的空间,不算输入本身。递归的空间复杂度要算调用栈深度。

1
2
3
4
5
6
7
8
9
10
O(1)       原地操作:交换、双指针
O(log n)   递归调用栈深度:二分、快排(平均)
O(n)       额外数组:归并排序的临时数组、哈希表
O(n²)      二维 DP 表
O(2ⁿ)      记录所有子集

常见陷阱:
  递归的空间 = O(递归深度)
  快排:平均 O(log n),最坏 O(n)(退化为链式递归)
  DFS:O(树高) 或 O(图的节点数)

面试做题策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
拿到一道算法题的思路:

Step 1:理解题意(5分钟内)
  ├── 输入输出是什么?
  ├── 数据范围是多少?(决定了时间复杂度上限)
  ├── 有没有特殊条件?(有序?正数?不重复?)
  └── 先想几个例子验证理解

Step 2:选择算法(先想暴力,再优化)
  ├── 暴力解法是什么?时间复杂度?
  ├── 能不能用哈希表空间换时间?
  ├── 有序 → 二分?双指针?
  ├── 最优值 → DP?贪心?
  ├── 排列组合 → 回溯?
  ├── 图/树 → BFS/DFS?
  └── 分治?

Step 3:和面试官沟通方案
  "我打算用 xxx 方法,时间 O(xxx),空间 O(xxx),可以吗?"
  不要闷头写代码!

Step 4:写代码
  ├── 先写框架(函数签名、主循环)
  ├── 再填细节
  ├── 注意边界:空数组、单元素、负数、溢出
  └── 变量名有意义

Step 5:测试
  ├── 用例子走一遍代码(dry run)
  ├── 测试边界情况
  └── 确认时间空间复杂度

速查表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
数据结构选择:
  随机访问        → vector (O(1))
  有序 + 查找     → set/map (O(logn))
  纯查找          → unordered_set/map (O(1))
  先进先出        → queue
  后进先出        → stack
  优先级          → priority_queue

算法选择:
  有序查找        → 二分 O(logn)
  最短路径(无权)  → BFS O(V+E)
  最短路径(有权)  → Dijkstra O(ElogV)
  排序            → std::sort O(nlogn)
  最优解          → DP 或贪心
  所有解/排列     → 回溯

复杂度速记:
  二分             O(log n)
  排序             O(n log n)
  两数之和(哈希)   O(n)
  LIS              O(n²) 或 O(n log n)
  背包             O(n × W)
  全排列           O(n × n!)
  子集             O(n × 2ⁿ)

DP 四步法:
  1. 状态定义 dp[i] 是什么
  2. 转移方程 dp[i] = f(dp[j])
  3. 初始值 base case
  4. 遍历顺序(保证依赖的先算)


补充:LeetCode 实战编程要点

以下是刷题时反复会用到的编程模式、边界处理和易错点,建议收藏反复查阅。


一、二分查找——彻底搞懂边界

二分查找 90% 的 bug 都出在边界处理上。记住以下三种模板,覆盖所有场景:

1
2
3
核心区别一句话:
  left <= right  → 搜索区间 [left, right],找到就返回
  left < right   → 搜索区间 [left, right),循环结束时 left == right 就是答案

模板 1:精确查找——找到 target 返回下标,找不到返回 -1

1
2
3
4
5
6
7
8
9
10
int search(vector<int>& nums, int target) {
    int lo = 0, hi = nums.size() - 1;   // 闭区间 [lo, hi]
    while (lo <= hi) {                    // 注意:<=
        int mid = lo + (hi - lo) / 2;    // 防溢出!不要写 (lo+hi)/2
        if (nums[mid] == target) return mid;
        else if (nums[mid] < target) lo = mid + 1;
        else hi = mid - 1;
    }
    return -1;
}

模板 2:找边界——第一个 >= target 的位置(lower_bound)

1
2
3
4
5
6
7
8
9
10
11
12
// 返回值范围 [0, n],如果所有元素都 < target 则返回 n
int lowerBound(vector<int>& nums, int target) {
    int lo = 0, hi = nums.size();   // 注意:hi = size(),开区间 [lo, hi)
    while (lo < hi) {                // 注意:< 不是 <=
        int mid = lo + (hi - lo) / 2;
        if (nums[mid] < target)
            lo = mid + 1;            // mid 太小,答案在右边
        else
            hi = mid;                // mid 可能是答案,不能跳过
    }
    return lo;                       // lo == hi 就是答案
}

模板 3:找边界——第一个 > target 的位置(upper_bound)

1
2
3
4
5
6
7
8
9
10
11
int upperBound(vector<int>& nums, int target) {
    int lo = 0, hi = nums.size();
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (nums[mid] <= target)      // 唯一区别:这里是 <=
            lo = mid + 1;
        else
            hi = mid;
    }
    return lo;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
边界写法速记口诀:

  "精确查找用闭区间,边界查找用左闭右开"
  "lo <= hi 配 hi = mid-1,lo < hi 配 hi = mid"

  精确查找          边界查找(lower/upper_bound)
  ─────────────     ──────────────────
  hi = size() - 1   hi = size()
  while (lo <= hi)  while (lo < hi)
  hi = mid - 1      hi = mid
  返回 mid           返回 lo

常见变形(全部可以用 lower_bound / upper_bound 推导):
  ┌─────────────────────────────┬──────────────────────────────┐
  │ 需求                        │ 写法                          │
  ├─────────────────────────────┼──────────────────────────────┤
  │ 第一个 == target             │ lb = lower_bound(target)      │
  │                             │ lb < n && nums[lb] == target  │
  ├─────────────────────────────┼──────────────────────────────┤
  │ 最后一个 == target           │ ub = upper_bound(target) - 1  │
  │                             │ ub >= 0 && nums[ub] == target │
  ├─────────────────────────────┼──────────────────────────────┤
  │ 第一个 > target              │ upper_bound(target)           │
  ├─────────────────────────────┼──────────────────────────────┤
  │ 最后一个 < target            │ lower_bound(target) - 1       │
  ├─────────────────────────────┼──────────────────────────────┤
  │ target 出现的次数            │ upper_bound - lower_bound     │
  └─────────────────────────────┴──────────────────────────────┘

"能力检测"型二分(最常考的高级变形):
  题目特征:"最小化最大值"或"最大化最小值"
  思路:二分答案 + 贪心验证
  例:分割数组的最大值(LC 410)、在 D 天内送达包裹(LC 1011)

  bool canFinish(int mid, ...) {
      // 贪心检查:在限制为 mid 时能否完成
  }
  int lo = 最小可能值, hi = 最大可能值;
  while (lo < hi) {
      int mid = lo + (hi - lo) / 2;
      if (canFinish(mid)) hi = mid;   // 能完成,尝试更小
      else lo = mid + 1;              // 不能完成,放宽限制
  }
  return lo;

二、滑动窗口——万能模板

面试超高频,几乎每场都考。核心思想:用两个指针维护一个窗口,右指针扩展窗口、左指针收缩窗口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 滑动窗口万能模板
int slidingWindow(string s) {
    unordered_map<char, int> window;  // 窗口内的状态
    int left = 0, result = 0;

    for (int right = 0; right < s.size(); right++) {
        char c = s[right];
        window[c]++;              // 1. 右指针扩展,更新窗口

        while (/* 窗口需要收缩的条件 */) {
            char d = s[left];
            window[d]--;          // 2. 左指针收缩,更新窗口
            left++;
        }

        result = max(result, right - left + 1);  // 3. 更新答案
    }
    return result;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
滑动窗口适用场景识别:
  关键词:"连续子串/子数组" + "满足某个条件" + "最长/最短/计数"

  ┌─────────────────────────────┬──────────────────────────────┐
  │ 题目                        │ 窗口收缩条件                   │
  ├─────────────────────────────┼──────────────────────────────┤
  │ 无重复字符的最长子串 (LC 3)   │ 窗口内有重复字符时收缩         │
  │ 最小覆盖子串 (LC 76)         │ 窗口包含所有目标字符时收缩      │
  │ 长度最小的子数组 (LC 209)     │ 窗口内和 >= target 时收缩      │
  │ 字母异位词 (LC 438)          │ 窗口大小 > p.size() 时收缩     │
  │ 最多 K 个不同字符 (LC 340)    │ 窗口内不同字符 > K 时收缩      │
  └─────────────────────────────┴──────────────────────────────┘

  求最长 → 在收缩前更新答案
  求最短 → 在收缩时更新答案

实例:无重复字符的最长子串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int lengthOfLongestSubstring(string s) {
    unordered_set<char> window;
    int left = 0, result = 0;

    for (int right = 0; right < s.size(); right++) {
        while (window.count(s[right])) {   // 有重复就收缩
            window.erase(s[left]);
            left++;
        }
        window.insert(s[right]);
        result = max(result, right - left + 1);
    }
    return result;
}

三、双指针——三种常见模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
模式 1:对撞指针(两端往中间走)
  适用:有序数组上的两数之和、接雨水、盛水最多的容器

  int left = 0, right = n - 1;
  while (left < right) {
      if (满足条件) 更新答案;
      if (某条件) left++;
      else right--;
  }

模式 2:快慢指针(同方向不同速度)
  适用:链表找环、链表中点、删除链表倒数第 N 个节点

  ListNode* slow = head, *fast = head;
  while (fast && fast->next) {
      slow = slow->next;
      fast = fast->next->next;
  }
  // slow 在中点(偶数节点时在左中点)

模式 3:同向双指针(归并/分区/去重)
  适用:合并有序数组、移除元素、去重

  // 原地去重(有序数组)
  int slow = 0;
  for (int fast = 1; fast < n; fast++) {
      if (nums[fast] != nums[slow]) {
          nums[++slow] = nums[fast];
      }
  }
  // 去重后长度 = slow + 1

四、单调栈——下一个更大/更小元素

1
2
3
4
核心思路:维护一个从栈底到栈顶单调递减(或递增)的栈。
遍历数组,对每个元素:
  1. 把栈中所有比它小的元素弹出(这些元素的"下一个更大"就是当前元素)
  2. 当前元素入栈
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 下一个更大元素(经典模板)
vector<int> nextGreater(vector<int>& nums) {
    int n = nums.size();
    vector<int> result(n, -1);     // 默认 -1 表示没有更大的
    stack<int> st;                  // 存下标

    for (int i = 0; i < n; i++) {
        while (!st.empty() && nums[st.top()] < nums[i]) {
            result[st.top()] = nums[i];  // 栈顶元素的下一个更大就是 nums[i]
            st.pop();
        }
        st.push(i);
    }
    return result;
}
1
2
3
4
5
6
7
8
9
10
单调栈变形速查:
  ┌─────────────────────────┬──────────────────────────────────┐
  │ 需求                    │ 栈的单调性                         │
  ├─────────────────────────┼──────────────────────────────────┤
  │ 下一个更大元素           │ 单调递减栈(栈顶最小)              │
  │ 下一个更小元素           │ 单调递增栈(栈顶最大)              │
  │ 上一个更大元素           │ 单调递减栈 + 从右往左遍历            │
  └─────────────────────────┴──────────────────────────────────┘

  经典题:每日温度(LC 739)、柱状图最大矩形(LC 84)、接雨水(LC 42)

五、前缀和——O(1) 求区间和

1
2
3
4
5
6
7
8
9
思路:preSum[i] = nums[0] + nums[1] + ... + nums[i-1]
     区间和 sum(l, r) = preSum[r+1] - preSum[l]

     nums:    [2, 3, 1, 4, 5]
     preSum: [0, 2, 5, 6, 10, 15]
                ↑ preSum[0] = 0(哨兵,非常关键!)

     sum(1, 3) = preSum[4] - preSum[1] = 10 - 2 = 8
     验证:3 + 1 + 4 = 8 ✓
1
2
3
4
5
6
7
// 前缀和构建
vector<int> preSum(nums.size() + 1, 0);
for (int i = 0; i < nums.size(); i++) {
    preSum[i + 1] = preSum[i] + nums[i];
}
// 区间和 [l, r]
int rangeSum = preSum[r + 1] - preSum[l];
1
2
3
4
5
6
7
8
9
10
11
前缀和的高级变形:

  "和为 K 的子数组"(LC 560)→ 前缀和 + 哈希表
    关键公式:preSum[j] - preSum[i] == K
    → 对每个 j,找之前有多少个 preSum[i] == preSum[j] - K
    → 用 unordered_map 存之前出现过的前缀和

  二维前缀和 → 矩阵区域和(LC 304)
    preSum[i][j] = 左上角 (0,0) 到 (i-1,j-1) 的和
    区域和 = preSum[r2+1][c2+1] - preSum[r1][c2+1]
                                - preSum[r2+1][c1] + preSum[r1][c1]

六、并查集(Union-Find)——连通性问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class UnionFind {
    vector<int> parent, rank_;
public:
    UnionFind(int n) : parent(n), rank_(n, 0) {
        iota(parent.begin(), parent.end(), 0);  // parent[i] = i
    }

    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);  // 路径压缩
        return parent[x];
    }

    void unite(int x, int y) {
        int px = find(x), py = find(y);
        if (px == py) return;
        if (rank_[px] < rank_[py]) swap(px, py);  // 按秩合并
        parent[py] = px;
        if (rank_[px] == rank_[py]) rank_[px]++;
    }

    bool connected(int x, int y) {
        return find(x) == find(y);
    }
};
1
2
3
4
5
6
7
8
9
10
并查集适用场景:
  关键词:"连通性"、"是否属于同一组"、"合并"

  经典题:
  ├── 岛屿数量(LC 200)—— BFS/DFS 也行,但并查集更优雅
  ├── 冗余连接(LC 684)—— 加边时检测是否成环
  ├── 账户合并(LC 721)
  └── 最小生成树 Kruskal 算法

  时间复杂度:近乎 O(1)(均摊,严格说是反阿克曼函数 α(n))

七、DP 公式速查表

面试时最怕”我知道用 DP 但想不出转移方程”。以下是高频 DP 题的状态定义和转移方程速查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
┌──────────────────────┬──────────────────────┬─────────────────────────────────┐
│ 题目                  │ 状态定义              │ 转移方程                         │
├──────────────────────┼──────────────────────┼─────────────────────────────────┤
│ 爬楼梯 (LC 70)        │ dp[i] = 到第i阶方法数 │ dp[i] = dp[i-1] + dp[i-2]       │
│ 打家劫舍 (LC 198)     │ dp[i] = 前i家最大金额 │ dp[i] = max(dp[i-1],            │
│                      │                      │              dp[i-2]+nums[i])   │
│ 最大子数组和 (LC 53)   │ dp[i] = 以i结尾的最大和│ dp[i] = max(nums[i],            │
│                      │                      │              dp[i-1]+nums[i])   │
│ 最长递增子序列 (LC 300)│ dp[i] = 以i结尾的LIS  │ dp[i] = max(dp[j]+1)            │
│                      │ 长度                  │   for j < i where nums[j]<nums[i]│
│ 零钱兑换 (LC 322)     │ dp[i] = 凑出i的最少   │ dp[i] = min(dp[i-coins[j]]+1)   │
│                      │ 硬币数                │   for each coin                  │
│ 最长公共子序列 (LC 1143)│dp[i][j] = A前i个和    │ 相同: dp[i-1][j-1]+1             │
│                      │ B前j个的LCS长度        │ 不同: max(dp[i-1][j], dp[i][j-1])│
│ 编辑距离 (LC 72)      │ dp[i][j] = A前i个变成 │ 相同: dp[i-1][j-1]               │
│                      │ B前j个的最少操作        │ 不同: min(dp[i-1][j],            │
│                      │                      │   dp[i][j-1], dp[i-1][j-1]) + 1 │
│ 不同路径 (LC 62)      │ dp[i][j] = 到(i,j)    │ dp[i][j] = dp[i-1][j]           │
│                      │ 的路径数               │          + dp[i][j-1]            │
│ 0-1背包              │ dp[j] = 容量j时最大价值 │ dp[j] = max(dp[j],              │
│                      │                      │              dp[j-w[i]]+v[i])    │
│                      │                      │   j 从大到小遍历!                │
│ 完全背包              │ 同上                  │ 同上,但 j 从小到大遍历!          │
│ 最长回文子串 (LC 5)    │ dp[i][j] = s[i..j]   │ dp[i][j] = s[i]==s[j]           │
│                      │ 是否回文              │            && dp[i+1][j-1]       │
│ 单词拆分 (LC 139)     │ dp[i] = s[0..i)能否   │ dp[i] = any(dp[j] &&            │
│                      │ 被拆分                │         s[j..i) in dict)         │
└──────────────────────┴──────────────────────┴─────────────────────────────────┘

DP 空间优化口诀:
  "一维看方向,二维看依赖"
  0-1背包逆序遍历 → 保证每个物品只用一次
  完全背包正序遍历 → 允许物品重复使用
  二维 DP 只依赖上一行 → 滚动数组优化为两行或一行

八、位运算技巧速查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
常用位运算操作:
  n & 1              → 判断奇偶(等价于 n % 2)
  n & (n - 1)        → 消除最低位的 1(Brian Kernighan)
  n | (n + 1)        → 将最低位的 0 变成 1
  n & (-n)           → 提取最低位的 1(lowbit)
  n ^ n = 0          → 任何数异或自己等于 0
  n ^ 0 = n          → 任何数异或 0 等于自己

经典题:
  只出现一次的数字 (LC 136):全部异或,成对抵消,剩下的就是答案
    int result = 0;
    for (int num : nums) result ^= num;
    return result;

  统计二进制中 1 的个数 (LC 191):
    int count = 0;
    while (n) {
        n &= (n - 1);   // 每次消除一个 1
        count++;
    }

  判断是否是 2 的幂:n > 0 && (n & (n-1)) == 0
  两个数不用临时变量交换:a ^= b; b ^= a; a ^= b;

九、Trie(前缀树)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct TrieNode {
    TrieNode* children[26] = {};
    bool isEnd = false;
};

class Trie {
    TrieNode* root = new TrieNode();
public:
    void insert(const string& word) {
        auto node = root;
        for (char c : word) {
            int i = c - 'a';
            if (!node->children[i])
                node->children[i] = new TrieNode();
            node = node->children[i];
        }
        node->isEnd = true;
    }

    bool search(const string& word) {
        auto node = find(word);
        return node && node->isEnd;
    }

    bool startsWith(const string& prefix) {
        return find(prefix) != nullptr;
    }

private:
    TrieNode* find(const string& s) {
        auto node = root;
        for (char c : s) {
            int i = c - 'a';
            if (!node->children[i]) return nullptr;
            node = node->children[i];
        }
        return node;
    }
};
1
2
3
4
5
6
7
8
Trie 适用场景:
  ├── 前缀匹配/自动补全
  ├── 单词搜索 II(LC 212)—— Trie + 回溯
  ├── 词典中最长的单词(LC 720)
  └── 替换单词(LC 648)

  时间:插入/查找 O(L),L = 单词长度
  空间:最坏 O(26^L),但通常远小于此

十、常见易错点与避坑指南

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
1. 整数溢出
   ✗ int mid = (left + right) / 2;            // left + right 可能溢出!
   ✓ int mid = left + (right - left) / 2;     // 安全

   ✗ int area = width * height;                // 两个 int 相乘可能溢出
   ✓ long long area = (long long)width * height;

2. 边界条件清单(写完代码后逐一检查)
   □ 空输入:nums.size() == 0
   □ 单元素:nums.size() == 1
   □ 全相同元素:[1, 1, 1, 1]
   □ 已排序/逆序:[1, 2, 3] / [3, 2, 1]
   □ 负数:[-1, -2, 3]
   □ 最大/最小值:INT_MAX, INT_MIN

3. 数组越界
   ✗ for (int i = 0; i <= nums.size(); i++)    // size() 返回 unsigned!
      // 当 nums 为空时 nums.size() - 1 = 巨大无符号数
   ✓ for (int i = 0; i < (int)nums.size(); i++)
   ✓ int n = nums.size();  // 先转成 int

4. 引用 vs 值传递
   ✗ void dfs(vector<int> path, ...)   // 每次递归都拷贝 path,O(n) 额外开销
   ✓ void dfs(vector<int>& path, ...) // 引用传递 + push_back/pop_back

5. unordered_map 的默认值
   map[key]++;   // 如果 key 不存在,会自动插入 key:0 然后 +1
                 // 这在统计频率时很方便,但在检查存在性时可能引入 bug
   // 检查存在性用 count() 或 find(),不要用 []

6. 字符串比较
   ✗ s1 == s2    // string 比较是 O(n),不是 O(1)
                 // 如果在循环内频繁比较长字符串,考虑用哈希

7. 递归爆栈
   深度超过 ~10^4 时可能栈溢出
   → 改成迭代 + 显式栈
   → 或者用尾递归优化(C++ 编译器可能优化)

8. 浮点数比较
   ✗ if (a == b)             // 浮点数不能直接比较
   ✓ if (abs(a - b) < 1e-9)  // 使用 epsilon

9. 取模运算
   题目说"答案对 10^9+7 取模"时:
   const int MOD = 1e9 + 7;
   // 加法取模:(a + b) % MOD
   // 乘法取模:(1LL * a * b) % MOD    ← 注意先转 long long
   // 减法取模:(a - b + MOD) % MOD    ← 防止负数!

10. 图的 visited 标记时机
    ✗ BFS 中在出队时标记 visited → 会重复入队,TLE
    ✓ BFS 中在入队时立即标记 visited
    DFS 中进入递归前标记(回溯时取消标记,如果需要所有路径)

十一、做题分类速查——”看到 XXX 就想到 YYY”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
看到"连续子数组/子串"    → 滑动窗口 / 前缀和
看到"排列/组合/子集"     → 回溯
看到"最短/最少步数"      → BFS
看到"最长/最大/最小"     → DP / 贪心
看到"有序数组查找"       → 二分
看到"第 K 大/小"         → 堆 / 快速选择
看到"连通性/分组"        → 并查集 / DFS
看到"前缀匹配"           → Trie
看到"下一个更大/更小"    → 单调栈
看到"区间合并/重叠"      → 排序 + 贪心
看到"树的路径/深度"      → DFS 递归
看到"层序/最近"          → BFS
看到"拓扑排序/先修课"    → BFS(Kahn) / DFS
看到"网格/矩阵搜索"      → DFS / BFS + visited
看到"回文"               → 中心扩展 / DP
看到"括号匹配/嵌套"      → 栈
看到"区间和/范围查询"    → 前缀和 / 线段树 / 树状数组
看到"最大化最小值"       → 二分答案

复杂度倒推法(n 是数据范围):
  n ≤ 10       → 暴力/回溯/全排列 O(n!)
  n ≤ 20       → 状压DP O(2^n) / 回溯剪枝
  n ≤ 100      → O(n^3) DP
  n ≤ 1000     → O(n^2) DP
  n ≤ 10^5     → O(n log n) 排序/二分/堆
  n ≤ 10^6     → O(n) 双指针/滑动窗口/前缀和
  n ≤ 10^9     → O(log n) 二分/数学

数据结构和算法不是背题,是练思维。理解了”为什么这个数据结构适合这个问题”,你就不再需要背 500 道题了。

本系列相关文章:

本文由作者按照 CC BY 4.0 进行授权