Vue3 的快速 Diff 算法中使用了最长递增子序列 来提升 Diff 的效率,因为最长递增子序列 可以最大程度减少 DOM 移动的次数,这也是 Vue3 的快速 Diff 算法比 Vue2 的双端 Diff 算法效率高的原因。因此掌握如何求取最长递增子序列也相当于掌握了快速 Diff 算法的精髓。
求取最长递增子序列是一个经典的算法题,有三种方法可以完成,分别是暴力法、动态规划、贪心 + 二分。
暴力法
暴力法的核心思想是:找到所有的递增子序列,然后从中找到长度最长的那一个。
代码实现:
js
function getSequence(arr) {
let maxLength = 0;
let longestSeq = [];
// 递归函数,找到以当前位置结尾的最长递增子序列
function findSubsequence(index, subSeq) {
let currentNum = arr[index];
let newSeq = [...subSeq, currentNum]; // 将当前数字加入当前子序列
// 遍历当前数字之后的所有数字
for (let i = index + 1; i < arr.length; i++) {
// 如果之后的数字大于当前数字,则调用递归函数,寻找更长的递增子序列
if (arr[i] > currentNum) {
findSubsequence(i, newSeq);
}
}
// 更新最长递增子序列和长度
if (newSeq.length > maxLength) {
maxLength = newSeq.length;
longestSeq = newSeq;
}
}
// 对每个位置调用递归函数
for (let i = 0; i < arr.length; i++) {
findSubsequence(i, []);
}
return longestSeq;
}
const list = [3, 7, 22, 4, 8, 13, 9, 11, 12]
getSequence(list) // [3, 7, 8, 9, 11, 12]
上面的代码,通过循环+递归,找到数组中所有的递增子序列,然后在循环+递归的过程中找到最长的那一个就是最长递增子序列。
暴力法虽然效率低下,但是我们不能不会。
动态规划
dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列。
设 dp[j] 为 [0, i - 1] 中的最长递增子序列,则 dp[i] 为当 num[i] 大于 num[j] 时,最长的 dp[j] 序列加上 num[i] 元素。即[...dp[j], num[i]]
代码实现:
js
function getSequence(arr) {
// 初始化最大序列长度为0
let maxLength = 0;
// 初始化最大序列为空数组
let maxSeq = [];
// 创建一个与输入数组相同长度的数组,每个位置都初始化为一个空数组
let sequences = new Array(arr.length).fill().map(() => []);
// 遍历输入数组
for (let i = 0; i < arr.length; i++) {
// 创建一个以当前元素为起始的新序列
let seq = [arr[i]];
// 遍历之前的元素,查找比当前元素小的元素并构建新的序列
for (let j = 0; j < i; j++) {
if (arr[j] < arr[i]) {
// 使用之前存储的序列拼接当前元素
seq = sequences[j].concat(arr[i]);
}
}
// 将得到的序列存储起来
sequences[i] = seq;
// 更新最大序列
if (seq.length > maxLength) {
maxLength = seq.length;
maxSeq = seq;
}
}
// 返回最大序列
return maxSeq;
}
const list = [3, 7, 22, 4, 8, 13, 9, 11, 12]
getSequence(list) // [3, 4, 8, 9, 11, 12]
注意,一个数组的最长递增子序列不一定是唯一的,所以,虽然用动态规划得到的结果与暴力法得到的结果不一样,但是也是正确的。
上述代码可能有点抽象,具体图解如下:
贪心 + 二分
贪心 + 二分为 Vue3 采用的方法,Vue3 中获取最长递增子序列的源码如下:
ts
function getSequence(arr: number[]): number[] {
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]
if (arrI !== 0) {
j = result[result.length - 1]
if (arr[j] < arrI) {
p[i] = j
result.push(i)
continue
}
u = 0
v = result.length - 1
while (u < v) {
c = (u + v) >> 1
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[u] = i
}
}
}
u = result.length
v = result[u - 1]
while (u-- > 0) {
result[u] = v
v = p[v]
}
return result
}
👆上面的代码摘自 Vue.js 3.2.45 版本
不过与上面的实现不同的是,这里返回的是最长递增子序列的索引。不过没关系,最后遍历一遍索引数组,也是可以获得最终的最长递增子序列的。
我们要求取一个数组的最长递增子序列,则要想办法让这个子序列尽量增长缓慢(贪心算法的思想)。当我们遍历数组的时候,发现当前值比前面的值大,则直接将当前值 push 进序列中,这是没错的,如果当前值比前一个值小,则在子序列中使用二分法找到比当前值大的最小的那个数,然后使用当前值替换该值。由下图所示,经过二分法的操作,序列 [6, 8, 14] 比 [6, 8, 15] 增长得更缓慢。
但是,可以发现二分法中的替换操作,改变了子序列的性质,因为在原数组中,14 的位置在 20 的后面,替换后 14 跑到 20 前面去了,虽然最长递增子序列的长度仍然是对的,但是实际的最长递增子序列是错误的。这显然是不对的,在 Vue3 中使用了回溯法纠正了这个错误。
回溯的关键在于定义了额外的 p 数组存储比当前值小的索引,即 p[i] 存的永远是比 arr[i] 小的那个值所在的位置。
js
// result 数组的最后一个元素为当前找到的最大值的索引
j = result[result.length - 1]
// 当前值大于最大值
if (arr[j] < arrI) {
// 因为 arr[j] < arrI ,j 为比当前值小的索引,则记录到 p 中
p[i] = j
// 将当前值的索引 push 到结果数组中
result.push(i)
continue
}
js
// 开始二分
// 左指针初始值为 0
u = 0
// 右指针初始值为数组长度-1,也就是最大索引
v = result.length - 1
// 当左指针小于右指针时,才需要进入循环
while (u < v) {
// 这个位置是取中间值,Vue最初的代码是 ((u + v)/ 2) | 0 后来改成了 (u+v)>>1,
// 更好的方式是 u+ ((v-u) >> 1) 可以避免指针越界,
// 不过在vue中节点的数量远达不到越界的情况可暂时忽略
c = (u + v) >> 1
// 如果中间值的位置的值小于当前值
if (arr[result[c]] < arrI) {
// 那么就说明要找的值在中间值的右侧,因此左指针变为中间值 +1
u = c + 1
} else { // 否则就是大于等于当前值
// 那么右指针变为中间值,再进行下一次循环
v = c
}
}
// 最后输出的左指针的索引一定是非小于当前值的,有可能大于也有可能等于,
// 如果当前值小于第一个非小于的值,那么就意味着这个值是大于的,排除了等于的情况。
if (arrI < arr[result[u]]) {
// 如果 u === 0 说明当前值是最小的,不会有比它小的值,那么它前面不会有任何的值,只有 u 大于 0 时才需要存储它前面的值
if (u > 0) {
// result[u - 1] 就是当前值存储位置的前一位,也就是比当前值小的那个值所在的位置,
// 记录到 p 中
p[i] = result[u - 1]
}
// 将第一个比当前值大的值替换为当前值,依次来让数组递增的更缓慢,但会导致结果不正确
result[u] = i
}
经过二分查找后,u
仍为 0 的话,说明 arrI 比 result 中的所有数的小。将这个比 result 中所有数都小的索引 i
记录下来:
js
// 此时 u 为 0
result[u] = i
c = (u + v) >> 1
为位操作,等价于 Math.floor((u + v) / 2)
。更好的方式是 u + ((v-u) >> 1)
可以避免指针越界,不过在 vue 中节点的数量远达不到越界的情况可暂时忽略。
js
// 使用二分可以找到最长的长度但是无法判断最长的序列
u = result.length
// 开始回溯倒序找到最长的序列,
// 因为 p 中当前位置存放的是上一个比当前值小的数所在的位置,所以使用倒序
v = result[u - 1]
while (u-- > 0) { // 当 u 的索引没有越界时一直循环
// 第一次循环, v 就是 result 里面的最后一项,因为最后一项是最大值,肯定是对的
result[u] = v
// 最后一项在 p 中记录了它的前一项,所以取出前一项放在 result
// 循环每取出一项,都能在 p 中找到它的前一项,
// 从而纠正由于二分查找替换导致的错误序列
v = p[v]
}
上面的代码说明会比较抽象,下面来看看图解说明,假设我们输入的数组为:[3, 4, 1]
由于求取的是最长递增子序,因此 result 中的元素应该是递增的, 但是被二分查找打乱了顺序,因此需要通过回溯来纠正。
大家可能还不太理解 p[i] 的意义,其实 p[i] 存的永远是比 arr[i] 小的那个值所在的位置,因此通过 p[i] ,可以找到 i - 1 那个位置的正确值是多少。
记住,如果 p[i] ≠ arr[i],则 p[i] 存的是比 arr[i] 小的那个值所在的位置。所以通过回溯 p[i] 可以纠正 result 的值。
text
下标 0 1 2 3 4 5 6 7
arr = [2, 4, 5, 3, 1, 9, 7, 8]
p = [2, 0, 1, 0, 1, 2, 2, 6]
p[7] 为比 arr[7] 小的位置,p[7] 等于 6 ,则 arr[6] 是第一个比 arr[7] 小的值
p[6] 为比 arr[6] 小的位置,p[6] 等于 2 ,则 arr[2] 是第一个比 arr[6] 小的值
p[5] 为比 arr[5] 小的位置,p[5] 等于 2 ,则 arr[2] 是第一个比 arr[5] 小的值
arr[4] 为 arr 中最小值,因此 p[4] 为初始值,即 arr[4]
p[3] 为比 arr[3] 小的位置,p[3] 等于 0 ,则 arr[0] 是第一个比 arr[3] 小的值
p[2] 为比 arr[2] 小的位置,p[2] 等于 1 ,则 arr[1] 是第一个比 arr[2] 小的值
p[1] 为比 arr[1] 小的位置,p[1] 等于 0 ,则 arr[0] 是第一个比 arr[1] 小的值
p[0] 等于 arr[0] 因为第一轮循环不会改变 p
总结
因此,Vue3 提升 Diff 算法性能的关键是根据新旧 dom 序列位置的变化,构造了一个最长递增子序列,从而找到更新前后相对位置不变的节点的位置,最大限度地降低了 dom 移动的次数,从而提升了 Diff 算法的性能。
有关 Vue 更多 Diff 算法的内容可看笔者写的另外一篇文章 Vue 源码解读:聊聊三种 Diff 算法