|
|
|
|
## [$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
|
|
|
|
|
```
|
|
|
|
|
在固定的中序序列条件下,枚举不同的根节点和叶子节点构建形态,可以获取不同的结构,也就是形态各异的二叉树,它们的前序遍历是不一样的。
|
|
|
|
|
|
|
|
|
|
<font color='red' size=4><b>解释:
|
|
|
|
|
- 心中有树,而手中无树
|
|
|
|
|
虽然给的是二叉树,但却无法用链式前向星把二叉树创建出来,因为这不是唯一的二叉树,不是一道图论题。
|
|
|
|
|
</b></font>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 闫式$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<j$。
|
|
|
|
|
|
|
|
|
|
- 回到本题,因为左子树右子树可以为空,也就是如果$i=k$时,左子树为空,如果$j=k$时,右子树为空, 题目中明确:**若某个子树为空,规定其加分为 $1$**,也就是此时$ls=1,rs=1$就可以得到正确的得分了。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 前序序列
|
|
|
|
|
|
|
|
|
|
我们只需要获取一个$f[i][j]$的时候,同时开一个相同大小的数组$g[i][j]$,存储此区间内的根节点是谁,其实这是一个递归定义,我们捋着这条线索,就可以一路向上(或向下),找出完整的二叉树形态,也就是在中序序列确定情况下的前序序列,捋的办法就是用递归。
|
|
|
|
|
|
|
|
|
|
**$Q$:如果存在多种方案,则输出字典序最小的方案。**
|
|
|
|
|
|
|
|
|
|
**答**: 因为$k$是从小到大遍历的,所以肯定是小的先存进去。而对比时采用了小于号,只有更小的才能更新,就保证取得的节点是字典序最小的方案。
|
|
|
|
|
|
|
|
|
|
另外一种就是不用记录数组,就是现推,现看$f[1][n]$是从哪个值转移过来的,边计算边退,两个都是一样的。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 四、$DP$代码
|
|
|
|
|
```cpp {.line-numbers}
|
|
|
|
|
#include <bits/stdc++.h>
|
|
|
|
|
|
|
|
|
|
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 <bits/stdc++.h>
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|