面试官:你如何评价Vue3的LIS算法?
我:啊?这题在《Vue2从入门到跑路》里没教啊!
最终收获Offer:《区块链公司の切图外包,用!important给老板的祖传CSS屎山强行续命》」
前言
经济下行时期,我司近期的收益也不是很好,前几天刚裁了一波人,顿时压力就上来了。
在这种环境下,本人觉得还是有必要抽点时间重新学习下各种原理和算法,试图回炉重造,从【代码搬运工】成功进化成【架构大师】。
目标:下一份工作不再是拧螺丝
,而是造火箭
!
定义
最长递增子序列(Longest Increasing Subsequence,简称LIS)
它要求给定一个序列(比如一个数组),找到其中最长的子序列 ,使得这个子序列中的元素是严格递增的 。注意,这个子序列不一定是连续的,但必须保持原序列中的相对顺序。
简单来说就是:需要是子序列,还要是递增的,然后还必须是最长的。
这三点满足了,一定是一个完美的LIS!
我们可以通过一个例子直观一些
例子
给定数组 [10, 9, 2, 5, 3, 7, 101, 18
],这个数组的最长递增子序列就是[2, 3, 7, 18]
也许起初你在理解这个算法的时候,可能会有这几个疑问:
- 这个P数组怎么就这样那样然后变成了这个数字了的?
- 回溯是什么?
- 如果不回溯有什么问题吗?
那么通过这篇文章,你将会找到以上疑问的答案,并且你会了解以下知识:
- 最长递增子序列的整个过程(图解且详细版)
- P数组的作用展示
- 为什么需要回溯,回溯前的数据为什么可能会存在不对呢
- 回溯的整个过程以及作用
- 在vue3中使用最长递增子序列的作用
- 在vue3中算法的代码实现
最长递增子序列的整个过程
在学习这个算法之前,我们先来了解一些基础知识便于后续更容易理解。
前提声明
- 下面即将演示的算法都是基于vue3源码里面的最长递增子序列算法。
这与其他地方比如力扣里面的算法稍有不同的是:vue3里面的输出结果是对应子序列的下标数组。
但是实现方式都是基于贪心+二分查找
- 本文选择以这个数组
[102, 103, 101, 105, 106, 108, 107, 109, 104]
为原数组来进行解析。
即我们在最后得到的数组应该是[0,1,3,4,6,7]
,而不是[102,103,105,106,107,109]
- 下文中的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算法中,大致步骤是这样的:
- 处理头部公共节点
- 处理尾部公共节点
- 处理中间部分节点(新增,删除,或更新节点位置)
在这里不详细描述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
}