vue3.0 diff算法详解(超详细)

142 阅读29分钟

vue3.0 diff算法详解(超详细)

看文章之前,先打开源码。手把手带你看源码。diff源码文件:renderer.ts

一 什么时候用到了diff算法,diff算法作用域?

1.1diff算法的作用域

patch概念引入

在vue update过程中在遍历子代vnode的过程中,会用不同的patch方法来patch新老vnode,如果找到对应的 newVnode 和 oldVnode,就可以复用利用里面的真实dom节点。避免了重复创建元素带来的性能开销。毕竟浏览器创造真实的dom,操纵真实的dom,性能代价是昂贵的。

patch过程中,如果面对当前vnode存在有很多chidren的情况,那么需要分别遍历patch新的children Vnode和老的 children vnode。

存在chidren的vnode类型

首先思考一下什么类型的vnode会存在children。

①element元素类型vnode

第一中情况就是element类型vnode 会存在 children vode,此时的三个span标签就是chidren vnode情况

<div>
   <span> 苹果🍎 </span> 
   <span> 香蕉🍌 </span>
   <span> 鸭梨🍐 </span>
</div>

在vue3.0源码中 ,patchElement用于处理element类型的vnode

②flagment碎片类型vnode

在Vue3.0中,引入了一个fragment碎片概念。 你可能会问,什么是碎片?如果你创建一个Vue组件,那么它只能有一个根节点。

<template>
   <span> 苹果🍎 </span> 
   <span> 香蕉🍌 </span>
   <span> 鸭梨🍐 </span>
</template>

这样可能会报出警告,原因是代表任何Vue组件的Vue实例需要绑定到一个单一的DOM元素中。唯一可以创建一个具有多个DOM节点的组件的方法就是创建一个没有底层Vue实例的功能组件。

flagment出现就是用看起来像一个普通的DOM元素,但它是虚拟的,根本不会在DOM树中呈现。这样我们可以将组件功能绑定到一个单一的元素中,而不需要创建一个多余的DOM节点。

<Fragment>
   <span> 苹果🍎 </span> 
   <span> 香蕉🍌 </span>
   <span> 鸭梨🍐 </span>
</Fragment>

在vue3.0源码中 ,processFragment用于处理Fragment类型的vnode

1.2 patchChildren

从上文中我们得知了存在children的vnode类型,那么存在children就需要patch每一个 children vnode依次向下遍历。那么就需要一个patchChildren方法,依次patch子类vnode。

patchChildren

vue3.0中 在patchChildren方法中有这么一段源码

// renderer.ts第1585行
const patchChildren: PatchChildrenFn = (
  n1,
  n2,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  slotScopeIds,
  optimized = false
) => {
  const c1 = n1 && n1.children
  const prevShapeFlag = n1 ? n1.shapeFlag : 0
  const c2 = n2.children

  const { patchFlag, shapeFlag } = n2
  // fast path
  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
      // 对于存在key的情况用于diff算法
      patchKeyedChildren(
        c1 as VNode[],
        c2 as VNodeArrayChildren,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      return
    } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
      // 对于不存在key的情况,直接patch
      patchUnkeyedChildren(
        c1 as VNode[],
        c2 as VNodeArrayChildren,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      return
    }
  }

patchChildren根据是否存在key进行真正的diff或者直接patch。

既然diff算法存在patchChildren方法中,而patchChildren方法用在Fragment类型和element类型的vnode中,这样也就解释了diff算法的作用域是什么。

1.3 diff算法作用?

通过前言我们知道,存在这children的情况的vnode,需要通过patchChildren遍历children依次进行patch操作,如果在patch期间,再发现存在vnode情况,那么会递归的方式依次向下patch,那么找到与新的vnode对应的vnode显的如此重要。

我们用两幅图来向大家展示vnode变化。 test2.jpegtest3.jpeg

如上两幅图表示在一次更新中新老dom树变化情况。

假设不存在diff算法,依次按照先后顺序patch会发生什么

如果不存在diff算法,而是直接patchchildren 就会出现如下图的逻辑。

diff3.jpeg

第一次patchChidrendiff4.jpeg

第二次patchChidren

diff5.jpeg

第三次patchChidren diff1.jpeg

第四次patchChidren

diff2.jpeg

如果没有用到diff算法,而是依次patch虚拟dom树,那么如上稍微修改dom顺序,就会在patch过程中没有一对正确的新老vnode,所以老vnode的节点没有一个可以复用,这样就需要重新创造新的节点,浪费了性能开销,这显然不是我们需要的。

那么diff算法的作用就来了。

diff作用就是在patch子vnode过程中,找到与新vnode对应的老vnode,复用真实的dom节点,避免不必要的性能开销。

二 diff算法具体做了什么(重点)?

通过前面的介绍,已经知道在patchChildren的过程中,存在 patchKeyedChildren与patchUnkeyedChildren。

2.1 patchUnkeyedChildren

patchKeyedChildren 是正式的开启diff的流程,那么patchUnkeyedChildren的作用是什么呢? 我们来看看针对没有key的情况patchUnkeyedChildren会做什么

const patchUnkeyedChildren = (
  c1: VNode[],
  c2: VNodeArrayChildren,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean,
) => {
  c1 = c1 || EMPTY_ARR
  c2 = c2 || EMPTY_ARR
  const oldLength = c1.length
  const newLength = c2.length
  const commonLength = Math.min(oldLength, newLength)
  let i
  for (i = 0; i < commonLength; i++) { //依次遍历新老vnode进行patch
    const nextChild = (c2[i] = optimized ? cloneIfMounted(c2[i] as VNode) : normalizeVNode(c2[i]))
    // 对于公共部分,进行从新patch工作
    patch(
      c1[i],
      nextChild,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized,
    )
  }
  if (oldLength > newLength) { //老vnode 数量大于新的vnode,删除多余的节点
    // remove old
    unmountChildren(c1, parentComponent, parentSuspense, true, false, commonLength)
  } else {  // 老vnode 数量小于于新的vnode,从新 mountChildren新增的节点
    // mount new
    mountChildren(
      c2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized,
      commonLength,
    )
  }
}

我们可以得到结论,对于不存在key情况

  • ① 比较新老children的length获取最小值 然后对于公共部分,进行从新patch工作。
  • ② 如果老节点数量大于新的节点数量 ,移除多出来的节点。
  • ③ 如果新的节点数量大于老节点的数量,从新 mountChildren新增的节点。

2.3 patchKeyedChildren

那么对于存在key情况呢? 会用到diff算法,diff算法做了什么呢?**patchKeyedChildren方法究竟做了什么?**见源码renderer.ts第1747行。

⓪我们先来看看一些声明的变量含义

//  c1 老的vnode c2 新的vnode
let i = 0 // 记录索引
const l2 = c2.length // 新vnode的数量
let e1 = c1.length - 1 // 老vnode 最后一个节点的索引
let e2 = l2 - 1 //新节点最后一个节点的索引

①第一步从头开始向尾寻找

// 1. sync from start
// (a b) c
// (a b) d e

// 从头对比找到有相同的节点 patch ,发现不同,立即跳出
while (i <= e1 && i <= e2) {
  const n1 = c1[i]
  const n2 = (c2[i] = optimized
    ? cloneIfMounted(c2[i] as VNode)
    : normalizeVNode(c2[i]))
  // 判断key ,type是否相等
  if (isSameVNodeType(n1, n2)) {
    patch(
      n1,
      n2,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    break
  }
  i++
}

第一步的事情就是从头开始寻找相同的vnode,然后进行patch,如果发现不是相同的节点,那么立即跳出循环。

具体流程如图所示

diff1.jpeg

isSameVNodeType

export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  if (
    __DEV__ &&
    n2.shapeFlag & ShapeFlags.COMPONENT &&
    hmrDirtyComponents.has(n2.type as ConcreteComponent)
  ) {
    // HMR only: if the component has been hot-updated, force a reload.
    return false
  }
  return n1.type === n2.type && n1.key === n2.key
}

②第二步从尾开始同前diff

// 2. sync from end
// a (b c)
// d e (b c)

// 如果第一步没有patch完,立即,从后往前开始patch ,如果发现不同立即跳出循环
while (i <= e1 && i <= e2) {
  const n1 = c1[e1]
  const n2 = (c2[e2] = optimized
    ? cloneIfMounted(c2[e2] as VNode)
    : normalizeVNode(c2[e2]))
  if (isSameVNodeType(n1, n2)) {
    patch(
      n1,
      n2,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    break
  }
  e1--
  e2--
}

经历第一步操作之后,如果发现没有patch完,那么立即进行第二部,从尾部开始遍历依次向前diff。

如果发现不是相同的节点,那么立即跳出循环。

具体流程如图所示

diff1.jpeg

③④主要针对新增和删除元素的情况,前提是元素没有发生移动, 如果有元素发生移动就要走⑤逻辑。

③ 如果老节点是否全部patch,新节点没有被patch完,创建新的vnode

// 3. common sequence + mount
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0

// 如果新的节点大于老的节点数 ,对于剩下的节点全部以新的vnode处理(这种情况说明已经patch完相同的vnode)
if (i > e1) {
  if (i <= e2) {
    const nextPos = e2 + 1
    const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
    while (i <= e2) {
      patch(
        null,
        (c2[i] = optimized
          ? cloneIfMounted(c2[i] as VNode)
          : normalizeVNode(c2[i])),
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      i++
    }
  }
}

i > e1

如果新的节点大于老的节点数 ,对于剩下的节点全部以新的vnode处理( 这种情况说明已经patch完相同的vnode ),也就是要全部create新的vnode.

具体逻辑如图所示

diff1.jpeg

④ 如果新节点全部被patch,老节点有剩余,那么卸载所有老节点

// 4. common sequence + unmount
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
  while (i <= e1) {
    unmount(c1[i], parentComponent, parentSuspense, true)
    i++
  }
}

对于老的节点大于新的节点的情况 ,对于超出的节点全部卸载 ( 这种情况说明已经patch完相同的vnode )

具体逻辑如图所示

diff1.jpeg

⑤ 不确定的元素 ( 这种情况说明没有patch完相同的vnode )★★★★★

在①②情况下没有遍历完的节点如下图所示。

diff.jpeg

剩下的节点。

diff.jpeg
5.1 创建一个新列表结点key与index的映射表
// 5. unknown sequence
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
else {
  const s1 = i  //prev starting index 老列表待处理的结点的开始下标
  const s2 = i  //next starting index 新列表待处理的结点的开始下标 

  // 5.1 build key:index map for newChildren
  //  创建一个新列表结点key与index的映射表  【目的:可以用旧的乱序结点去映射表中查找】
  const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
  //遍历未处理的新节点个数
  for (i = s2; i <= e2; i++) { 
    // 从新节点列表中取出当前正在遍历的节点
    const nextChild = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]))
    if (nextChild.key != null) {
      if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
        warn(
          `Duplicate keys found during update:`,
          JSON.stringify(nextChild.key),
          `Make sure keys are unique.`
        )
      }
      //将【结点key与index的映射】保存到映射表中
      keyToNewIndexMap.set(nextChild.key, i)
    }
  }

遍历所有新节点把索引和对应的key,存入map keyToNewIndexMap中

keyToNewIndexMap 存放 key -> index 的map

D : 2 E : 3 C : 4 I : 5

5.2 创建一个用来存放新节点索引和老节点索引的数组

newIndexToOldIndexMap 【数组的index是新vnode的索引 ,value是老vnode的索引】。 并将数组中的value全部填充为0。(0:意思代表都是新增节点)

// 5.2 loop through old children left to be patched and try to patch
// matching nodes & remove nodes that are no longer present
let j
let patched = 0 //记录新结点已处理个数
const toBePatched = e2 - s2 + 1 // 新列表中剩余待处理结点的长度
let moved = false //是否有结点要移动  默认情况没有节点需要移动
// used to track whether any node has moved
let maxNewIndexSoFar = 0 //记录复用结点相对下标
// works as Map<newIndex, oldIndex>
// Note that oldIndex is offset by +1
// and oldIndex = 0 is a special value indicating the new node has
// no corresponding old node.
// used for determining longest stable subsequence
//创建一个用来存放新节点索引和老节点索引的数组
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0 // 将数组的值全部填充为0。
5.3 遍历未处理老结点,去新数组中查找,看结点能否被复用
for (i = s1; i <= e1; i++) { // 遍历未处理老结点,去新数组中查找,看结点能否被复用
  const prevChild = c1[i]
  if (patched >= toBePatched) { // ① 新结点已经全部更新完 直接卸载旧结点(这些结点不能复用)
    // all new children have been patched so this can only be a removal
    unmount(prevChild, parentComponent, parentSuspense, true)
    continue
  }
  let newIndex
  // ② 如果,老节点的key存在 , 通过老节点的key找到老结点在新结点列表中对应的下标位置
  if (prevChild.key != null) {
    newIndex = keyToNewIndexMap.get(prevChild.key)  
  } else {
    // ③ 如果,老节点的key不存在
    for (j = s2; j <= e2; j++) { // 遍历剩下的所有新节点
      if (
        newIndexToOldIndexMap[j - s2] === 0 &&  // newIndexToOldIndexMap[j - s2] === 0 新节点没有被patch
        isSameVNodeType(prevChild, c2[j] as VNode)
      ) {
        // 如果找到与当前老节点对应的新节点那么 ,将新节点的索引,赋值给newIndex
        newIndex = j
        break
      }
    }
  }
  // ④ 没有找到与老节点对应的新节点,删除当前节点,卸载所有的节点
  if (newIndex === undefined) {
    unmount(prevChild, parentComponent, parentSuspense, true)
  } else {
     // ⑤ 没有找到与老节点对应的新节点,删除当前节点,卸载所有的节点

    // 给newIndexToOldIndexMap赋值 老结点要被复用 保存新结点下标在老结点中的位置
    newIndexToOldIndexMap[newIndex - s2] = i + 1    // 为什么是下标+1:因为0代表的是新增节点

    // 判断当前节点是否需要移动
    // 想象一下,如果每个节点都按序递增,那么每次都会进入该if语句
    if (newIndex >= maxNewIndexSoFar) {
       // 当前节点未移动,更新下标    例子:【1 3 5 没有结点插队;不需要移动】
      maxNewIndexSoFar = newIndex
    } else {
      // 如果进入该else语句说明:当前节点需要移动  例子:【有节点插队】 【1 3 5 没有结点插队】 后面来2 【结果变为:1 2 3 5】
      moved = true
    }
    // 继续精细化对比
    patch(
      prevChild,
      c2[newIndex] as VNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
    // 已经处理过的新结点数量+1
    patched++
  }
}

这段代码算是diff算法的核心。

  • 第一步:判断新结点已经全部更新完

    • 是:直接卸载旧结点(这些结点不能复用),continue结束本次循环

    • 否:继续往后

  • 第二步:通过老节点的key找到对应新节点的index

    • 开始遍历老的节点,判断有没有key:
      • 有:通过老节点的key去keyToNewIndexMap找到老结点在新结点列表中对应的下标位置;
      • 无:遍历剩下来的新节点试图找到对应index。
  • 第三步:判断index是否存在

    • 没有找到与老节点对应的新节点,删除当前老节点。continue结束本次循环
    • 如果存在index证明有对应的老节点,说老结点要被复用,继续往后
  • 第四步:newIndexToOldIndexMap找到对应新老节点关系。

    • 给newIndexToOldIndexMap赋值 老结点要被复用 保存新结点下标在老结点中的位置
    • 判断当前节点是否需要移动
    • 继续精细化对比
    • 让已经处理过的新结点数量+1

到这里,我们patch了一遍,把所有的老vnode都patch了一遍。

如图所示:

diff.jpeg

但是接下来的问题。

1 虽然已经patch过所有的老节点。可以对于已经发生移动的节点,要怎么真正移动dom元素。

2 对于新增的节点,(图中节点I)并没有处理,应该怎么处理。

5.4 处理移动老节点与创建新节点
// generate longest stable subsequence only when nodes have moved
// !获取最长最小递增子序列
// 【有位置结点发生变化】 就获取获取最小递增子序列 否则就付一个空数组
//  例如arr:[5,3,4,1,0] 最长递增子序列:[3,4] 返回结果的下标:[1,2]  【3在arr中的下标为:1,4在arr中的下标为:2】
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : EMPTY_ARR

// 最长子序列的指针
j = increasingNewIndexSequence.length - 1

// looping backwards so that we can use last patched node as anchor
// 处理未处理的新结点   从后向前遍历
for (i = toBePatched - 1; i >= 0; i--) {
  // 拿到新结点的后一个节点下标,当前索引加上起始索引,就是在新列表中的位置真实的索引
  const nextIndex = s2 + i // i的索引
  // 取出当前遍历节点
  const nextChild = c2[nextIndex] as VNode
  // 下一个节点的位置,用于移动DOM
  const nextPos = lastIndex + 1
  // 标杆节点【用于插入/移动到的他的前面】
  const anchor = nextPos < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor

  /**
   * !举例分析
   * 旧 [i ... e1 + 1]: a b [c d e] f g
   * 新 [i ... e2 + 1]: a b [e c d h] f g
   * ? 期待结果:①将h插入到f的前面 ②将e移动到c的前面
   */

  // 新列表结点index与老列表结点index映射关系表newIndexToOldIndexMap:[ 5, 3, 4, 0 ] 【5,3发生乱序,有结点要移动】move = true
  //  arr:[ 5, 3, 4, 0 ] 最长递增子序列为:[ 3, 4 ] 返回结果的下标increasingNewIndexSequence:[ 1, 2 ]  【3在arr中的下标为:1,4在arr中的下标为:2】

  /**
   * !分析步骤
   * 初始化:i =3 j = 1
   * 第一次:arr[3] = 0 新增 i--  i=2  ①将h插入到f的前面
   * 第二次:i与increasingNewIndexSequence[1]比:2 = 2 不移动   j-- i--  j=0  i=1
   * 第三次:i与increasingNewIndexSequence[1]比:1 = 1 不移动   j-- i--  j=-1 i=0
   * 第三次:j=-1,满足j<0判断  ②将e移动到c的前面        移动     i--  i=-1 结束    【j<0说递增子序列已经用完,不在递增子序列中的下标都要移动】
   *
   * ? 结果为 新增一次,移动一次。达到待结果
   */

  if (newIndexToOldIndexMap[i] === 0) {
    // 插入节点
    // mount new
    // ?情况1,该节点是全新节点
    // 创建真实结点并且插入 将创建h结点插入到f的前面
    patch(
      null,
      nextChild,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized,
    )
  } else if (moved) {
    // move if:
    // There is no stable subsequence (e.g. a reverse)
    // OR current node is not among the stable sequence
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      // ?情况2,不是递增子序列,该节点需要移动
      move(nextChild, container, anchor, MoveType.REORDER)
    } else {
      // ?情况3,是递增子序列,该节点不需要移动
      j-- // 让j指向下一个
    }
  }
}

扩展:最长稳定序列

首选通过getSequence得到一个最长稳定序列,对于index === 0 的情况也就是新增节点(图中I) 需要从新mount一个新的vnode,然后对于发生移动的节点进行统一的移动操作

什么叫做最长稳定序列

对于以下的原始序列 0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15 最长递增子序列为 0, 2, 6, 9, 11, 15.

为什么要得到最长稳定序列

因为我们需要一个序列作为基础的参照序列,其他未在稳定序列的节点,进行移动。

三、总结

经过上述我们大致知道了diff算法的流程

  1. 从头对比找到有相同的节点 patch ,发现不同,立即跳出。

  2. 如果第一步没有patch完,立即,从后往前开始patch ,如果发现不同立即跳出循环。

  3. 如果新的节点大于老的节点数 ,对于剩下的节点全部以新的vnode处理(这种情况说明已经patch完相同的vnode)。

  4. 对于老的节点大于新的节点的情况,对于超出的节点全部卸载 (这种情况说明已经patch完相同的vnode)。

  5. 不确定的元素( 这种情况说明没有patch完相同的vnode) 。

    • 把没有比较过的新的vnode节点,通过map保存

    • 记录已经patch的新节点的数量 patched

    • 没有经过 path 新的节点的数量 toBePatched

    • 建立一个数组newIndexToOldIndexMap,每个子元素都是[ 0, 0, 0, 0, 0, 0, ] 里面的数字记录老节点的索引 ,数组索引就是新节点的索引

    • 开始遍历老节点

      • ① 判断patched 是否大于等于 toBePatched

        • 是: 新结点已经全部更新完 ,那么直接卸载老的节点;continue结束本次循环

        • 否: 判断老节点的key是否存在

          • 是:通过key找到对应的index

          • 否:

            • 1 遍历剩下的所有新节点

            • 2 判断是否找到与当前老节点对应的新节点

              • 否:卸载当前老节点。

              • 是:

                • 把老节点的索引+1,记录在存放新节点的数组中
                • 如果节点发生移动 记录已经移动了
                • 继续精细化对比
                • 让已经处理过的新结点patched+1
    • 遍历结束

    • 是否发生移动

      • 是:根据 newIndexToOldIndexMap 新老节点索引列表找到最长稳定序列并返回

      • 否:返回一个空数组

    • 从后往前遍历未处理的新节点个数

      • 判断当前是否节点是否是新节点
        • 是:创建节点并插入
        • 否:判断是否有节点需要移动
          • 是:判断是否是递增子序列
            • 是:递增子序列,该节点不需要移动

            • 否:不是递增子序列,该节点需要移动

扩展知识:最长递增子序

1、题目

1.1 示例

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

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

视频解析

示例 1:

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

示例 2:

  • 输入: nums = [0,1,0,3,2,3]
  • 输出: 4
  • 解释: 最长递增子序列是 [0,1,2,3],因此长度为 4

示例 3:

  • 输入: nums = [7,7,7,7,7,7,7]
  • 输出: 1
  • 解释: 最长递增子序列是 [7],因此长度为 1 。

1.2 说明

1.3 提示:

  • 1 <= nums.length <= 2500
  • -104 <= nums[i] <= 104

1.4 进阶

  • 你可以设计时间复杂度为O(n^2)的解决方案吗?
  • 你能将算法的时间复杂度降低到O(nlog2n)) 吗?
  • 你能进一步输出该最长递增子序列吗?

2 、解法一(动态规划)

2.1 分析

定义 dp[i] 为 以 nums[i] 结尾的最长递增子序列长度。根据这个定义,很显然 dp[i] 的初始值都应该为 1 ,因为以 nums[i] 结尾的最长递增子序列至少应该包含其自身。

接下来我们从小到大计算 dp 数组的值,假设在计算 dp[i] 之前,我们已经计算出 dp[0]dp[i − 1] 的值,可以据此根据下列公式(即状态转移方程)计算出 dp[i]

dp[i]=max(dp[j])+1dp[i]=max(dp[j])+1

其中: 0 ≤ j < i 且 num[j] < num[i] ,即尝试往 dp[0]dp[i − 1] 中所对应的最长递增子序列后面再加一个 nums[i] 。由于 dp[j] 代表以 nums[j] 结尾的最长递增子序列,所以如果能从 dp[j] 这个状态转移过来,那么 nums[i] 必然要大于 nums[j],才能将 nums[i] 放在 nums[j] 后面以形成更长的递增子序列。

最后,我们所需的结果一定是 dp 数组中的最大值。

2.2 解答

1、求最长递增子序列长度

/**
 * 获取数组中严格递增子序列的长度
 * @param {number[]} arr 数组
 * @returns 
 */
function lengthOfLIS(arr) {
  if (!Array.isArray(arr) || arr.length === 0) {
    return 0
  }
  const dp = new Array(arr.length).fill(1)
  let result = 0
  for (let i = 1; i < arr.length; i++) {
    for (let j = 0; j < i; j++) {
      if (arr[i] > arr[j]) {
        dp[i] = Math.max(dp[i], dp[j] + 1)
      }
    }
    result = Math.max(dp[i], result) // 取长的子序列
  }
  return result
}

let result = [10, 9, 2, 5, 3, 7, 101, 18] // [2, 3, 7, 18]  4
console.log(lengthOfLIS(result))
result = [0, 1, 0, 3, 2, 3] // [0, 1, 2, 3] 4
console.log(lengthOfLIS(result))
result = [1, 4, 6, 3, 9, 7, 8]// [1, 4, 6, 7, 8] 5
console.log(lengthOfLIS(result))

2、进阶(获取最长递增子序列)

给定数组nums,设长度为n,输出nums的最长递增子序列。(如果有多个答案,请输出其中按数值进行比较的字典序最小的那个)。

链接:www.nowcoder.com/questionTer… 来源:牛客网

示例1

输入: nums = [2 1 5 3 6 4 8 9 7] 输出: [1 3 4 8 9]

示例2

输入: nums = [1 2 8 6 4] 输出: [1 2 4] 解释: 其最长递增子序列有3个,(1,2,8)、(1,2,6)、(1,2,4)其中第三个字典序最小,故答案为(1,2,4)

分析

假设dp[i]是以nums[i]结尾的序列的长度,那么在同一长度下,最右边的那个元素一定是字典序最小的,举个例子:序列“215364897”的每个元素对应的长度如下

215364897
112233454

可以看出每个长度对应的序列是降序,如长度为1时,序列为“21”,长度为2时,序列为“53”,因此要找出最长序列的最小字典序,从后往前遍历找到遇到的第一个对应长度的字符,如找到长度为5的最小字典序:

  • 1、从后往前遍历找到第一个对应长度为5的字符,这里是9

  • 2、继续从后往前遍历找到对应长度为4的字符,这里是8,

  • 3、继续从后往前遍历找到对应长度为3的字符,这里是4,

  • 4、继续从后往前遍历找到对应长度为2的字符,这里是3,

  • 5、继续从后往前遍历找到对应长度为1的字符,这里是1,

  • 所以得到:长度为5对应的最小字典序列就是13489

  // 最长递增子序列最大长度
  maxLen = Math.max(...dep)

  //从后往前遍历  获取最小子序列
  for (let i = dep.length - 1, tempResult = maxLen; i >= 0; i--) {
    if (dep[i] === tempResult) {
      resultArr.unshift(arr[i])
      tempResult--
    }
  }

完整代码

/**
 * 获取数组中最长递增子序列列表
 * @param {number[]} arr 数组
 * @returns 
 */
function getMaxLengthIncrementSeqArr(arr) {
  // 递增子序列数组
  let incrementSeqArr = []
  // 最长递增子序列最大长度
  let maxLength = 0
  const dp = new Array(arr.length).fill(1)

  // 判断是不是数组及长度是否大于0
  if (!Array.isArray(arr) || arr.length === 0) {
    return incrementSeqArr;
  }

  for (let i = 1; i < arr.length; i++) {
    for (let j = 0; j < i; j++) {
      if (arr[i] > arr[j]) {
        dp[i] = Math.max(dp[i], dp[j] + 1)
      }
    }
  }

  maxLength = Math.max(...dp)// 取最长递增子序列最大长度

  //从后往前遍历  获取最小子序列
  for (let i = dp.length - 1, tempValue = maxLength; i >= 0; i--) {
    if (dp[i] === tempValue) {
      incrementSeqArr.unshift(arr[i])
      tempValue--
    }
  }

  return incrementSeqArr
}

let result = [10, 9, 2, 5, 3, 7, 101, 18] // [2, 3, 7, 18]  
console.log(getMaxLengthIncrementSeqArr(result))
result = [0, 1, 0, 3, 2, 3] // [0, 1, 2, 3] 
console.log(getMaxLengthIncrementSeqArr(result))
result = [1, 4, 6, 3, 9, 7, 8]// [1, 4, 6, 7, 8] 
console.log(getMaxLengthIncrementSeqArr(result))

2.3 复杂度

  • 复杂度分析:时间复杂度O(n^2),n是nums的长度,外层需要循环n次,dp[i]需要从dp[0~i-1],所以复杂度是O(n^2)。
  • 空间复杂度是O(n),即dp数组的空间

3、解法二(二分查找

3.1、分析

  • 假设 nums = [2, 6, 8, 3, 4, 5, 1] ,按照下列步骤使用初始为空的辅助序列可以得到最长递增子序列的长度:

    1. 选取第一个元素 2 ,则 sub1 = [2] ;

    2. 由于 6 大于前一个元素,那么 sub1 = [2, 6] ;

    3. 由于 8 大于前一个元素,那么 sub1 = [2, 6, 8] ;

    4. 由于 3 小于前一个元素,我们不能直接将其追加至 sub1 之后,但这个元素必须暂时保留,因为在之后可能会有以 [2, 3] 开头的最长子序列,所以这里再使用另外一个辅助序列,最后得到 sub1 = [2, 6, 8] , sub2 = [2, 3] ;

    5. 对于元素 4 ,我们虽不能将其追加至 sub1 之后,但可以追加至 sub2 之后,因此 sub1 = [2, 6, 8] ,sub2 = [2, 3, 4] ;

    6. 对于元素 5 ,类似地有 sub1 = [2, 6, 8] ,sub2 = [2, 3, 4, 5] ;

    7. 对于元素 1,类似第 4 步,有 sub1 = [2, 6, 8] , sub2 = [2, 3, 4, 5] , sub3 = [1] ;

    8. 最后,我们得到最长的递增子序列长度的为 len(sub2) = 4 。

  • 然而,在上述步骤中,由于我们需要使用多个辅助的序列,因此效率会很低。实际上,我们可以仅使用一个辅助序列 sub :

    1. 当nums[i] 大于 sub 中最后一个元素时,直接往其后进行元素追加;

    2. 当某元素 nums[i] 不大于 sub 中的最后一个元素时,我们可以使用二分查找先找到 sub 中不小于nums[i] 的元素中最小的那个 ,然后使用 nums[i] 对其进行替换。

  • 基于上述改进,我们还是以 nums = [2, 6, 8, 3, 4, 5, 1] 为例,解释该算法的主要过程:

    1. 选取第一个元素 2 ,则 sub = [2] ;

    2. 由于 6 大于前一个元素,那么 sub = [2, 6] ;

    3. 由于 8 大于前一个元素,那么 sub = [2, 6, 8] ;

    4. 由于 3 **小于**前一个元素,此时需要先找到 sub 中不小于 3 的最小元素,即 6 ;

      然后替换后得到 sub = [2, 3, 8] ;

    5. 由于 4 **小于**前一个元素 ,此时需要先找到 sub 中不小于 4 的最小元素,即 8 ;

      然后替换后得到 sub = [2, 3, 4] ;

    6. 由于 5 大于前一个元素,那么 sub = [2, 3, 4, 5] ;

    7. 由于 1 **小于**前一个元素 ,参照3或4、然后替换后得到 sub = [1, 3, 4, 5] ;

    8. 最终,可得最长递增子序列的长度为 len(sub) = 4 。

  • 实际上,上述步骤最后得到的 sub 相当于按照 Patience Sorting 进行排列扑克牌的分堆后,每一堆牌最顶层牌组成的序列。

3.2解答

1、求最长递增子序列长度

/**
 * 求最长递增子序列长度
 * @param {number[]} numArr 
 * @returns 
 */
function lengthOfLIS(numArr) {

  let tail = [];//存放最长上升子序列数组
  let leftIndex, midIndex, rightIndex;
  if (numArr === null || numArr.length === 0) {
    return tail
  }


  tail = [numArr[0]];//存放最长上升子序列数组  默认numArr[0]就是最长上升子序列
  // tail数组中加入的序列长度
  let len = 1;

  for (let i = 1; i < numArr.length; i++) {
    if (numArr[i] > tail[tail.length - 1]) {
      //当numArr[i] 大于 tail 中最后一个元素时,直接往其后进行元素追加
      tail.push(numArr[i])
      len++
    } else {
      //否则进行二分查找
      leftIndex = 0
      rightIndex = tail.length - 1
      while (leftIndex < rightIndex) {
        midIndex = parseInt((leftIndex + rightIndex) / 2)
        if (tail[midIndex] < numArr[i]) {
          leftIndex = midIndex + 1
        } else {
          rightIndex = midIndex
        }
      }
      // 使用二分查找先找到 tail 中不小于 numArr[i] 的元素中最小的那个,然后使用 numArr[i] 对其进行替换。
      tail[leftIndex] = numArr[i]
    }
  }
  return len
}

let result = [10, 9, 2, 5, 3, 7, 101, 18] // [2, 3, 7, 18] 4 
console.log(lengthOfLIS(result))
result = [0, 1, 0, 3, 2, 3] // [0, 1, 2, 3] 4
console.log(lengthOfLIS(result))
result = [1, 4, 6, 3, 9, 7, 8]// [1, 4, 6, 7, 8] 5
console.log(lengthOfLIS(result))

2、进阶(输出最长递增子序列)

版本1(结果可能有误)
function getMaxLengthIncrementSeqArr(numArr) {

  let tail = [];//存放最长上升子序列数组
  let leftIndex, midIndex, rightIndex;
  if (numArr === null || numArr.length === 0) {
    return tail
  }

  tail = [numArr[0]];//存放最长上升子序列数组  默认numArr[0]就是最长上升子序列

  for (let i = 1; i < numArr.length; i++) {
    if (numArr[i] > tail[tail.length - 1]) {
      //当numArr[i] 大于 tail 中最后一个元素时,直接往其后进行元素追加
      tail.push(numArr[i])
    } else {
      //否则进行二分查找
      leftIndex = 0
      rightIndex = tail.length - 1
      while (leftIndex < rightIndex) {
        midIndex = parseInt((leftIndex + rightIndex) / 2)
        if (tail[midIndex] < numArr[i]) {
          leftIndex = midIndex + 1
        } else {
          rightIndex = midIndex
        }
      }
      // 使用二分查找先找到 tail 中不小于 numArr[i] 的元素中最小的那个,然后使用 numArr[i] 对其进行替换。
      tail[leftIndex] = numArr[i]
    }
  }
    
  return tail
}


let result = [10, 9, 2, 5, 3, 7, 101, 18] // [2, 3, 7, 18] 4 
console.log(getMaxLengthIncrementSeqArr(result))
result = [0, 1, 0, 3, 2, 3] // [0, 1, 2, 3] 4
console.log(getMaxLengthIncrementSeqArr(result))

// 出现bug
result = [3, 2, 8, 9, 5, 6, 7, 11, 15, 4]// 结果应该:[2, 5, 6, 7, 11, 15] 但打印结果为:[ 2, 4, 6, 7, 11, 15 ]
console.log(getMaxLengthIncrementSeqArr(result))

[3, 2, 8, 9, 5, 6, 7, 11, 15, 4]最长递增子序列应该是:[2, 5, 6, 7, 11, 15] ;结果长度没问题,但打印结果为:[ 2, 4, 6, 7, 11, 15 ],结果错误。

错误原因分析
tail 数组下标变化过程
numArr = [3, 2, 8, 9, 5, 6, 7, 11, 15, 4]

// 3
// 2
// 2 8 
// 2 8 9
// 2 5 9
// 2 5 6
// 2 5 6 7
// 2 5 6 7 11
// 2 5 6 7 11 15
// 2 4 6 7 11 15

在我们的例子中,需要在数组 [2, 5, 6, 7, 11, 15] 中找到 4 应该放入的位置,需要对数组进行二分查找, start: 0, end: 5, middle: 2, 然后使用 while 不断二分查询,最终找到替换元素是 5,然后用 4替换掉 5。

很明显,4 替换 5 明显是错误的,因为最长递增子序列的顺序不能颠倒。

解决方法:

通过前面的分析,可以知道递增子序列列表中最后一个数的结果是正确的。可以通过从尾结点追溯前驱。得到正确结果。

设置两个数组,pos[ ],pre[ ]。pos用来记录当前递增子序列的下标,pre用来记录当前递增子序列的前驱结点下标。

function getMaxLengthIncrementSeqArr(arr) {

  if (!Array.isArray(arr) || arr.length === 0) {
    return []
  }

  let leftIndex, midIndex, rightIndex
  const subArr = [arr[0]]//存放最长递增子序列数组 默认arr[0]就是最长递增子序列
  const pos = [0] // pos用来记录最长递增子序列的下标 默认0就是最长递增子序列的小标
  const pre = [undefined] // pre用来记录最长递增子序列的前驱结点下标 默认arr[0]没有前驱,即undefined

  for (let i = 1; i < arr.length; i++) {

    if (arr[i] > subArr[subArr.length - 1]) { // 当arr[i] 大于 subArr 中最后一个元素时

      pre[i] = pos[subArr.length - 1] //记录前驱
      pos[subArr.length] = i // 记录索引
      subArr.push(arr[i]) //直接往其后进行元素追加
      continue //结束本次循环

    } else {
      //否则进行二分查找
      leftIndex = 0
      rightIndex = subArr.length - 1
      while (leftIndex < rightIndex) {
        midIndex = parseInt((leftIndex + rightIndex) / 2)
        if (subArr[midIndex] < arr[i]) {
          leftIndex = midIndex + 1
        } else {
          rightIndex = midIndex
        }
      }
      // 使用二分查找先找到 subArr 中不小于 arr[i] 的元素中最小的那个,然后使用 arr[i] 对其进行替换。
      pre[i] = pos[leftIndex - 1]
      pos[leftIndex] = i
      subArr[leftIndex] = arr[i]
    }
  }

  // const result = [] // 返回结果
  // const newPos = [] // 返回结果对应下标
  // let len = subArr.length - 1
  // while (pos[len] !== undefined) {
  //   result.unshift(arr[pos[len]])
  //   newPos.unshift(pos[len])
  //   pos[len] = pre[pos[len]]
  // }

  let len = subArr.length - 1
  let prePos = pos[len] // 取出递增子序列列表中最后一个数在原数组中的下标
  while (prePos !== undefined) {
    subArr[len] = arr[prePos] // 更新结果   从数组中根据下标取出结果
    pos[len] = prePos //更新结果下标
    prePos = pre[prePos] // 从尾结点追溯前驱
    len--
  }
  console.log(`[${arr}]中最长递增子序列为: [${subArr}],最长递增子序列在原数组中的下标位置为:[${pos}]`)
  return subArr
}

let arr
arr = [10, 9, 2, 5, 3, 7, 101, 18]
console.log(getMaxLengthIncrementSeqArr(arr))// [2, 3, 7, 18]
arr = [0, 1, 0, 3, 2, 3]
console.log(getMaxLengthIncrementSeqArr(arr))// [0, 1, 2, 3] 
arr = [1, 2, 8, 6, 4]
console.log(getMaxLengthIncrementSeqArr(arr)) //[1, 2, 4]
arr = [1, 4, 6, 3, 9, 7, 8]
console.log(getMaxLengthIncrementSeqArr(arr)) //[1, 4, 6, 7, 8]
arr = [3, 2, 8, 9, 5, 6, 7, 11, 15, 4]
console.log(getMaxLengthIncrementSeqArr(arr)) //[2, 5, 6, 7, 11, 15]

3.3 复杂度

  • 复杂度分析:时间复杂度O(nlog2n),n是arr的长度,外层需要循环n次;二分查找需要log2n次,所以复杂度是O(nlog2n)。
  • 空间复杂度是O(n),即subArr,pos,pre数组的空间

vue3获取最长递增子序列下标

例如

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

解释:数组arr:[ 5, 3, 4, 0 ] ,最长递增子序列为:[ 3, 4 ] ,返回结果的下标数组:[ 1, 2 ] (因为:3在arr中的下标为:1,4在arr中的下标为:2)

输入: arr = [1, 2, 8, 6, 4] 输出: [0,1,4] 解释: 数组arr:[1, 2, 8, 6, 4], 其最长递增子序列有3个: [1, 2, 8]、[1, 2, 6]、[1, 2, 4],其中第三个最小,故答案为[0,1,4]

源码

/**
 * !获取最长递增子序列,返回的是结果所在的下标数组
 * @param arr 
 * @returns 
 */
function getSequence(arr: number[]) {
  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
      } else {
        // 当前项小于最后一项,二分查找+替换
        u = 0
        v = result.length - 1
        while (u < v) {
          c = (u + v) >> 1
          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
}

// 测试结果
let arr;
arr = [5, 3, 4, 0]
console.log(JSON.stringify(getSequence(arr))) // 下标位置为:[1,2]
arr = [1, 2, 8, 6, 4]
console.log(JSON.stringify(getSequence(arr)))// 下标位置为:[0,1,4]

自己写的

function getMaxLengthIncrementSeqArr(arr) {

  if (!Array.isArray(arr) || arr.length === 0) {
    return []
  }

  let leftIndex, midIndex, rightIndex
  const subArr = [arr[0]]//存放最长递增子序列数组 默认arr[0]就是最长递增子序列
  const pos = [0] // pos用来记录最长递增子序列的下标 默认0就是最长递增子序列的小标
  const pre = [undefined] // pre用来记录最长递增子序列的前驱结点下标 默认arr[0]没有前驱,即undefined

  for (let i = 1; i < arr.length; i++) {
    if (arr[i] !== 0) { //不是新增元素
      if (arr[i] > subArr[subArr.length - 1]) { // 当arr[i] 大于 subArr 中最后一个元素时

        pre[i] = pos[subArr.length - 1] //记录前驱
        pos[subArr.length] = i // 记录索引
        subArr.push(arr[i]) //直接往其后进行元素追加
        continue //结束本次循环

      }

      //否则进行二分查找
      leftIndex = 0
      rightIndex = subArr.length - 1
      while (leftIndex < rightIndex) {
        midIndex = parseInt((leftIndex + rightIndex) / 2)
        if (subArr[midIndex] < arr[i]) {
          leftIndex = midIndex + 1
        } else {
          rightIndex = midIndex
        }
      }
      // 使用二分查找先找到 subArr 中不小于 arr[i] 的元素中最小的那个,然后使用 arr[i] 对其进行替换。
      if (arr[i] < subArr[leftIndex]) {
        if (leftIndex > 0)
          pre[i] = pos[leftIndex - 1]
        pos[leftIndex] = i
        subArr[leftIndex] = arr[i]
      }
    }
  }

  let len = subArr.length - 1
  let prePos = pos[len] // 取出最后一个节点的下标
  while (prePos !== undefined) {
    // subArr[len] = arr[prePos] // 更新结果   从数组中根据下标取出结果
    pos[len] = prePos //更新结果下标
    prePos = pre[prePos] // 从尾结点追溯前驱
    len--
  }
  // console.log(`[${arr}]中最长递增子序列为: [${subArr}],最长递增子序列在原数组中的下标位置为:[${pos}]`)
  return pos
}

function getMaxLengthIncrementSeqArr(arr) {

  if (!Array.isArray(arr) || arr.length === 0) {
    return []
  }

  let leftIndex, midIndex, rightIndex
  const subArr = [arr[0]]//存放最长递增子序列数组 默认arr[0]就是最长递增子序列
  const pos = [0] // pos用来记录最长递增子序列的下标 默认0就是最长递增子序列的小标
  const pre = [undefined] // pre用来记录最长递增子序列的前驱结点下标 默认arr[0]没有前驱,即undefined

  for (let i = 1; i < arr.length; i++) {
    if (arr[i] !== 0) { //不是新增元素
      if (arr[i] > subArr[subArr.length - 1]) { // 当arr[i] 大于 subArr 中最后一个元素时

        pre[i] = pos[subArr.length - 1] //记录前驱
        pos[subArr.length] = i // 记录索引
        subArr.push(arr[i]) //直接往其后进行元素追加
        continue //结束本次循环

      }

      //否则进行二分查找
      leftIndex = 0
      rightIndex = subArr.length - 1
      while (leftIndex < rightIndex) {
        midIndex = parseInt((leftIndex + rightIndex) / 2)
        if (subArr[midIndex] < arr[i]) {
          leftIndex = midIndex + 1
        } else {
          rightIndex = midIndex
        }
      }
      // 使用二分查找先找到 subArr 中不小于 arr[i] 的元素中最小的那个,然后使用 arr[i] 对其进行替换。
      if (arr[i] < subArr[leftIndex]) {
        if (leftIndex > 0)
          pre[i] = pos[leftIndex - 1]
        pos[leftIndex] = i
        subArr[leftIndex] = arr[i]
      }
    }
  }

  let len = subArr.length - 1
  let prePos = pos[len] // 取出最后一个节点的下标
  while (prePos !== undefined) {
    // subArr[len] = arr[prePos] // 更新结果   从数组中根据下标取出结果
    pos[len] = prePos //更新结果下标
    prePos = pre[prePos] // 从尾结点追溯前驱
    len--
  }
  // console.log(`[${arr}]中最长递增子序列为: [${subArr}],最长递增子序列在原数组中的下标位置为:[${pos}]`)
  return pos
}

// 测试结果
let arr;
arr = [5, 3, 4, 0]
console.log(JSON.stringify(getMaxLengthIncrementSeqArr(arr))) // 最长递增子序列为: [3,4]  下标位置为:[1,2]
arr = [1, 2, 8, 6, 4]
console.log(JSON.stringify(getMaxLengthIncrementSeqArr(arr)))// 最长递增子序列为: [1,2,4] 下标位置为:[0,1,4]

引用参考:

blog.csdn.net/zl_Alien/ar…

leetcode-cn.com/problems/lo…

www.nowcoder.com/questionTer…