最长递增子序列及vue3.0中diff算法

1,037 阅读6分钟

首发地址
VUE3.0对diff过程进行了大升级,去掉了针对下标key的查找,而是变成了计算可以最少移动dom的方案,然后在进行dom更新,而要想看懂vue3.0中diff算法,首先需要先对最长递增子序列的求解有一个基本的了解,因为vue就是在它的基础上来不断打磨、完善的diff算法。

求解最长递增子序列leetcode300

给你一个整数数组nums,找到其中最长严格递增子序列的长度
示例:

输入:nums = [10, 9, 2, 5, 3, 7, 101, 18]
输出:4
解释:最长递增子序列是 [2, 3, 7, 101],因此长度为 4 。

动态规划:O(n²)

定义:dp[i]代表以num[i]结尾的最长子序列的长度
转移方程:

  • 双层遍历:对比num[i]num[i]之前的数据
  • num[i]>num[j]时,num[i]就可以拼接在num[j]后,此时num[i]位置的上升子序列长度为:dp[i]+1
  • num[i]<num[j]时,num[i]num[j]无法构成上升子序列,跳过
  • 计算出dp[i]中最大的值即为计算结果
function lengthOfLIS(nums: number[]): number {
  const len:number = nums.length
  if (len <=1 ) return len;
  let dp:number[] = new Array(len).fill(1)
  for (let i = 0; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[i] > nums[j]) {
        dp[i] = Math.max(dp[i], dp[j] + 1)
      }
    }
  }
  return Math.max(...dp)
}; 

计算过程图:
最长递增子序列

贪心 + 二分查找:O(nlogn)

要使上升子序列的长度尽可能的长,就要使序列上升的速度尽可能的慢,因此需要让序列内末尾数字尽可能的小。
我们可以维护一个result数组,用来存放单调递增序列结果,然后依次遍历nums数组;

  • 如果nums[i] > result[len], 则直接插入到result末尾
  • 否则,在result数组中通过二分查找的方式,找到第一个比nums[i]大的值result[j];并更新result[j] = nums[i]
function lengthOfLIS(nums: number[]): number {
  const n = nums.length
  if (n <=1 ) return n;
  let result:number[] = [nums[0]]
  let len = result.length	// 最大长度
  for (let i = 1; i < n; i++) {
    if (nums[i] > result[len-1]) { //大于末尾的值, 直接近栈
      result.push(nums[i]) 
      ++len
    } else {
      let left = 0, right = len;
      while(left < right) { // 二分查找序列内第一个大于nums[i]的值
        const mid = (left + right) >> 1
        if (result[mid] < nums[i]) {
          left = mid + 1
        } else {
          right = mid
        }
      }
      result[left] = nums[i] // 替换
    }
  }
  return len
}

计算过程图:
最长递增子序列

注意:这个方案中的result得到的长度是正确的,但是顺序并不一定是正确结果需要的顺序,比如[10, 9, 2, 5, 3, 7, 1, 18]得到的result[1, 3, 7, 18]

那么为什么贪心算法可以得到正确的长度呢?

要想得到最长上升子序列的正确长度,首先必须保证result内存放的数值增速尽可能稳和慢,所以要使用增长空间大、有潜力的值来组合;

比如1,50,5,……当我们遍历到50的时候,并不知道后面是否还有值,此时先将数据放入栈中存起来是明智的,继续往后遍历遇到了5,显然选用1,5比选用1,50更让人放心也更有潜力,因为后面的数再往栈内存放的几率更大,即使后面没有更多值了,那么选用1,5还是1,50其实最后长度是一样的。

那如果使用了更小的值,已经在栈内的值应该如何处理呢?比如我们栈中存放了1,3,9,10,再往后遍历的时候遇到了5,显然59,10都更有潜力,如果将栈直接变成1,3,5又不太可能,因为如果后面没有更多值了,长度由4变成3,结果是错误的;但如果不去管5的话,后面又碰到了 6,7,8那不就JJ了;

所以我们可以考虑既不能放弃有潜力的值,也不能错失正确的长度结果,因此我们不妨鱼和熊掌都兼得一下,比如将第一个大于5的值9替换掉变成1,3,5,10,这样在放弃栈内容顺序正确性的情况下保证了栈长度的正确性,接下来,再往后遍历会遇到3种情况:

  • 后面没有更多值了,此时结果长度为4,是没问题的

  • 如果后面遇到50,则可以直接插入到栈中,变为1,3,5,10,50,长度为5也是没问题的,因为我们并没有将最后的值替换掉,所以我们可以将栈想象成为9做了个替身5,真正的值还是替换前的1,3,9,10

  • 如果后面遇到了6,则按照一开始的规则,将10替换掉变成1,3,5,6,长度为4也是没问题的,因为我们将最后的值都做了替换,所以此时替身5就变成了真身,同时我们也发现,得到的栈中的值就是最后的最优解

可以发现,在没有替换完栈中的值时,中被替换的的值,起到的是占位的效果,为后面遍历数字提供参照的作用;

最长上升子序列进阶:得到正确的序列

要想得到正确的序列,首先要对上面的代码做一些改动:

  • result修改为存储下标(最后回溯是会改成真正的值);为下面的chain提供参考
  • 增加chain变量,存放每一位在被加入到result时其对应的前一位的下标值,进行关系绑定
  • 回溯chain,覆盖result的值。因为result内,最后一位一定是正确的,所以可以从后往前进行修正

上面我们说过在对栈内某个值进行替换后,变动的值后面的所有的值如果都没有变过的话,那么替换的值只是一个替身,无法作为最后结果进行输出,只有替换值后面的都变动过了,才会由替身变为真身。那么在没有全部替换前,我们是需要有一种方法去保存原来顺序的:

比如3,5,7,可以想象成7->5->3他们之间是强绑定,7前面绑定的永远都是55前面永远都是3

  • 如果此时遇到了4,栈会变成3,4,75虽然变成了4,但是7->5->3这个绑定关系是不会变的
  • 如果此时又遇到了15,栈变成了3,4,7,15,则绑定和回溯关系就变成了15->7->5->3

那么什么时候4能生效呢?那就是在4后面的值都被替换了,比如又遇到了68,则栈变为了3,4,6,8,绑定和回溯关系就变成了8->6->4->3

最长递增子序列

function getOfLIS(nums: number[]):number[]  {
  const n = nums.length
  if (n <=1 ) return nums;
  let result:number[] = [0]  // 由原来存储具体值改为存储下标
  let chain = new Map() // 通过下标存储映射关系
  for (let i = 0; i < n; i++) {
    const j = result[result.length - 1]
    if (nums[i] > nums[j]) {
      chain.set(i,{val: i, pre: j})
      result.push(i)
    } else {
      let left = 0, right = result.length;
      while(left < right) {
        const mid = (left + right) >> 1
        if (nums[result[mid]] < nums[i]) {
          left = mid + 1
        } else {
          right = mid
        }
      }
      chain.set(i,{val: i, pre: result[left - 1]})
      result[left] = i
    }
  }
  let preIdx = result[result.length - 1]
  let len = result.length
 // 从后往前进行回溯,修正覆盖result中的值,找到正确的顺序
  while(chain.get(preIdx)) {  
  	let lastObj = chain.get(preIdx)  
    result[--len] = nums[lastObj.val]
    preIdx = lastObj.pre
  }
  return result
}; 

const test= [9,2,5,3,7,101,4,18,1]
console.log(getOfLIS(test)); // [2,3,4,18]

vue3 DOM DIFF算法

vue3中的diff和上面的思想其实是一样的,都是基于下标来绑定数字在被插入result内时和其前面一个数字的关系。但是它看起来会更加难以理解,因为它是通过数组(P)来绑定回溯关系的,返回的是最长递增子序列的下标值

  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]
      // 排除了等于0的情况,原因是0并不代表任何dom元素,只是用来做占位的
      if (arrI !== 0) {
        j = result[result.length - 1]
        // 当前值大于子序列最后一项
        if (arr[j] < arrI) {
          // p内存储当前值的前一位下标
          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内存储找到的下标的前一位
            p[i] = result[u - 1]
          }
          // 找到下标,直接替换result中的数值
          result[u] = i
        }
      }
    }
    u = result.length
    v = result[u - 1]
    // 回溯,从最后一位开始,将result全部覆盖,
    while (u-- > 0) {
      result[u] = v
      v = p[v]
    }
    return result
  }

参考

Vue3 DOM Diff 核心算法解析
wikipedia-最长递增子序列