把每场战斗折算成升级/挨打的性价比,树上父亲先打的限制用并查集把子块并入父亲块,堆按性价比整块出战。
OJ: roj
题目 ID: 10011
难度:普及+/提高-
标签:贪心树并查集优先队列python
创建: 2026-10-02 17:59
更新: 2026-10-07 12:15
形式化题目
魔塔是一棵
战斗规则:
- 勇士先手,双方轮流出手一次造成「攻击
自己面对的防御」的伤害,血量 的一方死亡; - 勇士首次到达某点时立即与该点魔物战斗,魔物死后该点不再刷怪;
- 树上移动不花代价、不掉血。
由于必须先走到父亲点才能进入子树,合法的战斗顺序恰好是所有父亲先于儿子的排列。问打完全部魔物后的最大剩余血量;若勇士中途死亡则输出
样例:
下图为样例的树形结构与最优出战顺序:
graph TD
N1["1 起点"] --> N2["2 第4个打"]
N1 --> N3["3 第2个打"]
N1 --> N4["4 第1个打"]
N4 --> N5["5 第3个打"]
N5 --> N6["6 第5个打"]
图中给出最优出战顺序
正解
思路
第一步:算清单场战斗的代价。
勇士攻击
打完后防御变成
第二步:把总血量拆成与顺序有关/无关的两部分。
设战斗按
前三项都是常数,最大化剩余血量
第三步:朴素做法与瓶颈。
枚举所有「父亲先于儿子」的合法排列逐个算
第四步:没有树约束时,按性价比排序。
比较紧邻的两场
第五步:树约束下的块合并贪心。
父亲必须先打,儿子不能插到父亲前面,所以把「一个点 + 已经并进来的子块」视为一个块,块的性价比是加权平均:
用大根堆按
- 若块顶的父亲已出战:整块立刻出战——块顶先打,各子块按它们并入的先后(即
从高到低)依次出战; - 若父亲还没上场:把整块并进父亲所在的块(
取加权平均),相当于先攒着,等父亲能上场时再和别人比性价比。
正确性有两个关键点:
- 块交换引理:两个互不相干的块
相邻时, 在前的收益是 , 在前是 , 高的整块排前面更优。而每次合并出的新块 是「刚弹出的最大值」与「父亲块(不超过它)」的加权平均,所以弹出序列单调不增——同一父亲名下的子块天然按 降序并入,整块出战时内部顺序满足引理 1。 - 可以交换成贪心形式:任何合法方案中,儿子块都只能排在父亲之后;把尚未上场的父亲块与儿子块捆绑成整体参与性价比比较,不改变它与其他块的相对收益,因此逐块做相邻交换,任意方案都能化成贪心给出的块状方案且
不降。
用样例验证公式与顺序:
| 出战 | 交战前防御 |
挨打次数 |
每次掉血 |
本场掉血 | 剩余血量 | 战后 |
|---|---|---|---|---|---|---|
| 4 | 0 | 3 | 153 | 49847 | 5 | |
| 3 | 5 | 3 | 150 | 49697 | 10 | |
| 5 | 10 | 3 | 162 | 49535 | 13 | |
| 2 | 13 | 4 | 164 | 49371 | 17 | |
| 6 | 17 | 10 | 470 | 48901 | 18 |
最终血量
代码
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-02 18:13
# update_at: 2026-10-02 18:13
import heapq
import sys
from collections.abc import Iterator
INF = float("inf") # 挨打 0 次的块:一击必杀还白拿升级,性价比视为无穷
def root_tree(g: list[list[int]]) -> list[int]:
"""以 1 为根做迭代 DFS,返回每个点的父亲;1 的父亲记 0。"""
parent = [0] * len(g)
parent[1] = -1 # 先占位标记根已访问,遍历完再还原成 0
stack = [1]
while stack:
u = stack.pop()
for v in g[u]:
if parent[v] == 0:
parent[v] = u
stack.append(v)
parent[1] = 0
return parent
def hit_rounds(hp: int, hero_atk: int, monster_def: int) -> int:
"""打死这只怪前英雄要挨的次数:先手打 ceil(hp/(攻-防)) 次,此前挨 t 次反击。"""
per = hero_atk - monster_def
return (hp + per - 1) // per - 1
def block_ratio(total_gain: int, total_rounds: int) -> float:
"""块的性价比:每挨一次打能换来多少升级。"""
return total_gain / total_rounds if total_rounds else INF
def find(f: list[int], x: int) -> int:
"""并查集查块顶,路径压缩。"""
while f[x] != x:
f[x] = f[f[x]]
x = f[x]
return x
def fight_order(parent: list[int], rounds: list[int], gain: list[int]) -> list[int]:
"""块合并贪心:堆按性价比弹出全局最大的块;块顶的父亲已出战就整块出战
(块顶先打,子块按弹出先后即性价比降序),否则整块并进父亲所在的块。"""
n = len(parent) - 1
f = list(range(n + 1)) # 并查集:指向所在块的块顶
total_rounds = rounds[:] # 块累计挨打次数
total_gain = gain[:] # 块累计升级
kids: list[list[int]] = [[] for _ in range(n + 1)] # 块顶名下已并入的子块
popped = [False] * (n + 1) # 块顶弹出过:之后清掉过期堆项
scheduled = [False] * (n + 1) # 是否已出战
scheduled[1] = True # 1 号点是起点,视为最先出战
heap = [(-block_ratio(total_gain[i], total_rounds[i]), -i) for i in range(2, n + 1)]
heapq.heapify(heap)
order: list[int] = []
while heap:
_, neg_u = heapq.heappop(heap)
u = -neg_u
if popped[u]:
continue # 并块前压入的旧键,块已合并过
popped[u] = True
if scheduled[parent[u]]:
# 父亲已出战:整块立刻出战,块顶最先,子块按并入先后(性价比降序)
stack = [u]
while stack:
x = stack.pop()
scheduled[x] = True
order.append(x)
stack.extend(reversed(kids[x]))
continue
# 父亲还没上场:并进父亲的块攒着,合并后的性价比是按挨打次数的加权平均
p = find(f, parent[u])
total_gain[p] += total_gain[u]
total_rounds[p] += total_rounds[u]
kids[p].append(u)
f[u] = p
heapq.heappush(heap, (-block_ratio(total_gain[p], total_rounds[p]), -p))
return order
def final_hp(order: list[int], hp: int, armor: int, m_atk: list[int],
rounds: list[int], gain: list[int]) -> int:
"""按出战序列结算血量:每场挨 t×(怪攻-当前防御);中途阵亡返回 -1。"""
defense = armor
for u in order:
hp -= rounds[u] * (m_atk[u] - defense)
if hp <= 0:
return -1
defense += gain[u]
return hp
def solve() -> None:
data: Iterator[int] = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
g: list[list[int]] = [[] for _ in range(n + 1)]
for _ in range(n - 1):
x, y = next(data), next(data)
g[x].append(y)
g[y].append(x)
hp, hero_atk, armor = next(data), next(data), next(data)
# 怪物第 k 行对应节点 k+1:血量、攻击、防御、打完提升的等级
m_atk = [0] * (n + 1)
rounds = [0] * (n + 1)
gain = [0] * (n + 1)
for u in range(2, n + 1):
b, a, d, v = next(data), next(data), next(data), next(data)
m_atk[u], gain[u] = a, v
rounds[u] = hit_rounds(b, hero_atk, d)
order = fight_order(root_tree(g), rounds, gain)
print(final_hp(order, hp, armor, m_atk, rounds, gain))
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-04 22:51
* update_at: 2026-10-04 22:51
*/
// 牛半仙的魔塔:树上「父亲先于儿子」的块合并贪心。
// 单场战斗的代价是勇士挨打 t = ceil(b/(A-d)) - 1 次,收益是 val 级升级(防御 +val),
// 于是每场战斗的性价比 rho = val / t;父亲必须先打的限制用并查集把「攒着的子块」
// 并进父亲块(新块的 rho 是按挨打次数加权平均),大根堆每次弹出全局 rho 最大的块。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
typedef long long ll;
int n;
vector<int> g[MAXN]; // g[u] 存节点 u 的所有邻居
ll hp, hero_atk, armor; // 勇士初始血量、攻击、盔甲防御
ll monster_atk[MAXN]; // monster_atk[u] 节点 u 魔物的攻击
ll hits[MAXN]; // hits[u] 打死节点 u 的魔物前,勇士要挨的攻击次数
ll gain[MAXN]; // gain[u] 打死节点 u 的魔物后提升的等级(也是防御增量)
int up[MAXN]; // up[u] 节点 u 在树上的父亲,根 1 的父亲为 0
int block_top[MAXN]; // 并查集:指向所在块的块顶
ll block_gain[MAXN]; // 块内累计升级
ll block_hits[MAXN]; // 块内累计挨打次数
vector<int> merged_kids[MAXN]; // merged_kids[x] 已并入块顶 x 的子块,按并入先后排列
int ver[MAXN]; // 块顶的版本号,用来丢掉堆里的过期键
int scheduled[MAXN]; // 是否已出战(起点 1 视为最先出战)
int order_list[MAXN]; // 最终的出战顺序
int order_cnt;
int work_stack[MAXN]; // 迭代遍历树、展开整块时共用的显式栈
// 堆元素:块的性价比 = block_gain / block_hits,block_hits 为 0 时视为无穷大。
struct BlockNode {
ll gain_v;
ll hits_v;
int id; // 块顶编号
int ticket; // 该块顶的版本号,只有等于 ver[id] 的键才有效
};
// priority_queue 默认大根堆,operator< 定义「性价比更小」的一方为更小。
bool operator<(const BlockNode &x, const BlockNode &y) {
if (x.hits_v == 0 || y.hits_v == 0) {
if (x.hits_v == 0 && y.hits_v == 0) {
return x.id < y.id; // 两块都是一击必杀,顺序随意,用编号定序
}
return y.hits_v == 0; // x 的性价比有限、y 的无穷大,则 x 更小
}
return x.gain_v * y.hits_v < y.gain_v * x.hits_v; // 交叉相乘比较,避免浮点误差
}
priority_queue<BlockNode> heap;
// 并查集查块顶,带路径压缩。
int find_top(int x) {
while (block_top[x] != x) {
block_top[x] = block_top[block_top[x]];
x = block_top[x];
}
return x;
}
// 把块顶 u 的当前信息压进堆,并升级它的版本号。
void push_block(int u) {
ver[u]++;
BlockNode node;
node.gain_v = block_gain[u];
node.hits_v = block_hits[u];
node.id = u;
node.ticket = ver[u];
heap.push(node);
}
void read_input() {
cin >> n;
for (int i = 1; i < n; i++) {
int x, y;
cin >> x >> y;
g[x].push_back(y);
g[y].push_back(x);
}
cin >> hp >> hero_atk >> armor;
// 怪物第 k 行对应节点 k + 1,依次是血量 b、攻击 a、防御 d、提升等级 v
for (int u = 2; u <= n; u++) {
ll b, a, d, v;
cin >> b >> a >> d >> v;
monster_atk[u] = a;
gain[u] = v;
// 勇士先手,打死共需 ceil(b/(A-d)) 击,此前只挨 k - 1 次反击
ll per_hit = hero_atk - d;
hits[u] = (b + per_hit - 1) / per_hit - 1;
}
}
// 以 1 为根,用显式栈做迭代 DFS 求出每个点的父亲。
void build_parent() {
for (int i = 1; i <= n; i++) {
up[i] = 0;
}
int top = 0;
work_stack[top++] = 1;
up[1] = -1; // 先占位表示根已访问,遍历完再还原成 0
while (top > 0) {
int u = work_stack[--top];
for (int i = 0; i < (int)g[u].size(); i++) {
int v = g[u][i];
if (up[v] == 0) {
up[v] = u;
work_stack[top++] = v;
}
}
}
up[1] = 0;
}
void solve() {
for (int i = 1; i <= n; i++) {
block_top[i] = i;
block_gain[i] = gain[i];
block_hits[i] = hits[i];
}
scheduled[1] = 1; // 1 号点是起点,没有魔物,视为最先出战
for (int u = 2; u <= n; u++) {
push_block(u);
}
while (!heap.empty()) {
BlockNode cur = heap.top();
heap.pop();
if (cur.ticket != ver[cur.id]) {
continue; // 过期键:该块顶后来并过新子块,已压入新版本
}
int u = cur.id;
if (scheduled[up[u]]) {
// 父亲已出战:整个块立刻出战,块顶最先,子块按并入先后(性价比降序)出战
int top = 0;
work_stack[top++] = u;
while (top > 0) {
int x = work_stack[--top];
scheduled[x] = 1;
order_list[order_cnt++] = x;
// 逆序压栈,保证先并入的子块先弹出
for (int i = (int)merged_kids[x].size() - 1; i >= 0; i--) {
work_stack[top++] = merged_kids[x][i];
}
}
} else {
// 父亲还没上场:整块并进父亲所在的块攒着,性价比取按挨打次数的加权平均
int p = find_top(up[u]);
block_gain[p] += block_gain[u];
block_hits[p] += block_hits[u];
merged_kids[p].push_back(u);
block_top[u] = p;
push_block(p);
}
}
// 按出战顺序结算血量;中途死亡输出 -1
ll cur_hp = hp;
ll defense = armor;
for (int i = 0; i < order_cnt; i++) {
int u = order_list[i];
cur_hp -= hits[u] * (monster_atk[u] - defense);
if (cur_hp <= 0) {
cout << -1 << "\n";
return;
}
defense += gain[u];
}
cout << cur_hp << "\n";
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
build_parent();
solve();
return 0;
}复杂度
- 时间复杂度:
。每个点至多入堆、出堆各一次,路径压缩的并查集均摊 ,出战序列总长 。 - 空间复杂度:
,存树、并查集、子块链与堆。
总结
这道题的建模核心是把战斗折算成性价比:单场战斗的损失只取决于怪自己的
树上「父亲先于儿子」的限制不改变排序思想,只把单个点升级成块:堆弹出
实现上注意:怪物第