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

引言:

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

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

概念引入

LCS(最长公共子序列)

假设我们有:

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

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

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

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

LIS(最长上升子序列)

假设我们有:

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

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

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

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

简单来讲:

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

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

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

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

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

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

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

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

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

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

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

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

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

说人话,就是有两个序列,AAA 和 BBB, 若AAA 和 BBB中各元素都唯一(有唯一的Key),则求 ABA BAB 的 LCSLCSLCS,可以通过如下过程转换为求 LISLISLIS:
  • 新序列建表,留出空位

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

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

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

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

    • 从前往后遍历旧序列 AAA。

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

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

  • 求出 LIS,圈定"不动点"

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

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

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

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

根据以上的推导,唯一的 KeyKeyKey ,是将 LCSLCSLCS 问题转换为 LISLISLIS ,使得时间复杂度从低效的 O(n∗m)O(n*m)O(n∗m) 降低至 O(m∗log2m)O(m*log_2 m)O(m∗log2m)的前提。

因此,在开发过程中,需要保证标识节点所用的 KeyKeyKey 稳定且唯一。否则就会引发以下问题:

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

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

  • 它会同时遍历新旧两个序列,取二者长度的最小值(commonLength)。
  • 逐个 Patch :不管这两个节点内容差多少,Vue 都会强行认为"第 iii 个旧节点"就是"第 iii 个新节点",直接进行对比和更新。
  • 可能造成性能损耗和状态错位
2. 如果 key 重复:陷入"索引混乱"
A. 建立 Map 阶段的覆盖

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

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

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

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

  • 后果

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

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

相关推荐
涵涵(互关)几秒前
语法大全-only-writer
开发语言·前端·vue.js·typescript
FlyWIHTSKY28 分钟前
router-viiew没有滚动条,如何修复
前端·vue.js·elementui
gCode Teacher 格码致知30 分钟前
Javascript提高:国际化 API(Intl 对象)详解-由Deepseek产生
开发语言·javascript·ecmascript
hhzz32 分钟前
记录微信小程序tabbar不显示问题:uni-app Vue 3 自定义 tabBar 不渲染
vue.js·微信小程序·uni-app
靳向阳41 分钟前
【无标题】
前端·javascript·vue.js
涵涵(互关)1 小时前
GoView各项目文件中的相关语法
前端·vue.js·typescript
M ? A1 小时前
Vue 转 React:toRaw(),VuReact 怎么处理?
前端·javascript·vue.js·经验分享·react.js·面试·vureact
布局呆星4 小时前
Vue Router :基础使用与嵌套路由实战
前端·javascript·vue.js
谁呛我名字11 小时前
JavaScript 类型转换与运算规则
javascript
冰暮流星12 小时前
javascript事件案例-全选框案例
服务器·前端·javascript