规律是创造与发现的,记忆是规律的推导与提取
最近使用vue3开发组件的过程中遇到了一些问题,去瞅了眼源码,顺便又看了一下diff算法,看到了LIS——最长递增子串的算法。看了两天,记录一下自己的理解过程。
vue3使用了贪心算法+二分算法,相较于动态规划的O(n2)的时间复杂度,vue3的时间复杂度为O(nlogn),性能会更好一点。看源码很容易理解,patch过程中实际上新节点和旧节点的很多情况都已经处理了:
- 同步头部节点
// (a b) c
// (a b) d e
- 同步尾部节点
// a (b c)
// d e (b c)
- 添加新的节点
// (a b)
// (a b) c
// 新串结尾多了新节点
// (a b)
// c (a b)
// 新串开头多了新节点
- 移除旧的节点
// (a b) c
// (a b)
// 旧串结尾需要移除的节点
// a (b c)
// (b c)
//旧串开头需要移除的节点
- 处理未知子序列
// [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
//未知子序列剩余需要进行diff比对的为
// c d e
// e d c h
5.1. 为新序列剩余的子序列(步骤1-4处理后的新子序列 e b c h)建立 key:index 的map ,这样在遍历旧子序列(步骤1-4处理后的旧子序列 c d e)的时候可以根据旧子序列节点的key查找节点在新子序列中的index
5.2. 遍历旧子序列, 根据上一步骤的map进行patch和删除,如果旧子节点的key存在于map则进行patch,不存在则进行删除;同时根据旧节点在map中对应的新节点是否存在非递增节点(即key对应的index如果是非递增 则说明节点移动了)来判断是否需要进行“节点移动更新”,最终保存一个新节点对应的旧节点的index数组 (旧节点的index组成的数组)这一步实际上旧的节点已经被patch为新的节点,只是顺序和新序列的不一致
5.3. 这一步主要取决于上一步是否需要进行节点移动更新,主要是节点移动。这里的移动主要就是考虑如何使用最小代价移动,很明显我们找到的新子序列中最长递增子串LIS,这个LIS和旧子序列相交,那么旧子序列中需要移动的节点就达到最少。
通俗直观一点讲就是在操场上列队,需要移动几个人,那么剩下的几个就保持顺序不变,把需要移动的这几个人塞到这个剩余的队列中就行,有点像插入排序的操作。
在找到LIS之后,倒序遍历新子序列,如果子节点不在旧子序列中,那么添加这个新节点,否则判断是否节点在LIS中,如果在的话不做任何操作,如果不存在则移动到下一个节点(倒序遍历的前一个节点)的前面即可,这里移动实际上是在操作旧子序列,因为新节点已经在上一步被patch了。
最后,贪心算法+二分算法的LIS,有一个明显的问题就是在贪心替换的时候,会把已经正确的串里面的节点换成错误的节点,但是替换不会改变正确LIS的长度,而改变LIS长度的节点肯定是正确的,且改变长度的正确节点的前驱节点(即LIS的尾结点)一定是正确的,这一点保证了我们可以找到最终正确LIS,因此我们找一个序列单独记录正确节点的前驱节点,那么等我们遍历完整个序列,再根据前驱节点序列倒序回溯即可找到正确的LIS,具体看代码:
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++) {
// [9,5,2,3,6,7,8,10,1,4]
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
// 存储在 result 更新前的最后一个索引的值 这里一定是正确节点
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
// 二分搜索,查找比 arrI 小的节点,更新 result 的值
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[i] = result[u - 1];
}
result[u] = i;
}
}
}
u = result.length;
v = result[u - 1];
// 回溯数组 p,找到最终的索引
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
综上 vue3中LIS的算法的关键有以下几点:
- 贪心,不断找到差值最小的串,实际上只需要相同长度 结尾的点最小就行
- 在长度增长的时候,保存增长前的索引序列的结尾节点,即增长节点的前驱节点
- 在贪心替换的过程中,存储递增索引序列结尾节点的前驱节点的索引
- 遍历完成找到的是最小结尾节点索引组成的索引序列
- 对结尾节点的索引序列进行倒序遍历,从n-2(n-1为序列的尾结点)的位置开始替换为对应的前驱节点
- 得到正确的LIS
- 如果只求长度 倒序遍历这个就不需要 前驱节点数组也不需要