最长递增子序列

实现 LIS 算法,理解 Vue3 Diff 中的节点移动优化

问题

实现最长递增子序列(LIS)算法,并理解它在 Vue3 Diff 中的应用。

给定数组 [10, 9, 2, 5, 3, 7, 101, 18],最长递增子序列是 [2, 3, 7, 18][2, 3, 7, 101],长度为 4。

解答

方法一:动态规划 O(n²)

function lengthOfLIS(nums) {
  if (nums.length === 0) return 0
  
  // dp[i] 表示以 nums[i] 结尾的最长递增子序列长度
  const dp = new Array(nums.length).fill(1)
  
  for (let i = 1; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
      // 如果 nums[i] 大于 nums[j],可以接在后面
      if (nums[i] > nums[j]) {
        dp[i] = Math.max(dp[i], dp[j] + 1)
      }
    }
  }
  
  return Math.max(...dp)
}

console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])) // 4

方法二:贪心 + 二分查找 O(n log n)

function lengthOfLIS(nums) {
  if (nums.length === 0) return 0
  
  // tails[i] 表示长度为 i+1 的递增子序列的最小末尾元素
  const tails = []
  
  for (const num of nums) {
    // 二分查找 num 应该插入的位置
    let left = 0
    let right = tails.length
    
    while (left < right) {
      const mid = (left + right) >> 1
      if (tails[mid] < num) {
        left = mid + 1
      } else {
        right = mid
      }
    }
    
    // 替换或追加
    tails[left] = num
  }
  
  return tails.length
}

Vue3 中的实现:获取 LIS 的索引

Vue3 需要的不是长度,而是最长递增子序列的索引数组,用于确定哪些节点不需要移动。

// Vue3 源码简化版
function getSequence(arr) {
  const p = arr.slice() // 用于回溯的前驱索引数组
  const result = [0]    // 存储 LIS 的索引
  
  for (let i = 0; i < arr.length; i++) {
    const current = arr[i]
    
    // Vue3 中 0 表示新增节点,跳过
    if (current !== 0) {
      const lastIndex = result[result.length - 1]
      
      // 当前值大于 result 最后一个对应的值,直接追加
      if (arr[lastIndex] < current) {
        p[i] = lastIndex // 记录前驱索引
        result.push(i)
        continue
      }
      
      // 二分查找,找到第一个 >= current 的位置
      let left = 0
      let right = result.length - 1
      
      while (left < right) {
        const mid = (left + right) >> 1
        if (arr[result[mid]] < current) {
          left = mid + 1
        } else {
          right = mid
        }
      }
      
      // 替换
      if (current < arr[result[left]]) {
        if (left > 0) {
          p[i] = result[left - 1] // 记录前驱索引
        }
        result[left] = i
      }
    }
  }
  
  // 回溯,得到正确的 LIS 索引序列
  let len = result.length
  let idx = result[len - 1]
  
  while (len-- > 0) {
    result[len] = idx
    idx = p[idx]
  }
  
  return result
}

// 测试
console.log(getSequence([2, 3, 1, 5, 6, 8, 7, 9, 4]))
// [0, 1, 3, 4, 6, 7] 对应值 [2, 3, 5, 6, 7, 9]

Vue3 Diff 中的应用

// 简化的 Vue3 diff 逻辑
function patchKeyedChildren(oldChildren, newChildren) {
  // 1. 预处理:头部相同节点
  // 2. 预处理:尾部相同节点
  // 3. 处理中间乱序部分
  
  // 假设中间部分新节点在旧节点中的索引为:
  const newIndexToOldIndexMap = [5, 3, 4, 0] // 0 表示新增
  
  // 获取最长递增子序列的索引
  const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)
  // 结果: [1, 2] 对应值 [3, 4],这些节点不需要移动
  
  // 倒序遍历新节点
  let j = increasingNewIndexSequence.length - 1
  
  for (let i = newIndexToOldIndexMap.length - 1; i >= 0; i--) {
    if (newIndexToOldIndexMap[i] === 0) {
      // 新增节点,执行 mount
    } else if (j < 0 || i !== increasingNewIndexSequence[j]) {
      // 不在 LIS 中,需要移动
      // 执行 move 操作
    } else {
      // 在 LIS 中,不需要移动
      j--
    }
  }
}

关键点

  • LIS 作用:找出不需要移动的最长节点序列,最小化 DOM 操作
  • 贪心思想:维护一个递增的 tails 数组,每个位置存储该长度子序列的最小末尾
  • 二分查找:在 tails 中找到当前元素应该替换的位置,保证 O(log n) 复杂度
  • 回溯技巧:Vue3 用前驱数组 p 记录路径,最后回溯得到正确的索引序列
  • 倒序遍历:Vue3 倒序处理是为了使用 insertBefore 时锚点节点已经就位