Centroid Decomposition — Professional Level¶
One-line summary: A rigorous treatment of the centroid: existence and the
≤ N/2bound (Jordan), theO(log N)centroid-tree height as a direct consequence of strict halving, the path-decomposition theorem (everyu–vpath crosses the centroid-tree LCA ofu, vexactly once), and theO(N log N)build bound from the fact that each vertex appears inO(log N)levels.
Table of Contents¶
- Formal Definition
- Existence and the ≤ N/2 Property
- O(log N) Centroid-Tree Depth
- The Path-Decomposition Theorem
- Build Complexity O(N log N)
- Cache Behavior
- Average-Case
- Space-Time Trade-offs
- Worked Decomposition Trace
- Reference Implementations
- Comparison
- Open Problems
- Summary
1. Formal Definition¶
Let T = (V, E) be a tree with |V| = N. For a vertex v, removing v yields a forest T − v whose components we denote C₁(v), …, C_{deg(v)}(v).
Definition (centroid). A vertex c ∈ V is a centroid of T if
w(v) = max_i |Cᵢ(v)|. A centroid is a vertex minimizing w. (The classical statement uses ⌊N/2⌋; some texts use N/2 with real division — they coincide because component sizes are integers and the bound is tight only when N is even.) Definition (centroid decomposition). Recursively: let c be a centroid of T. The centroid tree CT(T) is the tree rooted at c whose subtrees are CT(C₁(c)), …, CT(C_k(c)), with each child centroid linked to c as its parent.
Definition (centroid level). level(v) is the depth of v in CT(T) (root at depth 0). Equivalently, the number of decomposition steps before v itself becomes a centroid.
We root T arbitrarily to compute subtree sizes; the centroid notion is root-independent (it depends only on T, not on the chosen root).
2. Existence and the ≤ N/2 Property¶
Theorem 1 (Jordan, 1869). Every tree T with N ≥ 1 vertices has at least one centroid, and at most two. If two centroids exist, they are adjacent.
Proof of existence and the bound. Root T at an arbitrary vertex r and compute subtree sizes s(v). Start at r and repeatedly move to the child u whose subtree is strictly largest whenever s(u) > ⌊N/2⌋. We claim this process halts at a centroid.
Consider the vertex c where it halts: every child u of c has s(u) ≤ ⌊N/2⌋. The component of T − c going "upward" (toward the root) has size N − s(c). We must show N − s(c) ≤ ⌊N/2⌋.
The process moved into c because, at c's parent p, the subtree s(c) > ⌊N/2⌋ (that is the only reason we descended into c). Hence s(c) > ⌊N/2⌋ ≥ N/2, so N − s(c) < N/2 ≤ ⌊N/2⌋ + 1, giving N − s(c) ≤ ⌊N/2⌋. Combined with every child component ≤ ⌊N/2⌋, all components of T − c are ≤ ⌊N/2⌋. Thus c is a centroid. ∎
Proof of "at most two, and adjacent." Suppose c₁ and c₂ are both centroids. Orient the edge between adjacent vertices toward the larger side. For any vertex v, the unique edge incident to v lying on the path toward the heavy side points "down" the weight gradient. A centroid is a local minimum of w. One shows w is "unimodal" along any path: it strictly decreases toward a centroid and strictly increases away from it. Two distinct local minima of a unimodal function on a path tree can only be adjacent (where the function value ties). If N is odd there is a unique centroid; if N is even there may be two adjacent centroids, each leaving one component of exactly N/2 on the side of the other. A full unimodality argument: for adjacent u, v, w(u) − w(v) changes sign by exactly the edge orientation, so at most one adjacent pair can tie at the minimum. ∎
Corollary. The centroid leaves every component with ≤ ⌊N/2⌋ vertices — this halving is what drives all subsequent complexity bounds.
3. O(log N) Centroid-Tree Depth¶
Theorem 2. The height of CT(T) is at most ⌊log₂ N⌋ + 1, i.e. O(log N).
Proof. Let v be any vertex and let N₀ > N₁ > … > N_{L} be the sizes of the nested sequence of components that contain v at successive decomposition levels, where N₀ = N and v becomes a centroid at level L (so N_L = 1 at the moment v is chosen, or v is in the size-1 residual). By Theorem 1's bound, at each step the component containing v is one of the ≤ ⌊Nᵢ/2⌋ pieces, hence
N_i ≤ N / 2ⁱ. Since N_L ≥ 1, we need N / 2^L ≥ 1, i.e. L ≤ log₂ N. Counting the root level, level(v) = L ≤ ⌊log₂ N⌋, and the height is ≤ ⌊log₂ N⌋ + 1. ∎ Corollary (per-vertex level membership). Each vertex belongs to exactly level(v) + 1 ≤ ⌊log₂ N⌋ + 1 = O(log N) centroid components — one per level it survives. This is the quantity that bounds total work.
4. The Path-Decomposition Theorem¶
This is the structural theorem that makes centroid decomposition useful.
Theorem 3 (Path–LCA). For any two vertices u, v ∈ V, let c = LCA_{CT}(u, v) be their lowest common ancestor in the centroid tree. Then:
clies on the unique tree pathP(u, v)inT.cis the unique vertex onP(u, v)that is a centroid-tree ancestor of bothuandv.- Equivalently,
cis the highest-level (closest-to-root) centroid lying onP(u, v).
Proof. (Existence / part 1.) Consider the decomposition levels. Let c be the first centroid (smallest level) whose removal separates u from v — i.e. the first level at which u and v cease to be in the same component (this includes the case where the chosen centroid is u or v). Before that level, u and v are in a common component C, so the whole path P(u, v) lies in C (a component is connected and a tree path between two of its vertices stays inside it). At the level where c is the centroid of C, removing c splits u and v apart, which forces c ∈ P(u, v) (the only way deleting a single vertex disconnects two vertices in a tree is if that vertex lies on the path between them). So c lies on P(u, v).
(c is the centroid-tree LCA / part 2.) At every level < level(c), u and v share a component, hence share the same centroid ancestor at that level — these are the common ancestors of u and v in CT. At level level(c), the centroid c is still a common ancestor (both u and v are in C, whose centroid is c). At level > level(c), u and v are in different components, so they have no further common ancestor. Thus c is the deepest common ancestor — LCA_{CT}(u, v).
(Uniqueness / part 3.) Suppose another centroid c' ≠ c on P(u, v) were also a common centroid-tree ancestor of both u and v. A centroid-tree ancestor of u is, by construction, a vertex whose component (at its level) contains u. If c' is an ancestor of both, then at level level(c') both u, v lie in c''s component, so they were not yet separated — meaning level(c') < level(c) or c' = c. But then c' is an ancestor of c in CT; an ancestor of the LCA that also lies on P(u,v) cannot be a common descendant-side centroid on the path without coinciding with c. Removing c already separated u, v; any higher centroid c' on the path is an ancestor of c, and on the path the centroids form a chain ordered by level, with exactly one of them — c — being the separating one. Hence c is the unique such vertex. ∎
Algorithmic consequence (no double counting). Define, for each unordered pair {u, v}, the "owner" centroid own(u, v) = LCA_{CT}(u, v). The map own partitions all C(N, 2) pairs (and hence all simple paths) by owner. Therefore
c that lie in c's component, with the two endpoints in different child branches (or one endpoint = c)" is precisely the set of paths owned by c. The inclusion–exclusion subtraction (whole component minus each branch) computes exactly this set, which is why it neither misses nor double-counts. 5. Build Complexity O(N log N)¶
Theorem 4. The centroid tree can be built in O(N log N) time and O(N) space.
Proof. Finding the centroid of a component of size n costs O(n): one DFS to compute residual subtree sizes, one descent to the centroid. Removing it and recursing partitions the component into pieces of total size n − 1 < n.
Charge the O(n) cost of processing a component to the vertices in that component: each vertex in a size-n component pays O(1). By the Corollary to Theorem 2, a fixed vertex v is a member of exactly level(v) + 1 = O(log N) components across the whole decomposition. Hence the total charge to v over all levels is O(log N), and summing over all vertices,
O(N) for adj, size, removed, cparent; the recursion stack is O(height of size-DFS), which is O(N) in the worst case (a path tree) unless an explicit-stack iterative DFS bounds it. The output (centroid tree) is O(N) edges. ∎ Tightness. The bound is tight: on a balanced tree each of the log N levels does Θ(N) aggregate work, giving Θ(N log N). Lower bound: any algorithm that, for each level, recomputes sizes over the residual tree must touch each surviving vertex once per level, and there are Θ(N log N) (vertex, level) incidences on a balanced tree.
Note on weighted variants and storage. If, per centroid, we store the distance of every component vertex (for radius/count queries), we materialize Σ_v O(log N) = O(N log N) distance entries, matching the time bound and giving O(N log N) space for that augmented index.
6. Cache Behavior¶
- The build is DFS-bound: subtree-size and centroid-finding passes traverse adjacency lists. With CSR (compressed-sparse-row) adjacency, each level streams the residual edges; cache behavior is that of a tree DFS — poor temporal locality across distant vertices but good when sibling edges are contiguous.
- The
removed[]guard introduces a branch per neighbor; on skewed trees the branch is predictable (mostly "not removed" early, mostly "removed" late), which is friendly to branch predictors. - The augmented per-centroid sorted distance arrays are scan-friendly: queries binary-search contiguous arrays, giving good locality for the radius-count pattern.
- Storing
cparent[]as a flatintarray makes theO(log N)ancestor walk a pointer-chase of length≤ height; for hot queries this is a handful of cache lines.
7. Average-Case¶
- On a uniformly random labeled tree (Cayley), the expected height is still
O(log N)for the centroid tree (the≤ N/2halving is worst-case, not average — the centroid tree height isO(log N)always). What varies by input is the constant: balanced inputs reach the⌊log₂ N⌋bound, while bushy random trees often have slightly smaller centroid-tree height. - Average per-component fan-out equals the average degree of centroids, which is
< 2amortized (the centroid tree hasN − 1edges overNnodes like any tree). - For counting applications, the distribution of distances per centroid governs the constant in
O(N log N)vsO(N log² N): when distances are bounded by a small diameter, frequency-array counting beats sorting.
8. Space-Time Trade-offs¶
| Variant | Time | Space | Supports |
|---|---|---|---|
Plain CT (cparent[] only) | build O(N log N) | O(N) | static counting via on-the-fly DFS; ancestor walks |
| CT + per-vertex ancestor distances | build O(N log N) | O(N log N) | O(log N) distance to any ancestor; nearest-marked |
| CT + per-centroid sorted distance arrays | build O(N log N) | O(N log N) | O(log² N) radius/count queries |
| CT + per-centroid Fenwick over distance | build O(N log N) | O(N log N) | dynamic count-within-radius, O(log² N) update |
The recurring trade is memory O(N) vs O(N log N): materializing per-level distance data costs the extra log N factor in space but unlocks O(log² N) queries/updates without per-query DFS.
9. Worked Decomposition Trace¶
We make every claim above concrete on a fixed 9-vertex tree. Vertices 0..8, edges:
9.1 The input tree (rooted at 0 for drawing)¶
N = 9, so ⌊N/2⌋ = 4. A centroid must leave every component with ≤ 4 vertices. 9.2 Level 0 — find the centroid of the whole tree¶
Root at 0, compute subtree sizes s(v):
| v | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| s(v) | 9 | 8 | 1 | 6 | 1 | 4 | 1 | 2 | 1 |
Descend from 0: child 1 has s=8 > 4 → move to 1. At 1, child 3 has s=6 > 4 → move to 3. At 3, children are 4 (s=1), 5 (s=4); none exceed 4. The "up" component from 3 is N − s(3) = 9 − 6 = 3 ≤ 4. Centroid = 3.
Check: removing 3 yields components {4} (size 1), {5,6,7,8} (size 4), {0,1,2} (size 3). Max = 4 = ⌊9/2⌋. Valid, and tight.
9.3 Level 1 — recurse into the three components¶
- Component
{0,1,2}(path2–1–0): sizes from any root give centroid 1 (removing 1 →{0},{2}, both ≤1). - Component
{4}: trivial centroid 4. - Component
{5,6,7,8}(edges5–6, 5–7, 7–8): root at 5,s(5)=4, s(6)=1, s(7)=2, s(8)=1. From 5, no child > 2 = ⌊4/2⌋ (child 7 has s=2, not > 2). Centroid 5. Removing 5 →{6},{7,8}, both ≤ 2.
9.4 Level 2 — recurse again¶
- Under
1:{0}→ centroid 0;{2}→ centroid 2. - Under
5:{6}→ centroid 6;{7,8}(edge 7–8): centroid 7 (removing 7 →{8}). - Level 3: under 7,
{8}→ centroid 8.
9.5 Resulting centroid tree CT(T)¶
height(CT) = 3 = ⌊log₂ 9⌋ + 1 = 3 + 1 − 1… check: ⌊log₂ 9⌋ = 3, bound is ≤ ⌊log₂ N⌋ + 1 = 4. Observed height 3 ≤ 4. ✓
9.6 Verifying the path-LCA theorem on this tree¶
Take u = 0, v = 8. Tree path: 0 – 1 – 3 – 5 – 7 – 8. Centroid-tree ancestors of 0: {0, 1, 3}. Of 8: {8, 7, 5, 3}. Common ancestors: {3}. So LCA_CT(0,8) = 3. And indeed 3 lies on the path 0–1–3–5–7–8, exactly once — confirming Theorem 3.
Take u = 6, v = 8. Path: 6 – 5 – 7 – 8. Ancestors of 6: {6,5,3}; of 8: {8,7,5,3}. Deepest common = 5. 5 is on the path. ✓ Note the higher common ancestor 3 is not on the path 6–5–7–8 — only the LCA itself is guaranteed on the path, matching part 3 (uniqueness).
9.7 Per-vertex level membership (drives O(N log N))¶
| vertex | level(v) | components it belongs to (one per level) |
|---|---|---|
| 3 | 0 | {0..8} |
| 1 | 1 | {0..8},{0,1,2} |
| 5 | 1 | {0..8},{5,6,7,8} |
| 0 | 2 | {0..8},{0,1,2},{0} |
| 8 | 3 | {0..8},{5,6,7,8},{7,8},{8} |
Each vertex appears in level(v)+1 ≤ 4 components — the O(log N) bound from §3, which is exactly what charges to O(N log N) total work in §5.
10. Reference Implementations¶
Three production-quality patterns, in Go, Java, Python (in that order): (A) centroid-tree build returning cparent[]; (B) count paths of length exactly K via inclusion–exclusion; (C) dynamic nearest-marked-node with point updates.
10.1 Go¶
package main
import "fmt"
// ---------- (A) Centroid-tree build ----------
type CD struct {
adj [][]int
removed []bool
size []int
cparent []int // centroid-tree parent; -1 for the root centroid
}
func NewCD(n int) *CD {
c := &CD{
adj: make([][]int, n),
removed: make([]bool, n),
size: make([]int, n),
cparent: make([]int, n),
}
for i := range c.cparent {
c.cparent[i] = -1
}
return c
}
func (c *CD) AddEdge(u, v int) {
c.adj[u] = append(c.adj[u], v)
c.adj[v] = append(c.adj[v], u)
}
func (c *CD) computeSize(u, p int) int {
c.size[u] = 1
for _, v := range c.adj[u] {
if v != p && !c.removed[v] {
c.size[u] += c.computeSize(v, u)
}
}
return c.size[u]
}
func (c *CD) findCentroid(u, p, n int) int {
for _, v := range c.adj[u] {
if v != p && !c.removed[v] && c.size[v] > n/2 {
return c.findCentroid(v, u, n)
}
}
return u
}
// Build sets cparent[] and returns the root centroid.
func (c *CD) Build(entry, par int) int {
n := c.computeSize(entry, -1)
ce := c.findCentroid(entry, -1, n)
c.removed[ce] = true
c.cparent[ce] = par
for _, v := range c.adj[ce] {
if !c.removed[v] {
c.Build(v, ce)
}
}
return ce
}
// ---------- (B) Count paths of length exactly K ----------
type ExactK struct {
adj [][]int
removed []bool
size []int
K int
answer int64
}
func (s *ExactK) computeSize(u, p int) int {
s.size[u] = 1
for _, v := range s.adj[u] {
if v != p && !s.removed[v] {
s.size[u] += s.computeSize(v, u)
}
}
return s.size[u]
}
func (s *ExactK) findCentroid(u, p, n int) int {
for _, v := range s.adj[u] {
if v != p && !s.removed[v] && s.size[v] > n/2 {
return s.findCentroid(v, u, n)
}
}
return u
}
func (s *ExactK) gather(u, p, d int, out *[]int) {
if d > s.K {
return // distances beyond K cannot contribute to an exact-K pair
}
*out = append(*out, d)
for _, v := range s.adj[u] {
if v != p && !s.removed[v] {
s.gather(v, u, d+1, out)
}
}
}
func (s *ExactK) decompose(entry int) {
n := s.computeSize(entry, -1)
c := s.findCentroid(entry, -1, n)
s.removed[c] = true
// inclusion-exclusion with a frequency array over the whole component
add := func(ds []int, sign int64) {
freq := make([]int64, s.K+2)
for _, d := range ds {
if d <= s.K {
freq[d]++
}
}
for _, d := range ds {
need := s.K - d
if need < 0 || need > s.K {
continue
}
cnt := freq[need]
if need == d {
cnt-- // exclude self
}
s.answer += sign * cnt // counts each ordered pair once
}
}
all := []int{0}
for _, v := range s.adj[c] {
if !s.removed[v] {
var br []int
s.gather(v, c, 1, &br)
add(br, -1) // remove same-branch (ordered) over-counts
all = append(all, br...)
}
}
add(all, +1)
for _, v := range s.adj[c] {
if !s.removed[v] {
s.decompose(v)
}
}
}
// ---------- (C) Dynamic nearest-marked-node ----------
type Near struct {
adj [][]int
removed []bool
size []int
anc [][][2]int // anc[x] = list of {centroid, dist(x,centroid)}
best []map[int]int
}
func (n *Near) computeSize(u, p int) int {
n.size[u] = 1
for _, v := range n.adj[u] {
if v != p && !n.removed[v] {
n.size[u] += n.computeSize(v, u)
}
}
return n.size[u]
}
func (n *Near) findCentroid(u, p, tot int) int {
for _, v := range n.adj[u] {
if v != p && !n.removed[v] && n.size[v] > tot/2 {
return n.findCentroid(v, u, tot)
}
}
return u
}
func (n *Near) record(u, p, d, c int) {
n.anc[u] = append(n.anc[u], [2]int{c, d})
for _, v := range n.adj[u] {
if v != p && !n.removed[v] {
n.record(v, u, d+1, c)
}
}
}
func (n *Near) build(entry int) {
tot := n.computeSize(entry, -1)
c := n.findCentroid(entry, -1, tot)
n.removed[c] = true
n.record(c, -1, 0, c)
for _, v := range n.adj[c] {
if !n.removed[v] {
n.build(v)
}
}
}
func (n *Near) Mark(x int) { for _, a := range n.anc[x] { n.best[a[0]][a[1]]++ } }
func (n *Near) Unmark(x int) {
for _, a := range n.anc[x] {
m := n.best[a[0]]
if m[a[1]] <= 1 { delete(m, a[1]) } else { m[a[1]]-- }
}
}
func (n *Near) Query(x int) int {
const INF = 1 << 30
res := INF
for _, a := range n.anc[x] {
mn := INF
for d := range n.best[a[0]] {
if d < mn { mn = d }
}
if mn < INF && a[1]+mn < res { res = a[1] + mn }
}
return res
}
func main() {
// (A) build centroid tree on the §9 tree
cd := NewCD(9)
for _, e := range [][2]int{{0, 1}, {1, 2}, {1, 3}, {3, 4}, {3, 5}, {5, 6}, {5, 7}, {7, 8}} {
cd.AddEdge(e[0], e[1])
}
root := cd.Build(0, -1)
fmt.Println("root centroid:", root, "cparent:", cd.cparent)
// (B) count paths of length exactly 3
ek := &ExactK{adj: make([][]int, 9), removed: make([]bool, 9), size: make([]int, 9), K: 3}
for _, e := range [][2]int{{0, 1}, {1, 2}, {1, 3}, {3, 4}, {3, 5}, {5, 6}, {5, 7}, {7, 8}} {
ek.adj[e[0]] = append(ek.adj[e[0]], e[1])
ek.adj[e[1]] = append(ek.adj[e[1]], e[0])
}
ek.decompose(0)
fmt.Println("paths of length exactly 3:", ek.answer/2) // /2: ordered->unordered
// (C) nearest marked node
near := &Near{adj: make([][]int, 9), removed: make([]bool, 9), size: make([]int, 9),
anc: make([][][2]int, 9), best: make([]map[int]int, 9)}
for i := range near.best {
near.best[i] = map[int]int{}
}
for _, e := range [][2]int{{0, 1}, {1, 2}, {1, 3}, {3, 4}, {3, 5}, {5, 6}, {5, 7}, {7, 8}} {
near.adj[e[0]] = append(near.adj[e[0]], e[1])
near.adj[e[1]] = append(near.adj[e[1]], e[0])
}
near.build(0)
near.Mark(8)
fmt.Println("nearest marked to 0:", near.Query(0)) // dist(0,8)=5
near.Mark(2)
fmt.Println("nearest marked to 0:", near.Query(0)) // dist(0,2)=2
}
10.2 Java¶
import java.util.*;
public class CentroidPro {
// ---------- (A) Centroid-tree build ----------
static class CD {
List<List<Integer>> adj; boolean[] removed; int[] size, cparent;
CD(int n) {
adj = new ArrayList<>();
for (int i = 0; i < n; i++) adj.add(new ArrayList<>());
removed = new boolean[n]; size = new int[n];
cparent = new int[n]; Arrays.fill(cparent, -1);
}
void addEdge(int u, int v) { adj.get(u).add(v); adj.get(v).add(u); }
int computeSize(int u, int p) {
size[u] = 1;
for (int v : adj.get(u)) if (v != p && !removed[v]) size[u] += computeSize(v, u);
return size[u];
}
int findCentroid(int u, int p, int tot) {
for (int v : adj.get(u))
if (v != p && !removed[v] && size[v] > tot / 2) return findCentroid(v, u, tot);
return u;
}
int build(int entry, int par) {
int tot = computeSize(entry, -1);
int c = findCentroid(entry, -1, tot);
removed[c] = true; cparent[c] = par;
for (int v : adj.get(c)) if (!removed[v]) build(v, c);
return c;
}
}
// ---------- (B) Count paths of length exactly K ----------
static class ExactK {
List<List<Integer>> adj; boolean[] removed; int[] size; int K; long answer = 0;
ExactK(int n, int K) {
this.K = K;
adj = new ArrayList<>();
for (int i = 0; i < n; i++) adj.add(new ArrayList<>());
removed = new boolean[n]; size = new int[n];
}
void addEdge(int u, int v) { adj.get(u).add(v); adj.get(v).add(u); }
int computeSize(int u, int p) {
size[u] = 1;
for (int v : adj.get(u)) if (v != p && !removed[v]) size[u] += computeSize(v, u);
return size[u];
}
int findCentroid(int u, int p, int tot) {
for (int v : adj.get(u))
if (v != p && !removed[v] && size[v] > tot / 2) return findCentroid(v, u, tot);
return u;
}
void gather(int u, int p, int d, List<Integer> out) {
if (d > K) return;
out.add(d);
for (int v : adj.get(u)) if (v != p && !removed[v]) gather(v, u, d + 1, out);
}
void add(List<Integer> ds, long sign) {
long[] freq = new long[K + 2];
for (int d : ds) if (d <= K) freq[d]++;
for (int d : ds) {
int need = K - d;
if (need < 0 || need > K) continue;
long cnt = freq[need];
if (need == d) cnt--; // exclude self
answer += sign * cnt; // ordered pairs
}
}
void decompose(int entry) {
int tot = computeSize(entry, -1);
int c = findCentroid(entry, -1, tot);
removed[c] = true;
List<Integer> all = new ArrayList<>(); all.add(0);
for (int v : adj.get(c)) if (!removed[v]) {
List<Integer> br = new ArrayList<>();
gather(v, c, 1, br);
add(br, -1);
all.addAll(br);
}
add(all, +1);
for (int v : adj.get(c)) if (!removed[v]) decompose(v);
}
}
// ---------- (C) Dynamic nearest-marked-node ----------
static class Near {
List<List<Integer>> adj; boolean[] removed; int[] size;
List<int[]>[] anc; TreeMap<Integer,Integer>[] best;
static final int INF = 1 << 30;
@SuppressWarnings("unchecked")
Near(int n) {
adj = new ArrayList<>();
for (int i = 0; i < n; i++) adj.add(new ArrayList<>());
removed = new boolean[n]; size = new int[n];
anc = new List[n]; best = new TreeMap[n];
for (int i = 0; i < n; i++) { anc[i] = new ArrayList<>(); best[i] = new TreeMap<>(); }
}
void addEdge(int u, int v) { adj.get(u).add(v); adj.get(v).add(u); }
int computeSize(int u, int p) {
size[u] = 1;
for (int v : adj.get(u)) if (v != p && !removed[v]) size[u] += computeSize(v, u);
return size[u];
}
int findCentroid(int u, int p, int tot) {
for (int v : adj.get(u))
if (v != p && !removed[v] && size[v] > tot / 2) return findCentroid(v, u, tot);
return u;
}
void record(int u, int p, int d, int c) {
anc[u].add(new int[]{c, d});
for (int v : adj.get(u)) if (v != p && !removed[v]) record(v, u, d + 1, c);
}
void build(int entry) {
int tot = computeSize(entry, -1);
int c = findCentroid(entry, -1, tot);
removed[c] = true; record(c, -1, 0, c);
for (int v : adj.get(c)) if (!removed[v]) build(v);
}
void mark(int x) { for (int[] a : anc[x]) best[a[0]].merge(a[1], 1, Integer::sum); }
void unmark(int x) {
for (int[] a : anc[x]) {
TreeMap<Integer,Integer> m = best[a[0]];
int v = m.get(a[1]); if (v <= 1) m.remove(a[1]); else m.put(a[1], v - 1);
}
}
int query(int x) {
int res = INF;
for (int[] a : anc[x]) {
TreeMap<Integer,Integer> m = best[a[0]];
if (!m.isEmpty()) res = Math.min(res, a[1] + m.firstKey());
}
return res;
}
}
public static void main(String[] args) {
int[][] edges = {{0,1},{1,2},{1,3},{3,4},{3,5},{5,6},{5,7},{7,8}};
CD cd = new CD(9);
for (int[] e : edges) cd.addEdge(e[0], e[1]);
System.out.println("root centroid: " + cd.build(0, -1));
System.out.println("cparent: " + Arrays.toString(cd.cparent));
ExactK ek = new ExactK(9, 3);
for (int[] e : edges) ek.addEdge(e[0], e[1]);
ek.decompose(0);
System.out.println("paths of length exactly 3: " + ek.answer / 2);
Near near = new Near(9);
for (int[] e : edges) near.addEdge(e[0], e[1]);
near.build(0);
near.mark(8);
System.out.println("nearest marked to 0: " + near.query(0)); // 5
near.mark(2);
System.out.println("nearest marked to 0: " + near.query(0)); // 2
}
}
10.3 Python¶
import sys
sys.setrecursionlimit(1 << 20)
# ---------- (A) Centroid-tree build ----------
class CD:
def __init__(self, n):
self.adj = [[] for _ in range(n)]
self.removed = [False] * n
self.size = [0] * n
self.cparent = [-1] * n
def add_edge(self, u, v):
self.adj[u].append(v); self.adj[v].append(u)
def compute_size(self, u, p):
self.size[u] = 1
for v in self.adj[u]:
if v != p and not self.removed[v]:
self.size[u] += self.compute_size(v, u)
return self.size[u]
def find_centroid(self, u, p, tot):
for v in self.adj[u]:
if v != p and not self.removed[v] and self.size[v] > tot // 2:
return self.find_centroid(v, u, tot)
return u
def build(self, entry, par):
tot = self.compute_size(entry, -1)
c = self.find_centroid(entry, -1, tot)
self.removed[c] = True
self.cparent[c] = par
for v in self.adj[c]:
if not self.removed[v]:
self.build(v, c)
return c
# ---------- (B) Count paths of length exactly K ----------
class ExactK:
def __init__(self, n, K):
self.adj = [[] for _ in range(n)]
self.removed = [False] * n
self.size = [0] * n
self.K = K
self.answer = 0
def add_edge(self, u, v):
self.adj[u].append(v); self.adj[v].append(u)
def compute_size(self, u, p):
self.size[u] = 1
for v in self.adj[u]:
if v != p and not self.removed[v]:
self.size[u] += self.compute_size(v, u)
return self.size[u]
def find_centroid(self, u, p, tot):
for v in self.adj[u]:
if v != p and not self.removed[v] and self.size[v] > tot // 2:
return self.find_centroid(v, u, tot)
return u
def gather(self, u, p, d, out):
if d > self.K:
return
out.append(d)
for v in self.adj[u]:
if v != p and not self.removed[v]:
self.gather(v, u, d + 1, out)
def add(self, ds, sign):
K = self.K
freq = [0] * (K + 2)
for d in ds:
if d <= K:
freq[d] += 1
for d in ds:
need = K - d
if 0 <= need <= K:
cnt = freq[need] - (1 if need == d else 0) # exclude self
self.answer += sign * cnt # ordered pairs
def decompose(self, entry):
tot = self.compute_size(entry, -1)
c = self.find_centroid(entry, -1, tot)
self.removed[c] = True
all_d = [0]
for v in self.adj[c]:
if not self.removed[v]:
br = []
self.gather(v, c, 1, br)
self.add(br, -1)
all_d.extend(br)
self.add(all_d, +1)
for v in self.adj[c]:
if not self.removed[v]:
self.decompose(v)
# ---------- (C) Dynamic nearest-marked-node ----------
class Near:
INF = 1 << 30
def __init__(self, n):
self.adj = [[] for _ in range(n)]
self.removed = [False] * n
self.size = [0] * n
self.anc = [[] for _ in range(n)] # (centroid, dist)
self.best = [dict() for _ in range(n)] # centroid -> {dist: count}
def add_edge(self, u, v):
self.adj[u].append(v); self.adj[v].append(u)
def compute_size(self, u, p):
self.size[u] = 1
for v in self.adj[u]:
if v != p and not self.removed[v]:
self.size[u] += self.compute_size(v, u)
return self.size[u]
def find_centroid(self, u, p, tot):
for v in self.adj[u]:
if v != p and not self.removed[v] and self.size[v] > tot // 2:
return self.find_centroid(v, u, tot)
return u
def record(self, u, p, d, c):
self.anc[u].append((c, d))
for v in self.adj[u]:
if v != p and not self.removed[v]:
self.record(v, u, d + 1, c)
def build(self, entry):
tot = self.compute_size(entry, -1)
c = self.find_centroid(entry, -1, tot)
self.removed[c] = True
self.record(c, -1, 0, c)
for v in self.adj[c]:
if not self.removed[v]:
self.build(v)
def mark(self, x):
for c, d in self.anc[x]:
self.best[c][d] = self.best[c].get(d, 0) + 1
def unmark(self, x):
for c, d in self.anc[x]:
m = self.best[c]
if m[d] <= 1:
del m[d]
else:
m[d] -= 1
def query(self, x):
res = self.INF
for c, d in self.anc[x]:
if self.best[c]:
res = min(res, d + min(self.best[c]))
return res
if __name__ == "__main__":
edges = [(0, 1), (1, 2), (1, 3), (3, 4), (3, 5), (5, 6), (5, 7), (7, 8)]
cd = CD(9)
for u, v in edges:
cd.add_edge(u, v)
print("root centroid:", cd.build(0, -1))
print("cparent:", cd.cparent)
ek = ExactK(9, 3)
for u, v in edges:
ek.add_edge(u, v)
ek.decompose(0)
print("paths of length exactly 3:", ek.answer // 2)
near = Near(9)
for u, v in edges:
near.add_edge(u, v)
near.build(0)
near.mark(8)
print("nearest marked to 0:", near.query(0)) # 5
near.mark(2)
print("nearest marked to 0:", near.query(0)) # 2
On the §9 tree, K = 3. The exactly-length-3 paths are: 0–1–3–4, 0–1–3–5, 2–1–3–4, 2–1–3–5, 1–3–5–6, 1–3–5–7, 4–3–5–6, 4–3–5–7, 3–5–7–8, 6–5–7–8 → 10 paths. The inclusion–exclusion accumulator computes ordered pairs, so the printed answer/2 is 10.
Production notes. The Query here scans each centroid's distance map for its minimum (O(distinct)), kept simple for clarity; replace with a balanced multiset (Java TreeMap.firstKey, already O(log)) or an indexed min-heap with lazy deletion for O(log N) min. The exact-K counter uses a frequency array rather than sorting because the distance domain is bounded by K, yielding O(N log N) overall rather than O(N log² N).
11. Comparison¶
| Structure | Query class | Build | Per op | Dynamic topology? |
|---|---|---|---|---|
| Centroid decomposition | distance counting, nearest-marked, path-property counting | O(N log N) | O(log²N) | No (rebuild) |
| Heavy-Light Decomposition (14) | path-aggregate query/update (sum/max/assign on u–v) | O(N) | O(log²N) | No (rebuild) |
| Link-Cut Trees | path-aggregate with link/cut | O(N) | O(log N) amort. | Yes |
| Mo's algorithm on trees | offline path queries (frequency/distinct) | — | O((N+Q)√N) | No |
| LCA index (13) | pairwise distance only | O(N) / O(N log N) | O(1)/O(log N) | No |
Centroid decomposition is the unique fit for "count/aggregate over the set of paths" and "distance-radius" queries; HLD/LCT handle aggregates along a single given path; Mo handles offline path-frequency queries when no clean per-centroid decomposition exists. They are complementary, not interchangeable.
12. Open Problems¶
- Fully-dynamic centroid decomposition. No known structure maintains a centroid tree under
O(polylog N)edge insertions/deletions while preserving theO(log N)height and the per-centroid augmentation; topology change generally forces rebuild. Top-trees and LCT give dynamic path aggregates but not the centroid-LCA path-partition property cheaply. - Optimal distance-counting. Whether "count pairs at distance
≤ K" admitsO(N)(no log) on arbitrary weighted trees is open in the comparison/algebraic models; CD givesO(N log N)–O(N log² N). - Cache-oblivious / external-memory CD. A provably
O((N/B) log_{M/B}(N/B))-I/O build is not standard; the DFS-driven build is not naturally I/O-efficient. - Parallel depth. Building CT with optimal work
O(N log N)and polylog span on a PRAM/CREW model, given centroid-finding's sequential descent, is subtle.
13. Summary¶
Centroid decomposition rests on four precise facts. (1) Jordan's theorem guarantees a centroid exists, leaving every component ≤ ⌊N/2⌋ (Theorem 1). (2) Strict halving forces the centroid tree's height to ≤ ⌊log₂ N⌋ + 1 and each vertex into O(log N) levels (Theorem 2). (3) The path-decomposition theorem (Theorem 3) shows every u–v path crosses LCA_{CT}(u, v) exactly once, which partitions all paths by owner-centroid and is the rigorous basis for counting without double-counting (resolved by inclusion–exclusion). (4) Because each vertex appears in O(log N) components and each component costs linear work, the build is O(N log N) in O(N) space (Theorem 4). These bounds are worst-case and shape-independent, which is exactly why the structure is both theoretically clean and operationally predictable; its principal limitation remains static topology, leaving fully-dynamic centroid decomposition an open problem.