回溯算法(Backtracking)一直是算法面试中的"拦路虎"。很多时候我们能写出递归的大致框架,但往往在**状态重置(恢复现场)**的细节上栽跟头。
今天通过两道经典的 LeetCode 题目------分割回文串 和N 皇后 ,来深入探讨回溯算法的通用模板,并重点解决一个核心痛点:到底什么时候在 for 循环里面恢复现场,什么时候在外面?
一、 分割回文串 (Palindrome Partitioning)
这道题是典型的"切割"问题。给定一个字符串,我们需要把它切成若干块,要求每一块都是回文串。
1. 解题思路
我们可以把字符串想象成一根长条面包,我们手里的刀就是变量 j。
-
横向遍历 (for 循环):决定当前这一刀切在哪里(切 1 个字?切 2 个字?...)。
-
纵向递归 (dfs):切完这一刀后,剩下的字符串交给下一层递归去继续切。
-
合法性判断:只有当切下来的这一块是回文串时,才允许进入下一层。
2. 代码实现
C++代码实现:
cpp
class Solution {
// 思路: i作为分割的起点,j作为分割的落点, 剩下的j+1到end我们交给下一层dfs去处理
vector<vector<string>> ans;
// 判断是否为回文串的双指针法
bool isTrue(string& s, int left, int right) {
while (left < right) {
if (s[left++] != s[right--]) {
return false;
}
}
return true;
}
void dfs(string& s, vector<string>& vals, int i, int n) {
// Base Case: i 走到了 n,说明字符串已经全部切割完毕
if (i == n) {
ans.push_back(vals);
return;
}
// j 代表当前这一刀切在哪个位置(闭区间 [i, j])
for (int j = i; j < n; j++) {
// 单个数字的分割一定是符合的比如a, b, c,也就是j = i的时候
// 只有当前切出来的子串 s[i...j] 是回文,才继续往下递归
if (isTrue(s, i, j)) {
// string substr (size_t pos = 0, size_t len = npos) const; 参数二是长度
// 计算长度: j - i + 1
vals.push_back(s.substr(i, j - i + 1)); // 做选择
dfs(s, vals, j + 1, n); // 递归:剩下的交给下一层
vals.pop_back(); // 撤销选择:回溯,尝试下一种切法
}
}
}
public:
vector<vector<string>> partition(string s) {
vector<string> vals;
int n = s.size();
dfs(s, vals, 0, n);
return ans;
}
};
3. 时空复杂度分析
-
时间复杂度:O(N * 2^N)。
-
在最坏情况下(比如字符串全是同一个字符 "aaaa"),每个字符之间的空隙都可以切或者不切,共有 2^(N-1) 种切割方案。
-
每一种方案我们需要 O(N) 的时间去构造子串和判断回文。
-
-
空间复杂度:O(N)。
- 递归调用栈的最大深度为 N,且用于存储路径的
vals数组最多也存储 N 个字符串。
- 递归调用栈的最大深度为 N,且用于存储路径的
二、 N 皇后 (N-Queens)
这是回溯算法的巅峰之作。我们需要在 N*N 的棋盘上放置 N 个皇后,使得它们不能互相攻击(同行、同列、同对角线)。
1. 解题思路
这道题本质上是一个全排列问题。因为每一行只能放一个皇后,我们只需要决定每一行的皇后放在哪一列。 为了将冲突判断从 O(N) 优化到 O(1),我们使用了三个辅助数组:
-
col[j]:标记第 j 列是否占用。
-
diag1[i + j] :标记副对角线(/ 方向)。规律是
row + col为定值。 -
diag2[i - j + n - 1] :标记主对角线(\ 方向)。规律是
row - col为定值(加偏移量 n-1 防止负数)。
2. 代码实现
C++代码实现:
cpp
class Solution {
// 思路: 按行去遍历,然后标记列重复,主副对角线重复
vector<vector<string>> ans;
// i: 当前正在处理第几行
void dfs(int i, int n, vector<string>& path, vector<int>& col, vector<int>& diag1, vector<int>& diag2) {
// Base Case: i == n 说明 N 行都放好了
if (i == n) {
ans.push_back(path);
return;
}
// 尝试当前行的每一列 j
for (int j = 0; j < n; j++) {
int d1 = i + j; // 副对角线索引
int d2 = i - j + n - 1; // 主对角线索引
// 剪枝:如果列、主对角线、副对角线任意一个有冲突,跳过
if (col[j] == 1 || diag1[d1] == 1 || diag2[d2] == 1) {
continue;
}
// 做选择
path[i][j] = 'Q';
col[j] = diag1[d1] = diag2[d2] = 1; // 标记占用
// 进入下一行
dfs(i + 1, n, path, col, diag1, diag2);
// 撤销选择 (恢复现场)
// 必须在 for 循环内部恢复,否则会影响下一次循环对 j+1 列的尝试
col[j] = diag1[d1] = diag2[d2] = 0; // 解除占用
path[i][j] = '.';
}
}
public:
vector<vector<string>> solveNQueens(int n) {
vector<string> path(n, string(n, '.'));
vector<int> col(n, 0);
// 对角线数组大小需要 2*n,防止越界
vector<int> diag1(2*n);
vector<int> diag2(2*n);
dfs(0, n, path, col, diag1, diag2);
return ans;
}
};
3. 时空复杂度分析
-
时间复杂度:O(N!)。
- 第一行有 N 种选法,第二行有 N-1 种... 近似于 N 的阶乘。实际因为剪枝的存在,运行速度会远快于 N!。
-
空间复杂度:O(N)。
- 递归深度为 N,辅助数组 path, col, diag 的空间也是 O(N)。
三、 核心探讨:恢复现场,到底在 For 循环里还是外?
这是很多同学最容易混淆的地方。观察上面的两段代码,你会发现:它们的 pop_back 或者状态重置,都写在了 for 循环里面(递归函数的后面)。
但是,如果你做过"单词搜索 (Word Search)"或者"迷宫路径",你会发现那些题的恢复现场是写在 for 循环外面的。
区别在哪里?
我总结为两种模式:"试衣间模式" vs "进屋锁门模式"。
1. 试衣间模式(恢复在 For 循环里)
适用场景 :N 皇后、全排列、分割回文串、组合总和。 逻辑本质:兄弟节点之间是**互斥(竞争)**关系。
想象你在试衣间试衣服(遍历 for 循环的 j):
-
你穿上红衣服(
j=0),照镜子(dfs)。 -
在你试下一件蓝衣服(
j=1)之前,你必须先把红衣服脱下来(恢复现场)。 -
如果你不脱,你身上就穿了两件衣服,这就乱套了。
所以,选一个,撤销一个,再选下一个。恢复现场必须紧跟在递归调用之后,包含在循环体内。
2. 进屋锁门模式(恢复在 For 循环外)
适用场景 :网格 DFS 搜索(Word Search)、迷宫找路。 逻辑本质:兄弟节点之间是**依存(合作)**关系。
想象你在走迷宫,你站在十字路口 A:
-
进屋 :你一旦踏上
A点,就要立刻标记A已访问(锁门)。这是为了防止你派出去的侦察兵(递归)晕头转向又走回A点。 -
派出侦察兵 :你在这个函数里写
for循环,分别向上下左右探索。 -
关键点 :当大儿子(上)探索失败回来时,你不能 恢复
A点的状态!因为二儿子(下)还没出发呢,你(父亲A)必须一直站在那里占位。 -
撤退 :只有当四个方向都试完了,证明
A点是个死胡同,你准备回退到上一个路口时,你才把A点解锁(恢复现场)。
总结口诀
-
构造参数/做选择题 (如 N 皇后、切字符串):在循环里恢复。因为每个选项是独立的,选完必须撤销才能选下一个。
-
地图游走/防止回头 (如网格 DFS):在循环外恢复。因为当前节点是所有分支的共同基石,所有分支走完前,当前节点不能松开。