从本质看: Vue3 为什么运用 LIS 算法

6 阅读6分钟

引言:

大家应该都学过,Vue3 的快速 Diff 算法中,运用了LISLIS 算法,即最长上升子序列。

为什么运用了这个算法呢?本质上是求解 LCS(最长公共子序列)LCS(最长公共子序列),通过 虚拟 Dom节点 唯一的 Key , 将 LCSLCS问题 降维为 LISLIS问题 而来。

概念引入

LCS(最长公共子序列)

假设我们有:

旧序列 AA:[1, 2, 3, 4, 5]

新序列 BB:[1, 4, 2, 3, 5]

直观上看,[1, 2, 3, 5] 这四个元素的相对顺序在 AABB 中都没变。这在 AABB 中元素是完全一致的最长子序列

这在算法上对应了一个经典概念:LCSLCS(最长公共子序列)。

LIS(最长上升子序列)

假设我们有:

序列 AA:[4,2,3,1,5]

其中,[2,3,5]AA 的一个子序列,且保证了升序,并且是所有满足升序的 AA 的子序列中最长的,这就是 AALIS(最长上升子序列)LIS(最长上升子序列)

如何求出同层DOM节点最少移动次数

显然,最少移动次数的本质是求 LCS(最长公共子序列)LCS(最长公共子序列)。即:取新旧 DOM 序列中,最长的、相对顺序完全一致的子序列。这部分节点作为“不动点”,其余节点进行平移。这在逻辑上能保证 DOM 操作次数达到理论最低值,是最优解。

简单来讲:

物理含义:最少移动次数 = 总元素数 - 不必移动的元素数。

不必移动的元素:必须构成一个 LCS(最长公共子序列)。

利用唯一的key,将求 LCS 转化为求 LIS

虽然 LCS 能给完美答案,但传统的 LCS 动态规划算法时间复杂度是 O(n×m)O(n \times m),其中 nn 是旧 DOM 序列长度,mm 是新 DOM 序列长度。这个时间复杂度在 nnmm 非常大的情况下很劣势。

然而由于我们给了唯一的 Key,就产生了一个很妙的性质:

一般意义上的 LCSLCS:处理的是一般序列(允许重复元素),不存在唯一的单射关系,属于典型的动态规划问题,复杂度下限为 O(n2)O(n^2)

特定约束下的 LCSLCS(唯一 Key):从离散数学角度看,由于建立了强一致的索引映射,它退化为了一个偏序集的最长链问题(Longest Chain in a Partial Order)。根据 Dilworth 定理 的相关应用,这类特殊的偏序最长链可以通过将二维关系压缩为一维索引,是利用了唯一 Key 带来的单射(Injection)关系。本质是一种坐标变换,把二维的“位置对比”压缩成了一维的“数值增长”。利用 贪心 + 二分查找 在 O(nlogn)O(n \log n) 时间内完成求解。 简单推导

LCSLCS 的本质:寻找一个子序列,使得其中的元素在 SoldS_{old}SnewS_{new} 中都满足相同的全序关系。

LISLIS 的本质:在序列 LL 中寻找一个子序列,使其元素值严格单调递增。

如果 f(ki)<f(kj)f(k_i) < f(k_j)i<ji < j,这意味着:

在新序列中,kik_ikjk_j 之前(由 i<ji < j 决定)。

在旧序列中,kik_i 也在 kjk_j 之前(由 f(ki)<f(kj)f(k_i) < f(k_j) 决定)。

等价性证明:满足上述条件的子序列,就是一个保序映射(Order-preserving map)。在元素唯一的条件下,新序列中索引的最长递增子序列(LISLIS),逻辑上等价于两个全序集之间的最长公共子序列(LCSLCS)。

说人话,就是有两个序列,AABB, 若AABB中各元素都唯一(有唯一的Key),则求 ABA BLCSLCS,可以通过如下过程转换为求 LISLIS:

  • 新序列建表,留出空位

    • 遍历新序列 BB,使用 Map 记录每个元素的 index 映射。例如 B = [a, c, g],则 Map 记录为 {a: 0, c: 1, g: 2}

    • 同时,定义一个数组 source,它的长度等于序列 BB 的长度,并全部填充为 0。这里的 0 有特殊含义,代表“这是一个全新增加的节点,在旧序列里没有”。

    • 此时 source = [0, 0, 0]

  • 旧序列查表,新位置填入旧下标

    • 从前往后遍历旧序列 AA

    • Map 中查当前的旧元素是否在新序列 BB 中。如果存在,我们就拿到了它在新序列里的位置 newIndex

    • 关键操作:把该元素在旧序列 AA 中的原始下标(为了避开初始值 0,通常会 +1+1)填入到 source 数组的 newIndex 位置上。也就是:source[newIndex] = oldIndex + 1

  • 求出 LIS,圈定“不动点”

    • 遍历结束后,source 数组中就填满了旧节点的位置索引。

    • 此时,对 source 数组求解 最长递增子序列(LIS)

    • 感性理解source 数组中递增的序列,意味着这几个节点在旧家和新家里的相对先后顺序完全没变。算法最终返回的正是这些“不动点”的索引集合,我们在操作 DOM 时,直接跳过它们即可,剩下的节点才需要真正调用 DOM API 进行移动。

稳定唯一的 Key 是将 LCS 问题转换为 LIS 问题的保证

根据以上的推导,唯一的 KeyKey ,是将 LCSLCS 问题转换为 LISLIS ,使得时间复杂度从低效的 O(nm)O(n*m) 降低至 O(mlog2m)O(m*log_2 m)的前提。 因此,在开发过程中,需要保证标识节点所用的 KeyKey 稳定且唯一。否则就会引发以下问题:

1. 如果完全没有 key:执行“就地复用”策略

当 Vue 发现子节点组没有 key 时,它不会调用复杂的 patchKeyedChildren(即包含 LIS 的算法),而是调用 patchUnkeyedChildren

  • 它会同时遍历新旧两个序列,取二者长度的最小值(commonLength)。
  • 逐个 Patch:不管这两个节点内容差多少,Vue 都会强行认为“第 ii 个旧节点”就是“第 ii 个新节点”,直接进行对比和更新。
  • 可能造成性能损耗和状态错位

2. 如果 key 重复:陷入“索引混乱”

A. 建立 Map 阶段的覆盖

在构建 keyToNewIndexMap(新序列映射表)时,Vue 会遍历新序列。

  • 如果发现两个节点 key 相同,后出现的节点会覆盖先出现的节点。
  • Map 里只剩下了重复 key 的最后一个位置。
B. 查找映射阶段的错误

当遍历旧序列去 Map 里通过 元素-index 映射生成 source 数组时:

  • 旧序列中所有带该重复 key 的节点,都会匹配到新序列中的同一个位置(即最后那个位置)。

  • 后果

    1. 多余的卸载:某些旧节点可能因为映射逻辑混乱,被误判为“新序列中不存在”,从而被错误地删除。

    2. DOM 冲突:Vue 可能会尝试把多个旧 DOM 挂载到同一个新位置,或者在 patch 时因为 VNode 引用混乱导致浏览器报错