正解是 Horn 的块合并贪心:反复把平均值最大的非根块并入父块,按对计费累加 total×size,并查集维护每块的点数与权值和,O(n²)。
OJ: roj
题目 ID: 3021
难度:提高+/省选-
标签:贪心并查集树形结构python
创建: 2026-10-01 10:51
更新: 2026-10-07 13:50
形式化题目
给出一棵
在这个序列上代价为
正解
思路
先看清代价在数什么。 把
右边第一部分是常数,第二部分说明:每一对「谁在谁前面」的有序关系,都要为排在后面的那个点付一次它的权值。后面算增量时直接数有序对,不必重算整条序列。
一个诱人但错误的局部贪心。 每一步在所有「父亲已染色」的候选点里挑权值最大的先染,看起来很合理,但它是错的。例如根为
1 (A=1)
├── 2 (A=1)
│ └── 3 (A=100)
└── 4 (A=50)先染
关键观察一:每个点一定紧跟在它的父块之后。 设
关键观察二:块按平均值降序排列最优。 两个块
| 顺序 | 跨块贡献 |
|---|---|
| $ | |
| $ |
两种顺序的块内代价相同,差别只在跨块项:
即块按平均值(权值和 ÷ 点数)降序排最优。
把两个观察合成一个贪心。 块顺序由平均值决定,而每个子块又必须紧跟父块——于是反复执行:
- 在所有非根块中,取平均值最大的块
(根块必须最先,不参与挑选); - 由观察一,
一定紧跟它的父块 ,把 并入 ,新块的平均值变成加权平均 ; - 合并后
变大了,下一轮重新比较——「排序」和「连续性」就这样交替满足。
共合并
增量怎么算。 合并
再加上初始的
下面用样例走一遍。树的结构与权值如下:
graph TD
N1["1 (A=1)"]
N2["2 (A=2)"]
N3["3 (A=1)"]
N4["4 (A=2)"]
N5["5 (A=4)"]
N1 --> N2
N1 --> N3
N2 --> N4
N3 --> N5
初始 cost = ΣA = 10,每个块 size 为 1、total 为该点的
| 轮次 | 当前非根块 (total,size) | 选中块 | 父块根 | 父块 size | 增量 | 累计 cost |
|---|---|---|---|---|---|---|
| 1 | 2(2,1) 3(1,1) 4(2,1) 5(4,1) | 5 | 3 | 1 | 14 | |
| 2 | 2(2,1) 3(5,2) 4(2,1) | 3 | 1 | 1 | 19 | |
| 3 | 2(2,1) 4(2,1) | 2 | 1 | 3 | 25 | |
| 4 | 4(2,1) | 4 | 1 | 4 | 33 |
观察重点有三处。第一轮选中的是权值最大的叶子——
对应的染色顺序是
代码
代码用 belong 做并查集(只有块根满足 belong[x] == x),up[x] 记录点 size/total 就是表格里那两列;up[root] = root 表示根块没有父块。cost 从 sum(weight) 起步,主循环每轮加一次 total[best] * size[parent_block],与上面的表格逐行对应。
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 10:53
# update_at: 2026-10-01 10:53
import sys
def find(belong: list[int], x: int) -> int:
"""返回 x 所在染色块的块根:块内最靠近根节点、也是最早被染色的那个点。"""
while belong[x] != x:
belong[x] = belong[belong[x]] # 路径压缩:块只会向上合并,深度不会变深
x = belong[x]
return x
def min_cost(weight: list[int], father: list[int], n: int, root: int) -> int:
"""贪心合并染色块:每次把平均值最大的非根块并进父块,累加跨块代价。"""
belong = list(range(n + 1)) # 并查集;根节点所在块始终以 root 为块根
up = father[:] # up[x]:点 x 的父节点落在哪个块里
up[root] = root # 根块之上没有块,指向自己表示"无父块"
size = [1] * (n + 1) # size[x]:块根 x 这一块的点数
total = weight[:] # total[x]:块根 x 这一块的权值和
cost = sum(weight) # 每个点自己那一轮先把 A[i] 记一次
for _ in range(n - 1):
# 除根块外平均值最大的块,一定整体紧跟在父块之后染色才最优
blocks = [x for x in range(1, n + 1) if belong[x] == x and x != root]
best = max(blocks, key=lambda x: total[x] / size[x])
parent_block = find(belong, up[best])
# best 块每个点都排在父块每个点之后,这一批"后来者"各产生一次代价
cost += total[best] * size[parent_block]
up[best] = parent_block
belong[best] = parent_block # best 并入父块,不再是块根
size[parent_block] += size[best]
total[parent_block] += total[best]
return cost
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, root = next(data), next(data)
weight = [0] + [next(data) for _ in range(n)] # 下标 1..n 对齐点编号
father = [0] * (n + 1)
for _ in range(n - 1):
a, b = next(data), next(data)
father[b] = a # 题面给的每对是(父节点,子节点)
print(min_cost(weight, father, n, root))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 14:34
* update_at: 2026-10-06 14:34
*/
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
typedef long long ll;
ll n; // 节点数
ll root; // 根节点编号
ll weight[MAXN]; // weight[i]:点 i 的权值 A[i]
ll father[MAXN]; // father[i]:点 i 的父节点,根节点保持为 0
// 并查集数组,只有块根 x 满足 belong[x] == x
ll belong[MAXN];
// up[x]:点 x 的父节点所在块的块根;up[root] = root 表示根块没有父块
ll up[MAXN];
ll block_size[MAXN]; // block_size[x]:块根 x 这一块的节点数
ll block_total[MAXN]; // block_total[x]:块根 x 这一块的权值和
// 返回 x 所在染色块的块根,并顺路做路径压缩
ll find_block(ll x) {
while (belong[x] != x) {
belong[x] = belong[belong[x]];
x = belong[x];
}
return x;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> root;
// 每个点自己那一轮先把 A[i] 记一次
ll cost = 0;
for (ll i = 1; i <= n; i++) {
cin >> weight[i];
cost += weight[i];
}
for (ll i = 1; i <= n - 1; i++) {
ll a, b;
cin >> a >> b;
father[b] = a; // 题目保证 a 是 b 的父节点
}
// 初始化:每个点自成一个块
for (ll i = 1; i <= n; i++) {
belong[i] = i;
up[i] = father[i];
block_size[i] = 1;
block_total[i] = weight[i];
}
up[root] = root; // 根块之上没有块
// Horn 贪心:每轮把平均值最大的非根块并入父块,共合并 n-1 轮
for (ll round = 1; round <= n - 1; round++) {
ll best = 0; // 当前平均值最大的非根块
for (ll i = 1; i <= n; i++) {
if (belong[i] != i || i == root) {
continue;
}
// 用乘法比较平均值 total/size,避免浮点误差
if (best == 0 ||
block_total[i] * block_size[best] > block_total[best] * block_size[i]) {
best = i;
}
}
ll parent_block = find_block(up[best]);
// best 块每个点都排在父块每个点之后,为这一批「后来者」各记一次代价
cost += block_total[best] * block_size[parent_block];
// 把 best 块整体并入父块
up[best] = parent_block;
belong[best] = parent_block;
block_size[parent_block] += block_size[best];
block_total[parent_block] += block_total[best];
}
cout << cost << "\n";
return 0;
}复杂度
- 时间复杂度:
。主循环执行 轮,每轮扫描 个点找块根并取最大平均值块,单轮 ;并查集合并与路径压缩均摊近似 ,被 的扫描吸收。 时约 次基本操作。 - 空间复杂度:
。 belong、up、size、total、father、weight各占,无递归栈。
总结
本题的核心是把决策单位从「点」提升到「块」。局部比较单点权值会失败,因为子节点的染色时机无法脱离它的祖先独立决定;交换论证给出两条结论——每个点与父块连续染色,块按平均值降序排列——合起来就是 Horn 的贪心合并:反复把平均值最大的非根块并入父块,并用「按对计费」在合并时 up[root] = root 表示没有父块。复杂度
图示解析
下图给出从题意到答案的主线:先建立「按对计费」的代价模型,再由交换论证得到两条块级性质,最后合成贪心合并流程。
输入:有根树 + 权值 A[i]
|
v
代价改写:sum t*A[v_t] = sum A[i] + sum_{有序对} A[后者]
| (按对计费)
v
局部贪心(每步选最大权值可染点) --反例--> 失败:祖先被拖后会抬高大权值点
|
v
交换论证
|- 观察一:每个点紧跟在父块之后 -> 块内连续
`- 观察二:块按平均值降序最优 -> 块间有序
|
v
贪心合并:反复取平均值最大的非根块 B,并入父块 P
| cost += total[B] * size[P]
| 并查集维护 belong / up / size / total
v
合并 n-1 次后只剩根块 -> 输出 cost读者应重点关注两处:中间的反例说明为什么必须放弃「按单点权值贪心」,而下面两条观察分别对应「块的内部」和「块的外部」——前者决定了「谁和谁必须绑在一起」,后者决定了「绑好的块谁先谁后」,二者缺一不可。最后的累加公式直接来自最上面的按对计费,所以整个过程只需要并查集,不需要每次重算完整染色序列。