注:本文采用vue版本为3.3.4
由于diff
算法在patchKeyedChildren
函数中进行应用,因此我们需要重点关注这个函数的逻辑来了解整个diff过程。当然,为了避免阅读的混乱,我们将这个函数的内容分块进行详细的分析和理解。
头对比
typescript
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
新的子节点的尾部标记,或者说新的子节点的尾部的ndex
i
是当前指针的标记,因为他会在结束时刻自增一次,而非下次循环的时候自增。
这段代码的核心思想是进行新旧比较,针对相同索引的两个节点,判断它们是否属于同一类型的节点。这一判断依据是isSameVNodeType
函数,该函数的本质,判断两个vnode
的type
和key
是否相同。
如果两个节点的类型相同,那么当前节点将进入递归的patch
流程。然而,只要其中一个节点不符合条件(即类型不同或键值对不匹配),就会立即退出循环。 需要注意的是,每次循环结束后,索引i
不会自增,而是保持在上一次循环中获得的值。
因此,在退出循环时,i
将指向第一个不满足isSameVNodeType
函数的节点的索引。这个索引是在上一个循环中i
自增后得到的。
尾对比
退出头对比,并不会结束函数,会进行另一个对比逻辑。尾对比,我们看下一段逻辑。
typescript
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都更新为对应的值,那么假如存在这么一个情况
html
// 更新前
<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
,
新增节点个数 >= 1
, 所以上文得 i <= e2
。
至此,新增节点的判断逻辑就被我们推出来了。我们直接看对应代码。
typescript
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
。
因此,这个循环将新增节点重复进行,直到达到所需的数量为止。
卸载旧节点
上文我们说了新增,那么删除连续的节点,会怎么样呢?
先上例子。
html
// 更新前
<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
。
删除节点个数 >= 1
, 所以上文得 i <= e1
。
这是删除节点的判断逻辑。我们接下来需要看相应的代码来实现这一逻辑。
typescript
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
,所以会执行删除节点个数次。
未知顺序
上文中的删除节点和新增节点,都是建立在连续基础上的,那么在不连续的基础上并且存在节点移动的情况呢?
也就是说不符合以上判断逻辑的呢?
举个例子,比如这种。
html
// 更新前
<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
节点后,从优化的角度来说,进行最小位置移动是首选。
我们看一下vue3
的实现方式。
typescript
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
,而值则是索引。这意味着,如果新旧节点类型相同且键值也相同,它们就存在相应的对照关系。
一旦有了新节点的索引图,我们需要遍历旧节点以查找它们在新节点中的位置信息。如果找到了,就进行更新。如果没找到,则进行删除。
注意,下文说的【变化节点】,是新旧节点对比出来不同的节点,也就是通过头对比和尾对比,掐头去尾剩下的节点,新的为新变化节点,旧的为旧变化节点
typescript
// 遍历老的子节点,尝试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
此时的值是
typescript
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
。
接下来我们还有什么操作没有做?移动和新增。
那么我们来看接下里的代码。
typescript
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
的逻辑。
typescript
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
的确比最终值小,那么需要看看当前最终值是不是暂存结果的第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]
。