图解版LIS,一篇文章教会你什么是最长递增子序列

面试官:你如何评价Vue3的LIS算法?

我:啊?这题在《Vue2从入门到跑路》里没教啊!

最终收获Offer:《区块链公司の切图外包,用!important给老板的祖传CSS屎山强行续命》」

前言

经济下行时期,我司近期的收益也不是很好,前几天刚裁了一波人,顿时压力就上来了。

在这种环境下,本人觉得还是有必要抽点时间重新学习下各种原理和算法,试图回炉重造,从【代码搬运工】成功进化成【架构大师】。

目标:下一份工作不再是拧螺丝,而是造火箭

定义

最长递增子序列(Longest Increasing Subsequence,简称LIS)

它要求给定一个序列(比如一个数组),找到其中最长的子序列 ,使得这个子序列中的元素是严格递增的 。注意,这个子序列不一定是连续的,但必须保持原序列中的相对顺序

简单来说就是:需要是子序列,还要是递增的,然后还必须是最长的

这三点满足了,一定是一个完美的LIS!

我们可以通过一个例子直观一些

例子

给定数组 [10, 9, 2, 5, 3, 7, 101, 18],这个数组的最长递增子序列就是[2, 3, 7, 18]

也许起初你在理解这个算法的时候,可能会有这几个疑问:

  • 这个P数组怎么就这样那样然后变成了这个数字了的?
  • 回溯是什么?
  • 如果不回溯有什么问题吗?

那么通过这篇文章,你将会找到以上疑问的答案,并且你会了解以下知识:

  1. 最长递增子序列的整个过程(图解且详细版)
  2. P数组的作用展示
  3. 为什么需要回溯,回溯前的数据为什么可能会存在不对呢
  4. 回溯的整个过程以及作用
  5. 在vue3中使用最长递增子序列的作用
  6. 在vue3中算法的代码实现

最长递增子序列的整个过程

在学习这个算法之前,我们先来了解一些基础知识便于后续更容易理解。

前提声明

  1. 下面即将演示的算法都是基于vue3源码里面的最长递增子序列算法。

这与其他地方比如力扣里面的算法稍有不同的是:vue3里面的输出结果是对应子序列的下标数组

但是实现方式都是基于贪心+二分查找

  1. 本文选择以这个数组[102, 103, 101, 105, 106, 108, 107, 109, 104]为原数组来进行解析。

即我们在最后得到的数组应该是[0,1,3,4,6,7],而不是[102,103,105,106,107,109]

  1. 下文中的P数组和result数组认识

P数组:用于存储了构建最长递增子序列的"路径信息",你也可以叫它前驱索引数组,以下均简称为P数组

result数组:算法最终的输出数组,记录了最长递增子序列对应原数组的索引值

初始化

P数组初始化

将P数组初始化为原数组一样的数据

你会不会想:为什么要跟原数组一样呢?我看其他文章人家都是初始化为0啊?

实际上P数组初始化时只是为了分配空间,具体空间里面分配的什么数据是没有多大意义的。

所以看个人爱好,喜欢0的可以全部设为0。不影响实际结果输出的。

result数组初始化

初始设置为[0],即从原数组的第一个数据开始

第一步

我们从原数组的第一个开始循环进行比较,当i=0时,拿当前数据 arr[i]​和result数组中的最后一个数据​进行比较。

  • 蓝色的球代表当前比较的数据
  • 黄色的球是已经遍历过的数据
  • 绿色的球代表发生替换或者新增的数据

这个时候,我们会拿到这些数据

js 复制代码
i:0
arr[0](原数组当前数据):102
result[result.length -1](result最后一个数据):102

判断结果

因为102 = 102,所以我们在这一步不用做任何更新操作

第二步

继续开始遍历,这个时候i=1

js 复制代码
i: 1
原数组当前数据:103
result最后一个数组:102

103 > 102 ?

✅ 是:更新P数组 数据,并且在result数组插入当前数据103的索引

❌ 否:二分查找result数组中最开始比103大的数据,找到并替换

更新结果

最终我们根据判断的结果选择在result数组中插入数据

P数组

我们首先先更新P数组,因为当前元素值 大于result数组的最后一个元素对应的值 ,所以我们将当前result数组的最后一个索引记录为当前的P元素。

因为103 > 102,所以需要将result的最后一个数据 0 记录为P[i],也就是P[i] = 0

(先更新P数组,再更新result数组,所以这个时候result数组还是只有一个0)

可以简单理解为,P数组的当前元素,一定是绿色更新元素前一个元素

result数组

插入当前数据103的索引1

第三步

继续开始遍历,这个时候i=2

js 复制代码
i:2
原数组当前数据:101
result最后一个数据:103

101 > 103 ?

❌ 是:更新P数组数据,并且在result数组插入当前数据101的索引

✅ 否:二分查找result数组中最开始比101大的数据,找到并替换

更新结果

P数组

这种情况中,我们找到了一个位置u,使得arr[i]可以替换掉arr[result[u]]。替换后,arr[i]的前驱应该是位置u-1对应的元素,即result[u-1]。

在当前步骤中,我们找到了102的位置是比101大的,那么这个时候的对应的前驱索引就应该是102(result未更新前)前面的索引。因为102是第一个数据,前面已经没有数据了,所以当前P可以不做更新或者更新为-1

result数组

因为要在result数组找比101大的数据,根据二分法找到102比101大,所以我们将102的索引替换为101的索引

你也可以这么理解,所有的数据都是薪资的具象化,对于老板来说,肯定是想要薪资少的员工

arr原数组就相当于人才市场

result数组就是目前需要和已经存在的员工

P数组相当于记录员,记录哪个岗位哪个员工干过

所以这个时候,老板发现101比102便宜,那就把102优化掉,替换为薪资更少的101

第四步

继续循环遍历,这时i=3

js 复制代码
i:3
原数组当前数据:105
result最后一个数据:103

105 > 103 ?

✅ 是:更新P数组数据,并且在result数组插入当前数据105的索引

❌ 否:二分查找result数组中最开始比105大的数据,找到并替换

更新结果

P数组更新

当前result数组[2,1],所以这个时候我们需要将P[i]记录为1(result的最后一个元素)

当然你也可以这么理解,当3插入了result之后,result数据当前为:[2,1,3]P[i]需要是当前更新或插入元素的前一个 ,3是当前需要插入到结果数组的,所以P[i]就取3前面的元素1

result数组更新

这就没啥说的了,直接将当前元素105的索引3插入结果数组。

第五步

继续循环遍历,这时i=4

js 复制代码
i:4
原数组当前数据:106
result最后一个数据:105

106 > 105 ?

✅ 是:更新P数组数据,并且在result数组插入当前数据106的索引

❌ 否:二分查找result数组中最开始比106大的数据,找到并替换

更新结果

第六步

继续循环遍历,这时i=5

js 复制代码
i:5
原数组当前数据:108
result最后一个数据:106

108 > 106 ?

✅ 是:更新P数组数据,并且在result数组插入当前数据108的索引

❌ 否:二分查找result数组中最开始比108大的数据,找到并替换

更新结果

第七步

继续循环遍历,这时i=6

js 复制代码
i:6
原数组当前数据:107
result最后一个数据:108

107 > 108 ?

❌ 是:更新P数组数据,并且在result数组插入当前数据107的索引

✅ 否:二分查找result数组中最开始比107大的数据,找到并替换

更新结果

根据判断,我们在这里应该进行替换操作

P数组更新

根据二分查找,我们找到result数组中对应的108比107大,所以将108替换为107,而108(未替换之前)在result数组的索引是5,5前面的元素是4,所以此时P[i]更新的值应该是4

result数组更新

将108替换为107,如下图示

第八步

继续循环遍历,这时i=7

js 复制代码
i:7
原数组当前数据:109
result最后一个数据:107

109 > 107 ?

✅ 是:更新P数组数据,并且在result数组插入当前数据109的索引

❌ 否:二分查找result数组中最开始比109大的数据,找到并替换

更新结果

第九步

继续循环遍历,这时i=8

js 复制代码
i:8
原数组当前数据:104
result最后一个数据:109

104 > 109 ?

❌ 是:更新P数组数据,并且在result数组插入当前数据104的索引

✅ 否:二分查找result数组中最开始比104大的数据,找到并替换

更新结果

根据判断,我们需要找到比104大的数据进行替换,经过查找发现105比104大,所以应该将105替换为104

P数组更新

我们先找到替换的元素105-在result数组中的元素是3,P[i]就取3前面的元素1

回溯

经过上面的步骤,我们已经初步得到了一个result数组和P数组

这时的result数组是:[2,1,8,4,6,7],对应原数组元素应该是[101,103,104,106,107,109]

单看[101,103,104,106,107,109]来说,确实是递增的子序列,但是对应到原数组中发现顺序并不是顺序排列的。

比如103在原数组第二个位置,101在原数组的第三个位置,但是得到的结果103竟然排在101后面!!

所以对于这个结果来说,并不是我们想要的最长递增子序列

为什么需要回溯

基于上面的分析,我们为什么要进行回溯的理由已经很清楚了。

在回溯前,我们的算法是基于贪心算法的,而贪心算法的核心思想就是:在构建递增子序列时,我们始终希望序列中的每个位置上的值越小越好。

也就是说,我们在进行替换元素的时候,贪心同学才不管什么顺序不顺序的,他只知道要让每个位置的元素越小越好。

也就导致了会存在过度替换的情况。

你也可以认为,贪心同学目的是想让老板省钱,找的员工薪资是越少越好,所以凡是在人才市场找到一个薪资低的,就把现在已有的薪资高的员工替换了。

什么?你说替换到大动脉了?哦,那关我贪心同学什么事?[摊手.png]

当老板发现问题之后,就找记录员P同学,想把之前的"大动脉"再重新找回来,这个过程就叫回溯。

前驱索引一定是正确的?

前面说了,在进行贪心算法的时候会存在过度替换的情况,这个时候需要通过P数组来进行数据矫正。可是为什么P数组一定能矫正呢?

首先我们看一下P数组更新的情况

情况一:直接添加到递增子序列末尾

js 复制代码
if (arr[j] < arrI) {
    p[i] = j;       // j是当前递增子序列的最后一个索引
    result.push(i);
}

在这种情况下,当前元素arr[i]大于递增子序列中的最后一个元素arr[j],所以它可以直接添加到子序列末尾。此时设置p[i] = j是绝对正确的,因为在任何有效的递增子序列中,arr[i]的前面一定是arr[j]

情况二: 替换递增子序列中的某个元素

js 复制代码
if (arrI < arr[result[u]]) {
    if (u > 0) {
        p[i] = result[u - 1];  // 记录前驱为待替换位置的前一个索引
    }
    result[u] = i;
}

这种情况中,我们找到了一个位置u,使得arr[i]可以替换掉arr[result[u]]。替换后,arr[i]的前驱应该是位置u-1对应的元素,即result[u-1]

P数组的一个关键特性是:每个元素的前驱关系一旦确定,就不会再改变 。当我们为arr[i]确定前驱时,是基于当前已知的"最优路径"做出的决策。即使后续的元素可能导致result数组发生变化,但这不会影响arr[i]与其前驱之间的关系

在这两种情况下,p[i]都指向了正确的前驱,并且这个关系在后续操作中不会被改变,这也保证了P数组的正确性。

P数组的作用

P数组是用来记录结果数组中每一个元素对应的前一个元素的索引,你可以通过下图来理解P数据与result的关系。

对于result来说,当前元素是7,想要获取7前面的元素是多少 ,可以通过查找P[7] = 6,发现7前面的元素应该是6

也就是说,P数组总能通过当前元素查找到对应的前一个元素应该是什么

也可以这么理解,当老板发现8员工虽然便宜,但是不能出色完成工作,于是在对比下发现前任员工还是比较出色的,虽然前任要贵了一点。

所以老板综合考虑,还是找来P同学,要求要把8替换为前一个员工。

P同学 通过8员工的后面一个同学4,来查找上一个员工P[4] = 3,然后再把8替换为3,当然薪资也高了1变成了105

以上只是举个例子说明P数组和result数组的关系

回溯过程

回溯是从 LIS 的最后一个元素开始,沿着前驱指针一步步回溯到序列的起点,从而得到完整的 LIS。

第一步

我们从result数组的最后一个元素开始

如图所示:

result数组的最后一个元素:7

P[7] = 6

说明在result数组中,7元素前面的一个元素应该是6,这个时候我们粗暴一点不管7前面是不是6,我们都把前面的元素替换为6

第二步

我们继续遍历

这个时候result的当前元素:6

P[6] = 4,那我们把6前面的元素替换为4

第三步

result的当前元素:4

P[4]=3,那我们把4前面的元素替换为3

当遍历到这里你会发现,4前面的元素应该是8,但是我们通过P数组拿到的数据是3,当两者不同时,我们总是认为P数组是正确的那一个。

此时应该能够明显地感受到前驱索引和回溯的魅力了叭

矫正后的result数组变成下图

第四步

result的当前元素:3

P[3]=1,result的前一个元素也是1,虽然相等我们还是暴力替换为1

第五步

result的当前元素:1

P[1]=0,result的前一个元素2,2不等于0替换前一个元素为0

替换后的result数组如下图

最终结果

所以在经过回溯之后的result结果如下图:

vue3中的LIS

在vue3中,对于新旧dom进行对比的diff算法中,大致步骤是这样的:

  1. 处理头部公共节点
  2. 处理尾部公共节点
  3. 处理中间部分节点(新增,删除,或更新节点位置)

在这里不详细描述diff算法,只了解与LIS有关部分的逻辑。

在处理第三点逻辑的时候,diff采用了LIS来减少dom的移动。

比如有以下新旧dom

  • 旧节点 keys[A, B, C, D, E]
  • 新节点 keys[A, C, D, B, E]

在去除开头和尾部的公共节点1,5,剩下的中间部分就是

  • 旧节点 中间部分[ B, C, D]
  • 新节点 中间部分[ C, D, B]

对于中间部分的节点,每个节点对应的新旧dom的位置都不一样,所以优化前需要三个dom节点都移动。

LIS优化后

vue3在LIS优化前会先拿到新节点在旧节点的位置数组,然后对这个位置数组来进行LIS处理。

位置映射

从新节点开始进行遍历循环

C节点在旧节点的第几个位置?第2个位置

D节点在旧节点的第几个位置?第3个位置

B节点在旧节点的第几个位置?第1个位置

所以我们最终构建的位置映射是[2,3,1]

  • 在vue3中,新增节点对应的的位置是0,我们目前举的例子比较简单,是没有新增节点的,只考虑节点移动更新

LIS处理

经过上面的处理,我们最终需要对数组[2,3,1]进行LIS处理,而这个数组的最长递增子序列应该是[0,1](注意,这个结果取的是[2,3]的索引值)

下图中蓝色标志 为该数据在最长递增子序列

vue3在LIS优化后,从新节点的最后一个数据开始遍历,开始对节点进行新增删除和移动。

最后一个节点B:不在最长递增子序列中,需要在旧节点进行移动,将B移动到索引值为2的位置

倒数第二个节点D:在最长递增子序列中,不需要移动

倒数第一个节点D:在最长递增子序列中,不需要移动

所以最终经过LIS优化后,只需要移动B节点,对比优化前的dom变动,少移动了两个节点。

问题一:原来的D是在2的位置,B移到位置2的话会不会直接把D给替代了?

具体移动B节点的过程应该是这样的:

  • 移动B时,它会先从原来的位置(索引0)被移除

  • 移除后,C会变成索引0,D会变成索引1

  • 然后B被插入到当前的末尾位置(此时是索引2)

  • 最终得到CDB顺序

问题2:B发生了移动,那相对的D和C的索引值也发生了变化,这个不算节点的移动吗?

  • 在Vue的diff算法中,"不移动"是指不需要显式地调用DOM API (如insertBefore)来重新定位节点。对于C和D,它们在DOM树中的相对位置没有变化(C在D前面),所以不需要通过DOM API移动它们。

  • 虽然C和D的DOM索引确实变化了(因为B被移出了),但Vue并没有对它们执行DOM移动操作,因为它们之间的相对顺序没有变

vue3有关代码

js 复制代码
            function getSequence(arr){
                // 复制输入数组作为前驱数组 p
                // p[i] 存储的是最长递增子序列中,位置 i 元素的前驱节点位置
                const p = arr.slice()
                // 初始化结果数组,默认将第一个元素的索引 0 加入结果
                const result = [0]
                let i, j, u, v, c
                const len = arr.length
                
                // 遍历数组中的每个元素
                for (i = 0; i < len; i++) {
                    const arrI = arr[i]
                    // 跳过值为 0 的元素(在 Vue 的 diff 算法中,0 表示新增节点)
                    if (arrI !== 0) {
                        j = result[result.length - 1]  // 获取当前结果数组中最后一个元素的索引
                        
                        // 情况1:如果当前元素大于结果数组最后一个元素,说明找到了更长的递增子序列
                        if (arr[j] < arrI) {
                            p[i] = j // 记录当前元素的前驱为 j
                            result.push(i)// 将当前索引添加到结果数组
                            continue
                        }
                        
                        // 情况2:当前元素小于等于结果数组最后一个元素,使用二分查找找到 result 数组中第一个大于等于 arrI 的元素位置 
                        u = 0
                        v = result.length - 1
                        while (u < v) {
                            c = ((u + v) / 2) | 0  // 计算中间索引,使用位运算向下取整
                            if (arr[result[c]] < arrI) { // 如果中间值小于当前值,在右半部分继续查找
                                u = c + 1
                            } 
                            else { // 否则在左半部分查找
                                v = c
                            }
                        }
                        
                        // 如果找到的位置对应的值大于当前值,则用当前值替换它
                        if (arrI < arr[result[u]]) {
                            // 如果不是替换第一个元素,则更新前驱
                            if (u > 0) {
                                p[i] = result[u - 1]
                            }
                            // 用当前索引替换 result 中的值
                            result[u] = i
                        }
                    }
                }
           
                // 最后,我们通过回溯 p 数组构建最终的最长递增子序列
                u = result.length
                v = result[u - 1]
                // 从后向前回溯,构建正确的结果
                while (u-- > 0) {
                    result[u] = v
                    v = p[v]
                }
                
                return result
            }
相关推荐
恋猫de小郭7 分钟前
Flutter 官方多窗口体验 ,为什么 Flutter 推进那么慢,而 CMP 却支持那么快
android·前端·flutter
STY_fish_20121 小时前
手拆STL
java·c++·算法
云边有个稻草人1 小时前
智启未来:当知识库遇见莫奈的调色盘——API工作流重构企业服务美学
前端·数据库
小纭在努力1 小时前
【算法设计与分析】实验——改写二分搜索算法,众数问题(算法分析:主要算法思路),有重复元素的排列问题,整数因子分解问题(算法实现:过程,分析,小结)
数据结构·python·学习·算法·算法设计与分析·实验报告·实验
芜湖xin2 小时前
【题解-洛谷】B4278 [蓝桥杯青少年组国赛 2023] 简单算术题
算法·
理智的灰太狼2 小时前
题目 3298: 蓝桥杯2024年第十五届决赛真题-兔子集结
算法·职场和发展·蓝桥杯
kingmax542120085 小时前
【洛谷P9303题解】AC- [CCC 2023 J5] CCC Word Hunt
数据结构·c++·算法·广度优先
白熊1886 小时前
【机器学习基础】机器学习入门核心算法:XGBoost 和 LightGBM
人工智能·算法·机器学习
仟濹6 小时前
【HTML】基础学习【数据分析全栈攻略:爬虫+处理+可视化+报告】
大数据·前端·爬虫·数据挖掘·数据分析·html
bai_lan_ya6 小时前
数据结构-排序-排序的七种算法(2)
数据结构·算法·排序算法