前言
在算法刷题的过程中,我们经常会遇到一些看似毫无头绪、只能暴力穷举的问题(比如求所有的组合、全排列、解数独等)。这时候,就需要请出算法界的一大杀器------回溯算法(Backtracking)。
很多初学者觉得回溯算法像是一门玄学,递归里面套着循环,循环里面又套着递归,很容易就被绕晕了。但实际上,回溯法有着极其清晰的内在逻辑。今天这篇博客彻底把回溯算法的底层逻辑扒明白,并结合 C语言/C++ 和 Python 三种语言,帮你彻底打通任督二脉!
一、 什么是回溯?
回溯其实是搜索与尝试的过程。
平时练习二叉树题目时,我们对深度优先遍历(DFS)一定不陌生。回溯是递归的副产品,只要有递归,就必定有回溯。 它们两者的关系如影随形。我们在进行递归搜索时,往往是一条路走到黑,当发现这条路走不通,或者已经达到目标条件时,就需要退回一步(回溯),换一条路继续走。
回溯法的效率高吗? 答案是:不高。 回溯法的本质就是纯暴力穷举 。它不是什么高效的魔法,它只是帮我们把那些连写嵌套 for 循环都写不出来的题目,通过递归的方式穷举出来。如果想提升效率,唯一的办法就是加一些剪枝操作,把明显不符合条件的路径提前砍掉,但改变不了其暴力的本质。
二、 回溯法能解决哪些问题?
就算回溯法是暴力搜索,它也是解决以下五类经典问题的唯一利器:
-
组合问题: N 个数里面按一定规则找出 k 个数的集合(例如:LeetCode 77. 组合)。注意:组合不强调元素的顺序。
-
切割问题: 一个字符串按一定规则有几种切割方式(例如:LeetCode 131. 分割回文串)。
-
子集问题: 一个 N 个数的集合里有多少种符合条件的子集(例如:LeetCode 78. 子集)。
-
排列问题: N 个数按一定规则全排列,有几种排列方式(例如:LeetCode 46. 全排列)。注意:排列强调元素的顺序。
-
棋盘问题: N 皇后问题、解数独等。
三、 回溯法的核心精髓:树形结构
这是理解回溯法最最最关键的一步!《代码随想录》 中有一个极其重要的结论:所有回溯法的问题都可以抽象为树形结构!
回溯法解决的都是在集合中递归查找子集的过程。我们可以把集合的大小理解为树的宽度 ,把递归的深度理解为树的深度。偷一下卡哥的图。

具体来说,这是一棵高度有限的 N 叉树:
-
树的宽度(横向遍历): 由
for循环(或 Python 的for...in)控制。每次在当前层从集合中取出一个元素。 -
树的深度(纵向遍历): 由
递归控制。每次取出一个元素后,带着这个元素进入下一层递归。 -
叶子节点: 就是我们需要的最终结果。当到达叶子节点(满足终止条件)时,收集结果,然后回溯,返回上一层,继续横向遍历下一个节点。
四、 回溯算法通用模板 (C & Python 双语)
掌握了树形结构,我们就可以推导出回溯算法的终极模板。和二叉树的递归遍历类似,回溯法也有其固定的书写三部曲:
1. 明确回溯函数的返回值以及参数
回溯函数通常命名为 backtracking。返回值一般为 void(在 Python 中不需要显式 return 值)。 因为回溯算法需要的参数往往比较多,一般很难一开始就确定下来。我的经验是:先写逻辑,需要用到什么参数,再往函数签名的括号里加。
2. 确定回溯函数的终止条件
什么时候到达树的叶子节点?这就是终止条件。 当满足终止条件时,通常我们需要把当前收集到的单条路径结果保存到最终的二维数组结果集中,然后 return。
3. 确定单层搜索的逻辑
在树形结构中,单层搜索就是通过循环横向遍历当前集合的所有元素。在这个循环里,我们要处理当前节点,递归进入下一层,最后千万不能忘了回溯(撤销处理)。
终极模板合体
对比查看两种语言的模板,你会发现它们在思想上高度一致。
【C / C++ 版模板】
在 C 语言或 C++ 中,我们通常使用数组或 vector 来记录路径和结果集。
cpp
// C/C++ 伪代码模板
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果; // 这一步至关重要!
}
}
【Python 版模板】
在 Python 中,代码更加简洁,列表(List)的 append 和 pop 操作完美契合了处理和撤销的过程。需要注意的是,在 Python 中存放结果时,必须存入路径的一份拷贝(如 path[:]),否则后续的回溯会把结果集里的数据也清空。
python
# Python 伪代码模板
def backtracking(参数):
if 终止条件:
结果集.append(路径[:]) # 注意:必须是浅拷贝
return
for 选择 in 本层集合中的元素:
处理节点 # 比如 path.append(元素)
backtracking(路径, 选择列表) # 递归
回溯,撤销处理结果 # 比如 path.pop()
五、 深刻理解"撤销处理"
很多同学最不理解的就是"为什么要撤销?"。无论你用 C 还是 Python,这一步都省不掉。
举个例子,我们在做组合问题,要从 [1, 2, 3] 中选出两个数。
-
第一步,循环取了
1,此时路径path = [1]。 -
递归进入下一层,循环取了
2,此时路径path = [1, 2]。达到数量要求,收集结果并return。 -
return返回后,我们回到了取1的那一层。但此时我们的path还是[1, 2]!如果不把2撤销掉,下一步循环取3时,path就会变成[1, 2, 3],这就完全乱套了。 -
所以,收集完
[1, 2]后,必须把2弹出来(C++ 里的pop_back()或 Python 里的pop()),让path变回[1]。接着循环向后走取3,path变成[1, 3],这才是正确的逻辑。
处理节点和撤销节点,永远是成对出现的,就像是对称的美学。
总结
回溯算法本质就是穷举,通过递归控制深度,通过 for 循环控制宽度,整体呈现出一棵 N 叉树的形态。
-
C语言/C++ 强调对内存和数据结构的精准控制,每一步的入栈出栈都清清楚楚。
-
Python 则胜在语法简洁,能够让你把更多精力放在算法逻辑本身,但要当心引用传递带来的坑(记得拷贝路径)。
只要在脑海中能够画出这棵树,明确什么时候该往下走(递归),什么时候该往右走(循环遍历),什么时候该回头(回溯撤销),所有的回溯问题都会迎刃而解。打好这个理论基础,后续不管是组合、分割、还是全排列,其实都是套用这个模板,只是在"参数传递"和"剪枝条件"上做文章。
照例贴上卡哥的代码随想录