## [$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; } ```