引言
提到最长递增子序列,不免想到力扣相关的一道题:300. 最长递增子序列。在该题中,求的是递增子序列的长度,而在diff算法中,需要得到由递增子序列索引所组成的数组。
以输入: nums = [10,9,2,5,3,7,101,18]为例,在力扣中,返回长度4即可,而在diff算法中需要返回最长递增子序列的索引组成的编号:[2,4,5,7]
在Vue3中,使用贪心+二分指针+回溯来得到最长递增子序列,下面分块来讲解算法实现的关键步骤、具体例子、具体代码实现。
贪心与二分指针
总体思路
建立一个栈,栈里理应是单调递增的,但有略微的不同。考虑以下三种情况:
1.如果当前的元素大于栈的最后一个元素,或者是首个元素,那么该元素进栈
2.如果当前的元素小于栈的最后一个元素,那么寻找栈内比它稍大的元素,并替换;
- 这里的逻辑是用当前元素占了个位置,代表的还是之前的元素。
- 寻找栈内比它稍大的元素,并替换,该步骤用普通的二分法来实现
3.如果相等,那么跳过
可以看看这个帖子,理解这里的步骤2的精髓:
举个例子
用数组[2,3,1,5,6,8,7,9,4]来讲解上述过程
先创建一个tails数组,用来存储最长递增子序列组成的索引数组。接着开始遍历输入数组nums的每一个元素。
1.tails里没有元素,所以索引0进栈。
2.指针往后走,因为3>2,所以索引1进栈。
3.指针往后走,因为1<tails栈顶的元素,所以要替换元素。因为2比1稍大,所以tails数组中2将会替换0.
4.指针继续往后走,由于后面的5,6,8都大于tails栈顶的元素,所以这三个对应的索引都进栈。
5.指针走到了元素7这里,因为7<tails栈顶的元素,所以要替换元素。因为8比7稍大,所以tails数组中6将会替换5.
6.指针继续往后走,最后我们可以得到这样的tails,而索引对应的值如左边所示:
最终得到的tails数组长度为6,代表最长递增子序列的长度为6.
代码实现
const lengthOfLIS = function(nums) {
let tails = [] //存储最长递增子序列的索引
for (let k of nums.keys()) {
let num = nums[k]
// 情况1: tails里没有元素或者比tails顶上的元素大,进栈
if (tails.length === 0 || num > nums[tails[tails.length - 1]]) {
tails.push(k)
}
// 情况2: 和tails里栈顶的元素相同,跳过
else if (num === nums[tails[tails.length - 1]]) {
continue;
}
// 情况3: 小于tails里栈顶的元素,找到稍大的元素,替换
else {
let i = 0,j = tails.length;
while (i < j) {
let mid = parseInt((i+j)/2);
if (nums[tails[mid]] < num) i=mid+1;
else j=mid;
}
tails[j] = k;
}
}
console.log(tails)
return tails.length
};
我们希望的最长递增序列为[2,3,5,6,7,9],但是根据tails里存储的索引,我们得到的最长递增序列是[1,3,4,6,7,9],并不正确。这是由于前面的替换操作引起的,下面使用回溯来修正。
回溯
代码实现
const lengthOfLIS2 = function(nums) {
let tails = []
// 新增p数组,key为当前元素在nums中的下标。value代表在tails栈中,上一个元素在nums中的下标
// 形成了一个树的结构,对于p[k]= v,代表的含义是:k的父节点为v
let p = new Array(nums.length).fill(undefined)
for (let k of nums.keys()) {
let num = nums[k]
if (tails.length === 0 || num > nums[tails[tails.length - 1]]) {
// 情况1: tails栈中新增元素,更新父节点的引用情况
p[k] = tails[tails.length - 1]
tails.push(k)
}
else if (num === nums[tails[tails.length - 1]]) {
continue;
}
else {
let i = 0,j = tails.length;
while (i < j) {
let mid = parseInt((i+j)/2);
if (nums[tails[mid]] < num) i=mid+1;
else j=mid;
}
tails[j] = k;
// 情况2: tails栈中替换元素,更新父节点的引用情况
p[k] = tails[j-1];
}
}
// 回溯操作
u = tails.length
v = tails[u - 1] // v为最深层的最右边的元素
while (u-- > 0) {
// 链表需要.next来找下一个节点,而我们建立的是可以往回退的树。用p[v]来往上搜索,收集从根部最右往上的一个子树。
tails[u] = v
v = p[v]
}
console.log(tails,p)
return tails.length
};
整体思路
- 新增p数组,key为当前元素在nums中的下标;value代表在tails栈中,上一个元素在nums中的下标。p形成了一个树的结构,对于p[k]= v,代表的含义是:k的父节点为v。对数组p的收集有两处:
- tails栈顶新增元素
- tails中替换元素
- 根据数组p,使用回溯收集从根部最右往上的一个子树。
举个例子
下面还是以数组[2,3,1,5,6,8,7,9,4]为例,讲解p数组的更新过程。
收集p数组
1.指针指向nums的第一个元素,此时需要新增,看看tails里0压着谁,更新p[0]=undefined.
2.指针往后走,由于3>2所以在tails栈内新增元素,发现tails里1压着0,更新p[1]=0,代表索引为1的元素的父节点为索引为0的元素。
3.指针往后,由于1<栈顶的元素3,所以要替换,更新p[2]=undefined,代表着索引为2的节点已经是tails的栈底元素。
4.指针往后,由于元素5大于栈顶的元素3,所以新增,更新p[3]=1.
5.指针往后走,遇到元素6,8和上面的步骤一致,然后更新tails数组和p数组
6.指针指向元素7,发现要替换,于是替换tails,看到tails数组中6压着4,代表6的父节点为4,所以p[6]=4
7.指针指向元素9,新添元素;指针指向元素4,发现要tails里存储的索引3要替换为8。由于tails里8压着1,所以p[8]=1。由此收集树的工作做好了。
构建一棵树
翻译一下p数组,对于p[k]=v,含义是p的父节点为k,也可说是v指向了k
1.p[0]=undefined,代表着0的父节点是undefined,于是我们可以画出:
2.p[1]=0,代表0指向1
3.指针往后走,步骤是一样的,省略相似步骤,可以得到
回溯修正
我们要得到的递增子序列,实际上就是最深层的最右边的元素往上的这颗子树,如蓝色所示。
最终选择哪些节点,是由tails最后一个元素决定的。tails最后一个元素是7,而它记录的父节点是6;通过p[v]可以不断找到父节点,由此进行修正。
修正完毕后,输出的最长递增子序列编号为: [0, 1, 3, 4, 6, 7],确实是正确的结果。
扩展
上述修正的过程,将数组转化为了树型结构。力口:287-寻找重复数,将数组转化为了链表,通过链表是否有环来判断数组是否有重复元素。