快速diff算法
Vue3中使用了快速diff算法,有两个核心思想
- 前置和后置预处理:将前置和后置的相同部分先预处理,剩下不同的部分再进行diff;
diff时,使用最长递增子序列算法,获取新节点集中,相对顺序不变的最多的节点,从而最少化对可复用节点的移动操作,相比于vue2的双端diff算法,进一步减少了移动操作。 - 最长递增子序列算法
Vue3的最长递增子序列算法,使用了贪心算法和二分查找的方式,然后根据辅助的记录数组,还原了输入数组的最长递增子序列,其中辅助记录数组的用途比较难以理解,即前项回溯为什么可以还原错误的最长递增子序列,本文试图对此做出解释。
为了好理解,用返回最长递增子序列的算法来解释,而非原始的返回该子序列对应的在原数组中的索引值。
function getRequenceValue(arr) {
const length = arr.length; // 最长递增子序列,元素是输入数组arr中的元素
const result = [arr[0]]; // result 最后一个元素
let resultLast;
let start;
let end;
let middle;
// 记录最长递增子序列在索引index处变化时,前一项即(index - 1)项
let p = arr.slice();
for (let i = 0; i < length; i++) {
const arrI = arr[i];
// 在 Vue 3 Diff 中,0 表示该新节点不在旧节点的中,是需要进行新增的节点
if (arrI !== 0) {
resultLast = result[result.length - 1];
if (arrI > resultLast) {
result.push(arrI);
// p[i]记录递增之前的最后一项
p[i] = resultLast;
continue;
}
start = 0;
end = result.length - 1;
// 如果arrI的值不大于arr[resultLast], 即arrI不大于当前递增子序列的最后一项,
// 那么就要根据二分查找替换递增子序列的某一项了
while (start < end) {
middle = ((start + end) / 2) | 0; // 或者 middle = Math.floor((start end) / 2);
if (result[middle] < arrI) {
start = middle + 1;
} else {
end = middle;
}
}
// while 循环结束后,start 和 end 会指向同一个元素, 此时找到替换的位置,
// 替换某一项s[j],同时将该项前面的那一项s[j - 1]记录在p[i]中, 被替换的项后面的所有项在顺序上都不对
if (result[end] > arrI) {
result[end] = arrI;
p[i] = result[end - 1];
}
}
}
console.log('输入数组: ', arr); // 输出原始输入数组
console.log('p 数组: ', p); // 输出记录数组p
console.log('还未修正的result 数组: ', result); // 输出未修正的数组result
// 找到最长递增子序列的长度了
let i = result.length - 1;
// 结果序列里的最后一项
// let last = result[i];
const interval = length - i - 1;
// 原代码用while,这里用for也是一样的
// while (i >= 0) {
// result[i - 1] = p[i + interval];
// i--;
// }
for (; i > 0; i--) {
result[i - 1] = p[i + interval - 1];
}
console.log('修正后的数组: ', result); // 打印修正的数组result
return result;
}
贪心算法的作用
为了在遍历arr的过程中,尽可能让递增子序列s更长,就需要子序列s里的元素(尤其是最大值)尽可能小,这样在后续遍历时,就会有更大的可能让递增子序列更长,所以这里用了贪心算法,如果遍历到的值arr[i]小于当前递增子序列的最大值s{max},那么就让遍历到的值arr[i]替换原递增子序列中的某一项。通过二分查找,可以替换某个合适的项(这一步也有可能会替换当前递增子序列的最大值s{max})。因此,在贪心算法的作用下,可以让结果数组中的元素尽可能小。
如何确保替换操作不影响递增子序列的长度是正确的
贪心算法可以保证递增子序列的长度就是要求的最大递增子序列长度。这一点通过以下的逻辑保证:
- 假定在索引i处的最长递增子序列为s, 那么在索引i + 1处,如果arr[i + 1] < s的最后一个元素,那么arr[i + 1]一定可以和s中的元素形成一个新的递增子序列,但这个子序列长度不超过s的长度;这跟把arr[i + 1]加入s,替换s中的某个元素的操作是一样的,都不会影响在i + 1处的递增子序列的最大长度,因此替换操作在索引i + 1处的最长递增子序列的长度不变;
- 如果nums[i + 1] > s的最后一个元素,那么nums[i + 1]可以加在s后,形成最新的最长递增子序列;
根据数学归纳法的思想,在i + 2, i + 3, …一直到结束,替换的方式都不会对最长递增子序列的长度有影响;
根据p数组修正替换后的数据
这里有三个原则:
- 替换后最终的最长递增子序列,最后一项一定是正确的,不管最后一项有没有被替换。因为替换操作不会影响长度,最后一项也是最大的值;
- 被替换的项的前一项在顺序上一定是正确的,替换操作只会引起后面项顺序的错误;
- 不管索引index处的项是被替换,还是新添加,index - 1的项都会记录在p数组
p[index] = arr[index - 1]
。
基于上述三个原则,可以开始修正数据:
- 由于最后一项是正确的,所以从最后一项开始,由后向前修正;
- 最后一项不需要从p数组中还原;
- 最后一项如果是新添加,那么原来的倒数第二项(记录在p数组里)肯定是正确的,并且是p[倒数第二项];如果最后一项是被替换的,那么原来的倒数第二项也是正确的(符合原则2),并且是p[倒数第二项];
- 倒数第二项修正后,不管倒数第二项是新添加,还是被替换,原来的倒数第三项也是正确的,记录在p[倒数第三项];
- 依次类推,一直修正到结果数组的第一项:
result[0] = p[arr.length - result.length];
所以根据上述步骤,有如下代码
let i = result.length - 1;
// 结果序列里的最后一项是正确的,不需要修正,从倒数第二项开始修正即可
// let last = result[i];
const interval = length - i - 1;
for (; i > 0; i--) {
result[i - 1] = p[i + interval - 1];
}
P数组(前项记录)的作用
P数组在递增子序列变更时,记录变更前(新添加或者被替换),变更的索引的前一项,因为前一项不受添加和替换的影响,是正确的,从而才能根据P数组来修正最终的最长递增子序列。
附
附上原始的算法,返回索引的子序列
function getRequence(arr) {
const length = arr.length;
const result = [0];
let resultLast;
let start;
let end;
let middle;
let p = arr.slice();
for (let i = 0; i < length; i++) {
const arrI = arr[i];
// 在 Vue 3 Diff 中,0 表示该新节点不在旧节点中,是需要进行新增的节点
if (arrI !== 0) {
resultLast = result[result.length - 1];
if (arrI > arr[resultLast]) {
result.push(i);
p[i] = resultLast;
continue;
}
start = 0;
end = result.length - 1;
while (start < end) {
middle = ((start + end) / 2) | 0; // 或者 middle = Math.floor((start end) / 2);
if (arr[result[middle]] < arrI) {
start = middle + 1;
} else {
end = middle;
}
}
if (arr[result[end]] > arrI) {
result[end] = i;
p[i] = result[end - 1];
}
}
}
let i = result.length;
let last = result[i - 1];
while (i-- > 0) {
result[i] = last;
last = p[last];
}
return result;
}