文档目录

一、核心思想——每次排除一半

前提:数组必须有序。

在有序数组 [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] 整体流程:

  1. 初始化:left = 0,right = n-1,搜索区间包括两端。
  2. 循环:while (left <= right),区间非空时继续。
  3. 取中点:mid = left + (right - left) / 2(防溢出写法)。
  4. 命中:nums[mid] == target → 返回 mid。
  5. 缩小:nums[mid] < target → target 在右半,left = mid + 1;否则在左半,right = mid - 1(mid 已检查过,排除)。

左闭右开 [left, right) 整体流程:

  1. 初始化:left = 0,right = n,搜索区间不包含 right。
  2. 循环:while (left < right),区间非空时继续。
  3. 取中点:mid = left + (right - left) / 2。
  4. 命中:nums[mid] == target → 返回 mid。
  5. 缩小: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
...

整体操作流程:

  1. 二分查找框架:left = 0,right = n-1,标准闭区间二分。
  2. 判断有序区间:比较 nums[left] 和 nums[mid]:
    • 若 nums[left] <= nums[mid]:左半区间是有序的。
    • 否则:右半区间是有序的。
  3. 在有序区间内判断:
    • 左半有序:检查 target 是否在 [nums[left], nums[mid]) 范围内。如果在,收缩右边界;否则去右半。
    • 右半有序:检查 target 是否在 (nums[mid], nums[right]] 范围内。如果在,收缩左边界;否则去左半。
  4. 返回:命中返回下标,否则返回 -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)。

整体操作流程:

  1. 二分框架:left = 0,right = n-1,使用 left < right 而非 <=,因为至少有一个峰值。
  2. 判断方向:比较 nums[mid] 和 nums[mid+1]:
    • 上坡(nums[mid] < nums[mid+1]):峰值在右侧,left = mid + 1。
    • 下坡或平台(nums[mid] >= nums[mid+1]):峰值在左侧或当前位置,right = mid。
  3. 返回:循环结束时 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}$。

整体操作流程:

  1. 初始化:left = 0,right = x。如果 x < 1,开方结果大于 x(如 0.25 → 0.5),所以将 right 设为 1。
  2. 浮点数二分:循环条件用 right - left > eps(精度),而非整数的 left <= right。
  3. 判断:mid * mid < x → mid 太小,left = mid;否则 mid 太大,right = mid。
  4. 返回:精度满足要求时,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 判定流程:

  1. 初始化:段计数 count = 1,当前段和 current_sum = 0。
  2. 遍历数组:尝试将每个元素加入当前段,如果加入后和超过 max_sum,另起一段。
  3. 剪枝:如果段数超过 k,直接返回 false。
  4. 返回:遍历结束且段数 ≤ k,返回 true。

splitArray 二分答案流程:

  1. 确定上下界:下界 = 单个元素最大值(每段至少要能容纳最大元素),上界 = 所有元素之和(一段装下全部)。
  2. 二分搜索:对 mid 调用 canSplit 判断可行性。
  3. 收紧:可行 → 尝试更小值,right = mid;不可行 → 必须增大,left = mid + 1。
  4. 返回: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)——面试最难点

动态规划本质:把一个大问题拆成重叠的子问题,保存子问题的结果,避免重复计算。

三个核心要素:

  1. 状态定义:dp[i] 或 dp[i][j] 表示什么?
  2. 状态转移方程:如何从子问题的解得到当前解?
  3. 初始条件:最小的子问题怎么解?

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 → 一维滚动数组优化

整体操作流程:

  1. 初始化:dp[j] 表示容量为 j 时能装的最大价值,初始为 0。
  2. 遍历物品:对每个物品 i(重量 w[i],价值 v[i])。
  3. 倒序遍历容量:从 W 递减到 w[i],dp[j] = max(dp[j], dp[j - w[i]] + v[i])。
  4. 为什么倒序:倒序保证 dp[j - w[i]] 是上一轮(未选当前物品)的状态,每个物品最多选一次。
  5. 返回: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 背包)

二、完全背包

问题:每个物品可以选无限次。

整体操作流程:

  1. 初始化:dp[j] 初始为 0。
  2. 遍历物品:对每个物品 i。
  3. 正序遍历容量:从 w[i] 递增到 W,dp[j] = max(dp[j], dp[j - w[i]] + v[i])。
  4. 为什么正序:正序时,dp[j - w[i]] 可能已经被当前物品更新过,意味着一个物品可以被多次选中。
  5. 返回:dp[W] 即为最大价值。
  6. 与 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

整体操作流程:

  1. 初始化:dp[i] = 1,每个元素自身至少构成一个长度为 1 的上升子序列。
  2. 双层循环:对每个 i,遍历它前面的所有 j。
  3. 状态转移:如果 nums[j] < nums[i](可以接在后面),dp[i] = max(dp[i], dp[j] + 1)。
  4. 更新全局最大值:每处理完一个 i,更新 max_len。
  5. 返回: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)$ 耐心排序

整体操作流程:

  1. 初始化:tails 数组为空,tails[k] 表示长度为 k+1 的上升子序列的最小末尾值。
  2. 遍历数组:对每个元素 x,用 lower_bound 在 tails 中找到第一个 >= x 的位置。
  3. 扩展或替换:
    • 如果 x 比所有末尾都大(it == tails.end()),将 x 追加到 tails 末尾——LIS 长度增加。
    • 否则,用 x 替换该位置的末尾值——让该长度的子序列末尾变得更小,后续更易扩展。
  4. 返回:tails.size() 即为 LIS 长度。
  5. 注意: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。

整体操作流程:

  1. 初始化:创建 (m+1) × (n+1) 的 DP 表格,dp[0][*] = dp[*][0] = 0(空串 LCS 为 0)。
  2. 逐行逐列填充:i 从 1 到 m,j 从 1 到 n。
  3. 字符匹配:如果 text1[i-1] == text2[j-1],dp[i][j] = dp[i-1][j-1] + 1。
  4. 字符不匹配:取两种跳过的最大值——max(dp[i-1][j], dp[i][j-1])。
  5. 返回: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 的总重量)

整体操作流程:

  1. 前缀和:计算 prefix 数组,使得 sum(i, j) = prefix[j+1] - prefix[i] 能在 O(1) 时间内得到合并代价。
  2. 初始化:dp[i][i] = 0(一堆石子不需要合并),其他初始化为 INT_MAX。
  3. 枚举区间长度:len 从 2 到 n(从小到大的区间顺序,保证递推时子区间已计算)。
  4. 枚举起点:对于每个起点 i,计算终点 j = i + len - 1。
  5. 枚举分割点:对 k 从 i 到 j-1,尝试在 k 处将区间分成两半,取 dp[i][k] + dp[k+1][j] + sum(i, j) 的最小值。
  6. 返回: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 递归整体流程:

  1. 终止条件:节点为空时返回。
  2. 访问节点:在前序位置处理当前节点(若需要中序或后序,调整递归顺序)。
  3. 递归子节点:先递归左子树,再递归右子树(或按需调整顺序)。

DFS 显式栈整体流程:

  1. 初始化:根节点入栈。
  2. 循环:栈不为空时,弹出栈顶并处理。
  3. 子节点入栈:先将右子节点入栈,再将左子节点入栈(出栈顺序为左→右,即先处理左子节点)。
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 算法(基于入度)

思路:

  1. 统计每个节点的入度(有多少节点指向它)。
  2. 把所有入度为 0 的节点入队。
  3. 每次弹出队首节点,把它所有后继节点的入度减 1。如果后继节点入度变为 0,入队。
  4. 重复直到队列为空。如果 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]

整体操作流程:

  1. 建图与统计入度:遍历边集,构建邻接表 graph,同时统计每个节点的入度(有多少前驱节点指向它)。
  2. 入队零入度节点:将所有入度为 0 的节点入队(没有前置依赖,可以最先执行)。
  3. 逐出队:弹出队首节点,加入结果集,将其所有后继节点的入度减 1。
  4. 入队检查:如果某个后继节点入度变为 0,将其入队。
  5. 环检测:如果结果集大小小于节点总数,说明图中存在环(环上的节点入度永远不为 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

整体操作流程:

  1. 初始化:dist 数组除起点外设为 INT_MAX,visited 全为 false。优先队列 pq 以距离为键。
  2. 起点入队:dist[start] = 0,pq.push({0, start})。
  3. 循环取最小:每次从优先队列中取出距离最小的未访问节点 u。
  4. 松弛操作:遍历 u 的所有邻接边 (v, w),如果 dist[u] + w < dist[v],更新 dist[v] 并将新距离入队。
  5. 标记已访问:节点一旦从队列取出,标记为已访问(后续再次取出时跳过)。
  6. 返回: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)、需要所有点对之间的最短距离。

整体操作流程:

  1. 拷贝邻接矩阵:dist[i][j] 初始为 i 到 j 的直接距离,不直接相连为 INT_MAX。
  2. 三层循环:外层 k 作为中间节点,内层 i 和 j 作为起点和终点。
  3. 松弛:如果 dist[i][k] + dist[k][j] 小于 dist[i][j],则更新。
  4. 为什么 k 在最外层:dist[i][j] 的更新依赖 dist[i][k] 和 dist[k][j],它们必须已经考虑了 [0, k-1] 作为中间节点。k 逐步增加,逐步允许更多的中间节点。
  5. 返回: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 整体操作流程:

  1. 初始化:每个节点的父节点指向自己,秩(树高)为 0,连通分量数 = 节点总数。
  2. find(路径压缩):
    • 递归查找根节点:parent[x] = find(parent[x])。
    • 路径压缩效果:路径上的所有节点直接指向根节点,下次查找 O(1)。
  3. unite(按秩合并):
    • 分别查找 x 和 y 的根节点,如果相同则已连通。
    • 按秩合并:矮的树接到高的树下(rank[rootX] < rank[rootY])。
    • 如果两棵树高度相同,合并后高度 +1。
    • 连通分量数 count 减 1。v
  4. 查询: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):
    1. 在 unordered_map 中查找 key。
    2. 不存在 → 返回 -1。
    3. 存在 → 用 splice 将节点从当前位置移到链表头部($O(1)$ 更新使用顺序),返回 value。
  • put(key, value):
    1. 查找 key 是否已存在。
    2. 已存在:更新 value,将节点移到链表头部。
    3. 不存在:如果缓存满,淘汰链表尾部节点(最久未使用),从 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):
    1. 从根节点开始,遍历单词的每个字符。
    2. 计算字符在 children 数组中的索引 c - 'a'。
    3. 如果当前节点没有该子节点,创建一个新节点。
    4. 移动到子节点,继续处理下一个字符。
    5. 遍历结束后,将当前节点标记为单词结尾(is_end = true)。
  • search(word):
    1. 从根节点开始,沿字符路径向下查找。
    2. 如果路径中断(缺子节点),返回 false。
    3. 到达终点后,检查 is_end 是否为 true(必须是完整单词而非前缀)。
  • startsWith(prefix):
    1. 同 search 的查找过程,但不需要检查 is_end。
    2. 只要路径存在,就返回 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):
    1. 用 k 个哈希函数分别计算 key 的哈希位置。
    2. 将位数组中这些位置全部置 1(已为 1 则不变)。
  • contains(key):
    1. 用同样的 k 个哈希函数计算位置。
    2. 如果所有位置均为 1 → 返回 true(可能存在,有误判风险)。
    3. 如果任意位置为 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 数组整体流程:

  1. 初始化:next[0] = -1(特殊标记,第一个字符就不匹配时 j 回退到 -1),双指针 i = 0, j = -1。
  2. 递推:i 从 0 到 m-1,j 表示当前已匹配的前缀长度:
    • 如果 j == -1 或 pattern[i] == pattern[j]:i++,j++,next[i] = j(前缀长度 +1)。
    • 否则:j = next[j](回溯到更短的前缀继续尝试)。
  3. 返回:next[i] 表示 pattern[0..i-1] 的最长相等前后缀长度。

KMP 搜索整体流程:

  1. 初始化:文本指针 i = 0,模式指针 j = 0。
  2. 匹配:i < n && j < m 时循环:
    • j == -1 或 text[i] == pattern[j] → i++,j++(匹配成功,同时前进)。
    • 否则 → j = next[j](i 不回溯,只回退 j 到 next 数组指示的位置)。
  3. 返回: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')
//   = 去掉最高位 + 整体左移一位 + 加入新低位

完整实现:

整体操作流程:

  1. 预计算:计算模式串 pattern 的哈希值 pat_hash,以及最高位的权值 base^(m-1) % mod。
  2. 初始窗口:计算文本串第一个长度为 m 的窗口的哈希值 text_hash。
  3. 检查匹配:如果 text_hash == pat_hash,返回起始位置 0。
  4. 滚动窗口:从 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。
  5. 未找到:返回 -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 已经去过

整体操作流程:

  1. 状态定义:dp[mask][i] 表示已访问 mask 中的城市、当前位于城市 i 的最短距离。mask 是一个整数,第 k 位为 1 表示已访问城市 k。
  2. 初始化:dp[1][0] = 0(从城市 0 出发,只访问了城市 0),其余为无穷大。
  3. 遍历所有状态:对每个 mask,遍历当前所在城市 i(必须在 mask 中),尝试去下一个未访问城市 j(不在 mask 中)。
  4. 状态转移:new_mask = mask | (1 << j),dp[new_mask][j] = min(dp[new_mask][j], dp[mask][i] + dist[i][j])。
  5. 返回:访问完所有城市(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 的小顶堆,遍历数据,比堆顶大就替换。

整体操作流程:

  1. 建堆:创建小顶堆 pq(堆顶是堆中最小的元素)。
  2. 遍历数据:对每个元素 x:
    • 堆未满(size < k):直接入堆。
    • 堆已满且 x > pq.top():弹出堆顶(当前第 K 大),将 x 入堆(更大的元素替换了最小的)。
  3. 收集结果:弹出堆中所有元素,此时堆中保留了最大的 K 个(但堆内是从小到大的)。
  4. 反转:使结果从大到小排列(可选)。
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 大的那一半。

整体操作流程:

  1. partition(找前 K 大版):与快排分区类似,但大的放左边(nums[j] > pivot 时交换),分区后 i 表示 pivot 在"从大到小"顺序中的位置。
  2. quickSelect:
    • 分区得到 p(第 p 大的元素位置,0-indexed)。
    • p == k → 找到,返回第 k 大的值。
    • p < k → 第 k 大在右半,递归右半。
    • p > k → 第 k 大在左半,递归左半。
  3. 结果:调用 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 数据流中的中位数与众数

一、双堆维护中位数

问题:一个数据流不断产生数字,随时求当前所有数的中位数。

思路:最大堆存左半(小于中位数),最小堆存右半(大于中位数)。

整体操作流程:

  1. 数据结构:left_ 是最大堆(存左半较小的数),right_ 是最小堆(存右半较大的数)。
  2. addNum(num):
    • 决定插入哪一侧:如果 num <= left_.top()(小于等于左半最大值),插入左半;否则插入右半。
    • 平衡:保证 left_.size() >= right_.size() 且差值 ≤ 1。
      • 左半比右半多超过 1 个:将左半堆顶移到右半。
      • 右半比左半多:将右半堆顶移到左半。
  3. 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)。

思路:不同的元素相互抵消,最后剩下的就是候选。

整体操作流程:

  1. 初始化:设第一个元素为候选众数 candidate,计数器 count = 1。
  2. 遍历:从第二个元素开始遍历:
    • 如果等于 candidate:count++(票数 +1)。
    • 如果不等于:count--(票数 -1,不同元素相互抵消)。
    • 如果 count == 0:换当前元素为新的候选人,count = 1。
  3. 返回:遍历结束后,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) 找出现次数超过一半的元素