diff算法核心之最长递增子序列

63 阅读6分钟

基本概念

diff算法就是进行虚拟节点对比,并返回一个patch对象,用来存储两个节点不同的地方,最后用patch记录的消息去局部更新Dom。

简单来说Diff算法就是在虚拟DOM树从上至下进行同层比对,如果上层已经不同了,那么下面的DOM全部重新渲染。这样的好处是算法简单,减少比对次数,加快算法完成速度。

  • 有两个特点:
  1. 比较只会在同层级进行, 不会跨层级比较
  2. 在diff比较的过程中,循环从两边向中间比较

背景

在vue中,diff算法是在列表数据发生变化时,新老数组进行比较差异(根据节点的key值),以最小的性能消耗使页面重新渲染某些节点,相比vue2,diff算法进行了很大程度的优化,其中复杂场景采用了最长递增子序列进行优化,下面先从简单的场景练练手,开始了解最长递增子序列主要做了什么事情。(在本篇文章中,均以key值代表节点)

举个栗子

场景一

新老树长度不一致,新树比老树长,有节点新增,循环新树

  • 先从头部循环新树,节点一致则跳过,多余则直接push
[1, 2, 3]; // 已渲染的旧节点树
[1, 2, 3, 4]; // 待渲染的目标新节点树
// 将旧节点与旧节点进行比较,获取最优更新策略
  • 从尾部循环新树,节点一致跳过,多余unshift
[1, 2, 3]; // 已渲染的旧节点树
[4, 5, 2, 3]; // 待渲染的目标新节点树
// 将旧节点与旧节点进行比较,获取最优更新策略

新老树长度不一致,新树比老树短,有节点删除,循环老树

  • 从头部循环老树,节点一致跳过,没有则删除
[1, 2, 3, 4]; // 已渲染的旧节点树
[1, 2, 3]; // 待渲染的目标新节点树
// 将旧节点与旧节点进行比较,获取最优更新策略

场景二(较复杂)

无法满足场景一后,需要更加精细的比较。

新老树长度不一致,在中间有新增或删除元素。

  • 在中间进行删除或者插入,就需要根据场景一的过程,标记出头尾可复用的节点,以下例子标记完后剩下7未被标记,需要根据新树做一个映射表,再根据新的映射表去老树查找是否存在,不存在则是新增节点,再通过老的key,来查找新树里对应的索引,如果找不到则是删除节点。
[1, 2, 3, 4, 5, 6,    8, 9]; // 已渲染的旧节点树
[1, 2, 3, 4, 5, 6, 7, 8, 9]; // 待渲染的目标新节点树
// 将旧节点与旧节点进行比较,获取最优更新策略

场景三(更复杂)

新老树长度不一致,无规律可循的情况。

  • 当然这种情况也可以使用场景一结合场景二去做树的删除、新增、替换操作,但其中很多元素可以复用,有些无需移动,所以并不是最优的。
[1, 2, 3, 4, 5, 7]; // 已渲染的旧节点树
[3, 4, 2, 5, 1, 6, 8]; // 待渲染的目标新节点树
// 将旧节点与旧节点进行比较,获取最优更新策略

最长递增子序列

在vue3中引入了一种新的解决方案最长递增子序列,当遇到场景三的情况时,使用二分查找和贪婪算法来得出,可以算出在一个序列中连续递增的个数和递增的具体值,有助于更加精准的获得最优解决方案。

// 现有如下树,求最长递增子序列
[2, 3, 1, 5, 6, 8, 7, 9, 4];

思路分析:

  • 设一个结果集,长度为1且第一项为0的数组。
  • 循环获取需要处理的数组,当循环的当前项比结果集数组最后一项大时,把当前项放到数组中,反之,采用二分查找和贪婪算法,算出结果集中比当前项第一大的项进行替换,可得出该数组的最长递增子序列个数。
  • 设一个追溯索引的数组,在结果集添加子元素时记录该结果集中的前一项是索引值,在结果集替换子元素时,也需要替换其索引值。

直接开干

  • 二分查找就是在结果集中取中间值,判断中间值和当前项的大小,直到获取到第一个大的值,结合下图进行理解。

image-20231019225248553.png

  • 循环需要处理的结果数组,生成结果数组,具体如下图所示(为了方便理解,图为值,代码实现结果集记录的是索引值)。

image-20231019223049580.png

const arr = [2, 3, 1, 5, 6, 8, 7, 9, 4];
function getSequence(arr) {
  const len = arr.length;
	const result = [0]; // 最长递增子序列个数的结果索引
	let resultLastItem; // 结果的最后一项
	for (let i = 0; i < len; i++) {
		const item = arr[i];
		resultLastItem = result[result.length - 1];
		// 判断如果当前项大于当前结果集里的最后一项,则push
		if (arr[resultLastItem] < item) {
			result.push(i);
			continue;
		}
		// 如果当前项小于当前结果集的最后一项,则采用二分查找和贪婪算法进行替换第一个比当前项大的值
		let startIndex = 0; // 二分查找结果集的开始索引
		let endIndex = result.length - 1; // 二分查找结果集的结束索引
		// 只要startIndex和endIndex重合则跳出循环
		while (startIndex < endIndex) {
			let middle = Math.floor((startIndex + endIndex) / 2) | 0;
			// 判断中间值是否小于当前项
			if (arr[result[middle]] < item) {
				startIndex = middle + 1;
			} else {
				endIndex = middle;
			}
		}
		// 找到比当前项大的第一个值后,进行替换
		if (item < arr[result[endIndex]]) {
			result[endIndex] = i;
		}
	}
  console.log(result); // [2, 1, 8, 4, 6, 7];
}
  • 最终获得结果集的个数为5个,此时该结果集并不是正确的值,需要通过回溯找到正确的值,如下图所示
    1. 第一次循环,前面没有值,因此不需要记录。
    2. 第二次循环,前面的值是2,记录其下标为0。

image-20231019225832795.png

  • 需要替换值时,则需要把溯源的值也替换掉,如下图

image-20231019230255816.png

  • 根据上述思路,完善代码,最后得出最长递增子序列[0, 1, 3, 4, 6, 7]
function getSequence(arr) {
	const len = arr.length;
	const result = [0]; // 最长递增子序列个数的结果索引
	let resultLastItem; // 结果的最后一项
	const p = arr.slice(0); // 用来追溯的索引数组
	for (let i = 0; i < len; i++) {
		const item = arr[i];
		resultLastItem = result[result.length - 1];
		// 判断如果当前项大于当前结果集里的最后一项,则push
		if (arr[resultLastItem] < item) {
			result.push(i);
			p[i] = resultLastItem; // push进数组时,让其记住他的前一项是谁
			continue;
		}
		// 如果当前项小于当前结果集的最后一项,则采用二分查找和贪婪算法进行替换第一个比当前项大的值
		let startIndex = 0; // 二分查找结果集的开始索引
		let endIndex = result.length - 1; // 二分查找结果集的结束索引
		// 只要startIndex和endIndex重合则跳出循环
		while (startIndex < endIndex) {
			let middle = Math.floor((startIndex + endIndex) / 2) | 0;
			// 判断中间值是否小于当前项
			if (arr[result[middle]] < item) {
				startIndex = middle + 1;
			} else {
				endIndex = middle;
			}
		}
		// 找到比当前项大的第一个值后,进行替换
		if (item < arr[result[endIndex]]) {
			p[i] = result[endIndex - 1]; // 替换时,让替换掉的人也记住前面是谁
			result[endIndex] = i;
		}
	}
	// // 追溯
	let i = result.length; // 获取结果集长度
	let last = result[i - 1]; // 获取结果集最后一项
	while (i-- > 0) {
		console.log(i);
		result[i] = last;
		last = p[last];
	}
	console.log(result); // [0, 1, 3, 4, 6, 7]
}