跳转至

第四章 搜索与二分

本章目标:掌握二分查找的多种变体(整数二分、浮点二分、二分答案、三分搜索),熟练运用 BFS/DFS 遍历图与树,理解 Flood Fill、剪枝与折半搜索技巧,并能综合运用搜索策略解决竞赛问题。


4.1 二分查找原理与实现

4.1.1 二分查找的基本思想

二分查找适用于单调性问题:在一个有序序列中,通过每次排除一半的元素,快速定位目标值。

graph LR
    A["有序数组: 1 3 5 7 9 11"] --> B["查找 7: mid=5, 5<7, 向右"]
    B --> C["区间 7 9 11: mid=9, 9>7, 向左"]
    C --> D["区间 7: 找到!"]

核心公式

  • mid = (l + r) / 2(向下取整)
  • mid = l + (r - l) / 2(防溢出写法)

4.1.2 整数二分

整数二分有两种常见模板,区别在于区间的开闭和 mid 的归属。

模板一:寻找左边界(满足条件的最小值)

// 在 [l, r] 中找到第一个满足 check(mid) == true 的位置
int binarySearchLeft(int l, int r) {
    while (l < r) {
        int mid = l + (r - l) / 2;
        if (check(mid)) r = mid;      // mid 可能是答案,保留
        else l = mid + 1;              // mid 不满足,排除
    }
    return l;  // l == r,即为答案
}

模板二:寻找右边界(满足条件的最大值)

// 在 [l, r] 中找到最后一个满足 check(mid) == true 的位置
int binarySearchRight(int l, int r) {
    while (l < r) {
        int mid = l + (r - l + 1) / 2;  // 注意 +1 防止死循环
        if (check(mid)) l = mid;          // mid 可能是答案,保留
        else r = mid - 1;                 // mid 不满足,排除
    }
    return l;  // l == r,即为答案
}

两种模板的关键区别

模板 mid 计算 check 为 true 时 check 为 false 时
左边界 (l+r)/2 r = mid l = mid + 1
右边界 (l+r+1)/2 l = mid r = mid - 1

记忆口诀:右边界模板的 mid 要 +1,否则当 l + 1 == rmid == l,若 check(mid) 为 true 则 l = mid(即 l 不变),造成死循环。

完整示例:查找元素的起始和结束位置

#include <bits/stdc++.h>
using namespace std;

// 在有序数组中查找 target 的起始和结束位置
vector<int> searchRange(vector<int>& nums, int target) {
    int n = nums.size();
    if (n == 0) return {-1, -1};

    // 找左边界:第一个 >= target 的位置
    int lo = 0, hi = n - 1;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (nums[mid] >= target) hi = mid;
        else lo = mid + 1;
    }
    if (nums[lo] != target) return {-1, -1};
    int left = lo;

    // 找右边界:最后一个 <= target 的位置
    hi = n - 1;  // lo 不需要重置(从 left 开始搜索更快)
    while (lo < hi) {
        int mid = lo + (hi - lo + 1) / 2;
        if (nums[mid] <= target) lo = mid;
        else hi = mid - 1;
    }
    return {left, lo};
}

int main() {
    vector<int> nums = {5, 7, 7, 8, 8, 10};
    auto res = searchRange(nums, 8);
    cout << res[0] << " " << res[1] << endl;  // 输出: 3 4
    return 0;
}

4.1.3 浮点二分

浮点二分用于求解连续值上的精确解(如开方、方程求根)。

#include <bits/stdc++.h>
using namespace std;

// 浮点二分求平方根
double sqrtBinary(double x) {
    double lo = 0, hi = max(1.0, x);  // 注意 x < 1 的情况
    while (hi - lo > 1e-9) {          // 精度要求 10^-9
        double mid = (lo + hi) / 2;
        if (mid * mid <= x) lo = mid;
        else hi = mid;
    }
    return lo;
}

// 浮点二分求方程 x^3 - x - 1 = 0 的根
double solveEquation() {
    double lo = 1, hi = 2;
    while (hi - lo > 1e-9) {
        double mid = (lo + hi) / 2;
        if (mid * mid * mid - mid - 1 <= 0) lo = mid;
        else hi = mid;
    }
    return lo;
}

int main() {
    cout << fixed << setprecision(6) << sqrtBinary(2.0) << endl;
    // 输出: 1.414214
    cout << fixed << setprecision(6) << solveEquation() << endl;
    // 输出: 1.324718
    return 0;
}

浮点二分的注意事项

  • 循环终止条件是 hi - lo > eps(精度),而非 l < r
  • 精度一般设为 1e-71e-9,根据题目要求调整
  • 也可以固定循环次数(如 100 次),效果等价

4.1.4 STL 二分函数

C++ STL 提供了便捷的二分查找函数:

#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {1, 3, 5, 7, 9, 11};

    // 1. binary_search:判断元素是否存在
    bool found = binary_search(v.begin(), v.end(), 7);  // true

    // 2. lower_bound:第一个 >= target 的迭代器
    auto it1 = lower_bound(v.begin(), v.end(), 6);
    cout << *it1 << endl;  // 输出: 7

    // 3. upper_bound:第一个 > target 的迭代器
    auto it2 = upper_bound(v.begin(), v.end(), 7);
    cout << *it2 << endl;  // 输出: 9

    // 4. 计算元素出现次数
    vector<int> a = {1, 2, 2, 2, 3, 4};
    int cnt = upper_bound(a.begin(), a.end(), 2) - lower_bound(a.begin(), a.end(), 2);
    cout << cnt << endl;  // 输出: 3

    return 0;
}
函数 功能 复杂度
binary_search 判断是否存在 O(log n)
lower_bound 第一个 >= val O(log n)
upper_bound 第一个 > val O(log n)

4.2 二分答案(判定性问题)

核心思想

当直接求解最优解困难,但验证某个解是否可行很容易时,可以将求解问题转化为判定问题,用二分来缩小答案范围。

graph TD
    A["原问题:求最优值"] --> B["转化为:判定 mid 是否可行"]
    B --> C{"check(mid)"}
    C -->|可行| D["尝试更优: 调整区间"]
    C -->|不可行| E["排除 mid: 调整区间"]
    D --> F["最终答案"]
    E --> F

适用条件

  1. 答案具有单调性:若 x 可行,则所有比 x 更优(或更差)的值也可行
  2. 存在高效的判定函数 check(mid)

经典模型

模型一:最小化最大值

问题:将数组分成 k 段,使得每段和的最大值最小。

思路:二分答案 mid(每段和的上限),贪心地判断能否在 k 段内完成。

#include <bits/stdc++.h>
using namespace std;

// 判定函数:能否将数组分成 <= k 段,每段和不超过 mid
bool check(vector<int>& nums, int k, long long mid) {
    int count = 1;       // 当前段数
    long long sum = 0;   // 当前段的和
    for (int x : nums) {
        if (x > mid) return false;  // 单个元素就超过 mid,不可能
        if (sum + x > mid) {
            count++;     // 开新段
            sum = x;
            if (count > k) return false;  // 超过 k 段
        } else {
            sum += x;
        }
    }
    return true;
}

// 二分答案:最小化分成 k 段后各段和的最大值
int splitArray(vector<int>& nums, int k) {
    long long lo = 0, hi = 0;
    for (int x : nums) {
        lo = max(lo, (long long)x);  // 下界:最大单个元素
        hi += x;                       // 上界:全部放在一段
    }

    while (lo < hi) {
        long long mid = lo + (hi - lo) / 2;
        if (check(nums, k, mid)) hi = mid;   // 可行,尝试更小
        else lo = mid + 1;                     // 不可行,增大
    }
    return lo;
}

int main() {
    vector<int> nums = {7, 2, 5, 10, 8};
    cout << splitArray(nums, 2) << endl;  // 输出: 18
    // 分成 {7,2,5} 和 {10,8},最大段和 = 18
    return 0;
}

模型二:最大化最小值

问题:在一条直线上有 n 个点,要放置 k 个点,使得相邻放置点之间的最小距离最大。

思路:二分答案 mid(最小距离),贪心地判断能否放置 k 个点使得间距 >= mid。

#include <bits/stdc++.h>
using namespace std;

// 判定函数:能否放置 k 个点使得相邻间距 >= mid
bool check(vector<int>& pos, int k, int mid) {
    int count = 1;          // 放了第一个点
    int last = pos[0];      // 上一个放置位置
    for (int i = 1; i < pos.size(); i++) {
        if (pos[i] - last >= mid) {
            count++;
            last = pos[i];
            if (count >= k) return true;
        }
    }
    return false;
}

// 二分答案:最大化最小距离
int maxMinDistance(vector<int>& pos, int k) {
    sort(pos.begin(), pos.end());
    int lo = 1;                          // 最小距离至少为 1
    int hi = pos.back() - pos.front();   // 最大距离

    while (lo < hi) {
        int mid = lo + (hi - lo + 1) / 2;  // 右边界模板
        if (check(pos, k, mid)) lo = mid;
        else hi = mid - 1;
    }
    return lo;
}

模型三:满足条件的第 k 小/大

问题:给定一个 n x m 的乘法表,求第 k 小的数。

#include <bits/stdc++.h>
using namespace std;

// 判定函数:乘法表中 <= mid 的元素有多少个
int countLessEqual(int n, int m, int mid) {
    int count = 0;
    for (int i = 1; i <= n; i++) {
        // 第 i 行是 i, 2i, 3i, ..., mi
        // <= mid 的元素个数为 min(mid/i, m)
        count += min(mid / i, m);
    }
    return count;
}

// 二分答案:乘法表中第 k 小的数
int findKthNumber(int n, int m, int k) {
    int lo = 1, hi = n * m;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (countLessEqual(n, m, mid) >= k) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}

4.3 BFS 模板与最短路

4.3.1 BFS 基本原理

广度优先搜索(BFS):从起点开始,按层次逐层向外扩展,第一次到达目标时的路径就是最短路径(在无权图中)。

graph TD
    A["起点入队"] --> B["取出队首"]
    B --> C["扩展所有未访问的邻居"]
    C --> D["邻居入队"]
    D --> B
    B --> E["队列为空 / 找到目标"]

4.3.2 BFS 模板

#include <bits/stdc++.h>
using namespace std;

// BFS 模板:网格最短路径
int bfs(vector<vector<int>>& grid, int sx, int sy, int ex, int ey) {
    int n = grid.size(), m = grid[0].size();
    int dx[] = {-1, 1, 0, 0};  // 方向数组:上下左右
    int dy[] = {0, 0, -1, 1};

    // visited 数组记录是否访问过
    vector<vector<bool>> visited(n, vector<bool>(m, false));
    queue<pair<int, int>> q;

    q.push({sx, sy});
    visited[sx][sy] = true;
    int steps = 0;

    while (!q.empty()) {
        int size = q.size();  // 当前层的节点数
        for (int i = 0; i < size; i++) {
            auto [x, y] = q.front();
            q.pop();

            if (x == ex && y == ey) return steps;  // 找到目标

            for (int d = 0; d < 4; d++) {
                int nx = x + dx[d];
                int ny = y + dy[d];
                // 检查边界、是否可走、是否已访问
                if (nx >= 0 && nx < n && ny >= 0 && ny < m &&
                    grid[nx][ny] == 0 && !visited[nx][ny]) {
                    visited[nx][ny] = true;
                    q.push({nx, ny});
                }
            }
        }
        steps++;  // 一层遍历完,步数 +1
    }
    return -1;  // 无法到达
}

4.3.3 网格 BFS

例题:最短路径(0-1 网格)

给定一个 n x m 的网格,0 表示可通行,1 表示障碍。求从左上角到右下角的最短路径长度。

#include <bits/stdc++.h>
using namespace std;

int shortestPath(vector<vector<int>>& grid) {
    int n = grid.size(), m = grid[0].size();
    if (grid[0][0] == 1 || grid[n-1][m-1] == 1) return -1;

    int dx[] = {-1, 1, 0, 0};
    int dy[] = {0, 0, -1, 1};

    vector<vector<int>> dist(n, vector<int>(m, -1));  // -1 表示未访问
    queue<pair<int, int>> q;

    q.push({0, 0});
    dist[0][0] = 1;  // 起点也算一步

    while (!q.empty()) {
        auto [x, y] = q.front();
        q.pop();

        for (int d = 0; d < 4; d++) {
            int nx = x + dx[d];
            int ny = y + dy[d];
            if (nx >= 0 && nx < n && ny >= 0 && ny < m &&
                grid[nx][ny] == 0 && dist[nx][ny] == -1) {
                dist[nx][ny] = dist[x][y] + 1;
                q.push({nx, ny});
            }
        }
    }
    return dist[n-1][m-1];
}

4.3.4 多源 BFS

问题:有多个起点同时出发,求每个点到最近起点的距离。

技巧:将所有起点同时入队,一起向外扩展。

#include <bits/stdc++.h>
using namespace std;

// 多源 BFS:多个起点同时出发
vector<vector<int>> multiSourceBFS(vector<vector<int>>& grid,
                                    vector<pair<int, int>>& sources) {
    int n = grid.size(), m = grid[0].size();
    int dx[] = {-1, 1, 0, 0};
    int dy[] = {0, 0, -1, 1};

    vector<vector<int>> dist(n, vector<int>(m, -1));
    queue<pair<int, int>> q;

    // 所有源点同时入队
    for (auto& [x, y] : sources) {
        q.push({x, y});
        dist[x][y] = 0;
    }

    while (!q.empty()) {
        auto [x, y] = q.front();
        q.pop();

        for (int d = 0; d < 4; d++) {
            int nx = x + dx[d];
            int ny = y + dy[d];
            if (nx >= 0 && nx < n && ny >= 0 && ny < m && dist[nx][ny] == -1) {
                dist[nx][ny] = dist[x][y] + 1;
                q.push({nx, ny});
            }
        }
    }
    return dist;
}

BFS 求最短路的条件

BFS 适用于无权图(或权值相同的图)的最短路径。如果边权不同,需要使用 Dijkstra 等算法。

4.3.5 拓展:0-1 BFS 与双向 BFS

0-1 BFS:当边权只有 0 和 1 两种时,不必上 Dijkstra——把普通队列换成双端队列即可:经 0 权边扩展出的点放队首(距离不变,相当于同层),经 1 权边扩展出的点放队尾(距离 +1,相当于下一层)。这样队列中的距离仍保持单调,复杂度 O(V + E)。

// 0-1 BFS:边权只有 0 和 1 的最短路,复杂度 O(V + E)
vector<int> zeroOneBFS(vector<vector<pair<int,int>>>& g, int s) {
    int n = g.size();
    vector<int> dist(n, INT_MAX);
    deque<int> dq;
    dist[s] = 0;
    dq.push_back(s);
    while (!dq.empty()) {
        int u = dq.front(); dq.pop_front();
        for (auto [v, w] : g[u]) {             // w 只能是 0 或 1
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                if (w == 0) dq.push_front(v);  // 0 权边:放队首,优先扩展
                else dq.push_back(v);          // 1 权边:放队尾
            }
        }
    }
    return dist;
}

双向 BFS:当起点和终点都已知时,可以从两端同时向中间搜索,每轮扩展节点数较少的一侧,两侧访问集合一旦相遇即得到最短路。设分支因子为 b、最短路长为 d,搜索量从 O(b^d) 降到 O(2·b^(d/2)),对状态空间巨大的问题(如单词接龙、八数码)提速非常明显。实现要点:用两个 visited 集合分别记录两端已访问状态,每轮把节点少的那侧整体扩展一层,扩展出的状态若出现在对侧集合中即可返回两侧步数之和。


4.4 DFS 模板与遍历

4.4.1 DFS 基本原理

深度优先搜索(DFS):沿着一条路径一直走到底,然后回溯到上一个分叉点,尝试其他路径。

graph TD
    A["从起点开始"] --> B["选择一条未走过的路"]
    B --> C["走到下一个节点"]
    C --> D{"还有路可走?"}
    D -->|是| B
    D -->|否| E["回溯到上一个节点"]
    E --> D

4.4.2 DFS 模板(递归)

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;
vector<int> graph[MAXN];  // 邻接表
bool visited[MAXN];

// DFS 模板
void dfs(int u) {
    visited[u] = true;
    // 处理当前节点 u

    for (int v : graph[u]) {
        if (!visited[v]) {
            dfs(v);  // 递归访问邻居
        }
    }
    // 回溯(如果需要恢复状态)
}

4.4.3 DFS 模板(迭代,使用栈)

#include <bits/stdc++.h>
using namespace std;

// DFS 迭代写法(用栈模拟递归)
void dfsIterative(vector<vector<int>>& graph, int start) {
    int n = graph.size();
    vector<bool> visited(n, false);
    stack<int> st;

    st.push(start);

    while (!st.empty()) {
        int u = st.top();
        st.pop();

        if (visited[u]) continue;
        visited[u] = true;

        // 处理节点 u

        // 注意:为了保持 DFS 顺序,邻居要逆序入栈
        for (int i = graph[u].size() - 1; i >= 0; i--) {
            int v = graph[u][i];
            if (!visited[v]) {
                st.push(v);
            }
        }
    }
}

4.4.4 DFS 应用:树的遍历

#include <bits/stdc++.h>
using namespace std;

struct TreeNode {
    int val;
    TreeNode* left;
    TreeNode* right;
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

// 前序遍历:根 -> 左 -> 右
void preorder(TreeNode* root) {
    if (!root) return;
    cout << root->val << " ";  // 访问根节点
    preorder(root->left);       // 遍历左子树
    preorder(root->right);      // 遍历右子树
}

// 中序遍历:左 -> 根 -> 右
void inorder(TreeNode* root) {
    if (!root) return;
    inorder(root->left);
    cout << root->val << " ";
    inorder(root->right);
}

// 后序遍历:左 -> 右 -> 根
void postorder(TreeNode* root) {
    if (!root) return;
    postorder(root->left);
    postorder(root->right);
    cout << root->val << " ";
}

4.5 Flood Fill(泛洪填充)

核心思想

从一个起始点出发,将所有与其连通满足条件的点进行标记或修改。常用 DFS 或 BFS 实现。

graph LR
    A["选起点"] --> B["标记为已访问"]
    B --> C["向四个方向扩展"]
    C --> D{"邻居满足条件?"}
    D -->|是| E["递归填充邻居"]
    D -->|否| F["跳过"]
    E --> C

经典例题:图像渲染

问题:给定一个二维网格表示图像,一个起始坐标和新颜色,将起始坐标所在连通区域的颜色全部改为新颜色。

#include <bits/stdc++.h>
using namespace std;

class Solution {
public:
    int dx[4] = {-1, 1, 0, 0};
    int dy[4] = {0, 0, -1, 1};
    int n, m;
    int oldColor;

    // DFS 泛洪填充
    void dfs(vector<vector<int>>& image, int x, int y, int newColor) {
        // 边界检查 & 颜色检查
        if (x < 0 || x >= n || y < 0 || y >= m) return;
        if (image[x][y] != oldColor) return;

        image[x][y] = newColor;  // 修改颜色

        for (int d = 0; d < 4; d++) {
            dfs(image, x + dx[d], y + dy[d], newColor);
        }
    }

    vector<vector<int>> floodFill(vector<vector<int>>& image,
                                   int sr, int sc, int newColor) {
        n = image.size();
        m = image[0].size();
        oldColor = image[sr][sc];

        if (oldColor != newColor)  // 避免无限递归
            dfs(image, sr, sc, newColor);

        return image;
    }
};

BFS 写法

#include <bits/stdc++.h>
using namespace std;

// BFS 写法的泛洪填充
vector<vector<int>> floodFillBFS(vector<vector<int>>& image,
                                  int sr, int sc, int newColor) {
    int n = image.size(), m = image[0].size();
    int oldColor = image[sr][sc];
    if (oldColor == newColor) return image;

    int dx[] = {-1, 1, 0, 0};
    int dy[] = {0, 0, -1, 1};

    queue<pair<int, int>> q;
    q.push({sr, sc});
    image[sr][sc] = newColor;

    while (!q.empty()) {
        auto [x, y] = q.front();
        q.pop();

        for (int d = 0; d < 4; d++) {
            int nx = x + dx[d];
            int ny = y + dy[d];
            if (nx >= 0 && nx < n && ny >= 0 && ny < m &&
                image[nx][ny] == oldColor) {
                image[nx][ny] = newColor;
                q.push({nx, ny});
            }
        }
    }
    return image;
}

Flood Fill 应用场景

场景 说明
数岛屿 统计网格中连通的 1 的个数
染色 将连通区域涂上新颜色
迷宫求解 标记可达区域
游戏中的区域识别 如扫雷展开

4.6 剪枝技巧

什么是剪枝?

剪枝是指在搜索过程中,提前排除不可能产生最优解的分支,从而减少搜索空间。

graph TD
    A["搜索树"] --> B["可行剪枝: 排除不合法的分支"]
    A --> C["最优剪枝: 排除不能优于当前最优的分支"]
    B --> D["减少搜索量"]
    C --> D

4.6.1 可行性剪枝

在搜索过程中,如果当前路径已经不可能满足约束条件,直接回溯。

// 示例:N 皇后问题中的可行性剪枝
bool isValid(vector<int>& queens, int row, int col) {
    for (int i = 0; i < row; i++) {
        // 检查列冲突和对角线冲突
        if (queens[i] == col || abs(queens[i] - col) == abs(i - row))
            return false;
    }
    return true;
}

void solveNQueens(vector<int>& queens, int row, int n, int& count) {
    if (row == n) {
        count++;
        return;
    }
    for (int col = 0; col < n; col++) {
        if (!isValid(queens, row, col)) continue;  // 可行性剪枝
        queens[row] = col;
        solveNQueens(queens, row + 1, n, count);
    }
}

4.6.2 最优性剪枝

在搜索最优解的过程中,如果当前路径的代价已经超过已知最优解,直接回溯。

// 示例:TSP(旅行商问题)中的最优性剪枝
int bestDist = INT_MAX;  // 当前最优解

void tsp(vector<vector<int>>& dist, vector<bool>& visited,
         int u, int count, int n, int curDist) {
    // 最优性剪枝:当前距离已超过已知最优
    if (curDist >= bestDist) return;

    if (count == n) {
        bestDist = min(bestDist, curDist + dist[u][0]);  // 回到起点
        return;
    }

    for (int v = 0; v < n; v++) {
        if (!visited[v]) {
            visited[v] = true;
            tsp(dist, visited, v, count + 1, n, curDist + dist[u][v]);
            visited[v] = false;  // 回溯
        }
    }
}

4.6.3 重复性剪枝

避免搜索到相同的状态,使用 visited 数组或记忆化来去重。

4.6.4 搜索顺序优化

优先搜索"更有希望"的分支,可以更快找到最优解。

// 示例:在搜索前对候选节点排序(如贪心顺序)
sort(candidates.begin(), candidates.end(), [](const auto& a, const auto& b) {
    return a.estimated_cost < b.estimated_cost;  // 按估价排序
});

剪枝策略总结

剪枝类型 思路 应用场景
可行性剪枝 当前状态已违反约束 N 皇后、数独
最优性剪枝 当前代价已超过已知最优 TSP、最优化搜索
重复性剪枝 避免搜索相同状态 网格搜索、记忆化
搜索顺序优化 优先搜索更有希望的分支 组合搜索、IDA*

拓展:迭代加深与 IDA*

迭代加深搜索(IDDFS):给 DFS 设一个深度上限 limit,从 limit = 1 开始逐次加 1 重新搜索。它用 DFS 的 O(深度) 空间获得了 BFS 的"按层找最短解"效果,适合"解的深度不深但状态分支很多"的问题。 IDA*:在迭代加深的基础上引入估价函数 h(状态)(乐观估计到目标还需的步数,不能高估),若"当前深度 + h > limit"直接剪枝,比裸 IDDFS 快得多。经典应用:八数码、十五数码、埃及分数。属于进阶内容,本书不展开完整例题,感兴趣可参考洛谷 P1379(八数码)与 UVA 1343。


4.7 三分搜索

4.7.1 核心思想

二分处理的是单调函数,三分处理的是单峰函数:函数在 [l, r] 上先严格递增、到达峰值后严格递减(求最小值时相反)。此时中点两侧无法用"大于/小于目标"来判断方向,改为取两个三等分点 m1 < m2 比较函数值:

  • f(m1) < f(m2):峰值一定不在 [l, m1],舍弃左段
  • f(m1) >= f(m2):峰值一定不在 [m2, r],舍弃右段

每轮把区间缩为原来的 2/3,复杂度 O(log((r-l)/ε))(每轮 2 次函数求值)。

graph LR
    A["单峰函数 [l, r]"] --> B["取三等分点 m1, m2"]
    B --> C{"f(m1) < f(m2)?"}
    C -->|是| D["峰在 (m1, r],令 l = m1"]
    C -->|否| E["峰在 [l, m2),令 r = m2"]
    D --> B
    E --> B

4.7.2 整数三分模板

// 整数三分:f 在 [l, r] 上严格单峰(先增后减),求最大值点
long long ternarySearch(long long l, long long r) {
    while (r - l > 2) {                    // 区间缩到长度 <= 3 时停止
        long long m1 = l + (r - l) / 3;
        long long m2 = r - (r - l) / 3;
        if (f(m1) < f(m2)) l = m1 + 1;     // 峰一定在 (m1, r]
        else r = m2 - 1;                    // 峰一定在 [l, m2)
    }
    long long best = l;                     // 剩余小区间直接暴力
    for (long long i = l + 1; i <= r; i++)
        if (f(i) > f(best)) best = i;
    return best;
}

整数三分的另一种写法

严格单峰(无相邻相等值)时也可以二分"上坡/下坡":比较 f(mid)f(mid + 1),若 f(mid) < f(mid + 1) 说明 mid 在上坡段,l = mid + 1;否则 r = mid。循环到 l == r 即为峰值点,每轮只求值 2 次且代码更短。

4.7.3 浮点三分模板

// 浮点三分:求单峰函数 f 在 [l, r] 上的最大值点
double ternarySearchReal(double l, double r) {
    while (r - l > 1e-7) {          // 精度按题目要求调整,也可固定循环 200 次
        double m1 = l + (r - l) / 3;
        double m2 = r - (r - l) / 3;
        if (f(m1) < f(m2)) l = m1;
        else r = m2;
    }
    return l;
}

4.7.4 例题:洛谷 P3382 【模板】三分

题意:给出一个 N 次函数(以系数形式给出,N ≤ 13)以及区间 [l, r],保证函数在 [l, r] 内单峰(先增后减),求峰值点 x(误差不超过 10^-5)。

#include <bits/stdc++.h>
using namespace std;

int n;
double c[15];  // 多项式系数(从最高次到常数项)

// 秦九韶算法求 f(x)
double f(double x) {
    double res = 0;
    for (int i = 0; i <= n; i++) res = res * x + c[i];
    return res;
}

int main() {
    double l, r;
    cin >> n >> l >> r;
    for (int i = 0; i <= n; i++) cin >> c[i];

    while (r - l > 1e-7) {              // 浮点三分:区间每轮缩为 2/3
        double m1 = l + (r - l) / 3;
        double m2 = r - (r - l) / 3;
        if (f(m1) < f(m2)) l = m1;      // 极大值点在 [m1, r]
        else r = m2;                     // 极大值点在 [l, m2]
    }
    printf("%.5f\n", l);
    return 0;
}

复杂度分析:每轮区间缩为 2/3,共 O(log((r-l)/ε)) 轮,每轮求值 2 次,单次求值 O(N)。

三分的常见坑

  • 必须严格单峰:函数若有"平台段"(一段相等的值),f(m1) == f(m2) 时无法判断峰在哪一侧,三分会出错
  • 最小值时把比较方向反过来(或对 -f 求最大值)
  • 整数三分收尾时区间还剩 2 ~ 3 个点,要暴力扫一遍取最值,不能直接返回 l
  • 浮点三分不要用 l != r 作为循环条件,会死循环;用精度差或固定循环次数

4.8 折半搜索(Meet in the Middle)

4.8.1 核心思想

当 n ≈ 40 时,直接枚举全部 2^40 ≈ 10^12 个子集不可行,但 2^20 ≈ 10^6 完全可以接受。折半搜索把元素分成两半分别枚举,再把两半的结果合并

\[ 2^{40} \;\longrightarrow\; 2 \times 2^{20} + \text{合并代价} \]
graph TD
    A["n 个元素(n ≈ 40)"] --> B["前一半:枚举 2^(n/2) 个子集"]
    A --> C["后一半:枚举 2^(n/2) 个子集"]
    B --> D["合并:排序 + 二分 / 双指针"]
    C --> D
    D --> E["总复杂度 O(2^(n/2) · n)"]

合并的常见手段:对一半的结果排序,另一半逐个二分查找(或两半都排序后双指针扫描)。

4.8.2 例题:洛谷 P4799 [CEOI2015 Day2] 世界冰球锦标赛

题意:有 n 场比赛(n ≤ 40),第 i 场票价为 a_i,预算为 M(0 ≤ M ≤ 10^18)。求有多少种观赛方案(子集,可以为空),使得总花费不超过 M。

思路:分成前后两半分别枚举所有子集和;对后一半的子集和排序,对前一半的每个和 s,用 upper_bound 统计后一半中和 ≤ M - s 的个数并累加。

#include <bits/stdc++.h>
using namespace std;

int n;
long long m, a[45];
vector<long long> sumL, sumR;

// 枚举下标区间 [l, r] 内所有子集的和,存入 res
void enumerateHalf(int l, int r, vector<long long>& res) {
    int len = r - l + 1;
    for (int s = 0; s < (1 << len); s++) {
        long long sum = 0;
        for (int i = 0; i < len; i++)
            if (s >> i & 1) sum += a[l + i];
        res.push_back(sum);
    }
}

int main() {
    cin >> n >> m;
    for (int i = 0; i < n; i++) cin >> a[i];

    int mid = n / 2;
    enumerateHalf(0, mid - 1, sumL);   // 前一半:2^(n/2) 个子集和
    enumerateHalf(mid, n - 1, sumR);   // 后一半
    sort(sumR.begin(), sumR.end());    // 对一半排序,另一半逐个二分

    long long ans = 0;
    for (long long s : sumL) {
        if (s > m) continue;
        // 右半部分中和 <= m - s 的子集个数
        ans += upper_bound(sumR.begin(), sumR.end(), m - s) - sumR.begin();
    }
    cout << ans << endl;
    return 0;
}

样例:n=5, M=1000,票价 {100, 1500, 500, 500, 1000},输出 8(含空集)。

复杂度分析:枚举两半 O(2^(n/2) · n/2),排序 O(2^(n/2) · n/2),合并 O(2^(n/2) · n/2),总计 O(2^(n/2) · n);空间 O(2^(n/2))。

折半搜索的常见坑

  • 答案会爆 int:方案数最多 2^40,必须用 long long;子集和也可能超出 int 范围
  • 枚举子集时 1 << len 在 len ≥ 31 时溢出,分半后 len ≤ 20 才安全——这正是"必须折半"的原因之一
  • 空集(和为 0)通常也算一种方案,两半的枚举都要从 s = 0 开始
  • 合并方式取决于问题:计数用排序 + 二分/双指针,求最接近某值用 lower_bound,判存在性用哈希表

综合例题

例题 1:单词接龙(BFS)

问题:给定两个单词 beginWordendWord,以及一个字典,每次只能变换一个字母,求从 beginWordendWord 的最短转换序列长度。

#include <bits/stdc++.h>
using namespace std;

int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
    unordered_set<string> dict(wordList.begin(), wordList.end());
    if (!dict.count(endWord)) return 0;

    queue<string> q;
    q.push(beginWord);
    unordered_set<string> visited;
    visited.insert(beginWord);
    int steps = 1;

    while (!q.empty()) {
        int size = q.size();
        for (int i = 0; i < size; i++) {
            string word = q.front();
            q.pop();

            // 尝试修改每个字符
            for (int j = 0; j < word.size(); j++) {
                char original = word[j];
                for (char c = 'a'; c <= 'z'; c++) {
                    word[j] = c;
                    if (word == endWord) return steps + 1;
                    if (dict.count(word) && !visited.count(word)) {
                        visited.insert(word);
                        q.push(word);
                    }
                }
                word[j] = original;  // 恢复
            }
        }
        steps++;
    }
    return 0;
}

例题 2:岛屿数量(DFS / Flood Fill)

#include <bits/stdc++.h>
using namespace std;

class Solution {
public:
    int dx[4] = {-1, 1, 0, 0};
    int dy[4] = {0, 0, -1, 1};

    void dfs(vector<vector<char>>& grid, int x, int y) {
        int n = grid.size(), m = grid[0].size();
        if (x < 0 || x >= n || y < 0 || y >= m || grid[x][y] != '1')
            return;

        grid[x][y] = '0';  // 标记为已访问(沉岛)

        for (int d = 0; d < 4; d++)
            dfs(grid, x + dx[d], y + dy[d]);
    }

    int numIslands(vector<vector<char>>& grid) {
        int n = grid.size(), m = grid[0].size();
        int count = 0;

        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == '1') {
                    count++;
                    dfs(grid, i, j);  // 沉掉整个岛屿
                }
            }
        }
        return count;
    }
};

练习题

LeetCode 暑假 - DFS & BFS

题号 题目 难度 链接 完成
100 Same Tree Easy 链接 - [ ]
104 Maximum Depth of Binary Tree Easy 链接 - [ ]
101 Symmetric Tree Easy 链接 - [ ]
897 Increasing Order Search Tree Easy 链接 - [ ]
938 Range Sum of BST Easy 链接 - [ ]
997 Find the Town Judge Easy 链接 - [ ]
463 Island Perimeter Easy 链接 - [ ]
733 Flood Fill Easy 链接 - [ ]
441 Arranging Coins Easy 链接 - [ ]
1022 Sum of Root To Leaf Binary Numbers Easy 链接 - [ ]
69 Sqrt(x) Easy 链接 - [ ]
399 Evaluate Division Medium 链接 - [ ]
529 Minesweeper Medium 链接 - [ ]
419 Battleships in a Board Medium 链接 - [ ]
107 Binary Tree Level Order Traversal II Medium 链接 - [ ]
130 Surrounded Regions Medium 链接 - [ ]
1026 Maximum Difference Between Node and Ancestor Medium 链接 - [ ]
756 Pyramid Transition Matrix Medium 链接 - [ ]
378 Kth Smallest Element in a Sorted Matrix Medium 链接 - [ ]
611 Valid Triangle Number Medium 链接 - [ ]
2616 Minimize the Maximum Difference of Pairs Medium 链接 - [ ]
126 Word Ladder II Hard 链接 - [ ]
127 Word Ladder Hard 链接 - [ ]
301 Remove Invalid Parentheses Hard 链接 - [ ]
407 Trapping Rain Water II Hard 链接 - [ ]
668 Kth Smallest Number in Multiplication Table Hard 链接 - [ ]
410 Split Array Largest Sum Hard 链接 - [ ]
753 Cracking the Safe Hard 链接 - [ ]
297 Serialize and Deserialize Binary Tree Hard 链接 - [ ]
4 Median of Two Sorted Arrays Hard 链接 - [ ]

参考解答

以下给出上表中已整理的参考题解,按 Easy / Hard 分组(题号与上表一一对应;Easy 组 10 题,Hard 组 9 题)。建议先独立完成,再展开折叠块对照。

Easy 组

LC 733. Flood Fill

题目链接

给定一个 m x n 的整数矩阵 image 表示图像,以及起始像素坐标 (sr, sc) 和新颜色值 newColor。从起始像素开始,将与起始像素颜色相同且四连通的所有像素填充为新颜色。返回修改后的图像。

💡 思路提示

经典 Flood Fill,BFS 或 DFS 都可以。从起点出发,向四个方向扩展,将所有与起始颜色相同的连通像素染成新色。注意若新色与原色相同则无需操作。时间 O(nm),空间 O(nm)。

📝 C++ 解答
class Solution {
public:
    vector<vector<int>> floodFill(vector<vector<int>>& image, int sr, int sc, int newColor) {
        int m = image.size(), n = image[0].size();
        int oldColor = image[sr][sc];
        if (oldColor == newColor) return image;
        queue<pair<int,int>> q;
        q.push({sr, sc});
        image[sr][sc] = newColor;
        int dx[] = {0, 0, 1, -1}, dy[] = {1, -1, 0, 0};
        while (!q.empty()) {
            auto [x, y] = q.front(); q.pop();
            for (int d = 0; d < 4; d++) {
                int nx = x + dx[d], ny = y + dy[d];
                if (nx >= 0 && nx < m && ny >= 0 && ny < n && image[nx][ny] == oldColor) {
                    image[nx][ny] = newColor;
                    q.push({nx, ny});
                }
            }
        }
        return image;
    }
};
// Time: O(m * n), Space: O(m * n)
LC 463. Island Perimeter

题目链接

给定一个由 0(水)和 1(陆地)组成的二维网格,网格中恰有一座岛屿(四连通)。岛屿内部没有湖泊,求岛屿的周长。

💡 思路提示

遍历每个陆地格子,对每条边检查:若相邻位置越界或为水域,则该边贡献 1 的周长。等价于每块陆地贡献 4 条边,减去与相邻陆地共享的边数。时间 O(nm),空间 O(1)。

📝 C++ 解答
class Solution {
public:
    int islandPerimeter(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size(), res = 0;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (grid[i][j] == 1) {
                    res += 4;
                    if (i > 0 && grid[i-1][j] == 1) res -= 2;
                    if (j > 0 && grid[i][j-1] == 1) res -= 2;
                }
            }
        }
        return res;
    }
};
// Time: O(m * n), Space: O(1)
LC 69. Sqrt(x)

题目链接

给定一个非负整数 x,计算并返回 x 的算术平方根的整数部分(即向下取整)。不允许使用内置指数函数和运算符。

💡 思路提示

整数二分,在 [0, x] 上找最大的 mid 使得 mid * mid <= x。注意用 long long 防止 mid * mid 溢出。时间 O(log x),空间 O(1)。

📝 C++ 解答
class Solution {
public:
    int mySqrt(int x) {
        long long lo = 0, hi = x;
        while (lo <= hi) {
            long long mid = lo + (hi - lo) / 2;
            if (mid * mid <= x) lo = mid + 1;
            else hi = mid - 1;
        }
        return hi;
    }
};
// Time: O(log x), Space: O(1)
LC 441. Arranging Coins

题目链接

你有 n 枚硬币,想要排列成阶梯形状,其中第 k 行恰好有 k 枚硬币。给定 n,求可以完整排列的行数。

💡 思路提示

二分答案,找最大的 k 使得 k*(k+1)/2 <= n。也可以用求根公式直接计算。时间 O(log n),空间 O(1)。

📝 C++ 解答
class Solution {
public:
    int arrangeCoins(int n) {
        long long lo = 1, hi = n;
        while (lo <= hi) {
            long long mid = lo + (hi - lo) / 2;
            if (mid * (mid + 1) / 2 <= n) lo = mid + 1;
            else hi = mid - 1;
        }
        return (int)hi;
    }
};
// Time: O(log n), Space: O(1)
LC 1022. Sum of Root To Leaf Binary Numbers

题目链接

给定一棵二叉树,每个节点值为 0 或 1。从根到叶的路径表示一个二进制数(最高位在根)。求所有根到叶路径所表示的二进制数之和。

💡 思路提示

DFS 遍历根到叶路径,每到一层将当前累积值左移一位并加上当前节点值。到达叶节点时将该路径对应的值累加到答案中。时间 O(n),空间 O(h)。

📝 C++ 解答
class Solution {
public:
    int sumRootToLeaf(TreeNode* root) {
        return dfs(root, 0);
    }
private:
    int dfs(TreeNode* node, int val) {
        if (!node) return 0;
        val = val * 2 + node->val;
        if (!node->left && !node->right) return val;
        return dfs(node->left, val) + dfs(node->right, val);
    }
};
// Time: O(n), Space: O(h)
LC 897. Increasing Order Search Tree

题目链接

给定一棵二叉搜索树,将其重新排列为一棵只有右子节点的递增顺序搜索树。树中最左侧的节点为根,每个节点只有右子节点。

💡 思路提示

中序遍历 BST 得到递增序列,边遍历边将节点串成右链。使用一个哨兵(dummy)节点简化操作,记录当前链尾指针。时间 O(n),空间 O(h)。

📝 C++ 解答
class Solution {
public:
    TreeNode* increasingBST(TreeNode* root) {
        TreeNode dummy(0);
        TreeNode* cur = &dummy;
        inorder(root, cur);
        return dummy.right;
    }
private:
    void inorder(TreeNode* node, TreeNode*& cur) {
        if (!node) return;
        inorder(node->left, cur);
        node->left = nullptr;
        cur->right = node;
        cur = node;
        inorder(node->right, cur);
    }
};
// Time: O(n), Space: O(h)
LC 100. Same Tree

题目链接

给定两棵二叉树的根节点 p 和 q,编写一个函数来检验它们是否相同。如果两棵树在结构上相同且节点值相同,则认为它们相同。

💡 思路提示

递归比较两棵树:两节点均为空则相同;一个为空另一个非空则不同;否则当前节点值相等且左右子树分别相同则返回 true。时间 O(min(n,m)),空间 O(h)。

📝 C++ 解答
class Solution {
public:
    bool isSameTree(TreeNode* p, TreeNode* q) {
        if (!p && !q) return true;
        if (!p || !q || p->val != q->val) return false;
        return isSameTree(p->left, q->left) && isSameTree(p->right, q->right);
    }
};
// Time: O(min(n, m)), Space: O(h)
LC 104. Maximum Depth of Binary Tree

题目链接

给定一棵二叉树的根节点,返回其最大深度。最大深度是从根节点到最远叶节点的最长路径上的节点数。

💡 思路提示

DFS 递归,当前深度 = 1 + max(左子树深度, 右子树深度)。空节点深度为 0。时间 O(n),空间 O(h)。

📝 C++ 解答
class Solution {
public:
    int maxDepth(TreeNode* root) {
        if (!root) return 0;
        return 1 + max(maxDepth(root->left), maxDepth(root->right));
    }
};
// Time: O(n), Space: O(h)
LC 101. Symmetric Tree

题目链接

给定一棵二叉树的根节点,检查它是否是镜像对称的(即其左右子树是否互为镜像)。

💡 思路提示

递归判断两棵子树是否镜像对称:左子树的左孩子与右子树的右孩子比较,左子树的右孩子与右子树的左孩子比较。时间 O(n),空间 O(h)。

📝 C++ 解答
class Solution {
public:
    bool isSymmetric(TreeNode* root) {
        return check(root->left, root->right);
    }
private:
    bool check(TreeNode* l, TreeNode* r) {
        if (!l && !r) return true;
        if (!l || !r || l->val != r->val) return false;
        return check(l->left, r->right) && check(l->right, r->left);
    }
};
// Time: O(n), Space: O(h)
LC 997. Find the Town Judge

题目链接

在小镇上有 n 个人,编号从 1 到 n。给定一个信任关系数组 trust,其中 trust[i] = [a, b] 表示 a 信任 b。找到法官:法官被其他所有人信任,且法官不信任任何人。返回法官编号,不存在则返回 -1。

💡 思路提示

用入度出度数组统计。对每对信任关系 [a, b],a 的出度加 1,b 的入度加 1。法官满足入度 = n-1 且出度 = 0。等价于对每个人维护 trustCount,信任别人则 -1,被信任则 +1,最终值为 n-1 的人即为法官。时间 O(n + m),空间 O(n)。

📝 C++ 解答
class Solution {
public:
    int findJudge(int n, vector<vector<int>>& trust) {
        vector<int> cnt(n + 1, 0);
        for (auto& t : trust) {
            cnt[t[0]]--;
            cnt[t[1]]++;
        }
        for (int i = 1; i <= n; i++) {
            if (cnt[i] == n - 1) return i;
        }
        return -1;
    }
};
// Time: O(n + m), Space: O(n)

Hard 组

LC 126. Word Ladder II

题目链接

给定 beginWord、endWord 和一个字典 wordList,找出所有从 beginWord 到 endWord 的最短转换序列。每次转换只能改变一个字母,且转换后的单词必须在字典中。

💡 思路提示

BFS 找最短路径长度并记录每层的前驱关系,然后 DFS 回溯生成所有最短路径。

📝 C++ 解答
class Solution {
public:
    vector<vector<string>> findLadders(string beginWord, string endWord, vector<string>& wordList) {
        unordered_set<string> dict(wordList.begin(), wordList.end());
        if (!dict.count(endWord)) return {};
        dict.insert(beginWord);
        unordered_map<string, vector<string>> pre;  // 前驱关系
        unordered_map<string, int> dist;            // 到 beginWord 的距离
        dist[beginWord] = 0;
        queue<string> q;
        q.push(beginWord);
        int n = beginWord.size();
        int minDist = INT_MAX;
        // BFS 构建前驱图
        while (!q.empty()) {
            string cur = q.front(); q.pop();
            int d = dist[cur];
            if (d >= minDist) continue;
            for (int i = 0; i < n; i++) {
                string next = cur;
                for (char c = 'a'; c <= 'z'; c++) {
                    next[i] = c;
                    if (!dict.count(next)) continue;
                    if (!dist.count(next)) {
                        dist[next] = d + 1;
                        pre[next].push_back(cur);
                        q.push(next);
                        if (next == endWord) minDist = d + 1;
                    } else if (dist[next] == d + 1) {
                        pre[next].push_back(cur);
                    }
                }
            }
        }
        vector<vector<string>> res;
        if (!dist.count(endWord)) return res;
        // DFS 回溯生成路径
        vector<string> path = {endWord};
        function<void(string&)> dfs = [&](string& node) {
            if (node == beginWord) {
                vector<string> tmp = path;
                reverse(tmp.begin(), tmp.end());
                res.push_back(tmp);
                return;
            }
            for (auto& p : pre[node]) {
                path.push_back(p);
                dfs(p);
                path.pop_back();
            }
        };
        dfs(endWord);
        return res;
    }
};
// Time: O(N * L * 26 + 所有最短路径数 * L),N=字典大小,L=单词长度
// Space: O(N * L)
LC 127. Word Ladder

题目链接

给定 beginWord、endWord 和一个字典 wordList,找到从 beginWord 到 endWord 的最短转换序列的长度。每次转换只能改变一个字母,且转换后的单词必须在字典中。

💡 思路提示

将 beginWord 入队 BFS,每次枚举每个位置替换 a-z,用哈希集合判断是否在字典中。找到 endWord 即返回层数。

📝 C++ 解答
class Solution {
public:
    int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
        unordered_set<string> dict(wordList.begin(), wordList.end());
        if (!dict.count(endWord)) return 0;
        queue<string> q;
        q.push(beginWord);
        int steps = 1, n = beginWord.size();
        while (!q.empty()) {
            int sz = q.size();
            while (sz--) {
                string cur = q.front(); q.pop();
                for (int i = 0; i < n; i++) {
                    char orig = cur[i];
                    for (char c = 'a'; c <= 'z'; c++) {
                        if (c == orig) continue;
                        cur[i] = c;
                        if (cur == endWord) return steps + 1;
                        if (dict.count(cur)) {
                            q.push(cur);
                            dict.erase(cur);
                        }
                    }
                    cur[i] = orig;
                }
            }
            steps++;
        }
        return 0;
    }
};
// Time: O(N * L * 26),N=字典大小,L=单词长度
// Space: O(N * L)
LC 301. Remove Invalid Parentheses

题目链接

给定一个包含括号和小写字母的字符串,删除最少数量的无效括号,使得字符串有效。返回所有可能的有效字符串。

💡 思路提示

BFS:逐层删除字符,找到第一个合法字符串即为最少删除次数。或 DFS + 剪枝:先计算需要删除的左右括号数,再在搜索中剪枝。

📝 C++ 解答
class Solution {
public:
    vector<string> removeInvalidParentheses(string s) {
        // 先计算需要删除的左右括号数
        int l = 0, r = 0;
        for (char c : s) {
            if (c == '(') l++;
            else if (c == ')') {
                if (l > 0) l--;
                else r++;
            }
        }
        vector<string> res;
        unordered_set<string> vis;
        function<void(int, int, int, int, int, string&)> dfs =
            [&](int i, int cl, int cr, int dl, int dr, string& cur) {
            if (i == (int)s.size()) {
                if (dl == 0 && dr == 0) res.push_back(cur);
                return;
            }
            // 剪枝:剩余字符不足以删除完
            if ((int)s.size() - i < dl + dr) return;
            char c = s[i];
            if (c == '(') {
                // 删除这个 '('
                if (dl > 0) dfs(i + 1, cl, cr, dl - 1, dr, cur);
                // 保留
                cur.push_back(c);
                dfs(i + 1, cl + 1, cr, dl, dr, cur);
                cur.pop_back();
            } else if (c == ')') {
                // 删除这个 ')'
                if (dr > 0) dfs(i + 1, cl, cr, dl, dr - 1, cur);
                // 保留(必须有匹配的左括号)
                if (cl > cr) {
                    cur.push_back(c);
                    dfs(i + 1, cl, cr + 1, dl, dr, cur);
                    cur.pop_back();
                }
            } else {
                cur.push_back(c);
                dfs(i + 1, cl, cr, dl, dr, cur);
                cur.pop_back();
            }
        };
        string cur;
        dfs(0, 0, 0, l, r, cur);
        return res;
    }
};
// Time: O(2^N * N),N=字符串长度
// Space: O(N)
LC 407. Trapping Rain Water II

题目链接

给定一个 m x n 的整数矩阵 heightMap,其中 heightMap[i][j] 表示单元格的高度,计算下雨后该地形能接多少体积的雨水。

💡 思路提示

将四周的格子加入最小堆,每次取出最低的,向内扩展,能接的水 = max(堆顶高度, 当前格子高度) - 当前格子高度。类似二维版本的木桶原理。

📝 C++ 解答
class Solution {
public:
    int trapRainWater(vector<vector<int>>& heightMap) {
        int m = heightMap.size(), n = heightMap[0].size();
        if (m < 3 || n < 3) return 0;
        using T = tuple<int,int,int>;
        priority_queue<T, vector<T>, greater<T>> pq;
        vector<vector<bool>> vis(m, vector<bool>(n, false));
        // 四周入堆
        for (int i = 0; i < m; i++) {
            pq.push({heightMap[i][0], i, 0});
            pq.push({heightMap[i][n-1], i, n-1});
            vis[i][0] = vis[i][n-1] = true;
        }
        for (int j = 1; j < n - 1; j++) {
            pq.push({heightMap[0][j], 0, j});
            pq.push({heightMap[m-1][j], m-1, j});
            vis[0][j] = vis[m-1][j] = true;
        }
        int res = 0, level = 0;
        int dx[] = {0, 0, 1, -1}, dy[] = {1, -1, 0, 0};
        while (!pq.empty()) {
            auto [h, x, y] = pq.top(); pq.pop();
            level = max(level, h);
            for (int d = 0; d < 4; d++) {
                int nx = x + dx[d], ny = y + dy[d];
                if (nx < 0 || nx >= m || ny < 0 || ny >= n || vis[nx][ny]) continue;
                vis[nx][ny] = true;
                if (heightMap[nx][ny] < level)
                    res += level - heightMap[nx][ny];
                pq.push({heightMap[nx][ny], nx, ny});
            }
        }
        return res;
    }
};
// Time: O(M * N * log(M * N))
// Space: O(M * N)
LC 668. Kth Smallest Number in Multiplication Table

题目链接

给定 m 行 n 列的乘法表(第 i 行第 j 列为 i*j),返回乘法表中第 k 小的数。

💡 思路提示

二分答案 mid,统计乘法表中 <= mid 的数的个数(每行 min(mid/i, n)),根据个数与 k 的关系调整区间。

📝 C++ 解答
class Solution {
public:
    int findKthNumber(int m, int n, int k) {
        int lo = 1, hi = m * n;
        while (lo < hi) {
            int mid = lo + (hi - lo) / 2;
            int cnt = 0;
            for (int i = 1; i <= m; i++)
                cnt += min(mid / i, n);
            if (cnt < k) lo = mid + 1;
            else hi = mid;
        }
        return lo;
    }
};
// Time: O(M * log(M * N))
// Space: O(1)
LC 410. Split Array Largest Sum

题目链接

给定一个非负整数数组 nums 和一个整数 k,将数组分成 k 个非空连续子数组,使得这 k 个子数组各自和的最大值最小。返回该最小的最大值。

💡 思路提示

二分每段和的上限 mid,贪心判断能否在 k 段内分完(尽量让每段和不超过 mid)。下界为 max(nums),上界为 sum(nums)。

📝 C++ 解答
class Solution {
public:
    int splitArray(vector<int>& nums, int k) {
        long long lo = *max_element(nums.begin(), nums.end());
        long long hi = accumulate(nums.begin(), nums.end(), 0LL);
        while (lo < hi) {
            long long mid = lo + (hi - lo) / 2;
            int cnt = 1;
            long long sum = 0;
            for (int x : nums) {
                if (sum + x > mid) { cnt++; sum = 0; }
                sum += x;
            }
            if (cnt <= k) hi = mid;
            else lo = mid + 1;
        }
        return lo;
    }
};
// Time: O(N * log(S)),S = sum(nums)
// Space: O(1)
LC 753. Cracking the Safe

题目链接

给定 n 位密码和 k 个数字(0 到 k-1),找到一个最短的字符串,使得所有 k^n 种可能的密码组合都作为该字符串的连续子串出现。

💡 思路提示

将 n-1 位字符串视为节点,添加一位数字视为边(构成 n 位密码),构造欧拉回路(Hierholzer 算法),使每条边恰好经过一次。

📝 C++ 解答
class Solution {
public:
    string crackSafe(int n, int k) {
        int tot = pow(k, n);  // 边的总数 = k^n
        string start(n - 1, '0');
        string res = start;
        unordered_set<string> vis;
        function<bool(string&)> dfs = [&](string& node) {
            if ((int)vis.size() == tot) return true;
            string tmp = node;
            for (char c = '0'; c < '0' + k; c++) {
                string edge = node + c;
                if (vis.count(edge)) continue;
                vis.insert(edge);
                string next = edge.substr(1);
                res.push_back(c);
                if (dfs(next)) return true;
                res.pop_back();
                vis.erase(edge);
            }
            return false;
        };
        dfs(start);
        return res;
    }
};
// Time: O(k^n * n),需要遍历所有 k^n 条边
// Space: O(k^n * n),哈希集合存储所有边
LC 297. Serialize and Deserialize Binary Tree

题目链接

设计一个算法将二叉树序列化为字符串,并能从该字符串反序列化出原始二叉树结构。不限制具体序列化格式。

💡 思路提示

序列化时 BFS 层序遍历,用特殊字符(如 "#")表示空节点;反序列化时逐层还原,用队列维护父节点。

📝 C++ 解答
class Codec {
public:
    string serialize(TreeNode* root) {
        if (!root) return "";
        string res;
        queue<TreeNode*> q;
        q.push(root);
        while (!q.empty()) {
            TreeNode* t = q.front(); q.pop();
            if (!t) { res += "#,"; continue; }
            res += to_string(t->val) + ",";
            q.push(t->left);
            q.push(t->right);
        }
        return res;
    }

    TreeNode* deserialize(string data) {
        if (data.empty()) return nullptr;
        vector<string> nodes;
        istringstream ss(data);
        string token;
        while (getline(ss, token, ',')) nodes.push_back(token);
        TreeNode* root = new TreeNode(stoi(nodes[0]));
        queue<TreeNode*> q;
        q.push(root);
        int i = 1;
        while (!q.empty() && i < (int)nodes.size()) {
            TreeNode* cur = q.front(); q.pop();
            if (nodes[i] != "#") {
                cur->left = new TreeNode(stoi(nodes[i]));
                q.push(cur->left);
            }
            i++;
            if (i < (int)nodes.size() && nodes[i] != "#") {
                cur->right = new TreeNode(stoi(nodes[i]));
                q.push(cur->right);
            }
            i++;
        }
        return root;
    }
};
// Time: O(N)
// Space: O(N)
LC 4. Median of Two Sorted Arrays

题目链接

给定两个大小分别为 m 和 n 的有序数组 nums1 和 nums2,找出这两个有序数组的中位数。要求时间复杂度为 O(log(m+n))。

💡 思路提示

在较短数组上二分切割位置 i,使左半部分长度 = (m+n+1)/2,通过比较 nums1[i-1]、nums2[j-1]、nums1[i]、nums2[j] 确保左半部分最大值 <= 右半部分最小值。

📝 C++ 解答
class Solution {
public:
    double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
        if (nums1.size() > nums2.size()) swap(nums1, nums2);
        int m = nums1.size(), n = nums2.size();
        int lo = 0, hi = m, half = (m + n + 1) / 2;
        while (lo <= hi) {
            int i = lo + (hi - lo) / 2;  // nums1 左半部分取 i 个
            int j = half - i;             // nums2 左半部分取 j 个
            int l1 = (i == 0) ? INT_MIN : nums1[i - 1];
            int r1 = (i == m) ? INT_MAX : nums1[i];
            int l2 = (j == 0) ? INT_MIN : nums2[j - 1];
            int r2 = (j == n) ? INT_MAX : nums2[j];
            if (l1 <= r2 && l2 <= r1) {
                if ((m + n) % 2 == 1) return max(l1, l2);
                return (max(l1, l2) + min(r1, r2)) / 2.0;
            } else if (l1 > r2) {
                hi = i - 1;
            } else {
                lo = i + 1;
            }
        }
        return 0;
    }
};
// Time: O(log(min(m, n)))
// Space: O(1)

ACM Day1 - 二分部分

题号 平台 题目 难度 链接 完成
1907D CF Jumping Through Segments 1400 链接 - [ ]
1985F CF Final Boss 1500 链接 - [ ]
1971E CF Find the Car 1500 链接 - [ ]
2009E CF Klee's SUPER DUPER LARGE Array!!! 1400 链接 - [ ]

寒假二分 / 搜索

题号 题目 难度 链接 完成
374 Guess Number Higher or Lower Easy 链接 - [ ]
349 Intersection of Two Arrays Easy 链接 - [ ]
190 Reverse Bits Easy 链接 - [ ]
191 Number of 1 Bits Easy 链接 - [ ]
200 Number of Islands Medium 链接 - [ ]
543 Diameter of Binary Tree Easy 链接 - [ ]

本章小结

mindmap
  root((搜索与二分))
    二分查找
      整数二分
        左边界模板
        右边界模板
      浮点二分
      STL 函数
      二分答案
        最小化最大值
        最大化最小值
    三分搜索
      整数三分
      浮点三分
    折半搜索
      两半枚举
      排序合并
    BFS
      基本模板
      网格 BFS
      多源 BFS
      最短路径
      0-1 BFS
      双向 BFS
    DFS
      递归模板
      迭代模板
      树的遍历
    Flood Fill
      DFS 实现
      BFS 实现
      数岛屿
    剪枝技巧
      可行性剪枝
      最优性剪枝
      重复性剪枝

名言

"搜索的精髓在于:在正确的地方剪掉正确的枝。" -- 理解剪枝比写出搜索更重要。

学习建议

  1. 二分模板必须背熟,特别是左边界和右边界的区别
  2. BFS 用于最短路,DFS 用于遍历和路径搜索
  3. 二分答案是竞赛高频考点,要多练习判定函数的编写
  4. Flood Fill 是网格搜索的基础,务必熟练
  5. 剪枝需要经验积累,多做题才能培养直觉