一、题目
给定一个整数数组 nums,表示一个排列。
要求将它原地修改成:
字典序中紧挨着当前排列的下一个更大排列。
如果已经是最大的排列,则变成最小排列。
例如:
[1,2,3]
下一个排列:
[1,3,2]
而:
[3,2,1]
已经是最大排列,所以变成:
[1,2,3]
二、什么叫"下一个排列"?
例如:
[1,2,3]
所有排列按从小到大排列:
123
132
213
231
312
321
所以:
123
的下一个排列就是:
132
不是随便找到一个更大的排列,例如:
213
虽然也更大,但它不是"紧挨着"的下一个。
因此整道题真正的要求是:
比当前排列大,但增大的幅度要尽可能小。
三、核心思想:尽量从右边修改
假设:
1 2 7 4 3 1
如果想得到一个更大的排列,我们当然不希望一上来修改最左边。
因为越靠左的位置,权重越大。
例如:
127431
如果把第一位 1 改大:
2xxxxx
整体一下子就变大很多。
所以我们的目标是:
尽量保持前面的数字不动,只修改尽可能靠右的位置。
这就是为什么第一步一定:
从右往左找
四、最终算法只有三步
整道题可以压缩成:
1. 从右往左找第一个 nums[i] < nums[i+1]
2. 从右往左找第一个 nums[j] > nums[i]
然后交换 nums[i] 和 nums[j]
3. 反转 i+1 到末尾
可以记口诀:
找小
找大
交换
反转
五、第一步:为什么找 numsi < numsi+1?
代码:
int i = nums.length - 2;
while (i >= 0 && nums[i] >= nums[i + 1]) {
i--;
}
最终希望找到:
nums[i] < nums[i+1]
例如:
[1,2,7,4,3,1]
从右向左看:
3 > 1
4 > 3
7 > 4
这些都在下降。
继续:
2 < 7
于是:
i = 1
nums[i] = 2
此时:
1 2 | 7 4 3 1
↑
i
六、为什么后面的降序这么重要?
后缀:
7 4 3 1
已经是降序。
对于这几个数字来说:
7431
已经是它们能够组成的最大排列。
换句话说:
只重新排列后面的 7、4、3、1,已经无法得到比当前更大的排列。
所以如果整个数组还要继续变大:
必须修改 nums[i]
也就是这里的:
2
因此:
从右找到第一个 nums[i] < nums[i+1]
实际是在寻找:
最靠右、还能让整个排列变大的位置。
七、为什么不能从左边找?
因为我们的目标不是:
找到任意一个更大的排列
而是:
找到最接近当前排列的更大排列
所以要:
尽可能保持高位不变
因此必须从:
右边
开始尝试。
八、第二步:应该把 numsi 换成谁?
现在:
1 2 | 7 4 3 1
已经找到:
nums[i] = 2
要让排列变大,就必须把这个 2 换成一个:
> 2
的数字。
右边:
7 4 3 1
比 2 大的有:
7
4
3
应该选谁?
如果选:
7
变化太大。
如果选:
4
还是比必要的大。
最好的就是:
3
所以目标是:
找到右侧所有大于
nums[i]的数字中最小的那个。
九、为什么从右边找第一个大于 numsi 的数?
代码:
int j = nums.length - 1;
while (nums[j] <= nums[i]) {
j--;
}
因为我们已经知道:
i 后面的部分是降序
例如:
7 4 3 1
从右往左看:
1 3 4 7
所以第一个:
> 2
的就是:
3
而且它正好是:
比 2 大的最小数字。
因此:
swap(nums, i, j);
十、交换以后发生什么?
原来:
1 2 7 4 3 1
交换:
2 和 3
得到:
1 3 7 4 2 1
现在前两位:
1 3
已经保证:
新排列 > 原排列
但是:
1 3 7 4 2 1
还不是"下一个排列"。
因为后面的:
7 4 2 1
太大了。
十一、第三步:为什么需要把后缀变成最小?
我们已经通过:
2 → 3
让整个排列变大。
既然高位已经满足:
比原来大
那么后面的数字就应该:
尽可能小。
于是希望:
7 4 2 1
变成:
1 2 4 7
最终:
1 3 1 2 4 7
这才是距离原排列最近的更大排列。
十二、为什么不需要 Arrays.sort()?
理论上,可以把:
i+1 ~ n-1
排序成升序。
但我们已经知道:
原后缀是降序
交换以后,它仍然保持非递增结构。
因此只需要:
反转
就可以得到升序。
例如:
7 4 2 1
反转:
1 2 4 7
所以:
reverse(nums, i + 1, nums.length - 1);
时间复杂度:
O(n)
而不用:
O(n log n)
的排序。
十三、完整例子:1,2,7,4,3,1
原数组:
1 2 7 4 3 1
第一步:找 i
从右:
3 >= 1
4 >= 3
7 >= 4
继续:
2 < 7
所以:
i = 1
nums[i] = 2
第二步:找 j
从右找:
> 2
先看:
1
不行。
再看:
3
可以。
所以:
j = 4
nums[j] = 3
交换:
1 3 7 4 2 1
第三步:反转后缀
反转:
7 4 2 1
得到:
1 2 4 7
最终:
1 3 1 2 4 7
所以:
[1,2,7,4,3,1]
的下一个排列:
[1,3,1,2,4,7]
十四、再看最简单的 1,2,3
1 2 3
第一步:
2 < 3
所以:
i = 1
第二步:
3 > 2
所以:
j = 2
交换:
1 3 2
后缀只有一个:
2
反转不变。
结果:
1 3 2
正确。
十五、再看 1,3,2
原数组:
1 3 2
第一步从右:
3 >= 2
继续:
1 < 3
所以:
i = 0
nums[i] = 1
第二步从右找:
> 1
最右边:
2
满足。
交换:
2 3 1
然后反转:
3 1
变成:
1 3
最终:
2 1 3
排列顺序:
123
132
213
完全正确。
十六、特殊情况:完全降序
例如:
3 2 1
这是当前所有数字能够组成的:
最大排列
执行:
int i = nums.length - 2;
开始:
i = 1
判断:
2 >= 1
所以:
i = 0
继续:
3 >= 2
所以:
i = -1
也就是说:
没有找到任何一个
nums[i] < nums[i+1]。
这说明整个数组:
完全非递增
已经是最大排列。
十七、i = -1 怎么处理?
代码:
if (i >= 0) {
...
}
因为现在:
i = -1
所以交换部分直接跳过。
最后:
reverse(nums, i + 1, nums.length - 1);
这里:
i + 1 = 0
所以相当于:
reverse(nums, 0, nums.length - 1);
反转整个数组:
3 2 1
↓
1 2 3
正好得到最小排列。
所以这个模板连特殊情况都自然处理了。
十八、为什么第一步判断是 >=?
代码:
while (i >= 0 && nums[i] >= nums[i + 1]) {
i--;
}
这里不是:
nums[i] > nums[i+1]
而是:
nums[i] >= nums[i+1]
因为数组可能有重复元素。
例如:
1 5 5 4
后缀中的:
5 5 4
仍然属于:
非递增
所以相等也要继续向左找。
最终我们寻找的是:
严格满足 nums[i] < nums[i+1]
的位置。
十九、为什么第二步判断是 <=?
代码:
while (nums[j] <= nums[i]) {
j--;
}
我们的目标是:
找到严格大于 nums[i] 的数字
所以:
小于
或者
等于
都不能用。
必须跳过:
nums[j] <= nums[i]
直到:
nums[j] > nums[i]
二十、完整 Java 代码
java
class Solution {
public void nextPermutation(int[] nums) {
int n = nums.length;
// 1. 从右找第一个 nums[i] < nums[i+1]
int i = n - 2;
while (i >= 0 && nums[i] >= nums[i + 1]) {
i--;
}
// 2. 找到以后,从右找第一个 > nums[i] 的数字
if (i >= 0) {
int j = n - 1;
while (nums[j] <= nums[i]) {
j--;
}
swap(nums, i, j);
}
// 3. 后缀反转成最小排列
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--;
}
}
}
二十一、swap 怎么写?
交换数组两个位置:
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
就是最标准的:
借一个 temp
二十二、reverse 怎么写?
private void reverse(int[] nums, int left, int right) {
while (left < right) {
swap(nums, left, right);
left++;
right--;
}
}
例如:
7 4 2 1
↑ ↑
l r
交换:
1 4 2 7
然后:
↑ ↑
l r
交换:
1 2 4 7
完成。
二十三、为什么一定是 i+1 开始反转?
因为:
0 ~ i
这一部分已经决定了:
新排列比原排列更大
尤其是:
nums[i]
刚刚已经替换成了更大的数字。
为了让新排列:
尽可能小
只能调整:
i+1 到末尾
所以:
reverse(nums, i + 1, n - 1);
二十四、整道题真正的贪心思想
这道题本质上是一个非常漂亮的贪心。
要求:
比当前大
但是尽可能小
所以:
第一层贪心
尽可能从右边修改
因此找:
最靠右的 nums[i] < nums[i+1]
第二层贪心
既然必须把:
nums[i]
变大,那就:
只变大一点点
因此找:
右侧最小的、严格大于 nums[i] 的数字
第三层贪心
既然高位已经变大,那么低位:
尽可能小
所以后缀改成:
升序
整个逻辑就是:
高位尽量不动
↓
必须变的位置只变大一点
↓
后面的部分尽可能小
二十五、为什么这题不是回溯?
看到:
排列
很容易想到前面的:
回溯
但这道题并不是要求:
生成所有排列
而是已知一个排列:
直接找到下一个排列
所以如果先生成所有排列,再排序,再找当前的下一个:
复杂度非常高
完全没必要。
这道题通过当前数组的:
局部单调性
可以直接在:
O(n)
时间里得到答案。
二十六、复杂度分析
第一步:
从右找 i
最多扫描:
O(n)
第二步:
从右找 j
最多:
O(n)
第三步:
reverse
最多:
O(n)
总复杂度:
O(n)
不是:
O(3n)
因为常数忽略。
所以:
时间复杂度:O(n)
只用了:
i
j
temp
left
right
几个变量。
所以:
空间复杂度:O(1)
二十七、面试怎么讲?
可以这样回答:
下一个排列要求找到比当前排列大的最小排列,因此要尽量保持高位不变。
我先从右往左找到第一个
nums[i] < nums[i+1]的位置,此时i后面的部分已经是非递增序列,说明只调整后缀已经无法得到更大的排列。然后从右向左找到第一个严格大于
nums[i]的元素。由于后缀是非递增的,所以这个元素就是能够替换nums[i]的最小更大值,然后交换两者。交换以后,高位已经保证比原排列大。为了让整个新排列尽可能小,再把
i+1到末尾的后缀反转成升序。如果一开始没有找到
i,说明原数组完全非递增,已经是最大排列,直接反转整个数组即可。时间复杂度 O(n),额外空间复杂度 O(1)。
二十八、最容易写错的地方
1. i 从哪里开始?
数组:
0 1 2 ... n-2 n-1
需要比较:
nums[i]
nums[i + 1]
所以最后一个合法的 i 是:
n - 2
因此:
int i = n - 2;
2. 第一个 while 写反
正确:
while (i >= 0 && nums[i] >= nums[i + 1]) {
i--;
}
它是在:
跳过右侧的非递增部分。
最后得到:
nums[i] < nums[i+1]
3. 第二步要找严格大于
正确:
while (nums[j] <= nums[i]) {
j--;
}
退出时:
nums[j] > nums[i]
4. 交换以后不能直接结束
错误:
swap(nums, i, j);
return;
还必须:
reverse(nums, i + 1, n - 1);
因为要求的是:
刚好下一个
而不是:
任意更大的排列
5. 完全降序不能漏掉
例如:
3 2 1
最终:
i = -1
所以一定要把交换放进:
if (i >= 0)
但:
reverse(...)
必须放在 if 外面。
正确结构:
if (i >= 0) {
...
}
reverse(nums, i + 1, n - 1);
这样:
i=-1
时自动变成:
reverse(nums, 0, n - 1);
二十九、面试前 30 秒速记
看到:
下一个排列
先想到:
比当前大,但尽可能小
口诀:
找小
找大
交换
反转
模板:
java
int i = nums.length - 2;
while (i >= 0 && nums[i] >= nums[i + 1]) {
i--;
}
if (i >= 0) {
int j = nums.length - 1;
while (nums[j] <= nums[i]) {
j--;
}
swap(nums, i, j);
}
reverse(nums, i + 1, nums.length - 1);
理解:
从右找下降点
→ 尽量少动高位
从右找刚好更大的数
→ 当前位只增大一点
反转后缀
→ 后面的数字尽可能小
三十、最终记忆模型
我最终可以把这道题压成这样:
例如:
1 2 7 4 3 1
第一步:
找最靠右还能变大的位置
1 [2] 7 4 3 1
第二步:
找右边最小但比 2 大的数字
1 [2] 7 4 [3] 1
第三步:
交换
1 3 7 4 2 1
第四步:
后缀变最小
7 4 2 1
↓
1 2 4 7
答案:
1 3 1 2 4 7
所以这题真正应该记住的不是四五十行解释,而是:
下一个排列 = 尽量从右改变;该位置只变大一点;剩余后缀变得最小。
对应代码就是:
从右找 nums[i] < nums[i+1]
↓
从右找 nums[j] > nums[i]
↓
swap
↓
reverse(i+1, n-1)
这就是完整的「下一个排列」模板。