原题说明:实现获取下一个排列的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。
如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。
必须原地修改,只允许使用额外常数空间。
以下是一些例子,输入位于左侧列,其相应输出位于右侧列。
1,2,3 → 1,3,2
3,2,1 → 1,2,3
1,1,5 → 1,5,1
原题链接:https://leetcode-cn.com/problems/next-permutation
题意分析:
先给出几个实例
1)123 → 132
2)10203 → 10230
3)102320 → 103022
4)1079864 → 1084679
5)321 → 123
6)98700 → 00789
以上是用LeetCode自带的控制台直接测试的,也算是本道题的一个小收获。
本题的解题过程将随着实例的难度增加而不断拓展。
解法一:暴力求解
本道题用暴力求解是不可行的,时间复杂度是xx。
解法二:我的解法(也是官方题解)
PART1:
从实例(1)(2)可以看出,遍历应该从右往左进行。设遍历的循环变量为$i$,一旦找到$operatorname{nums}[i-1]$小于$operatorname{nums}[i]$,就说明对于当前数组排列下的整数,已经有一个比它大的数。从这点可以认为,本道题就是在做一次(大小)排列——一旦完成排列,就是所谓的“下一个”数,故操作结束。
所以第一代代码完成的就很快,如下(手写的、没有用IDE测试过)
for(int i = nums.length - 1;i>=0 ; i--){
if(nums[i-1]>nums[i])//3,2,1
i--;
else {
swap(nums, nums[i-1], nums[i]);
break;
}
}
这里后接的代码就是用于满足实例(5)和(6)的。我当时的想法是,最终满足这两个实例的条件就是给到的数组是一个递减的数组。一旦有两个相邻位置不满足,那么就像上文所说的,一次交换之后就可以直接作为结果输出了。
当然,我对这样的不定式是进行过推导的,当时所有的实例都是没有问题,后来我发现,问题就出现在“两个相邻位置”。
PART2:
于是就看到了实例(3)的情况。这种情况出现的原因可以这样分析——
假设整数为$(j+k-1)(j+k)(j+k-2) cdots(j)$,当对头两位数字进行交换时,得到的结果是$(j+k)(j+k-1)(j+k-2) cdots(j)$。从形式上看,得到是一个递减数列,但是从题目要求看却应该是$(j+k)(j)(j+1) cdots(j+k-1)$。换言之,真正的下一位,应该是最大的位数+1,然后其余位数进行递增排列。试想999到10000,9变成了10,其余的位数都是最小的0。
所以代码应该是当进行大小交换后,对交换数字右边的全部数字进行递增排列。
这里涉及到两个问题:1)如何递增?2)在这个基础上不定式的推导是否安全?
对于第一个问题,先要了解目标数列是什么情况?无序的、还是有序的?显然是有序的,且是递减的。
有数列${x-1, x, y, z, m, n}$,假设${y, z, m, n}$是无序的,若$m<n$,那么开始遍历时,就已经进行了交换(看上去就是实例(1)),由此根本不再涉及${x-1, x}$的交换,矛盾;如果不是递减的,同样可以采用$m<n$的例子说明,矛盾。
然后对于这个递减数列如何变成递增呢?直接的算法就是排序算法。但是一来题目有要求,二来真的有必要么?由于是有序数列,递减变成递增还可以通过交换数列两端的数字完成。该部分代码如下:
private void reverse(int[] nums, int L, int R) { while(L<R){ swap(nums, L, R); L++; R--; } }
这里又涉及到循环条件的考虑。若数列包含偶数个元素,自然$L<R$就足够满足;若是奇数个元素,似乎中间元素取不到,所以当时我想应该设置条件为$L leq R$,但是转念一想中间元素何须交换呢?所以当$L$和$R$都取到中间元素时,就可以终止了。
对于第二个问题,考虑就比较复杂,这就涉及到$L$的取值,这里初步确定$L$等于循环变量$i$;$R$等于$nums.length-1$——
如图a,当$i$取$nums .$length$-1$时,$L=R$,如无需进行颠倒数列的操作
如图b,当$i$取$nums .$length$-2$时,$L=nums.length-2$,在交换之后、再进行一次颠倒即可(偶数个)。
如图c,当$i$取$nums .$length$-3$时,$L=nums.length-3$,在交换之后、再进行一次颠倒即可(奇数个)。
由此,可以推导至所有情况,故安全。
这样的话,之前的程序可以改进为:
for(int i = nums.length - 1;i>=0 ; i--){ if(nums[i-1]>nums[i]) i--; else { swap(nums, nums[i-1], nums[i]); reverse(nums, i, nums.length - 1); break; } }
PART3:
关于相邻的问题,还引出了实例(4)的情况。这种情况的实质就是,交换的对象不一定是相邻的,或者说,为了得到下一更大的数字,应该在出现需要交换时,在交换的数字(索引为$i-1$)右边的数列中找到比数字大的最小值进行交换。这里查找的依据仍然是这个数列是递减数列。代码如下
private int search(int[] nums, int head, int headindex) { int index = headindex; while(index<nums.length) { if(nums[index]<=head) {//2,3,2,0 index--; return index; } index++; } return --index;//1,2,3 }
代码的思路就是从$i$处开始自左往右遍历,找到第一个比$i-1$小的数字的位置$index$,由于是递减数列,所以交换位置应该是$index-1$。然后接下去就是swap操作。
这里同样有两个问题——
第一个问题已经用注释给出实例了。若要被交换的数字是$2$,数列是$320$,那么第一个比$2$小的数字是$0$,如此一来就是$2$和数列中的$2$进行交换了,这就达不成目的。所以应该设置比较条件是小于等于(第4行)。
第二个问题出现在实例为$123$的情况下,即数组越界。这种情况是因为$3>2$,循环变量只能继续迭代,但是已经满足终止条件(此时$index=nums.length$),由于返回是$index$,此时将其作为索引进行交换时,就会越界。查到原因后才将第10行代码改成--index(一开始是index--,仍然错误)
那另一种解决方案就是知道这种情况的前提下,直接返回$nums.length-1$
PART4:
以上问题解决之后,就是面对实例(5)和实例(6)了,直接上源码——
1 public int[] nextPermutation(int[] nums) { 2 if(nums.length < 1 || nums == null) 3 return nums; 4 5 int i = nums.length - 1; 6 while(i>=1) { 7 if(nums[i]<=nums[i-1]) 8 i--; 9 else { 10 int head = nums[i-1]; 11 int swapindex = search(nums, head, i); 12 swap(nums, i-1, swapindex); 13 reverse(nums, i, nums.length - 1); 14 break; 15 } 16 } 17 if(i==0) 18 reverse(nums, i, nums.length - 1); 19 return nums; 20 } 21 22 private int search(int[] nums, int head, int headindex) { 23 int index = headindex; 24 while(index<nums.length) { 25 if(nums[index]<=head) {//2,3,2,0 26 index--; 27 return index; 28 } 29 index++; 30 } 31 return nums.length - 1; 32 } 33 34 private void swap(int[] nums, int a, int b) { 35 int tmp = nums[a]; 36 nums[a] = nums[b]; 37 nums[b] =tmp; 38 } 39 40 private void reverse(int[] nums, int L, int R) { 41 while(L<R){ 42 swap(nums, L, R); 43 L++; R--; 44 } 45 }
第7到15行就是PART1的增强版,解决了实例(1)-(4)。这里需要关注的是第6行循环的条件。一开始很自然地以为是$i geq 0$,但是看第8行就知道,当$i=0$时,这行执行时就会数组越界。故循环条件为$i geq 1$。
一旦$i=0$(while循环会导出循环变量),就说明整个数列是一个递减的数列、即是一个最大值,因此,下一个大值就是返回最小值了,直接进行颠倒操作。
那么其实我在PART1的时候,考虑到的情况应该是987000 → 70089,所以我当时想的是,应该还要有额外的标记。但是给的结果就是000789,我也没有办法。。。
总结:
这次我意识到了
- 找合适实例的价值
- 对于边界条件的推导也更有耐心