T31 下一个排列
理解题意
如果我们看过「LeetCode」中本题的评论区,就会发现有部分网友对这道题提出了争议。
本题中反复出现了一个名词「字典序」,这个概念在本系列的前几期文章中已经有所涉及,但没有细讲,这里我们再深入一下。所谓的「字典序」,实际上就是「字符编码序」,而我们在一般解题时碰到的都是英文字符,因此可以暂且简单理解为「ASCII码序」。
如果要比较两个字符s1、s2的字典序,只需要比较它们的ASCII码大小就可以了。如果s1的ASCII码大于s2,那么就有s1的字典序大于s2,记作s1 > s2。
而对于比较两个字符串S1、S2的字典序,则从它们下标为0处开始逐位比较,若比较过程中发现S1[0] = S2[0]、S1[1] = S2[1]、… 、S1[i - 1] = S2[i - 1]、S1[i] > S2[i],则说明S1的字典序大于S2的字典序(我们规定若i下标越界,则该位字典序为无穷小)。
在JavaScript中,如果对字符串使用比较运算符,就会采取上述的机制进行计算。除此之外,数组的sort方法默认也是按照字典序大小进行排序。
由上面的概念与分析,我们可以很容易推知:比较两个仅含个位数的字符,其等价于直接比较这两个数的大小,例如"3" > "1";进一步地,如果比较两个仅含相同位数多位数的字符串,其同样等价于直接比较这两个数的大小,例如"124" > "123"。
由上面的推论我们可以知道,如果在本题中仅考虑0 ≤ nums[i] < 10的情况,我们可以简单将题目理解为:给定一个用于表示多位数的Number数组,现在对其进行重新排列,使得重新排列后所得的新多位数恰好为大于原多位数的最小多位数。
然而尴尬的是,题目中规定的数据范围为0 ≤ nums[i] ≤ 100。如果按照我们前面对多位字符串「字典序」大小比较规则的理解,对测试用例nums = [1,21,3],应该有ans = [1,3,21],因为"1321 > "1213"恰好成立;但实际上经网友测试,发现OJ返回的正确答案竟然为ans = [3,1,21]!
这就是本题引发争议的地方所在。其实,在出题人的设想中,对于数组中存在多位数的情况,数组中其他数在组合成新多位数前需进行补位,而问题恰恰就出现在这里——题干中对于这一点根本未进行说明。比如刚才的测试用例nums = [1,21,3],实际上对应的应该是字符串"012103",所以才会有ans = [3,1,21](对应字符串"030121")而非ans = [1,3,21](对应字符串"010321")。
至于为什么出题人原本设想的题目中有补位的操作,经过后续的分析大家自然就能明白,这里暂且先卖个关子。
知道了这一点,我们发现本题纯粹就是一个数学问题,我们完全可以重新定义题干中的「下一个排列」:
整数数组的「下一个排列」是指其中整数按序连接(如位数不一致则连接前位数较小者需先补
0)后下一个所得多位数数值更大的排列。更正式地,如果数组的所有排列根据其中所含整数按序连接(如位数不一致则连接前位数较小者需先补0)所得多位数的数值,从小到大排列在一个容器中,那么数组的「下一个排列」就是在这个有序容器中排在它后面的那个排列。
而原题引入「字典序」进行表述,看似简化了文字,实则仍然未能传递解答本题的关键信息,一道争议题由此产生。
思路分析
下面我们开始分析本题的解题思路。为了便于讲解,我们使用「个位数」进行进行分析。形成完整思路后,我们再分析当数组中存在「多位数」时,我们的思路是否仍然适用。「从简单的情况入手」是贯穿本系列的一贯宗旨。
如何得到下一个比原多位数大的多位数呢?我们可以将这个问题分割成两个子问题:
-
①要得到一个比原数大的数;
-
②新得到的数相较原数增加的幅度必须最小。
问题①是非常简单的。我们只需要将原数「低位」中某个数(记其在数组中的下标为p),与「高位」中比它小的某个数(记其在数组中的下标为q)进行交换,就一定能得到一个比原数更大的数。譬如,我们将多位数1123中的百位与个位交换,得到的1321就比原数大。
当然了,前述的「低位」和「高位」显然只是一个相对概念,并不能帮助我们精确确定p、q的位置。
为了探寻确定p、q的规律,下面我们重点来关注问题②。
首先,我们应该尽可能选取原数偏低位的两个数进行交换操作,以降低对原数大小的影响。为此我们必须控制q的范围,使它尽可能指向数组靠右端,进而也就能间接控制p的范围。
这个要如何操作呢?
既然q指向的是处于「高位」需要交换至「低位」的较小数,那么只有当nums[q]的右侧至少存在一个比它大的数,才能进行交换。因此对于最靠右端的q来说,必然满足:
nums[q] < nums[q + 1] ≥ nums[q + 2] ≥ … ≥ nums[-1]
(Tip: 为了表述的方便,我们记 nums 数组的最后一个元素为 nums[-1] ,下同)
我们注意到,在nums[q]右端的数一定按非升序的顺序排列。也就是说,nums[q]是原数从低位到高位看过去,第一个数值发生降落的数。根据这个推论,我们可以通过从数组尾端向前遍历的手段来确定我们需要的q。
确定q之后,我们再来确定p。事实上,我们应该让p指向原数低位所有大于nums[q]中的数中最小的数。即:
nums[q+1] ≥ nums[q+2] ≥ … ≥ nums[p-1] ≥ nums[p] > nums[q] ≥ nums[p+1] ≥ … ≥ nums[-1]
这个结论是非常显然的。例如对于多位数12943,按照前面的推理显然q指向千位的2,而选取3与之交换得到13942,显然比选取4或9得到的数相较于原数增加幅度更小。
但到这还没完,我们还有进一步压缩所得新数增加幅度的空间。在交换大小数之后,我们可以对nums[q]右侧的所有数进行非降序排序,以尽可能保证右侧的所有数中较小的数能够占据高位。例如我们可以对上例数13924中3右侧的三个数进行排序,得到13249,这显然进一步缩小了增加的幅度。
但此时一个新的问题又浮出了水面。我们现在又遇到了需要「数组排序」的情景。不言而喻的是,在做算法题时,为了尽可能地锻炼我们的能力,我们应当尽可能避免使用排序等简单粗暴的方案。那么在本题中,如果不进行排序,我们还能完成预想中的操作吗?
答案是肯定的。由先前的分析,当低位的nums[p]和高位的nums[q]数值发生交换后,我们知道在新的nums[q]右侧,如下结论仍然成立:
nums[q+1] ≥ nums[q+2] ≥ … ≥ nums[p-1] ≥ 新的nums[p] ≥ nums[p+1] ≥ … ≥ nums[-1]
可见,此时数组在区间[q + 1, -1]的范围内仍然保持非升序的状态,我们只需将该区间进行「翻转」即可,而无需进行排序!
至此,解答本题的基本思路就分析完毕了。最后我们来考虑开始时我们提出的问题:当数组中存在「多位数」时,我们的思路是否仍然适用?
还记得我们先前按照出题人原意修改后的「下一个排列」的定义吗?它指出,当数组中数的位数不相同时,连接成整数前需要进行补0。而补0的意义就在于,使不同位数的数之间的地位等价,抹去位数不同对组合后多位数的影响。
这个可能比较难以理解,我们不妨举个🌰。
假设三个数1、3、14,则它们经排列所得的其中一个多位数为1314。我们很容易注意到,数1、3都只能影响所得多位数的其中一位,而14却能影响两位,可见它们此时在排列时的地位是不等价的。
现在我们对1和3进行补位,得到01和03,那么它们和14经排列所得的其中一个多位数为010314。现在,无论是哪个数,都能够影响所得多位数的其中两位,它们在排列过程中的地位是完全等价的。
Tip: 其实出题人原本的意图就在此处,可惜未能在题干予以表达。
因此,对于存在多位数的数组nums,我们也可以放心大胆地使用上面分析的思路进行「下一个排列」的求解,因为最终得出多位数的补位规则决定了它和其他数的低位是完全等价的(虽然题目并不需要我们求解这个得出的多位数)。
当然在「LeetCode」平台上也有网友提出可以通过引入「百进制」来理解,这里我们就不详细展开了。
实现代码
本题中另外一个隐藏的考点在于目前JavaScript中自带的数组reverse方法是无法设定翻转范围的,需要我们进行手工实现。但这对我们来说属于基本功的范畴,故本文不再展开。
本题的解答代码如下:
function nextPermutation(nums) {
const end = nums.length - 1;
//确定q的位置
let q = null;
let qNum = null;
for (let i = end; i >= 0; i--) {
if (nums[i - 1] < nums[i]) {
q = i - 1;
qNum = nums[q];
break;
}
}
//处理完全非升序数组的特殊情况
if(q === null) return reverse(nums, 0, end);
//确定p的位置
let p = null;
let pNum = Infinity;
for (let i = end; i > q; i--) {
if (nums[i] < pNum && nums[i] > qNum) {
p = i;
pNum = nums[i];
}
}
//交换两数
[nums[p], nums[q]] = [qNum, pNum];
//对数组右侧进行翻转
return reverse(nums, q + 1, end);
}
function reverse(arr, start, end) {
for (let i = 0; i < Math.floor((end - start + 1) / 2); i++) {
[arr[start + i], arr[end - i]] = [arr[end - i], arr[start + i]];
}
return arr;
}
写在文末
我是来自学生组织江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》。
我们诚挚邀请您体验我们作品。如果您喜欢TA的话,欢迎向您的同事和朋友推荐,您的支持是我们最大的动力!