跳过正文
  1. 文章/

Vue 3 源码核心解析:最长递增子序列 (LIS) 算法全景图

·3295 字·7 分钟·
hujiacheng
作者
hujiacheng
Front-end Developer / Strive To Become Better
目录

1. 背景与目的
#

在 Vue 3 的 Diff 算法(patchKeyedChildren)中,当新旧节点列表出现乱序(Unknown Sequence)时,为了将 DOM 移动操作降到最低,Vue 需要找出一组“不需要移动的节点”

这组节点必须满足两个条件:

  1. 相对顺序不变(即在旧列表和新列表中顺序一致)。
  2. 长度尽可能长(留下的越多,移动的越少)。

这就是 最长递增子序列 (Longest Increasing Subsequence, LIS) 的应用场景。Vue 3 采用了 贪心 + 二分查找 + 回溯 的算法,将时间复杂度压缩到了 O(n\logn)

2. 核心变量三剑客
#

理解这个算法,首先要看懂这三个变量的“分工”。

变量名类型物理含义形象比喻关键作用
arrArray输入数组考卷新节点在旧列表中的索引序列。
resultArray结果索引集草稿纸 / 指针1. 存储 LIS 的索引
2. 它的长度代表当前找到的最长序列长度。
3. 它的最后一个元素是回溯的唯一入口(HEAD指针)。
pArray前驱节点表家谱 / 树结构p[i] = j 表示:在 LIS 序列中,节点 i 的前一个节点是 j它是回溯的唯一依据。

⚠️ 高能预警: resultp 里面存的都是 arr索引 (Index),而不是具体的值 (Value)!

3. 核心原理升华:p 数组的本质——隐式的“多叉树”
#

在阅读源码时,我们很容易把 p 仅仅看作是一个辅助数组。但如果从数据结构的视角审视,p 数组本质上就是一棵(或多棵)倒置的树(Inverted Tree)

3.1 数组即树 (Array as Tree)
#

通常树的表示是 Parent -> Children,但在这里,Vue 采用的是 Child -> Parent (p[i] = j) 的反向链接方式。

  • 索引 i 是树中的一个节点。
  • 索引 ji 的父节点。
  • p 数组 就是一张完整的“全量父子关系表”。

3.2 动态生长与分叉 (Branching)
#

随着贪心算法的执行,这棵树会不断地分叉。每一次“替换”操作,实际上并不是删除了旧的路径,而是在某个节点上长出了一条更有潜力的新树枝

可视化演示: 假设输入序列为 [2, 5, 8, 3, 4, 9]。算法结束时,p 数组里实际存储了这样一棵形状复杂的树:

       索引0 (值2)  <-- 根节点 (Root)
      /           \
  索引1 (值5)     索引3 (值3)  <-- 发生分叉 (替换导致)
     |              |
  索引2 (值8)     索引4 (值4)
                    |
                  索引5 (值9)  <-- 最长枝的末端 (Leaf)

3.3 result 的角色
#

result 数组就像 Git 的 HEAD 指针。 它永远指向当前被认为是最优分支(Master)的末端。当算法把 result 的末尾从 8 换成 9 时,就像是把 HEAD 指针从一个旧分支切换到了一个更有潜力的新分支上。

4. 算法流程可视化推演
#

我们使用经典案例来演示完整过程:

  • 输入 arr: [2, 5, 8, 3, 4, 9]
  • 目标: 找出最长递增子序列的索引。

第一阶段:贪心 + 二分 (构建隐式树)
#

步骤ival操作逻辑result (存索引)p (树结构记录)可视化树状态
102初始:result 为空,直接放入。[0][]2 (根)
215追加:5 > 2,追加。p[1]=0[0, 1]p[1]=02 -> 5
328追加:8 > 5,追加。p[2]=1[0, 1, 2]p[2]=12 -> 5 -> 8 (主干)
433替换:3 < 8。二分查找发现 3 比 5 小且比 2 大。替换掉 5p[3]=0[0, 3, 2]
(注意:这里乱序了)
p[3]=0分叉!
A: 2->5->8
B: 2->3
544替换:4 < 8。二分查找发现 4 比 8 小且比 3 大。替换掉 8p[4]=3[0, 3, 4]p[4]=3新枝超车!
A: 2->5->8
B: 2->3->4
659追加:9 > 4,追加。p[5]=4[0, 3, 4, 5]p[5]=4最终态
B: 2->3->4->9

阶段一结束状态:

  • result: [0, 3, 4, 5] —— 长度为 4,结尾是索引 5。
  • p: 记录了 2->5->82->3->4->9 两条路径。

5. 深度释疑:为什么选 2,3,4,9 而不是 2,5,8,9?
#

在上面的例子中,2, 5, 8, 92, 3, 4, 9 长度都是 4,都是合法的递增子序列。 为什么算法最终选择了后者?这就涉及到了“潜力(Potential)”的核心博弈。

5.1 什么是“潜力”?
#

潜力指的是序列结尾数字的大小

  • 规则:结尾数字越,后面能接纳的数字范围就越
  • 比喻:这就像跳高比赛。你的上一跳成绩(结尾数字)就是下一跳的起跳门槛。门槛越低,后面能跳过去的人就越多。

5.2 决策时刻对比
#

让我们回到算法执行到一半的关键时刻: 此时序列是 2, 5, 8

  • 如果不替换(保留 2, 5, 8

  • 当前的门槛是 8

  • 后果:只有大于 8 的数字(如 9, 10)才能接在后面。如果后面来的是 67,就接不上了,序列长度锁死在 3。

  • 如果替换(变成 2, 3, 4

  • 当前的门槛降到了 4

  • 后果:大于 4 的所有数字(5, 6, 7, 8, 9…)全都能接在后面!

5.3 算法的“上帝视角”盲区
#

算法在遍历时是不知道后面还有个 9 的。它只知道:

“我现在有机会把结尾从 8 降到 4,虽然长度暂时没变,但我为未来创造了更多可能性。”

结果验证:

  • 如果是 ... 9:两个序列都能接上,打平。
  • 如果是 ... 62, 5, 8 接不上(败);2, 3, 4 能接上变成 2, 3, 4, 6(胜)。

结论: 算法选择 2, 3, 4 是因为它具有更高的容错率更广的兼容性。虽然在这个特定例子中两者长度一样,但在更复杂的随机数据中,贪心策略(选小的)总是能大概率保证最终序列是最长的。

6. LIS 算法完整流程图
#

下面的流程图展示了 LIS 算法从初始化到回溯的完整执行流程:

flowchart TD
Start([开始]) --> Init["初始化:
p = arr.slice
result = 0
i = 0"] Init --> LoopCheck{"i 小于
arr.length?"} LoopCheck -->|是| CheckZero{"arr(i)
不等于 0?"} LoopCheck -->|否| Backtrack[回溯阶段开始] CheckZero -->|是| GetLast["获取 j =
result 末尾索引"] CheckZero -->|否| NextIter1["i 自增"] NextIter1 --> LoopCheck GetLast --> Compare{"arr(i)
大于
arr(j)?"} Compare -->|是| Append["追加操作:
p(i) = j
result.push(i)"] Compare -->|否| BinarySearch["二分查找:
找到第一个
比 arr(i) 大的位置 u"] Append --> NextIter2["i 自增"] NextIter2 --> LoopCheck BinarySearch --> CheckReplace{"arr(i) 小于
arr(result(u))?"} CheckReplace -->|是| Replace["替换操作:
if u 大于 0:
p(i) = result(u-1)
result(u) = i"] CheckReplace -->|否| NextIter3["i 自增"] Replace --> NextIter4["i 自增"] NextIter4 --> LoopCheck NextIter3 --> LoopCheck Backtrack --> BackInit["u = result.length
v = result 末尾元素"] BackInit --> BackLoop{"u 大于 0?"} BackLoop -->|是| BackRestore["result(u) = v
v = p(v)
u 自减"] BackLoop -->|否| Return([返回 result]) BackRestore --> BackLoop style Start stroke:#4caf50,stroke-width:3px style Return stroke:#4caf50,stroke-width:3px style Append stroke:#ff9800,stroke-width:2px style Replace stroke:#e91e63,stroke-width:2px style BinarySearch stroke:#2196f3,stroke-width:2px style Backtrack stroke:#9c27b0,stroke-width:2px style BackRestore stroke:#9c27b0,stroke-width:2px

7. 回溯修正 (Backtracking)
#

result 的末尾开始,利用 p 还原真相。

  • 入口result 最后一个元素是 5 (对应值 9)。
  • 回溯逻辑
  1. 位置 End (Idx 5): 查 p[5] -> 爸爸是 4 (值 4)。
  2. 位置 Middle (Idx 4): 查 p[4] -> 爸爸是 3 (值 3)。覆盖 result[2]
  3. 位置 Middle (Idx 3): 查 p[3] -> 爸爸是 0 (值 2)。覆盖 result[1]
  • 注意:这里完美的避开了索引 1 和 2 (值 5 和 8) 的那条“潜力较低”的废弃分支。
  1. 位置 Start (Idx 0): 查 p[0] -> 无。结束。

最终输出: [0, 3, 4, 5] (对应值 2, 3, 4, 9)。✅

8. 源码逐行精读
#

function getSequence(arr) {
  const p = arr.slice(); // 1. 创建 p 数组,用于记录"父节点" (隐式树结构)
  const result = [0]; // 2. result 初始化,存入第0个元素的索引
  let i, j, u, v, c;
  const len = arr.length;

  for (i = 0; i < len; i++) {
    const arrI = arr[i];
    // Vue 中 0 代表新增节点,不参与计算
    if (arrI !== 0) {
      j = result[result.length - 1]; // 获取 result 当前末尾的索引

      // 【贪心策略 A】:如果当前值 > result 末尾的值
      // 说明可以延长序列,直接追加
      if (arr[j] < arrI) {
        p[i] = j; // 关键!记录 p:当前 i 的爸爸是 j
        result.push(i); // result 长度 +1
        continue;
      }

      // 【二分查找】:如果当前值小,找到它该替换的位置
      // 目标:找到第一个比 arrI 大的数
      u = 0;
      v = result.length - 1;
      while (u < v) {
        c = (u + v) >> 1; // 位运算取整
        if (arr[result[c]] < arrI) {
          u = c + 1;
        } else {
          v = c;
        }
      }

      // 【贪心策略 B】:替换,让序列增长潜力更大
      // 虽然这步操作可能导致 result 中间乱序,但为了让结尾更小(门槛更低),必须换。
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]; // 关键!记录 p:替换后,i 的爸爸变成了 u-1
        }
        result[u] = i; // 仅仅替换 result 中的索引
      }
    }
  }

  // 【回溯阶段】:修正 result 中的乱序,还原正确路径
  u = result.length;
  v = result[u - 1]; // 拿到最后一个确定的锚点 (HEAD指针)

  while (u-- > 0) {
    result[u] = v; // 无视 result 中间的值,直接用正确路径覆盖 (Overwrite)
    v = p[v]; // 找爸爸 (沿着树往上爬)
  }

  return result;
}

9. 总结 Q&A
#

Q1: 为什么回溯时,result 中间的乱序不会导致错误?
#

A: 因为回溯只信赖 result长度最后一个元素。 中间的元素在回溯过程中会被当做“垃圾数据”直接**覆盖(Overwrite)**掉。真正的路径关系保存在 p 数组 这个“只增不减”的历史档案中。

Q2: 为什么要用二分查找?
#

A: 为了在 O(n\logn) 的时间内完成计算。如果用遍历查找,就是 $O(n^2)$。二分查找是为了快速找到"第一个比当前元素大的数"进行替换。

Q3: 整个算法的思想精华是什么?
#

“贪心 + 树结构记录”

  • 贪心:只顾当下,为了让序列结尾更小(潜力更大),甚至不惜让临时列表 (result) 乱序。
  • 记录:利用 p 数组构建一棵隐式的多叉树,记录每一次连接的父子关系,确保即使当前乱了,最后也能顺藤摸瓜找回来。

相关文章