数据结构 算法 回溯算法 苏丙榅 2026-03-10 2026-03-12 1. 回溯算法概述 回溯算法(backtracking algorithm)是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,直到找到解或者尝试了所有可能的选择都无法找到解为止。这种走不通就退回再走的技术称为回溯法,它是一种“优化的暴力搜索”,而满足回溯条件的某个状态的点称为“回溯点”。
回溯算法通常采用深度优先搜索 来遍历解空间,前序、中序和后序遍历都属于深度优先搜索。回溯算法的核心思想有两点:
深度优先搜索 :回溯算法基于深度优先搜索的思想,它会沿着树的深度遍历树的节点,尽可能深的搜索树的分支。当节点所在的路径无法得到有效的解时,算法会回溯到上一个节点,尝试其他的路径继续搜索。
穷举与剪枝 :本质上是对所有可能的解进行穷举搜索,但与纯粹的暴力枚举不同,回溯算法会在搜索过程中根据问题的约束条件进行剪枝操作,避免对一些明显不可能产生解的分支进行搜索,从而提高搜索效率。
1.1 子集树 子集树解决的是选还是不选的问题,也就是组合问题 。元素的顺序不重要,只关心包含了谁 。
节点的含义:到达树的第i层 ,意味着我们正在考虑第i个 物品。对于每个物品,只有两条路:
要它(左子树)。
不要它(右子树)。
树的形状:这是一棵二叉树 。因为每个节点只有选 和不选 两个分支。
图解示例 (从集合 [A, B, C] 中选,求所有子集):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 {} (根节点:还没开始选) / \ 选A 不选A / \ {A} {} (第1 层结束:决定A的命运) / \ / \ 选B 不选B 选B 不选B / \ / \ {A,B} {A} {B} {} (第2 层结束:决定B的命运) / \ / \ / \ / \ 选C 不选C 选C 不选C 选C 不选C 选C 不选C | | | | | | | | {A,B,C} {A,B} {A,C} {A} {B,C} {B} {C} {} (第3 层结束:决定C的命运)
顺着这棵树走到尽头,你会得到以下 8 个结果,正好对应数学上的 23 =8 种子集:
左左左 :选A -> 选B -> 选C = {A, B, C}
左右右 :选A -> 选B -> 不选C = {A, B}
左左右 :选A -> 不选B -> 选C = {A, C}
左右右 :选A -> 不选B -> 不选C = {A}
右左左 :不选A -> 选B -> 选C = {B, C}
右左右 :不选A -> 选B -> 不选C = {B}
右右左 :不选A -> 不选B -> 选C = {C}
右右右 :不选A -> 不选B -> 不选C = {} (空集)
在C++代码中子集树 的循环里,通常是:
1 2 3 dfs (i + 1 , sum + w[i]); dfs (i + 1 , sum);
1.2 排列树 排列树解决的是先排A还是先排B的问题,也就是全排列问题。 元素的顺序很重要 ,或者需要穷举所有可能的顺序 。
图解示例 (对 [A, B, C] 进行全排列):
1 2 3 4 5 6 7 8 9 10 11 12 13 { } (根节点:还没开始排) / | \ 放A 放B 放C (第1 层:第1 个位置有3 种选择) | | | {A} {B} {C} (第1 层结束:第1 位已确定) / \ / \ / \ 放B 放C 放A 放C 放A 放B (第2 层:第2 个位置只有剩下的2 种选择) | | | | | | {AB} {AC} {BA} {BC} {CA} {CB} (第2 层结束:前2 位已确定) | | | | | | 放C 放B 放C 放A 放B 放A (第3 层:第3 个位置只有剩下的1 种选择) | | | | | | {ABC} {ACB} {BAC} {BCA} {CBA} {CAB} (第3 层结束:排列完成)
特征
子集树
排列树
典型问题
0-1背包、组合
全排列、旅行商问题
决策逻辑
0 or 1 (选还是不选)
谁是下一个? (排列组合)
兄弟节点关系
兄弟节点做同样的选择逻辑,只是针对不同的物品
兄弟节点代表不同的元素在同一个位置
树的分叉数
固定为 2 (二叉树)
随层数递减 (n, n−1, n−2 …)
是否去重
天然不需要担心顺序问题 (因为只有位置i)
需要标记 是否用过该元素 (防重复选)
在C++代码中排列树 的循环里,通常是:
1 2 3 4 5 6 7 8 9 10 11 for (int i = 0 ; i < n; i++) { if (!used[i]) { used[i] = true ; path.push_back (nums[i]); dfs (depth + 1 ); used[i] = false ; } }
1.3 剪枝 在算法和数据结构中(特别是回溯算法、决策树学习中),剪枝指的是在搜索或构建树的过程中,提前断开那些“明显不可能得出最优解”或“明显不符合要求”的路径,从而减少计算量,提高效率。
树的每一次分叉代表一种选择。树越深,节点越多(呈指数级增长)。如果我们能判断出某一根树枝“长势不好”,就在它刚开始长出来的时候把它切断,那么这根树枝下面的所有子树就都不用生成了。
剪枝的核心目的:
少干活 :不计算那些注定没用的情况。
省空间 :不需要存储那些废弃的分支。
通常在以下 3 种情况下,我们会挥动剪刀:
可行性剪枝(约束条件) —— “这路不对,别走了”
最优性剪枝(界限条件) —— “这路太慢,追不上了”
重复性剪枝(去重) —— “来过了,别回头”
我们在刚才的排列树 上加上一个具体的限制条件,来看看什么是“剪枝”。假设题目要求:
生成的排列中,中间那个字符(第2个位置)不能是 “B” 。
我们来看如何在生长过程中把不符合要求的树枝“剪”掉,从而省去后面无意义的计算。
1 2 3 4 5 6 7 8 9 10 11 12 13 { } (根节点: 未开始) / | \ 放A 放B 放C | | | {A} {B} {C} (第1 层结束) / \ / \ / \ 放B 放C 放A 放C 放A 放B | | | | | | ✂ {AC} {BA} {BC} {CA} ✂ (第2 层结束) (第2 位是B) | | | | (第2 位是B) 放B 放C 放A 放B | | | | {ACB} {BAC} {BCA} {CAB} (最终结果)
1.4 解空间 在回溯算法中,解空间 是一个非常核心的理论概念。简单来说,解空间就是问题所有可能解构成的集合 。回溯算法的过程,本质上就是在这个解空间中进行遍历 ,并在这个过程中搜索 出满足条件的解。
下面详细解释这一概念及其用途:
解空间是一个隐形的树状结构 。我们可以把问题的求解过程看作是从根节点出发,一路向下做决策,直到抵达叶子节点。
状态的集合:解空间包含了从初始状态开始,经过一系列操作(决策)所能达到的所有状态。
树的隐喻:
根节点 :对应初始状态。
边 :代表你做了一个选择。
节点 :代表经过一系列选择后的状态。
叶子节点 :代表一个确定的方案或无法继续的状态。
举个栗子: 如果有 3 个物品 [A, B, C],求所有的子集,解空间树就是:
1 2 3 4 5 6 7 { } <-- 第0 层(根节点,空集) / | \ A B C <-- 第1 层:选了1 个元素 / \ | AB AC BC <-- 第2 层:选了2 个元素 | ABC <-- 第3 层:选了3 个元素
因此,解空间 = 整棵树的所有节点(根节点 + 中间分支节点 + 叶子节点) 。比如:“({} -> A, A -> AB, AB -> ABC …) 这些所有节点以及它们之间的连线,共同构成了解空间。 那么,解空间有什么用呢?
理解解空间的概念,对于算法设计和调试至关重要。它的主要作用体现在以下几点:
定义搜索的边界:解空间告诉算法去哪里找
剪枝的依据:这是解空间最主要的作用。
区分问题类型(集合 vs 树):理解解空间的结构有助于你使用不同的 C++ 策略。
子集/组合问题:解空间通常是一颗子集树 。解空间的元素是无序的,为了避免重复(AB 和 BA 是一样的),我们需要 起始索引来限制解空间的搜索方向(只往右找,不往回找)。
排列问题:解空间通常是一颗排列树 。有序,[1, 2] 和 [2, 1] 是两个解。所以解空间的搜索方向包含所有未使用的元素,需要 used 数组来辅助。
复杂度分析:看到问题,先想象它的解空间树有多大,树叶有多少个。
最坏时间复杂度通常取决于解空间的大小(节点数量)。
排列问题:解空间大小通常是 O(n!)。
子集/组合问题:解空间大小通常是 O(2n )。
如果不加剪枝,回溯算法的时间复杂度就是解空间的大小。
在 C++ 编写回溯代码时,我们大脑里的思维路径应该是这样的:
构建解空间 :想象这棵树长什么样?根节点是什么?子节点是什么?(是排列树还是子集树?)
遍历解空间 :用递归函数和 for 循环来遍历这棵树的节点。
剪枝解空间 :在 if 判断中,把那些不符合条件的树枝折断,缩小搜索范围。
一句话总结:解空间是回溯算法的“地图”,算法是在这张地图上找宝藏的过程,而剪枝是为了抄近路。
2. 回溯算法的抽象结构 回溯法解决的问题都可以抽象为树形结构 (N叉树):
树的宽度 :每次可做出的选择数量(for循环)。
树的深度 :递归的深度(递归次数)。
在回溯算法的核心逻辑处理流程中,其递归函数的处理一共分三个要点:
回溯函数返回值及参数 :通常是void,参数根据问题来确定。
回溯函数终止条件 :也就是什么时候收集处理的结果。
单层搜索的过程 :
for循环遍历
处理节点
递归
回溯操作(撤销处理)
以下为 C++通用的代码模版:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 void backtrack (参数) { if (终止条件) { result.push_back (path); return ; } for (int i = startIndex; i < size; i++) { if () continue ; path.push_back (当前元素); backtrack (i + 1 , 其他参数); path.pop_back (); } }
3. 经典问题实战 3.1 组合问题
题目 :给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
难点解析 :
去重 :如何保证 [1, 2] 和 [2, 1] 不重复出现?-> startIndex 的使用。
剪枝 :如果剩余的元素不够凑齐 k 个,循环就没有必要继续了。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #include <iostream> #include <vector> using namespace std;class Solution { public : void backtrack (int n, int k, int startIndex) ; vector<vector<int >> combine (int n, int k); void printCombinations (const vector<vector<int >>& combinations) ; void testCombine () ; private : vector<vector<int >> result; vector<int > path; };
算法策略 :
树形结构建模 :
将问题抽象为一棵 n 叉树
树的深度:由递归深度表示,最大深度为 k(组合大小)
树的宽度:由每层的选择数量决定,通过 for 循环遍历
避免重复的关键 - startIndex:
引入 startIndex 参数,表示当前层从哪个数字开始选择
每次递归调用时传入 i + 1,确保下一层只从更大的数字开始选择
这样就保证了组合的 单调递增 特性,自然避免了 [2, 1] 这种重复
剪枝优化 :
观察 :如果剩余可选的数字数量小于还需要选择的数字数量,就没有必要继续了
数学推导 :当前最多可以遍历到的索引为 n - (k - path.size()) + 1
效果 :大幅减少递归调用次数,提高算法效率
回溯流程 :
选择 :将当前数字加入路径
探索 :递归处理下一个数字
撤销 :回溯到上一层状态,尝试其他选择
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 #include "backtracking.h" void Solution::backtrack (int n, int k, int startIndex) { if (path.size () == k) { result.push_back (path); return ; } for (int i = startIndex; i <= n - (k - path.size ()) + 1 ; i++) { path.push_back (i); backtrack (n, k, i + 1 ); path.pop_back (); } } vector<vector<int >> Solution::combine (int n, int k) { result.clear (); path.clear (); backtrack (n, k, 1 ); return result; } void Solution::printCombinations (const vector<vector<int >>& combinations) { cout << "总共找到 " << combinations.size () << " 个组合:" << endl; for (int i = 0 ; i < combinations.size (); i++) { cout << "组合 " << i + 1 << ": [" ; for (int j = 0 ; j < combinations[i].size (); j++) { cout << combinations[i][j]; if (j < combinations[i].size () - 1 ) { cout << ", " ; } } cout << "]" << endl; } cout << "------------------------" << endl; } void Solution::testCombine () { Solution sol; cout << "=== 测试用例 1: n=4, k=2 ===" << endl; vector<vector<int >> result1 = sol.combine (4 , 2 ); printCombinations (result1); cout << "=== 测试用例 2: n=5, k=3 ===" << endl; vector<vector<int >> result2 = sol.combine (5 , 3 ); printCombinations (result2); cout << "=== 测试用例 3: 边界测试 n=1, k=1 ===" << endl; vector<vector<int >> result3 = sol.combine (1 , 1 ); printCombinations (result3); cout << "=== 测试用例 4: k=1 的特殊情况 ===" << endl; vector<vector<int >> result4 = sol.combine (3 , 1 ); printCombinations (result4); cout << "=== 测试用例 5: k=n 的特殊情况 ===" << endl; vector<vector<int >> result5 = sol.combine (3 , 3 ); printCombinations (result5); } int main () { Solution s; cout << "组合问题算法测试" << endl; cout << "========================" << endl; s.testCombine (); return 0 ; }
算法可视化(以 n=4, k=2 为例):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 回溯树结构: 开始 [] | ├── 选择1 [1 ] │ ├── 选择2 [1 ,2 ] ✓ │ ├── 选择3 [1 ,3 ] ✓ │ └── 选择4 [1 ,4 ] ✓ | ├── 选择2 [2 ] │ ├── 选择3 [2 ,3 ] ✓ │ └── 选择4 [2 ,4 ] ✓ | ├── 选择3 [3 ] │ └── 选择4 [3 ,4 ] ✓ | └── 选择4 [4 ] (剪枝:需要2 个数字,但只剩1 个可选)
3.2 排列问题
题目 :给定一个不含重复数字的数组 nums,返回其所有可能的全排列。
难点解析 :
每层都是从0开始遍历 :因为排列讲究顺序,[1, 2] 和 [2, 1] 是不同的排列。
使用状态 :由于每次都从头遍历,需要记录哪些元素已经在当前路径 path 中被使用过了。
C++ 代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #include <iostream> #include <vector> using namespace std;class Solution1 { public : void backtrack (vector<int >& nums, vector<bool >& used) ; vector<vector<int >> permute (vector<int >& nums); void printPermutations (const vector<vector<int >>& permutations) ; bool validatePermutations (const vector<vector<int >>& result, vector<int >& nums) ; void testPermute () ; private : vector<vector<int >> result; vector<int > path; };
算法策略 :
树形结构建模 :
将问题抽象为一棵 n 叉树
树的深度:由递归深度表示,最大深度为 n(数组长度)
树的宽度:每层的宽度都是 n(因为排列中每个位置都可以选择任何未使用的元素)
避免重复使用的关键 - used 数组:
使用 vector<bool> used 记录每个元素是否已在当前路径中使用
在选择元素前检查 used[i],确保同一个元素不会被重复选择
这是与组合问题的核心区别:组合用 startIndex,排列用 used 数组
回溯流程 :
选择 :将当前未使用的元素加入路径,标记为已使用
探索 :递归处理下一个位置
撤销 :回溯到上一层状态,取消标记,尝试其他选择
终止条件 :
当路径长度等于数组长度时,说明已生成一个完整的排列
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 #include "backtracking.h" #include <algorithm> void Solution1::backtrack (vector<int >& nums, vector<bool >& used) { if (path.size () == nums.size ()) { result.push_back (path); return ; } for (int i = 0 ; i < nums.size (); i++) { if (used[i]) { continue ; } used[i] = true ; path.push_back (nums[i]); backtrack (nums, used); path.pop_back (); used[i] = false ; } } vector<vector<int >> Solution1::permute (vector<int >& nums) { result.clear (); path.clear (); vector<bool > used (nums.size(), false ) ; backtrack (nums, used); return result; } void Solution1::printPermutations (const vector<vector<int >>& permutations) { cout << "总共找到 " << permutations.size () << " 个排列:" << endl; for (int i = 0 ; i < permutations.size (); i++) { cout << "排列 " << i + 1 << ": [" ; for (int j = 0 ; j < permutations[i].size (); j++) { cout << permutations[i][j]; if (j < permutations[i].size () - 1 ) { cout << ", " ; } } cout << "]" << endl; } cout << "------------------------" << endl; } bool Solution1::validatePermutations (const vector<vector<int >>& result, vector<int >& nums) { int n = nums.size (); int expectedCount = 1 ; for (int i = 1 ; i <= n; i++) { expectedCount *= i; } if (result.size () != expectedCount) { cout << "错误:预期 " << expectedCount << " 个排列,实际找到 " << result.size () << endl; return false ; } for (const auto & perm : result) { if (perm.size () != n) { cout << "错误:排列长度不正确" << endl; return false ; } vector<int > sorted_perm = perm; sort (sorted_perm.begin (), sorted_perm.end ()); if (sorted_perm != nums) { cout << "错误:排列包含错误元素" << endl; return false ; } } cout << "√ 验证通过:所有排列都正确" << endl; return true ; } void Solution1::testPermute () { Solution1 s; cout << "=== 测试用例 1: [1,2,3] ===" << endl; vector<int > nums1 = { 1 , 2 , 3 }; vector<vector<int >> result1 = s.permute (nums1); printPermutations (result1); validatePermutations (result1, nums1); cout << "=== 测试用例 2: [0,1] ===" << endl; vector<int > nums2 = { 0 , 1 }; vector<vector<int >> result2 = s.permute (nums2); printPermutations (result2); validatePermutations (result2, nums2); cout << "=== 测试用例 3: 单个元素 [5] ===" << endl; vector<int > nums3 = { 5 }; vector<vector<int >> result3 = s.permute (nums3); printPermutations (result3); validatePermutations (result3, nums3); cout << "=== 测试用例 4: 四个元素 [1,2,3,4] ===" << endl; vector<int > nums4 = { 1 , 2 , 3 , 4 }; vector<vector<int >> result4 = s.permute (nums4); cout << "4个元素的排列数: " << result4. size () << " (4! = 24)" << endl; printPermutations (result4); validatePermutations (result4, nums4); cout << "------------------------" << endl; } int main () { Solution s; cout << "排列问题算法测试" << endl; Solution1 sol; sol.testPermute (); return 0 ; }
3.3 棋盘问题(N皇后)
题目 :在 n×n 的国际象棋棋盘上放置 n 个皇后,使其不能互相攻击(任意两个皇后不能在同一行、同一列、同一斜线)。
难点解析 :
多维搜索 :这是一个二维棋盘,通常可以用一维数组 或递归深度来表示行号,循环来表示列号。
校验逻辑 :放入皇后前,需要检查当前位置是否合法(上方、左上角、右上角是否有皇后)。由于我们是一行一行放的,所以不需要检查下方和左右方。
字符串处理 :棋盘的表示通常使用 vector<string>。
C++ 代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #include <iostream> #include <vector> #include <string> using namespace std;class Solution2 { public : void backtrack (int n, int row, vector<string>& chessboard) ; bool isValid (int targetRow, int targetCol, vector<string>& chessboard, int n) ; vector<vector<string>> solveNQueens (int n); private : vector<vector<string>> result; };
算法策略 :
路径选择 :
从棋盘的第一行(Row 0)开始,逐行向下处理。
函数参数通常只传递当前行索引 row。
合法性校验(剪枝) :
在当前行 row,遍历每一列 col(从 0 到 N-1)。
对于位置(row, col),检查是否触发冲突:
col 列是否有皇后?
主对角线(方向 \)是否有皇后?
副对角线(方向 /)是否有皇后?
策略 :如果任何一项检查为“是”,则跳过 该列(剪枝),不进行递归。
递归与状态更新 :
如果位置(row, col)合法:
将皇后放置在该位置(逻辑上)。
更新状态 :标记列 col、主对角 、副对角 为“已占用”。
递归调用 :进入下一行 backtrack(row + 1)。
回溯与状态撤销 :
当子递归返回(无论是因为找到了一个解还是无路可走),需要撤销当前节点的选择,以便尝试当前行的下一列。
撤销状态 :将列 col、主对角 、副对角标记回“未占用”。
终止条件 :
当 row == N 时,说明成功在 0 到 N-1 行都放置了皇后,找到一个合法解,计数器加 1 并返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 #include "backtracking.h" void Solution2::backtrack (int n, int row, vector<string>& chessboard) { if (row == n) { result.push_back (chessboard); return ; } for (int col = 0 ; col < n; col++) { if (isValid (row, col, chessboard, n)) { chessboard[row][col] = 'Q' ; backtrack (n, row + 1 , chessboard); chessboard[row][col] = '-' ; } } } bool Solution2::isValid (int targetRow, int targetCol, vector<string>& chessboard, int n) { for (int i = 0 ; i < targetRow; i++) { if (chessboard[i][targetCol] == 'Q' ) { return false ; } } for (int i = targetRow - 1 , j = targetCol - 1 ; i >= 0 && j >= 0 ; i--, j--) { if (chessboard[i][j] == 'Q' ) { return false ; } } for (int i = targetRow - 1 , j = targetCol + 1 ; i >= 0 && j < n; i--, j++) { if (chessboard[i][j] == 'Q' ) { return false ; } } return true ; } vector<vector<string>> Solution2::solveNQueens (int n) { vector<string> chessboard (n, string(n, '-' )) ; backtrack (n, 0 , chessboard); return result; } int main () { cout << "N皇后问题算法测试" << endl; Solution2 sol; int n = 4 ; vector<vector<string>> solutions = sol.solveNQueens (n); cout << n << "皇后问题共有 " << solutions.size () << " 种解法:" << endl; for (int i = 0 ; i < solutions.size (); i++) { cout << "解法 " << i + 1 << ":" << endl; for (const string& row : solutions[i]) { cout << row << endl; } cout << endl; } return 0 ; }