回溯算法
回溯算法(Backtracking)是一种广泛用于解决组合问题、排列问题、路径搜索问题等的算法。
回溯法(Backtracking)是一种尝试-回退的算法思想,适用于解决需要在决策过程中试探多种可能性的问题。它在探索所有解的过程中,可以在确定某条路径不满足条件时,立即回退到上一步,从而避免不必要的计算。这种方法特别适用于以下几类场景:
它的核心思想是:尝试每一种可能的选择,并在发现当前选择不符合条件时,回退到上一步,继续尝试其他的可能性。回溯算法的关键在于“试探”和“回退”,它在求解问题的过程中能够有效地避免不必要的计算。
回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。
回溯算法框架
抽象地说,解决一个回溯问题,实际上就是遍历一棵「决策树」的过程,树的每个叶子节点存放着一个合法答案。你把整棵树遍历一遍,把叶子节点上的答案都收集起来,就能得到所有的合法答案。
站在回溯树的一个节点上,你只需要思考 3 个问题:
-
路径:也就是已经做出的选择。
-
目的:记录之前的选择,按照题目的要求来判断当前选择是否合理(比如用过的不能再用、不能和之前的处于同一列同一行)
-
图:决策树里从头节点到最底下竖着的那根都是路径(记录)。
- 全排列就是已经得到的排列。
- N皇后就是已经摆好前面Q的棋盘。
-
-
选择列表:也就是你当前可以做的选择。
-
目的:确定当前这一步可以做多少个节点。
-
图:决策树里当前节点下所有的分支都是选择。但是需要注意按照题目的限制进行「剪枝」。
- 全排列的「选择列表」就是之前还没用过的元素。
- N皇后就是这一行的每一列。
-
工作顺序:选择完这一个之后就顺着「路径」往下选择,对应决策树里的一根树枝,等价于树的递归遍历。
-
如何选择:遍历选择列表,判断是否满足要求。例如,是棋盘就遍历当前行的每一列,全排列就是遍历所有元素,但是都要判断当前元素(当前列)是否满足要求。
-
-
结束条件:也就是到达决策树底层,无法再做选择的条件。、
回溯算法就是个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做一些操作,算法框架如下
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
# 做选择
将该选择从选择列表移除
路径.add(选择)
backtrack(路径, 选择列表)
# 撤销选择
路径.remove(选择)
将该选择再加入选择列表
适用场景
-
适用场景
1. 组合问题
- 需要从一个集合中选择出满足特定条件的所有组合。
- 例子:子集生成、组合求和、排列生成等。例如,从集合
{1, 2, 3}中生成所有子集,或求解某个和为指定数的组合。
2. 排列问题
- 需要找出某个集合或数组的所有排列方式。
- 例子:全排列问题。在给定数组
[1, 2, 3]中,找出所有可能的排列方式。
3. 划分问题
- 将一个集合或数组划分成满足特定条件的子集。
- 例子:将一个数组划分成两个和相等的子集,或划分为每部分和相同的多个子集。
4. 路径搜索问题
- 在图、网格或树结构中寻找所有可能的路径或特定条件的路径。
- 例子:迷宫问题(找到所有可行路径),N皇后问题(在N×N棋盘上放置N个皇后,使它们互不攻击),八数码问题等。
5. 数独与填充问题
- 需要按照规则填充数独棋盘、填字游戏等,需要逐步试探填充的方案。
- 例子:经典的数独问题,根据已知数字在9×9棋盘上填满数字,使每行、每列和每个3×3的小宫格内都含有1到9不重复的数字。
6. 分配问题
- 将若干元素分配到不同位置或组合,使得满足特定条件。
- 例子:背包问题的分配,或将任务分配给工人以最小化时间、成本等。
7. 约束满足问题(CSP)
- 需要在满足多个约束条件的前提下找到解答。
- 例子:八皇后问题、图的着色问题、逻辑谜题等。这类问题通常需要满足不同的限制条件,回溯法可以逐步试探解空间,直到找到符合条件的解。
8. 分支限界问题
- 这类问题通常使用回溯结合分支限界(Branch and Bound)方法来减少搜索空间。
- 例子:旅行商问题(TSP)、最小生成树等。这些问题的解法通常会将解空间分割成多个部分,在回溯时通过限界条件剪枝,减少不必要的计算。
回溯算法的经典例题
1. 全排列问题
全排列问题要求从一个数组中找出所有可能的排列方式。假设数组是 [1, 2, 3],我们需要输出所有的排列。
思路:
-
路径:记录当前已选的元素。
-
选择列表:当前未选的元素。
-
结束条件:路径的长度等于数组长度时,即完成一次排列。
代码实现:
class Solution {
private:
vector<vector<int>> res;
public:
vector<vector<int>> permute(vector<int>& nums) {
vector<int> track;
vector<bool> used(nums.size(), false);
backtrack(nums, track, used);
return res;
}
void backtrack(vector<int>& nums, vector<int> &track, vector<bool>& used) {
if (track.size() == nums.size()) {
res.push_back(track);
return;
}
for (int i = 0; i < nums.size(); i++) {
if (used[i]) continue;
used[i] = true;
track.push_back(nums[i]);
backtrack(nums, track, used);
used[i] = false;
track.pop_back();
}
}
};
2. N 皇后问题
N 皇后问题要求在一个 N×N 的棋盘上放置 N 个皇后,使得它们互不攻击。
思路:
-
路径:记录当前棋盘上每一行已放置的皇后。
-
选择列表:当前行的所有列。
-
结束条件:所有行都已放置完皇后。
代码实现:
class Solution {
public:
vector<vector<string>> results;
vector<vector<string>> solveNQueens(int n) {
string line(n, '.');
vector<string> board(n, line);
backtrack(board, 0);
return results;
}
void backtrack(vector<string>& board, int row) {
if (row == board.size()) {
results.push_back(board);
return;
}
for (int col = 0; col < board.size(); col++) {
if (!is_valid(board, row, col)) continue;
board[row][col] = 'Q';
backtrack(board, row + 1);
board[row][col] = '.'; // 撤销选择
}
}
bool is_valid(vector<string>& board, int row, int col) {
// 检查同一列是否已有皇后
for (int i = 0; i < row; i++) {
if (board[i][col] == 'Q') return false;
}
// 检查左上对角线
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (board[i][j] == 'Q') return false;
}
// 检查右上对角线
for (int i = row - 1, j = col + 1; i >= 0 && j < board.size(); i--, j++) {
if (board[i][j] == 'Q') return false;
}
return true;
}
};
总结
回溯算法通过递归的方式探索所有可能的解,并通过剪枝避免无效的计算。它广泛应用于组合、排列、路径搜索等问题,能够有效解决许多复杂的约束满足问题。在实际应用中,回溯算法虽然是暴力穷举的方式,但通过合理的剪枝策略,可以在一定程度上优化搜索效率。