算法补充专题
Hot 100 没专门成章, 但面试 (尤其国内) 高频会问的经典主题. 本章按 “如何理解题目 → 为什么选算法 → 核心思想 → C++ 模板 → 易错点” 来写. 目标不是让你背代码, 而是让你知道题目出现某些信号时该选什么算法.
优先级: 排序手写 ⭐, 位运算 ⭐, 前缀和 / 差分, 并查集, 设计题. 排序和位运算几乎必问; 并查集和前缀和是中高频; LFU 等设计题偏大厂.
代码上下文: 示例使用 C++17, 省略重复的标准库头文件和
using namespace std;. 片段按文中给出的前置条件使用; 复制到独立文件时, 需要补齐该片段使用的标准库头文件.
进度自测
- ⭐ 手写快速排序: 能解释分区, 平均 / 最坏复杂度, 为什么不稳定
- ⭐ 手写归并排序: 能解释稳定性, 为什么链表排序常用归并
- ⭐ 手写堆排序: 能解释建堆, 下沉, 为什么堆适合 Top K
- 排序对比: 稳定性 / 复杂度 / 适用场景
- ⭐ 位运算基础:
&|^~<<>> - ⭐
n & (n - 1), 判 2 的幂, 统计 1 的个数 - 出现 3 次只出 1 次, 子集枚举, 状态压缩
- 并查集模板: find 路径压缩 + union 按秩 / 按大小
- 并查集应用: 省份数量 547 / 冗余连接 684
- 一维前缀和 / 二维前缀和
- 差分数组: 区间增减
- 设计题: LFU 460 / 用栈实现队列 232 / 循环队列 622
学习目标与章节边界
本章训练可迁移的算法专题: 排序性质, 并查集, 前缀和与数据结构组合; 具体 Hot 100 题的逐题推导, 代码与边界统一在 LeetCode Hot 100 算法清单的 “核心题完整教学单元”, 数据超过单机内存时改看海量数据处理. 完成后应能写出专题模板, 给出复杂度推导, 并用一个反例解释选型边界.
学算法的通用方法
很多人学算法痛苦, 是因为一上来就背代码. 正确顺序应该是:
- 先翻译题目: 题目到底让你求什么? 是存在性, 数量, 最大最小, 所有方案, 还是设计接口?
- 看限制条件: 时间, 空间, 输入规模决定算法上限.
- 识别关键词: 有序, 连续, 频次, 连通, 区间修改, Top K, 出现次数.
- 先说暴力: 暴力怎么做? 复杂度多少? 为什么不够?
- 再选算法: 说明这个算法利用了题目的哪个性质.
- 最后写模板: 写稳定模板, 不要临场发明边界.
面试中可以这样表达:
暴力做法是 [暴力方案], 复杂度为 [暴力复杂度].题目要求 / 数据规模不允许. 这里有 [题目特征], 所以我选 [算法名称].核心是 [核心性质], 实现上维护 [关键状态], 复杂度为 [目标复杂度].
时间复杂度和空间复杂度
令 n 为输入规模, 例如数组长度, 节点数或字符串长度. 复杂度描述 n 增大时资源消耗的渐进增长, 不是某台机器上的精确耗时. 忽略常数和低阶项: 3n^2 + 2n + 1 是 Theta(n^2), 因为大 n 时二次项主导; 但常数在实际约束下仍会影响能否通过.
O(f(n))是渐进上界,Omega(f(n))是渐进下界,Theta(f(n))同时给出紧确的上, 下界. 面试通常报O作为可保证的上界, 但不能把O(n^2)误说成一定是Theta(n^2).- 最好, 平均和最坏复杂度分别对应最有利, 按输入分布期望和最不利的输入. 摊还复杂度把一系列操作的总成本均摊到单次操作, 例如动态数组扩容的单次追加摊还为
O(1), 不表示每次追加都只执行常数次复制. - 空间复杂度要说明口径: 额外空间是不含输入本身的辅助内存; 递归还要计入调用栈. 例如归并排序通常额外
O(n), 二分查找的迭代写法额外O(1), 递归写法还占O(log n)栈空间.
常见增长从低到高是 O(1), O(log n), O(n), O(n log n), O(n^2), O(2^n), O(n!). 二分查找每轮将候选规模减半, 经过 k 轮满足 n / 2^k <= 1, 所以 k = O(log n); 两层各扫描 n 次的嵌套循环执行约 n * n 次, 因此是 O(n^2). 选型还必须代入约束: n 为 10^5 时通常应避免 O(n^2), n 为 20 左右时 O(2^n) 的状态枚举可能可行, O(n!) 则往往只适合更小的 n 或强剪枝.
1. 手写排序 (⭐ 国内几乎必问)
如何理解排序题
面试让你手写排序, 不是考你会不会调用 sort, 而是考你是否理解:
- 为什么平均是 O (n log n)?
- 最坏情况什么时候出现?
- 是否稳定?
- 是否原地?
- 适合数组还是链表?
- 工程里什么时候不用自己写?
常见选择:
- 快速排序: 数组通用排序, 平均快, 原地, 但不稳定, 最坏 O (n²).
- 归并排序: 稳定, 复杂度稳定 O (n log n), 需要 O (n) 空间, 适合链表和外部排序.
- 堆排序: 原地, 最坏 O (n log n), 不稳定; 思想和优先队列 / Top K 相关.
1.1 快速排序
核心思想
快排是分治:
- 选一个基准值 pivot.
- 分区: 小于 pivot 的放左边, 大于等于 pivot 的放右边.
- pivot 归位后, 递归排序左右两边.
关键理解: 分区后 pivot 的最终位置已经确定, 后续不需要再动它.
C++ 模板
#include <vector>
#include <cstdlib>
#include <algorithm>
using namespace std;
int partition(vector<int>& a, int lo, int hi) {
int pivot = a[hi];
int i = lo; // [lo, i) 都小于 pivot
for (int j = lo; j < hi; ++j) {
if (a[j] < pivot) {
swap(a[i], a[j]);
i++;
}
}
swap(a[i], a[hi]);
return i;
}
void quickSort(vector<int>& a, int lo, int hi) {
if (lo >= hi) return;
int p = partition(a, lo, hi);
quickSort(a, lo, p - 1);
quickSort(a, p + 1, hi);
}
怎么向面试官解释
- 平均复杂度 O (n log n):每次分区 O (n), 理想情况下递归深度 log n.
- 最坏 O (n²):如果数组已经有序且每次选端点作 pivot, 每次只减少一个元素.
- 额外空间平均 O (log n), 最坏 O (n):递归栈深度取决于分区是否均衡.
- 不稳定: 交换会打乱相等元素的原始顺序.
优化
- 随机 pivot: 降低遇到最坏情况的概率.
- 三数取中: 从头, 中, 尾选中位数作 pivot.
- 重复值很多时使用三路分区, 避免大量相等元素导致分区极度不均衡.
- 小数组用插入排序: 工程优化.
一次分区 trace: 数组 [4,2,5,2,3], 以末尾 3 为 pivot. 扫描到 2 时交换到左区, 扫描到第二个 2 时再交换, 扫描结束得到 [2,2,3,4,5], pivot 位于 2; 递归只处理 [2,2] 与 [4,5]. 这里的 “不稳定” 也可见于携带原始序号的 (2,A),(2,B): 跨 pivot 的交换可能改变 A/B 顺序.
int randomizedPartition(vector<int>& a, int lo, int hi) {
int pivotIndex = lo + rand() % (hi - lo + 1);
swap(a[pivotIndex], a[hi]);
return partition(a, lo, hi); // 依赖本节已有 Lomuto partition
}
void quickSort3Way(vector<int>& a, int lo, int hi) {
if (lo >= hi) return;
int pivotIndex = lo + rand() % (hi - lo + 1);
int pivot = a[pivotIndex];
swap(a[lo], a[pivotIndex]);
int lt = lo, i = lo + 1, gt = hi;
while (i <= gt) {
if (a[i] < pivot) swap(a[lt++], a[i++]);
else if (a[i] > pivot) swap(a[i], a[gt--]);
else ++i;
}
quickSort3Way(a, lo, lt - 1);
quickSort3Way(a, gt + 1, hi);
}
随机 pivot 不改变最坏 O(n^2), 但使对固定输入的期望复杂度为 O(n log n); 三路分区将等于 pivot 的元素一次性跳过, 重复值很多时明显减少递归. 边界: 全相等数组应令三路分区一次结束; rand() 仅作教学随机化, 生产代码使用 <random> 的确定性种子策略或标准库排序.
1.2 归并排序
核心思想
归并排序也是分治:
- 把数组从中间拆成两半.
- 分别递归排序.
- 合并两个有序数组.
它稳定, 是因为合并时如果左右元素相等, 先放左边元素, 就保持了原始相对顺序.
C++ 模板
void mergeSort(vector<int>& a, int lo, int hi, vector<int>& tmp) {
if (lo >= hi) return;
int mid = lo + (hi - lo) / 2;
mergeSort(a, lo, mid, tmp);
mergeSort(a, mid + 1, hi, tmp);
int i = lo, j = mid + 1, k = lo;
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++];
for (int x = lo; x <= hi; ++x) a[x] = tmp[x];
}
void mergeSort(vector<int>& a) {
if (a.empty()) return;
vector<int> tmp(a.size());
mergeSort(a, 0, static_cast<int>(a.size()) - 1, tmp);
}
适用场景
- 需要稳定排序.
- 链表排序: 链表合并两个有序链表很方便, 不需要随机访问.
- 外部排序: 数据太大放不进内存时, 可以分块排序再多路归并.
1.3 堆排序
核心思想
堆是一棵完全二叉树, 用数组表示:
- 下标 i 的左孩子:
2*i + 1 - 下标 i 的右孩子:
2*i + 2 - 下标 i 的父节点:
(i - 1) / 2
大顶堆满足: 每个节点都大于等于孩子. 因此堆顶是最大值.
堆排序流程:
- 建大顶堆.
- 把堆顶最大值交换到数组末尾.
- 缩小堆范围, 对新堆顶下沉.
- 重复直到有序.
C++ 模板
void sink(vector<int>& a, int root, int n) {
while (true) {
int largest = root;
int l = root * 2 + 1;
int r = root * 2 + 2;
if (l < n && a[l] > a[largest]) largest = l;
if (r < n && a[r] > a[largest]) largest = r;
if (largest == root) break;
swap(a[root], a[largest]);
root = largest;
}
}
void heapSort(vector<int>& a) {
int n = a.size();
for (int i = n / 2 - 1; i >= 0; --i) sink(a, i, n); // 建堆
for (int end = n - 1; end > 0; --end) {
swap(a[0], a[end]);
sink(a, 0, end);
}
}
怎么理解建堆 O (n)
不是每个节点都下沉 log n. 底层节点很多但下沉距离短, 上层节点少但下沉距离长, 总和是 O (n).面试一般说结论即可.
1.4 冒泡排序的可验证优化
冒泡排序相邻比较并交换逆序元素, 每轮会把当前无序区的最大值送到右侧. 只在 a[j] > a[j + 1] 时交换, 相等元素不交换, 因此它是稳定排序. 基础实现最好, 平均, 最坏时间分别为 O(n), O(n^2), O(n^2) (最好情况依赖提前退出), 空间 O(1); 工程中通常使用标准库排序, 此处用于理解交换和边界.
void bubbleSort(vector<int>& a) {
int unsortedEnd = static_cast<int>(a.size()) - 1;
while (unsortedEnd > 0) {
int lastSwap = -1;
for (int j = 0; j < unsortedEnd; ++j) {
if (a[j] > a[j + 1]) {
swap(a[j], a[j + 1]);
lastSwap = j;
}
}
if (lastSwap == -1) break; // 本轮没有交换,数组已有序
unsortedEnd = lastSwap;
}
}
lastSwap 之后的区间在本轮没有发生交换, 已经有序, 下一轮无需再扫描. -1 专门表示本轮没有交换, 因此不会与索引 0 发生交换混淆. 用 [1,2,3,4] 验证首次扫描即退出; 用 [3,2,1] 验证连续交换得到 [1,2,3].
1.5 红黑树: 有界高度的有序映射基础
红黑树是带颜色约束的二叉搜索树. 它满足二叉搜索树顺序, 并维持以下关键性质:
- 根节点为黑色.
- 所有空叶子节点 (概念上的 NIL) 为黑色.
- 红色节点的子节点都是黑色, 不存在连续红节点.
- 从任一节点到其所有后代 NIL 叶子的路径, 黑色节点数相同, 称为黑高一致.
这些约束限制了最长路径不会超过最短路径的两倍, 因而树高为 O(log n), 查找, 插入和删除都能保持 O(log n). 工程上它适合需要按键有序遍历, 范围查询或有序映射 / 集合的场景; 具体 JDK 类采用何种内部实现和阈值会随版本变化, 不应把某个 JDK 的细节当作通用承诺.
插入新节点通常先按普通二叉搜索树插入并标红, 以免立即改变各路径黑高. 若出现红父子冲突:
- 叔节点为红: 将父和叔染黑, 祖父染红, 把检查上移到祖父.
- 叔节点为黑或 NIL: 先将 “内侧” 形状旋转为 “外侧”, 再围绕祖父旋转, 并交换父 / 祖父的颜色.
修复循环结束后必须无条件将根节点染黑, 包括首次插入以及叔节点为红, 检查上移到根的情形; 这样才能恢复根为黑色的性质.
以左左形状为例, grandparent 的左孩子 parent 和 parent 的左孩子 node 均为红. 对祖父右旋, 然后将 parent 染黑, grandparent 染红; 中序顺序仍是 left < parent < grandparent < right, 所以旋转没有破坏二叉搜索树顺序, 只调整了局部父子关系.
删除若移除了黑节点, 可能使某一路少一个黑色. 修复围绕 “带额外黑色” 的节点及其兄弟进行: 兄弟为红时先旋转并换色, 转成兄弟为黑的情况; 兄弟及其子节点都黑时把兄弟染红并把问题上移; 兄弟有靠外的红孩子时, 通过一次或两次旋转和重新着色让路径黑高恢复. 面试回答重点是 “旋转保持中序顺序, 变色恢复红节点和黑高约束”, 不必背诵某个库的全部分支代码.
排序对比 (高频问)
| 算法 | 平均 | 最坏 | 额外空间 | 稳定 | 核心用途 |
|---|---|---|---|---|---|
| 快排 | O (n log n) | O (n²) | O (log n) | 否 | 数组通用排序, 实际很快 |
| 归并 | O (n log n) | O (n log n) | O (n) | 是 | 稳定排序, 链表排序, 外部排序 |
| 堆排 | O (n log n) | O (n log n) | O (1) | 否 | 原地排序, 理解 Top K |
| 插入 | O (n²) | O (n²) | O (1) | 是 | 小数组, 近乎有序数组 |
| 冒泡 | O (n²) | O (n²) | O (1) | 是 | 教学, 提前退出时最好 O (n) |
易错点
- 快排分区时循环边界最容易错, 建议固定一种 partition 写法.
- 快排不是稳定排序.
- 归并合并时
<=才能保持稳定. - 堆排序下沉范围
n是当前堆大小, 不是数组总长度. - Java 中基本类型
Arrays.sort(int[])不是稳定排序; 对象数组排序是稳定的 TimSort.
2. 位运算专题 (⭐)
如何理解位运算题
位运算题常见信号:
- 数字出现次数有规律: 一个出现一次, 其余出现两次 / 三次.
- 要求 O (1) 额外空间.
- 需要表示集合状态: 选/不选, 访问/未访问.
- 需要判断奇偶, 2 的幂, 某一位是否为 1.
位运算本质是把整数看成二进制位数组.
基础运算
| 运算 | 含义 | 例子 |
|---|---|---|
& | 两位都为 1 才为 1 | 判断某位是否为 1 |
| ` | ` | 有一位为 1 就为 1 |
^ | 相同为 0, 不同为 1 | 成对抵消 |
~ | 按位取反 | 掩码处理 |
<< | 左移, 乘 2 | 1 << i 表示第 i 位 |
>> | 右移 | 取高位 / 除 2 |
必背技巧
以下是表达式速记, 不是可独立编译的函数:
n & (n - 1); // 消除最低位的 1
n & -n; // 取最低位的 1,也叫 lowbit
(n & (n - 1)) == 0 // 判断 2 的幂,前提 n > 0
(x >> i) & 1; // 取第 i 位
x | (1 << i); // 把第 i 位置 1
x & ~(1 << i); // 把第 i 位置 0
x ^ (1 << i); // 翻转第 i 位
异或为什么能找单身数
异或有三个性质:
a ^ a = 0a ^ 0 = a- 交换律和结合律成立
所以一堆数里, 成对出现的数字都会抵消, 最后剩下只出现一次的数.
int singleNumber(vector<int>& nums) {
int ans = 0;
for (int x : nums) ans ^= x;
return ans;
}
统计 1 的个数
int hammingWeight(unsigned int n) {
int cnt = 0;
while (n != 0) {
n &= (n - 1); // 每次消掉一个最低位的 1
cnt++;
}
return cnt;
}
复杂度不是固定 32 次, 而是和 1 的个数有关.
判 2 的幂
bool isPowerOfTwo(int n) {
return n > 0 && (n & (n - 1)) == 0;
}
理解: 2 的幂二进制只有一个 1, 例如 8 是 1000, 7 是 0111, 相与为 0.
出现 3 次, 只出现 1 次
思路: 每一位单独统计 1 的个数. 如果其他数都出现 3 次, 那么每一位的计数对 3 取模后, 剩下的就是只出现一次的数在这一位上的值.
#include <cstdint>
int32_t singleNumberII(vector<int>& nums) {
uint32_t ans = 0;
for (int i = 0; i < 32; ++i) {
int cnt = 0;
for (int x : nums) {
uint32_t bits = static_cast<uint32_t>(x);
if ((bits >> i) & 1u) cnt++;
}
if (cnt % 3) ans |= (uint32_t{1} << i);
}
return static_cast<int32_t>(ans);
}
子集枚举
如果 n 比较小, 可以用一个整数 mask 表示选了哪些元素.
vector<vector<int>> subsets(vector<int>& nums) {
int n = nums.size();
if (n >= 31) throw invalid_argument("subsets requires n < 31");
vector<vector<int>> res;
uint32_t total = uint32_t{1} << n;
for (uint32_t mask = 0; mask < total; ++mask) {
vector<int> cur;
for (int i = 0; i < n; ++i) {
if ((mask >> i) & 1u) cur.push_back(nums[i]);
}
res.push_back(cur);
}
return res;
}
易错点
- 判断 2 的幂必须加
n > 0. - 左移注意溢出,
1 << 31对有符号 int 有风险, 必要时用1LL. - C++ 负数右移和符号位相关, 题目涉及无符号时用
unsigned更清晰.
3. 并查集 (Union-Find)
如何理解题目
并查集专门处理 “分组” 和 “连通性”.看到这些表达可以优先想并查集:
- 两个元素是否属于同一组.
- 不断合并集合.
- 最后有几个连通分量.
- 加一条边会不会形成环.
典型题: 省份数量, 冗余连接, 等式方程可满足性.
为什么选并查集
如果每次都 DFS/BFS 判断两个点是否连通, 多次查询会很慢. 并查集把每个集合用一个代表元表示, 合并和查询都接近 O (1).
核心思想
parent[x]表示 x 的父节点.- 如果
parent[x] == x, x 是这个集合的根. find(x)找 x 的根.union(a, b)把两个集合合并.
两个优化:
- 路径压缩: find 时把沿途节点直接挂到根上.
- 按秩 / 按大小合并: 小树挂到大树下, 避免树太高.
C++ 模板
class UnionFind {
vector<int> parent;
vector<int> size;
public:
int count;
UnionFind(int n) : parent(n), size(n, 1), count(n) {
for (int i = 0; i < n; ++i) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]);
return parent[x];
}
bool unite(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return false;
if (size[ra] < size[rb]) swap(ra, rb);
parent[rb] = ra;
size[ra] += size[rb];
count--;
return true;
}
bool connected(int a, int b) {
return find(a) == find(b);
}
};
应用: 547 省份数量
下面的函数依赖上一小节定义的 UnionFind 类型, 不是独立代码块.
int findCircleNum(vector<vector<int>>& isConnected) {
int n = isConnected.size();
UnionFind uf(n);
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
if (isConnected[i][j] == 1) uf.unite(i, j);
}
}
return uf.count;
}
应用: 684 冗余连接
遍历边 (u, v):
- 如果 u 和 v 已经连通, 再加这条边就成环, 这条边就是答案.
- 否则合并它们.
vector<int> findRedundantConnection(vector<vector<int>>& edges) {
UnionFind uf(static_cast<int>(edges.size()) + 1); // 题目节点编号从 1 开始
for (const auto& edge : edges) {
if (!uf.unite(edge[0], edge[1])) return edge;
}
return {};
}
对 n 条边, 时间 O(n alpha(n)), 空间 O(n), 其中 alpha 为反阿克曼函数, 实践中近似常数. 边界: 若输入不保证 “恰有一条冗余边”, 空返回仅表示没有找到, 调用方不能把它当有效边.
易错点
find不做路径压缩, 最坏会退化.- 合并时要合并根, 不是直接
parent[a] = b. - 节点编号有时从 1 开始, 要开
n + 1.
4. 前缀和 & 差分
如何理解题目
前缀和和差分是一对逆运算.
- 前缀和: 适合多次查区间和. 先预处理, 后面 O (1) 查询.
- 差分: 适合多次做区间修改. 每次 O (1) 标记, 最后统一还原.
关键词: 区间和, 子数组和, 区域和, 区间加, 批量更新.
4.1 一维前缀和
定义: pre[i] 表示前 i 个元素的和, 也就是 nums[0..i-1].
区间 [l, r] 的和: pre[r + 1] - pre[l].
vector<long long> buildPrefix(const vector<int>& nums) {
int n = nums.size();
vector<long long> pre(n + 1, 0);
for (int i = 0; i < n; ++i) {
pre[i + 1] = pre[i] + nums[i];
}
return pre;
}
long long rangeSum(const vector<long long>& pre, int l, int r) {
return pre[r + 1] - pre[l];
}
为什么开 n + 1? 因为 pre[0] = 0 表示空前缀, 可以统一处理从 0 开始的区间.
4.2 前缀和 + 哈希表: 560 和为 K 的子数组
普通滑动窗口不适用, 因为数组可能有负数. 用前缀和:
如果 pre[j] - pre[i] = k, 说明 (i, j] 这段子数组和为 k. 遍历到当前前缀和 sum 时, 只要知道之前有多少个 sum - k.
int subarraySum(vector<int>& nums, int k) {
unordered_map<long long, int> cnt;
cnt[0] = 1;
long long sum = 0;
int ans = 0;
for (int x : nums) {
sum += x;
if (cnt.count(sum - k)) ans += cnt[sum - k];
cnt[sum]++;
}
return ans;
}
4.3 二维前缀和
pre[i][j] 表示从左上角到 (i-1, j-1) 的矩形和.
查询 (r1, c1) 到 (r2, c2):
sum = pre[r2+1][c2+1]
- pre[r1][c2+1]
- pre[r2+1][c1]
+ pre[r1][c1]
最后加回左上角, 是因为它被减了两次.
vector<vector<long long>> buildPrefix2D(const vector<vector<int>>& matrix) {
int m = matrix.size(), n = m ? matrix[0].size() : 0;
vector pre(m + 1, vector<long long>(n + 1, 0));
for (int r = 0; r < m; ++r)
for (int c = 0; c < n; ++c)
pre[r + 1][c + 1] = matrix[r][c] + pre[r][c + 1] + pre[r + 1][c] - pre[r][c];
return pre;
}
long long sumRegion(const vector<vector<long long>>& pre, int r1, int c1, int r2, int c2) {
return pre[r2 + 1][c2 + 1] - pre[r1][c2 + 1] - pre[r2 + 1][c1] + pre[r1][c1];
}
预处理时间 / 空间 O(mn), 单次合法矩形查询 O(1). 边界: 空矩阵只能构建 1x1 前缀表, 不能查询; 单格查询 (r,c,r,c) 必须返回原值.
4.4 差分数组
如果有很多次 “区间 [l, r] 都加 val”, 逐个元素加会 O (n*m).差分可以每次 O (1):
class Difference {
vector<long long> diff;
public:
explicit Difference(const vector<int>& nums) : diff(nums.size()) {
if (nums.empty()) return;
diff[0] = nums[0];
for (size_t i = 1; i < nums.size(); ++i) {
diff[i] = nums[i] - nums[i - 1];
}
}
void increment(int l, int r, long long val) {
if (l < 0 || l > r || r >= static_cast<int>(diff.size())) {
throw out_of_range("invalid difference range");
}
diff[l] += val;
if (r + 1 < static_cast<int>(diff.size())) diff[r + 1] -= val;
}
vector<long long> result() const {
if (diff.empty()) return {};
vector<long long> res(diff.size());
res[0] = diff[0];
for (size_t i = 1; i < diff.size(); ++i) {
res[i] = res[i - 1] + diff[i];
}
return res;
}
};
典型题
- 560 和为 K 的子数组: 前缀和 + 哈希表.
- 724 寻找中心下标: 左和等于右和.
- 304 二维区域和检索: 二维前缀和.
- 1109 航班预订统计: 差分数组.
- 1094 拼车: 差分 + 判断任意位置是否超载.
易错点
- 前缀和数组建议开
n + 1. - 560 必须先
cnt[0] = 1, 代表空前缀. - 差分更新右边界时要判断
r + 1是否越界. - 有负数时不要用滑动窗口求和为 K.
5. 设计题
如何理解设计题
设计题不是让你炫复杂代码, 而是考 “数据结构组合”.先问清楚:
- 每个接口是什么?
- 每个操作要求什么复杂度?
- 数据量多大?
- 是否有淘汰策略或顺序要求?
设计题的套路是: 单一数据结构不够, 就组合两个或多个结构.
5.1 LRU 缓存 (146)
完整实现见 LeetCode Hot 100 算法清单第 6 节 “链表” 中的 LRU 缓存代码. 第 52 篇目前没有 LRU 专属标题; 该真实锚点的目标节包含 LRUCache 完整实现. 这里不重复代码, 只保留选型逻辑:
- 要 O (1) 查 key: 用哈希表.
- 要 O (1) 删除最久未使用: 用双向链表维护顺序.
- get/put 后都要把节点移动到最新位置.
这是 “哈希表 + 双向链表” 的经典组合.
第 52 篇给的是 C++ 版; Android 面试手写通常要求岗位语言, 下面补 Java 手写版, 分 “简单版” 和 “面试手写版”. Kotlin 可等价改写 (data class + LinkedHashMap, 或 HashMap + 双向链表).
简单版: 继承 LinkedHashMap
LinkedHashMap 自带按访问顺序排列与 removeEldestEntry 淘汰钩子, 十几行即可实现:
class LRUCache extends LinkedHashMap<Integer, Integer> {
private final int capacity;
public LRUCache(int capacity) {
// accessOrder = true: get/put 命中的节点移到队尾, 队尾即最近使用.
super(capacity, 0.75f, true);
this.capacity = capacity;
}
public int get(int key) {
Integer value = super.get(key);
// 未命中返回 -1; 命中时 super.get 已更新访问顺序.
return value == null ? -1 : value;
}
public void put(int key, int value) {
super.put(key, value); // 复用 LinkedHashMap, put 后会自动触发淘汰检查.
}
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
// 队头是最久未使用; 超容量才淘汰, 由 LinkedHashMap 自动调用.
return size() > capacity;
}
}
面试手写版: HashMap + 双向链表
面试常要求不依赖 LinkedHashMap, 手写 “哈希表定位 + 双向链表维护顺序”:
class LRUCache {
// 节点同时存 key/value: 淘汰尾部时用 key 去删哈希表.
static class Node {
int key, value;
Node prev, next;
Node(int key, int value) { this.key = key; this.value = value; }
}
private final int capacity;
private final Map<Integer, Node> map = new HashMap<>();
// 哨兵头尾节点, 避免判空; head 之后是最近使用, tail 之前是最久未使用.
private final Node head = new Node(0, 0);
private final Node tail = new Node(0, 0);
public LRUCache(int capacity) {
this.capacity = capacity;
head.next = tail;
tail.prev = head;
}
public int get(int key) {
Node node = map.get(key);
if (node == null) return -1;
moveToHead(node); // 访问过就升级为最近使用
return node.value;
}
public void put(int key, int value) {
Node node = map.get(key);
if (node != null) {
node.value = value;
moveToHead(node); // 覆盖值并升级为最近使用
return;
}
if (map.size() == capacity) removeTail(); // 满则淘汰最久未使用
Node fresh = new Node(key, value);
map.put(key, fresh);
addToHead(fresh);
}
private void moveToHead(Node node) {
removeNode(node);
addToHead(node);
}
private void addToHead(Node node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void removeTail() {
Node last = tail.prev;
removeNode(last);
map.remove(last.key); // 链表和哈希表必须同步删除
}
}
为什么 get/put 都是 O (1)
- 哈希表: 查 key 平均 O (1), 负责 “定位”.
- 双向链表: 已知节点后增删都是 O (1), 直接改前驱 / 后继指针即可. 这正是不能用单链表的原因: 删尾节点要先遍历找前驱, 是 O (n).
- 两个操作都只是 “一次哈希查找 + 常数次指针改动”, 所以 get/put 均为 O (1).
5.2 LFU 缓存 (460)
如何理解题目
LFU 是 Least Frequently Used: 淘汰访问频次最低的 key; 如果频次相同, 淘汰最久未使用的.
比 LRU 多了一个维度: 访问频次.
选用结构
为了让 get/put 都接近 O (1), 需要:
key -> 节点: 快速找到 key.freq -> 双向链表: 同一频次内部按 LRU 排序.minFreq: 当前最小频次, 淘汰时直接定位.
C++ 可用 list + unordered_map 简化.
class LFUCache {
struct Node { int key, value, freq; };
int cap, minFreq = 0;
unordered_map<int, list<Node>::iterator> keyIt;
unordered_map<int, list<Node>> freqList;
void touch(list<Node>::iterator it) {
Node node = *it;
int f = node.freq;
freqList[f].erase(it);
if (freqList[f].empty()) {
freqList.erase(f);
if (minFreq == f) minFreq++;
}
node.freq++;
freqList[node.freq].push_front(node);
keyIt[node.key] = freqList[node.freq].begin();
}
public:
LFUCache(int capacity) : cap(capacity) {}
int get(int key) {
if (!keyIt.count(key)) return -1;
int val = keyIt[key]->value;
touch(keyIt[key]);
return val;
}
void put(int key, int value) {
if (cap <= 0) return;
if (keyIt.count(key)) {
keyIt[key]->value = value;
touch(keyIt[key]);
return;
}
if (keyIt.size() == cap) {
auto& lst = freqList[minFreq];
int oldKey = lst.back().key;
lst.pop_back();
keyIt.erase(oldKey);
if (lst.empty()) freqList.erase(minFreq);
}
minFreq = 1;
freqList[1].push_front({key, value, 1});
keyIt[key] = freqList[1].begin();
}
};
易错点
- 更新已有 key 也算一次访问, 要提升频次.
- 淘汰后要同步删除
keyIt. - 某个频次链表空了, 如果它等于
minFreq, 要更新minFreq.
5.3 用栈实现队列 (232)
核心思想
队列是先进先出, 栈是后进先出. 用两个栈:
in: 负责入队.out: 负责出队.- 当
out空时, 把in全部倒进去, 顺序就反过来了.
class MyQueue {
stack<int> in, out;
void transfer() {
if (out.empty()) {
while (!in.empty()) {
out.push(in.top());
in.pop();
}
}
}
public:
void push(int x) { in.push(x); }
int pop() {
transfer();
int x = out.top(); out.pop();
return x;
}
int peek() {
transfer();
return out.top();
}
bool empty() { return in.empty() && out.empty(); }
};
复杂度: 单次最坏 O (n), 但每个元素最多进出两个栈一次, 所以摊还 O (1).
5.4 设计循环队列 (622)
核心思想
固定数组 + 头尾指针. 为了区分空和满, 最简单是额外维护 size.
class MyCircularQueue {
vector<int> data;
int head = 0, tail = 0, sz = 0, cap;
public:
MyCircularQueue(int k) : data(k), cap(k) {}
bool enQueue(int value) {
if (isFull()) return false;
data[tail] = value;
tail = (tail + 1) % cap;
sz++;
return true;
}
bool deQueue() {
if (isEmpty()) return false;
head = (head + 1) % cap;
sz--;
return true;
}
int Front() { return isEmpty() ? -1 : data[head]; }
int Rear() {
if (isEmpty()) return -1;
return data[(tail - 1 + cap) % cap];
}
bool isEmpty() { return sz == 0; }
bool isFull() { return sz == cap; }
};
5.5 设计 HashMap / Trie
- HashMap: 数组 + 链表 / 红黑树处理冲突; 核心概念是哈希函数, 冲突, 负载因子, 扩容.
- Trie: 前缀树, 适合字符串前缀查询. 每个节点保存子节点数组和
isEnd.
class Trie {
struct Node {
Node* child[26]{};
bool isEnd = false;
};
Node* root = new Node();
public:
~Trie() { destroy(root); }
void insert(string word) {
Node* cur = root;
for (char c : word) {
int i = c - 'a';
if (!cur->child[i]) cur->child[i] = new Node();
cur = cur->child[i];
}
cur->isEnd = true;
}
bool search(string word) {
Node* node = find(word);
return node && node->isEnd;
}
bool startsWith(string prefix) {
return find(prefix) != nullptr;
}
private:
void destroy(Node* node) {
if (!node) return;
for (Node* child : node->child) destroy(child);
delete node;
}
Node* find(const string& s) {
Node* cur = root;
for (char c : s) {
int i = c - 'a';
if (!cur->child[i]) return nullptr;
cur = cur->child[i];
}
return cur;
}
};
易错点
- 设计题先确认接口, 再写数据结构.
- LRU/LFU 的关键不是 “能跑”, 而是所有操作是否满足复杂度.
- 循环队列最容易错的是
Rear()和取模. - Trie 如果字符集不是小写字母, 不能直接用 26 长度数组, 要改成哈希表.
小结: 补充专题优先级
- 必吃透: 快排/归并/堆排 + 三者对比; 位运算基础和经典 trick.
- 建议掌握: 并查集模板, 前缀和 + 哈希表, 差分数组.
- 冲大厂加分: LFU, 循环队列, HashMap/Trie 设计.
- 学习方式: 每个专题至少掌握一道母题, 能解释 “为什么选它”, 再去刷变体.
主题练习与预期证据
- 用三路快排处理
[2,2,2,1,3], 预期有序输出[1,2,2,2,3], 并说明等值区间为何不递归. - 为 684 输入
[[1,2],[1,3],[2,3]], 预期返回[2,3]; 证据是unite返回false的时刻. - 为矩阵
[[1,2],[3,4]]查询(0,1)到(1,1), 预期 6; 手算四项容斥证明没有错位.