## [$AcWing$ $479$. 加分二叉树](https://www.acwing.com/problem/content/description/481/)
### 一、题目描述
设一个 $n$ 个节点的二叉树 $tree$ 的 **中序遍历** 为$(1,2,3,…,n)$,其中数字 $1,2,3,…,n$ 为节点编号。
每个节点都有一个分数(均为正整数),记第 $i$ 个节点的分数为 $d_i$,$tree$ 及它的每个子树都有一个加分,任一棵子树 $subtree$(也包含 $tree$ 本身)的加分计算方法如下:
$subtree$的左子树的加分 $×$ $subtree$的右子树的加分 $+$ $subtree$的根的分数
若某个子树为空,规定其加分为 $1$。
叶子的加分就是叶节点本身的分数,不考虑它的空子树。
试求一棵符合中序遍历为$(1,2,3,…,n)$且加分最高的二叉树 $tree$。
要求输出:
(1)$tree$的最高加分
(2)$tree$的前序遍历
**输入格式**
第 $1$ 行:一个整数 $n$,为节点个数。
第 $2$ 行:$n$ 个用空格隔开的整数,为每个节点的分数($0$<分数<$100$)。
**输出格式**
第 $1$ 行:一个整数,为最高加分(结果不会超过$int$范围)。
第 $2$ 行:$n$ 个用空格隔开的整数,为该树的前序遍历。如果存在多种方案,则输出字典序最小的方案。
**数据范围**
$n<30$
**输入样例**:
```cpp {.line-numbers}
5
5 7 1 2 10
```
**输出样例**:
```cpp {.line-numbers}
145
3 1 2 4 5
```
### 三、解题思路
**前导知识:[二叉树的三种遍历方式](https://www.cnblogs.com/littlehb/p/15089114.html)**
题目的输入为这$n$个点 **中序序列** 的权值,我们直接存储。这里科普一下,一个二叉树的中序序列就是这个二叉树的节点 **向下投影** 构成的序列。
而事实上仅靠投影无法断定这个二叉树的具体结构,因此前序遍历的序列也会不同。
比如`1 2 3 4 5`这个中序序列
它可以是
```cpp {.line-numbers}
3
2 4
1 5
```
也可以是
```cpp {.line-numbers}
3
1 4
2 5
```
当然也可以是
```cpp {.line-numbers}
4
2 5
1 3
```
在固定的中序序列条件下,枚举不同的根节点和叶子节点构建形态,可以获取不同的结构,也就是形态各异的二叉树,它们的前序遍历是不一样的。
解释:
- 心中有树,而手中无树
虽然给的是二叉树,但却无法用链式前向星把二叉树创建出来,因为这不是唯一的二叉树,不是一道图论题。
### 闫式$DP$分析法
#### 状态表示
**集合**
$f[i][j]$:从$i$到$j$区间内,选一点$k$作为根节点,表示中序遍历是 $w[i \sim j]$ 的所有二叉树的集合
**属性**
加分二叉树的$Max$最大值
#### 状态转移
任选节点$k$,以$k$点为根构成的加分二叉树的值=它的左子树的最大加分值 $\times$ 右子树的最大加分值 $+$ $k$点权值
$$\large f[i][j] = max(f[i][j], f[i][k - 1] * f[k + 1][j] + w[k]) (i <= k <= j)$$
**$Q$:石子合并枚举的$k$满足条件为$i <= k < j$,为什么本题是$i<=k<=j$呢?**
答:
- 石子合并至少需要两堆才能合并,即有$k$,也必须有$k + 1$,才能划分成两个区间。
换句话说,就是$i<=k,k+1<=j$,也就是$i<=k
using namespace std;
const int N = 50;
int n;
int w[N]; // 权值数组
int f[N][N]; // DP数组 : i,j区间内的最大得分
int g[N][N]; // 路径数组:i,j区间内的最大得分,是在k这个节点为根的情况下获得的
// 前序遍历输出路径
void out(int l, int r) {
/*
其实,最后一个可以执行的条件是l=r,也就是区间内只有一个节点
当只有一个节点时,g[l][r] = l = r ①,此时继续按划分逻辑划分的话:
就是: [l,g[l][r]-1],[g[l][r]+1,r] ②
将①代入②,就是[l,l-1],[r+1,r],很明显,当出现前面比后面还大时,递归应该结束了
*/
if (l > r) return;
cout << g[l][r] << " "; // 输出根结点,前序遍历
out(l, g[l][r] - 1); // 递归左子树
out(g[l][r] + 1, r); // 递归右子树
}
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &w[i]);
/* DP初始化
① 因为左、右子树为空时,返回1,这其实也是为计算公式准备前提,按返回1计算时,无左右子树的点
,也就是叶子节点时,得分就是w[i]
② 此时,记录的辅助路径数组,g[i][i]表示的就是从i到i的区间内,根是谁,当然是i
Q:为什么一定要记录g[i][i]=i,不记录不行吗?一个点的区间为什么也要记录根是谁呢?
答:因为这是递归的出口,如果不记录的话,那么out输出时就会找不到出口,导致死循环,TLE或者MLE
*/
for (int i = 1; i <= n; i++)
f[i][i] = w[i], g[i][i] = i;
// 区间DP
for (int len = 2; len <= n; len++) // 单个节点组成的区间都是初值搞定了,我们只讨论两个长度的区间
for (int l = 1; l + len - 1 <= n; l++) { // 枚举左端点
int r = l + len - 1; // 计算右端点
// 枚举根节点k, 两个区间:[l,k-1],[k+1,r],根节点k也占了一个位置
// 注意:k是可以取到r的,原因论述见题解
for (int k = l; k <= r; k++) {
// 根据题意特判
int ls = k == l ? 1 : f[l][k - 1]; // 左子树为空,返回1
int rs = k == r ? 1 : f[k + 1][r]; // 右子树为空,返回1
// 得分计算公式
int t = ls * rs + w[k];
// 记录取得最大值时的根节点k
if (f[l][r] < t) {
f[l][r] = t;
g[l][r] = k; // 记录l~r区间的最大得分是由哪个根节点k转化而来
}
}
}
// 输出
cout << f[1][n] << endl;
// 利用递归,输出字典序路径
out(1, n);
return 0;
}
```
### 五、记忆化搜索
```cpp {.line-numbers}
#include
using namespace std;
const int N = 35;
int n;
int w[N];
int f[N][N];
int g[N][N];
// 计算最大结果
int dfs(int l, int r) {
int &v = f[l][r]; // 简化代码
if (v) return v; // 记忆化搜索
if (l == r) return g[l][r] = l, v = w[l]; // 叶子分数是权值
if (l > r) return v = 1; // 题设空子树的分数为1,递归出口
for (int k = l; k <= r; k++) {
// 因为k是枚举根,根占了一个点,所以,左侧是[l,k-1],右侧是[k+1,r]
int t = dfs(l, k - 1) * dfs(k + 1, r) + w[k];
if (t > v) v = t, g[l][r] = k; // 记录第一次出现最大值时的k,方便输出字典序
}
return v;
}
// 前序遍历输出路径
void out(int l, int r) {
if (l > r) return; // 递归出口
cout << g[l][r] << " "; // 输出最优根
out(l, g[l][r] - 1); // 递归左子树
out(g[l][r] + 1, r); // 递归右子树
}
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &w[i]);
// 区间1~n的最大结果
cout << dfs(1, n) << endl;
// 输出路径
out(1, n);
return 0;
}
```