Vue3 DOM Diff 核心演算法解析

童歐巴發表於2020-10-13

image

觀感度:?????

口味:辣炒花蛤

烹飪時間:10min

本文已收錄在前端食堂同名倉庫Github github.com/Geekhyt,歡迎光臨食堂,如果覺得酒菜還算可口,賞個 Star 對食堂老闆來說是莫大的鼓勵。

想要搞明白 Vue3 的 DOM Diff 核心演算法,我們要從一道 LeetCode 真題說起。

我們先來一起讀讀題:

LeetCode 真題 300. 最長上升子序列

給定一個無序的整數陣列,找到其中最長上升子序列的長度。

示例:

輸入: [10,9,2,5,3,7,101,18]
輸出: 4
解釋: 最長的上升子序列是 [2,3,7,101],它的長度是 4。

說明:

  • 可能會有多種最長上升子序列的組合,你只需要輸出對應的長度即可。
  • 你演算法的時間複雜度應該為 O(n2) 。

進階: 你能將演算法的時間複雜度降低到 O(nlogn) 嗎?

讀題結束。

什麼是上升子序列?

首先,我們需要對基本的概念進行了解和區分:

  • 子串:一定是連續的
  • 子序列:子序列不要求連續 例如:[6, 9, 12] 是 [1, 3, 6, 8, 9, 10, 12] 的一個子序列
  • 上升/遞增子序列:一定是嚴格上升/遞增的子序列

注意:子序列中元素的相對順序必須保持在原始陣列中的相對順序

題解

動態規劃

關於動態規劃的思想,還不瞭解的同學們可以移步我的這篇專欄入個門,「演算法思想」分治、動態規劃、回溯、貪心一鍋燉

我們可以將狀態 dp[i] 定義為以 nums[i] 這個數結尾(一定包括 nums[i])的最長遞增子序列的長度,並將 dp[i] 初始化為 1,因為每個元素都是一個單獨的子序列。

定義狀態轉移方程:

  • 當我們遍歷 nums[i] 時,需要同時對比已經遍歷過的 nums[j]
  • 如果 nums[i] > nums[j]nums[i] 就可以加入到序列 nums[j] 的最後,長度就是 dp[j] + 1

注:(0 <= j < i) (nums[j] < nums[i])

const lengthOfLIS = function(nums) {
    let n = nums.length;
    if (n == 0) {
        return 0;
    }
    let dp = new Array(n).fill(1);
    for (let i = 0; i < n; i++) {
        for (let j = 0; j < i; j++) {
            if (nums[j] < nums[i]) {
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }
    }
    return Math.max(...dp) 
}
  • 時間複雜度:O(n^2)
  • 空間複雜度:O(n)

這裡我畫了一張圖,便於你理解。

貪心 + 二分查詢

關於貪心和二分查詢還不瞭解的同學們可以移步我的這兩篇專欄入個門。

這裡再結合本題理解一下貪心思想,同樣是長度為 2 的序列,[1,2] 一定比 [1,4] 好,因為它更有潛力。換句話說,我們想要組成最長的遞增子序列,
就要讓這個子序列中上升的儘可能的慢,這樣才能更長。

我們可以建立一個 tails 陣列,用來儲存最長遞增子序列,如果當前遍歷的 nums[i] 大於 tails 的最後一個元素(也就是 tails 中的最大值)時,我們將其追加到後面即可。否則的話,我們就查詢 tails 中第一個大於 nums[i] 的數並替換它。因為是單調遞增的序列,我們可以使用二分查詢,將時間複雜度降低到 O(logn)

const lengthOfLIS = function(nums) {
    let len = nums.length;
    if (len <= 1) {
        return len;
    }
    let tails = [nums[0]];
    for (let i = 0; i < len; i++) {
        // 當前遍歷元素 nums[i] 大於 前一個遞增子序列的 尾元素時,追加到後面即可
        if (nums[i] > tails[tails.length - 1]) {
            tails.push(nums[i]);
        } else {
            // 否則,查詢遞增子序列中第一個大於當前值的元素,用當前遍歷元素 nums[i] 替換它
            // 遞增序列,可以使用二分查詢
            let left = 0;
            let right = tails.length - 1;
            while (left < right) {
                let mid = (left + right) >> 1;
                if (tails[mid] < nums[i]) {
                    left = mid + 1;
                } else {
                    right = mid;
                }
            }
            tails[left] = nums[i];
        }
    }
    return tails.length;
};
  • 時間複雜度:O(nlogn)
  • 空間複雜度:O(n)

這裡我畫了一張圖,便於你理解。

注意:這種方式被替換後組成的新陣列不一定是解法一中正確結果的陣列,但長度是一樣的,不影響我們對此題求解。

比如這種情況:[1,4,5,2,3,7,0]

  • tails = [1]
  • tails = [1,4]
  • tails = [1,4,5]
  • tails = [1,2,5]
  • tails = [1,2,3]
  • tails = [1,2,3,7]
  • tails = [0,2,3,7]

我們可以看到最後 tails 的長度是正確的,但是裡面的值不正確,因為最後一步的替換破壞了子序列的性質。

Vue3 DOM Diff 核心演算法

搞清楚了最長遞增子序列這道演算法題,我們再來看 Vue3 的 DOM Diff 核心演算法就簡單的多了。

我知道你已經迫不及待了,但是這裡還是要插一句,如果你還不瞭解 React 以及 Vue2 的 DOM Diff 演算法可以移步這個連結進行學習,循序漸進的學習可以讓你更好的理解。

回來後我們思考一個問題:Diff 演算法的目的是什麼?

為了減少 DOM 操作的效能開銷,我們要儘可能的複用 DOM 元素。所以我們需要判斷出是否有節點需要移動,應該如何移動以及找出那些需要被新增或刪除的節點。

好了,進入本文的正題,Vue3 DOM Diff 核心演算法。

首先我們要搞清楚,核心演算法的的位置。核心演算法其實是當新舊 children 都是多個子節點的時候才會觸發。

下面這段程式碼就是 Vue3 的 DOM Diff 核心演算法,我加上了在原始碼中的路徑,方便你查詢。

// packages/runtime-core/src/renderer.ts
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]
    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
      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]
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}

getSequence 的作用就是找到那些不需要移動的元素,在遍歷的過程中,我們可以直接跳過不進行其他操作。

其實這個演算法的核心思想就是我們上面講到的求解最長遞增子序列的第二種解法,貪心 + 二分查詢法。這也是為什麼不著急說它的原因,因為如果你看懂了上面的 LeetCode 題解,你就已經掌握了 Vue3DOM Diff 核心演算法的思想啦。

不過,想要搞懂每一行程式碼的細節,還需放到 Vue3 整個 DOM Diff 的上下文中去才行。而且需要注意的是,上面程式碼中的 getSequence 方法的返回值與 LeetCode 題中所要求的返回值不同,getSequence 返回的是最長遞增子序列的索引。上文我們曾提到過,使用貪心 + 二分查詢替換的方式存在一些 Bug,可能會導致結果不正確。Vue3 把這個問題解決掉了,下面我們來一起看一下它是如何解決的。

// packages/runtime-core/src/renderer.ts
function getSequence(arr: number[]): number[] {
  const p = arr.slice() // 拷貝一個陣列 p
  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 // 最後一項與 p 對應的索引進行對應
        result.push(i)
        continue
      }
      // arrI 比 arr[j] 小,使用二分查詢找到後替換它
      // 定義二分查詢區間
      u = 0
      v = result.length - 1
      // 開啟二分查詢
      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 // 有可能替換會導致結果不正確,需要一個新陣列 p 記錄正確的結果
      }
    }
  }
  u = result.length
  v = result[u - 1]
  // 倒敘回溯 用 p 覆蓋 result 進而找到最終正確的索引
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}

Vue3 通過拷貝一個陣列,用來儲存正確的結果,然後通過回溯賦值的方式解決了貪心 + 二分查詢替換方式可能造成的值不正確的問題。

以上就是 Vue3 DOM Diff 的核心演算法部分啦,歡迎光臨前端食堂,客官您慢走~

❤️愛心三連擊

1.如果你覺得食堂酒菜還合胃口,就點個贊支援下吧,你的是我最大的動力。

2.關注公眾號前端食堂,吃好每一頓飯!

3.點贊、評論、轉發 === 催更!

相關文章