1. 提出問題
假設我們要設計一個將英語翻譯成法語的程序,即對英文文本中出現的每個單詞,我們需要查找其對應的法語單詞。爲了實現這一查找操作,我們可以構建一棵二叉搜索樹,將n個英文單詞作爲關鍵字,對應的法語單詞作爲關聯數據。
通過使用紅黑樹或其他平衡搜索樹結構,我們可以做到平均搜索時間爲O(lgn)。但需要注意到的是,每個單詞的出現頻率是不一樣的,並且這種頻率的差異還是比較大的。比如像 "the" 這種單詞,出現的頻率就比較高,而像 "machicolation" 這類單詞幾乎就不會出現。因此,如果我們在不考慮單詞出現頻率的情況下去構造二叉搜索樹,很有可能將類似 "the" 這類高頻單詞置於樹的較深的結點處,這樣勢必會使搜索時間增加。
於是,在給定單詞出現頻率的前提下,我們應該如何去組織一棵二叉搜索樹,使得所有搜索操作訪問的結點總數最少呢?這便是二叉搜索樹問題(optimal binary search tree)。其形式化的描述如下:
給定一個包含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)可由如下公式計算出:
其中,depth(node)表示node的深度(約定根結點的深度爲0)。
由於
我們可以將上述E(T)計算式變形爲:
舉個例子,對一個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。這裏只是以這個案例引出最優二叉搜索樹問題。
2. 分析問題
同樣,我們試圖用動態規劃方法來解決該問題。老規矩,先考察動態規劃方法的兩個重要特性。
2.1 最優子結構
假定一棵最優二叉搜索樹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\),並對其左右子樹分別求解,即可保證找出原問題的最優解。
2.2 子問題重疊
從以上分析中我們發現,求解\(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\)的子樹的所有元素概率之和:
當一棵樹成爲一個結點的子樹時,其上的每一個結點的深度都會增加1。根據之前的期望搜索代價\(E(T)\)的計算公式的變形式
我們可以得出,當包含關鍵字\(k_i...k_j\)的最優二叉搜索樹中的每個結點的深度增加1時,\(E(T)\)將增加\(w(i, j)\)。於是我們可得到如下公式:
注意到:
於是上述\(e(i, j)\)遞推式可簡化爲:
需要注意的是,我們在上面定義\(e(i, j)\)時,給出了\(i\) 與 \(j\)的取值範圍。其中有種特別的情況是,當\(j=i-1\)時,表示只包含“僞關鍵字”結點\(d_{r-1}\),此時搜索期望代價\(e(i, i-1) = q_{i-1}\)
根據以上分析,我們可得最終的遞推式:
我們的最終目標是計算出\(e(1, n)\)。
同矩陣鏈的乘法問題一樣,該問題的子問題是由連續的下標子域構成,許多子問題“共享”另一些子問題,即子問題重疊。
3. 解決問題
有了前兩部分的分析,我們可以很容易地設計出一個自底向上的動態規劃算法來解決該問題。
下面給出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