假设咱们要设计一个将英语翻译成法语的程序,即对英文文本中出现的每一个单词,咱们须要查找其对应的法语单词。为了实现这一查找操做,咱们能够构建一棵二叉搜索树,将n个英文单词做为关键字,对应的法语单词做为关联数据。html
经过使用红黑树或其余平衡搜索树结构,咱们能够作到平均搜索时间为O(lgn)。但须要注意到的是,每一个单词的出现频率是不同的,而且这种频率的差别仍是比较大的。好比像 "the" 这种单词,出现的频率就比较高,而像 "machicolation" 这类单词几乎就不会出现。所以,若是咱们在不考虑单词出现频率的状况下去构造二叉搜索树,颇有可能将相似 "the" 这类高频单词置于树的较深的结点处,这样势必会使搜索时间增长。node
因而,在给定单词出现频率的前提下,咱们应该如何去组织一棵二叉搜索树,使得全部搜索操做访问的结点总数最少呢?这即是二叉搜索树问题(optimal binary search tree)。其形式化的描述以下:python
给定一个包含n个不一样关键字且已排序的序列\(K = < k_1, k_2,...,k_n >(其中k_1<k_2<...<k_n)\)和关键字\(k_i\)的搜索频率\(p_i,i=1,2...n\);还给定(n+1)个“伪关键字”\(d_0,d_1,...d_n\),表示不在K中的值(由于有些搜索值可能不在K中),同时也给出其被搜索的频率\(q_i\)。要用这些关键字构造一棵二叉搜索树T使得在T中搜索一次的指望搜索代价最小。算法
指望搜索代价E(T)可由以下公式计算出:数组
\[ E(T) = \sum_{i=1}^n(depth(k_i)+1)\cdot p_i+\sum_{i=0}^n(depth(d_i)+1)\cdot q_i \]spa
其中,depth(node)表示node的深度(约定根结点的深度为0)。翻译
因为设计
\[ \sum_{i=1}^np_i + \sum_{i=0}^nq_i = 1 \]code
咱们能够将上述E(T)计算式变形为:htm
\[ E(T) = 1 + \sum_{i=1}^ndepth(k_i) \cdot p_i + \sum_{i=0}^n depth(d_i) \cdot q_i \]
举个例子,对一个n = 5的关键字集合,给出以下搜索几率:
i | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
\(p_i\) | 0.15 | 0.10 | 0.05 | 0.10 | 0.20 | |
\(q_i\) | 0.05 | 0.10 | 0.05 | 0.05 | 0.05 | 0.10 |
咱们能够这么构造出一棵二叉搜索树:
也能够这么构造:
经过计算能够发现,第一种方式的搜索代价为2.8,而第二种方式的搜索代价为2.75,所以第二种方式更好。事实上,第二种方式构造的二叉搜索树是一棵最优搜索二叉树。从该例子中咱们能够看出,最优二叉搜索树不必定是高度最矮的二叉树,并且几率最大的关键字也不必定出如今根结点,不要把它和哈夫曼树混淆。
PS:固然,上述采用二叉搜索树的策略也许并非最好的,好比能够采用效率更高的hashtable,好比采用分块查找。可是,二叉搜索树较hashtable而言,也有其优点,具体可参见 Advantages of BST over Hash Table。这里只是以这个案例引出最优二叉搜索树问题。
一样,咱们试图用动态规划方法来解决该问题。老规矩,先考察动态规划方法的两个重要特性。
假定一棵最优二叉搜索树T有一棵子树T',那么若是将T'单独拿出来考虑,它必然也是一棵最优二叉搜索树。咱们一样能够用剪切-粘贴法来证实这点:若是T'不是一棵最优二叉搜索树,那么咱们把有T'包含的关键字组成的最优二叉搜索树”粘贴“到T'位置,此时构造的树必定比以前二叉搜索树T更优,这与T是最优二叉搜索树像矛盾,以上得证。
上述证实了该问题具备最优子结构性质,接着咱们考虑如何经过子问题的最优解来构造原问题的最优解。通常地,给定关键字序列 \(k_i,...,k_j,1 \leqslant i \leqslant j \leqslant n\),其叶结点必然是伪关键字\(d_{i-1}...d_j\)。假定关键字\(k_r(i \leqslant r \leqslant j)\)是这些关键字的最优子树的根结点,那么\(k_r\)的左子树包含关键字\(k_1, k_2...k_{r-1}\),其右子树包含关键字\(k_{r+1}...k_j\)。只要咱们检查全部可能的根结点\(k_r\),并对其左右子树分别求解,便可保证找出原问题的最优解。
从以上分析中咱们发现,求解\(k_r\)的左右子树问题和求解原问题的模式是同样的,所以咱们能够用一个递归式来描述原问题的解。
定义\(e(i, j)(其中i\geqslant 1,j \leqslant n 且j \leqslant i-1)\)为在包含关键字\(k_i...k_j\)的最优二叉搜索树中进行一次搜索的指望代价;\(w(i, j)\)表示以下包含关键字\(k_i,...,k_j\)的子树的全部元素几率之和:
\[ w(i, j) = \sum_{k=i}^j p_k + \sum_{k=i}^j q_k \]
当一棵树成为一个结点的子树时,其上的每个结点的深度都会增长1。根据以前的指望搜索代价\(E(T)\)的计算公式的变形式
\[ E(T) = 1 + \sum_{i=1}^ndepth(k_i) \cdot p_i + \sum_{i=0}^n depth(d_i) \cdot q_i \]
咱们能够得出,当包含关键字\(k_i...k_j\)的最优二叉搜索树中的每一个结点的深度增长1时,\(E(T)\)将增长\(w(i, j)\)。因而咱们可获得以下公式:
\[ e(i, j) = p_r + [e(i, r-1) + w(i, r-1)] + [e(r+1, j) + w(r+1, j)] \]
注意到:
\[ w(i, j) = w(i, r-1) + p_r +w(r+1, j) \]
因而上述\(e(i, j)\)递推式可简化为:
\[ e(i, j) = e(i, r-1) + e(r+1, j) + w(i, j) \]
须要注意的是,咱们在上面定义\(e(i, j)\)时,给出了\(i\) 与 \(j\)的取值范围。其中有种特别的状况是,当\(j=i-1\)时,表示只包含“伪关键字”结点\(d_{r-1}\),此时搜索指望代价\(e(i, i-1) = q_{i-1}\)
根据以上分析,咱们可得最终的递推式:
\[ e(i, j) = \begin{cases} q_{i-1} & \text{ 若$j = i-1$}\\ \min \limits_{i\leqslant r \leqslant j}[e(i, r-1)+ e(r+1, j) + w(i, j)] & \text { 若$i\leqslant j$} \end{cases} \]
咱们的最终目标是计算出\(e(1, n)\)。
同矩阵链的乘法问题同样,该问题的子问题是由连续的下标子域构成,许多子问题“共享”另外一些子问题,即子问题重叠。
有了前两部分的分析,咱们能够很容易地设计出一个自底向上的动态规划算法来解决该问题。
下面给出Python的实现版本:
# 计算e与root # e[i][j]意思与上述分析中的e(i, j)一致; # root[i][j]表示包含关键字k_i...k_j的最优二叉搜索树的根结点关键字的下标; # w[i][j]意思也与上述分析中的w(i, j)一致,这里用w数组做为“备忘录”, # 用公式 w[i][j] = w[i][j - 1] + p[j] + q[j]来计算w[i][j],可利用上以前计算出的w[i][j - 1], # 这样能够节省Θ(j - i)次加法。 def optimal_bst(p, q, n): e = [[0 for i in range(n + 1)] for j in range(n + 2)] w = [[0 for i in range(n + 1)] for j in range(n + 2)] root = [[0 for i in range(n + 1)] for j in range(n + 1)] for i in range(1, n + 2): e[i][i - 1] = q[i - 1] w[i][i - 1] = q[i - 1] for l in range(1, n + 1): for i in range(1, n - l + 2): j = i + l - 1 e[i][j] = float('inf') w[i][j] = w[i][j - 1] + p[j] + q[j] for r in range(i, j + 1): t = e[i][r - 1] + e[r + 1][j] + w[i][j] if t < e[i][j]: e[i][j] = t root[i][j] = r return e, root # 先序遍历打印树 def printByPreorderingTraverse(root, i, j): if i > j: return r = root[i][j] print(r, end='') if r > 0 : printByPreorderingTraverse(root, i, r - 1) if r < len(root) - 1: printByPreorderingTraverse(root, r + 1, j) if __name__ == '__main__': p = [0, 0.15, 0.10, 0.05, 0.10, 0.20] q = [0.05, 0.10, 0.05, 0.05, 0.05, 0.10] n = 5 e, root = optimal_bst(p, q, n) print('最优指望搜索代价为:', e[1][n]) print('最优搜索二叉树的先序遍历结果为:', end='') printByPreorderingTraverse(root, 1, n)
打印结果为:
最优指望搜索代价为: 2.75 最优搜索二叉树的先序遍历结果为:21543