☰
下一个排列算法:字典序原理、全排列枚举与工程应用
2026/10/14 7:25:52 网站建设 项目流程

前两天在一个算法交流群里,有人贴出一道题:"下一个排列"。他一开始直接用递归把全排列全部生成,排序后查找当前位置,再取下一条。小数据跑得通,数据稍微大一点就直接超时。群里有人提醒他有标准解法,核心三句话就能写完。但等到有人追问"为什么从右往左找升序对""为什么交换后要反转",帮他的那个人也说不清楚了。

这个场景我见过太多次。"下一个排列"是高频算法题,但它不是那种背下模板就能应付的题目,而是一个能延伸到全排列枚举、康托展开、状态压缩遍历的枢纽型考点。它表面考的是数组操作,底层考的是你对字典序的理解。今天这篇文章不打算只贴一份标准代码,而是把这道题从错误思路、数学原理、手工推导到边界条件、扩展应用完整走一遍。无论你是刚开始刷题的新手,还是准备面试的在职开发者,只要顺着逻辑推一遍,后面遇到任何和排列顺序有关的问题都能自己推理出答案。

1. "下一个排列"的三条歪路:暴力枚举、数字加一和直觉式改数

先来说说我见过的三类错误解法。这三类错误代表三种不同的思维误区,也解释了为什么这道题值得认真对待。

1.1 暴力枚举:看起来万无一失,实际上n稍大就崩

最直接的想法是:把当前数字能组成的所有排列都生成出来,按字典序排好,找到当前排列,然后取下一个。这个方案正确性没问题,但复杂度是O(n!),n一旦大起来,就是一场灾难。

简单算一笔账:n等于10时,全排列是360多万个,内存勉强还能扛住;n等于12,直接逼近4.8亿个排列;n等于15,排列总数超过一万亿。在这种量级下,生成所有排列再查找,任何在线评判系统都不会给你通过的机会。更不用说递归生成全排列时还要维护路径、处理去重、拷贝数组,代码复杂度一点也不低。一个正确但几乎不可用的方案,在算法题里就是零分方案。

1.2 把排列当数字加一:方向错了,加得再多也不是排列

第二类错解比较有迷惑性,因为它利用了字典序和整数大小比较的相似性。有人把数组[1,2,3]看成整数123,加一得到124,再拆成[1,2,4],认为这就是答案。问题立刻就来了:排列要求数字来自给定的集合且不重复,124根本不是一个合法排列。

再举一个例子,[1,3,2]如果按数字加一,变成134,拆开后得到的[1,3,4]不仅超出了集合,还直接跳过了正确答案[2,1,3]。这是因为排列的字典序虽然有"逐位比较"的特征,但它没有十进制那种固定进位规则,不能用数值加法模拟。把字典序当成普通整数运算,是典型的"看着像就往上套"。

1.3 从最左边改:不知道"下一个"到底藏在右边哪里

第三类错误藏在标准解法附近。有些人看完题解后,会习惯性从数组左边开始找该改的数字。比如面对[1,5,8,4,7,6,5,3,1],心想1已经是最小开头了,那就把第二位5换大一点,变成[1,8,?, ?, ...],结果直接从[1,5]开头跳到了[1,8]开头,中间以[1,5]为前缀的所有排列全部被跳过。

字典序下的"下一个"要求严格意义上的相邻,意味着只要后缀还有继续变大的空间,前面任何一位都不能动。只有整个后缀已经大到不能再大时,才轮到前一位做最小幅度的调整。所以答案一定藏在"最靠右的还有增长空间的位置"附近,而不是左边某个数值较大的元素。这三条歪路有一个共同的病灶:大家都在急着找"某个更大的数",却忽略了对"下一个"这个词语的精确理解。先把这个问题想清楚,后面的三步算法就不是需要死记硬背的口诀,而是顺理成章的推导结果。

2. 字典序的底层规律:为什么答案永远藏在"最右侧的升级点"

2.1 字典序和数字比较是同一套直觉

要理解"下一个排列",先必须理解排列之间的字典序关系。你可以把它想象成查英语词典的顺序:先比较第一个字母,相同再比较第二个,还相同就继续往后。比如"apple"和"apply",前四个字母相同,第五个字母e小于y,所以"apple"排在"apply"前面。数组的字典序完全一样,从索引0开始逐位比较,遇到第一个不同的位置,数值小的排列排前面。

这种比较规则和多位整数的大小比较很像,但有一个关键区别:整数的不同位权重不同,而字典序里所有位在"决定顺序"时没有权重差异——高位永远优先。正因为高位优先,想让一个排列变成"紧挨着的下一个",最核心的原则就是:尽量保持高位不动,只在最靠右的位置做最小幅度的调整。这个"尽量靠右动刀"的原则,几乎可以解释后面每一步算法的含义。

2.2 后缀降序区:一个排列的"增长潜力"已经耗尽

看一个排列当前的状态,你会发现它由两部分组成:一个前缀和一个后缀。如果从某个位置开始,后面的元素严格递减,比如[7,6,5,3,1],那么这段后缀已经是它所能形成的最大字典序片段,没有继续增大的空间了。

以[1,5,8,4,7,6,5,3,1]为例,从索引4开始的[7,6,5,3,1]是降序区,这意味着以[1,5,8,4]作为前缀时,后面的排列已经排到了最大形态。这时候你想让整个排列变大,就不可能只在后缀内部做文章,必须"侵入"后缀前面的那个元素,也就是索引3位置的4。这个4就是整个排列增长的"闸门"。这就是为什么算法第一步要从右往左找第一个满足a[i] < a[i+1]的位置。倒过来扫描时,遇到的第一"前面比后面小"的地方,就是最靠右的、依然有增长潜力的位置。

2.3 从右向左找第一个升序对,到底在找什么

很多题解只说"找第一个升序对",但没说为什么要从右往左找,也没说这个升序对代表什么。本质原因是:我们想从排列的最末端开始,尽可能晚地扰动前缀。假设整个序列已经完全降序,比如[3,2,1],任何位置的元素都比右边大,找不到升序对,说明这个排列已经是该数字集合下字典序最大的排列,没有下一个,只能整体反转回[1,2,3]。

只要还存在升序对,排列就还没走到尽头。最靠右的那个升序对,决定了我们需要把哪一位当作"升级点"。你可以把a[i]理解成"这次要进位的那一位":它右边的所有元素都是降序,说明以它为前缀时,后面已经达到最大;现在要做的事情是,把a[i]换成"比它大一点点的某个元素",然后把右边恢复成最小排列,也就是升序,两者拼接起来就得到紧挨着的下一档。

2.4 一次交换 + 一次反转,为何恰好构成"紧挨着的下一个"

找到升级点之后,有两件事要做。第一,用谁换掉a[i]?为了不跳跃,必须选择右侧降序区里"比a[i]大的最小元素"。从右往左扫描右侧降序区,遇到的第一个大于a[i]的元素,正好是这个"刚大一点点"的值:因为区间降序,越靠右的元素数值越小,第一个大于a[i]的值就意味着大于a[i]的所有候选里最靠近a[i]的那一个。把这样一个元素换到a[i]的位置,前缀得到的新值在数值上没有中间夹着其他可用数字,因此不会跳过任何排列。

第二,换完之后怎么处理右边?交换之后右侧区域不再是降序,但还保留着一定的单调性,把它整个反转,就能得到该区域内字典序最小的排列。前缀刚好升了一小档,后缀取最小值,合在一起就是"大于原排列的所有排列中最小的那一个"。这就是"下一个排列"的精确含义。整个算法只需要一次扫描、一次交换、一次反转,不需要排序,就是因为寻找"最小的大于a[i]"和"逆序成最小后缀"两个动作合在一起,恰好完成了精确进位的全部工作。

3. 手工推导实录:拿[1,5,8,4,7,6,5,3,1]完整走完三个步骤

理论讲得再多,不如动手走一遍。我选一个足够复杂的例子:[1,5,8,4,7,6,5,3,1]。你跟着走一遍之后会发现,代码只是这个过程的自然翻译。

3.1 第一步:锁定i=3,当前排列已经走到"4"开头的最后一档

从数组最右边开始,逐对向前比较:

  • 比较a[8]=1和a[7]=3,左边小于右边吗?1 < 3,不是(我们要找的是从左往右看满足a[i] < a[i+1]的位置,从右往左开始检查时,当前位置i=7的a[7]=3,a[8]=1,3 < 1不成立,继续)。
  • 比较a[7]=3和a[6]=5,3 < 5?不成立,继续。
  • 比较a[6]=5和a[5]=6,5 < 6不成立,继续。
  • 比较a[5]=6和a[4]=7,6 < 7不成立,继续。
  • 比较a[4]=7和a[3]=4,4 < 7成立,停。

所以i=3,a[i]=4。从i+1=4开始,后缀[7,6,5,3,1]严格降序。这个状态的含义是:以[1,5,8,4]作为前四位时,第五位之后的排列已经走到了最大形态[7,6,5,3,1],无法再往后走了。想继续变大,只能把第四位的4"进位"。

3.2 第二步:从右侧降区里挑出"刚好比4大一点"的5

右侧区间是[7,6,5,3,1],现在要找一个元素替换a[3]=4。如果选择7,新前缀从[1,5,8,4]跳到[1,5,8,7],跨度太大,会跳过很多中间排列。选择3或1会让前缀变小,违背"下一个"必须大于原排列的基本要求。正确做法是选择"比4大的最小元素"。

从右往左扫描右侧区间:1不大于4,3不大于4,5大于4,停。找到j=6,a[j]=5。这个5就是降序区里最靠右的、大于4的最小值。所有大于4的候选是5、6、7,选5是唯一正确选项。

3.3 第三步:交换并反转,得到[1,5,8,5,1,3,4,6,7]

先交换a[3]和a[6],数组变成[1,5,8,5,7,6,4,3,1]。注意原本的4被放到了索引6的位置,右侧从索引4开始是[7,6,4,3,1],仍然保持降序。

接着从索引4到末尾整体反转,[7,6,4,3,1]变成[1,3,4,6,7]。最终结果是[1,5,8,5,1,3,4,6,7]。

这里有一个容易出错的细节:反转的区间必须是从i+1到末尾的整个子数组,不是只反转局部。很多人漏掉"从i+1开始"这个边界,把升级点本身也反转进去,导致结果完全错误。

3.4 细节校验:为什么交换后右侧依然是降序

这个细节值得单独说。右侧区间[7,6,5,3,1]原本严格降序,我们选中j=6处的5与a[3]=4交换后,右侧变成了[7,6,4,3,1],依旧是降序。这不是巧合,背后有两条数学保证。

第一,所有位于j左侧的值(7、6)都大于5,也一定大于4,所以它们依然大于新填入的4。第二,所有位于j右侧的值(3、1)都不大于4——这里要用到"5是右侧第一个大于4的值"这个条件:如果j右侧存在某个大于4的值,按照降序排列它一定小于5,从右往左扫描时就会先遇到它,j就不会停在5。既然不存在这样的值,j右侧所有元素都小于等于4,新填入的4不会破坏降序。因此交换后右侧保持降序,反转后便得到了严格升序的最小后缀。正是因为有这层数学保障,算法才能一次反转收工,而不是交换后还得排序。

4. 代码落地:最短实现、三种语言的差别、三个边界条件

原理走通之后,代码只有十几行。下面是不依赖任何语言库函数的手写实现,以C++为主,附上Python对照。

4.1 C++实现与思路对照

void nextPermutation(vector<int>& nums) { int n = nums.size(); int i = n - 2; while (i >= 0 && nums[i] >= nums[i + 1]) --i; if (i >= 0) { int j = n - 1; while (j > i && nums[j] <= nums[i]) --j; swap(nums[i], nums[j]); } reverse(nums.begin() + i + 1, nums.end()); }

代码很短,但有两个比较符号值得反复琢磨。第一个是第一步循环里的nums[i] >= nums[i + 1],它的作用是跳过所有"已经降序"的位置。只有写成>=,才能保证最后停下来的i满足严格小于右边的条件;如果写成>,遇到重复元素时会提前停在相等的位置,逻辑直接错乱。

第二个是第二步循环里的nums[j] <= nums[i]。右侧区间可能有等于nums[i]的值,我们要找的是严格大于a[i]的最靠右元素,所以必须用<=跳过所有等于目标值的位置。这一步错一个符号,结果就是另一段完全不同的排列。

如果第一步结束时i < 0,说明整个序列从头到尾都是降序,已经是字典序最大的排列,跳过交换,直接整体反转,就回到了字典序最小的排列。这份代码天然覆盖了这个情况,因为此时reverse(nums.begin() + 0, nums.end())就是对整个数组反转。

4.2 Python版本

def next_permutation(nums): n = len(nums) i = n - 2 while i >= 0 and nums[i] >= nums[i + 1]: i -= 1 if i >= 0: j = n - 1 while nums[j] <= nums[i]: j -= 1 nums[i], nums[j] = nums[j], nums[i] nums[i + 1:] = reversed(nums[i + 1:])

Python的切片反转一行完成,整体更短。注意reversed返回迭代器,但切片赋值能正确处理。

4.3 边界一:长度小于2和纯降序序列

长度是0或1时,第一步循环根本不会进入,反转一个空区间或单元素区间等于什么都没做,所以不需要单独加判断。纯降序序列比如[3,2,1],第一步一路跳过直到i = -1,然后整个反转成[1,2,3],正好满足"不存在下一个时返回最小排列"的要求。这种接口设计有一个好处:每次调用都得到字典序轮转中的下一个状态,循环调用可以覆盖全部排列。

4.4 边界二:重复元素下找j时用>还是>=

重复元素是最容易写错的地方。来看一个具体的反例:[3,1,2,1,1]。

从右往左找升序对:a[4]=1和a[3]=1相等,跳过;a[2]=2和a[3]=1,2 < 1不成立,跳过;a[1]=1和a[2]=2,1 < 2成立,停。于是i=1,a[i]=1,右侧区间是[2,1,1]。

现在从右往左找大于1的最小元素。用正确的<=写法:j从末尾开始,nums[4]=1,1 <= 1成立,跳过;nums[3]=1,1 <= 1成立,跳过;nums[2]=2,2 <= 1不成立,停,j=2,找到第二个位置的2,正确。但如果把条件误写成nums[j] < nums[i],j从末尾开始,nums[4]=1,1 < 1为假,直接停,j=4,选中的是一个等于a[i]的值,整个算法立刻失效。所以在重复元素存在时,<=和>=不是风格偏好,而是正确性的前提。

4.5 关于二分优化:能用但没必要

第二步在右侧降序区找"大于a[i]的最小值",因为区间单调递减,理论上可以用二分把第二次扫描从O(n)降到O(logn)。但第一步找i本身就需要O(n),整体复杂度依然是O(n),空间复杂度O(1)。二分只能在常数上做一点优化,不会改变数量级。在实际刷题和绝大多数业务场景中,线性扫描就够了,而且二分需要自定义比较函数,处理重复元素时边界条件更容易出错,性价比很低。

5. 跳出一题看一类:全排列枚举与康托展开

"下一个排列"这个算法的价值,远不止解决一道题。它最大的意义在于,把全排列枚举从"递归回溯+路径记录"简化成了"循环+状态变换"。

5.1 用next_permutation循环,不用递归也能枚举全排列

先对数组排序得到最小排列,然后持续调用下一个排列,直到回到最小排列为止:

vector<vector<int>> permute(vector<int>& nums) { sort(nums.begin(), nums.end()); vector<vector<int>> res; do { res.push_back(nums); } while (next_permutation(nums.begin(), nums.end())); return res; }

这个写法有很明显的优势。没有递归深度问题,不会爆栈;天然去重,重复元素也只会枚举一次;代码量远小于回溯版本。底层实现无论标准库怎么写,核心语义都和我们前面推导的"从右往左找升序对"一致。如果你在面试中被要求手写,完全可以自己实现一个替代。

5.2 给定排列求序号:康托展开的公式与例子

知道一个排列在字典序中是第几个,是康托展开解决的问题。设排列为p[0..n-1],对于每个位置i,统计p[i]右侧有多少个比它小的数字,记为cnt[i],则排名(从1开始)的计算公式是:

排名 = 1 + Σ cnt[i] * (n-1-i)!

举一个例子,求排列[2,3,1]的排名:

  • i=0,值为2,右侧比2小的只有1,cnt = 1;
  • i=1,值为3,右侧比3小的只有1,cnt = 1;
  • i=2,值为1,右侧比1小的没有,cnt = 0。

代入公式:1 + 1×2! + 1×1! + 0×0! = 1 + 2 + 1 = 4。验证一下前4个排列是[1,2,3]、[1,3,2]、[2,1,3]、[2,3,1],[2,3,1]正好排第4。康托展开和下一个排列实际上是互逆关系:一个是从排列到序号的映射,另一个是序号附近的状态迁移。

5.3 已知序号还原排列:逆康托展开的思路

反过来,给一个排名r,要求还原排列。先把r减去1,然后不断用阶乘分解余数。举个例子,r=4,n=3:

  • r-1=3,3除以2!得到1,余数1,从剩余数字{1,2,3}中取第2小,也就是2;
  • 1除以1!得到1,余数0,从剩余{1,3}中取第2小,也就是3;
  • 0除以0!得到0,从剩余{1}中取第1小,也就是1。

得到[2,3,1],和前面的例子对上了。逆康托展开的价值在于,你可以直接跳到任意排名对应的排列,而不必从最小排列一个个递推。这两道题放在一起理解,才算真正摸到了排列序号的底层。

5.4 重复元素时依然可以套用

带重复元素的场景下,康托展开需要调整统计方式:统计比当前值小的数字时,每个数字只计数一次,并且要考虑剩余位置中重复元素的阶乘,公式会复杂不少。不过实际工程和大多数算法题中,直接调用next_permutation就能规避手工公式的复杂度,因为标准库实现已经处理了重复元素。只有在需要精确排名或随机访问第k个排列时,才需要认真实现去重版本的康托展开与逆展开。

6. 面试追问、工程落地和我踩过的几个坑

最后聊聊实战层面。这道题不是背下代码就能过关的,面试官只要多追问两层,很多人就会露馅。

6.1 面试官最想听你讲清楚的两个点,其实都是推导过程

第一个点是"为什么从右往左找第一个升序对"。标准回答不是背诵步骤,而是说清楚:右侧的降序区已经达到了该前缀下的最大排列,想要得到紧邻的下一个排列,必须在尽可能靠右的位置做一次进位。第二个点是"为什么选择比a[i]大的最小元素"。因为这样能保证改动后的前缀比原来大,且是所有可能前缀里增幅最小的,后缀再逆序成最小,整体才不会跳过任何中间排列。能把这两点当场推出来,比默写代码更能让面试官认可。

6.2 写码时最容易翻车的三件事

翻车第一件:把第一步的>=写成>。在重复元素数组里,等于没有被跳过,循环可能一路走到底,导致本不该整体反转的排列被整体反转。翻车第二件:找到i之后忘记反转右侧,或者反转范围写错。比如[1,3,2]的标准答案是[2,1,3],如果只交换不反转,会得到[2,3,1],它虽然比原排列大,但跳过了[2,1,3]这中间一个排列。翻车第三件:C++里使用std::reverse时迭代器范围出错,把nums.begin() + i + 1写成nums.begin() + i,把升级点本身也反转进去。解决这些问题的可靠方法是,找一个固定测试数组,在本地把全排列序列全部打印出来,对着字典序表逐一检查结果,而不是只凭眼睛看某一个中间状态。

6.3 工程中的真实使用场景:从SKU组合到状态枚举

很多入门者以为"下一个排列"只活在面试题里,其实业务中也有它的身影。我在一个模拟项目X里处理过订单SKU的规格组合,需要按特定优先级遍历所有组合;如果每次都用递归生成排列,数据量一上来递归深度和重复状态都会失控。改用"排序+next_permutation"的循环方案后,状态稳定,还能随时从任意中间状态续跑。类似的场景还有权限组合枚举、测试用例的排列覆盖,以及一些需要按字典序递进生成状态的算法框架。它的核心价值不是"一次性生成所有排列",而是提供一个无状态、可暂停、可续跑的顺序生成器。

6.4 我踩过的一次"二分优化"翻车经历

有一段时间我总觉得线性扫描不够优雅,非要把第二步改成二分。右侧区间是降序,我写了一个自定义比较的upper_bound,普通测试全部通过,一上[3,1,2,1,1]这种带重复元素的用例就错。排查了半天,发现是二分的边界条件没有处理好"严格大于"和"相等元素跳过"的优先级,最后把代码改回从右往左的线性扫描,一行while干净利落地解决。那次之后我的体会是:不要为了优化而优化。特别是当优化只能降低常数、不能改变复杂度量级时,正确性和可读性永远排在前面。

这道题我已经写过很多遍,现在每次用到它时,心里默念的就是三句话:找最右的升序对,换右边最接近的大数,反转后缀。只要这三句话的逻辑链条还在,任何语言、任何边界情况都不会让我发怵。排列问题的核心就是理解字典序的"进位"规则,理解了它,"下一个排列"就不再是题库里的一道题,而是一把随手可用的钥匙。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询