解析 LeetCode 31. 下一个排列:找下降点 + 交换 + 翻转

一、题目分析
(一)问题定义
给定一个整数数组 nums,每个元素代表一位数字,整个数组表示一个整数的各位排列。要求把它变成字典序中紧挨着的下一个更大排列。如果当前已经是字典序最大的排列,就返回字典序最小的排列,也就是升序排列。
比如 1,2,3 的下一个排列是 1,3,2。3,2,1 没有下一个更大排列,所以返回 1,2,3。1,1,5 的下一个排列是 1,5,1。
(二)核心挑战
- 不能暴力生成所有排列再排序比较,必须在线性时间内完成
- 必须原地修改数组,不允许新建等长数组存储中间结果
- 字典序的增量要刚好一步,不能跳过或重复,比如 1,2,3 → 1,3,2 是对的,跳到 2,1,3 就错了
二、算法思路:找最长非递增后缀 + 交换 + 翻转
(一)为什么先从右往左找下降点
- 字典序比较是从左到右逐位比大小,要让结果刚好变大一点,就得尽量保持左边不变,只动最靠右能改的位置
- 从右往左扫描,找到第一个满足 numsi < numsi+1 的下标 i,说明 i 右侧是一段非递增序列(比如 5,4,3,2,1 或 5,4,3,3,1),这段内部已经排成该前缀下最大的字典序,没法再靠它自己变大,必须把 numsi 换掉
- 这个 i 就是 pivot,是唯一需要被替换的位置
(二)为什么在右侧找最后一个大于 numsi 的数,而不是第一个
- 因为右侧是非递增的,从右往左遍历,第一个遇到的大于 numsi 的数,就是右侧里最小的那个大于值
- 比如 numsi = 3,右侧是 5,4,4,2,1,从右往左扫,先看到 1 不行,2 不行,4 符合,这就是最小的大于 3 的数,选它交换后整体增量最小
- 如果选最左边那个 5,得到的排列就跳得太远了
(三)为什么交换后一定要翻转右侧子数组
- 交换完成后,numsi+1...n-1 仍然保持非递增(因为原来非递增,只是把末尾一个较大数换到了前面,但没破坏整体趋势)
- 而我们要的是该新前缀下的最小后缀,才能保证整体是下一个排列,不是下下个
- 所以把 numsi+1...n-1 翻转成升序,就得到了字典序最小的合法后缀
- 举例:1,2,3,6,5,4 → 找到 i=2(nums2=3),右侧 6,5,4 中最后一个大于 3 的是 4,交换得 1,2,4,6,5,3,再翻转 6,5,3 成 3,5,6,最终 1,2,4,3,5,6
三、代码实现与详细解析
java
public void nextPermutation(int[] nums)
{
int n = nums.length;
// 初始化 i 为倒数第二个位置,准备从右往左找下降点
int i = n - 2;
// 向左扫描,跳过所有非下降位置:nums[i] >= nums[i+1] 就继续
while (i >= 0 && nums[i] >= nums[i + 1])
{
i--;
}
// 如果 i >= 0,说明找到了下降点,需要交换
if (i >= 0)
{
// j 从末尾开始往左找,找第一个大于 nums[i] 的数
int j = n - 1;
while (j > i && nums[j] <= nums[i])
{
j--;
}
// 找到了,交换 nums[i] 和 nums[j]
swap(nums, i, j);
}
// 无论是否找到下降点,都要把 i+1 到末尾翻转成升序
// 若没找到(i == -1),相当于翻转整个数组,把降序变升序
reverse(nums, i + 1, n - 1);
}
private void swap(int[] nums, int i, int j)
{
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
private void reverse(int[] nums, int left, int right)
{
while (left < right)
{
swap(nums, left, right);
left++;
right--;
}
}
(一)代码流程拆解
- 初始化:设 n = nums.length,i = n-2,从倒数第二位开始向左扫描
- 找下降点:while 循环判断 numsi >= numsi+1,一直左移直到不满足或 i < 0。退出时 i 是 pivot 或 -1
- 判断分支:if (i >= 0) 决定是否执行交换逻辑,避免越界访问
- 查找交换对象:j 从 n-1 开始左移,跳过所有小于等于 numsi 的数,停在第一个更大的位置
- 执行交换:调用 swap 把 pivot 位和右侧选中的数互换
- 统一翻转:reverse 始终作用于 i+1 到末尾,这个范围恰好是原非递增后缀,翻转后变成升序,确保最小后缀
(二)关键逻辑解析
pivot 位置定义: numsi < numsi+1,且 i 右侧完全非递增。例如 1,3,2 中 i=0(nums0=1 < nums1=3),右侧 3,2 非递增
交换目标选择: 在右侧非递增段中找最后一个大于 numsi 的数,即从右往左第一个符合条件的。例如 numsi=2,右侧 5,4,3,2,1,选 3 而不是 5,避免跳步
翻转范围固定: 一定是 i+1 到 n-1,不会多也不会少。因为 pivot 已确定,右侧段长度就是 n-1-i,翻转后正好构成最小后缀
边界全覆盖: 当数组长度为 1,i 初始化为 -1,while 直接跳过,reverse(nums, 0, 0) 不执行任何交换,结果不变。当全降序如 3,2,1,i 最终为 -1,reverse 翻转整个数组得 1,2,3
四、复杂度分析
(一)时间复杂度
O(n)。最坏情况扫描三次:第一次找 pivot 最多 n-1 步,第二次找交换点最多 n-1 步,第三次翻转最多 n/2 步,总步数与 n 成正比
(二)空间复杂度
O(1)。只使用了 i、j、temp、n 四个整型变量,没有额外数组,也没有递归调用栈