樹上的路徑問題很難直接用陣列或線段樹處理;重心分解把「隨便兩點之間的路徑」變成「一定經過某個特定節點的路徑」。

樹的重心

移除某個節點後,剩下的每個連通子樹大小都 ≤ n/2——這個節點就是樹的重心。

     1
    / \
   2   3
  / \   \
 4   5   6
 
n = 6,移除節點 1:
  子樹 {2,4,5}(大小 3)、{3,6}(大小 2)
  最大子樹 3 ≤ 6/2 = 3 ✓ → 節點 1 是重心

重心的保證:每棵樹至少有一個重心,且找重心只需 O(n)(先算子樹大小,找滿足條件的節點)。

分解過程

1. 找當前樹的重心 c
2. 統計「所有經過 c 的路徑」
3. 移除 c(標記 removed[c] = true)
4. 對剩下的每個子樹遞迴重複
 
遞迴深度:每次移除重心後,每個子樹大小 ≤ n/2
→ 至多 log n 層,每層 O(n),總 O(n log n)

路徑統計(長度 ≤ k 的路徑數量)

long countPaths(int root, int k) {
    int c = findCentroid(root, subtreeSize[root]);
    removed[c] = true;
 
    List<Long> allDist = new ArrayList<>();
    allDist.add(0L); // 從 c 出發的路徑(c 本身)
 
    long count = 0;
    for (int child : adj[c]) {
        if (removed[child]) continue;
        List<Long> childDist = getDistances(child, c, 1);
 
        // 統計「一端在 child 子樹,一端在已處理子樹」且距離 ≤ k 的路徑
        count += countPairs(allDist, childDist, k);
        allDist.addAll(childDist);
    }
 
    // 遞迴處理每個子樹
    for (int child : adj[c])
        if (!removed[child])
            count += countPaths(child, k);
 
    return count;
}

⚠️ 上面 findCentroid(root, subtreeSize[root]) 為了精簡直接沿用了一開始算好的 subtreeSize 陣列,但實務上不能這樣:移除重心後各子樹的大小會整個改變,每一層遞迴都要重算 subtree size(進 countPaths 先跑一次 DFS 重算當前子樹的大小),拿舊的整棵樹的 size 去找重心會找錯。

countPairs(A, B, k):統計 a∈A, b∈B 且 a+b ≤ k 的對數。排序 A,對 B 中每個 b 二分搜尋,O((|A|+|B|) log n)。

容斥補正:直接統計 allDist 會算到「兩端都在同一個子樹」的路徑,通過分批統計(先統計、再加入 childDist)避免重複。

重心分解樹(Centroid Tree)

把每個重心作為父節點,子節點是它移除後各子樹的重心,形成「重心分解樹」。

重心分解樹高度 O(log n),任意節點到根路徑上的節點集合代表「原樹中包含這個節點的所有重心」——對每個重心做統計,等效於覆蓋所有路徑。

這棵樹還能拿來查距離。任兩點 u、v 在重心分解樹上的最近公共祖先,就是原樹上「u→v 路徑一定會經過」的那一層重心,於是:

dist(u, v) = dist(u, 最近公共重心) + dist(v, 最近公共重心)

預處理各層重心到子樹各點的距離後,單次距離查詢降到 O(log n)——重心分解樹的高度只有 log n,是這一切能成立的關鍵。

複雜度

操作複雜度
建重心分解樹O(n log n)
路徑計數(長度 ≤ k)O(n log n)
距離查詢(預處理後)O(log n)

🎬 互動視覺化樹分解兩招並排看 — 一層層剝掉重心、看子樹如何減半,順便和樹鏈剖分的「切鏈」對照,兩種拆樹思路差在哪一目了然。


重心分解的直覺是:任何兩點路徑都必須經過某一層的重心——找到這一層,問題就分解了。

接下來往哪走