回溯、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。


本章小结

  1. 回溯 = DFS + 约束剪枝 + 状态撤销,是"聪明的穷举"
  2. P ⊂ NP,NP-Complete 是 NP 中最难的问题,归约是证明 NP-Complete 的标准工具
  3. NP-Hard 至少和 NP-Complete 一样难,不一定在 NP 内
  4. 近似算法在最优解代价太高时提供可证明的近似保证
  5. 顶点覆盖 2-近似、Christofides TSP 1.5-近似是近似算法的经典案例

关键术语

术语 释义
回溯 系统穷举 + 约束剪枝的搜索框架,DFS + 状态撤销
P 能在多项式时间求解的问题类
NP 解能在多项式时间验证的问题类
NP-Complete NP 中最难的问题——任意 NP 问题可归约到它
归约 将一个问题的实例多项式时间转化为另一问题的实例
近似算法 保证解的质量不差于最优解 c 倍的算法
posted @ 2026-06-22 01:09  Yobeeo  阅读(9)  评论(0)    收藏  举报