小O的树上路径
小O的树上路径
OPPO 笔试真题 · 2024 秋招 · 研发通用岗 / 2024 秋招 · 后端开发
判题请到牛客原题提交。
题目描述
小O有一棵树,树上的每一条边上都有一个一定价值的物品,从 1 号点开始,每个点最多只能走两次,小O想知道最多拿到多少价值的物品。
注意:物品拿走以后就没了。
输入格式
第一行输入一个整数 表示树上的点数。
此后 行,每行输入三个整数 和 表示 和 之间有一条边,边上有一个价值为 的物品。
输出格式
在一行上输出一个整数,表示小O最多能够拿到的物品价值。
示例
示例 1
输入:
4
1 2 1
1 3 2
1 4 3输出:
5说明:
小红走过的点依次为[1, 4, 1, 3],拿到价值为 3,2 的物品,1 号点走了两次,3 号点和 4 号点各走了一次。
解题思路
第一步:「每个点最多走两次」到底限制了什么
题面把「走」定义成「出现在走过的点序列里」。示例里的序列是[1, 4, 1, 3]:1 号点出现两次,4 号和 3 号各出现一次。
沿着树走,一个点 的出现次数等于
因为离开 (去孩子、或回父亲)不会再让 出现一次,只有「回到 」
才算。于是预算 2 次意味着:
每个点最多只允许「回来」一次。
再补一条:一条边来回走两次,物品只有一件,拿过就没了,所以每条边最多
贡献一次价值。
第二步:把合法走法拆成「主链 + 挂在主链上的支链」
沿着走法看:
- 「下去了不再回来」的那一步,不消耗出现次数,所以可以一路往下形成一条
从 1 号点出发的主链,直到走法的终点; - 主链上的每个点,还剩下一次「回来」的额度,可以下去再回来一次,
也就是额外挂一条向下的支链; - 支链上的点同样只剩一次「回来」额度,所以支链本身也只能是一条链,
不能再分叉。
于是所有合法走法都长这个样子:
一条从 1 开始的主链 + 主链上每个点最多挂一条向下的支链,
价值就是这些边的权值之和(同一条边只算一次)。
第三步:树形 DP
把 1 号点当根,定义两个量:
down[u] 表示「从 往下走一条链、再原路回到 」能拿到的最大价值,
它就是一条向下延伸的链。
(内层 在「没有别的孩子」时取 0。)
- 前一项:在 身上挂一条支链,然后停在 ;
- 后一项:主链继续走进孩子 ,同时 上再挂一条支链。支链必须挂在
别的孩子上,否则 到 这条边会被算两次。
实现时对每个 先求出孩子里 的最大值和次大值:
主链走的是最大值那个孩子,支链就用次大值;主链走别的孩子时,支链可以用
最大值。
答案是 best[1]。
完整代码实现
import sys
def solve() -> None:
"""迭代后序遍历树,自底向上算 down 与 best"""
data = sys.stdin.buffer.read().split()
if not data:
return
it = iter(data)
n = int(next(it))
adj = [[] for _ in range(n + 1)]
for _ in range(n - 1):
u = int(next(it)); v = int(next(it)); w = int(next(it))
adj[u].append((v, w))
adj[v].append((u, w))
# 以 1 为根,迭代建出 BFS 序(避免深链递归爆栈)
parent = [0] * (n + 1)
parent[1] = -1
order = [1]
for u in order:
for v, _ in adj[u]:
if v != parent[u]:
parent[v] = u
order.append(v)
down = [0] * (n + 1) # 向下一条链再回来
best = [0] * (n + 1) # 进入 u 后能拿到的最大值
for u in reversed(order):
# 先取孩子里最大的两个「探路」收益(留一个给别人,别和主链抢边)
first = second = 0
first_child = 0
for v, w in adj[u]:
if parent[v] != u:
continue
d = w + down[v]
if d > first:
second, first, first_child = first, d, v
elif d > second:
second = d
down[u] = first
cur = first # 探一次路就停在 u
for v, w in adj[u]:
if parent[v] != u:
continue
other = second if v == first_child else first
cand = w + best[v] + other # 主链走 v,再挂上最好的那个分支
if cand > cur:
cur = cand
best[u] = cur
sys.stdout.write(str(best[1]) + "\n")
if __name__ == "__main__":
solve()示例推演
以题面样例为例,树是
1
1/ |2 \3
2 3 4叶子 2、3、4 没有孩子,所以 down[2] = down[3] = down[4] = 0,best[2] = best[3] = best[4] = 0。
到 1 号点,先算三个孩子的探路收益 :
| 孩子 | |||
|---|---|---|---|
| 2 | 1 | 0 | 1 |
| 3 | 2 | 0 | 2 |
| 4 | 3 | 0 | 3 |
最大值 first = 3(孩子 4),次大值 second = 2(孩子 3),所以down[1] = 3。接着枚举主链往哪儿走:
| 主链走 | 主链收益 | 支链收益 | 合计 |
|---|---|---|---|
| 不走(停在 1) | 0 | 3 | 3 |
| 孩子 2 | 3 | 4 | |
| 孩子 3 | 3 | 5 | |
| 孩子 4 | 2 | 5 |
best[1] = 5,输出 5。对应的走法就是题面说明里的 1 → 4 → 1 → 3:
先下支链去 4(拿 3,再回来),主链再走到 3(拿 2)。
再看一个「支链挂在主链中间」的例子:边为 1-2(1)、2-3(50)、2-4(50)、1-5(2)。
| 节点 | down | best | 说明 |
|---|---|---|---|
| 3、4、5 | 0 | 0 | 叶子 |
| 2 | 50 | 100 | 主链走 3、支链挂 4(或反过来) |
| 1 | 103 | 主链走 2,1 上挂支链 5 |
最优走法是 1 → 5 → 1 → 2 → 3 → 2 → 4,拿到 。
复杂度分析
| 解法 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 枚举所有走法 | 指数级 | 指数级 | 走法数量随点数爆炸,不可行 |
| 树形 DP(本文) | 每个点只统计一次孩子,另加两次遍历 |
, 的两次遍历(建父节点、倒序转移)在 Python 里也是毫秒级。
易错点总结
1. 以为能走遍所有边
「每个点最多两次」是很紧的限制。以 1 为中心、三条边权为 1、2、3 的菊花图为
例,走 1→2→1→3→1→4 会让 1 号点出现三次,不合法;最多只能拿最大的两条边。
2. 主链和支链抢同一条边
主链走孩子 的时候, 上的支链不能再挂到 上,否则 到
这条边被算两次(其实也只拿得到一件物品)。所以要用「最大值 + 次大值」,
而不是「最大值 × 2」。
3. 递归写 DFS 爆栈
且树是一条链时,递归深度就是 。要么sys.setrecursionlimit(10**6),要么像本文这样先迭代求出遍历顺序,
再倒序做转移。
4. down 的下界忘了取 0
叶子节点的 down 必须是 0(原地不动),而不是负无穷。写 -inf 又忘了
在 max 里补 0,叶子就会算出负数。
5. 权值范围
、,答案最大约 ,超出 32 位整数范围。
Python 的 int 天然没问题;换成 C++ 要用 long long。
扩展思考
- 把「每个点最多走两次」改成「最多走 次」,则每个点可以挂 条支链,
只需把「最大值 + 次大值」换成「前 大的 」之和,复杂度仍然是
(排序)或 。 - 如果要求最后必须回到 1 号点,那么根节点也只剩一次「回来」的机会,
整条路线只能是「一条向下的链 + 原路返回」,答案退化成down[1]。 - 如果边权允许为负,
max(0, ...)的写法自动就对了:不划算的边不去走。 - 把「边上有物品」换成「点上有物品」,DP 结构几乎不变,只是计价值的位置
从边换到点,需要额外注意每个点的物品只算一次。