前言
Vue3探秘系列文章链接:
不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)
不止响应式:Vue3探秘系列— diff算法的完整过程(三)
计算属性:Vue3探秘系列— computed的实现原理(六)
Hello~大家好。我是秋天的一阵风
上一篇我们提到了,普通元素在进行更新的时候有九种情况,其中最后一种最为复杂,也是我们今天要探究的内容:diff算法。
新子节点数组相对于旧子节点数组的变化,无非是通过更新、删除、添加和移动节点来完成,而核心 diff 算法,就是在已知旧子节点的 DOM 结构、vnode 和新子节点的 vnode 情况下,以较低的成本完成子节点的更新为目的,求解生成新子节点 DOM 的系列操作。
为了方便你理解,我先举第一个例子:
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
</ul>
然后我们在中间插入一行,得到一个新列表:
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="e">e</li>
<li key="c">c</li>
<li key="d">d</li>
</ul>
我们可以直接感受到,差异主要在新子节点中的 b 节点后面多了一个 e 节点。
我们再看第二个例子:
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
<li key="e">e</li>
</ul>
然后我们删除中间一项,得到一个新列表
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="d">d</li>
<li key="e">e</li>
</ul>
我们可以看到,这时差异主要在新子节点中的 b 节点后面少了一个 c 节点。
综合这两个例子,我们很容易发现新旧 children 拥有相同的头尾节点
。
对于相同的节点,我们只需要做对比更新即可,所以 diff 算法的第一步从头部开始同步。
一、同步头部结点
我们先来看一下头部节点同步的实现代码:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 3, e2 = 4
// (a b) c d
// (a b) e c d
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = c2[i]
if (isSameVNodeType(n1, n2)) {
// 相同的节点,递归执行 patch 更新节点
patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
}
else {
break
}
i++
}
}
在整个 diff 的过程,我们需要维护几个变量:头部的索引 i
、旧子节点的尾部索引 e1
和新子节点的尾部索引 e2
。
同步头部节点就是从头部开始,依次对比新节点
和旧节点
,如果它们相同的则执行 patch 更新节点
;
如果不同或者索引 i 大于索引 e1 或者 e2,则同步过程结束。
我们拿第一个例子来说,通过下图看一下同步头部节点后的结果
可以看到,完成头部节点同步后:i 是 2
,e1 是 3
,e2 是 4
。
二、同步尾部节点
接着从尾部开始同步尾部节点,实现代码如下:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 3, e2 = 4
// (a b) c d
// (a b) e c d
// 2. 从尾部开始同步
// i = 2, e1 = 3, e2 = 4
// (a b) (c d)
// (a b) e (c d)
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = c2[e2]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
}
else {
break
}
e1--
e2--
}
}
同步尾部节点就是从尾部开始,依次对比新节点和旧节点,如果相同的则执行 patch 更新节点
;
如果不同或者索引 i
大于 索引e1 或者 e2
,则同步过程结束。
我们来通过下图看一下同步尾部节点后的结果:
可以看到,完成尾部节点同步后:i 是 2
,e1 是 1
,e2 是 2
。
接下来只有 3 种情况要处理:
- 新子节点有剩余要添加的新节点;
- 旧子节点有剩余要删除的多余节点;
- 未知子序列
三、添加新的结点
先要判断新子节点是否有剩余的情况,如果满足则添加新子节点,实现代码如下:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 3, e2 = 4
// (a b) c d
// (a b) e c d
// ...
// 2. 从尾部开始同步
// i = 2, e1 = 3, e2 = 4
// (a b) (c d)
// (a b) e (c d)
// 3. 挂载剩余的新节点
// i = 2, e1 = 1, e2 = 2
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1
const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor
while (i <= e2) {
// 挂载新节点
patch(null, c2[i], container, anchor, parentComponent, parentSuspense, isSVG)
i++
}
}
}
}
如果索引 i 大于尾部索引 e1
且 i 小于 e2
,那么从索引 i
开始到索引 e2
之间,我们直接挂载新子树这部分的节点。
对我们的例子而言,同步完尾部节点后 i 是 2
,e1 是 1
,e2 是 2
,此时满足条件需要添加新的节点,我们来通过下图看一下添加后的结果:
添加完 e 节点
后,旧子节点的 DOM 和新子节点对应的 vnode 映射一致,也就完成了更新。
四、删除多余结点
如果不满足添加新节点的情况,我就要接着判断旧子节点是否有剩余,如果满足则删除旧子节点,实现代码如下:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 4, e2 = 3
// (a b) c d e
// (a b) d e
// ...
// 2. 从尾部开始同步
// i = 2, e1 = 4, e2 = 3
// (a b) c (d e)
// (a b) (d e)
// 3. 普通序列挂载剩余的新节点
// i = 2, e1 = 2, e2 = 1
// 不满足
if (i > e1) {
}
// 4. 普通序列删除多余的旧节点
// i = 2, e1 = 2, e2 = 1
else if (i > e2) {
while (i <= e1) {
// 删除节点
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
}
如果索引 i
大于尾部索引 e2
,那么从索引 i
开始到索引 e1
之间,我们直接删除旧子树这部分的节点。
第二个例子是就删除节点的情况,我们从同步头部节点开始,用图的方式演示这一过程。
首先从头部同步节点:
此时的结果:i 是 2,e1 是 4,e2 是 3。
接着从尾部同步节点:
此时的结果:i 是 2
,e1 是 2
,e2 是 1
,满足删除条件,因此删除子节点中的多余节点:
删除完 c 节点后,旧子节点的 DOM 和新子节点对应的 vnode 映射一致,也就完成了更新。
五、处理未知子序列
单纯的添加和删除节点都是比较理想的情况,操作起来也很容易,但是有些时候并非这么幸运,我们会遇到比较复杂的未知子序列,这时候 diff 算法会怎么做呢?
我们再通过例子来演示存在未知子序列的情况,假设一个按照字母表排列的列表:
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
<li key="e">e</li>
<li key="f">f</li>
<li key="g">g</li>
<li key="h">h</li>
</ul>
然后我们打乱之前的顺序得到一个新列表:
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="e">e</li>
<li key="d">c</li>
<li key="c">d</li>
<li key="i">i</li>
<li key="g">g</li>
<li key="h">h</li>
</ul>
在操作前,它们对应渲染生成的 vnode 可以用一张图表示:
我们还是从同步头部节点开始,用图的方式演示这一过程。
首先从头部同步节点:
同步头部节点后的结果:i 是 2
,e1 是 7
,e2 是 7
。
接着从尾部同步节点:
同步尾部节点后的结果:i 是 2
,e1 是 5
,e2 是 5
。可以看到它既不满足添加新节点的条件,也不满足删除旧节点的条件。那么对于这种情况,我们应该怎么处理呢?
结合上图可以知道,要把旧子节点的 c、d、e、f
转变成新子节点的 e、c、d、i
。从直观上看,我们把 e 节点
移动到 c 节点
前面,删除 f 节点
,然后在 d 节点
后面添加 i 节点
即可。
-
当两个节点类型相同时,我们执行更新操作;
-
当新子节点中没有旧子节点中的某些节点时,我们执行删除操作;
-
当新子节点中多了旧子节点中没有的节点时,我们执行添加操作,这些操作我们在前面已经阐述清楚了。
-
相对来说这些操作中最麻烦的就是移动,我们既要判断哪些节点需要移动也要清楚如何移动。
1. 移动子节点
那么什么时候需要移动呢,就是当子节点排列顺序发生变化的时候,举个简单的例子具体看一下:
var prev = [1, 2, 3, 4, 5, 6]
var next = [1, 3, 2, 6, 4, 5]
可以看到,从 prev 变成 next,数组里的一些元素的顺序发生了变化,我们可以把子节点类比为元素,现在问题就简化为我们如何用最少的移动使元素顺序从 prev 变化为 next 。
一种思路是在 next 中找到一个递增子序列
,比如 [1, 3, 6]
、[1, 2, 4, 5]
。之后对 next 数组进行倒序遍历
,移动所有不在递增序列中的元素即可。
如果选择了 [1, 3, 6]
作为递增子序列,那么在倒序遍历的过程中,遇到 6、3、1
不动,遇到 5、4、2
移动即可,那么,我们就需要移动三次, 如下图所示:
如果选择了 [1, 2, 4, 5]
作为递增子序列,那么在倒序遍历的过程中,遇到 5、4、2、1
不动,遇到 6、3
移动即可,那么,我们就只需要移动两次次, 如下图所示:
递增子序列越长,所需要移动元素的次数越少,所以如何移动的问题就回到了求解最长递增子序列的问题。我们稍后会详细讲求解最长递增子序列的算法,所以先回到我们这里的问题,对未知子序列的处理。
我们现在要做的是在新旧子节点序列中找出相同节点并更新
,找出多余的节点删除
,找出新的节点添加
,找出是否有需要移动的节点
,如果有该如何移动。
在查找过程中需要对比新旧子序列,那么我们就要遍历某个序列,如果在遍历旧子序列的过程中需要判断某个节点是否在新子序列中存在,这就需要双重循环,而双重循环的复杂度是 O(n2)
,为了优化这个复杂度,我们可以用一种空间换时间的思路,建立索引图,把时间复杂度降低到 O(n)
。
所以处理未知子序列的第一步,就是建立索引图。
2. 对新的子序列数组建立索引图: keyToNewIndexMap
通常我们在开发过程中, 会给 v-for 生成的列表中的每一项分配唯一 key 作为项的唯一 ID,这个 key 在 diff 过程中起到很关键的作用。对于新旧子序列中的节点,我们认为 key 相同的就是同一个节点,直接执行 patch 更新即可。
我们根据 key 建立新子序列的索引图,实现如下:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 7, e2 = 7
// (a b) c d e f g h
// (a b) e c d i g h
// 2. 从尾部开始同步
// i = 2, e1 = 7, e2 = 7
// (a b) c d e f (g h)
// (a b) e c d i (g h)
// 3. 普通序列挂载剩余的新节点, 不满足
// 4. 普通序列删除多余的旧节点,不满足
// i = 2, e1 = 4, e2 = 5
// 旧子序列开始索引,从 i 开始记录
const s1 = i
// 新子序列开始索引,从 i 开始记录
const s2 = i //
// 5.1 根据 key 建立新子序列的索引图
const keyToNewIndexMap = new Map()
for (i = s2; i <= e2; i++) {
const nextChild = c2[i]
keyToNewIndexMap.set(nextChild.key, i)
}
}
新旧子序列是从 i 开始的,所以我们先用 s1、s2 分别作为新旧子序列的开始索引,接着建立一个 keyToNewIndexMap
的 Map<key, index>
结构,遍历新子序列,把节点的 key
和 index
添加到这个 Map
中,注意我们这里假设所有节点都是有 key 标识的。
keyToNewIndexMap
存储的就是新子序列中每个节点在新子序列中的索引,我们来看一下示例处理后的结果,如下图所示:
我们得到了一个值为 {e:2,c:3,d:4,i:5}
的新子序列索引图
3. 对旧的子序列节点遍历,更新和移除旧节点
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 7, e2 = 7
// (a b) c d e f g h
// (a b) e c d i g h
// 2. 从尾部开始同步
// i = 2, e1 = 7, e2 = 7
// (a b) c d e f (g h)
// (a b) e c d i (g h)
// 3. 普通序列挂载剩余的新节点,不满足
// 4. 普通序列删除多余的旧节点,不满足
// i = 2, e1 = 4, e2 = 5
// 旧子序列开始索引,从 i 开始记录
const s1 = i
// 新子序列开始索引,从 i 开始记录
const s2 = i
// 5.1 根据 key 建立新子序列的索引图
// 5.2 正序遍历旧子序列,找到匹配的节点更新,删除不在新子序列中的节点,判断是否有移动节点
// 新子序列已更新节点的数量
let patched = 0
// 新子序列待更新节点的数量,等于新子序列的长度
const toBePatched = e2 - s2 + 1
// 是否存在要移动的节点
let moved = false
// 用于跟踪判断是否有节点移动
let maxNewIndexSoFar = 0
// 这个数组存储新子序列中的元素在旧子序列节点的索引,用于确定最长递增子序列
const newIndexToOldIndexMap = new Array(toBePatched)
// 初始化数组,每个元素的值都是 0
// 0 是一个特殊的值,如果遍历完了仍有元素的值为 0,则说明这个新节点没有对应的旧节点
for (i = 0; i < toBePatched; i++)
newIndexToOldIndexMap[i] = 0
// 正序遍历旧子序列
for (i = s1; i <= e1; i++) {
// 拿到每一个旧子序列节点
const prevChild = c1[i]
if (patched >= toBePatched) {
// 所有新的子序列节点都已经更新,剩余的节点删除
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
// 查找旧子序列中的节点在新子序列中的索引
let newIndex = keyToNewIndexMap.get(prevChild.key)
if (newIndex === undefined) {
// 找不到说明旧子序列已经不存在于新子序列中,则删除该节点
unmount(prevChild, parentComponent, parentSuspense, true)
}
else {
// 更新新子序列中的元素在旧子序列中的索引,这里加 1 偏移,是为了避免 i 为 0 的特殊情况,影响对后续最长递增子序列的求解
newIndexToOldIndexMap[newIndex - s2] = i + 1
// maxNewIndexSoFar 始终存储的是上次求值的 newIndex,如果不是一直递增,则说明有移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
}
else {
moved = true
}
// 更新新旧子序列中匹配的节点
patch(prevChild, c2[newIndex], container, null, parentComponent, parentSuspense, isSVG, optimized)
patched++
}
}
}
我们建立了一个 newIndexToOldIndexMap
的数组,来存储新子序列节点的索引
和旧子序列节点的索引
之间的映射关系 (这里非常重要,一定要理解清楚newIndexToOldIndexMap这个数组存储的是什么东西,在下面的流程结果图中我会专门解释),用于确定最长递增子序列
,这个数组的长度为新子序列的长度
,每个元素的初始值设为 0, 它是一个特殊的值,如果遍历完了仍有元素的值为 0,则说明遍历旧子序列的过程中没有处理过这个节点,这个节点是新添加的。
接下来,我们就需要遍历旧子序列
,有相同的节点就通过 patch 更新
,并且移除那些不在新子序列中的节点
,同时找出是否有需要移动的节点
下面我们说说具体的操作过程:
- 正序遍历旧子序列,根据前面建立的
keyToNewIndexMap
查找旧子序列中的节点
在新子序列中的索引
,如果找不到就说明新子序列中没有该节点,就删除它;如果找得到则将它在旧子序列中的索引更新到newIndexToOldIndexMap
中。
注意这里索引加了长度为 1 的偏移,是为了应对 i 为 0 的特殊情况,如果不这样处理就会影响后续求解最长递增子序列。
-
遍历过程中,我们用变量
maxNewIndexSoFar
跟踪判断节点是否移动,maxNewIndexSoFar
始终存储的是上次求值的newIndex
,一旦本次求值的newIndex
小于maxNewIndexSoFar
,这说明顺序遍历旧子序列的节点在新子序列中的索引并不是一直递增的,也就说明存在移动的情况。 -
除此之外,这个过程中我们也会更新
新旧子序列中匹配的节点
,另外如果所有新的子序列节点都已经更新,而对旧子序列遍历还未结束,说明剩余的节点就是多余的,删除即可。
至此,我们完成了新旧子序列节点的更新、多余旧节点的删除,并且建立了一个 newIndexToOldIndexMap
存储新子序列节点的索引和旧子序列节点的索引之间的映射关系,并确定是否有移动。
我们来看一下示例处理后的结果,如下图所示:
对 newIndexToOldIndexMap 解释:
(1) 新子序列的第一个元素 e 在 旧子序列中的 索引值是 4, 所以 第一个值是 4 + 1 = 5;
(2) 新子序列的第二个元素 c 在 旧子序列中的 索引值是 2, 所以 第二个值是 2 + 1 = 3;
(3) 新子序列的第三个元素 d 在 旧子序列中的 索引值是 3, 所以 第三个值是 3 + 1 = 4;
(4) 新子序列的第四个元素 i 在 旧子序列中的 索引值是 -1, 也就是找不到,所以 第四个值是 0 ;
可以看到, c、d、e 节点被更新,f 节点被删除,newIndexToOldIndexMap
的值为 [5, 3, 4 ,0],此时 moved 也为 true
,也就是存在节点移动的情况。
4. 移动和挂载新节点
接下来,就到了处理未知子序列的最后一个流程,移动和挂载新节点,我们来看一下这部分逻辑的实现:
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 6, e2 = 7
// (a b) c d e f g
// (a b) e c d h f g
// 2. 从尾部开始同步
// i = 2, e1 = 6, e2 = 7
// (a b) c (d e)
// (a b) (d e)
// 3. 普通序列挂载剩余的新节点, 不满足
// 4. 普通序列删除多余的节点,不满足
// i = 2, e1 = 4, e2 = 5
// 旧子节点开始索引,从 i 开始记录
const s1 = i
// 新子节点开始索引,从 i 开始记录
const s2 = i //
// 5.1 根据 key 建立新子序列的索引图
// 5.2 正序遍历旧子序列,找到匹配的节点更新,删除不在新子序列中的节点,判断是否有移动节点
// 5.3 移动和挂载新节点
// 仅当节点移动时生成最长递增子序列
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
let j = increasingNewIndexSequence.length - 1
// 倒序遍历以便我们可以使用最后更新的节点作为锚点
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex]
// 锚点指向上一个更新的节点,如果 nextIndex 超过新子节点的长度,则指向 parentAnchor
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// 挂载新的子节点
patch(null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG)
}
else if (moved) {
// 没有最长递增子序列(reverse 的场景)或者当前的节点索引不在最长递增子序列中,需要移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, 2)
}
else {
// 倒序递增子序列
j--
}
}
}
}
我们前面已经判断了是否移动,如果 moved
为 true
就通过 getSequence(newIndexToOldIndexMap)
计算最长递增子序列,这部分算法我会放在后文详细介绍。
接着我们采用倒序的方式遍历新子序列,因为倒序遍历可以方便我们使用最后更新的节点作为锚点。
在倒序的过程中,锚点指向上一个更新的节点,然后判断 newIndexToOldIndexMap[i]
是否为 0,
-
如果是则表示这是新节点,就需要挂载它;
-
接着判断是否存在节点移动的情况,如果存在的话则看节点的索引是不是在最长递增子序列中,如果在则倒序最长递增子序列,否则把它移动到锚点的前面。
为了便于你更直观地理解,我们用前面的例子展示一下这个过程,
此时 toBePatched
的值为 4,j
的值为 1,最长递增子序列 increasingNewIndexSequence
的值是 [1, 2]
。
(1) 在倒序新子序列的过程中,首先遇到节点 i
,发现它在 newIndexToOldIndexMap
中的值是 0,则说明它是新节点,我们需要挂载它;
(2) 然后继续遍历遇到节点 d
,因为 moved 为 true,且 d 的索引存在于最长递增子序列中,则执行 j-- 倒序最长递增子序列,j 此时为 0;
(3) 接着继续遍历遇到节点 c
,它和 d 一样,索引也存在于最长递增子序列中,则执行 j--,j 此时为 -1;
(4) 接着继续遍历遇到节点 e
,此时 j 是 -1 并且 e 的索引也不在最长递增子序列中,所以做一次移动操作,把 e 节点移到上一个更新的节点,也就是 c 节点 的前面。
新子序列倒序完成,即完成了新节点的插入和旧节点的移动操作,也就完成了整个核心 diff 算法对节点的更新。
5. 最长递增子序列
求解最长递增子序列是一道经典的算法题,多数解法是使用动态规划的思想,算法的时间复杂度是 O(n2)
,而 Vue.js 内部使用的是维基百科提供的一套“贪心 + 二分查找”
的算法,贪心算法的时间复杂度是 O(n)
,二分查找的时间复杂度是 O(logn)
,所以它的总时间复杂度是 O(nlogn)
。
单纯地看代码并不好理解,我们用示例来看一下这个子序列的求解过程。
假设我们有这样一个数组 arr:[2, 1, 5, 3, 6, 4, 8, 9, 7]
,求解它最长递增子序列的步骤如下:
(1) 对于 i = 0 , arr[0] =2
时,最长递增子序列是 [2]
(2) 对于 i = 1 , arr[1] =1
时,最长递增子序列是 [2]
或 [1]
(3) 对于 i = 2 , arr[2] =5
时, 5
比前面的 1
要大,在1的序列上加上自己, 最长递增子序列是[1,5]
(4) 对于 i = 3 , arr[3] =3
时, 3
比前面的 5
要小,继续往前找,找到 1
比自己小,最长递增子序列是 [1,3]
(5) 对于 i = 4 , arr[4] =6
时, 6
比前面的 3
要大,在 3
的序列上加上自己, 最长递增子序列是 [1, 3,6]
(6) 对于 i = 5 , arr[5] =4
时, 4
比前面的 6
要小,继续往前找,找到 4
比自己小,最长递增子序列是 [1,3,4]
(7) 对于 i = 6 , arr[6] =8
时, 8
比前面的 4
要大,在 4
的序列上加上自己, 最长递增子序列是 [1, 3,4,8]
(8) 对于 i = 7 , arr[7] =9
时, 9
比前面的 8
要大,在8
的序列上加上自己, 最长递增子序列是 [1, 3,4,8,9]
(9) 对于 i = 8 , arr[8] =7
时, 7
比前面的 9
要小,继续往前找,找到4
比自己小,最长递增子序列是 [1,3,4,7]
通过演示我们可以得到这个算法的主要思路:
-
对数组遍历,依次求解长度为 i 时的最长递增子序列,当 i 元素大于 i - 1 的元素时,添加 i 元素并更新最长子序列;
-
否则往前查找直到找到一个比 i 小的元素,然后插在该元素后面并更新对应的最长递增子序列。
这种做法的主要目的是让递增序列的差尽可能的小,从而可以获得更长的递增子序列,这便是一种贪心算法的思想。
了解了算法的大致思想后,接下来我们看一下源码实现:
function getSequence (arr) {
// 创建一个与输入数组相同的副本,用于记录每个元素的前一个元素索引。
const p = arr.slice()
// 初始化结果数组,用于存储最长递增子序列的索引。
const result = [0]
let i, j, u, v, c
const len = arr.length
// 遍历输入数组,寻找最长递增子序列
for (i = 0; i < len; i++) {
const arrI = arr[i]
// 如果当前元素不为0,则进行处理。
if (arrI !== 0) {
j = result[result.length - 1] // result数组中最大的值的索引
// 如果当前元素大于结果数组最后一个元素,则直接添加到结果数组中。
if (arr[j] < arrI) {
// 如果当前值大于result中的最大值,那么就将当前值添加到result数组中,意思是递增序列长度增加1
p[i] = j // 因为result在这里马上会push进去一个值,当前位置存储的是还没push前的最后一位也就是push后的前一位的索引
result.push(i) // 也就是当前值arrI 所在位置的前一位的索引
continue
}
// 使用二分查找,确定当前元素应该插入的结果数组的位置。
u = 0 //左指针初始值为0
v = result.length - 1 // 右指针初始值为result数组长度-1,也就是最大索引
// 二分搜索,查找比 arrI 小的节点,更新 result 的值
while (u < v) { // 当左指针小于右指针时,才需要进入循环
c = ((u + v) / 2) | 0 // 这个位置是取中间值,Vue最初的代码是 ((u + v)/ 2) | 0 后来改成了 (u+v)>>1, 更好的方式是 u+ ((v-u) >> 1) 可以避免指针越界,不过在vue中节点的数量远达不到越界的情况可暂时忽略
if (arr[result[c]] < arrI) { // 如果中间值的位置的值小于当前值
u = c + 1 // 那么就说明要找的值在中间值的右侧,因此左指针变为中间值+1
}
else { // 否则就是大于等于当前值
v = c // 那么右指针变为中间值,再进行下一次循环
}
}
// 最后输出的左指针的索引一定是非小于当前值的,有可能大于也有可能等于
if (arrI < arr[result[u]]) { // 如果当前值小于第一个非小于的值,那么就意味着这个值是大于的,排除了等于的情况。
if (u > 0) { // 如果u === 0 说明当前值是最小的,不会有比它小的值,那么它前面不会有任何的值,只有u大于0时才需要存储它前面的值
p[i] = result[u - 1] // 当前位置因为result[u]马上就被arrI替换,所以result[u - 1]就是当前值存储位置的前一位,也就是比当前值小的那个值所在的位置
}
result[u] = i // 将第一个比当前值大的值替换为当前值,依次来让数组递增的更缓慢
}
}
}
// 逆向遍历结果数组,填充每个元素的前一个元素索引,以便最终得到完整的最长递增子序列。
// 使用二分可以找到最长的长度但是无法判断最长的序列
// 开始回溯倒序找到最长的序列,因为p中当前位置存放的是上一个比当前值小的数所在的位置,所以使用倒序
u = result.length // 获取递增数组的长度
v = result[u - 1] // 获取递增数组的最后一项也就是最大值的索引
while (u-- > 0) { // 当u的索引没有越界时一直循环
result[u] = v // 一开始result的最后一个值存放的索引一定是最大值
v = p[v] // 根据当前值就是该序列中最大的部分来查找是谁跳动这个位置的,依次往前查
}
return result
}
为了方便理解,我们还是用上面的案例来逐步分析流程 :arr:[2, 1, 5, 3, 6, 4, 8, 9, 7]
(1) 得到result数组,result存储的是arr数组的索引值
经过 for循环以后,我们得到了 result的结果为 [1,3,5,8,7]
其中 result 存储的是长度为 i 的递增子序列最小末尾值的索引。比如我们上述例子的第九步,在对数组 p 回溯之前, result对应arr数组的 值就是 [1, 3, 4, 7, 9]
,这不是最长递增子序列,它只是存储的对应长度递增子序列的最小末尾。
因此在整个遍历过程中会额外用一个数组 p,来存储在每次更新 result 前最后一个索引的值,并且它的 key 是这次要更新的 result 值:
j = result[result.length - 1]
p[i] = j
result.push(i)
可以看到,result 添加的新值 i 是作为 p 存储 result 最后一个值 j 的 key。上述例子遍历后 p 的结果如图所示:
(2) 回溯
从 result 最后一个元素 9 对应的索引 7 开始回溯,可以看到 p[7] = 6
,p[6] = 5
,p[5] = 3
,p[3] = 1
,所以通过对 p 的回溯,得到最终的 result 值是 [1, 3 ,5 ,6 ,7]
,也就找到最长递增子序列的最终索引了。
这里要注意,我们求解的是最长子序列索引值,它的每个元素其实对应的是数组的下标。对于我们的例子而言,[2, 1, 5, 3, 6, 4, 8, 9, 7]
的最长子序列是 [1, 3, 4, 8, 9]
,而我们求解的 [1, 3 ,5 ,6 ,7]
就是最长子序列中元素在原数组中的 下标 所构成的新数组。
总结
本篇内容我们探究了diff算法的完整过程,从同步头部结点、同步尾部结点、添加新的结点、删除多余结点,再到处理未知子序列中建立索引图、更新和删除旧结点、移动和挂载新结点。最后再研究了vue3中获取最长递增子序列的算法。 下一篇我们会继续探究 组件初始化发生了什么~
文章如果对你学习源码有帮助,恳请给我一个赞 ~ 再次感谢~