理解 vue3 diff 中的最长递增子序列算法

1,280 阅读2分钟

对本文中二分法查找上界算法可以参考使用二分查找法寻找数组中的目标值与上下界

  • 在 vue3 中,我们需要知道老节点与新节点下标的排序,假如:
oldVNodes: [0,1,2,3,4,5];
newVNodes: [1,2,3,5,4,0];
// 得出最长递增子序列为 [1,2,3,4], 所以可以在移动老节点的时候,忽略掉 [1,2,3,4] 这些元素而不去移动他们。
  • vue3 内使用的求最长递增子序列的下标算法如下:
// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
    function getSequence(arr) {
      const p = arr.slice()
      const result = [0]
      let i, j, u, v, c
      const len = arr.length
      for (i = 0; i < len; i++) {
        const arrI = arr[i]
        if (arrI !== 0) {
          j = result[result.length - 1]
          if (arr[j] < arrI) {
            p[i] = j
            result.push(i)
            continue
          }
          u = 0
          v = result.length - 1
          while (u < v) {
            c = ((u + v) / 2) | 0
            if (arr[result[c]] < arrI) {
              u = c + 1
            } else {
              v = c
            }
          }
          if (arrI < arr[result[u]]) {
            if (u > 0) {
              p[i] = result[u - 1]
            }
            result[u] = i
          }
        }
      }
      u = result.length
      v = result[u - 1]
      while (u-- > 0) {
        result[u] = v
        v = p[v]
      }
      return result
    }

可能大家一眼看下去有点懵,但是下面我们将由浅入深去学习这个算法。

  • 首先让我们先引入一道题目 leetcode-cn.com/problems/lo… 这是 LeetCode 上一道求最长递增子序列数量的题目,根据题目意思与例子:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。

我们可以使用动态规划的算法去解决该道问题:


const lengthOfLIS = (nums) => {
// 假如输入的数组长度小于等于 1 ,那么就代表为空数组或者只有一位,那么直接返回该数组长度即可
if (nums.length <= 1) return nums.length;
// 将数组内的每一个元素都配置对应的动态规划前驱(以下简称 dp)
// 每一个元素所对应的 dp 意思就是假如用该元素作为结尾,与在该元素之前的所有元素形成一个递增子序列的话,那么从而得到该元素作为结尾时候的最长的序列长度。
// 比如有数组 [2,5,3],那么 2,5,3 的 dp 默认值都是 1,因为该元素至少都可以形成一个长度为 1 的最长递增子序列
// 首先从 5 开始,因为第一位 2 的 dp 肯定是 1,那么 5 去遍历它之前的元素和其 dp 值,首先去遍历 2 ,5 比 2 大,那么假如 5 作为结尾的话,可以形成 2, 5
// 那么 2 的 dp 是 1,所以 5 的 dp 是 1 + 1 = 2,依次类推其他的元素

// 设每个 dp 对应的默认值为 1
let dp = Array(nums.length).fill(1);
// 遍历所有数组元素
for (let i = 1; i < nums.length; i++) {
  // 记录以当前元素结尾,那么该元素与之前的数的 dp 集合,即上面的例子 2, 5 ,3 中,到 5 的时候,那么 prevDp 内就是 [2],因为 2,5 搭配形成了新的
  // 最长递增子序列
  let prevDp = [];
  // 遍历该元素之前的元素
  for (let k = 0; k < i; k++) {
    // 只有该元素大于了之前的元素的时候,才可以形成一个新的递增子序列
    if (nums[i] > nums[k]) {
      prevDp.push(dp[k]);
    }
  }
  if (prevDp.length !== 0) {
    // 获取遍历后的所有可以形成新地址子序列的长度,得到最长那个
    dp[i] = Math.max(...prevDp) + dp[i];
    prevDp = [];
  }
}
// 得到 dp 列表中最大长度的那个数
return Math.max(...dp);
}
  • 通过上面的例子,我们学习到了如何使用动态规划去找到最长递增子序列的长度,但是我们的目标想知道在这个递增子序列里面都有神马元素,而不仅仅只是找到数量,那么请看下面的算法,通过贪心算法 + 二分查找上界的方式去获取对应的最长递增子序列:
var lengthOfLIS = function(nums) {
    if (nums.length === 0) return [];
    // 默认返回结果的第一个是当前数组的第一位
    let result = [nums[0]];

    for (let i = 1; i < nums.length; ++i) {
      // 如果当前数值大于已选结果的最后一位,则直接往后新增,若当前数值更小,则直接替换前面第一个大于它的数值
      // 举例 [9, 2, 5, 3],首先:
      // 9:作为 result 的默认值放在了第一位,所以遍历下标从 1 开始
      // 2:2 比 result 的最后一位 9 小,所以需要去找到 result 里面的上界值,并切换,此时 result 只有 9
      // 并且 9 也是 2 在 result 里面的上界值,所以 9 被替换成 2
      // 5:5 比 2 大,直接推入 result 内,目前 result 为 [2,5]
      // 3:3 比 result 的最后一位小,所以继续走 2 的逻辑,替换掉 5,所以最后的最长递增子序列是 [2,3]

      // 引伸为什么不是 2,5,而是 2,3 呢,因为理论上我们在求最长递增子序列的时候,需要的是最小的值组成的最长
      // 的序列,为什么要这样是因为假如传入的数组是动态的,比如说原来是 [9, 2, 5, 3],然后加多一个 4,那么我
      // 假如用的是 2,5 的话,4 比 5 小,所以 4 即便是把 5 替换掉了形成 2,4,但是还是不对,因为应该是 2,3,4
      // 才是正确的答案,所以我们要求在求最长递增子序列的时候,都是拿的最小的值,也就解释了为什么我们要在替换掉
      // result 里面的上界值

      
      // 假如本次循环的目标比 result 最后一个都大,直接推入
      if (nums[i] > result[result.length - 1]) {
        result[result.length] = nums[i];
      } else {
        // 二分查找:找到第一个大于当前数值的结果进行替换
        let left = 0, right = result.length - 1;
        while (left < right) {
          let middle = ((left + right) / 2) | 0;
          if (result[middle] < nums[i]) {
            left = middle + 1;
          } else {
            right = middle;
          }
        }
        // 替换当前下标
        result[left] = nums[i];
      }
    }
    return result;
  };
  • 到这里似乎就很完美了因为我们已经找到正确的最长递增子序列了,但是其实我们使用的 贪心算法 是有缺陷的,比如在这个例子里:
[10,9,2,5,3,7,101,18,1]
// 正确应该是:[2,3,7,18]
// 但是求出来却是 [1,3,7,18]
// 到最后 1 会因为比 18 但是又因为 2 是它的上界值,所以把 2 给替换掉了

所以我们需要对上一步 贪心算法 + 二分查找上界 的算法进行优化,得到的就是最终的 vue 3 内的方法,使用回溯的方式去验证之前求出来的值是否是正确的,假如不正确,就替换掉。

function getSequence(arr) {
    // 复制一份 arr, 用以作为回溯的时候去判断 result 中的下标是否正确
    const p = arr.slice()
    // 默认 result 为 [0]
    const result = [0]
    let i, j, u, v, c
    const len = arr.length
    // 遍历数组
    for (i = 0; i < len; i++) {
      const arrI = arr[i]
      // 假如当前数不等于 0,这是 vue3 diff 的逻辑,假如为 0 不去做处理
      if (arrI !== 0) {
        // j 为当前 result 的最后一位,那么就是上次遍历存在 result 内的元素的下标
        j = result[result.length - 1]
        // result 内最后一个下标对应的 arr 数字是否小于当前元素
        // 比如 [10, 9],在遍历到 10 的时候,那么就是 result = [10], 假如到 9 ,那么就会去看 9 是否比 result 的最后一位
        // 大,假如是的话,比如 [10, 11],那么 p 就是 [10, 0], 即假如涉及到 result 的长度增加的时候,那么当前 p 所在的 i 的位置,就是
        // result 里面的最后一个值,注意, p 内的数字有两种类型,比如 之前求到的 [10, 0],第一位 10 比较特殊是原来的数字,后面的则都是下标,那么 0 就是 之前 result 内的
        // 10 所对应的下标 0
        if (arr[j] < arrI) {
          p[i] = j
          result.push(i)
          continue
        }

        u = 0
        v = result.length - 1
        // 使用 二分查找法寻找上界目标,然后在这个 while 后面的 result[u] = i 代理里面替换掉
        while (u < v) {
          c = ((u + v) / 2) | 0
          if (arr[result[c]] < arrI) {
            u = c + 1
          } else {
            v = c
          }
        }
        // 假如当前遍历的元素小于结果数组中最后一个数所对应 arr 的下标的值
        // 那么就使用上面求到的上界值,去替换掉上界值对应的元素
        // 假如当前元素并没有比 result 的最后一个大的话,同时 p[i] 也会延续 result 最后一个值去填充,比如
        // 原数组 2, 5, 3,一开始 p 是原数组的复制,所以 p 也等于 2, 5, 3, result 是 [0]
        // 首先对比 2 ,2 的下标是 0,与 result 最后一个,也就是 0 相等,不处理
        // 然后 5,5 比 2 大,那么 p 就变成了 [2, 1, 3], 因为 5 的下标是 1 ,所以同理 result 就是 [0, 1];
        // 最后到 3 ,3 比 5 小,那么根据下面的 if 内的逻辑,就是 p 内的 3 就沿用上一个元素 1,所以 p 就是 [2, 1, 1], result 内的 5 会被 3 因为是上界值而被替换掉。
        if (arrI < arr[result[u]]) {
          if (u > 0) {
            p[i] = result[u - 1]
          }
          result[u] = i
        }
      }
    }
    console.log(p)
    u = result.length
    v = result[u - 1]
    while (u-- > 0) {
      result[u] = v
      v = p[v]
    }
    return result
  }