注:本文使用vue版本为3.3.4
我们已经在上一节中探讨了初始化和更新的流程,但是尚未深入了解其中关键的diff算法。那么接下来,我们将深入探讨这个神秘的diff算法是如何运作的。
由于diff算法在patchKeyedChildren函数中进行应用,因此我们需要重点关注这个函数的逻辑来了解整个diff过程。当然,为了避免阅读的混乱,我们将这个函数的内容分块进行详细的分析和理解。
头对比
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // 旧子节点的结束索引
let e2 = l2 - 1 // 新子节点的结束索引
// 从开头开始
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
// 如果是相同类型的节点,则执行patch
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
} else {
break
}
i++
}
}
一上来就出现好几个变量,这里需要分别理解一下。
c1旧虚拟节点树,一个数据。也就是旧父节点的children字段
c2新虚拟节点树,一个数据。也就是新父节点的children字段
l2 新虚拟节点数的长度,也就是新父节点的children的长度
e1旧的子节点的尾部标记,或者说旧的子节点的尾部的index
e2新的子节点的尾部标记,或者说新的子节点的尾部的index
i是当前指针的标记,因为他会在结束时刻自增一次,而非下次循环的时候自增。
这段代码的核心思想是进行新旧比较,针对相同索引的两个节点,判断它们是否属于同一类型的节点。这一判断依据是isSameVNodeType函数,我们曾在之前的文章中详细讨论过该函数的本质,即判断两个vnode的type和key是否相同。
如果两个节点的类型相同,那么当前节点将进入递归的patch流程。然而,只要其中一个节点不符合条件(即类型不同或键值对不匹配),就会立即退出循环。需要注意的是,每次循环结束后,索引i不会自增,而是保持在上一次循环中获得的值。
因此,在退出循环时,i将指向第一个不满足isSameVNodeType函数的节点的索引。这个索引是在上一个循环中i自增后得到的,本轮循环由于不满足isSameVNodeType条件而跳出循环,所以i没有自增。
尾对比
退出头对比,并不会结束函数,会进行另一个对比逻辑。尾对比,我们看下一段逻辑。
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // 旧子节点的结束索引
let e2 = l2 - 1 // 新子节点的结束索引
// 从结尾开始
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
// 如果是相同类型的节点,则执行patch
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
} else {
// 如果不是相同类型的节点,则跳出循环
break
}
e1--
e2--
}
}
尾对比是类似于头对比的一种方法,只不过判断指针从i变为了e1和e2。这是因为我们知道一段数组的开头索引必然是0,但我们无法确定它们的结尾索引是否相同。因此,当我们从后往前对比时,不断缩减e1和e2。如果经过isSameVNodeType函数判断为真,那么就执行patch流程;否则,将会跳出循环。需要注意的是,跳出循环的处理方式和头对比相同,所以e1和e2的值此时是尾对比最后一个不符合isSameVNodeType函数的两个vnode的index。
可能有人会问,如果数组节点没有改动,那岂不是要进行两次对比,一次头对比和一次尾对比?实际上,我们不必为此担忧。因为在开始时,有一个判断逻辑,即i必须小于等于e1和e2,否则无法进入尾对比的循环。而在后续逻辑中,也有类似的判断逻辑,只有i小于等于e1或e2才会进入逻辑。因此,即使头对比完整走完流程,由于每次循环都是自增的,最终i也会等于c1.length,因此不会进入尾对比的循环。
挂载新节点
经过头对比和尾对比,此时i和e1、e2都更新为对应的值,那么假如存在这么一个情况
// 更新前
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
</ul>
// 更新后
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="e">e</li>
<li key="c">c</li>
<li key="d">d</li>
</ul>
我们看到,多出来一个e,那么按照之前的逻辑来看,此时i是2,因为i是头对比第一个不符合isSameVNodeType函数的两个vnode的index,旧节点的 <li key="c">c</li>和新节点 <li key="e">e</li>无论如何都是不符合isSameVNodeType的。 然后进行尾对比,e1初始值是3,e2初始值是4。当 <li key="b">b</li> 和 <li key="e">e</li>的时候,不符合isSameVNodeType逻辑,此时e1是1,e2是2。
我们来整理一下经过头对比和尾对比的变量。i是2,e1是1,e2是2。
这种情况是新节点树增加了一个新的节点,因为e2增加了新节点,所以e2 > e1,同时新增的节点最少有一个,所以头对比中,出现类型不相同的index,一定小于等于尾对比中的e2。因为i是新增节点的开始index,e2是新增节点结束的index,头index一定是小于等于结束index。因为e2 - e1 = 新增节点个数,e2 + 1 - i = 新增节点个数 。
所以e1 + 1= i 所以,e1 < i,上文得 i<= e2。
至此,新增节点的判断逻辑就被我们推出来了。我们直接看对应代码。
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // 旧子节点的结束索引
let e2 = l2 - 1 // 新子节点的结束索引
// 挂载新节点
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++
}
}
}
}
正如我们所推论的,当涉及到多余节点时,将遵循此逻辑。我们将根据最后一个符合isSameVNodeType的节点作为锚点,如果没有则以parentAnchor为准。
然后重复新增节点的过程,同时增加i的值,直到i大于e2。我曾经提到过,i是新增节点的开始索引,e2是新增节点的结束索引。
每次成功新增一个节点后,i会再次增加,只有当新增节点的数量大于i时,才会让i超过e2。
因此,这个循环将新增节点重复进行,直到达到所需的数量为止。
卸载旧节点
上文我们说了新增,那么删除连续的节点,会怎么样呢?
先上例子。
// 更新前
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
<li key="e">e</li>
</ul>
// 更新后
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="d">d</li>
<li key="e">e</li>
</ul>
我们删除了节点c,通过头对比,i是2,旧节点<li key="c">c</li>和新节点 <li key="d">d</li>是不同类型。
根据尾对比,e1初始值是4,e2初始值是3。当 <li key="c">c</li> 和 <li key="b">b</li>的时候,不符合isSameVNodeType逻辑,此时e1是2,e2是1。
我们来整理一下经过头对比和尾对比的变量。i是2,e1是2,e2是1。
i为删除节点的起始位置,e1为删除节点的结束位置,因此i <= e1
因为e1 - e2 = 删除节点个数,e1 + 1 - i = 删除节点个数。
所以e2 + 1= i 所以,e2 < i,上文得 i<= e1。
这是删除节点的判断逻辑。我们接下来需要看相应的代码来实现这一逻辑。
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // 旧子节点的结束索引
let e2 = l2 - 1 // 新子节点的结束索引
// 卸载旧节点
else if (i > e2) {
while (i <= e1) {
// 卸载节点
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
}
同新增一样,i直到大于e1都会执行,执行次数为删除节点的个数,而i是开始卸载的节点的index,所以会执行删除节点个数次。
未知顺序
上文中的删除节点和新增节点,都是建立在连续基础上的,那么在不联系的基础上并且存在节点移动的情况呢?也就是说不符合以上判断逻辑的呢?
举个例子,比如这种。
// 更新前
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
<li key="e">e</li>
<li key="f">f</li>
<li key="g">g</li>
<li key="h">h</li>
</ul>
//更新后
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="f">f</li>
<li key="i">i</li>
<li key="c">c</li>
<li key="d">d</li>
<li key="g">g</li>
<li key="h">h</li>
</ul>
不同之处在于我们删除了e节点,替换新增了i节点,然后将f移动到i之前,c和d移动到i之后移动到e移动到c之前,也就是说我们经过了移动、删除、新增。他们背后的实现方式实际上是DOM操作,因此我们需要计算出最小DOM操作。
那么我们如果想要实现更新后的结构,需要进行以下操作
需要删除的节点执行卸载操作
需要新增的节点执行新增操作
节点再旧节点中也在新节点中,执行更新操作
如果节点需要移动,执行最小移动,根据上文例子,需要辨别是i移动到了c之前还是cd移动到i节点后,从优化的角度来说,进行最小位置移动是首选。
我们看一下vue的实现方式。
const s1 = i // 旧子节点的开始索引
const s2 = i // 新子节点的开始索引
// 为新子节点构建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) {
keyToNewIndexMap.set(nextChild.key, i)
}
}
// keyToNewIndexMap {f: 2, i: 3, c: 4, d: 5}
在这里,Vue通过将i保存在两个变量(s1,s2)中,以此作为结构开始出现差异的起始索引。因此,两个节点树各自保存了一个i。然后,它循环遍历新节点树中的不同节点,将它们保存在一个Map结构中,其中key是新节点的key,而值则是索引。这意味着,如果新旧节点类型相同且键值也相同,它们就存在相应的对照关系。
一旦有了新节点的索引图,我们需要遍历旧节点以查找它们在新节点中的位置信息。如果找到了,就进行更新;如果没找到,则进行删除。
注意,下文说的【变化节点】,是新旧节点对比出来不同的节点,也就是通过头对比和尾对比,掐头去尾剩下的节点,新的为新变化节点,旧的为旧变化节点
// 5.2 遍历老的子节点,尝试patch匹配的节点,并移除不再存在的节点
let patched = 0
const toBePatched = e2 - s2 + 1 // 记录没有更新的节点,e2是变化节点的尾index,s2其实是i,所以toBePatched是新变化节点的长度
let moved = false // 用于跟踪节点是否已移动
let maxNewIndexSoFar = 0
// 作为Map<newIndex, oldIndex>使用
// 请注意,oldIndex的值被偏移+1
// 而oldIndex = 0是一个特殊值,表示新节点没有对应的旧节点。
// 用于确定最长递增子序列
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0 // newIndexToOldIndexMap 此时都是 0
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
if (patched >= toBePatched) {
// 所有新子节点都已被patch,所以这只能是一个移除
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// 没有key的节点,尝试找到一个相同类型的没有key的节点
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
newIndex = j
break
}
}
}
if (newIndex === undefined) {
// 未找到匹配的新节点,将旧节点卸载
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
newIndexToOldIndexMap[newIndex - s2] = i + 1
// 如果没有move,newIndex 一直都是 大于等于 maxNewIndexSoFar,newIndex从keyToNewIndexMap获取
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
// 执行patch
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
patched++
}
}
这块逻辑做了什么呢,他这里又定义了一个数组——newIndexToOldIndexMap,个数是新的变化节点的长度,value每一项是0。作用是记录新节点在旧节点的对应关系。
前面我们遍历了新节点,得到了keyToNewIndexMap,这次我们遍历旧节点,如果旧节点不在keyToNewIndexMap之中,那么说明这个旧节点是被删了,执行卸载操作。
如果旧节点在新节点keyToNewIndexMap之中,那么需要根据keyToNewIndexMap获取他的index值,也就是在新节点中的index,然后通过s2获取在newIndexToOldIndexMap的index,将对应的value改为旧节点的index + 1。
newIndex >= maxNewIndexSoFar 这一步判断很巧妙,maxNewIndexSoFar默认是0,本次循环是循环旧的变化节点,所有的index是自增的,而newIndex是根据旧的变化节点获取对应的在新变化节点的index,所以如果没有移动操作的话,newIndex应该也是自增的,经过newIndex >= maxNewIndexSoFar 判断后,newIndex会赋值给maxNewIndexSoFar,所以如果自增的话,newIndexmax一直都会大于等于NewIndexSoFar(也就是上一个newIndex)。
但凡有一个不是,那么会将move设置为true,从而得出这个新节点存在移动情况这个结论。
然后使用patch更新既在旧节点树又在新节点树的节点。
根据以上操作,newIndexToOldIndexMap此时的值是
newIndexToOldIndexMap = [6, 0, 3, 4]
新变化节点中,第一个是f,e在旧节点的index是 5,所以value是5 + 1 = 6
新变化节点中,第二个是i,i不在旧节点树中,所以value默认值是0
新变化节点中,第三个是c,c在旧节点的index是 2,所以value是 2 + 1 = 3
新变化节点中,第四个是d,d在旧节点的index是 3,所以value是 3 + 1 = 4
同时因为存在移动关系,move是true。
接下来我们还有什么操作没有做?移动和新增。
那么我们来看接下里的代码。
let j
// 只有在节点移动时才生成最长递增子序列
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR;
j = increasingNewIndexSequence.length - 1;
// 从后往前循环,以便我们可以使用最后一个patch的节点作为锚点
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i;
const nextChild = c2[nextIndex] as VNode;
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor;
if (newIndexToOldIndexMap[i] === 0) {
// 挂载新节点
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else if (moved) {
// 移动节点条件:
// 没有递增的子序列(例如,反向)
// OR 当前节点不在递增序列中
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER);
} else {
j--;
}
}
}
如果move为true,会获取最长递增子序列,这个我们之后会讲。总之会获取一个最长连续递增的一个序列。比如说[6, 0, 3, 4],最长的就是[0, 3, 4],那么他的序列是[1, 2, 3],但是,因为0是新增节点,因此不能算在旧节点中,所以最长是[3, 4],而序列也就是[2, 3]。
这里格外注意一点,虽然[3, 4]本身是序列,但求的是newIndexToOldIndexMap的序列,所以应该从newIndexToOldIndexMap的序列这个角度来回答。
从尾部遍历新的变化节点,如果发现newIndexToOldIndexMap是0,说当前节点是新增的(说明旧节点找不到对应位置),所以使用patch来新增。
如果存在移动情况,那么就看看当前节点是不是在最长递增子序列里面,如果不在,就移动到锚点前面,否则将最长递增子序列的尾部index向前移动一次。
这里需要分析一下为什么尾部index向前移动一次,也就是j--,有个前提是因为最长递增子序列是递增的,并且是基于newIndexToOldIndexMap得出来的,所以最长递增子序列的value也可以看做是 toBePatched循环的i,当i出现在increasingNewIndexSequence里面一次,就让j自减一次。
这里我们捋一下,我们根据toBePatched得出了newIndexToOldIndexMap这个数组,而最长递增子序列的value是newIndexToOldIndexMap的索引,此时我们正在循环toBePatched,并定义了他的索引i,所以我们可以说i可以和最长递增子序列的value进行比较。此时我们是从后往前循环toBePatched,而j也是最长递增子序列的最后一个value的key,而increasingNewIndexSequence[j]正是最长递增子序列的最后一个value,也是i。
所以上面的逻辑是如果i是最长递增子序列的最后一个value,那么下次就比较最长递增子序列倒数第二个value,当然下一个i也是--。
那么比较到什么时候呢,比较到 j < 0,就是当前指针已经不在最长递增子序列里面了,已经把最长递增子序列比较过去了,最长递增子序列的每个值都被比较过了(上文提到了,j是最长递增子序列的长度,并且每比较一次最长递增子序列,j都自减1),或者i !== increasingNewIndexSequence[j],就是当前 i 还没比较到最长递增子序列那里。
如果是最长递增子序列就不进行移动操作,而是j--。
那么如果不是呢?就调用移动函数,移动到什么地方呢?我们知道移动是根据anchor来移动的,本身move函数就是各种dom操作的封装,所以如果不是最长递增子序列,就将他移动到anchor的前面。
这里还需要注意一下,触发move除了检测数有move行为,还有一个前提是当前节点不在最长递增子序列,但是anchor可以在最长递增子序列里面。
我们看一下anchor怎么来的。
anchor每次循环都会重新计算,而anchor正是当新节点数中,当前处理节点的后一个节点,如果没有就是parentAnchor。
那么为什么anchor是当前处理节点的后一个节点呢?其实原因很简单。当前循环是按照新节点树循环的,新节点树是最终形态,在新节点树中,当前节点就应该是在后一个节点的前面,天经地义的。而后一个节点要么是已经确定的没有变化的节点(被头比较和尾比较去掉的),要么是最长递增子序列不用移动的,也是已经确定的,要么是已经被处理了(anchor每次循环都会重新计算,同时循环是从后往前,所以后面的都是被处理的),甚至即使后面没有节点,也是parentAnchor确定的。
同时在上一节,我们知道vnode没有产生el的能力,el是旧vnode传过来的,因此位置不同的vnode的el是相同的,所以当用move的时候,使用的nextChild是新节点的,虽然反直觉(移动节点应该移动旧节点到新节点)但归根结底使用的el,所以可以直接使用新节点的nextChild,并且新节点的nextChild实际上是新节点vnode,有最新的数据,所以move使用的是新节点的nextChild也可以。
这里可能有些误区,就是最长递增子序列是连续的,实际上最长递增子序列也存在不连续的情况,就拿上文的[6, 0, 3, 4]来说,我们改造一下,[6, 0, 3, 4, 9, 6],最长的就是[3, 4, 6],那么他的序列是[2, 3, 5],也就是i的值是5的时候,当前指针在最长递增子序列里面,j--,但下一轮4就不是了,但anchor是5,当i是3的时候,当前指针又在最长递增子序列里面了,再次j--。
总结一下,在上面的循环中,是按照新节点来循环的,并且倒序循环,如果遇到需要新节点,就创建,遇到最长递增子序列则直接跳过,遇到移动节点,就调用移动函数,并不关心移动节点原来的位置,只需要知道他新的位置就是在当前循环的位置就可以,移动的el是从新节点获取的,因为新节点的数据都是最新的可以一并获取,为什么没有删除?删除在生成keyToNewIndexMap的时候已经被处理了。
最长递增子序列
我们之前一直提到最长递增子序列,那么这个是怎么来的,从源码上来说,是getSequence得出来的,那我们直接看看getSequence的逻辑。
function getSequence(arr: number[]): 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]
// 0 是新增
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
// 二分查找,查找最后一个比 arrI 小的节点
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
}
可以看到,这里使用了贪心算法和二分查找来解决问题,但是不光如此,还加入了回溯,我们先看看前面两个是什么意思
贪心算法,每一步就做出最优解,当遍历结束,得到的就是全局的最优解
二分查找,每次查找都是和区间中间元素对比,根据对比结果不断缩小区间,从而得到结果。(如果区间变为0可就是没找到)
那么我们假设自己实现一下逻辑,贪心算法的逻辑肯定是局部最优解——最长子序列一定要尽可能递增幅度小,因为这样才可以在相同的长度里面,得到最长结果。
首先我们肯定需要一个临时数组,来保存结果,如果当前遍历的元素大于临时数组的最后一个元素(因为是递增的,所以最后一个元素也是最大的),那么就push进去,如果不是,按照贪心算法的逻辑,我们需要找到第一个大于当前遍历的元素的值,然后替换目标,这样保证递增幅度最小,为什么替换第一个大于当前遍历的元素的值就是最小的呢?这与我们的查找方式有关,因为临时数组始终是递增的,我们可以从左往右遍历,这样我们得到的第一个大于当前遍历的元素的值,替换他就可以实现递增幅度小了,因为再往右边的话比他更大,而因为这个i代表的元素存在位置,因此不存在相等的情况,所以替换之后,左边的肯定比当前遍历的元素小,右边得肯定比当前遍历的元素大,而当前元素比替换前的元素还小,因此实现递增幅度最小。
那么我们进一步思考,从左往右比较耗费性能,直接替换成二分查找,但二分查找不是从左往右,所以不能是第一个大于当前遍历的元素的值,而是最后一个大于当前遍历元素的值。
到这一步看似结束了,但实际上我们的逻辑跟需求不符,因为Vue需求是保持单调递增情况下的最长递增子序列,上文的逻辑我们忽略了顺序的问题,粗暴地让数值在临时数组里面进行替换,导致最终的结果对应的元素并非单调递增的。
为了解决这个问题,Vue使用了回溯。
vue不光使用了临时数组result,还是用了一个另一个数组p来保存每个索引的前一个递增子序列的索引。
这里需要注意的这个result保存的并非值,而是值的索引,值在原数组的索引。
举个例子,这里有这个数组,[4, 1, 3, 5, 2, 8, 7, 6, 9, 0],我们一步一步来。
在进入循环之前,p是[4, 1, 3, 5, 2, 8, 7, 6, 9, 0],len是10,result有默认值,所以是[0]
这里需要注意下,入口处排除了arrI是0的情况,因此理论上来讲,进入之后逻辑的arrI都是唯一的,只有0是不唯一,因为0意味着新建节点,但这里不处理0的情况
i = 0 循环,arrI是数组0索引的值,也就是4,j被赋值为result最后一个,也就是j为0,然后数组第i个和数组第j个比大小。按照正常逻辑,如果数组第i个大于临时数组最后一个,索引就会push进临时数组,但是在这里,他们是相同的,也就是不大于,所以进入二分查找。
二分查找之前的准备中 u 被初始化0,也就是开始index。v是临时数组result最后一个index,也就是结束index。
结束index大于开始index的情况才会进入二分循环,因为这里都是0,所以不会进入二分循环。
在这里判断当前元素是否小于暂存里面二分查找最终区间所对应的值,这里需要注意一下,正常的二分最终区间开始和结尾的索引是相同的。
这里都是相同的值,索引不会进入if判断,进入下一次循环。
i = 1 循环,arrI是1,那么进入二分查找逻辑,然后u被初始化为0,v也是0,所以不会进入二分循环。
之后的处理,由于arrI是1,arr[result[u]]也就是result中对应的最终的arr的值是4,所以当前值比暂存值小,为什么是u?因为在二分循环中,u和v是不断的变化的,里面存在一个中间值c,当然,粗暴除以2会存在结果是X.5的情况,这里用 | 0进行向下取整,result[c]就是结果的中间索引——(没错,虽然这里我们定义arr的元素为值,但本身就是一些索引,所以result里面存的是索引的索引,而c是索引的索引的索引)。在二分法中,使用arr[result[c]]和 arrI 来进对比,如果小与arrI,因为result对应的值和arr都是单调递增的,所以左侧没有符合的元素,u会被赋值为c+1,反之v会被赋值为c,从而区间不断变化、缩小,最终弄u 和v 都相同。所以u是最终值,result[u]是最终的arr索引,arr[result[u]]是最终值, 这个值是暂存结果里面,最接近arrI的值!可能比arrI大,也可能比arrI小。总之是最接近arrI的值,因为这里面的元素都是唯一的,所以不存在相等的情况。
如果arrI的确比最终值小,那么需要看看当前最终值是不是暂存结果的第0个,如果是第0个就不在p中记录路径,因为p记录的暂存值被修改前的最后一个暂存元素,第0个没有上一个索引。
因为arrI比最终值小,arrI当前是1,最终值是4,所以索引进行替换,result的[0]变成了[1]。因为最终值是最接近arrI的值,且每个值唯一,所以也不担心替换后,result对应的值失去单调递增的情况。
在二分查找中,如果中间值小于arrI ,那么u会被赋值为c + 1,而右侧没有符合的元素,结束会被赋值为c,这导致我们找的最接近arrI的值是比arrI大的,也就是上文的最后一个大于当前遍历元素的值。
i = 2循环,arrI是3,显而易见,arrI 大于临时数组最后一个对应的值也就是1,所以arrI的索引被push进result中,但在更新之前,p的i索引所在的值,被更改为result最后一个值,也就是1,然后result才push 索引 i,这里需要注意,此时p存的并不止arr的值,还混杂着arr的索引。此时p被当前步骤赋值的元素保存着两个信息,一个信息肯定是他的索引,因为p是arr克隆来的,所以他的索引对应的 arr的索引,也就是i或者result的元素值,另一个信息是元素的值,值是当前索引在result中的前一个的值,由于result的值是arr的索引,也就是i,也就是arr的索引,所以说p存的并不止arr的值,还混杂着arr的索引。此时result是[1,2],此时p是[4, 1, 1, 5, 2, 8, 7, 6, 9, 0],数组中下标为2的值已经被更改为result中,值为2的前一个值。也就是1。
然后continue,进入下次循环
i = 3循环,arrI是5,进入push,此时result是[1, 2, 3],此时p是[4, 1, 1, 2, 2, 8, 7, 6, 9, 0]。然后continue
i = 4循环,arrI是2,比result最后一项小,所以进入二分查找,此时u是1,最终值是3,显而易见需要替换,因为存在上一个值,所以p记录为暂存值被修改前的最后一个暂存元素,由于这次并非新增而是修改,所以是result[u - 1],然后result[u]替换成对应的i索引。此时result是[1, 4, 3],p是[4, 1, 1, 2, 1, 8, 7, 6, 9, 0],我们可以看到数组中下标为4的值已经被更改为result中,值为4的前一个值。也就是1。
i= 5循环,arrI是8,进入push,此时result是 [1, 4, 3, 5],此时p是[4, 1, 1, 2, 1, 3, 7, 6, 9, 0]。然后continue,有人说result对应的值不是单调递增的吗,这里不是了啊?注意,说的是result对应的值单调递增,二分法也是基于result对应的值来二分的,此时result对应的值是[1,2,5,8]单调递增,能用二分法。
i= 6循环,arrI 是7,比result最后一项小,所以进入二分查找,此时u是3,最终值是8,需要替换,然后p对应的值更改为替换值的上一个值,为[4, 1, 1, 2, 1, 3, 3, 6, 9, 0],result为[1, 4, 3, 6]。
i= 7 循环,arrI 是6,比result最后一项小,所以进入二分查找,此时u是3,最终值是7,需要替换,然后p对应的值更改为替换值的上一个值,为[4, 1, 1, 2, 1, 3, 3, 3, 9, 0],result为[1, 4, 3, 7]。
i= 8 循环,arrI是9,进入push,此时result是 [1, 4, 3, 7, 8],此时p是[4, 1, 1, 2, 1, 3, 3, 3, 7, 0]。然后continue
i= 9 循环,arrI是0,不处理,结束循环。
但还没结束,因为以上的逻辑只是我们说的贪心算法和二分查找的实现,p还没有起到作用,接下来是回溯。
首先获取result的长度,然后获取最后result最后一个值v。在这里长度是5,v是8
循环u次,将result从后往前赋值,因为result实际上并非我们想要的结果,我们想要的结果里面,只有最后一个值,和result的长度是准的,因此我们需要利用p回溯回去。
u为4的时候,result为[1, 4, 3, 7, 8],v 被p赋值为7
u为3的时候,result为[1, 4, 3, 7, 8],v 被p赋值为3
u为2的时候,result为[1, 4, 3, 7, 8],v 被p赋值为2
u为1的时候,result为[1, 2, 3, 7, 8],v 被p赋值为1
u为0的时候,result为[1, 2, 3, 7, 8],v 被p赋值为1,但无效赋值
得出最终结果 [1, 2, 3, 7, 8]
可能大家对p的作用不太理解,首先明确一个观点——贪心算法是局部最优力图全局最优。
而实际上p的构造过程,也是基于这个思想的,我们在第20步发现p本身不是递增的,甚至在某些情况下,为了得到非递增的结果,我们可能需要在第16步对已构造的p元素进行修改。
实际上,在构造p的时候,每一个元素的索引总是大于或等于每一个元素的值,这个就决定了最后回溯p的时候v = p[v],v越来越小,因为我们是从后往前回溯,所以result单调递增。
那么,为什么在构造p的时候,我们可以保证每一个元素的索引总是大于或等于每一个元素的值呢?因为p最早是第二次循环才构造,第一次循环因为result没有前一个值所以不会构造p,当第二个循环构造p的时候,p的值是上次循环中result最后的值,也是上一个i,也就是0,此时i= 1,也就是i > result[result.length - 1] = j = p[i],也就是说i > p[i],而当被修改的时候,也是在当前i进行构造,也就是说当前i > result最后的值 >= result[u-1] = p[i],也就是说i>p[i]
无论从哪个角度来看,p的索引都会比p索引对应的数值更大,而这个索引对应的数值,恰好是下一轮循环中p的索引值。
从而保证从后往前回溯过程中,p[v]越来越小,而p[v]就是result的元素,从而保证result单调递增。
有人会问,回溯会不会影响结果的长度?答案是不会的。在构造路径p时,会影响结果长度的行为只有一个——push,而另一个操作替换,则存在两种情况:
替换结尾,替换结尾即替换result[result.length - 1],然后构造新的p元素,都会指向最新的结尾(请注意,p元素的值是当前推向result的元素的前一个的值)。虽然这会影响result的长度,但是在回溯的时候也会回溯到被替换的结尾。举个例子,[1, 5, 3, 4, 9],在遍历到3的时候,会将5的index[1]替换成3的index[2],但他们的p都指向1的index[0]。然而,到了4的时候,4的index[3]构造出来的p指向的是3的index[2],从而得到[0, 2, 3, 4],也就是[1, 3, 4, 9]这个结果。
替换中间,这种情况并不会影响result的长度,只是追求了局部最优解。如果不进行替换,只是没有了局部最优解,但result的长度并没有改变。例如[1, 5, 9, 4],在遍历到9的时候,结果就是[0, 1, 2]。但是局部最优解会在遍历到4的时候将5的index[1]替换成4的index[3],此时结果变成了[0, 3, 2]。尽管如此,在回溯的时候,9构造出来的p依然指向5的index[1],而5构造出来的p依然在里面保留,指向1的index[0],所以回溯的结果还是[0, 1, 2]。