叶值的最小代价生成树 图解题解
这道题到底在问什么
- 输入
- arr = [4,11]
- 输出
- 44(只有一种树,根 = 4×11)
- 输入
- arr = [6,2,4]
- 输出
- 32(两种树里更小的那个)
- 输入
- 本节演示 arr = [6,2,4,5]
- 输出
- 58
先想最直接的笨办法
记住这条转移:一段叶子拆成左右两块,代价 = 左块代价 + 右块代价 + 两块最大叶的乘积。枚举所有切法取最小,就是这段的 dp 值。(动画第 3 步)
最优解:为什么这么做
一句话答案:LeetCode 1130 叶值的最小代价生成树:叶子顺序锁死,内部节点值=左右两块最大叶之积,求和最小。用区间 DP,dp[i][j] 枚举切点取左段+右段+两块最大叶之积的最小,时间 O(n³)、空间 O(n²)。
叶值的最小代价生成树,究竟在求什么
给一个正整数数组 arr,把这些数当二叉树的叶子(二叉树=每个点最多两个孩子,最下面不分叉的点叫叶子),叶子从左到右的顺序必须和数组一致、不能打乱。每个非叶节点(有两孩子的内部节点)的值=左边叶子的最大值乘右边叶子的最大值。所有能搭出的树里,要让全部非叶节点值之和最小。本文用 arr=[6,2,4,5] 演示,最小和 58。
把每种搭法都试一遍,为什么算不完
n 片叶子能搭出的不同二叉树按卡特兰数(随规模指数级暴涨的组合数)疯长,n=40 已是天文数字,逐棵枚举跑不完。更要命的是重复:算整段 [6,2,4,5] 时,子段 [2,4] 的最优代价会在多棵大树里各算一遍。
既然子段最优解反复被用,就算一次存起来,这就是动态规划(把每段叶子的最优代价先算好存下,更长区间直接取用)。所以叫区间 DP(状态=每步要盯住的量,这里是一段叶子的端点 [i,j])。大 O 记号(描述规模变大时运算量怎么涨)把指数枚举压回 O(n³)。
状态定成一段区间,dp[i][j] 存的是什么
把 dp[i][j] 定义成:只用第 i 到第 j 这段叶子搭树的最小非叶节点值之和。(i,j)是表的(行号,列号),从 0 数起,只有 i≤j 的右上三角有意义。对角线 dp[i][i] 是单独一片叶子,没有内部节点、代价 0,是不用再往下拆的最简单情况(base case)。答案在最右上角 dp[0][n-1],填表从短区间到长区间。
切点怎么枚举,根的那笔乘积从哪来
算 dp[i][j] 时,在这段叶子中间选一条缝隙切成左块 [i,k] 和右块 [k+1,j]。左块最优代价 dp[i][k]、右块 dp[k+1][j],接起两块的根值=左块最大叶乘右块最大叶(每块各取最大叶,题目规定),即 max(i..k)×max(k+1..j),三项相加是这种切法的代价。缝隙落在 i 到 j-1 的任意位置,取所有切法的最小就是 dp[i][j]。
拿 [6,2,4,5] 亲手把 dp 表填一遍
四片单叶先填 0。长度 2 只有一种切法:[6,2] 得 6×2=12,[2,4] 得 2×4=8,[4,5] 得 4×5=20。
长度 3 比两种切法。[6,2,4] 在 6 后切 0+8+6×4=32、在 2 后切 12+0+6×4=36,取 32;[2,4,5] 在 2 后切 0+20+2×5=30、在 4 后切 8+0+4×5=28,取 28。
整段 [6,2,4,5] 比三种:在 6 后切 0+28+6×5=58、在 2 后切 12+20+6×5=62、在 4 后切 32+0+6×5=62,最小 58 就是答案,和题面对上。
根写成两块之和,dp[0][1] 为什么从 12 塌成 8
复杂度:区间约 n² 个,每个枚举最多 n 个切点,时间 O(n³);dp 表 n×n,空间 O(n²),n≤40 够用。
根取成两块之和是最常见的错,dp[0][1] 会算成 6+2=8 而不是 6×2=12,答案错到底。也别取成整段最大叶;叶子顺序也不能重排,一换位置最优树就变了。边界也能手验:两片叶子只一种树,全 1 时每根都是 1×1。
▶ 动画逐步走查(共 26 步)——想跟着动画一帧帧对照就展开
- 3记住这条转移:一段叶子拆成左右两块,代价 = 左块代价 + 右块代价 + 两块最大叶的乘积。枚举所有切法取最小,就是这段的 dp 值。
- 4这是一张 4×4 的 dp 表,行号 i、列号 j,只有 i ≤ j 的右上三角有意义。先看对角线:dp[0][0]、dp[1][1] 一直到 dp[3][3],它们都是单个叶子,自己就是叶子、没有内部节点,代价全是 0。我们要的答案在最右上角 dp[0][3],也就是整段 [6,2,4,5] 的最优解。填表的顺序是从短区间到长区间,因为长区间要用到短区间的结果。
- 5先把每个内部节点的代价讲透。任意一段叶子切成左右两块后,接它俩的那个根,值就是左块里最大的叶子乘以右块里最大的叶子。比如把 [6] 和 [2] 接起来,根就是 6 乘 2 等于 12。注意取的是每一块的最大叶子,不是整段的最大值,也不是叶子之和。后面每一格 dp 都在反复用这条规则。
- 6轮到算 dp[0][1],也就是叶子 [6,2] 这一段。它要在中间选一个缝隙 k 切成左右两块,缝隙可以在 0 到 0 之间。每个切法都得算一遍代价,然后取最小的那个。
- 7切点 k=0,把 [6,2] 切成左块 [6] 和右块 [2]。左块最优是 dp[0][0]=0,右块是 dp[1][1]=0,再加上接它俩的根 6 乘 2 等于 12。三项相加 12。先记下这个候选。
- 8所有切法比完:k=0→12。最小的是 k=0 给出的 12,把它写进 dp[0][1]。这一格定下来了,后面更长的区间就能直接拿它当左块或右块用。
- 9轮到算 dp[1][2],也就是叶子 [2,4] 这一段。它要在中间选一个缝隙 k 切成左右两块,缝隙可以在 1 到 1 之间。每个切法都得算一遍代价,然后取最小的那个。
- 10切点 k=1,把 [2,4] 切成左块 [2] 和右块 [4]。左块最优是 dp[1][1]=0,右块是 dp[2][2]=0,再加上接它俩的根 2 乘 4 等于 8。三项相加 8。先记下这个候选。
- 11所有切法比完:k=1→8。最小的是 k=1 给出的 8,把它写进 dp[1][2]。这一格定下来了,后面更长的区间就能直接拿它当左块或右块用。
- 12轮到算 dp[2][3],也就是叶子 [4,5] 这一段。它要在中间选一个缝隙 k 切成左右两块,缝隙可以在 2 到 2 之间。每个切法都得算一遍代价,然后取最小的那个。
- 13切点 k=2,把 [4,5] 切成左块 [4] 和右块 [5]。左块最优是 dp[2][2]=0,右块是 dp[3][3]=0,再加上接它俩的根 4 乘 5 等于 20。三项相加 20。先记下这个候选。
- 14所有切法比完:k=2→20。最小的是 k=2 给出的 20,把它写进 dp[2][3]。这一格定下来了,后面更长的区间就能直接拿它当左块或右块用。
- 15轮到算 dp[0][2],也就是叶子 [6,2,4] 这一段。它要在中间选一个缝隙 k 切成左右两块,缝隙可以在 0 到 1 之间。每个切法都得算一遍代价,然后取最小的那个。
- 16切点 k=0,把 [6,2,4] 切成左块 [6] 和右块 [2,4]。左块最优是 dp[0][0]=0,右块是 dp[1][2]=8,再加上接它俩的根 6 乘 4 等于 24。三项相加 32。先记下这个候选。
- 17切点 k=1,把 [6,2,4] 切成左块 [6,2] 和右块 [4]。左块最优是 dp[0][1]=12,右块是 dp[2][2]=0,再加上接它俩的根 6 乘 4 等于 24。三项相加 36。比当前最优 32 大,这个切法不划算。
- 18所有切法比完:k=0→32, k=1→36。最小的是 k=0 给出的 32,把它写进 dp[0][2]。这一格定下来了,后面更长的区间就能直接拿它当左块或右块用。
- 19轮到算 dp[1][3],也就是叶子 [2,4,5] 这一段。它要在中间选一个缝隙 k 切成左右两块,缝隙可以在 1 到 2 之间。每个切法都得算一遍代价,然后取最小的那个。
- 20切点 k=1,把 [2,4,5] 切成左块 [2] 和右块 [4,5]。左块最优是 dp[1][1]=0,右块是 dp[2][3]=20,再加上接它俩的根 2 乘 5 等于 10。三项相加 30。先记下这个候选。
- 21切点 k=2,把 [2,4,5] 切成左块 [2,4] 和右块 [5]。左块最优是 dp[1][2]=8,右块是 dp[3][3]=0,再加上接它俩的根 4 乘 5 等于 20。三项相加 28。比之前的 30 还小,刷新最优。
- 22所有切法比完:k=1→30, k=2→28。最小的是 k=2 给出的 28,把它写进 dp[1][3]。这一格定下来了,后面更长的区间就能直接拿它当左块或右块用。
- 23轮到算 dp[0][3],也就是叶子 [6,2,4,5] 这一段。它要在中间选一个缝隙 k 切成左右两块,缝隙可以在 0 到 2 之间。每个切法都得算一遍代价,然后取最小的那个。
- 24切点 k=0,把 [6,2,4,5] 切成左块 [6] 和右块 [2,4,5]。左块最优是 dp[0][0]=0,右块是 dp[1][3]=28,再加上接它俩的根 6 乘 5 等于 30。三项相加 58。先记下这个候选。
- 25切点 k=1,把 [6,2,4,5] 切成左块 [6,2] 和右块 [4,5]。左块最优是 dp[0][1]=12,右块是 dp[2][3]=20,再加上接它俩的根 6 乘 5 等于 30。三项相加 62。比当前最优 58 大,这个切法不划算。
- 26切点 k=2,把 [6,2,4,5] 切成左块 [6,2,4] 和右块 [5]。左块最优是 dp[0][2]=32,右块是 dp[3][3]=0,再加上接它俩的根 6 乘 5 等于 30。三项相加 62。比当前最优 58 大,这个切法不划算。
- 27所有切法比完:k=0→58, k=1→62, k=2→62。最小的是 k=0 给出的 58,把它写进 dp[0][3]。这一格定下来了,后面更长的区间就能直接拿它当左块或右块用。
- 28整张表填满了,右上角 dp[0][3] = 58 就是最终答案。回看这条最优路线:先把中间的小叶子 2 和它较小的邻居配掉,再让 4 跟较小的邻居配,最后剩 6 和 5 接到根,层层乘积加起来正好 58。叶子越小越早被乘掉、乘的邻居越小越省,这就是为什么最优解长这样。
⚠️ 容易写错的地方
✗ 错:以为可以随意给叶子配对、打乱顺序建树
✓ 对:叶子的中序顺序锁死,只能在相邻区间里切
题目要求叶子按 arr 顺序做中序遍历,所以建树等价于不断把一段相邻叶子切成左右两块
✗ 错:把根的值算成两块叶子之和,或整段的最大值
✓ 对:根 = 左块最大叶 × 右块最大叶,是乘积,且各取各块的最大
内部节点定义就是左右子树最大叶的乘积,取错成和或取错范围,结果全错
✗ 错:照搬 C++ 那句 f[i][j] 大于 0 当已算标记到别的题
✓ 对:只有当区间代价必为正时这个哨兵才安全
本题长度 ≥ 2 的区间代价一定大于 0、单叶返回 0 不写表,所以 0 不会和已算混淆;代价可能为 0 的题要改用 null 或单独的 visited 标记
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def mctFromLeafValues(self, arr: List[int]) -> int:
@cache
def dfs(i: int, j: int) -> Tuple:
if i == j:
return 0, arr[i]
s, mx = inf, -1
for k in range(i, j):
s1, mx1 = dfs(i, k)
s2, mx2 = dfs(k + 1, j)
t = s1 + s2 + mx1 * mx2
if s > t:
s = t
mx = max(mx1, mx2)
return s, mx
return dfs(0, len(arr) - 1)[0]C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int mctFromLeafValues(vector<int>& arr) {
int n = arr.size();
int f[n][n];
int g[n][n];
memset(f, 0, sizeof(f));
for (int i = n - 1; ~i; --i) {
g[i][i] = arr[i];
for (int j = i + 1; j < n; ++j) {
g[i][j] = max(g[i][j - 1], arr[j]);
}
}
function<int(int, int)> dfs = [&](int i, int j) -> int {
if (i == j) {
return 0;
}
if (f[i][j] > 0) {
return f[i][j];
}
int ans = 1 << 30;
for (int k = i; k < j; ++k) {
ans = min(ans, dfs(i, k) + dfs(k + 1, j) + g[i][k] * g[k + 1][j]);
}
return f[i][j] = ans;
};
return dfs(0, n - 1);
}
};Java
import java.util.*;
class Solution {
private Integer[][] f;
private int[][] g;
public int mctFromLeafValues(int[] arr) {
int n = arr.length;
f = new Integer[n][n];
g = new int[n][n];
for (int i = n - 1; i >= 0; --i) {
g[i][i] = arr[i];
for (int j = i + 1; j < n; ++j) {
g[i][j] = Math.max(g[i][j - 1], arr[j]);
}
}
return dfs(0, n - 1);
}
private int dfs(int i, int j) {
if (i == j) {
return 0;
}
if (f[i][j] != null) {
return f[i][j];
}
int ans = 1 << 30;
for (int k = i; k < j; k++) {
ans = Math.min(ans, dfs(i, k) + dfs(k + 1, j) + g[i][k] * g[k + 1][j]);
}
return f[i][j] = ans;
}
}复杂度
时间
O(n³)
一共约 n² 个区间状态 dp[i][j],每个状态要枚举最多 n 个切点 k,所以是 n² × n。题目 n ≤ 40,完全够用
空间
O(n²)
按峰值算:dp 备忘表 n×n,C++/Java 还有一张同样大小的最大叶表 g,都是平方级。自顶向下递归再额外占 O(n) 栈深
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 叶值的最小代价生成树 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
参考代码的 dfs(i,j) 为什么一次返回两个值?+
Python 版的 dfs(i,j) 返回一个元组(最小代价,这段叶子的最大值)。返回最大值是为了省掉单独预处理一张最大叶表:枚举切点时,直接拿左右两个子调用返回的 mx 相乘当根,不必再扫一遍区间求最大。C++ 和 Java 换了种分工:先用两层循环预处理 g[i][j]=max(g[i][j-1],arr[j]) 这张最大叶表,dfs 只返回代价,枚举切点时查 g 表算乘积。两种写法算的是同一张 dp,只是最大叶从递归返回值取还是从预处理表取。
这题能不能优化到 O(n)?+
能,用单调栈(一种栈,里面元素保持单调,常用来给每个数找左右第一个更大或更小的值)。核心观察是:一个较小的叶子迟早要和某个邻居相乘被消掉,让它乘上两侧较小的那个邻居最省。单调栈维护一个递减序列,每弹出一个数就把它和两侧较小邻居相乘、累加进答案,一趟 O(n) 扫完。区间 DP 的 O(n³) 更通用也更好想(312 戳气球就是同款区间 DP、枚举切点),单调栈是想通规律之后的进阶优化。
为什么根取的是两块最大叶的乘积,而不是整段最大或叶子之和?+
这是题目对内部节点值的定义,不是可选项:每个非叶节点值=左子树最大叶×右子树最大叶。理解最优解为什么长那样也顺着这条规则——越小的叶子越该早点和较小的邻居相乘、被消化掉,免得它反复参与后面更大的乘积。[6,2,4,5] 的最优路线里,2 先和邻居配掉、4 再和较小邻居配,最后剩 6 和 5 接到根,层层乘积正好加到 58。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 叶值的最小代价生成树 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。