1. 题目背景与核心思路解析
NOIP 2003年的《加分二叉树》题目看似是一道传统的二叉树问题,实则暗藏玄机。题目给出一个关键条件:二叉树的中序遍历序列为(1,2,3,...,n)。这个条件直接决定了本题的解题方向。
中序遍历的特性是"左子树-根-右子树",而题目给定的中序序列是连续递增的整数。这意味着对于任意子树,其包含的节点编号必然构成一个连续区间。例如,若某子树包含节点3、4、5,那么这三个节点在中序序列中必定是连续的。
这个发现将问题转化为区间DP问题。我们可以将整个问题分解为若干子区间的最优解组合。具体来说:
- 对于区间[i,j],我们需要枚举每个可能的根节点k
- 计算以k为根时,左子树[i,k-1]和右子树[k+1,j]的最优值组合
- 通过比较所有可能的k值,找出区间[i,j]的最优解
这种分治思想是动态规划的典型应用,也是解决本题的核心所在。
2. 状态定义与转移方程详解
2.1 状态定义
我们定义两个关键状态数组:
dp[i][j]:表示由编号i到j的节点组成的子树能获得的最高加分root[i][j]:记录区间[i,j]取得最高分时的根节点编号
这两个数组构成了我们解决问题的基本数据结构。其中dp数组存储最优值,root数组存储最优解的结构信息,用于后续的前序遍历输出。
2.2 状态转移方程
转移方程的核心思想是枚举每个可能的根节点,计算对应的加分,然后取最大值。具体公式为:
code复制dp[i][j] = max(dp[i][k-1] * dp[k+1][j] + a[k]) for k in [i,j]
这个方程的含义是:
- 对于区间[i,j]中的每一个节点k,假设它作为根节点
- 计算左子树[i,k-1]的最高加分乘以右子树[k+1,j]的最高加分
- 再加上根节点k本身的分数a[k]
- 在所有可能的k中取最大值作为dp[i][j]的值
同时,我们需要记录使dp[i][j]取得最大值的k值,存入root[i][j]。
3. 算法实现细节与边界处理
3.1 初始化处理
正确的初始化是算法成功的关键。我们需要处理两种特殊情况:
- 空子树:当区间左端点大于右端点时,表示空树,根据题意其加分为1
cpp复制for(int i=1;i<=n+1;i++){ dp[i][i-1] = 1; } - 单节点子树(叶子节点):直接取节点自身的分数
cpp复制for(int i=1;i<=n;i++){ dp[i][i] = a[i]; root[i][i] = i; }
3.2 主循环结构
算法采用自底向上的方式,先处理小区间,再逐步扩大区间范围:
cpp复制for(int len=2;len<=n;len++){ // 区间长度从2开始
for(int i=1;i<=n-len+1;i++){ // 枚举区间起点
int j = i + len - 1; // 计算区间终点
for(int k=i;k<=j;k++){ // 枚举可能的根节点
long long temp = dp[i][k-1] * dp[k+1][j] + a[k];
if(temp > dp[i][j]){
dp[i][j] = temp;
root[i][j] = k;
}
}
}
}
3.3 前序遍历输出
利用root数组递归输出前序遍历结果:
cpp复制void print(int l, int r){
if(l > r) return;
int k = root[l][r];
cout << k << " ";
print(l, k-1);
print(k+1, r);
}
4. 关键注意事项与优化技巧
4.1 数据类型选择
题目说明最高加分可能达到4×10^9,这超过了32位int的范围(约2.1×10^9)。因此必须使用long long类型存储dp值:
cpp复制long long dp[35][35];
4.2 边界条件处理
四个易错边界条件需要特别注意:
- 空子树的初始化要扩展到n+1,防止右子树越界
- 单节点区间要单独初始化,避免进入主转移方程
- 枚举根节点时,必须包含区间端点(k<=j而非k<j)
- 输出前序遍历时,正确处理空子树返回条件
4.3 算法复杂度分析
该算法的时间复杂度为O(n^3),空间复杂度为O(n^2)。对于n=30的题目限制来说,这个复杂度是完全可接受的。
5. 完整代码实现与测试
以下是整合了所有优化和注意事项的完整代码:
cpp复制#include <iostream>
using namespace std;
const int MAXN = 35;
int n;
int a[MAXN];
long long dp[MAXN][MAXN];
int root[MAXN][MAXN];
void printPreorder(int l, int r) {
if(l > r) return;
int k = root[l][r];
cout << k << " ";
printPreorder(l, k-1);
printPreorder(k+1, r);
}
int main() {
cin >> n;
for(int i=1; i<=n; i++) cin >> a[i];
// 初始化
for(int i=1; i<=n+1; i++) {
dp[i][i-1] = 1; // 空子树
dp[i][i] = a[i]; // 单节点
root[i][i] = i; // 单节点的根是自己
}
// 区间DP
for(int len=2; len<=n; len++) {
for(int i=1; i<=n-len+1; i++) {
int j = i + len - 1;
for(int k=i; k<=j; k++) {
long long temp = dp[i][k-1] * dp[k+1][j] + a[k];
if(temp > dp[i][j]) {
dp[i][j] = temp;
root[i][j] = k;
}
}
}
}
cout << dp[1][n] << endl;
printPreorder(1, n);
return 0;
}
测试用例示例:
输入:
code复制5
5 7 1 2 10
输出:
code复制145
3 1 2 4 5
6. 算法扩展与变种思考
6.1 其他遍历顺序问题
本题的核心在于利用中序遍历的性质。如果题目改为给出前序或后序遍历序列,问题性质将完全不同。例如:
- 前序遍历+中序遍历:可以唯一确定二叉树结构
- 后序遍历+中序遍历:同样可以唯一确定二叉树
- 仅有前序或后序遍历:无法唯一确定二叉树
6.2 不同评分规则的影响
本题的评分规则是左子树加分×右子树加分+根节点分数。如果改为其他规则,如加法规则或最大值规则,算法框架仍然适用,只需修改转移方程即可。
6.3 更大规模数据的处理
对于n更大的情况(如n=1000),O(n^3)的算法可能不够高效。可以考虑以下优化:
- 四边形不等式优化:可以将复杂度降低到O(n^2)
- 记忆化搜索:可能在某些情况下减少不必要的计算
- 并行计算:利用现代CPU的多核特性加速计算
在实际编程竞赛中,理解算法本质比记忆模板更重要。这道题很好地展示了如何通过分析题目条件,将看似复杂的问题转化为经典算法模型的能力。