ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python递归求解排列组合:回溯算法与剪枝优化

Python递归求解排列组合:回溯算法与剪枝优化

Python求解排列组合:递归程序设计艺术(4)

递归这东西,刚接触的时候觉得绕,写多了会觉得爽,再往后写复杂问题又会觉得又绕又爽。排列组合恰好是练习递归的黄金领域,问题本身足够简单,一眼能看懂在求什么,但实现方式却能千变万化,每一步选择、每一个剪枝条件都藏着程序设计的思想。这篇是这个系列的第四篇,我不打算重复教科书上那种“递归就是函数调用自己”的基础概念,而是直接聚焦到排列组合问题上,从设计思路、代码实现到性能对比,把递归程序设计的门道一次讲透。适合已经写过一些Python、知道函数和列表怎么用,但遇到递归就头皮发麻的同学,也适合想看看排列组合除了调库之外到底怎么实现的开发者。

很多人写排列组合第一反应是itertools.permutations一行搞定,确实方便,但库函数封装得太好,反而让人看不到问题背后的结构。递归写法逼着你去思考“状态”是什么、“选择”是什么、“终止条件”是什么,这种思维训练对后续处理图遍历、动态规划、回溯搜索都有直接帮助。换句话说,排列组合是理解递归的一把钥匙,这篇就是教你怎么用好这把钥匙。

1. 递归解排列组合的整体设计思路

先聊设计思路。你拿到“求三个元素的全排列”或“从五个元素中选三个的组合”这种需求,第一反应可能是暴力嵌套循环。做两个元素的组合用两层循环,三个用三层,那十个呢?你还写十层循环吗?显然不现实。递归的核心价值就在这里:它把“嵌套多少层”这个不确定的问题,变成“每一层做什么”这个确定的问题,层数由递归深度天然解决。

排列和组合虽然常放在一起说,但设计逻辑有本质差异。排列强调顺序,[A,B,C]和[A,C,B]算两种结果;组合强调集合,[A,B,C]和[A,C,B]是同一种结果。这个差异直接影响递归函数的状态设计。

求排列时,每一层递归要回答的问题是:当前位置还可以放哪些元素?答案是“还没被用过、且尚未固定在前面位置上的所有元素”。所以你需要一个标记数组,记录每个元素是否已经被使用,每层递归把未使用的元素依次放到当前位,然后进入下一层,等所有位置填满就得到一个排列。

求组合时,每一层递归要回答的问题变成:从当前位置开始,我选哪个元素作为下一位?答案是“下标递增地选择后面的元素”。组合不考虑顺序,所以必须约定一个方向,比如总是往后选,这样[A,C]不会因为先选C再选A而重复出现。这种“下标递增”的约定,是组合递归最关键的防重复手段。

理解了这两点,你再看网上各种排列组合递归代码,就会发现核心都逃不出这两个模型。一个是“判断用没用过”的标记模型,一个是“下标递增”的方向模型。心里有这两个模型,遇到变种题目你也能很快拆解。

1.1 为什么递归天生适合这类问题

排列组合本质上是一个多阶段决策过程:第一个位置做什么选择,第二个位置做什么选择,以此类推。而且每个阶段面临的选择结构相似——都是“从某个候选集合里挑一个”。这种“处理方式相同、但处理范围不断缩小”的特性,正好是递归的舒适区。

用循环实现时,循环层数必须预先确定,而排列组合的规模是动态的,循环层数跟着规模走,代码就没法写了。递归深度则是在运行时由调用栈动态维护的,规模是3就递归3层,规模是10就递归10层,代码完全不变,这就是递归“以不变应万变”的优势。

你可以把递归过程想象成在填写一张多栏表格。第一层递归负责填第一栏,但填之前并不知道后面栏会怎么填,所以先把第一栏填好,然后把“填后面栏”这件事完整地交给下一层递归去处理。下一层递归又把自己处理不了的部分继续往下交。等到最后一栏填完,整张表就确定下来了,然后一层一层往上返回。

这种“把问题变简单后交给别人”的思想,就是分治思想的雏形。很多同学学递归学不会,原因是总想在脑子里模拟整个调用过程,这其实是徒劳的。正确的做法是只关注两层关系:当前这一层做什么,以及下一层调用返回什么。想通这两点,递归就通了。

2. 核心细节解析:递归函数的三要素

写递归函数,不管题目怎么变,牢牢抓住三要素就能稳。

终止条件:也就是递归什么时候停止。排列是填满了所有位置,组合是选够了需要的个数。终止条件不写对,要么无限递归爆栈,要么提前返回漏结果。

递推关系:当前结果怎么从更小的结果推出来。排列每选一个元素,问题规模减一;组合每选一个元素,可选的候选范围缩小一段。递推关系决定了每次递归调用参数怎么变。

返回值设计:是收集所有结果返回,还是带着当前路径一路传递到底?排列组合场景通常是把结果收集到一个全局容器里,递归函数本身不返回具体值,只负责在终止时把当前路径保存下来。

这三个要素在代码里长什么样,下面这段可以直观感受:

def permute(nums): result = [] n = len(nums) used = [False] * n def backtrack(path): if len(path) == n: result.append(path[:]) return for i in range(n): if used[i]: continue used[i] = True path.append(nums[i]) backtrack(path) path.pop() used[i] = False backtrack([]) return result

注意result.append(path[:])这一行,我故意用了切片拷贝。很多人第一次写递归收集结果,直接result.append(path),最后发现result里全是空列表或者全是同一个列表——因为path在递归回溯过程中一直是被复用的同一个对象,你如果不拷贝,存进去的是引用,等回溯结束后这些引用指向的列表已经被修改了。这个坑几乎每个人都会踩一次,记住:收集结果时一定要拷贝。

组合对应的代码是这样:

def combine(n, k): result = [] def backtrack(start, path): if len(path) == k: result.append(path[:]) return for i in range(start, n + 1): path.append(i) backtrack(i + 1, path) path.pop() backtrack(1, []) return result

组合的终止条件是路径长度等于k,递推参数从start变成i + 1,保证后面的选择永远比前面大,自然不重复。这两个代码放在一起对比着看,递归三要素的差异就非常清晰了。

2.1 参数设计的门道:状态传递的四种方式

递归函数的参数设计,决定了代码的可读性和运行效率。我在实际写排列组合递归的时候总结出四种状态传递方式,各有各的适用场景。

第一种是路径参数式,就像上面代码里的path,每层递归把自己的选择结果传给下一层。这种方式最直观,也最好调试,因为当前走到了哪一步,看一眼path就知道。

第二种是标记参数式,用一个布尔数组标记元素是否已用,适用于排列类问题。写的时候注意标记的还原时机——递归调用返回后必须立刻还原,否则会影响后续分支的遍历。

第三种是范围参数式,用一个start下标标记可选范围起点,适用于组合类问题。这种方式不需要标记数组,因为范围的递增天然保证了不重复。

第四种是计数参数式,维护“还剩几个位置要填”之类的计数器。这种方式在一些变种题里很好用,比如求n个0和n个1的所有排列(括号生成问题),就是通过统计当前用了多少个0多少个1来决定下一步可以选什么。

了解这四种方式,不是为了背模板,而是为了看到新题目时能快速判断该用哪种状态模型。判断依据其实就两个问题:每一步选择会不会影响后续步骤的候选集?如果会,是哪些候选被排除?答案如果涉及“用过不能再用”,就用标记式;答案如果涉及“只能往后选”,就用范围式;答案如果两个都有,比如“每个元素只能用一次但结果又有顺序要求”,那往往要标记加范围组合使用。

2.2 剪枝优化:递归程序的提速关键

排列组合的递归本质是穷举,穷举就不可能避免所有无效路径,但优秀的程序能把明显无效的路径提前掐断,这就是剪枝。

最常见的剪枝场景是元素重复。假设输入是[1, 1, 2],要求输出所有不重复排列。如果不管三七二十一直接排列,会出现两个[1, 1, 2]——第1个1先选和第2个1先选各自生成一份。剪枝的办法是先给数组排序,然后在循环里加一个判断:如果当前元素和上一个元素相同,且上一个元素没被用过,就跳过当前元素。

def permute_unique(nums): result = [] n = len(nums) used = [False] * n nums.sort() def backtrack(path): if len(path) == n: result.append(path[:]) return for i in range(n): if used[i]: continue if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]: continue used[i] = True path.append(nums[i]) backtrack(path) path.pop() used[i] = False backtrack([]) return result

这个剪枝判断的逻辑要理解透彻:nums[i] == nums[i - 1]说明遇到重复元素,not used[i - 1]说明前一个相同元素刚才没走这条分支,意味着当前这个分支和前一个分支等价。等价分支剪掉一个,结果就不会重复。初看这个条件可能觉得别扭,想不明白为什么不写成used[i - 1]。你动手模拟一遍就清楚了,写成used[i - 1]反而会把所有含重复元素的有效结果全部剪光。

另一个非常实用的剪枝是优化组合的循环上界。求从1到n中选k个数的组合,如果当前已经选了len(path)个,还需要k - len(path)个,那么循环变量i的终点必须为后面留足空间,i最大只能到n - (k - len(path)) + 1。原代码的range(start, n + 1)其实做了大量无效遍历,最后几轮循环根本不可能凑够k个数。把上界改成range(i, n - (k - len(path)) + 2),在n很大k很小时效率提升非常明显。

3. 实操过程:从暴力到优雅的完整演进

与其直接贴一段最终代码让人死记硬背,不如看完整演进过程。我用一个实际需求走一遍:从[1, 2, 3, 4]中选两个数的所有组合并打印。

第一版:暴力循环

两层循环轻松解决,输出(1,2),(1,3),(1,4)...。这个版本只适用于k=2固定不变的情况。一旦k变成3、4、5,代码就得重写。它能解决问题,但没有任何扩展性。

第二版:递归版本

这就是前面写的combine函数。核心区别在于,不管k是几,代码都不用变。这就是递归的第一个直接好处——参数化问题规模。

第三版:加入剪枝的递归版本

把循环上界从n + 1收紧到n - (k - len(path)) + 2。这个版本运行效率高,代码依旧简洁。

第四版:返回生成器的惰性版本

把result收集改为yield产出,函数变成生成器,调用方可以按需取结果,不需要一次性把所有组合都存进内存。当组合数巨大时,这个版本的差别是致命的。

def combine_iter(n, k): path = [] def backtrack(start): if len(path) == k: yield path[:] return for i in range(start, n - (k - len(path)) + 2): path.append(i) yield from backtrack(i + 1) path.pop() yield from backtrack(1)

这个演进过程值得反复琢磨。每个版本解决的具体问题不同,暴力解决“能算”,递归解决“能变着规模算”,剪枝解决“算得快”,生成器解决“内存装得下”。实际的工程代码往往是这四者的组合,你能根据场景选到合适的一版,说明递归是真入门了。

3.1 逐行拆解:一只猴子怎么拿到香蕉

递归初学者最容易卡壳的地方是“这个函数到底怎么运行的”。我拿排列代码的调用过程模拟一遍,用“猴子拿香蕉”的故事辅助理解。

假设你的目标是从[1, 2]中生成所有排列。第一层递归进来的猴子叫A,它站在第一个位置,面前有两根香蕉编号1和2。A拿起编号1的香蕉,在used标记表上划掉1号,然后叫来猴子B,让B处理第二个位置。B看到标记表上1已被划掉,能选的只有2号香蕉,于是拿起2号,填满最后一个位置,记录结果[1, 2]。B的任务完成后,把2号香蕉放回去,标记表恢复成只有1号被划掉的状态,B回到A身边报告。A这时把1号香蕉也放回去,拿起2号香蕉,划掉标记,叫B再次处理第二个位置。B这次看到1号香蕉可用,拿起1号,记录结果[2, 1]。全部完成后,所有排列都拿到了。

这个故事里有几个关键动作:猴子放回香蕉的动作就是代码里的path.pop()和used[i] = False,这个动作叫“回溯还原”。没有还原,程序会越走越偏,最终爆栈或者结果残缺。理解了还原动作,就理解了回溯法。

每次递归调用时,调用的不是“另一个函数”,而是“能力更弱的自己”。猴子B其实是能力弱一点点的猴子A——它面对的是更少的可选元素和更短的空位。这种自我调用的结构,让同一个函数的代码天然适应不同规模,无需重写。

3.2 规模估算:你的程序到底会算到多少

递归写好了,得心里有数它到底会跑多久。排列的时间复杂度是O(n!),组合是O(C(n, k))。这些符号看起来很学术,我算一笔具体账。

求10个元素的全排列,结果是10! = 3628800,也就是三百多万个结果。每个结果存储还需要额外开列表,内存轻易上几百MB。如果跑15个元素的全排列,15! = 1307674368000,一万三千多亿,你的程序性能再好也扛不住。所以递归解决排列组合问题时,脑海中必须有一根弦:结果是阶乘级增长的。

组合的数量相对温和。从50个元素里选5个,C(50,5) = 2118760,两百万出头,比10个元素的全排列还少。但C(50,25)直接爆炸到1.26e14,又是天文数字。所以剪枝在这里的意义不只是“快一点”,而是“能不能跑完”的问题。

遇到大规模问题时,如果答案只要求数量而不要求具体集合,用数学公式直接算;如果要求具体集合但规模爆炸,那就要思考是否真的需要全量枚举,多数业务场景只需要部分结果,这时生成器版本就能配合外部条件提前终止。

3.3 生成器版对比测试:内存占用差距有多大

我实际跑过一个对比测试:用普通列表收集所有方式和生成器产出方式,分别求C(20, 10)的结果。C(20, 10) = 184756,不算特别大,但足以看出差异。

普通版本在递归结束后一次性返回包含全部结果的列表。实测内存占用大约60MB,原因是每个组合都是一个独立的Python列表对象,列表本身加上元素引用都有额外开销。生成器版本在递归过程中逐条yield结果,调用方每取一条,内存里只存在当前这一条组合对象,实测内存占用稳定在几个MB以内,差距接近20倍。

如果你只是把结果打印出来或者交给其他函数逐条处理,生成器版完胜。但生成器也不是没有代价——它的结果不能被随机访问,不能len(),只能顺序消费。如果后续逻辑需要反复遍历结果集合,一次性收集反而更方便。

4. 踩坑实录:递归排列组合的典型问题

写了这些年代码,排列组合递归的坑我基本都踩过一遍。选几个典型的拿出来说,帮你们少走弯路。

第一个坑:收集结果忘了拷贝。前面我已经强调过,result.append(path)是经典错误。原因在于path是同一个列表对象,递归回溯后内容不断变化,最后所有存入的结果都指向同一个最终状态。解决方案是path[:]拷贝,或者用tuple(path)转成元组。

第二个坑:还原顺序不对。path.pop()和used[i] = False这两个操作必须在递归调用返回后立即执行,而且顺序无所谓,但绝对不能漏掉任何一个。漏掉used[i] = False的后果是后续分支看不到这个可用元素,结果数量直接减半甚至更少,而且程序不会报错,这种bug极难排查。

第三个坑:可变默认参数。如果你图省事把path写成def backtrack(path=[]),Python只会创建一次默认列表,所有递归调用共享这一个列表,数据互相污染。这个坑不只在排列组合里出现,任何递归都不建议用可变默认参数。

第四个坑:排列组合搞混了递归边界。排列的终止条件是len(path) == n,组合是len(path) == k。看起来很像,但含义完全不同。写组合时用n做终止条件,程序会一直选到所有元素,结果数量错得离谱而且几乎不会报错。

第五个坑:超大n值直接卡死。递归是很消耗资源的,Python默认递归深度限制在1000左右。排列组合问题虽然一般不会触发这个限制,但极端情况下堆栈溢出依然可能发生。真遇到深度很大的情况,只能用显式栈模拟递归或者调整递归限制。

4.1 常见问题速查表

症状可能原因解决方案
结果全为空列表收集结果用了path没切片改为path[:]或tuple(path)
结果数量减半且不稳定漏掉used[i] = False还原检查递归返回后的还原语句是否执行
组合结果出现大量重复递归参数传的是start而不是i+1修正递推参数,确保严格递增
程序运行极慢循环上界没剪枝循环终点改为n - (k - len(path)) + 2
结果顺序和预期不符输入数组未排序先对输入排序再做递归
深度超过限制报错递归层数过深改用迭代栈或sys.setrecursionlimit(谨慎使用)

这张表是我日常调试的总结,遇到问题先对照这张表排查一遍,九成问题都能定位。剩下的问题就要靠打印path在每个递归入口输出当前状态,逐步跟踪。

4.2 调试技巧:递归程序的可视化追踪

递归程序调试最怕的就是“看不到过程”。分享几个实操技巧。

第一个技巧:在递归入口和返回处各打印一行,缩进对齐。比如在backtrack(path)入口打印" " * len(path) + f"enter: {path}",退出前打印" " * len(path) + f"exit: {path}"。这样跑出来的日志天然形成一棵树状结构,每一步的选择和回溯一目了然。这个技巧对理解递归行为非常有帮助。

第二个技巧:用小规模输入验证。比如[1, 2]求排列,手动推导一遍,再对比程序输出。结果一致再放大输入。千万别用大规模输入调试,你根本看不过来。

第三个技巧:条件断点。用调试器在特定路径长度处设置断点,比如len(path) == n - 1时停住,观察下一步的选择范围。这种方式比在循环里打印所有信息高效得多。

第四个技巧:写独立的“校验函数”。比如写一个函数检查结果列表里所有排列是否都不重复、是否完整覆盖所有可能。用暴力穷举结果做对拍,能快速发现递归逻辑里的隐藏错误。这个思路在做算法题时同样好用。

5. 从排列组合到更广的递归程序设计

排列组合只是一个起点,但掌握了它,你可以顺藤摸瓜理解更多递归程序设计。常见的括号生成、子集枚举、数独求解、N皇后问题,本质上都是“从候选集中按规则逐步选择”的过程,只是规则更复杂、剪枝条件更多。

拿N皇后问题举例,它其实可以看成排列问题的变种——N个皇后对应N行,每行选一列,约束条件是任意两个皇后不能同列、同对角线。核心递归结构就是在每一行尝试放置皇后,用标记记录哪些列和对角线已占用。你如果把排列问题的“用没用过”标记扩展成“这个位置是否合法”判断,N皇后问题不过是加上了两个复杂剪枝条件的排列搜索。

再比如子集枚举,它比组合问题更开放——组合要求正好选k个,子集允许从空集到全集任意大小。实现上的差别是:终止条件从“长度等于k”变成“遍历完所有元素后在每一层都记录当前路径”。递归结构一脉相承,只是输出时机变了。这就是递归程序设计的魅力:模板骨架不变,微调条件和输出位置,就能解决一大类问题。

所以,学习排列组合递归,目标不是背下那两个函数,而是通过它们理解状态空间搜索的通用框架。遇到新问题,先尝试把问题描述成“多阶段决策过程”,再定义清晰的选择与约束,然后套用递归框架,最后用剪枝优化。这套方法论反复练习,写递归就会慢慢形成肌肉记忆。

5.1 几种经典变种的快速实现参考

为了帮助你把思路迁移到实际问题,这里给几个高频变种的参考代码,都不长,但结构值得反复咀嚼。

子集枚举:不限定个数,输出所有子集。

def subsets(nums): result = [] n = len(nums) def backtrack(start, path): result.append(path[:]) for i in range(start, n): path.append(nums[i]) backtrack(i + 1, path) path.pop() backtrack(0, []) return result

注意这里result.append放在递归入口而不是终止条件里,因为子集可以在任意长度上输出。

括号生成:给定n对括号,生成所有合法组合。

def generate_parenthesis(n): result = [] def backtrack(path, left, right): if len(path) == 2 * n: result.append(path) return if left < n: backtrack(path + "(", left + 1, right) if right < left: backtrack(path + ")", left, right + 1) backtrack("", 0, 0) return result

这里我没用path列表而直接用字符串拼接,因为字符串是不可变对象,每次递归传参自然形成独立副本,不需要手动回溯。这个写法在不可变数据结构的场景下极其舒服,值得借鉴。

重复元素的组合:输入[1, 2, 2],输出不重复组合。和排列去重类似,先排序加剪枝判断。

def combine_unique(nums, k): result = [] nums.sort() def backtrack(start, path): if len(path) == k: result.append(path[:]) return for i in range(start, len(nums)): if i > start and nums[i] == nums[i - 1]: continue path.append(nums[i]) backtrack(i + 1, path) path.pop() backtrack(0, []) return result

上面剪枝条件写的是i > start而不是i > 0,这是组合去重与排列去重的重要区别。组合中,同一个位置上的起点允许使用重复元素,只有跨位置重复才需要跳过。想明白这个区别,你的剪枝水平就过关了。

这几个变种代码的逻辑密度很高,建议每段都手动推演几十行调用过程,彻底搞明白后再往下学,收获会大得多。

6. 最后的经验心得

写了这么多年递归,我最大的体会是:递归不是玄学,而是一种思维习惯的转变。初学阶段,强迫自己在每个递归问题里先回答三个问题——递归什么时候停、每一层做什么、返回给上一层什么——不回答完不许写代码,坚持下来,递归能力扎实得非常快。

另一个体会是,调试递归别靠瞪眼,暴力输出调用日志和路径内容是最高效的方式,配合小规模用例对拍,很多逻辑错误几十秒就能定位。

最后再分享一个实用技巧:给你的递归函数写清晰的中文注释,把每个参数的含义、每层递归的边界条件都写出来。递归代码时间一长自己都容易忘,注释是最廉价的维护成本。我见过太多人递归能写出来,过两个月再看却看不懂自己写的是什么。

排列组合只是个小小的领域,背后的递归思维却能伴随整个编程生涯。希望这篇能把递归这扇门再推开一些,后面的路,你会越走越宽。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进