【无脑美学!!!】浅谈点分治

同步发表在了我的公众号

为什么点分治是“无脑美学”

点分治真是太牛了!!!

在树上做路径统计,朴素做法常常要枚举两点或许多路径,复杂度一下就 \(O(n^2)\),显然无法接受。

点分治的思想是:每次在重心处分割,把全局的暴力拆成若干规模均衡的小暴力;由于重心保证每块至多半树大小,分治深度 \(O(\log n)\),在每一层用线性或近线性代价做统计,整体就收敛到 \(O(n\log n)\) 甚至更优的级别。这就是“把该算的一口气算完,但保证每一口都不太大”的无脑美学。

1. 预备知识与术语

1.1 树的重心

重心的等价刻画包括:

  • 删除该点后,每个连通块的大小都不超过原树的一半;

  • 若重心不唯一,则至多两个且相邻;以重心为根时所有子树规模 \(\le \frac{n}{2}\)

  • 到所有点距离和在重心处取最小(有两个重心时两者相同)。

这些性质是点分治正确性与复杂度的核心支点。

1.2 点分治

在当前连通块上找到重心 \(c\),只统计路径经过 \(c\) 的答案;然后把 \(c\) 删去,对各连通块递归。由重心性质,递归点分树的高度为 \(O(\log n)\)。许多问题在每层统计可以做到 \(O(\text{块大小})\),于是总复杂度 \(O(n\log n)\)

由于中文谐音,大家普遍更喜欢叫“淀粉质”。

1.3 与其它树技术的关系

  • HLD 更偏向多次在线区间查询/修改;点分治更擅长“一次性统计所有跨子树配对”的路径问题。两者经常互补。
  • 点分治也能做“把到各层重心的距离预存起来”的动态/在线查询(如染色点最近距离),这是点分树的经典用法。

2. 复杂度与正确性一览

2.1 复杂度框架

因为重心的妙妙性质,分解层数 \(O(\log n)\)。如果在每层把所有与该层有关的节点(或边)线性地扫一遍、再配上排序/双指针/桶合并等近线性手段,那么“每层均摊 \(O(n)\)”的结论成立,故总计 \(O(n\log n)\)

2.2 正确性直观

对任意一对点 \((u,v)\),要么其路径在当前层经过重心 \(c\),要么不经过。前者在处理 \(c\) 时被完整统计,后者全部落入某个子问题(删去 \(c\) 后的某个连通块)里继续统计。

3. 标准流程

3.1 找重心

常见写法是两步:先 DFS 求子树规模,再以“最大残块最小”为准则选择重心;

也可以用“从任意点出发,始终往子树规模 \(>\frac{n}{2}\) 的儿子走,直到没有”为止的线性走法。

3.2 收集与统计(以“距离限制”为例)

把重心 \(c\) 的每个儿子子树当作一个“桶”,从该儿子出发向下收集到 \(c\) 的距离。先把所有子树的距离汇总后做一次全局配对统计(如排序+双指针数对),再减去“同一子树内部”的配对(容斥),就得到路径经过 \(c\) 的贡献。这是许多经典问题的普遍套路。

3.3 清理与递归

统计完 \(c\) 后,标记 \(c\) 为“已删除”,对每个尚未删除的连通块递归,重复 3.1 ~ 3.2。整体形成一棵“点分树”。

3.4 常见细节

  • 只在“当前连通块”里 DFS/收集,跳过已删点。
  • 桶/标记要么局部开小数组+原地清,要么记录访问过的位置再回滚,避免全局大清空的多余开销。
  • 多测时优先复用全局数组,按需清空头节点与边表。

下面给出一个模板级可跑示例代码:以“无权树上距离 \(\le K\) 的点对个数”为例,完整演示“找重心—收集—配对—容斥—递归”的标准流程与写法。其复杂度为 \(O(n\log n)\)

模板示例 A:树上距离 \(\le K\) 的点对数(点分治)

简要题意概括:给定一棵 \(n\) 个点的无权树与整数 \(K\),统计所有点对 \((u,v)\) 使得树上距离 \(\mathrm{dist}(u,v)\le K\) 的对数。请输出答案。

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 200000 + 5;
int n, K, ec, hd[N], to[N << 1], nx[N << 1];
int sz[N], vis[N];
int dt[N], tp[N];

void add(int u, int v)
{
    to[++ec] = v;
    nx[ec] = hd[u];
    hd[u] = ec;
}

void dfs(int u, int p)
{
    sz[u] = 1;
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        dfs(v, u);
        sz[u] += sz[v];
    }
}

int rt, bv;
void gcr(int u, int p, int t)
{
    int mx = t - sz[u];
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        gcr(v, u, t);
        mx = max(mx, sz[v]);
    }
    if (mx < bv)
    {
        bv = mx;
        rt = u;
    }
}

void col(int u, int p, int d, int &c)
{
    if (d > K)
        return;
    dt[++c] = d;
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        col(v, u, d + 1, c);
    }
}

ll cnt(int *a, int m)
{
    sort(a + 1, a + m + 1);
    ll ans = 0;
    int i = 1, j = m;
    while (i < j)
    {
        if (a[i] + a[j] <= K)
        {
            ans += j - i;
            ++i;
        }
        else
            --j;
    }
    return ans;
}

ll sol(int s)
{
    dfs(s, 0);
    bv = 1e9;
    gcr(s, 0, sz[s]);
    int c = rt;
    vis[c] = 1;

    ll ans = 0;
    int dc = 0;

    for (int e = hd[c]; e; e = nx[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        int tc = 0;
        col(v, c, 1, tc);

        for (int i = 1; i <= tc; i++)
            tp[i] = dt[i];
        ll sub = cnt(tp, tc);
        ans -= sub;

        for (int i = 1; i <= tc; i++)
            dt[++dc] = tp[i];
    }

    dt[++dc] = 0;
    ans += cnt(dt, dc);
    for (int e = hd[c]; e; e = nx[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        ans += sol(v);
    }
    return ans;
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> K;
    for (int i = 1; i < n; i++)
    {
        int u, v;
        cin >> u >> v;
        add(u, v);
        add(v, u);
    }
    cout << sol(1) << "\n";
    return 0;
}

代码要点

  • dfs+gcr 两步找重心(3.1);vis 表示该点已被“切掉”。重心选择用“最大残块最小”准则实现。
  • col 从子树收集到重心的距离;cnt 通过排序+双指针统计“和 \(\le K\)”的对数(等价于跨子树的距离 \(\le K\))。容斥做法:总和 cnt(dt) 减去各子树内部 cnt(tp)
  • 主过程 sol:标记重心、做统计、递归到各连通块,形成点分树(3.2 ~ 3.3)。

4. 三类经典计数

4.1 距离恰为 \(K\) 的点对数(无权树)

核心套路:以当前重心 \(c\) 为桥,把跨不同子树的路径用频次数组配对成和恰为 \(K\);容斥去掉同子树配对。

简要题意概括:给定无权树与整数 \(K\),统计距离恰为 \(K\) 的点对数。

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 200000 + 5;
int n, K, ec, hd[N], to[N << 1], nx[N << 1];
int sz[N], vis[N], b[N], stk[N], top;
int dt[N], tp[N];
ll ans;
void add(int u, int v)
{
    to[++ec] = v;
    nx[ec] = hd[u];
    hd[u] = ec;
}
void dfs(int u, int p)
{
    sz[u] = 1;
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        dfs(v, u);
        sz[u] += sz[v];
    }
}
int rt, bv;
void gcr(int u, int p, int t)
{
    int mx = t - sz[u];
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        gcr(v, u, t);
        mx = max(mx, sz[v]);
    }
    if (mx < bv)
    {
        bv = mx;
        rt = u;
    }
}
void col(int u, int p, int d, int &c)
{
    if (d > K)
        return;
    dt[++c] = d;
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        col(v, u, d + 1, c);
    }
}
void addb(int d)
{
    if (!b[d])
        stk[++top] = d;
    b[d]++;
}
void clrb()
{
    while (top)
    {
        b[stk[top--]] = 0;
    }
}
ll sol(int s)
{
    dfs(s, 0);
    bv = 1e9;
    gcr(s, 0, sz[s]);
    int c = rt;
    vis[c] = 1;
    ll res = 0;
    addb(0);
    for (int e = hd[c]; e; e = nx[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        int tc = 0;
        col(v, c, 1, tc);
        for (int i = 1; i <= tc; i++)
        {
            int x = dt[i];
            if (x <= K && K - x >= 0)
                res += b[K - x];
        }
        for (int i = 1; i <= tc; i++)
            addb(dt[i]);
    }
    clrb();
    for (int e = hd[c]; e; e = nx[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        res += sol(v);
    }
    return res;
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> K;
    for (int i = 1; i < n; i++)
    {
        int u, v;
        cin >> u >> v;
        add(u, v);
        add(v, u);
    }
    ans = sol(1);
    cout << ans << "\n";
    return 0;
}

要点:每层把“已合并的子树”距离放入桶 b[],对当前子树逐点查询 b[K−d];处理完即整体清桶,保证复杂度。

4.2 距离集合命题(多组 \(K_i\) 离线判存在)

思路与 4.1 类似,但把答案是否存在变成布尔合并:在某一层以 vis[dist]=true 逐子树滚动,当处理当前子树 tp[] 时,通过 vis[K−x] 检验所有查询。

4.3 距离模 \(m\) 的余数类统计(例:\(\bmod 3\)

把距离按 \(\bmod m\) 分桶:对子树 tp[] 统计与全局桶 cnt[r] 的配对,满足 \((r_{sub}+r_{all})\bmod m\) 落在指定集合即可。该类统计与和 \(\le K\) 不同,通常不需要排序,常数出色,适合在点分层上做线性配对。

5. 进阶:权值、限制与多维信息

5.1 边权和 \(\le K\) 的点对数

把“距离”换成“权和”,流程完全一致:在每个子树向下收集到重心的累加边权,之后用排序 + 双指针统计“和 \(\le K\)”。这正是许多教材在讲解权值版点分治时的标准写法;同理可做边权和 \(=K\)

简要题意概括:给定一棵带权树与整数 \(K\),统计所有点对 \((u,v)\) 使路径边权和 \(\le K\)

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 200000 + 5;
int n, ec, hd[N], to[N << 1], nx[N << 1], wt[N << 1];
int sz[N], vis[N];
ll K, dt[N], tp[N];
void add(int u, int v, int w)
{
    to[++ec] = v;
    nx[ec] = hd[u];
    wt[ec] = w;
    hd[u] = ec;
}
void dfs(int u, int p)
{
    sz[u] = 1;
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        dfs(v, u);
        sz[u] += sz[v];
    }
}
int rt, bv;
void gcr(int u, int p, int t)
{
    int mx = t - sz[u];
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        gcr(v, u, t);
        mx = max(mx, sz[v]);
    }
    if (mx < bv)
    {
        bv = mx;
        rt = u;
    }
}
void col(int u, int p, ll d, int &c)
{
    if (d > K)
        return;
    dt[++c] = d;
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        col(v, u, d + wt[e], c);
    }
}
ll cnt(ll *a, int m)
{
    sort(a + 1, a + m + 1);
    ll res = 0;
    int i = 1, j = m;
    while (i < j)
    {
        if (a[i] + a[j] <= K)
        {
            res += j - i;
            i++;
        }
        else
            j--;
    }
    return res;
}
ll sol(int s)
{
    dfs(s, 0);
    bv = 1e9;
    gcr(s, 0, sz[s]);
    int c = rt;
    vis[c] = 1;
    ll res = 0;
    int dc = 0;
    dt[++dc] = 0;
    for (int e = hd[c]; e; e = nx[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        int tc = 0;
        col(v, c, wt[e], tc);
        for (int i = 1; i <= tc; i++)
            tp[i] = dt[i];
        ll sub = cnt(tp, tc);
        for (int i = 1; i <= tc; i++)
            dt[++dc] = tp[i];
        res -= sub;
    }
    res += cnt(dt, dc);
    for (int e = hd[c]; e; e = nx[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        res += sol(v);
    }
    return res;
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> K;
    for (int i = 1; i < n; i++)
    {
        int u, v, w;
        cin >> u >> v >> w;
        add(u, v, w);
        add(v, u, w);
    }
    cout << sol(1) << "\n";
    return 0;
}

5.2 更多路径属性的合并规则

  • 颜色/奇偶:对子树统计“到重心路径上颜色出现向量/奇偶性”,跨子树按规则合并。
  • 位或/位与/异或:可以用位集或字典树在每层配对;异或的典型写法是“到重心前缀异或”,跨子树找互补。
  • 双维限制(如边数与权和):在每层对子树向量做“二分 + 单调性”或固定一维枚举。

6. 动态点分治与点分树

当题目变成多次在线修改/查询时,点分治的层序结构可以长期复用:为每个节点保存到其点分树祖先的距离;把答案函数写成“沿点分树自底向上合并”。每次修改与查询都只在 \(O(\log n)\) 个祖先上更新/取最小值。

简要题意概括:给定无权树,初始 \(1\) 号点为红色,支持两类操作:把某点染红;查询某点到任意红点的最小距离。输出所有查询结果。

如果你不会点分治,就只能 CDQ 分治+虚树+换根 DP,我们显然不能接受如此大的码量。而且此种做法是 \(O(n \log^2 n)\) 的,更加无法接受。

不妨选择码量小,常数小,单 \(\log\) 的点分治写法。

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 200000 + 5, LG = 20, INF = 1e9;
int n, q, ec, hd[N], to[N << 1], nx[N << 1];
int up[LG][N], dep[N];
int sz[N], vis[N], par[N], best[N];
void add(int u, int v)
{
    to[++ec] = v;
    nx[ec] = hd[u];
    hd[u] = ec;
}
void dfs0(int u, int p)
{
    up[0][u] = p;
    for (int k = 1; k < LG; k++)
        up[k][u] = up[k - 1][up[k - 1][u]];
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p)
            continue;
        dep[v] = dep[u] + 1;
        dfs0(v, u);
    }
}
int lca(int u, int v)
{
    if (dep[u] < dep[v])
        swap(u, v);
    int d = dep[u] - dep[v];
    for (int k = 0; k < LG; k++)
        if (d >> k & 1)
            u = up[k][u];
    if (u == v)
        return u;
    for (int k = LG - 1; k >= 0; k--)
        if (up[k][u] != up[k][v])
        {
            u = up[k][u];
            v = up[k][v];
        }
    return up[0][u];
}
int dst(int u, int v)
{
    int w = lca(u, v);
    return dep[u] + dep[v] - 2 * dep[w];
}
void dfs(int u, int p)
{
    sz[u] = 1;
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        dfs(v, u);
        sz[u] += sz[v];
    }
}
int rt, bv;
void gcr(int u, int p, int t)
{
    int mx = t - sz[u];
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        gcr(v, u, t);
        mx = max(mx, sz[v]);
    }
    if (mx < bv)
    {
        bv = mx;
        rt = u;
    }
}
void build(int s, int p)
{
    dfs(s, 0);
    bv = 1e9;
    gcr(s, 0, sz[s]);
    int c = rt;
    par[c] = p;
    vis[c] = 1;
    for (int e = hd[c]; e; e = nx[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        build(v, c);
    }
}
void upd(int u)
{
    int x = u;
    while (x)
    {
        best[x] = min(best[x], dst(x, u));
        x = par[x];
    }
}
int qry(int u)
{
    int x = u, res = INF;
    while (x)
    {
        res = min(res, best[x] + dst(x, u));
        x = par[x];
    }
    return res;
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> q;
    for (int i = 1; i < n; i++)
    {
        int u, v;
        cin >> u >> v;
        add(u, v);
        add(v, u);
    }
    dep[1] = 0;
    dfs0(1, 1);
    build(1, 0);
    for (int i = 1; i <= n; i++)
        best[i] = INF;
    upd(1);
    while (q--)
    {
        int t, u;
        cin >> t >> u;
        if (t == 1)
            upd(u);
        else
            cout << qry(u) << "\n";
    }
    return 0;
}

复杂度:建点分树 \(O(n\log n)\);每次修改/查询沿点分树链长 \(O(\log n)\),用 LCA 取原树距离,整体 \(O((n+q)\log n)\)

6.1 细节与常见坑

  • 距离获取:如上代码用倍增 LCA 获取 dst(u,v),避免为每个重心开整图距离表导致的内存爆炸。
  • 清理策略:动态题里 best[] 只递减,无需清桶;静态计数题务必用“访问栈回滚”清桶,避免 memset 全清导致层上重复 \(O(n\log n)\) 外的额外常数。
  • 正确性直观:任意查询点 \(u\) 到最近红点 \(x\) 的最短路,必经过 \(u\) 在点分树上的某个祖先 \(c\),于是 best[c]+dist(c,u) 的最小值即答案。

7. 专题实战案例

7.1 CF 161D Distance in Tree(距离恰为 \(K\)

问题:给无权树与 \(K\),数距离恰为 \(K\) 的点对。
要点:以重心为桥,“桶配对”找 \(d_1+d_2=K\),对子树做容斥;整题 \(O(n\log n)\)

7.2 IOI 2011 Race

问题:带权树,找权和恰为 \(K\) 的路径且使边数最少,不存在则 \(-1\)

思路:点分治层上做跨子树合并。收集每个子树到重心的对儿 \((\text{sum}, \text{edges})\),用一张长为 \(K\!+\!1\) 的数组 bk[sum]=最少边数 做最优合并,查询 bk[K-sum] 即可;用访问栈回滚清理,常数更稳。

简要题意概括:给一棵带权树与 \(K\),求权和恰为 \(K\) 的路径最少边数,若无则 \(-1\)

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 200000 + 5, MK = 1000000 + 5, INF = 1e9;
int n, ec, hd[N], to[N << 1], nx_[N << 1], wt[N << 1];
int sz[N], vis[N], rt, bv;
int st[MK], tp, bk[MK];
ll K;
int dt[N], de[N];
void ad(int u, int v, int w)
{
    to[++ec] = v;
    nx_[ec] = hd[u];
    wt[ec] = w;
    hd[u] = ec;
}
void df(int u, int p)
{
    sz[u] = 1;
    for (int e = hd[u]; e; e = nx_[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        df(v, u);
        sz[u] += sz[v];
    }
}
void gc(int u, int p, int t)
{
    int mx = t - sz[u];
    for (int e = hd[u]; e; e = nx_[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        gc(v, u, t);
        mx = max(mx, sz[v]);
    }
    if (mx < bv)
    {
        bv = mx;
        rt = u;
    }
}
void cl(int u, int p, int s, int d, int &c)
{
    if (s > K)
        return;
    dt[++c] = s;
    de[c] = d;
    for (int e = hd[u]; e; e = nx_[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        cl(v, u, s + wt[e], d + 1, c);
    }
}
int sol(int s)
{
    df(s, 0);
    bv = 1e9;
    gc(s, 0, sz[s]);
    int c = rt;
    vis[c] = 1;
    int ans = INF;
    tp = 0;
    if (bk[0] > 0)
    {
        bk[0] = 0;
        st[++tp] = 0;
    }
    else if (bk[0] == INF)
    {
        bk[0] = 0;
        st[++tp] = 0;
    }
    for (int e = hd[c]; e; e = nx_[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        int tc = 0;
        cl(v, c, wt[e], 1, tc);
        for (int i = 1; i <= tc; i++)
        {
            int s1 = dt[i], d1 = de[i];
            if (s1 <= K)
            {
                int s2 = (int)(K - s1);
                if (bk[s2] < INF)
                    ans = min(ans, d1 + bk[s2]);
            }
        }
        for (int i = 1; i <= tc; i++)
        {
            int s1 = dt[i], d1 = de[i];
            if (bk[s1] > d1)
            {
                if (bk[s1] == INF)
                    st[++tp] = s1;
                bk[s1] = d1;
            }
        }
    }
    while (tp)
    {
        bk[st[tp--]] = INF;
    }
    for (int e = hd[c]; e; e = nx_[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        ans = min(ans, sol(v));
    }
    return ans;
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> K;
    for (int i = 1; i <= n; i++)
        hd[i] = 0;
    ec = 0;
    for (int i = 1; i < n; i++)
    {
        int u, v, w;
        cin >> u >> v >> w;
        ad(u, v, w);
        ad(v, u, w);
    }
    for (int i = 0; i < MK; i++)
        bk[i] = INF;
    int res = sol(1);
    cout << (res >= INF ? -1 : res) << "\n";
    return 0;
}

说明:数组 bk 长度按题面 \(K\le10^6\) 预留(每格存“到重心已合并部分的最少边数”),每层只回滚访问过的位置,避免整段清空。

7.3 Luogu P3806【模板】点分治 1(多次查询“是否存在距离=\(k\)”)

问题:带边权树,给 \(m\)\(k\),回答是否存在路径权和恰为 \(k\)。数据范围 \(n\le10^4,m\le100,k\le10^7\)

思路:点分治;在当前重心把子树距离收集到数组,然后对每个查询 \(k\)vis[k-d] 判定(先判、后合并、容斥去重),用“访问栈回滚”清理。

简要题意概括:给带权树与若干 \(k\),问是否存在一条路径权和恰为 \(k\)

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 10000 + 5, Q = 100 + 5, MK = 10000000 + 5;
int n, m, ec, hd[N], to[N << 1], nx_[N << 1], wt[N << 1];
int sz[N], vis[N], rt, bv;
int qu[Q], ok[Q];
int mklen = 0;
unsigned char mk[MK];
int st[MK], tp;
int dt[N];
void ad(int u, int v, int w)
{
    to[++ec] = v;
    nx_[ec] = hd[u];
    wt[ec] = w;
    hd[u] = ec;
}
void df(int u, int p)
{
    sz[u] = 1;
    for (int e = hd[u]; e; e = nx_[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        df(v, u);
        sz[u] += sz[v];
    }
}
void gc(int u, int p, int t)
{
    int mx = t - sz[u];
    for (int e = hd[u]; e; e = nx_[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        gc(v, u, t);
        mx = max(mx, sz[v]);
    }
    if (mx < bv)
    {
        bv = mx;
        rt = u;
    }
}
void cl(int u, int p, int s, int &c, int lim)
{
    if (s > lim)
        return;
    dt[++c] = s;
    for (int e = hd[u]; e; e = nx_[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        cl(v, u, s + wt[e], c, lim);
    }
}
void addm(int x)
{
    if (!mk[x])
    {
        mk[x] = 1;
        st[++tp] = x;
    }
}
void clr()
{
    while (tp)
    {
        mk[st[tp--]] = 0;
    }
}
void chk(int *a, int c)
{
    for (int i = 1; i <= c; i++)
    {
        int x = a[i];
        for (int j = 1; j <= m; j++)
        {
            int k = qu[j];
            if (k >= x && mk[k - x])
                ok[j] = 1;
        }
    }
}
void ins(int *a, int c)
{
    for (int i = 1; i <= c; i++)
        addm(a[i]);
}
void solve(int s)
{
    df(s, 0);
    bv = 1e9;
    gc(s, 0, sz[s]);
    int c = rt;
    vis[c] = 1;
    tp = 0;
    addm(0);
    for (int e = hd[c]; e; e = nx_[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        int lim = 0;
        for (int i = 1; i <= m; i++)
            if (qu[i] > lim)
                lim = qu[i];
        int tc = 0;
        cl(v, c, wt[e], tc, lim);
        chk(dt, tc);
        ins(dt, tc);
    }
    clr();
    for (int e = hd[c]; e; e = nx_[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        solve(v);
    }
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    for (int i = 1; i <= n; i++)
        hd[i] = 0;
    ec = 0;
    for (int i = 1; i < n; i++)
    {
        int u, v, w;
        cin >> u >> v >> w;
        ad(u, v, w);
        ad(v, u, w);
    }
    for (int i = 1; i <= m; i++)
        cin >> qu[i], ok[i] = 0;
    solve(1);
    for (int i = 1; i <= m; i++)
        cout << (ok[i] ? "AYE" : "NAY") << "\n";
    return 0;
}

8. 与其它技术的对照与混合

  • 点分治 vs HLD:多次在线区间型(路径加减、区间取 min/max/kth)更偏向 HLD;一次性统计跨不同子树配对的路径题点分治更自然。
  • 点分治 vs DSU on tree:当统计是“以某点为根、向下只算一次”的聚合(如子树颜色计数),DSU 往往更优;跨子树配对需先查后合并+容斥,点分治更顺手。
  • 混合技法:点分治外层 + 子问题内用二分、堆、字典树(异或)、或倍增 LCA 拿原树距离,均是常见组合;

9. 常见坑与 Debug 清单

  1. 重复计数:忘做子树内自配对的“容斥减法”,答案翻倍。
  2. 清理策略memset 全清大桶会炸常数;请用访问栈回滚只清触及位置(上面两份代码已示范)。
  3. 层上排序次数:能不多排就不多排;如“和 \(\le K\)”用一次全局排序 + 双指针、子树内做减法。
  4. 最长链/退化链:树退化成链时,重心分治深度仍是 \(O(\log n)\),但若子过程没控常数,仍可能 TLE。
  5. 数据上界:IOI Race 的 \(K\le10^6\) 需大数组;Luogu P3806 的 \(k\le10^7\) 也要注意字节级内存。
  6. 多测复用:注意边表、标记、桶的复用与清空次序;若有 RE,多半是清理不彻底或越界。
  7. 选重心实现:推荐“df 求子树 + gc 取最大残块最小”的二段式,或“沿大儿子走”的线性法,均与重心性质一致。

10. 常数优化与实现建议

  • 分治层只清访问过的桶/位点。用访问栈或就地记录后回滚的方法,避免整段 memset 导致额外 \(O(n\log n)\) 常数。
  • 统一在当前层先查后并(容斥),并尽量把全局排序/双指针/一次桶合并收敛为每层一次,维持均摊 \(O(n)\);整体 \(O(n\log n)\)
  • 找重心的两种方法不多赘述,看自己习惯。
  • 混合技术的边界:需要大量在线路径区间操作 → 倾向 HLD;一次性统计跨子树配对 → 点分治更自然。
  • 复杂度:点分树深度 \(O(\log n)\),每层总访问量 \(O(n)\),若合并中引入平衡树/堆等,则在该层再乘以相应因子(例如加一个 \(\log n\))。

11. 模板库补充

11.A 基础骨架(仅构建点分治与递归,不做统计)

简要题意概括:读入一棵树,构建点分治骨架并返回 \(0\),便于在 sol() 的处理当前重心处插入任意统计逻辑。

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 200000 + 5;
int n, ec, hd[N], to[N << 1], nx[N << 1];
int sz[N], vis[N], rt, bv;
void ad(int u, int v)
{
    to[++ec] = v;
    nx[ec] = hd[u];
    hd[u] = ec;
}
void df(int u, int p)
{
    sz[u] = 1;
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        df(v, u);
        sz[u] += sz[v];
    }
}
void gc(int u, int p, int t)
{
    int mx = t - sz[u];
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        gc(v, u, t);
        mx = max(mx, sz[v]);
    }
    if (mx < bv)
    {
        bv = mx;
        rt = u;
    }
}
ll sol(int s)
{
    df(s, 0);
    bv = 1e9;
    gc(s, 0, sz[s]);
    int c = rt;
    vis[c] = 1;
    ll ans = 0;
    for (int e = hd[c]; e; e = nx[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        ans += sol(v);
    }
    return ans;
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    for (int i = 1; i < n; i++)
    {
        int u, v;
        cin >> u >> v;
        ad(u, v);
        ad(v, u);
    }
    cout << sol(1) << "\n";
    return 0;
}

11.B 余数类统计示例:无权树上“距离 % 3 == 0”的点对数量

思路:在当前重心 \(c\) 处,先把已合并部分的到 \(c\) 的距离模 \(3\) 计数放入桶 cnt[3](初始 cnt[0]=1 代表重心自身),对子树逐个收集 d%3,先用 cnt[(3-r)%3] 统计跨子树配对,再把该子树计数并入桶,最后递归到子块。复杂度 \(O(n\log n)\)

简要题意概括:给定无权树,统计所有点对 \((u,v)\) 使得距离 \(\mathrm{dist}(u,v)\equiv 0\pmod 3\)

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 200000 + 5;
int n, ec, hd[N], to[N << 1], nx[N << 1];
int sz[N], vis[N], rt, bv, dt[N];
ll ans;
void ad(int u, int v)
{
    to[++ec] = v;
    nx[ec] = hd[u];
    hd[u] = ec;
}
void df(int u, int p)
{
    sz[u] = 1;
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        df(v, u);
        sz[u] += sz[v];
    }
}
void gc(int u, int p, int t)
{
    int mx = t - sz[u];
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        gc(v, u, t);
        mx = max(mx, sz[v]);
    }
    if (mx < bv)
    {
        bv = mx;
        rt = u;
    }
}
void cl(int u, int p, int d, int &c)
{
    dt[++c] = d % 3;
    for (int e = hd[u]; e; e = nx[e])
    {
        int v = to[e];
        if (v == p || vis[v])
            continue;
        cl(v, u, d + 1, c);
    }
}
ll go(int s)
{
    df(s, 0);
    bv = 1e9;
    gc(s, 0, sz[s]);
    int c = rt;
    vis[c] = 1;
    ll res = 0;
    int cnt[3] = {0, 0, 0};
    cnt[0] = 1;
    for (int e = hd[c]; e; e = nx[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        int tc = 0;
        cl(v, c, 1, tc);
        int add0 = 0, add1 = 0, add2 = 0;
        for (int i = 1; i <= tc; i++)
        {
            int r = dt[i];
            res += cnt[(3 - r) % 3];
            if (r == 0)
                add0++;
            else if (r == 1)
                add1++;
            else
                add2++;
        }
        cnt[0] += add0;
        cnt[1] += add1;
        cnt[2] += add2;
    }
    for (int e = hd[c]; e; e = nx[e])
    {
        int v = to[e];
        if (vis[v])
            continue;
        res += go(v);
    }
    return res;
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    for (int i = 1; i < n; i++)
    {
        int u, v;
        cin >> u >> v;
        ad(u, v);
        ad(v, u);
    }
    ans = go(1);
    cout << ans << "\n";
    return 0;
}

说明:可推广到任意模 \(m\):把 cnt 与子树计数扩展到长度 \(m\) 即可(依题面内存限制而定)。

12. 复杂度证明

  • 点分树深度:若每次选择当前连通块的重心 \(c\),则删去 \(c\) 后,所有子块大小 \(\le\lfloor n/2\rfloor\)。于是任一节点在点分树上的层数 \(\le \lfloor\log_2 n\rfloor\)
  • 每层工作量:在某一固定层,所有递归块两两不交,其大小和为 \(n\)。若“处理当前重心”的统计在每块内是线性/近线性的(例如一次排序+双指针或常数模桶),则该层均摊 \(O(n)\)
  • 总复杂度:把上两条合并:层数 \(O(\log n)\) × 每层 \(O(n)\) ⇒ 总计 \(O(n\log n)\)。如在统计中对每个元素还需一次 \(\log n\) 的平衡树操作,则整体上界相应变为 \(O(n\log^2 n)\)

13. 练习题单

  • CF 342E
  • IOI 2011 Race
  • CF 766E
  • CF 293E
  • CF 161D
  • 「岱陌算法杯」ROUND #3 (Div. 1 + Div. 2) D.巡逻路径
posted @ 2025-08-25 09:36  薛儒浩  阅读(32)  评论(0)    收藏  举报