快速Diff算法之 最长递增子序列

一、基础背景

vue3.0中也对dom-diff算法进行了优化,其中就用到了 「最长递增子序列」

在项目中,实际上我们编写的模板或者jsx语法会被@vue/compiler-dom转化为虚拟 DOM 节点,即 Virtual DOM,之后再将虚拟 DOM 节点渲染成实际的 DOM 节点,Virtual DOM 也会被组织成树形结构,即 Virtual DOM 树。类似如下所示:

<div id="app">
  <p class="text">hello world!!!</p>
</div>

对应的Virtual DOM

{
  tag: 'div',
  props: {
    id: 'app'
  },
  chidren: [
    {
      tag: 'p',
      props: {
        className: 'text'
      },
      chidren: [
        'hello world!!!'
      ]
    }
  ]
}

也许你会有个问题,为什么要引入虚拟DOM,而不是直接把组件转换成正式DOM节点呢?

  • 具备跨平台的优势

        由于虚拟DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等,是实现ssr、小程序等的基础。

  • 提升渲染性能。

        因为DOM是一个很大的对象,直接操作DOM,即便是一个空的 div 也要付出昂贵的代价,执行速度远不如我们抽象出来的 Javascript 对象的速度快,因此,把大量的DOM操作搬运到 Javascript 中,运用diff算法来计算出真正需要更新的节点,最大限度地减少DOM操作,从而显著提高性能。虚拟DOM的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。

  • 虚拟DOM的应用

        渲染函数:https://cn.vuejs.org/v2/guide/r渲染函数 & JSX | Vue.jshttps://cn.vuejs.org/v2/guide/r

根据上面的介绍,我们知道Vue会把我们编写的组件转换成虚拟DOM树,并且将虚拟DOM树进行比较后再根据变化情况更新真实DOM。比如原有列表为 [a, b, c, d, e, f] ,而新列表为 [a, d, b, c, e, f], 这时会这样进行diff:

  1. 去除相同前置和后置元素 即双端Diff,可以比较容易实现而且带来带来比较明显的提升;

    比如针对上情况,去除相同的前置和后置元素后,真正需要处理的是 [ b, c, d][d, b, c] ,复杂性会大大降低。

  2. 最长递增子序列

    接着要将原数组中的[ b, c, d] 转化成 [d, b, c] 。Vue3 中对移动次数进行了进一步的优化。下面对这个算法进行介绍:

    1. 首先遍历新列表,通过 key 去查找在原有列表中的位置,从而得到新列表在原有列表中位置所构成的数组。比如原数组中的[ b, c, d], 新数组为 [d, b, c] ,得到的位置数组为 [3, 1, 2] ,现在的算法就是通过位置数组判断最小化移动次数;

    2. 计算最长递增子序列

      最长递增子序列是经典的动态规划算法,不了解的可以前往 最长递增子序列 去补充一下前序知识。那么为什么最长递增子序列就可以保证移动次数最少呢?因为在位置数组中递增就能保证在旧数组中的相对位置的有序性,从而不需要移动,因此递增子序列的最长可以保证移动次数的最少

      对于前面的得到的位置数组[3, 1, 2],得到最长递增子序列 [1, 2] ,在子序列内的元素不移动,不在此子序列的元素移动即可。对应的实际的节点即 d 节点移动至b, c前面即可。

Vue3.0的源码👉:vue-next/renderer.ts 请阅读其中的getSequence方法。

力扣上没有完全一样的题目,有一道最接近的题 300. 最长递增子序列 ,它的要求是求最长递增子序列的长度,我们可以把题目换成求最长递增子序列的索引。

二、题目描述:

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

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1:

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

 

这里再强调一遍,我们要求的是最长子序列对应的索引。

示例 2:

输入:nums = [0,1,0,3,2,3] 输出:[ 0, 1, 4, 5 ]

解释:最长递增子序列是 [0,1,2,3],因此索引是[ 0,1,4,5 ]。

示例 3:

输入:nums = [7,7,7,7,7,7,7] 输出:[0]

解释:最长递增子序列是 [7],因此索引是[0]。

三、思路分析:

假设我们要实现getSequence方法,入参是nums数组,返回结果是一个数组。

/**
 * @param {number[]} nums
 * @return {number[]}
 */
var getSequence = function (nums) {
  // your code
}

步骤1

先创建一个空数组result保存索引。遍历nums,将当前项current和result的最后一项对应的值last进行比较。如果当前项大于最后一项,直接往result中新增一项;否则,针对result数组进行二分查找,找到并替换比当前项大的那项。下图示意图中为了方便理解result存放的是nums中的值,实际代码存放的是数组索引。

 

function getSequence (nums) {
  let result = []
  for (let i = 0; i < nums.length; i++) {
    let last = nums[result[result.length - 1]],
      current = nums[i]
    if (current > last || last === undefined) {
      // 当前项大于最后一项
      result.push(i)
    } else {
      // 当前项小于最后一项,二分查找+替换
      let start = 0,
        end = result.length - 1,
        middle
      while (start < end) {
        middle = Math.floor((start + end) / 2)
        if (nums[result[middle]] > current) {
          end = middle
        } else {
          start = middle + 1
        }
      }
      result[start] = i
    }
  }
  return result
}

步骤2

这步是难点,因为步骤1在替换的过程中贪心了,导致最后的结果错乱。

为了解决这个问题,使用的前驱节点的概念,需要再创建一个数组preIndexArr。在步骤1往result中新增或者替换新值的时候,同时preIndexArr新增一项,该项为当前项对应的前一项的索引。这样我们有了两个数组:

  • result:[1,3,4,6,7,9]

  • preIndexArr:[undefined,0,undefined,1,3,4,4,6,1]

result的结果是不准确的,但是result的最后一项是正确的,因为最后一项是最大的,最大的不会算错。我们可知最大一项是值9,索引是7。可查询preIndexArr[7]获得9的前一项的索引为6,值为7...依次类推能够重建新的result。

注意:下图中为了方便理解,result存放的是值,实际代码中存放的是索引。 ok,思路说完了,那么下面就开始码代码了

function getSequence(nums) {
  let result = [],
    preIndex = []
  for (let i = 0; i < nums.length; i++) {
    let last = nums[result[result.length - 1]],
      current = nums[i]
    if (current > last || last === undefined) {
      // 当前项大于最后一项
      preIndex[i] = result[result.length - 1]
      result.push(i)
    } else {
      // 当前项小于最后一项,二分查找+替换
      let start = 0,
        end = result.length - 1,
        middle
      while (start < end) {
        middle = Math.floor((start + end) / 2)
        if (nums[result[middle]] > current) {
          end = middle
        } else {
          start = middle + 1
        }
      }

      if (current < nums[result[start]]) {
        preIndex[i] = result[start - 1]
        result[start] = i
      }
    }
  }
  // 利用前驱节点重新计算result
  let length = result.length, //总长度
    prev = result[length - 1] // 最后一项
  while (length-- > 0) {// 根据前驱节点一个个向前查找
    result[length] = prev
    prev = preIndex[result[length]]
  }
  return result
}

题解的时间复杂度为O(nlogn),即for循环的n乘以二分查找的logn

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值