一、核心思想——每次排除一半
前提:数组必须有序。
在有序数组 [1, 3, 5, 7, 9, 11, 13, 15] 中找 7:
[1, 3, 5, 7, 9, 11, 13, 15] left=0, right=7, mid=3 → nums[3]=7 ✅ 找到
↑mid
如果找 6:
[1, 3, 5, 7, 9, 11, 13, 15] left=0, right=7, mid=3 → nums[3]=7 >6
left=0, right=2, mid=1 → nums[1]=3 <6
left=2, right=2, mid=2 → nums[2]=5 <6
left=3, right=2 → 退出,找不到
二、两种区间写法
面试高频考点:左闭右闭 [left, right] 和 左闭右开 [left, right) 的区别。
左闭右闭 [left, right] 整体流程:
- 初始化:
left = 0,right = n-1,搜索区间包括两端。 - 循环:
while (left <= right),区间非空时继续。 - 取中点:
mid = left + (right - left) / 2(防溢出写法)。 - 命中:
nums[mid] == target→ 返回mid。 - 缩小:
nums[mid] < target→ target 在右半,left = mid + 1;否则在左半,right = mid - 1(mid 已检查过,排除)。
左闭右开 [left, right) 整体流程:
- 初始化:
left = 0,right = n,搜索区间不包含right。 - 循环:
while (left < right),区间非空时继续。 - 取中点:
mid = left + (right - left) / 2。 - 命中:
nums[mid] == target→ 返回mid。 - 缩小:
nums[mid] < target→left = mid + 1;否则right = mid(右开,mid 保留在搜索范围内作为新右边界)。
int binarySearchClose(const vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1; // ① [left, right] 闭区间
while (left <= right) { // ② 区间非空
int mid = left + (right - left) / 2; // ③ 取中点(防溢出)
if (nums[mid] == target) return mid; // ④ 命中
if (nums[mid] < target) left = mid + 1; // ⑤ target 在右半
else right = mid - 1; // ⑥ target 在左半
}
return -1; // ⑦ 未找到
}
int binarySearchOpen(const vector<int>& nums, int target) {
int left = 0, right = nums.size(); // ① [left, right) 右开区间
while (left < right) { // ② 区间非空
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
if (nums[mid] < target) left = mid + 1;
else right = mid; // ③ 右开,mid 作为新右边界
}
return -1;
}
两种写法的关键区别:
左闭右闭 [left, right] |
左闭右开 [left, right) |
|
|---|---|---|
| 初始边界 | 0, n-1 |
0, n |
| 循环条件 | left <= right |
left < right |
| right 更新 | right = mid - 1 |
right = mid |
| 区间含义 | 搜索区间包含 right | 搜索区间不包含 right |
个人推荐:用左闭右闭 [left, right],更容易理解,与日常思维一致。
三、std::lower_bound 和 std::upper_bound
这两个函数写二分搜索时非常有价值,理解它们等于理解了二分搜索的边界处理。
// lower_bound:第一个 >= target 的位置
// 在 [1, 2, 3, 5, 5, 5, 7, 9] 中:
// lower_bound(5) → 指向第一个 5(位置 3)
// lower_bound(4) → 指向第一个 5(第一个 >=4 的元素)
// lower_bound(10) → 指向 end()(所有元素 < 10)
// upper_bound:第一个 > target 的位置
// upper_bound(5) → 指向 7(第一个 >5 的元素)
// upper_bound(5) - lower_bound(5) = 3(5 的个数)
**整体操作流程**:
1. **初始化**:`left = 0`,`right = n`,采用左闭右开区间 `[left, right)`。
2. **循环**:`while (left < right)`。
3. **判断**:如果 `nums[mid] < target`,目标在 `mid` 右侧,`left = mid + 1`;否则 `nums[mid] >= target`,目标在 `mid` 或左侧,`right = mid`。
4. **返回**:循环结束时 `left == right`,指向第一个 `>= target` 的元素位置。
```cpp
int lowerBound(const vector<int>& nums, int target) {
int left = 0, right = nums.size(); // ① [left, right) 区间
while (left < right) { // ② 区间非空
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1; // ③ mid < target,答案在右侧
} else {
right = mid; // ④ mid >= target,答案在左侧含 mid
}
}
return left; // ⑤ 第一个 >=target 的位置
}
5.2 二分搜索变种
一、旋转排序数组搜索(LeetCode 33)
问题:[4, 5, 6, 7, 0, 1, 2] 这样一个数组(原本有序,在某点旋转),在 $O(\log N)$ 时间内查找 target。
核心思路:每次二分后,至少有一半是有序的。
nums = [4, 5, 6, 7, 0, 1, 2], target = 0
mid = 3, nums[3] = 7
左半 [4,5,6,7] 有序,右半 [0,1,2] 有序?— 不对!
准确说:nums[left] <= nums[mid] → 左半有序
nums[4]=4 <= nums[3]=7 ✅ 左半有序
target=0 在左半 [4,7] 范围内?0 < 4 → 不在,去右半
left = mid + 1 = 4
mid = 5, nums[5] = 1
nums[4]=0 <= nums[5]=1 → 左半 [0,1] 有序
target=0 在 [0,1] 内 → 去左半
right = mid = 5
...
整体操作流程:
- 二分查找框架:
left = 0,right = n-1,标准闭区间二分。 - 判断有序区间:比较
nums[left]和nums[mid]:- 若
nums[left] <= nums[mid]:左半区间是有序的。 - 否则:右半区间是有序的。
- 若
- 在有序区间内判断:
- 左半有序:检查 target 是否在
[nums[left], nums[mid])范围内。如果在,收缩右边界;否则去右半。 - 右半有序:检查 target 是否在
(nums[mid], nums[right]]范围内。如果在,收缩左边界;否则去左半。
- 左半有序:检查 target 是否在
- 返回:命中返回下标,否则返回 -1。
int search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (left <= right) { // ① 标准二分框架
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
if (nums[left] <= nums[mid]) { // ② 左半有序
if (nums[left] <= target && target < nums[mid]) {
right = mid - 1; // ③ target 在左半
} else {
left = mid + 1; // ④ target 在右半
}
} else { // ⑤ 右半有序
if (nums[mid] < target && target <= nums[right]) {
left = mid + 1; // ⑥ target 在右半
} else {
right = mid - 1; // ⑦ target 在左半
}
}
}
return -1;
}
二、寻找峰值(LeetCode 162)
问题:数组 [1, 2, 3, 1] 中,峰值元素比左右都大(3)。在 $O(\log N)$ 时间内找到任意一个峰值。
核心思路:如果 nums[mid] < nums[mid+1],峰值在右侧。否则峰值在左侧(包含 mid)。
整体操作流程:
- 二分框架:
left = 0,right = n-1,使用left < right而非<=,因为至少有一个峰值。 - 判断方向:比较
nums[mid]和nums[mid+1]:- 上坡(
nums[mid] < nums[mid+1]):峰值在右侧,left = mid + 1。 - 下坡或平台(
nums[mid] >= nums[mid+1]):峰值在左侧或当前位置,right = mid。
- 上坡(
- 返回:循环结束时
left == right,该位置就是一个峰值。
int findPeakElement(vector<int>& nums) {
int left = 0, right = nums.size() - 1;
while (left < right) { // ① 二分查找
int mid = left + (right - left) / 2;
if (nums[mid] < nums[mid + 1]) { // ② 上坡 → 峰值在右
left = mid + 1;
} else { // ③ 下坡或平 → 峰值在左
right = mid;
}
}
return left; // ④ left == right 即峰值
}
为什么一定存在峰值? 因为 nums[-1] = -∞ 且 nums[n] = -∞,所以至少存在一个局部最大值。
5.3 浮点数二分与二分答案
一、浮点数平方根
问题:计算 $\sqrt{x}$,精度 $10^{-6}$。
整体操作流程:
- 初始化:
left = 0,right = x。如果x < 1,开方结果大于 x(如 0.25 → 0.5),所以将right设为 1。 - 浮点数二分:循环条件用
right - left > eps(精度),而非整数的left <= right。 - 判断:
mid * mid < x→ mid 太小,left = mid;否则 mid 太大,right = mid。 - 返回:精度满足要求时,
left即为近似平方根。
double mySqrt(double x) {
double left = 0, right = x;
if (x < 1) right = 1; // ① x<1 时 sqrt(x) > x
while (right - left > 1e-7) { // ② 精度控制而非整数比较
double mid = left + (right - left) / 2;
if (mid * mid < x) {
left = mid; // ③ mid 太小,向右靠
} else {
right = mid; // ④ mid 太大,向左靠
}
}
return left; // ⑤ 返回近似值
}
和整数二分的区别:
- 循环条件用
right - left > eps(精度)而不是left <= right。 left和right的更新不需要±1,直接用mid。- 因为浮点数是稠密的,不存在"跳过"的问题。
二、二分答案——把最优化问题转为判定问题
核心思想:当问题要求"最小化最大值"或"最大化最小值",且解是单调的(如果 x 可行,则 > x 也可行),就可以用二分答案。
问题:把数组分成 k 段,让每段和的最大值最小。
nums = [7, 2, 5, 10, 8], k = 2
如果每段最大和 ≤ 10:能分成 2 段吗?
[7,2] sum=9 ≤10 ✅, [5,10,8] sum=23 >10 ❌ → 不行
如果每段最大和 ≤ 15:能分成 2 段吗?
[7,2,5] sum=14 ≤15 ✅, [10,8] sum=18 >15 ❌ → 不行
如果每段最大和 ≤ 18:
[7,2,5] sum=14 ≤18 ✅, [10,8] sum=18 ≤18 ✅ → 可以!
canSplit 判定流程:
- 初始化:段计数
count = 1,当前段和current_sum = 0。 - 遍历数组:尝试将每个元素加入当前段,如果加入后和超过
max_sum,另起一段。 - 剪枝:如果段数超过
k,直接返回false。 - 返回:遍历结束且段数 ≤ k,返回
true。
splitArray 二分答案流程:
- 确定上下界:下界 = 单个元素最大值(每段至少要能容纳最大元素),上界 = 所有元素之和(一段装下全部)。
- 二分搜索:对
mid调用canSplit判断可行性。 - 收紧:可行 → 尝试更小值,
right = mid;不可行 → 必须增大,left = mid + 1。 - 返回:
left即为最小的最大段和。
bool canSplit(const vector<int>& nums, int k, int max_sum) {
int count = 1; // ① 至少一段
int current_sum = 0;
for (int x : nums) { // ② 遍历每个元素
if (current_sum + x > max_sum) { // ③ 超过上限,另起一段
count++;
current_sum = x;
if (count > k) return false; // ④ 段数超了 → 不可行
} else {
current_sum += x; // ⑤ 加入当前段
}
}
return true; // ⑥ 可以在 k 段内完成
}
int splitArray(vector<int>& nums, int k) {
int left = *max_element(nums.begin(), nums.end()); // ① 下界:最大元素
int right = accumulate(nums.begin(), nums.end(), 0); // ② 上界:总和
while (left < right) { // ③ 二分搜索
int mid = left + (right - left) / 2;
if (canSplit(nums, k, mid)) {
right = mid; // ④ 可行,尝试更小值
} else {
left = mid + 1; // ⑤ 不可行,增大上限
}
}
return left; // ⑥ 最小的最大段和
}
时间复杂度:check 函数 $O(N)$ × 二分 $\log(\sum nums)$ 次 = $O(N \log S)$。
第六阶段:动态规划(DP)——面试最难点
动态规划本质:把一个大问题拆成重叠的子问题,保存子问题的结果,避免重复计算。
三个核心要素:
- 状态定义:
dp[i]或dp[i][j]表示什么? - 状态转移方程:如何从子问题的解得到当前解?
- 初始条件:最小的子问题怎么解?
6.1 背包 DP
一、0-1 背包
问题:有 N 个物品,每个物品重量 w[i]、价值 v[i],背包容量 W,每个物品最多选一个,求能装的最大价值。
物品:重量 w = [2, 3, 4, 5] 价值 v = [3, 4, 5, 6] 容量 W = 8
dp[i][j]:前 i 个物品中选,总重量 ≤ j 时的最大价值
初始化 dp[0][*] = 0(没有物品可选)
状态转移:
dp[i][j] = max(dp[i-1][j], // 不选第 i 个
dp[i-1][j - w[i-1]] + v[i-1]) // 选第 i 个(前提 j >= w[i-1])
填表过程:
j=0 1 2 3 4 5 6 7 8
i=0: 0 0 0 0 0 0 0 0 0
i=1(w=2,v=3): 0 0 3 3 3 3 3 3 3
i=2(w=3,v=4): 0 0 3 4 4 7 7 7 7
i=3(w=4,v=5): 0 0 3 4 5 7 8 9 9
i=4(w=5,v=6): 0 0 3 4 5 7 8 9 10 ← 结果 = 10
选法:物品 2(w=3,v=4) + 物品 4(w=5,v=6) = 重量 8,价值 10
二维 DP → 一维滚动数组优化
整体操作流程:
- 初始化:
dp[j]表示容量为j时能装的最大价值,初始为 0。 - 遍历物品:对每个物品
i(重量w[i],价值v[i])。 - 倒序遍历容量:从
W递减到w[i],dp[j] = max(dp[j], dp[j - w[i]] + v[i])。 - 为什么倒序:倒序保证
dp[j - w[i]]是上一轮(未选当前物品)的状态,每个物品最多选一次。 - 返回:
dp[W]即为最大价值。
int knapsack01(const vector<int>& w, const vector<int>& v, int W) {
int n = w.size();
vector<int> dp(W + 1, 0); // ① dp[j] = 容量 j 的最大价值
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]);// ④ 不选 vs 选
}
}
return dp[W]; // ⑤ 容量 W 的最大价值
}
为什么倒序?
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
正序遍历时:dp[j - w[i]] 可能已经包含了当前第 i 个物品
→ 相当于"一个物品选多次"(完全背包)
倒序遍历时:dp[j - w[i]] 还是上一轮的状态(还没更新)
→ 保证每个物品最多选一次(0-1 背包)
二、完全背包
问题:每个物品可以选无限次。
整体操作流程:
- 初始化:
dp[j]初始为 0。 - 遍历物品:对每个物品
i。 - 正序遍历容量:从
w[i]递增到W,dp[j] = max(dp[j], dp[j - w[i]] + v[i])。 - 为什么正序:正序时,
dp[j - w[i]]可能已经被当前物品更新过,意味着一个物品可以被多次选中。 - 返回:
dp[W]即为最大价值。 - 与 0-1 背包的唯一区别:内层循环的方向——倒序是 0-1,正序是完全背包。
int knapsackComplete(const vector<int>& w, const vector<int>& v, int W) {
int n = w.size();
vector<int> dp(W + 1, 0);
for (int i = 0; i < n; i++) { // ① 遍历每个物品
for (int j = w[i]; j <= W; j++) { // ② 正序遍历容量
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);// ③ 允许重复选择
}
}
return dp[W];
}
0-1 背包 vs 完全背包的代码区别:就一个倒序/正序。
6.2 线性 DP
一、最长上升子序列(LIS,LeetCode 300)
问题:[10, 9, 2, 5, 3, 7, 101, 18],最长上升子序列长度?答案是 4([2, 3, 7, 101] 或 [2, 5, 7, 101])。
解法 1:$O(N^2)$ DP
整体操作流程:
- 初始化:
dp[i] = 1,每个元素自身至少构成一个长度为 1 的上升子序列。 - 双层循环:对每个
i,遍历它前面的所有j。 - 状态转移:如果
nums[j] < nums[i](可以接在后面),dp[i] = max(dp[i], dp[j] + 1)。 - 更新全局最大值:每处理完一个
i,更新max_len。 - 返回:
max_len即为 LIS 长度。
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n, 1); // ① dp[i]: 以 i 结尾的 LIS 长度
int max_len = 1;
for (int i = 0; i < n; i++) { // ② 遍历每个元素作为结尾
for (int j = 0; j < i; j++) { // ③ 找前面比它小的元素
if (nums[j] < nums[i]) { // ④ 可以接在后面
dp[i] = max(dp[i], dp[j] + 1); // ⑤ 取最大长度
}
}
max_len = max(max_len, dp[i]); // ⑥ 更新全局最大值
}
return max_len;
}
思路:对每个 i,往前找所有比 nums[i] 小的 j,取最大的 dp[j] + 1。
解法 2:$O(N \log N)$ 耐心排序
整体操作流程:
- 初始化:
tails数组为空,tails[k]表示长度为k+1的上升子序列的最小末尾值。 - 遍历数组:对每个元素
x,用lower_bound在tails中找到第一个>= x的位置。 - 扩展或替换:
- 如果
x比所有末尾都大(it == tails.end()),将x追加到tails末尾——LIS 长度增加。 - 否则,用
x替换该位置的末尾值——让该长度的子序列末尾变得更小,后续更易扩展。
- 如果
- 返回:
tails.size()即为 LIS 长度。 - 注意:
tails本身不一定是一个合法的 LIS 序列,但其长度等于 LIS 的长度。
int lengthOfLIS(vector<int>& nums) {
vector<int> tails; // ① tails[k] = 长度为 k+1 的最小末尾
for (int x : nums) { // ② 遍历每个元素
auto it = lower_bound(tails.begin(), tails.end(), x);// ③ 找第一个 >= x 的位置
if (it == tails.end()) {
tails.push_back(x); // ④ x 大于所有末尾,扩展 LIS
} else {
*it = x; // ⑤ 替换末尾为更小的值
}
}
return tails.size(); // ⑥ LIS 长度
}
原理:
nums = [3, 5, 6, 2, 5, 4, 7]
tails 的变化:
x=3: tails = [3] (长度为 1 的 LIS 末尾最小是 3)
x=5: tails = [3, 5] (长度为 2 的 LIS 末尾最小是 5)
x=6: tails = [3, 5, 6] (长度为 3 的 LIS 末尾最小是 6)
x=2: tails = [2, 5, 6] (替换 3 → 2,长度为 1 的末尾更小了)
x=5: tails = [2, 5, 6] (5 ≥ tails[1]=5,不动)
// 注意 lower_bound 找第一个 >=5,是 tails[1]=5
// 严格递增,5 不小于 5 → 不替换
x=4: tails = [2, 4, 6] (替换 5 → 4,长度为 2 的末尾更小了)
x=7: tails = [2, 4, 6, 7] (扩展!LIS 长度 = 4)
关键:tails 不一定是一个合法的 LIS([2, 4, 6, 7] 在原始数组中是 [2, 4, 6, 7] 吗?检查原始数组——确实按顺序出现了 2, 5, 4, 7,不对应。但 tails.size() 就是 LIS 的正确长度)。
二、最长公共子序列(LCS,LeetCode 1143)
问题:s1 = "abcde", s2 = "ace",最长公共子序列是 "ace",长度 3。
整体操作流程:
- 初始化:创建
(m+1) × (n+1)的 DP 表格,dp[0][*] = dp[*][0] = 0(空串 LCS 为 0)。 - 逐行逐列填充:
i从 1 到 m,j从 1 到 n。 - 字符匹配:如果
text1[i-1] == text2[j-1],dp[i][j] = dp[i-1][j-1] + 1。 - 字符不匹配:取两种跳过的最大值——
max(dp[i-1][j], dp[i][j-1])。 - 返回:
dp[m][n]即为 LCS 长度。
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));// ① 初始化 DP 表
for (int i = 1; i <= m; i++) { // ② 遍历 text1
for (int j = 1; j <= n; j++) { // ③ 遍历 text2
if (text1[i-1] == text2[j-1]) { // ④ 字符匹配
dp[i][j] = dp[i-1][j-1] + 1; // ⑤ 长度 +1
} else { // ⑥ 不匹配
dp[i][j] = max(dp[i-1][j], // ⑦ 跳过 text1 的字符
dp[i][j-1]); // ⑧ 跳过 text2 的字符
}
}
}
return dp[m][n]; // ⑨ LCS 长度
}
填表过程:
"" a c e
"" 0 0 0 0
a 0 1 1 1 ← a 匹配
b 0 1 1 1
c 0 1 2 2 ← c 匹配
d 0 1 2 2
e 0 1 2 3 ← e 匹配 → 答案 3
6.3 区间 DP
问题:石子合并——N 堆石子排成一排,每次合并相邻两堆,代价为两堆重量和。求最小总代价。
石子重量 [3, 2, 4, 1]
dp[i][j]:合并第 i 堆到第 j 堆的最小代价
dp[i][i] = 0(一堆不需要合并)
转移方程:dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum(i,j))
其中 k 从 i 到 j-1,sum(i,j) 是合并 i~j 的代价(即 i~j 的总重量)
整体操作流程:
- 前缀和:计算
prefix数组,使得sum(i, j) = prefix[j+1] - prefix[i]能在 O(1) 时间内得到合并代价。 - 初始化:
dp[i][i] = 0(一堆石子不需要合并),其他初始化为INT_MAX。 - 枚举区间长度:
len从 2 到 n(从小到大的区间顺序,保证递推时子区间已计算)。 - 枚举起点:对于每个起点
i,计算终点j = i + len - 1。 - 枚举分割点:对
k从i到j-1,尝试在k处将区间分成两半,取dp[i][k] + dp[k+1][j] + sum(i, j)的最小值。 - 返回:
dp[0][n-1]即为合并全部石子堆的最小总代价。
int mergeStones(vector<int>& stones) {
int n = stones.size();
vector<int> prefix(n + 1, 0); // ① 前缀和
for (int i = 0; i < n; i++) prefix[i+1] = prefix[i] + stones[i];
auto sum = [&](int i, int j) { return prefix[j+1] - prefix[i]; };
vector<vector<int>> dp(n, vector<int>(n, INT_MAX));
for (int i = 0; i < n; i++) dp[i][i] = 0; // ② 一堆不用合并
for (int len = 2; len <= n; len++) { // ③ 从小到大枚举区间长度
for (int i = 0; i + len - 1 < n; i++) { // ④ 枚举起点
int j = i + len - 1; // ⑤ 计算终点
for (int k = i; k < j; k++) { // ⑥ 枚举分割点
dp[i][j] = min(dp[i][j],
dp[i][k] + dp[k+1][j] + sum(i, j));// ⑦ 合并 i~j 的总代价
}
}
}
return dp[0][n-1]; // ⑧ 合并全部的最小代价
}
区间 DP 的特点:先枚举区间长度 len,再枚举起点 i。因为转移依赖更短的区间,所以要从小到大算。
6.4 面试追问 Q&A
Q1:DP 和递归+记忆化的区别? A:本质一样,都是"保存子问题结果避免重复计算"。递归+记忆化是自顶向下(从大问题往下拆,遇到算过的直接返回),DP 是自底向上(从小问题往上推)。递归+记忆化代码更直观,DP 避免了递归栈开销,性能略好。
Q2:什么时候用 DP 什么时候用贪心? A:贪心就是"每次都选看起来最好的",只依赖局部信息。DP 需要考虑所有可能。能用贪心的问题(如找零钱、活动选择)有最优子结构 + 贪心选择性质。不确定时先用 DP 保证正确,再尝试优化。
第七阶段:图算法与搜索
7.1 DFS 与 BFS 模板
一、DFS(深度优先搜索)
适用场景:求路径、遍历全部节点、回溯问题。
DFS 递归整体流程:
- 终止条件:节点为空时返回。
- 访问节点:在前序位置处理当前节点(若需要中序或后序,调整递归顺序)。
- 递归子节点:先递归左子树,再递归右子树(或按需调整顺序)。
DFS 显式栈整体流程:
- 初始化:根节点入栈。
- 循环:栈不为空时,弹出栈顶并处理。
- 子节点入栈:先将右子节点入栈,再将左子节点入栈(出栈顺序为左→右,即先处理左子节点)。
void dfs(TreeNode* node, vector<int>& result) {
if (!node) return; // ① 空节点返回
result.push_back(node->val); // ② 前序位置处理当前节点
dfs(node->left, result); // ③ 递归左子树
dfs(node->right, result); // ④ 递归右子树
}
void dfsStack(TreeNode* root) {
if (!root) return;
stack<TreeNode*> stk;
stk.push(root); // ① 根节点入栈
while (!stk.empty()) {
TreeNode* node = stk.top();
stk.pop(); // ② 弹出栈顶
// 处理 node
if (node->right) stk.push(node->right); // ③ 右子节点先入栈
if (node->left) stk.push(node->left); // ④ 左子节点后入栈(保证左先处理)
}
}
二、BFS(广度优先搜索)
适用场景:最短路径、层次遍历、拓扑排序。
// BFS 通用模板
void bfs(TreeNode* root) {
if (!root) return;
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
int level_size = q.size(); // 当前层节点数
for (int i = 0; i < level_size; i++) {
TreeNode* node = q.front();
q.pop();
// 处理 node
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
}
三、DFS vs BFS 怎么选?
| 场景 | 用哪个 | 原因 |
|---|---|---|
| 找任意一条路径 | DFS | 递归实现简单,找到就返回 |
| 找最短路径(无权图) | BFS | BFS 第一次访问到目标节点时就是最短路径 |
| 树/图的全部遍历 | 两者皆可 | 看是否需要按层次区分 |
| 递归深度可能 > 10000 | BFS 或显式栈 DFS | 递归可能栈溢出 |
| 拓扑排序 | BFS(Kahn) | 比 DFS 后序法更直观 |
7.2 拓扑排序
应用场景:课程安排(先修课依赖)、编译依赖、任务调度。
一、Kahn 算法(基于入度)
思路:
- 统计每个节点的入度(有多少节点指向它)。
- 把所有入度为 0 的节点入队。
- 每次弹出队首节点,把它所有后继节点的入度减 1。如果后继节点入度变为 0,入队。
- 重复直到队列为空。如果 final 节点数 < 总节点数,说明有环。
课程依赖:
课程 1 → 课程 3(1 是 3 的先修)
课程 2 → 课程 3
课程 3 → 课程 4
有向图:
1 → 3 → 4
↑
2 ┘
入度表:1:0, 2:0, 3:2, 4:1
步骤:
1. 入度为 0:1, 2 → 入队
2. 弹出 1,3 入度 -1 → 3 入度 1
3. 弹出 2,3 入度 -1 → 3 入度 0 → 3 入队
4. 弹出 3,4 入度 -1 → 4 入度 0 → 4 入队
5. 弹出 4
拓扑排序结果:[1, 2, 3, 4] 或 [2, 1, 3, 4]
整体操作流程:
- 建图与统计入度:遍历边集,构建邻接表
graph,同时统计每个节点的入度(有多少前驱节点指向它)。 - 入队零入度节点:将所有入度为 0 的节点入队(没有前置依赖,可以最先执行)。
- 逐出队:弹出队首节点,加入结果集,将其所有后继节点的入度减 1。
- 入队检查:如果某个后继节点入度变为 0,将其入队。
- 环检测:如果结果集大小小于节点总数,说明图中存在环(环上的节点入度永远不为 0)。
vector<int> topologicalSort(int n, const vector<vector<int>>& edges) {
vector<vector<int>> graph(n); // ① 邻接表
vector<int> indegree(n, 0); // ② 入度表
for (auto& e : edges) { // ③ 建图
int u = e[0], v = e[1];
graph[u].push_back(v); // u → v
indegree[v]++; // ④ v 的入度 +1
}
queue<int> q;
for (int i = 0; i < n; i++) {
if (indegree[i] == 0) q.push(i); // ⑤ 入度为 0 的节点入队
}
vector<int> result;
while (!q.empty()) {
int u = q.front(); q.pop(); // ⑥ 弹出队首
result.push_back(u); // ⑦ 加入拓扑排序结果
for (int v : graph[u]) { // ⑧ 遍历所有后继
if (--indegree[v] == 0) { // ⑨ 入度减为 0 → 入队
q.push(v);
}
}
}
return result; // ⑩ result.size() < n 则有环
}
7.3 最短路径
一、Dijkstra(单源,非负权边)
问题:一个图中,从起点到所有其他点的最短距离。
思路:每次选离起点最近的未访问节点,用它更新相邻节点。
图:
2
A───→B
│ │
1 │ │ 3
↓ ↓
C───→D
1
从 A 出发:
dist: A=0, B=∞, C=∞, D=∞
1. 从 A 出发,更新邻居:
dist[B] = min(∞, 0+2) = 2
dist[C] = min(∞, 0+1) = 1
dist = {A:0, B:2, C:1, D:∞}
标记 A 已访问
2. 选未访问中最小 dist:C(1)
dist[D] = min(∞, 1+1) = 2
标记 C 已访问
3. 选未访问中最小:B(2) 和 D(2),任选一个,比如 B
dist[D] = min(2, 2+3) = 2 ← 不变
标记 B 已访问
4. 选 D,没有未访问邻居
最终:A→C→D = 2,A→B = 2
整体操作流程:
- 初始化:
dist数组除起点外设为INT_MAX,visited全为false。优先队列pq以距离为键。 - 起点入队:
dist[start] = 0,pq.push({0, start})。 - 循环取最小:每次从优先队列中取出距离最小的未访问节点
u。 - 松弛操作:遍历
u的所有邻接边(v, w),如果dist[u] + w < dist[v],更新dist[v]并将新距离入队。 - 标记已访问:节点一旦从队列取出,标记为已访问(后续再次取出时跳过)。
- 返回:
dist数组即为起点到所有点的最短距离。
vector<int> dijkstra(int start, const vector<vector<pair<int, int>>>& graph) {
int n = graph.size();
vector<int> dist(n, INT_MAX); // ① 距离初始化为无穷大
vector<bool> visited(n, false);
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;// ② 小顶堆
dist[start] = 0; // ③ 起点到自身距离为 0
pq.push({0, start}); // ④ 起点入队
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop(); // ⑤ 取距离最近的未访问节点
if (visited[u]) continue; // ⑥ 已处理过则跳过
visited[u] = true; // ⑦ 标记已访问
for (auto [v, w] : graph[u]) { // ⑧ 遍历所有邻接边
if (d + w < dist[v]) { // ⑨ 发现更短路径
dist[v] = d + w; // ⑩ 更新距离
pq.push({dist[v], v}); // ⑪ 新距离入队
}
}
}
return dist; // ⑫ 返回所有点的最短距离
}
时间复杂度:$O((V+E)\log V)$,每条边入队一次,堆操作 $\log V$。
二、Floyd-Warshall(全源最短路径)
时间复杂度:$O(V^3)$。 适用场景:顶点数少(V < 500)、需要所有点对之间的最短距离。
整体操作流程:
- 拷贝邻接矩阵:
dist[i][j]初始为 i 到 j 的直接距离,不直接相连为INT_MAX。 - 三层循环:外层
k作为中间节点,内层i和j作为起点和终点。 - 松弛:如果
dist[i][k] + dist[k][j]小于dist[i][j],则更新。 - 为什么 k 在最外层:
dist[i][j]的更新依赖dist[i][k]和dist[k][j],它们必须已经考虑了[0, k-1]作为中间节点。k 逐步增加,逐步允许更多的中间节点。 - 返回:
dist即为所有点对之间的最短距离矩阵。
vector<vector<int>> floydWarshall(const vector<vector<int>>& graph) {
int n = graph.size();
vector<vector<int>> dist = graph; // ① 拷贝邻接矩阵
for (int k = 0; k < n; k++) { // ② 中间节点
for (int i = 0; i < n; i++) { // ③ 起点
for (int j = 0; j < n; j++) { // ④ 终点
if (dist[i][k] != INT_MAX && dist[k][j] != INT_MAX) {
dist[i][j] = min(dist[i][j], // ⑤ 尝试经过 k 的路径
dist[i][k] + dist[k][j]);
}
}
}
}
return dist; // ⑥ 所有点对的最短距离
}
为什么 k 在最外层? 因为 dist[i][j] 的更新依赖 dist[i][k] 和 dist[k][j],而它们必须已经考虑了 [0, k-1] 作为中间节点。k 从 0 到 n-1,逐步允许更多的中间节点。
7.4 并查集(Union-Find)
一、解决的问题
问题:判断两个节点是否连通(直接或间接相连),以及连通分量的数量。
经典应用:朋友圈数量、图中冗余连接、岛屿数量。
二、实现
Union-Find 整体操作流程:
- 初始化:每个节点的父节点指向自己,秩(树高)为 0,连通分量数 = 节点总数。
- find(路径压缩):
- 递归查找根节点:
parent[x] = find(parent[x])。 - 路径压缩效果:路径上的所有节点直接指向根节点,下次查找 O(1)。
- 递归查找根节点:
- unite(按秩合并):
- 分别查找 x 和 y 的根节点,如果相同则已连通。
- 按秩合并:矮的树接到高的树下(
rank[rootX] < rank[rootY])。 - 如果两棵树高度相同,合并后高度 +1。
- 连通分量数
count减 1。v
- 查询:
getCount()返回当前连通分量数量。
class UnionFind {
vector<int> parent; // parent[i] = i 的父节点
vector<int> rank; // rank[i] = 树的高度
int count; // 连通分量数
public:
UnionFind(int n) : parent(n), rank(n, 0), 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]; // ④ 返回根节点
}
void unite(int x, int y) {
int rootX = find(x); // ⑤ 找 x 的根
int rootY = find(y); // ⑥ 找 y 的根
if (rootX == rootY) return; // ⑦ 已经连通
if (rank[rootX] < rank[rootY]) { // ⑧ 矮树接到高树下
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX; // ⑨ 高度相同,合并后高度 +1
rank[rootX]++;
}
count--; // ⑩ 连通分量数减 1
}
int getCount() const { return count; } // ⑪ 查询连通分量数
};
路径压缩的物理效果:
初始: 0 1 2 3 4
| | | | |
0 1 2 3 4
unite(0,1): 0 → 1
unite(2,3): 2 → 3
unite(1,2): 找 1 的根 = 1,找 2 的根 = 3,按秩合并
树形:
1 3
/ / \
0 2 4
(unite(3,4))
接下来 find(0):
find(0) → find(1) → 返回 1
路径压缩:parent[0] = 1
树形变为:
1
/ \
0 3
/ \
2 4
下次 find(0) 直接 O(1):parent[0] → 1
时间复杂度:近乎 $O(1)$(实际是反阿克曼函数 $\alpha(N)$,对于可观测的 N 都 ≤ 5)。
三、面试追问 Q&A
Q1:Dijkstra 为什么不能处理负权边? A:Dijkstra 假设"已处理的节点距离不会变小"。如果有一条负权边,可能让已经处理过的节点的距离变得更小——Dijkstra 不会重新检查已处理的节点。有负权边应该用 Bellman-Ford。
Q2:拓扑排序怎样检测环? A:Kahn 算法结束时,如果 result 中的节点数 < 总节点数,说明有环(环上的节点入度永远不会变成 0)。
Q3:并查集的路径压缩和按秩合并一定要两个都用吗? A:两个都用能达到最优复杂度 $O(\alpha(N))$。只用路径压缩,复杂度是对数级的。只用按秩合并也是对数级的。在实际编码中,只用路径压缩已经非常快,按秩合并不影响正确性只是优化——很多实现只写路径压缩。
第四阶段到第七阶段总结
第四阶段(排序算法):
快排:原地分区,缓存友好,平均 O(N log N),最坏退化 O(N²)
归并:稳定,O(N log N),外部排序首选
堆排:原地 O(1) 空间,但缓存不友好
实际选型:std::sort = 快排 + 堆排 + 插入排序的混合
第五阶段(二分搜索):
[left,right] vs [left,right) 两种写法
lower_bound:第一个 >=target 的位置
旋转数组:判断哪半有序来决定搜索方向
二分答案:把最优化问题转为判定问题
第六阶段(动态规划):
状态定义 + 转移方程 + 初始条件 = DP 三要素
0-1 背包:一维数组倒序
完全背包:一维数组正序
LIS:O(N log N) 耐心排序
LCS:二维表格递推
区间 DP:从小到大枚举区间长度
第七阶段(图算法):
DFS:递归/显式栈,找路径
BFS:队列,找最短路径(无权图)
拓扑排序:Kahn 算法(入度表 + 队列)
Dijkstra:优先队列,非负权单源最短路径
Floyd:三重循环,全源最短路径(V<500)
并查集:路径压缩 + 按秩合并 ≈ O(1)
第八阶段:经典数据结构手写
8.1 LRU 缓存(LeetCode 146)
一、为什么要手写这个?
LRU(Least Recently Used)是面试出现频率最高的数据结构题,没有之一。因为:
- 它是真实的工程需求(操作系统页面置换、Redis缓存、数据库Buffer Pool都用)。
- 它考察了双向链表 + 哈希表两种数据结构的结合。
- 你需要在 $O(1)$ 时间内完成
get和put。
二、数据结构设计
需要实现:
get(key):存在返回值,不存在返回 -1。访问过的 key 变为"最近使用"。
put(key, value):存在则更新值;不存在则插入,如果满则淘汰最久未使用的。
数据结构选择:
1. 哈希表(unordered_map):O(1) 查找 key 是否存在。
2. 双向链表(list):维护使用顺序。
- 头部 = 最近使用
- 尾部 = 最久未使用
哈希表的 value 存链表的迭代器 → 查到时直接 O(1) 移到头部。
内存布局:
链表:
[head] ⇄ [key=k1, val=v1] ⇄ [key=k2, val=v2] ⇄ ... ⇄ [tail(哨兵)]
↑ 最近使用 最久未使用 ↑
哈希表:
{k1: 指向 k1 节点的迭代器}
{k2: 指向 k2 节点的迭代器}
三、完整实现
整体操作流程:
- get(key):
- 在
unordered_map中查找 key。 - 不存在 → 返回 -1。
- 存在 → 用
splice将节点从当前位置移到链表头部($O(1)$ 更新使用顺序),返回 value。
- 在
- put(key, value):
- 查找 key 是否已存在。
- 已存在:更新 value,将节点移到链表头部。
- 不存在:如果缓存满,淘汰链表尾部节点(最久未使用),从 map 中删除对应 key。在链表头部插入新节点,在 map 中记录位置。
class LRUCache {
int cap_;
list<pair<int, int>> items_; // 链表:头=最近使用,尾=最久未使用
unordered_map<int, list<pair<int, int>>::iterator> cache_;// key → 迭代器
public:
LRUCache(int capacity) : cap_(capacity) {}
int get(int key) {
auto it = cache_.find(key); // ① 在哈希表中查找
if (it == cache_.end()) return -1; // ② 不存在 → -1
items_.splice(items_.begin(), items_, it->second);// ③ 移到链表头部
return it->second->second; // ④ 返回 value
}
void put(int key, int value) {
auto it = cache_.find(key);
if (it != cache_.end()) { // ① key 已存在
it->second->second = value; // ② 更新 value
items_.splice(items_.begin(), items_, it->second);// ③ 移到头部
return;
}
if (cache_.size() >= cap_) { // ④ 缓存满
auto& last = items_.back(); // ⑤ 取尾部节点(最久未使用)
cache_.erase(last.first); // ⑥ 从哈希表删除
items_.pop_back(); // ⑦ 从链表删除
}
items_.emplace_front(key, value); // ⑧ 在链表头部插入
cache_[key] = items_.begin(); // ⑨ 记录到哈希表
}
};
四、核心细节:为什么 splice 是 $O(1)$?
// splice 不拷贝元素,只改 3 个指针
items_.splice(items_.begin(), items_, it->second);
// 操作本质:
// 把 it->second 这个节点的 prev/next 和 items_.begin() 之前的节点串起来
// 等价于:
// 1. 从当前位置摘下(改前驱和后继的指针)
// 2. 插入到头部(改 head 的指针)
// 一共改 6 个指针,常数时间
五、面试追问 Q&A
Q1:为什么用 list 不用自己手写双向链表?
A:面试时可以手写,但工作中用 std::list 的 splice 更不容易出错。手写的好处是展示你对链表指针操作的理解。
Q2:为什么 map 不存指针而是迭代器?
A:list 的迭代器在 splice 后不会失效(这是 list 的核心特性——节点地址不变)。如果存指针,万一 list 内部重新分配,指针就悬垂了。
Q3:Get 和 Put 的时间复杂度?
A:都是 $O(1)$。哈希表查找 $O(1)$ 均摊,splice 是 $O(1)$,emplace_front 和 pop_back 都是 $O(1)$。
8.2 字典树(Trie)
一、解决什么问题?
问题:给你 10000 个单词,快速判断一个字符串是否出现过,或者以某个前缀开头。
如果用 unordered_set:查完整单词 $O(1)$。但查"所有以 app 开头的单词"就需要遍历全部 10000 个。
Trie 的承诺:前缀查询也是 $O(L)$(L = 字符串长度),不依赖字典大小。
二、结构原理
插入 "apple"、"app"、"apply"、"apt" 后的 Trie:
root
/ \
a ...
/
p
/
p ← 有一个 is_end 标记("app" 到这里结束)
/ \
l t
| |
e ... ("apt" 的 t)
|
e ← is_end("apple")
|
y ← is_end("apply")
查找 "appl":
root → a → p → p → l → 没到 is_end → 不是完整单词
但路径存在 → 是前缀
Trie 整体操作流程:
- insert(word):
- 从根节点开始,遍历单词的每个字符。
- 计算字符在
children数组中的索引c - 'a'。 - 如果当前节点没有该子节点,创建一个新节点。
- 移动到子节点,继续处理下一个字符。
- 遍历结束后,将当前节点标记为单词结尾(
is_end = true)。
- search(word):
- 从根节点开始,沿字符路径向下查找。
- 如果路径中断(缺子节点),返回
false。 - 到达终点后,检查
is_end是否为true(必须是完整单词而非前缀)。
- startsWith(prefix):
- 同
search的查找过程,但不需要检查is_end。 - 只要路径存在,就返回
true。
- 同
class Trie {
struct TrieNode {
TrieNode* children[26] = {}; // ① 26 个小写字母的子节点指针
bool is_end = false; // ② 标记是否为完整单词
};
TrieNode* root_;
public:
Trie() : root_(new TrieNode()) {}
void insert(string word) {
TrieNode* cur = root_;
for (char c : word) { // ③ 遍历每个字符
int idx = c - 'a'; // ④ 计算子节点索引
if (!cur->children[idx]) { // ⑤ 子节点不存在则创建
cur->children[idx] = new TrieNode();
}
cur = cur->children[idx]; // ⑥ 移动到子节点
}
cur->is_end = true; // ⑦ 标记为完整单词
}
bool search(string word) {
TrieNode* cur = root_;
for (char c : word) { // ① 沿路径查找
int idx = c - 'a';
if (!cur->children[idx]) return false; // ② 路径中断 → 不存在
cur = cur->children[idx];
}
return cur->is_end; // ③ 必须是完整单词
}
bool startsWith(string prefix) {
TrieNode* cur = root_;
for (char c : prefix) {
int idx = c - 'a';
if (!cur->children[idx]) return false;
cur = cur->children[idx];
}
return true; // ④ 路径存在即匹配
}
};
时间复杂度:
insert:$O(L)$(L = 单词长度)。search:$O(L)$。startsWith:$O(L)$。
空间复杂度:$O(\text{总字符数} \times 26)$。每个节点固定 26 个指针(或 26 个 unique_ptr)。
三、面试追问 Q&A
Q1:Trie 和哈希表比有什么优缺点? A:
- 哈希表查找完整单词更快($O(1)$ vs $O(L)$)。
- Trie 支持前缀查询(哈希表做不到)。
- Trie 空间更大(每个字符一个节点 + 26指针)。
- 对中文等非 ASCII 字符,Trie 需要更复杂的设计。
Q2:Trie 怎么压缩空间?
A:用压缩字典树(Radix Tree / Patricia Trie)——把只有一个子节点的链压缩成一个节点存储多个字符。内核的路由表、Redis 的 Rax 都用了这种优化。
8.3 布隆过滤器(Bloom Filter)
一、解决什么问题?
问题:判断一个元素是否可能在集合中。允许"假阳性"(不在却说在),但不允许"假阴性"(在却说不在)。
典型场景:
- 防止缓存穿透:查询一个不存在的 key,每次都查数据库。布隆过滤器说"不存在"就一定不存在,直接拒绝;说"存在"才去查。
- 爬虫 URL 去重:已经爬过的 URL 不用再爬。
- 邮箱垃圾邮件过滤:几十亿条黑名单。
二、原理
布隆过滤器的核心:一个位数组 + k 个哈希函数。
初始状态(位数组长度 m=12,哈希函数 k=3):
[0][0][0][0][0][0][0][0][0][0][0][0]
插入 "apple":
哈希1("apple") % 12 = 2 → 位置 2 置 1
哈希2("apple") % 12 = 7 → 位置 7 置 1
哈希3("apple") % 12 = 5 → 位置 5 置 1
→ [0][0][1][0][1][0][0][1][0][0][0][0]
↑ ↑ ↑
插入 "banana":
哈希1("banana") % 12 = 5 → 已经是 1
哈希2("banana") % 12 = 9 → 位置 9 置 1
哈希3("banana") % 12 = 1 → 位置 1 置 1
→ [0][1][1][0][1][0][0][1][0][1][0][0]
↑ ↑ ↑
查询 "apple":
哈希1→位置2=1 ✅ 哈希2→位置7=1 ✅ 哈希3→位置5=1 ✅
所有位都是 1 → "可能存在"
查询 "grape":
哈希1("grape") % 12 = 2 → 1 ✅
哈希2("grape") % 12 = 7 → 1 ✅
哈希3("grape") % 12 = 4 → 0 ❌
有一位是 0 → "一定不存在"
整体操作流程:
- hash(key, i):用双哈希法模拟多个独立哈希函数——
h1 = hash(key),h2 = h1 >> 32,第 i 个哈希值为(h1 + i * h2) % bits.size()。 - insert(key):
- 用 k 个哈希函数分别计算 key 的哈希位置。
- 将位数组中这些位置全部置 1(已为 1 则不变)。
- contains(key):
- 用同样的 k 个哈希函数计算位置。
- 如果所有位置均为 1 → 返回
true(可能存在,有误判风险)。 - 如果任意位置为 0 → 返回
false(一定不存在)。
class BloomFilter {
std::vector<bool> bits_; // 位数组
size_t k_; // 哈希函数个数
std::pair<size_t, size_t> hash(const std::string& key, size_t i) const {
size_t h1 = std::hash<std::string>{}(key); // ① 主哈希
size_t h2 = h1 >> 32; // ② 辅助哈希
size_t index = (h1 + i * h2) % bits_.size(); // ③ 第 i 个哈希位置
return {index, 0};
}
public:
BloomFilter(size_t bit_count, size_t hash_count)
: bits_(bit_count, false), k_(hash_count) {}
void insert(const std::string& key) {
size_t h1 = std::hash<std::string>{}(key);
size_t h2 = h1 >> 32;
for (size_t i = 0; i < k_; i++) { // ① k 个哈希函数
size_t idx = (h1 + i * h2) % bits_.size();
bits_[idx] = true; // ② 对应位置 1
}
}
bool contains(const std::string& key) const {
size_t h1 = std::hash<std::string>{}(key);
size_t h2 = h1 >> 32;
for (size_t i = 0; i < k_; i++) {
size_t idx = (h1 + i * h2) % bits_.size();
if (!bits_[idx]) return false; // ③ 有一位为 0 → 一定不存在
}
return true; // ④ 所有位为 1 → 可能存在
}
};
三、假阳性率怎么算?
假阳性率 ≈ (1 - e^(-k * n / m))^k
m = 位数组长度(bit 数)
n = 插入的元素个数
k = 哈希函数个数
经验值(让假阳性率最低):
k = (m / n) * ln(2) ← 最优哈希函数个数
m = -n * ln(p) / (ln(2)^2) ← 给定假阳性率 p 所需的位数
例如:
n = 1000 万,希望 p = 1%:
m ≈ 9580 万 bit ≈ 114 MB
k ≈ 7 个哈希函数
四、面试追问 Q&A
Q1:布隆过滤器能不能删除? A:不能直接删除(因为多个元素可能共享同一 bit,置 0 会影响其他元素)。改进版叫计数布隆过滤器(Counting Bloom Filter),把位数组换成计数器数组,但空间增大几倍。
Q2:布隆过滤器和哈希表怎么选? A:哈希表精确但不省空间。布隆过滤器省空间但有误报率。百万级以上的去重场景,布隆过滤器空间优势明显。
第九阶段:字符串算法
9.1 KMP 算法
一、解决什么问题?
问题:在字符串 "ABABDABACDABABCABAB" 中找 "ABABCABAB" 出现的位置。
暴力匹配:每次匹配失败时,模式串只后移一位,重新匹配。最坏 $O(N \times M)$。
KMP 的洞察:匹配失败时,模式串的前缀已经知道文本串的某些信息,可以直接跳过一段距离。
二、KMP 的核心:next 数组
next 数组的定义:next[i] = 模式串 [0, i-1] 这个子串中,最长相等前后缀的长度。
模式串 P = "ABABCABAB"
手动计算 next:
i=0: next[0] = -1(特殊标记,表示第一个字符就不匹配)
i=1: P[0] = "A" → 没有前后缀 → next[1] = 0
i=2: P[0..1] = "AB" → 前缀"A", 后缀"B" → 不相等 → next[2] = 0
i=3: P[0..2] = "ABA" → 前缀"A"="A"✅, "AB"≠"BA" → next[3] = 1
i=4: P[0..3] = "ABAB" → 前缀"AB"="AB"✅, "ABA"≠"BAB" → next[4] = 2
i=5: P[0..4] = "ABABC" → "AB"?="BC"→no, "A"?="C"→no → next[5]=0
i=6: P[0..5] = "ABABCA" → "A"="A"✅ → next[6]=1
i=7: P[0..6] = "ABABCAB" → "AB"="AB"✅ → next[7]=2
i=8: P[0..7] = "ABABCABA" → "ABA"="ABA"✅ → next[8]=3
最终 next = [-1, 0, 0, 1, 2, 0, 1, 2, 3]
next 数组的含义:当 P[i] 和文本串不匹配时,i 回退到 next[i],模式串的 [0, next[i]-1] 已经和文本串匹配好了,不需要重新比较。
三、完整实现
构建 next 数组整体流程:
- 初始化:
next[0] = -1(特殊标记,第一个字符就不匹配时 j 回退到 -1),双指针i = 0, j = -1。 - 递推:
i从 0 到 m-1,j表示当前已匹配的前缀长度:- 如果
j == -1或pattern[i] == pattern[j]:i++,j++,next[i] = j(前缀长度 +1)。 - 否则:
j = next[j](回溯到更短的前缀继续尝试)。
- 如果
- 返回:
next[i]表示pattern[0..i-1]的最长相等前后缀长度。
KMP 搜索整体流程:
- 初始化:文本指针
i = 0,模式指针j = 0。 - 匹配:
i < n && j < m时循环:j == -1或text[i] == pattern[j]→i++,j++(匹配成功,同时前进)。- 否则 →
j = next[j](i 不回溯,只回退 j 到 next 数组指示的位置)。
- 返回:
j == m→ 匹配成功,返回i - j(起始位置);否则返回 -1。
vector<int> buildNext(const string& pattern) {
int m = pattern.size();
vector<int> next(m);
next[0] = -1; // ① 特殊标记
int i = 0, j = -1; // ② i:当前处理位置, j:已匹配前缀长度
while (i < m - 1) { // ③ 遍历模式串
if (j == -1 || pattern[i] == pattern[j]) { // ④ 匹配成功或从头开始
i++; j++;
next[i] = j; // ⑤ 记录最长相等前后缀长度
} else {
j = next[j]; // ⑥ 回溯到更短的前缀
}
}
return next;
}
int kmpSearch(const string& text, const string& pattern) {
int n = text.size(), m = pattern.size();
if (m == 0) return 0;
vector<int> next = buildNext(pattern);
int i = 0, j = 0; // ① i:文本指针, j:模式指针
while (i < n && j < m) {
if (j == -1 || text[i] == pattern[j]) { // ② 字符匹配
i++; j++;
} else {
j = next[j]; // ③ 关键:i 不回溯,只回退 j
}
}
if (j == m) return i - j; // ④ 匹配成功,返回文本中的起始位置
return -1; // ⑤ 匹配失败
}
KMP 的匹配过程(文本串 "ABABDABACDABABCABAB",模式串 "ABABCABAB"):
文本: A B A B D A B A C D A B A B C A B A B
模式: A B A B C A B A B
匹配 D ≠ C 时:
模式 C 的位置 j=4,next[4]=2
→ j 跳到 2(模式串的第三个字符 A)
→ 模式串的 "AB" 已经匹配好了,不需要重新比较
A B A B D A B A C ...
A B A B C ...(j=2 开始,跳过 A B)
时间复杂度:$O(N + M)$。文本串只遍历一次,不回溯。 空间复杂度:$O(M)$。
四、面试追问 Q&A
Q1:KMP 和暴力匹配比好在哪里? A:暴力匹配中文本串的 i 总是回溯(匹配失败就 i=i-j+1,j=0)。KMP 的 i 永不回溯,只回退 j。所以 KMP 始终 $O(N+M)$,暴力最坏 $O(NM)$。
Q2:有没有更简单的替代方案?
A:如果不需要最高性能,C++ 直接用 std::string::find 或 std::search。面试考 KMP 主要是考察"利用失败信息避免重复比较"的思维。实际工程中 BM(Boyer-Moore)模式串越长越快,比 KMP 更快。
9.2 字符串哈希(Rabin-Karp)
一、核心思想
把一个字符串映射成一个整数(哈希值)。然后比较两个字符串是否相等 ≈ 比较它们的哈希值是否相等。
// 把 "hello" 看成 26 进制数:
// h = ('h'-'a'+1) * 26^4 + ('e'-'a'+1) * 26^3 + ... + ('o'-'a'+1) * 26^0
// 这样每个字符串唯一对应一个 hash 值
二、滚动哈希——滑动窗口内 O(1) 更新
// 在 "abcdefg" 中找 "cde"
// 先计算 "abc" 的哈希
// 窗口右移 → "abc" → "bcd"
// hash("bcd") = (hash("abc") - val('a') * 26^2) * 26 + val('d')
// = 去掉最高位 + 整体左移一位 + 加入新低位
完整实现:
整体操作流程:
- 预计算:计算模式串
pattern的哈希值pat_hash,以及最高位的权值base^(m-1) % mod。 - 初始窗口:计算文本串第一个长度为 m 的窗口的哈希值
text_hash。 - 检查匹配:如果
text_hash == pat_hash,返回起始位置 0。 - 滚动窗口:从
i = m开始滑动到文本末尾:- 去掉最左字符:
text_hash -= val(text[i-m]) * power。 - 左移一位:
text_hash *= base。 - 加入新字符:
text_hash += val(text[i])。 - 以上每步都对
mod_取模。 - 处理负数:如果
text_hash < 0,加mod_取正。 - 比较哈希值,相等则返回起始位置
i - m + 1。
- 去掉最左字符:
- 未找到:返回 -1。
class RabinKarp {
const long long base_ = 131; // ① 进制(常用质数)
const long long mod_ = 1e9 + 7; // ② 取模用的大质数
long long pow_mod(long long base, long long exp) const {
long long res = 1;
while (exp) { // ③ 快速幂
if (exp & 1) res = (res * base) % mod_;
base = (base * base) % mod_;
exp >>= 1;
}
return res;
}
public:
int search(const string& text, const string& pattern) {
int n = text.size(), m = pattern.size();
if (m > n) return -1;
long long pat_hash = 0;
for (char c : pattern) { // ④ 计算模式串哈希
pat_hash = (pat_hash * base_ + (c - 'a' + 1)) % mod_;
}
long long power = pow_mod(base_, m - 1); // ⑤ 最高位权值
long long text_hash = 0;
for (int i = 0; i < m; i++) { // ⑥ 计算首个窗口哈希
text_hash = (text_hash * base_ + (text[i] - 'a' + 1)) % mod_;
}
if (text_hash == pat_hash) return 0; // ⑦ 首个窗口匹配
for (int i = m; i < n; i++) { // ⑧ 滚动窗口
text_hash = (text_hash - (text[i - m] - 'a' + 1) * power) % mod_;// ⑨ 去掉最左
text_hash = (text_hash * base_) % mod_; // ⑩ 左移一位
text_hash = (text_hash + (text[i] - 'a' + 1)) % mod_;// ⑪ 加入最右
if (text_hash < 0) text_hash += mod_; // ⑫ 处理负数
if (text_hash == pat_hash) return i - m + 1;// ⑬ 匹配
}
return -1; // ⑭ 未找到
}
};
三、哈希碰撞怎么办?
不同字符串可能有相同的哈希值(hash collision)。
解决方案:
1. 双哈希:用两组不同的 base 和 mod,两个哈希值都相等才认为匹配。
2. 哈希值相等时,再逐字符比较确认(如果 m 很小,代价不高)。
一般用单哈希 + 少量误判可接受(比如海量去重场景)。
时间复杂度:$O(N + M)$。比 KMP 常数更小(没有 next 数组构建过程)。
第十阶段:位运算技巧
10.1 常用位运算技巧
一、三个最常用的底层技巧
// 1. 消去最后一个 1
int lowbit_clear(int x) {
return x & (x - 1);
// x = 10101000
// x-1 = 10100111
// & = 10100000 ← 最后一个 1 被消掉
}
// 2. 取出最后一个 1
int lowbit(int x) {
return x & -x;
// x = 10101000
// -x = 01011000(补码表示)
// & = 00001000 ← 只留下最后一个 1
}
// 3. 判断 2 的幂
bool isPowerOfTwo(int x) {
return x > 0 && (x & (x - 1)) == 0;
// 2 的幂:二进制只有一个 1
// 8 = 00001000, 7 = 00000111, & = 00000000 ✅
// 10 = 00001010, 9 = 00001001, & = 00001000 ≠ 0 ❌
}
二、更多常用操作
// 统计 1 的个数(Brian Kernighan 算法)
int countBits(int x) {
int count = 0;
while (x) {
x &= (x - 1); // 每次消去最后一个 1
count++;
}
return count;
}
// 判断第 k 位是否为 1(从 0 开始)
bool getBit(int x, int k) {
return (x >> k) & 1;
}
// 将第 k 位置为 1
int setBit(int x, int k) {
return x | (1 << k);
}
// 将第 k 位置为 0
int clearBit(int x, int k) {
return x & ~(1 << k);
}
// 交换两个数(不用临时变量)
void swap(int& a, int& b) {
a ^= b;
b ^= a;
a ^= b;
}
三、面试追问 Q&A
Q1:x & -x 为什么能取出最后一个 1?
A:-x 是 x 的补码(取反 + 1)。当 x 加 1 时,最后一个 1 变成 0,后面的 0 都变成 1,进位停止。所以 x & -x 只保留了最后一个 1。
x = 10101000
~x = 01010111
-x = ~x + 1 = 01011000
x & -x = 00001000 ✅
Q2:位运算比普通运算快多少? A:在现代 CPU 上差异不大(都是 1 个时钟周期)。位运算的价值在于在某些场景下让代码更清晰(如权限控制、状态标记压缩)。
10.2 位图与状态压缩
一、位图——用 bit 做海量判重
// 假设有 10 亿个整数(0 ~ 10^9),标记哪些出现过
// 用 bool 数组:10 亿字节 ≈ 1GB
// 用位图:10 亿 bit ≈ 125MB(节省 8 倍)
class BitMap {
vector<uint64_t> bits_; // 每个 uint64_t 存 64 个 bit
public:
BitMap(size_t size) : bits_((size + 63) / 64, 0) {}
void set(size_t pos) {
bits_[pos / 64] |= (1ULL << (pos % 64));
}
bool test(size_t pos) const {
return (bits_[pos / 64] >> (pos % 64)) & 1;
}
};
二、状态压缩 DP——用 int 代替 bool 数组
问题:N 个城市,旅行商要走遍所有城市,每个城市去一次,求最短路径(TSP)。
N ≤ 20 时,可以用一个 int 的 20 个 bit 表示"哪些城市已经去过":
visited = 0b00000000000000000101
↑ ↑
城市 19 城市 0 和 2 已经去过
整体操作流程:
- 状态定义:
dp[mask][i]表示已访问mask中的城市、当前位于城市 i 的最短距离。mask是一个整数,第 k 位为 1 表示已访问城市 k。 - 初始化:
dp[1][0] = 0(从城市 0 出发,只访问了城市 0),其余为无穷大。 - 遍历所有状态:对每个
mask,遍历当前所在城市 i(必须在mask中),尝试去下一个未访问城市 j(不在mask中)。 - 状态转移:
new_mask = mask | (1 << j),dp[new_mask][j] = min(dp[new_mask][j], dp[mask][i] + dist[i][j])。 - 返回:访问完所有城市(
mask = (1<<n)-1)后,回到城市 0 的最小距离。
int tsp(vector<vector<int>>& dist) {
int n = dist.size();
vector<vector<int>> dp(1 << n, vector<int>(n, INT_MAX / 2));// ① dp[mask][i]
dp[1][0] = 0; // ② 从城市 0 出发
for (int mask = 1; mask < (1 << n); mask++) { // ③ 遍历所有访问状态
for (int i = 0; i < n; i++) {
if (!(mask & (1 << i))) continue; // ④ i 不在 mask 中 → 跳过
for (int j = 0; j < n; j++) {
if (mask & (1 << j)) continue; // ⑤ j 已访问 → 跳过
int next = mask | (1 << j); // ⑥ 新状态:加入 j
dp[next][j] = min(dp[next][j], // ⑦ 更新最短距离
dp[mask][i] + dist[i][j]);
}
}
}
int ans = INT_MAX;
for (int i = 1; i < n; i++) { // ⑧ 回到城市 0
ans = min(ans, dp[(1 << n) - 1][i] + dist[i][0]);
}
return ans;
}
为什么 N ≤ 20 时可以用状态压缩?
- 状态数 = $2^{20}$ ≈ 100 万,int 刚好能存下 20 个城市的状态。
- N=20 时 DP 表大小 = 100万 × 20 ≈ 2000 万,内存约 160MB,还能接受。
- N=30 时状态数 = 10 亿,完全不可行。
第十一阶段:海量数据处理与 TopK
11.1 TopK 问题
一、小顶堆求 TopK 大
问题:100 亿个整数,找最大的 100 个。(内存不够装下所有数据)
思路:维护一个大小为 100 的小顶堆,遍历数据,比堆顶大就替换。
整体操作流程:
- 建堆:创建小顶堆
pq(堆顶是堆中最小的元素)。 - 遍历数据:对每个元素
x:- 堆未满(
size < k):直接入堆。 - 堆已满且
x > pq.top():弹出堆顶(当前第 K 大),将x入堆(更大的元素替换了最小的)。
- 堆未满(
- 收集结果:弹出堆中所有元素,此时堆中保留了最大的 K 个(但堆内是从小到大的)。
- 反转:使结果从大到小排列(可选)。
vector<int> topK(const vector<int>& data, int k) {
priority_queue<int, vector<int>, greater<>> pq; // ① 小顶堆
for (int x : data) {
if (pq.size() < k) { // ② 堆未满直接入
pq.push(x);
} else if (x > pq.top()) { // ③ 比堆顶大则替换
pq.pop(); // ④ 去掉当前第 K 大
pq.push(x); // ⑤ 加入更大的
}
}
vector<int> result;
while (!pq.empty()) {
result.push_back(pq.top());
pq.pop();
}
reverse(result.begin(), result.end()); // ⑥ 反转为从大到小
return result;
}
时间复杂度:$O(N \log K)$。如果 K 远小于 N,相当于 $O(N)$。 空间复杂度:$O(K)$。
二、快速选择(QuickSelect)
问题:不需要遍历全部数据?不要求 TopK 有序,只想要"最大的 K 个"?
思路:用快排的 partition 思想,每次只递归包含第 K 大的那一半。
整体操作流程:
- partition(找前 K 大版):与快排分区类似,但大的放左边(
nums[j] > pivot时交换),分区后i表示 pivot 在"从大到小"顺序中的位置。 - quickSelect:
- 分区得到
p(第 p 大的元素位置,0-indexed)。 p == k→ 找到,返回第 k 大的值。p < k→ 第 k 大在右半,递归右半。p > k→ 第 k 大在左半,递归左半。
- 分区得到
- 结果:调用
quickSelect(nums, 0, n-1, k-1)后,前 k 个元素就是前 K 大(不保证有序)。
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) { // ② 大的放左边(区别:找前 K 大)
swap(nums[i++], nums[j]);
}
}
swap(nums[i], nums[right]); // ③ 归位 pivot
return i; // ④ 返回 pivot 在从大到小序列中的位置
}
int quickSelect(vector<int>& nums, int left, int right, int k) {
if (left == right) return nums[left];
int p = partition(nums, left, right); // ① 分区
if (p == k) return nums[p]; // ② 找到第 k 大的元素
if (p < k) return quickSelect(nums, p + 1, right, k); // ③ 第 k 大在右半
return quickSelect(nums, left, p - 1, k); // ④ 第 k 大在左半
}
vector<int> topKQuickSelect(vector<int>& nums, int k) {
quickSelect(nums, 0, nums.size() - 1, k - 1); // ① 找第 k 大(k-1 是 0-indexed)
return vector<int>(nums.begin(), nums.begin() + k);// ② 前 k 个就是前 K 大
}
时间复杂度:平均 $O(N)$,最坏 $O(N^2)$(和快排一样可以通过随机 pivot 避免退化)。 空间复杂度:$O(\log N)$(递归栈)。
三、堆 vs 快速选择怎么选?
| 场景 | 用堆 | 用快速选择 |
|---|---|---|
| 数据量大到不能全部装入内存 | ✅ 堆只需 $O(K)$ 内存,适合流式处理 | ❌ 需要随机访问全部数据 |
| 数据在数组里,全部在内存 | ✅ 简单稳定 | ✅ 更快($O(N)$ vs $O(N\log K)$) |
| 需要实时更新(不断有新数据) | ✅ 直接 push/pop | ❌ 每次重新算 |
| K 非常接近 N | ❌ $O(N\log K)$ ≈ $O(N\log N)$ | ✅ $O(N)$ |
11.2 大文件排序与去重
一、外部排序
场景:磁盘上有 100GB 的文件,每行一个整数,内存只有 1GB,怎么排序?
分治策略(External Sort):
1. 分割(Split Phase):
读入 1GB 到内存 → 快排 → 写回磁盘为 "chunk1.sorted"
读入 1GB 到内存 → 快排 → 写回磁盘为 "chunk2.sorted"
... 共 100 个有序块
2. 多路归并(Merge Phase):
打开 100 个文件句柄,每个读第一个元素
用大小为 100 的小顶堆找最小的元素输出到结果文件
从输出元素的文件读入下一个
重复直到所有文件处理完
总 I/O:读 100GB + 写 100GB(分割),再读 100GB + 写 100GB(归并)
= 读 200GB + 写 200GB
二、海量去重
场景:100GB 的 URL 文件中,统计去重后的 URL 数量。
内存不够放 unordered_set,怎么办?
方案一:布隆过滤器(允许少量误判)
- 分配几百 MB 的位数组,遍历 URL,插入布隆过滤器。
- 最后统计插入了多少个(需要计数布隆过滤器或记录数量)。
方案二:哈希分片 + 分治
1. 用哈希函数把每个 URL 分配到 1000 个小文件中:
file_id = hash(url) % 1000
写入 file_{file_id}.txt
2. 每个小文件足够小,可以全部加载到内存:
用 unordered_set 对每个小文件去重
输出到独立的结果文件
3. 合并各文件结果
(因为哈希分片后,相同的 URL 一定在同一个文件中)
11.3 数据流中的中位数与众数
一、双堆维护中位数
问题:一个数据流不断产生数字,随时求当前所有数的中位数。
思路:最大堆存左半(小于中位数),最小堆存右半(大于中位数)。
整体操作流程:
- 数据结构:
left_是最大堆(存左半较小的数),right_是最小堆(存右半较大的数)。 - addNum(num):
- 决定插入哪一侧:如果
num <= left_.top()(小于等于左半最大值),插入左半;否则插入右半。 - 平衡:保证
left_.size() >= right_.size()且差值 ≤ 1。- 左半比右半多超过 1 个:将左半堆顶移到右半。
- 右半比左半多:将右半堆顶移到左半。
- 决定插入哪一侧:如果
- findMedian():
- 奇数个元素(
left_.size() > right_.size()):中位数 =left_.top()。 - 偶数个元素:中位数 =
(left_.top() + right_.top()) / 2.0。
- 奇数个元素(
class MedianFinder {
priority_queue<int> left_; // ① 最大堆(存左半较小的一半)
priority_queue<int, vector<int>, greater<>> right_;// ② 最小堆(存右半较大的一半)
public:
void addNum(int num) {
if (left_.empty() || num <= left_.top()) { // ③ 小于等于左半最大值 → 左半
left_.push(num);
} else {
right_.push(num); // ④ 否则 → 右半
}
if (left_.size() > right_.size() + 1) { // ⑤ 左半太多 → 移一个到右半
right_.push(left_.top());
left_.pop();
}
if (right_.size() > left_.size()) { // ⑥ 右半太多 → 移一个到左半
left_.push(right_.top());
right_.pop();
}
}
double findMedian() {
if (left_.size() > right_.size()) { // ⑦ 奇数个 → 左半堆顶
return left_.top();
}
return (left_.top() + right_.top()) / 2.0; // ⑧ 偶数个 → 两堆顶均值
}
};
时间复杂度:addNum $O(\log N)$,findMedian $O(1)$。
二、Boyer-Moore 投票算法求众数
问题:数组中找出现次数超过一半的元素(LeetCode 169)。
思路:不同的元素相互抵消,最后剩下的就是候选。
整体操作流程:
- 初始化:设第一个元素为候选众数
candidate,计数器count = 1。 - 遍历:从第二个元素开始遍历:
- 如果等于
candidate:count++(票数 +1)。 - 如果不等于:
count--(票数 -1,不同元素相互抵消)。 - 如果
count == 0:换当前元素为新的候选人,count = 1。
- 如果等于
- 返回:遍历结束后,
candidate就是出现次数超过一半的众数。
int majorityElement(vector<int>& nums) {
int candidate = nums[0]; // ① 初始候选人
int count = 1; // ② 票数
for (int i = 1; i < nums.size(); i++) {
if (nums[i] == candidate) { // ③ 相同 → 加票
count++;
} else { // ④ 不同 → 抵销
count--;
if (count == 0) { // ⑤ 票数为 0 → 换候选人
candidate = nums[i];
count = 1;
}
}
}
return candidate; // ⑥ 最终候选人即为众数
}
原理:
nums = [2, 2, 1, 1, 1, 2, 2]
2: candidate=2, count=1
2: count=2
1: 不同 → count=1
1: 不同 → count=0 → candidate=1, count=1
1: count=2
2: 不同 → count=1
2: 不同 → count=0 → candidate=2, count=1
最终 candidate=2 ✅
时间复杂度:$O(N)$,空间 $O(1)$。
第八阶段到第十一阶段总结
第八阶段(数据结构手写):
LRU 缓存:list + unordered_map,splice O(1) 移到头部
Trie:26 叉树,前缀查询 O(L),空间大
布隆过滤器:位数组 + k 哈希,省空间但可能有误判
第九阶段(字符串算法):
KMP:next 数组记录最长相等前后缀,i 不回溯
滚动哈希:O(N) 滑动窗口更新哈希值
第十阶段(位运算):
x & (x-1):消去最后一个 1
x & -x:取出最后一个 1
位图:8 倍省空间,海量判重
状态压缩:用 int 的 bit 表示集合,N≤20 可用
第十一阶段(海量数据):
TopK:小顶堆 O(N log K) 或 快速选择 O(N)
外部排序:分割 + 多路归并
双堆中位数:最大堆 + 最小堆
摩尔投票:O(N) 找出现次数超过一半的元素