通俗的聊聊vue3 diff 算法中的LIS

536 阅读4分钟

规律是创造与发现的,记忆是规律的推导与提取

最近使用vue3开发组件的过程中遇到了一些问题,去瞅了眼源码,顺便又看了一下diff算法,看到了LIS——最长递增子串的算法。看了两天,记录一下自己的理解过程。

vue3使用了贪心算法+二分算法,相较于动态规划的O(n2)的时间复杂度,vue3的时间复杂度为O(nlogn),性能会更好一点。看源码很容易理解,patch过程中实际上新节点和旧节点的很多情况都已经处理了:

  1. 同步头部节点
    // (a b) c
    // (a b) d e
  1. 同步尾部节点
   // a (b c)
    // d e (b c)
  1. 添加新的节点
  // (a b)
    // (a b) c
    // 新串结尾多了新节点
    // (a b)
    // c (a b)
    // 新串开头多了新节点
  1. 移除旧的节点
  // (a b) c
    // (a b)
    // 旧串结尾需要移除的节点
    // a (b c)
    // (b c)
    //旧串开头需要移除的节点
  1. 处理未知子序列
    // [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的算法的关键有以下几点:

  1. 贪心,不断找到差值最小的串,实际上只需要相同长度 结尾的点最小就行
  2. 在长度增长的时候,保存增长前的索引序列的结尾节点,即增长节点的前驱节点
  3. 在贪心替换的过程中,存储递增索引序列结尾节点的前驱节点的索引
  4. 遍历完成找到的是最小结尾节点索引组成的索引序列
  5. 对结尾节点的索引序列进行倒序遍历,从n-2(n-1为序列的尾结点)的位置开始替换为对应的前驱节点
  6. 得到正确的LIS
  7. 如果只求长度 倒序遍历这个就不需要 前驱节点数组也不需要