函数图即基环树森林:挂树自底向上算“牺牲一个孩子”的最小代价,环上枚举环尾状态后线性 DP,整体 O(N)。
OJ: roj
题目 ID: 3191
难度:提高+/省选-
标签:图论基环树树形DP
创建: 2026-10-02 00:26
更新: 2026-10-10 13:00
形式化题目
有
求
注意约束是单向的:只对"被投放"的点提要求。没被投放的点不受任何限制,
所以"一个都不投放"永远合法,答案至少是
由于每个点出度为
以样例
graph LR
g1["1"] --> g2["2"]
g2 --> g3["3"]
g3 --> g1
g4["4"] --> g3
g5["5"] --> g6["6"]
g6 --> g5
左边
正解
思路
第一步:把条件翻译成"孩子"的语言。
把边反向,记
即:
第二步:先做树。 环外的点构成以环上点为根的内向树。对树上的点
| 记号 | 含义 |
|---|---|
| 在 |
子树各自独立,
取 gain 的取值只可能是
于是对
这一步实现时不必显式建树:用入度做拓扑排序(剥叶子)自底向上汇总即可,
剥完还剩下入度非
第三步:再做环。 环上点
只要枚举环尾是否投放这两种情况,环就断成一条链,链上相邻两点之间是一条真实的
限制关系,可以线性 DP 扫完。用
| 新状态 | 取值 | 说明 |
|---|---|---|
两种环尾状态分别给环首定下不同的初值,并在链尾取对应的那一维:
| 环尾 | 环首 |
链尾取 |
|---|---|---|
| 不投放 | ||
| 投放 |
长度为
用样例走一遍。 点
复杂度。 每个点进出栈一次、环上每个点被扫两次,时间
代码
/**
* 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-10 13:00
* update_at: 2026-10-10 13:00
*/
// 基环树森林:剥掉挂树后每个环单独 DP,求最多能投放多少个元素
#include <cstdio>
#include <vector>
using namespace std;
typedef long long ll;
const ll NEG = -(1LL << 50); // 非法状态;合法值恒为非负
const int NO_CHILD = 2; // gain 的初值:没有孩子就付不出“牺牲”
int n;
vector<int> parent; // parent[u] 就是题面的 A[u]
vector<int> indeg; // 入度 = 孩子个数;> 0 表示落在某个环上
vector<int> off; // off[v]:v 不投放时,v 的子树(不含 v)最多能投放多少个
vector<int> gain; // gain[v]:逼 v 的某个孩子不投放所需的最小牺牲
// 剥掉所有不在环上的点,自底向上把每棵挂树压成两个数
void peel_trees() {
indeg.assign(n + 1, 0);
for (int v = 1; v <= n; v++) {
indeg[parent[v]]++;
}
off.assign(n + 1, 0);
gain.assign(n + 1, NO_CHILD);
vector<int> stk;
for (int v = 1; v <= n; v++) {
if (indeg[v] == 0) {
stk.push_back(v); // 环外的叶子先入栈
}
}
while (!stk.empty()) {
int v = stk.back();
stk.pop_back();
int slack = (gain[v] >= 1) ? 0 : 1 - gain[v]; // 孩子本来未必投放,牺牲可以减免
int p = parent[v];
off[p] += off[v] + slack;
if (slack < gain[p]) {
gain[p] = slack;
}
indeg[p]--;
if (indeg[p] == 0) {
stk.push_back(p);
}
}
}
// 一个环最多能投放多少个;ring 按「孩子在前、父亲在后」排列
ll ring_best(const vector<int>& ring) {
int head = ring[0];
ll on_tree = (gain[head] < NO_CHILD) ? (ll)off[head] + 1 - gain[head] : NEG;
if ((int)ring.size() == 1) { // 自环:唯一的环上候选是自己,不能当见证
return off[head] > on_tree ? off[head] : on_tree;
}
ll best = NEG;
for (int tail_off = 1; tail_off >= 0; tail_off--) {
ll dp0 = off[head];
ll dp1 = tail_off ? (ll)off[head] + 1 : on_tree;
for (int t = 1; t < (int)ring.size(); t++) {
int v = ring[t];
ll free_choice = dp0 > dp1 ? dp0 : dp1; // 环上前驱不投放,v 才可以投放
ll by_tree = (gain[v] < NO_CHILD) ? dp1 - gain[v] : NEG;
ll best_pred = dp0 > by_tree ? dp0 : by_tree;
dp1 = (ll)off[v] + 1 + best_pred;
dp0 = free_choice + off[v]; // v 不投放,挂树各自取最优
}
ll cur = tail_off ? dp0 : dp1; // 环尾不投放取 dp0,投放取 dp1
if (cur > best) {
best = cur;
}
}
return best;
}
int main() {
if (scanf("%d", &n) != 1) {
return 0;
}
parent.assign(n + 1, 0);
for (int i = 1; i <= n; i++) {
scanf("%d", &parent[i]);
}
peel_trees();
ll total = 0;
for (int s = 1; s <= n; s++) {
if (indeg[s] == 0) {
continue; // 已被剥掉,贡献早已算进它所属的环
}
vector<int> ring;
int u = s;
while (indeg[u] > 0) { // 顺着 parent 走,孩子总在父亲前面
ring.push_back(u);
indeg[u] = 0; // 打标记,免得同一个环被走第二遍
u = parent[u];
}
total += ring_best(ring);
}
printf("%lld\n", total);
return 0;
}#!/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 00:34
# update_at: 2026-10-02 00:56
import sys
from array import array
from itertools import chain
NEG = -(1 << 50) # 非法状态;合法值恒为非负
NO_CHILD = 2 # gain 的初值:没有孩子就付不出"牺牲",且 max(0, 1 - 2) = 0 正好等价于无穷大
def peel_trees(parent: array, n: int) -> tuple[array, array, array]:
"""剥掉所有不在环上的点,自底向上把每棵挂树压成两个数。
parent[u] 就是题面的 A[u];u 的见证者集合恰好是 parent 为 u 的那些点。
off[v] v 不投放时,v 的子树(不含 v)最多能投放多少个
gain[v] 逼 v 的某个孩子不投放所需的最小牺牲,没有孩子时是 NO_CHILD
返回的 indeg[v] > 0 表示 v 剥不掉,也就是落在某个环上。
"""
indeg = array('i', bytes(4 * (n + 1)))
for v in range(1, n + 1):
indeg[parent[v]] += 1 # 入度正是 v 的孩子个数
off = array('i', bytes(4 * (n + 1)))
gain = array('b', bytes([NO_CHILD]) * (n + 1))
stack = [v for v in range(1, n + 1) if indeg[v] == 0] # 环外的叶子先入栈
while stack:
v = stack.pop()
slack = 0 if gain[v] >= 1 else 1 - gain[v] # 孩子本来未必投放,牺牲可以减免
p = parent[v]
off[p] += off[v] + slack
if slack < gain[p]:
gain[p] = slack
indeg[p] -= 1
if indeg[p] == 0: # 孩子都处理完了,v 也成了叶子
stack.append(p)
return indeg, off, gain
def ring_best(ring: list[int], off: array, gain: array) -> int:
"""一个环最多能投放多少个。ring 按“孩子在前、父亲在后”排列。
于是 A[ring[i]] = ring[i-1]:轮到 ring[i] 时,它在环上的见证者 ring[i-1] 已经定好,
dp0 / dp1 就是“ring[i] 不投放 / 投放”的最优值。环尾 ring[-1] 是环首 ring[0] 的
见证者,故枚举环尾状态两次,让环首取到对应的初值,并在链尾取回对应的一维。
"""
head = ring[0]
on_tree = off[head] + 1 - gain[head] if gain[head] < NO_CHILD else NEG # 只靠挂树当见证
if len(ring) == 1: # 自环:唯一的环上候选是自己,不能当见证
return off[head] if off[head] > on_tree else on_tree
best = NEG
for tail_off in (True, False):
dp0 = off[head]
dp1 = off[head] + 1 if tail_off else on_tree
for v in ring[1:]:
free = dp0 if dp0 > dp1 else dp1 # 环上前驱不投放,v 才可以投放
by_tree = dp1 - gain[v] if gain[v] < NO_CHILD else NEG
dp1 = off[v] + 1 + (dp0 if dp0 > by_tree else by_tree)
dp0 = free + off[v] # v 不投放,挂树各自取最优
cur = dp0 if tail_off else dp1 # 环尾不投放取 dp0,投放取 dp1
if cur > best:
best = cur
return best
def best_total(parent: array, n: int) -> int:
"""整片基环树森林最多能投放多少个元素。"""
indeg, off, gain = peel_trees(parent, n)
total = 0
for s in range(1, n + 1):
if indeg[s] == 0: # 已被剥掉,贡献早已算进它所属的环
continue
ring: list[int] = []
u = s
while indeg[u] > 0: # 顺着 parent 走,孩子总在父亲前面
ring.append(u)
indeg[u] = 0 # 打标记,免得同一个环被走第二遍
u = parent[u]
total += ring_best(ring, off, gain)
return total
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data) # 第一行是元素个数
parent = array('i', chain([0], data)) # 用 0 占位,使 A[i] 落在下标 i
sys.stdout.write(str(best_total(parent, n)) + "\n")
if __name__ == "__main__":
solve()复杂度
时间
实现上有一点需要留意:list[int] 会占掉约 160MB,
超过本题 128MB 的限制。因此 parent、indeg、off 用 array('i')(32 位整型),
gain 只取 array('b'),把峰值压到约 85MB。
总结
这道题的难点不在 DP 本身,而在看出图的结构:每个点出度为
图示解析
这张图串起本题从建模到得到答案的主线:
输入: 每个点一条出边 i -> A[i]
`- 每个弱连通块恰好一个环 => 基环树森林
|- 环外的挂树: 自底向上剥叶子
| |- off[v] = Σ (off[c] + slack[c]) v 不投放
| `- gain[v] = min slack[c] 逼一个孩子不投放的最小牺牲
`- 环上: 枚举环尾投放 / 不投放两种, 环断成链
|- dp0' = max(dp0, dp1) + off[v]
`- dp1' = max(dp0, dp1 - gain[v]) + off[v] + 1
`- 各连通块最优值相加 = 答案顺着读:函数图决定了"一个环 + 若干挂树"的结构;挂树只向环上传两个数