回溯、NP 完全性与近似算法 — 从零精通算法与数据结构——Google 面试系统备战 第18篇
第18章:回溯、NP 完全性与近似算法
本章目标
读完本章你会:
- 用统一的回溯框架解决排列、子集、N 皇后等经典问题
- 理解剪枝策略——如何大幅缩减搜索空间
- 分清 P / NP / NP-Complete / NP-Hard 四个复杂度类
- 解释归约(Reduction)的概念——把未知问题映射到已知问题
- 设计近似算法——当最优解太贵时,多好算"够好"
知识讲解
从一个生活例子开始
你在迷宫的入口。有 8 个岔路口,每个路口有 3 个分岔。你要找出所有通向出口的路径。
DFS + 回溯: 每到岔路口都选一条路走,标记已走过,走不通就回退(回溯),换一条路。关键优化——剪枝:如果你发现某条路进入了死循环(已走过的节点),直接放弃,不用走到底。
回溯 = 系统性地穷举所有可能,但用约束检测提前终止不可能的分支。它本质上是"聪明的暴力"——穷举但绝不浪费在一个注定失败的方向。
工作原理
18.1 回溯框架
BACKTRACK(状态, 候选集):
if 状态是解:
记录这个解
return
for 每个候选项 in 候选集:
if 候选满足约束:
将候选加入状态
BACKTRACK(新状态, 更新候选集)
将候选移除状态(回溯——撤销)
三大经典问题:
| 问题 | 状态 | 约束 | 搜索空间 |
|---|---|---|---|
| 排列 | 当前部分排列 | 元素不重复使用 | n! |
| 子集 | 当前部分子集 | 无 | 2ⁿ |
| N 皇后 | 当前棋盘放置 | 皇后互不攻击 | 约 n! |
N 皇后剪枝效率展示:
N=8 时,暴力放置空间 = C(64,8) ≈ 4.4×10⁹。加上"每行一个"约束 → 8⁸ = 1.7×10⁷。加上"不同列"约束 → 8! = 40320。加上"不同对角线"剪枝 → 实际访问约 2000 个节点。
递进剪枝使搜索空间缩小了 6 个数量级。这就是回溯 + 剪枝的力量。
18.2 P、NP 与归约
非正式定义:
| 类 | 含义 | 示例 |
|---|---|---|
| P | 能在多项式时间内求解 | 排序、最短路径、MST |
| NP | 能在多项式时间内验证给定解的正确性 | TSP(验证一条路线是否 ≤ K) |
| NP-Complete | NP 中最难的问题——任意 NP 问题能多项式归约到它 | 3-SAT、顶点覆盖、哈密顿回路 |
| NP-Hard | 至少和 NP-Complete 一样难,不一定属于 NP | TSP 优化版(找最短路线) |
归约(Reduction): 把问题 A 的实例多项式时间转换成问题 B 的实例,使得解 B 等价于解 A。
已知 SAT 是 NP-Complete:
SAT → 3-SAT (限制每个 clause 恰好 3 个 literal)
3-SAT → CLIQUE (构图——literals 为节点,边表示兼容)
CLIQUE → VERTEX-COVER (补图的最大团 → 最小顶点覆盖)
因此 3-SAT, CLIQUE, VERTEX-COVER 都是 NP-Complete。
面试中不需要手写归约,但需要知道: 如果一个问题被证明是 NP-Complete,那它极可能没有多项式时间解——不要追求最优算法,考虑近似或启发式。
18.3 近似算法
当问题 NP 难时,退而求其次——找一个"不差于最优解 c 倍"的解。
顶点覆盖的 2-近似:
近似算法(贪心):
取任意边 (u, v),将 u 和 v 都加入覆盖
删除与 u 或 v 相连的所有边
重复直到无边
最优解中每条边至少有一个端点被选 → 每条边我们选了两端点 → 最多 2× 最优
TSP 的 Christofides 1.5-近似:
1. 构建 MST
2. 对 MST 上度数为奇数的节点求最小权完美匹配
3. 合并 MST 和匹配边形成欧拉回路
4. 短路化(skip 重复访问的节点)→ 哈密顿回路
成本 ≤ MST + 匹配 ≤ OPT + 0.5·OPT = 1.5·OPT
近似策略盘点:
- 贪心: 活动选择(恰好最优)、集合覆盖(ln n 近似)
- 随机化舍入: 解 LP 松弛后随机取整
- Christofides: 结合 MST 和匹配
- PTAS: 对于任意 ε > 0,能在 n^{f(1/ε)} 内找到 (1+ε)-近似——如某些背包变体
代码实战
include/algo/backtracking.h
#ifndef ALGO_BACKTRACKING_H_
#define ALGO_BACKTRACKING_H_
#include <string>
#include <vector>
namespace algo {
// ========== N 皇后 ==========
// 返回所有解——每个解用字符串数组表示棋盘
std::vector<std::vector<std::string>> SolveNQueens(int n);
// ========== 全排列 ==========
std::vector<std::vector<int>> Permutations(std::vector<int>& nums);
// ========== 子集(幂集)==========
std::vector<std::vector<int>> Subsets(const std::vector<int>& nums);
// ========== 组合——从 n 中选 k ==========
std::vector<std::vector<int>> Combinations(int n, int k);
// ========== 顶点覆盖的 2-近似 ==========
// 返回近似的最小顶点覆盖
std::vector<int> VertexCover2Approximation(
int vertices,
const std::vector<std::pair<int, int>>& edges);
} // namespace algo
#endif // ALGO_BACKTRACKING_H_
include/algo/backtracking_impl.h(N 皇后 + 顶点覆盖)
namespace algo {
inline std::vector<std::vector<std::string>> SolveNQueens(int n) {
std::vector<std::vector<std::string>> results;
std::vector<int> queens(n, -1); // queens[row] = col
// 对角线检测:col, diag1(row-col), diag2(row+col)
std::vector<bool> col_used(n, false);
std::vector<bool> diag1(2*n-1, false);
std::vector<bool> diag2(2*n-1, false);
std::function<void(int)> backtrack = [&](int row) {
if (row == n) {
std::vector<std::string> board(n, std::string(n, '.'));
for (int r = 0; r < n; ++r) board[r][queens[r]] = 'Q';
results.push_back(board);
return;
}
for (int col = 0; col < n; ++col) {
if (!col_used[col] && !diag1[row-col+n-1] && !diag2[row+col]) {
queens[row] = col;
col_used[col] = diag1[row-col+n-1] = diag2[row+col] = true;
backtrack(row + 1);
col_used[col] = diag1[row-col+n-1] = diag2[row+col] = false;
}
}
};
backtrack(0);
return results;
}
inline std::vector<std::vector<int>> Permutations(std::vector<int>& nums) {
std::vector<std::vector<int>> results;
std::vector<bool> used(nums.size(), false);
std::vector<int> current;
std::function<void()> backtrack = [&]() {
if (current.size() == nums.size()) {
results.push_back(current);
return;
}
for (std::size_t i = 0; i < nums.size(); ++i) {
if (used[i]) continue;
used[i] = true;
current.push_back(nums[i]);
backtrack();
current.pop_back();
used[i] = false;
}
};
backtrack();
return results;
}
inline std::vector<int> VertexCover2Approximation(
int vertices,
const std::vector<std::pair<int, int>>& edges) {
std::vector<std::vector<int>> adj(vertices);
for (auto& [u, v] : edges) {
adj[u].push_back(v);
adj[v].push_back(u);
}
std::vector<bool> in_cover(vertices, false);
auto remaining = edges; // 复制边列表(实际可用链表优化)
while (!remaining.empty()) {
auto [u, v] = remaining.back();
remaining.pop_back();
if (!in_cover[u] && !in_cover[v]) {
in_cover[u] = in_cover[v] = true;
}
}
std::vector<int> result;
for (int i = 0; i < vertices; ++i) {
if (in_cover[i]) result.push_back(i);
}
return result;
}
} // namespace algo
回溯代码的通用模式: 递归 → 检测是否为解 → 遍历候选 → 约束检查 → 递归 → 撤销状态(回溯)。撤销是关键步骤——忘记撤销是回溯最常见的 bug。
本章小结
- 回溯 = DFS + 约束剪枝 + 状态撤销,是"聪明的穷举"
- P ⊂ NP,NP-Complete 是 NP 中最难的问题,归约是证明 NP-Complete 的标准工具
- NP-Hard 至少和 NP-Complete 一样难,不一定在 NP 内
- 近似算法在最优解代价太高时提供可证明的近似保证
- 顶点覆盖 2-近似、Christofides TSP 1.5-近似是近似算法的经典案例
关键术语
| 术语 | 释义 |
|---|---|
| 回溯 | 系统穷举 + 约束剪枝的搜索框架,DFS + 状态撤销 |
| P | 能在多项式时间求解的问题类 |
| NP | 解能在多项式时间验证的问题类 |
| NP-Complete | NP 中最难的问题——任意 NP 问题可归约到它 |
| 归约 | 将一个问题的实例多项式时间转化为另一问题的实例 |
| 近似算法 | 保证解的质量不差于最优解 c 倍的算法 |

浙公网安备 33010602011771号