最优二叉搜索树的平均搜索代价最小化问题
好的,这是区间动态规划领域的一个经典且重要的问题。我们从一个具体但不复杂的例子入手,来逐步理解这个问题的本质和解决方案。
问题描述
假设我们有一组有序的关键字,例如 [“do”, “if”, “while”]。每个关键字被搜索的概率是已知的,我们用一个数组 p 表示,例如 p = [0.5, 0.1, 0.05]。这意味着用户搜索“do”的概率是50%,搜索“if”的概率是10%,搜索“while”的概率是5%。
同时,搜索也可能失败。例如,用户可能想搜索“for”,但“for”并不在我们的关键字列表中。我们把所有可能失败的情况(即,不在关键字列表中的值)也考虑进去。我们定义一些虚拟键(dummy keys),代表搜索失败的情况。对于一个有n个关键字的序列,有n+1个可能的失败区间(小于第一个关键字,在两个关键字之间,大于最后一个关键字)。每个失败区间也有一个被搜索的概率,用数组 q 表示。例如,q = [0.15, 0.1, 0.05, 0.05]。
我们的目标是构建一棵二叉搜索树(BST)来存放这些关键字,使得在给定这些成功和失败概率的情况下,一次搜索的平均代价(平均比较次数)最小化。
一个关键概念:在一棵BST中,搜索一个关键字的代价等于从根节点到该关键字节点的路径长度+1(即比较次数)。搜索失败(落入一个虚拟键)的代价等于从根节点到达的“外部节点”(空指针)的深度。
我们的任务:给定关键字序列(已排序)和对应的概率数组 p[1..n] 和 q[0..n],设计一个算法,计算并构建出具有最小平均搜索代价的二叉搜索树。
渐进式解题过程
步骤1:理解问题输入和输出
- 输入:
- 关键字数量
n。 - 成功概率数组
p[1..n],p[i]是搜索关键字i的概率。 - 失败概率数组
q[0..n],q[i]是搜索值落在关键字i和i+1之间(对于q[0]是小于所有关键字,q[n]是大于所有关键字)的概率。
- 关键字数量
- 隐含条件:
sum(p) + sum(q) = 1。 - 输出:
- 最小的平均搜索代价。
- (可选)这棵最优BST的结构(根节点是什么,左右子树如何划分)。
步骤2:分析最优子结构
区间动态规划的核心是识别“最优子结构”。假设我们选择关键字 k 作为树的根节点。
- 那么,所有关键字
1, 2, ..., k-1必然在它的左子树中。 - 所有关键字
k+1, ..., n必然在它的右子树中。
这个划分非常像快速排序或矩阵链乘,它把一个大问题(为关键字 i 到 j 构建最优BST)分解为两个子问题(为关键字 i 到 k-1 构建左子树,为关键字 k+1 到 j 构建右子树)。
如果一个树对于整个集合是最优的,那么它的左、右子树对于它们各自的子关键字集合也一定是最优的。否则,我们可以用一个更好的子树替换它,从而得到整体更好的树,这与“最优”矛盾。
步骤3:定义DP状态
我们定义一个二维DP表 e[i][j]。
e[i][j]的含义:为包含关键字i, i+1, ..., j的集合(以及相关的虚拟键)构建一棵最优二叉搜索树的期望搜索代价。- 注意:当
i > j时,表示子树为空。此时,这个“子树”只包含一个虚拟键(失败节点)。它的期望搜索代价就是搜索到这个失败节点的代价(深度)乘以它的概率。但在这里,我们把它当作一个基本情况来处理。
更常见且方便的做法是,我们同时定义另一个表 w[i][j],用来存储子树的概率总和。
w[i][j]的含义:包含关键字i到j的子树(以及其所有下属的虚拟键)被搜索到的总概率。- 计算公式:
w[i][j] = sum(p[i..j]) + sum(q[i-1..j])。可以递归计算:w[i][j] = w[i][j-1] + p[j] + q[j]。
为什么要 w[i][j]?想象一下,当你把两棵子树 (i, k-1) 和 (k+1, j) 分别挂到根节点 k 下面时,子树中所有节点的深度都增加了1。因此,整个子树的期望搜索代价会增加,增加的量正好等于该子树的总概率 w(因为每个节点的深度+1,其贡献的期望代价就增加了 概率 * 1)。
步骤4:推导状态转移方程
我们的目标是求 e[1][n]。
1. 基本情况:
- 当
j = i - 1时,子树为空,只包含虚拟键d_{i-1}(即对应失败区间q[i-1])。此时构建的“树”就是一个外部节点。搜索到这个外部节点的代价就是它的深度。在动态规划构建过程中,当我们把它作为子树时,它的深度会在父节点中被计算。所以,我们可以将其基础代价设为q[i-1](更准确地说,是子树自身的代价,在挂到父节点前)。 - 因此,我们定义
e[i][i-1] = q[i-1]。 - 同理,
w[i][i-1] = q[i-1]。
2. 一般情况 (i <= j):
- 我们需要尝试从
i到j中每个关键字k作为根节点。 - 如果选择
k作为根:- 左子树包含关键字
i ... k-1,其最优期望代价为e[i][k-1]。 - 右子树包含关键字
k+1 ... j,其最优期望代价为e[k+1][j]。 - 左子树的总概率为
w[i][k-1]。 - 右子树的总概率为
w[k+1][j]。
- 左子树包含关键字
- 关键点:当我们将左右子树连接到根节点
k时,这两棵子树中每一个节点(包括虚拟的外部节点)的深度都增加了1。因此,整个搜索代价的期望值会增加w[i][k-1] + w[k+1][j]。 - 此外,我们还要加上根节点
k本身的贡献:搜索到k的代价是1(一次比较),乘以它的概率p[k],即p[k]。 - 因此,以
k为根的子树(i, j)的总期望代价为:
cost = e[i][k-1] + e[k+1][j] + w[i][k-1] + p[k] + w[k+1][j] - 注意
w[i][k-1] + p[k] + w[k+1][j]正好等于w[i][j](整个子树的总概率)。 - 所以,转移方程可以优雅地写为:
e[i][j] = min_{k from i to j} ( e[i][k-1] + e[k+1][j] + w[i][j] ) - 其中
w[i][j]是固定的(对于给定的i, j),无论谁当根,这部分因深度增加而“额外付出”的代价都是一样的。我们要最小化的,实际上是e[i][k-1] + e[k+7][j]这部分。
步骤5:计算顺序与初始化
e表和w表都是二维的,大小为(n+2) x (n+1)或(n+1) x (n+1),为了方便处理i-1和j+1的边界,通常下标从0开始或留出空位。- 我们需要先计算小区间,再计算大区间。因此,最外层的循环是区间长度
L,从0到n-1。L = 0: 计算e[i][i],即只有一个关键字的子树。- 以唯一的关键字
i为根。 e[i][i] = e[i][i-1] + e[i+1][i] + w[i][i]- 其中
e[i][i-1] = q[i-1],e[i+1][i] = q[i]。 w[i][i] = p[i] + q[i-1] + q[i]。
- 以唯一的关键字
L = 1: 计算包含两个关键字的子树e[i][i+1],以此类推。
- 同时,我们需要另一个表
root[i][j]来记录对于区间[i, j]取得最小代价时选择的根节点k,以便最后重建最优BST的结构。
步骤6:实例演算
让我们用一个超简单的例子来走一遍流程。
假设 n = 1,只有一个关键字 “A”。
p[1] = 0.4q[0] = 0.1(搜索值小于"A"的概率)q[1] = 0.5(搜索值大于"A"的概率)- 总和 = 1.0。
计算:
-
初始化:
w[1][0] = q[0] = 0.1e[1][0] = q[0] = 0.1w[2][1] = q[1] = 0.5e[2][1] = q[1] = 0.5
-
计算
e[1][1]:w[1][1] = p[1] + q[0] + q[1] = 0.4 + 0.1 + 0.5 = 1.0。- 只有一个根可选
k=1。 e[1][1] = e[1][0] + e[2][1] + w[1][1] = 0.1 + 0.5 + 1.0 = 1.6。
所以,最小的平均搜索代价是 1.6。你可以验证:这棵树只有根节点"A"。搜索"A"成功比较1次,概率0.4,贡献 0.4*1=0.4。搜索失败,比较1次后到达空指针,概率 0.1+0.5=0.6,贡献 0.6*1=0.6。总期望 1.0?等等,不对。
这里有一个重要的理解点:我们的 e[i][j] 计算的代价,包含了从这个子树的根开始的搜索。而在我们最后的答案 e[1][n] 中,我们计算的已经是整棵树的期望代价。对于这个单节点树:
- 搜索“A”:比较1次(根节点),代价1。
- 搜索失败(比如小于“A”):比较流程是:访问根节点"A"(1次比较),发现目标值更小,转向左子树(空),这意味着我们实际上进行了一次比较就判断失败了吗?不,在经典的二叉搜索树模型中,搜索失败是在到达一个外部(空)节点时判定的。要到达代表“小于A”的外部节点,你需要从根节点走左指针,所以深度是2(根节点和外部节点)。
- 因此,更准确的模型是:一个包含关键字
i..j的子树,它的所有外部节点(失败节点)的深度比其父关键字节点深一层。 - 在我们定义的
e[i][i-1] = q[i-1]中,这个q[i-1]是外部节点的概率,但它本身代表的代价是这个外部节点作为一棵“空树”的根的代价,即为1?这造成了混乱。
为了清晰,我们采用算法导论中的标准定义,它修正了深度计算:
修正后的定义:
- 对于一个包含节点(包括内部关键字节点和外部失败节点)的树,搜索代价是搜索路径上访问的节点数。
- 因此,访问一个深度为
d的内部关键字节点,代价为d。 - 访问一个深度为
d的外部失败节点,代价也为d(你需要比较d次才知道失败)。 - 那么,期望代价 = Σ(每个节点的深度 × 该节点被访问的概率)。
在这个定义下,我们的动态规划需要做调整:
e[i][j]: 由关键字i..j构成的最优子树的期望搜索代价。w[i][j]: 子树i..j中所有节点(包括关键字i..j和所有相关的失败节点q[i-1] .. q[j])的概率之和。w[i][j] = sum(p[i..j]) + sum(q[i-1..j])。- 转移方程:
e[i][j] = min_{i<=k<=j} { e[i][k-1] + e[k+1][j] + w[i][j] } - 边界条件:
e[i][i-1] = w[i][i-1] = q[i-1]。因为一个空子树只包含一个外部失败节点d_{i-1},其深度为0?不对,这里需要小心。
最清晰的逻辑:
当 j = i-1,表示没有关键字,只有失败节点 d_{i-1}。这棵“树”只有一个外部节点。它的期望代价就是 q[i-1] * 1?不,在动态规划组合时,当我们把它作为子树挂到某个根节点下,这个外部节点的深度会增加。所以,在 e[i][i-1] 中,我们应该存储的是以这个外部节点为根的子树,在其自身层级上的代价贡献。由于它只是一个节点,访问它的代价是1(一次访问)。因此,e[i][i-1] = q[i-1] * 1 = q[i-1]。这是合理的。
现在重新计算 n=1 的例子:
w[1][1] = p1 + q0 + q1 = 0.4+0.1+0.5=1.0e[1][1] = min_{k=1} ( e[1][0] + e[2][1] + w[1][1] )e[1][0] = q0 = 0.1e[2][1] = q1 = 0.5e[1][1] = 0.1 + 0.5 + 1.0 = 1.6
这个 1.6 就是整棵树的总期望代价。让我们手动验证一下树的结构:
根节点 A (深度1)
/
外部节点d0(深度2) 外部节点d1(深度2)
搜索概率和代价:
- 搜索A成功:概率0.4,深度1,贡献 0.4*1 = 0.4
- 搜索失败(落入d0):概率0.1,深度2,贡献 0.1*2 = 0.2
- 搜索失败(落入d1):概率0.5,深度2,贡献 0.5*2 = 1.0
总期望 = 0.4 + 0.2 + 1.0 = 1.6。完全正确!
步骤7:算法总结与复杂度
- 初始化二维数组
e[1..n+1, 0..n]和w[1..n+1, 0..n]。 - 初始化边界:对于
i=1到n+1,令e[i][i-1] = q[i-1],w[i][i-1] = q[i-1]。 - 循环区间长度
l = 0到n-1:- 循环起点
i = 1到n-l:- 计算
j = i + l。 - 计算
w[i][j] = w[i][j-1] + p[j] + q[j]。 - 设置
e[i][j] = 无穷大。 - 循环可能的根节点
k = i到j:t = e[i][k-1] + e[k+1][j] + w[i][j]- 如果
t < e[i][j],则更新e[i][j] = t,并记录root[i][j] = k。
- 计算
- 循环起点
- 最终结果:
e[1][n]即为最小平均搜索代价。通过root表递归构建树的结构。
时间复杂度: 三重循环,O(n³)。
空间复杂度: O(n²)。
通过以上步骤,我们完成了对“最优二叉搜索树”问题的完整分析。这个问题完美地体现了区间动态规划的思想:将问题分解为左右两个子区间,通过遍历所有可能的分割点(根节点)来寻找最优解,并利用辅助表 w 来高效计算因深度增加而产生的额外代价。