☰
LeetCode 31 下一个排列:字典序算法与降序后缀的工程实践
2026/10/13 2:52:19 网站建设 项目流程

1. 问题拆解:从“全排列的顺序”说起

关于“31.下一个排列”这道题,我想先聊一个看起来简单、但很多人第一次接触时都会卡住的问题:计算机怎么理解“字典序”?

你可以把数组[1,2,3]的所有全排列,按从小到大排成一个列表:

[1,2,3] [1,3,2] [2,1,3] [2,3,1] [3,1,2] [3,2,1]

这个列表里的顺序,就是字典序。字典序的规则并不复杂:两个排列从头开始逐位比较,第 i 位数字更小的那个排列排前面。如果前几位完全相同,就比较下一位,直到分出大小。

那么“下一个排列”要做的事情,就是给定一个排列,找出它在字典序中紧挨着的后一个排列。比如输入[1,3,2],下一个就是[2,1,3]。如果当前排列已经是字典序中最大的那个,也就是降序排列,比如[3,2,1],那就回到最小的排列[1,2,3]。

这道题看起来只是一个原地修改数组的要求——题目明确要求不能申请额外空间,只能用常数级别额外空间来完成——但它背后考察的其实是对“全排列生成顺序”的理解深度。如果你对字典序的生成规律不熟悉,很容易一上来就想着先求出全排列再去找下一个,虽然思路直观,但复杂度完全无法接受,全排列的时间复杂度是阶乘级别的。这道题想要的正解,时间复杂度只有 O(n),空间复杂度 O(1)。

我第一次刷到这道题的时候,说实话,第一反应也是“直接调库”。很多语言的标准库都内置了现成的下一个排列方法,比如 C++ 里的next_permutation,Python 里虽然没直接同名函数,但组合工具里也有相关能力。但如果是面试场景,或者需要在限定环境下手写实现,理解库函数底层的原理就变得非常关键。这道题的价值就在于,它强迫你把库函数背后的算法逻辑想清楚,而不是永远当一个黑盒调用者。

先说清楚目标读者:这道题适合算法入门不久、正在刷数组/双指针类题目的人,也适合已经开始接触回溯、但纠结于“全排列到底怎么来”的开发者。整道题一共就三步逻辑,但每一步都有容易踩的坑。

2. 核心规律的直觉建立:从“降序后缀”说起

要理解下一个排列的算法,先得理解一个关键观察——降序后缀。

我们看[1,5,8,4,7,6,5,3,1]这个排列。从右往左看,末尾的[3,1]是降序,[5,3,1]还是降序,[6,5,3,1]依然是降序,直到[4,7,6,5,3,1]这里,4 小于 7,降序被打破了。

打破降序的这个位置,就是整个算法的入口。

为什么要特别关注“降序后缀”?因为字典序的本质是“高位优先”。排列的高位越靠前,它的权重大。我们要找到“下一个更大的排列”,本质上就是要找到一个恰好比当前排列大一点点的排列。那么这个“大一点点”应该发生在哪一位?

答案是:发生在最后一个“还有资格变大”的位置上。更准确地说,是从右往左数第一个打破降序的位置。想要通过调整让排列变大,又不希望变得太大,就必须把调整点放在尽可能靠右的位置。

我习惯用一个生活化类比来理解这件事——把它想成数字进位。比如数字123,下一个数字是124,变化发生在个位,不是十位。因为个位是最低位的“调整点”,往后再没有更低位了;如果个位已经满到无法增加,比如129,下一个数字是130,变化就发生在十位。永远从最低那一位尝试进位,找不到可进位的位置,再往更高一位看。

排列也是类似的。全排列里,“权重最低”的位置是数组末尾。末尾已经是降序时,说明这个后缀里不存在更小的排列了——也就是说后缀已经到了它的最大形态。想继续变大,必须把改动点往前挪,打碎更靠前的降序。

所以算法第一步一定是:从右往左扫描,找到nums[i] > nums[i-1]的第一个位置i-1,也就是第一个需要变大的位置。如果整个数组从右往左全是升序——等价于从左往右看是降序——那就说明这个排列本身已经是字典序最大的排列,直接反转整个数组返回。

这一步复杂度是 O(n) 的扫描,但它同时给出了一个很重要的结论:在i-1位置右侧的所有元素,都是从大到小排列的,也就是一个降序序列。这个结论是后续第二步选择交换对象的基础。

也许有人会问:为什么右后缀一定是降序?因为i-1是第一个打破降序的位置,意味着从末尾一路扫描到i,每个相邻对都是nums[k] > nums[k-1],等价于整个[i:]区间内严格降序。这个性质不是巧合,而是扫描规则的直接结果,后续反转操作的依据也正是这一点。

3. 三步核心算法推导:为什么是“交换+反转”

标准解法分为三步,我先给出完整流程,再逐条解释每个步骤背后的逻辑。

第一步,从右向左找到第一个nums[i] < nums[i+1]的位置i。如果找不到,说明整个数组是降序排列的,反转整个数组后返回。这里的i就是我们要调整的“低位进位点”。

第二步,从右向左找到第一个大于nums[i]的元素nums[j],交换nums[i]和nums[j]。这个步骤保证了交换后位置i上的值变大了,而且变大的幅度是“刚好超过原来一点”的最小幅值。

第三步,将i+1到数组末尾的区间反转。因为交换后,该区间依然是严格降序,反转成升序后,它就是该区间元素能组成的最小排列,保证整体“只多不少且增量最小”。

三个步骤环环相扣,缺一不可。

3.1 第二步到底在找什么:严格大于,不是大于等于

很多人第一次写代码时会把第二步写成“找到第一个大于等于nums[i]的元素”,这是一个隐蔽的错误。

为什么必须严格大于?我们回顾第二步的目标:在i位置右侧所有降序元素中,挑选一个nums[j]与nums[i]交换。这个j是“从右往左”第一个大于nums[i]的元素,本质上是在降序序列中找大于nums[i]的最小元素。

如果降序序列中存在与nums[i]相等的元素,而我们的循环条件写成了>=,取到的就是相等的那个,交换之后nums[i]完全没有变大,后面反转升序出来的排列可能比原始排列还小,这与“下一个更大的排列”的目标直接冲突。

为什么会存在相等元素还影响结果?关键在于交换后的后缀状态。我们用[2,3,1,3]这个例子来推演。从右往左扫描,3 > 1,i = 1,nums[i] = 3。从右往左找第一个大于 3 的元素,当循环条件写成严格大于时,找到末尾的 3,交换后[3,3,1,2],反转[1,2]得到[3,3,1,2]。但原始排列的字典序下一个应该是什么?实际上原来的[2,3,1,3]后面确实应该直接跳到[2,3,3,1]。这里我举的例子如果改成[2,1,3,3]更直观:降序后缀是[3,3],打破降序的位置是第二个元素 1,右侧大于 1 的最小元素是 3,交换后[2,3,1,3],再把[1,3]升序排成[1,3],得到[2,3,1,3]。如果第二步拿的是另一个 3,结果也一样,因为相等元素互换不影响排列内容,但写代码时用大于等于条件仍然不严谨,在存在重复元素的边界用例下可能出问题。更稳妥的实践是坚持“严格大于”,语义清晰,也符合算法定义。

这个细节,如果只是读题不实际写代码,很难发现。我从个人经验出发,建议在本地跑一个标准库函数和手写实现的对拍测试,专门构造含重复元素的用例,很快就能定位到这一处差异。

3.2 第三步为什么要反转:交换后后缀的固定形态

交换完成之后,数组形态是这样的:在i+1到末尾的区间内,元素依然保持降序。为什么?

因为原始数组中,区间[i+1:]是降序,这是第一步扫描得到的结论。第二步交换时,nums[j]是从这个降序区间中取出的,而nums[i]是从区间左侧插入到这个降序序列中的。因为nums[i]小于右侧所有大于它的元素中的最小值,所以插入后,这个区间从i位置处被拆开,但拆开后的两段,左边那段依然降序,右边那段也依然降序,且左边那段的所有元素都不小于右边那段的元素。换句话说,[i+1:]整体依然保持降序。

举个例子,[1,3,2]中,i = 1,nums[i] = 3。右侧降序后缀只有一个元素[2],但这一步内部并没有可见的交换复杂性。换一个更明显的例子:[1,5,1]。扫描发现1 < 5,i = 1,nums[i] = 5。从右往左找第一个大于 5 的元素——不存在,因为1不大于5。实际上对于[1,5,1],第一步找到的破降序位置是i=1,右侧区间是[1],从右往左找大于nums[i]的元素,没有,这一步不会进入。等等,这里需要重新审视:正确的前置条件是nums[i] < nums[i+1]。[1,5,1]中,5 > 1,所以第一步其实找不到i满足nums[i] < nums[i+1],直接进入反转流程,结果是[1,1,5]。我的例子给错了。

重新来。[2,3,1]:从右往左,1 < 3不成立,3 > 1,所以找到i = 1,nums[i] = 3。右侧区间是[1],从右往左找比3大的元素,没有,这里就不对了,因为[2,3,1]的下一个排列应该是[3,1,2],它的调整点应该是i = 0,而不是i = 1。问题出在第一步扫描条件上:找到的是第一个满足nums[i] < nums[i+1]的位置,而不是满足nums[i] > nums[i+1]的位置。

从右往左扫描要找的是“左边的数小于右边的数”这对关系,比如[1,3,2]中,1 < 3,位置i = 0就是调整点。所以扫描条件是nums[i] < nums[i+1],不是大于。上面示例中的语句应该改为:[2,3,1]中,从右往左,1 < 3不成立,3 > 1,所以不满足条件,直到2 < 3成立,i = 0,调整点是数组首元素 2。

我再整理一个完全正确的演示序列:

例子:[1,2,7,4,3]

第一步扫描:从右往左,3 < 4 不成立,4 < 7 不成立,7 < 2 不成立,2 < 1 不成立?不对,顺序反了。从数组末尾开始比较nums[i] < nums[i+1],最后一个比较是nums[0]=1和nums[1]=2,确实是1 < 2,所以i = 0。但直觉上,调整点应该是数字 2 而不是数字 1,因为后缀[7,4,3]整体降序,[1,2]中的 2 才有资格变大。这里的问题是扫描应该从倒数第二个元素开始往前比较,nums[i]和nums[i+1],发现第一个升序的相邻对。[1,2,7,4,3]中,相邻对从右往左依次是(4,3)降序,(7,4)降序,(2,7)升序,找到,i = 1,也就是数字 2。这才是正确的调整点。

交换:从右往左找第一个大于 2 的元素,依次找到 3,交换,得到[1,3,7,4,2]。反转i+1=2到末尾[7,4,2],反转后[2,4,7],最终结果是[1,3,2,4,7]。

而[1,2,7,4,3]的字典序下一个确实应该是[1,3,2,4,7]。手写验证:以[1,2,...]开头的排列中,1,2,7,4,3是1,2前缀下最大的排列,因为后缀已降序。下一个只能提升第二个位置到 3,后缀取最小[2,4,7],得到1,3,2,4,7。完全吻合。

3.3 三个步骤的复杂度账

每一步都只有一次线性扫描或一次线性反转,整体时间复杂度是 O(n),额外只用了常数个变量,空间复杂度 O(1)。这个复杂度意味着,即使数组长度达到百万级别,也能在极短时间内完成计算。

对比暴力做法:先枚举所有排列,排序后查找当前排列,再取后面一个,时间复杂度至少 O(n!),空间复杂度 O(n!),完全不可用。理解这道题的正解,本质上就是理解了为什么不需要枚举也能定位下一个排列。

4. 代码落地:C++ / Python 双语言实现与细节打磨

在这一节里给出手写实现的最佳实践。

4.1 C++ 实现

class Solution { public: 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; // 从右向左找到第一个大于 nums[i] 的元素 while (j >= 0 && nums[j] <= nums[i]) { j--; } swap(nums[i], nums[j]); } // 反转降序后缀为升序 reverse(nums.begin() + i + 1, nums.end()); } };

有三处细节值得展开。

第一,i的初始值是n - 2,从倒数第二个元素开始,这样才能比较nums[i]和nums[i+1],把最后两个元素包含在扫描范围内。

第二,第一步扫描条件用nums[i] >= nums[i + 1]。等号是必须的。如果相邻两个元素相等,从字典序角度看,它们不构成严格升序,因此不应当在这里设置调整点。比如[1,1,2],从右往左扫描,2 > 1,不满足>=,停在了i = 1的位置,这里的调整是数字 1(第二个)变到 2,得到[1,2,1],验证正确。如果没有等号,条件写成>,同样这个例子里因为1 < 2也会停下来,结果一致。但考虑[1,2,2]:从右往左,2 == 2,带等号时条件成立继续左移,1 < 2停止,i = 0,从右找第一个大于 1 的元素是 2(最右边的),交换后[2,2,1],反转后缀[2,1]为[1,2],得到[2,1,2],这是正确的。如果不带等号,[2,2]的相等关系会被误判成升序,导致i = 1,交换后[1,2,2]反转空后缀,结果还是[1,2,2],没有推进。所以等号不仅不能省略,而且是关键。

第三,第二步扫描条件用nums[j] <= nums[i],同样必须带等号。在降序后缀里跳过相等的元素,从右往左找的第一个严格大于nums[i]的元素,就是最靠近右端的大于元素。如果是找“最小的大于元素”,但降序后缀中更大的元素反而在左端。这里需要想清楚:为什么从右往左遇到的“第一个大于”就等于“最小值大于”?

因为降序后缀从右到左是递增的。例如[7,4,3]从右往左是 3、4、7,逐渐变大。从右往左找第一个大于 2 的元素,先看 3,3 > 2,直接命中。3 确实是降序后缀中大于 2 的最小值。假如没带等号,遇到等于nums[i]的元素时会停下,然后把一个相等的数交换到前端,不仅没有让排列变大,还会破坏后缀的有序性,得到错误的排列。所以等号的意义是跳过相等项,确保命中的是“严格大于且最小”。

4.2 Python 实现

class Solution: def nextPermutation(self, nums: List[int]) -> None: """ Do not return anything, modify nums in-place instead. """ n = len(nums) i = n - 2 while i >= 0 and nums[i] >= nums[i + 1]: i -= 1 if i >= 0: j = n - 1 while j >= 0 and nums[j] <= nums[i]: j -= 1 nums[i], nums[j] = nums[j], nums[i] left, right = i + 1, n - 1 while left < right: nums[left], nums[right] = nums[right], nums[left] left += 1 right -= 1

Python 里有一个容易忽略的坑:题目要求原地修改数组,不能返回新列表,也不能在函数内把nums重新赋值成另一个列表。如果写成nums = reversed(nums)之类,在函数外看到的nums并没有变化,因为只是让局部变量指向了新的对象。要确保修改的是传入列表本身,必须对列表元素逐个赋值,或者用切片赋值。切片赋值nums[:] = ...也可以,但手写双指针反转更符合算法原意。

4.3 为什么手动反转而不是直接调用排序

有人可能问,第三步用sort行不行?排序确实能把降序后缀转换为升序,但排序的时间复杂度是 O(m log m),其中 m 是后缀长度。手工反转只需要 O(m) 时间,而且后缀已经被前面步骤保证是降序的,反转比排序更精准地利用了信息。

另一个角度是,反转是在已知“必然降序”的前提下进行的。如果某个实现不在交换后保持降序性质,那反转就会失效,所以反转也反过来验证了前面两步执行的正确性。这是一个很好的自检思路:如果反转后缀后结果还是错,回头检查前两步是否破坏了降序结构。

5. 常见问题与排查技巧实录

刷题时最容易出问题的几个点,我整理成速查表,每个都是我实际在运行和调试中遇到过的。

问题现象常见原因排查方法
输入[3,2,1]输出不是[1,2,3]第一步没判断i < 0,直接对不存在的i做交换或反转检查if (i >= 0)是否只包裹交换逻辑,反转逻辑是否在其外
输入[1,1,5]结果变成[1,1,5]或错误排列第一步或第二步遗漏等号重点排查>=和<=,单独跑重复元素用例
输入[1,3,2]结果变成[2,3,1]而不是[2,1,3]第三步没有反转后缀检查反转区间是否写成了[i+1:]而不是[i:]
输入[1,2]结果没变化第一步错误地从i = n - 1开始确认i初始化为n - 2
输出正确但提交超时使用了全排列或排序方案确认只有一次线性扫描和一次线性反转

5.1 一个隐蔽的边界:单个元素数组

nums = [5],n = 1,i初始化为-1,第一步循环直接跳过,i < 0,所以不进入交换,直接反转从i+1=0到末尾,也就是整个数组。反转长度为 1 的数组,结果还是[5]。从语义上讲,单个元素的排列只有一个,它的下一个就是它自己。这个行为是合理的。

但有些实现里,如果反转函数写的是reverse(nums.begin() + i + 1, nums.end()),在 C++ 中,如果i + 1等于n,begin() + n是合法的尾后迭代器,反转区间为空,也没有问题。Python 中切片[i+1:]或反转区间的双指针也要注意索引不要越界。

5.2 对拍测试:让两个实现互相验证

我想分享一个平时刷算法题很实用的做法:在本地用标准库实现和手写实现做对拍。

比如在 Python 中,可以先用itertools.permutations生成所有排列,按字典序排序,然后对每个排列调用手写函数,验证得到的结果与排序列表中下一个元素是否一致。在 C++ 中,直接用标准库的next_permutation与手写实现对比。

对拍时一定要覆盖这些类型:

  • 升序数组:[1,2,3,4,5]
  • 降序数组:[5,4,3,2,1]
  • 有重复值:[1,2,2,3]、[2,2,2]
  • 长度极小:[1]、[1,2]、[2,1]
  • 后缀完全降序且长度大于 2:[1,4,3,2]
  • 全局降序但首尾相接:[2,1]

对拍逻辑非常简单:

import itertools def brute_force(nums): perms = sorted(set(itertools.permutations(nums))) idx = perms.index(tuple(nums)) return list(perms[(idx + 1) % len(perms)])

对拍是我自己排查第三类问题([1,3,2]变[2,3,1])时最快定位的手段。手写实现丢进去对拍一轮,几百个用例跑下来,错误模式会非常明显。

5.3 替代思路辨析:为什么“暴力 + 剪枝”不可取

有人可能想,既然全排列能解决,为什么不在生成时记录当前排列的位置,提前终止?这确实是一种“笨但正确”的解法。但注意题目要求原地修改,而且全排列的生成过程本身就需要 O(n!) 次递归。即使剪枝,找到当前排列之后还要生成下一个,而下一个排列和当前排列之间可能隔着大量不必要的组合。在数据规模较大时,这种方法会超时。正确解法是词典序生成排列的数学性质直接推导的,从原理上绕开了枚举。

6. 从一道题到一类题:字典序算法的应用价值

这道题看起来只是刷题,实际上字典序下一个排列的思想在现实开发中到处可见。

6.1 全排列生成与回溯剪枝优化

生成全排列最典型的方式是回溯。但如果要求按字典序输出所有排列,而且需要在生成过程中跳过某些不满足约束的排列,那么理解“下一个排列”的数学规律可以帮你直接跳转,而不是逐一遍历。例如某个场景下只需要第 k 个排列,可用康托展开(Cantor Expansion)直接算出第 k 个排列的元素分布,而它的逆过程依赖的正是“剩余元素按字典序排列”的规则,与本题第三步反转后缀的思路一脉相通。

第 k 个排列的问题,我的个人经验是:先用小规模数据手算一遍康托展开,再对照代码看每一步减掉的数量是怎么来的。很多教程直接丢公式,看完了照样不会写。搞懂本题的降序后缀后,再回头理解康托展开会顺畅很多。

6.2 数组元素组合的下一个状态

在一些业务系统中,需要遍历一个集合的所有组合或排列状态,比如排课表中的教师分组、抽奖活动的号码排列规则、问卷题目的选项顺序轮换等。字典序的下一个排列算法提供了一种无状态、无碰撞的遍历方式:只要记住当前排列,就能算出下一个。不需要存储整个状态空间,也不需要在内存中维护一个巨大的全局表。

6.3 降序后缀的巧用:下一个更大的整数

LeetCode 平台上有一道“下一个更大元素”系列题,与本体的区别在于,一个是数组间的位置关系,一个是整体排列的大小关系。另有一道“数字组合中的下一个更大整数”变体题,比如给定整数123857,找出用相同数字组成且大于该数的最小整数。这个变体的本质就是本题:把整数拆分成数组,执行一次nextPermutation,再拼回去。一遍过这个变体题时,就懂了本题的跨界应用价值。

6.4 面试时的思考路径展示

面试官拿到这题,看重的不一定是能默写代码,而是能否通过观察降序后缀推导出交换策略。我在不同场合模拟面试时发现,有一种表达比较加分——先讲“我需要找到一个尽可能靠右的、还能变大的位置”,再说明“右侧降序意味着该后缀是最大形态”,再解释“交换右侧大于当前位的最小元素,让当前位恰好变大”,最后说“反转后缀变成升序才能让全局最小”。这样四步讲下来,既有原理推导,也有操作动机,比上来直接背代码清晰得多。

7. 实操总结与最后叮嘱

这道题我前前后后刷过多遍,每次的体会都不太一样。最初只是记住“三步走”,后来在重复元素用例上栽过跟头,再往后才真正理解每一步的数学依据。这里分享几个我个人的实操习惯。

第一,写代码时把第一步循环的终止条件拆开想清楚。i最后停下来的位置有两种情况:要么找到一个合法的升序邻接对,要么i从-1越界退出。后面无论是交换还是反转,都依赖这个状态。最好在测试用例中同时覆盖这两种情况,确保它们各自走得通。

第二,反转区间千万别写错。很多人会下意识写reverse(nums[i:]),把nums[i]也包含进反转范围,结果整个排列推翻重来。正确范围是从i + 1开始。记住:调整点本身是已经变大过的数,不需要动,要动的是它右边的降序大尾巴。

第三,在本地调试时用重复元素构造用例。不要只盯着[1,2,3]这种理想输入,现实中的数据几乎都会带重复。[1,1,2]、[2,2,2]、[2,2,3,1]这几个用例能测试到等号条件的全部分支。我自己是把这三个用例写进测试文件固定跑的,任何修改后先跑一遍,确认没问题再提交。

第四,如果面试时被要求分析复杂度,不要只说 O(n) 和 O(1)。面试官想听到的关键词是“一次线性扫描”“常数级别额外变量”“降序后缀的反转是线性操作”。把这些结论和代码步骤逐一对应起来,会比背诵复杂度结论更有说服力。

这道题还有一个不太起眼但很重要的知识点:为什么反转后缀后一定能得到“最小后缀”?因为降序后缀反转后就是升序,升序排列是该区间元素能组成的最小排列。而区间元素集合在交换前后保持不变,只是排列顺序变了。所以这个反转操作既保证了下一个排列存在,也保证了它是“最小”的增长量。

最后再分享一下对拍工具的具体用法:先把标准库实现写成一个独立函数,打印出每一步的结果,用手写实现逐步比对。如果某个用例两个函数输出不同,缩小数组长度到 3 到 4 个元素,手动穷举全部排列,找出首个不一致的位置,基本一眼就能看出是等号问题还是反转范围问题。这种排查方式比盯着代码死看效率高得多。

本题的代码量不大,但信息密度不小。真正吃透它,全排列、字典序、康托展开这些相关概念都会跟着清晰起来。如果你正在刷算法题,建议把这道题和回溯全排列的题目放在同一天练习,互相印证理解。我实际试下来,双题组合练比单独刷一道记忆效果要好很多。

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

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

立即咨询