区间动态规划例题:最小成本构建唯一二叉搜索树问题(进阶:带深度限制与操作成本)
题目描述
给定一个由 n 个不同整数组成的排序数组 keys(升序),表示二叉搜索树(BST)中所有节点的键值。同时给定一个长度为 n+1 的数组 freq,其中 freq[i] 表示第 i 个键值(对应 keys[i])的搜索频率,freq[n] 表示所有不存在于 keys 中的值的搜索频率(即失败搜索的频率,通常对应叶子节点下的空指针)。
此外,还给出以下限制和成本:
- 深度限制:树中任何节点的深度不能超过一个给定的整数
maxDepth(根节点深度为 0)。 - 操作成本:对每个节点,如果其深度为
d,则访问该节点的成本为costPerDepth[d](一个给定数组,长度为maxDepth+1)。对于失败搜索(即访问空指针),其成本基于其父节点的深度计算(即深度d的空指针访问成本为costPerDepth[d])。
目标是构建一棵满足深度限制的二叉搜索树,并最小化以下总代价:
\[\text{总代价} = \sum_{i=0}^{n-1} \text{freq}[i] \times (\text{访问键值} \text{keys}[i] \text{的成本}) + \sum_{j=0}^{n} \text{freq}[j] \times (\text{访问第} j \text{个空指针的成本}) \]
其中访问键值的成本等于其节点深度对应的 costPerDepth 值;访问空指针的成本等于其父节点深度对应的 costPerDepth 值(若父节点深度为 d)。
解题思路
这是经典最优二叉搜索树(Optimal BST)问题的扩展,增加了深度限制和基于深度的可变访问成本。
核心挑战在于:在构建BST时,不仅要考虑频率加权路径长度,还要确保树不超过最大深度,且节点的访问成本随深度变化。
-
状态定义
设dp[l][r][d][k]表示:用子数组keys[l..r](索引从l到r,包含两端)构建一棵满足深度限制的二叉搜索树,且该树的根节点深度恰好为d,且该树在区间[l, r]上构建,同时该树的根节点是区间中的第k个元素(即keys[l + k]为根,k从 0 到r-l)。
这个状态表示以keys[l+k]为根、深度为d的BST的最小总代价(包括该子树内所有键值和失败搜索的代价)。
但这样的状态维度太高(O(n^3 * maxDepth)),需要优化。 -
优化状态
观察到:如果我们固定一个区间[l, r]和该区间子树的根深度d,那么左子树的深度必须为d+1,右子树深度也为d+1(因为深度从根向下递增)。因此,我们可以将状态简化为:
dp[l][r][d]表示在区间[l, r]上构建一棵BST,且该子树根节点的深度为d的最小总代价(不再显式指定根是哪个键值,而是在状态转移时枚举根的位置)。
状态维度:O(n^2 * maxDepth)。 -
转移方程
对于区间[l, r]和深度d:- 如果
l > r:表示空子树(只有失败搜索)。此时该空子树对应一个空指针,其父节点深度为d-1(因为空指针是深度为d的节点的子节点,所以其访问成本基于深度d-1?需要仔细定义)。
但更合理的定义是:当区间为空时,表示一个失败搜索节点,其访问成本应由其父节点深度d决定(因为该空指针是深度为d的节点的子节点,所以访问该空指针的成本为costPerDepth[d])。
因此,若l > r,则dp[l][r][d] = freq[r+1] * costPerDepth[d],其中freq[r+1]是区间[l, r]对应的失败搜索频率(注意:区间[l, r]对应失败搜索频率为freq[l]到freq[r+1],但当区间为空时,应只对应一个失败搜索节点?需要统一处理频率前缀和)。
为了高效计算总频率,我们预先计算前缀和
psum[i] = sum(freq[0..i]),则区间[l, r]内所有键值频率和为psum[r] - psum[l-1](若l>r则为0),且该区间对应的失败搜索频率总和为psum[r+1] - psum[l](包括左端和右端的失败搜索)。但更标准的处理是:对于区间
[l, r],其所有节点(键值和失败搜索)的总频率是固定的,等于psum[r+1] - psum[l]。
当我们将区间[l, r]以某个键值keys[m]为根划分为左区间[l, m-1]和右区间[m+1, r]时,根节点的深度为d,访问成本为costPerDepth[d]。
左子树根深度为d+1,右子树根深度也为d+1。因此,转移方程为:
- 如果
\[ dp[l][r][d] = \min_{m \in [l, r]} \left( dp[l][m-1][d+1] + dp[m+1][r][d+1] + costPerDepth[d] \times \text{totalFreq}(l, r) \right) \]
其中 totalFreq(l, r) = psum[r+1] - psum[l] 是该子树中所有键值和失败搜索的总频率。
注意:根节点 keys[m] 的访问频率已包含在 totalFreq 中(因为它是区间的一部分),所以我们只需在总代价中加上 costPerDepth[d] * totalFreq(l, r) 即可(这实际上将根节点的访问成本按频率加权加入,同时左、右子树的代价在递归中已包含各自的频率加权)。
但这个方程有一个问题:costPerDepth[d] * totalFreq(l, r) 实际上将整个区间的总频率都按深度 d 的成本计算了一次,而左、右子树的代价 dp[l][m-1][d+1] 等又已经包含了它们各自的频率按深度 d+1 计算的成本。这会导致重复计算?让我们仔细分析:
更准确的做法是:dp[l][r][d] 应表示该子树的总代价,其中所有节点的访问成本已经根据其自身深度计算。当我们以 keys[m] 为根时:
- 根节点的深度为
d,访问成本为costPerDepth[d],频率为freq[m](注意freq[m]是键值频率,不是失败搜索)。 - 左子树
[l, m-1]的所有节点深度在d+1及以上,其总代价为dp[l][m-1][d+1]。 - 右子树
[m+1, r]类似。 - 此外,还有两个失败搜索节点:一个在左子树空指针(对应频率
freq[m]的失败部分?实际上失败搜索频率是连续的:区间[l, r]对应的失败搜索频率为freq[l], ..., freq[r+1],当我们选择根m后,左子树对应失败搜索频率为freq[l..m],右子树对应freq[m+1..r+1]。这些失败搜索的访问成本由它们父节点的深度决定,即左子树空指针的父节点深度为d,右子树空指针的父节点深度也为d。因此,这两个失败搜索节点的成本在根节点层面计算,还是归入左右子树?
为了简化,我们采用标准最优BST的动态规划方法,但扩展深度和成本:
设 dp[l][r][d] 表示区间 [l, r] 构建的子树,其根节点的深度为 d,且该子树的总代价(包括所有键值节点和失败搜索节点)的最小值。
转移时,枚举根节点位置 m:
\[ dp[l][r][d] = \min_{m \in [l, r]} \left( dp[l][m-1][d+1] + dp[m+1][r][d+1] + costPerDepth[d] \times freq[m] + \text{额外失败搜索成本} \right) \]
其中 freq[m] 是键值 keys[m] 的频率。
额外失败搜索成本:在根节点 m 处,有两个空指针(左和右),分别对应失败搜索频率 freq[m] 和 freq[m+1]?不对,失败搜索频率数组 freq 的长度为 n+1,索引 i 对应键值 keys[i] 的左空指针(若 i 从0开始)。更标准地:freq[i] 对应在键值 keys[i-1] 和 keys[i] 之间的搜索失败频率(对于 i=0 是小于最小键值的失败搜索,对于 i=n 是大于最大键值的失败搜索)。
为了避免混淆,我们采用经典OBST的DP定义,但加上深度维度。
令 w(l, r) = sum_{i=l}^{r} freq[i] + sum_{j=l}^{r+1} failFreq[j] 是区间 [l, r] 的总权重(键值频率+失败搜索频率)。
实际上,我们可以将失败搜索频率合并到权重中:设 weight[i] 为键值 keys[i] 的频率,failWeight[i] 为失败搜索频率(共 n+1 个)。在区间DP时,总权重 totalWeight(l, r) = sum_{i=l}^{r} weight[i] + sum_{j=l}^{r+1} failWeight[j]。
那么转移方程可写为:
\[ dp[l][r][d] = \min_{m \in [l, r]} \left( dp[l][m-1][d+1] + dp[m+1][r][d+1] + costPerDepth[d] \times totalWeight(l, r) \right) \]
解释:当根节点深度为 d 时,其所有子节点(包括键值和失败搜索)的深度都至少为 d+1,因此整个子树的总代价等于左、右子树的代价(它们的根深度为 d+1)加上根节点本身的贡献:根节点的访问成本(深度 d)乘以整个子树的总权重。这是因为在经典OBST中,当根节点深度增加1时,所有子节点的访问成本都增加一层,所以根节点的成本贡献等于其深度成本乘以子树总权重。
这个公式是经典OBST的推广,其中 costPerDepth[d] 代替了固定的深度增量成本。
- 边界条件
当l > r时,表示空子树。此时该子树只有一个失败搜索节点(对应一个空指针),其访问成本基于父节点深度d(即该空指针的父节点深度为d),所以:
\[ dp[l][r][d] = costPerDepth[d] \times failWeight[l] \quad (\text{注意:当区间为空时,对应的失败搜索频率是 } failWeight[l]) \]
实际上,当 l > r 时,对应的失败搜索频率是 failWeight[l](因为区间 [l, r] 为空,即键值区间为空,失败搜索位于位置 l)。但为了统一,我们可以在初始化时处理。
-
深度限制
如果d > maxDepth,则这个状态非法,代价设为无穷大。 -
最终答案
整个树对应区间[0, n-1],根节点深度为 0。所以答案为:
\[ \min_{d=0}^{maxDepth} dp[0][n-1][d] \]
但注意,根节点深度必须从0开始,且不能超过 maxDepth。
逐步计算示例
假设:
keys = [10, 20, 30]weight = [3, 1, 4](键值频率)failWeight = [2, 1, 3, 2](失败搜索频率,共4个)maxDepth = 2costPerDepth = [1, 2, 4](深度0、1、2的访问成本)
-
计算前缀和
psum用于快速计算totalWeight(l, r):- 总权重数组:将
weight和failWeight交错?更简单的方法:直接计算区间总权重。 - 我们可以预先计算二维数组
wt[l][r] = totalWeight(l, r)。
例如:wt[0][2] = weight[0]+weight[1]+weight[2] + failWeight[0]+failWeight[1]+failWeight[2]+failWeight[3] = 3+1+4 + 2+1+3+2 = 16。
- 总权重数组:将
-
初始化
dp[l][r][d]为无穷大(非法状态)。
处理空区间l > r:dp[l][r][d] = costPerDepth[d] * failWeight[l](对于l从0到n,r = l-1)。
例如:dp[0][-1][0] = costPerDepth[0]*failWeight[0] = 1*2 = 2(区间为空,对应失败搜索在位置0)。
-
按区间长度从小到大计算。
区间长度len = 1(单个键值):- 例:区间
[0,0](键值10),枚举深度d从0到maxDepth。
对于d=0:枚举根m=0:
dp[0][0][0] = dp[0][-1][1] + dp[1][0][1] + costPerDepth[0]*wt[0][0]
其中dp[0][-1][1] = costPerDepth[1]*failWeight[0] = 2*2=4,dp[1][0][1]是空区间(因为1>0),值为costPerDepth[1]*failWeight[1]=2*1=2。
wt[0][0] = weight[0] + failWeight[0]+failWeight[1] = 3+2+1=6。
所以dp[0][0][0] = 4 + 2 + 1*6 = 12。
类似计算其他深度。
- 例:区间
-
最终计算区间
[0,2](所有键值)的dp[0][2][d],取最小值为答案。
算法复杂度
- 状态数:
O(n^2 * maxDepth) - 每个状态转移需要枚举根位置
O(n) - 总复杂度:
O(n^3 * maxDepth) - 可优化:利用决策单调性(Knuth优化)可降为
O(n^2 * maxDepth),但这里深度维度限制了优化的直接应用。
关键点总结
- 状态设计要同时涵盖区间、深度,以跟踪节点深度的限制和成本。
- 转移时,根节点成本贡献等于其深度成本乘以整个子树的总权重(包括键值和失败搜索)。
- 深度限制通过禁止
d > maxDepth的状态来实现。 - 初始条件对应空子树(只有失败搜索节点),其成本由父节点深度决定。
这个扩展问题结合了最优二叉搜索树、深度限制和可变访问成本,是区间DP的一个综合性应用。