什么是最长递增子序列?
简单来说最长递增子序列就是在一个数组中呈现递增的数据的长度
lettcode原题
示例:我们有一个数组 arr
const arr = [10, 9, 2, 5, 3, 7, 101, 18]
- 那么他的序列有那些呢?
[2]
[2, 3]
[2, 5]
[2, 3, 7]
[2, 5, 7]
[2, 3, 101]
[2, 5, 101]
[2, 3, 7, 101]
[10, 101]
- 其中,最长递增子序列为
[2, 3, 7, 101]
,其长度为4
。
求取最长递增子序列
我们上面了解了什么是最长递增子序列,那么我们就来实现如何获取最长的序列吧~
动态规划
- 我们首先通过动态规划的方式求取最长序列。
- 定义状态: 定义一个dp用于存储数组 array 中所有的项对应的最长序列大小。默认是1。可以用
dp[i]
表示以第i
个元素结尾的最长递增子序列的长度。 - 状态转移方程: 建立两层遍历,通过两层遍历的方式去查找当前这个数据和下一个数据大小的对比,从而改变其序列长度。找到状态之间的关系,建立递推公式:
dp[j] = Math.max(dp[i] + 1, dp[j])
。 - 计算结果: 根据状态转移方程我们可以得到数组中每个数据对应的最长序列值是多少。
- 定义状态: 定义一个dp用于存储数组 array 中所有的项对应的最长序列大小。默认是1。可以用
/**
* @param {number[]} nums
* @return {number}
*/
function lengthOfLIS(nums) {
// 第一步定义一个dp用于存储数组 array 中所有的项对应的最长序列大小。默认是0
const dp = new Array(nums.length).fill(1)
// 双层遍历
for(let i = 0; i < nums.length; ++i) {
for (let j = i; j < nums.length; ++j) {
// 判断当前 i 对应的数据是否 小于 j 对应的数据。如果小于,则代表 j 与 i 形成一个递增的序列
if (nums[i] < nums[j]) {
// 根据递推公式:dp[j] = Math.max(dp[j], i + 1)
dp[j] = Math.max(dp[i] + 1, dp[j])
}
}
}
// 获取 dp 中最大的数,即对应着最长的序列
return Math.max(...dp)
}
- 查看运行结果
console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18]))
// 结果
4
通过贪心算法加二分查找法
- 建立一个结果数组,用于存储当前增长最慢且最长的序列的索引。默认是第一个即:
[0]
- 增长最慢:后一个值一定比前一个大,并且他们之间的差值是最小的
- 如:
[1, 3, 2, 5]
。他增长最慢的序列就是:[1, 2]
对应的索引就是:[0, 1]
- 如:
- 增长最慢:后一个值一定比前一个大,并且他们之间的差值是最小的
- 遍历原数组。通过贪心我们认为下一个一定比前一个大。
- 如果当前值大于结果数组中的最后一位,那么我们就可以把当前值放到结果数组中的最后一位,因为他们肯定形成一个递增
- 如果当前值小于最后一位,那么我们就需要通过二分去查找最接近的一个值了
- 二分查找法
- 通过头尾
中间
的指针对比当前元素,判断当前元素属于那个区间(left -> middle 或 middle -> right) - 如果在左侧,那么将
left
指针移动到中间的下一个位置(因为中间已经比过了) - 否则,将
right
移动到中间的位置
- 通过头尾
- 二分结束之后我们可以得到一个
left
,判断当前值是否小于这个left
。如果小于,则把当前值替换掉left
- 二分查找法
- 最后拿到
result
的长度
即可得到最长的一个子序列 注意:当前子序列长度是对的,但是顺序是不对的
/**
* @param {number[]} nums
* @return {number}
*/
function lengthOfLIS (nums) {
// 用于存储当前增长最慢且最长的序列的索引
const result = [0]
for (let i = 0; i < nums.length; ++i) {
// 如果当前值大于结果数组的最后一个,则意味着他是当前序列中的最大值
if (nums[i] > nums[result[result.length - 1]]) {
// 将当前索引加入到结果的最后一个
result.push(i)
} else {
let left = 0, right = result.length - 1
while (left < right) {
// vue 源码使用的是 left + right >> 1 。这里这样写是为了避免 Infinity 导致的结果不正确问题((Infinity - 1) + (Infinity - 10) >> 1)
const middle = left + ((right - left) >> 1)
// 说明在右边
if (nums[i] > nums[result[middle]]) {
// 将左指针移动到中间嗯后一位
left = middle + 1
} else {
// 将右指正移动到中间
right = middle
}
}
// 二分和贪心走完了,我们只需要判断 left 是不是 大于当前值,如果大于,则替换即可
if (nums[i] < nums[result[left]]) {
// 直接替换即可
result[left] = i
}
}
}
// 返回序列长度
return result.length
}
- 查看运行结果
console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18]))
// 结果
4
回溯
- 为什么会需要回溯呢?在
vue3.x
中,我们需要的是判断节点是否需要移动,这个时候我们需要一个有序的序列,乱的肯定不行。可以先试下我们按照之前的二分和贪心拿到的结果。我们在返回result.length
之前打印下result
的结果看下。为了方便查看我们打印了result
的结果和它对应的在nums
中实际的结果
// 因为上面的数据返回的结果刚好符合数序,所以我们换一个数据尝试结果
// [2, 4, 5, 3, 1, 9, 7, 8]
/**
* @param {number[]} nums
* @return {number}
*/
function lengthOfLIS (nums) {
// 用于存储当前增长最慢且最长的序列的索引
const result = [0]
// .....
// 增加打印结果
console.log(result, result.map(m => nums[m]))
return result
}
console.log(lengthOfLIS([2, 4, 5, 3, 1, 9, 7, 8]))
// 执行结果
[ 4, 3, 2, 6, 7 ] [ 1, 3, 5, 7, 8 ]
5
- 通过结果打印我们可以看到,虽然最长序列的长度是正确的,但是存储的结果中的序列却不是正确的,正确的序列应该是:
[ 2, 4, 5, 7, 8 ]
。所以这个时候vue
通过了一个巧妙的方式给他重新进行一个回溯已达到正确的队列。 - 怎么回溯呢?
- 简单来说就是:
我们在查找过程中,因为我们拿到的值一定比上一个大,也就是 result 中存储的后一个值肯定比前一个大,那么我们就可以存储当前值的上一个值的索引位置,在回溯的时候我们就可以从后往前查,我们可以定义两个指针,一个指向最后一个,一个执行前一个,那么我们下一个取的值就是上一次执行的前一个的值。
这么说可能有点抽象,我们看下代码实现。- 增加一个
p数组
用来存储回溯的数据(存储的是前一个最大值索引
)result
修改分为两种情况,- 直接在后面插入,那么这个时候它的上一个最大值就是 result 的最后一个数据(新的数据插入
result
前)。 - 修改某个位置的
result
。那么它上一个最大值就是你修改位置的前一个数据。(为什么是前一个数据:因为我们按照贪心算法给他加入的时候是有序的,他是单调递增的也就是说他的前一个一定是离他最近符合条件的最大值。)
- 直接在后面插入,那么这个时候它的上一个最大值就是 result 的最后一个数据(新的数据插入
- 回溯
- 通过两个指针去处理。(我们引用 vue 源码的变量吧,
u
、v
)- u:当前指针指向
result
要修改的数据。默认指向最后一个
,因为我们是从后往前回溯的 - v:当前指针指向
result 的上一个
,其实就是u - 1
- u:当前指针指向
- 有了这两个指针,那么我们在修改
u
的时候,是不是v
其实指向的就是u
所对应的上一个索引,这个时候我们去p
里面取u
所对应的值不就是他的上一个最大值了嘛。所以我们直接把u
位置的值 给到v
就ok了。
- 通过两个指针去处理。(我们引用 vue 源码的变量吧,
- 增加一个
- 简单来说就是:
/**
* @param {number[]} nums
* @return {number}
*/
function lengthOfLIS (nums) {
// 用于存储当前增长最慢且最长的序列的索引
const result = [0]
// 增加一个 p 用于存储回溯的数据
const p = nums.slice(0)
for (let i = 0; i < nums.length; ++i) {
// 如果当前值大于结果数组的最后一个,则意味着他是当前序列中的最大值
if (nums[i] > nums[result[result.length - 1]]) {
// 修改当前数据对应的前一个最大值。取 result 的最后一个
p[i] = result[result.length - 1]
// 将当前索引加入到结果的最后一个
result.push(i)
} else {
let left = 0, right = result.length - 1
while (left < right) {
// vue 源码使用的是 left + right >> 1 。这里这样写是为了避免 Infinity 导致的结果不正确问题((Infinity - 1) + (Infinity - 10) >> 1)
const middle = left + ((right - left) >> 1)
// 说明在右边
if (nums[i] > nums[result[middle]]) {
// 将左指针移动到中间的后一位
left = middle + 1
} else {
// 将右指正移动到中间
right = middle
}
}
// 二分和贪心走完了,我们只需要判断 left 是不是 大于当前值,如果大于,则替换即可
if (nums[i] < nums[result[left]]) {
// 直接替换即可
result[left] = i
// 修改了 result 的值,所以我们也需要修改 p 对应的索引
if (left > 0) { // 防止数组越界
// 修改当前数据对应的前一个最大值。取 left 的上一个
p[i] = left - 1
}
}
}
}
// 回溯
let u = result.length, v = result[u - 1] // 默认为最后一个
while(u-- > 0) { // 因为这里执行了 u--,所以此刻 u 指向的就是 result 的最后一个
// 最后一个肯定是最大的,所以第一次执行的时候这个复制就是将最后一个值修改为最后一个相当于是没有变化
// 下一次进入的时候,v 的值已经是他所对应的最大值了
result[u] = v
// 第一次就是取 result 最后一个值对应的上一个最大值,
// 下一次进入的时候,就是取当前的下一个最大值是啥
v = p[v]
}
// 我们输出 result 看结果
console.log(result)
// 返回序列长度
return result.length
}
- 执行结果
console.log(getMaxLength([2, 4, 5, 3, 1, 9, 7, 8]))
// 输出结果
[ 0, 1, 2, 6, 7 ]
5
这个时候我们可以看到输出的 result 序列顺序已经没有问题了,顺利完成了回溯。