這一部分介紹了設計和分析高效算法的三種重要技術:動態規劃、貪心算法、和攤還分析。
動態規劃通常用來解決最優化問題,在這類問題中,通過做出一組選擇來達到最優解。在做出每個選擇的同時,通常會生成與原問題形式相同的子問題。當多於一個選擇子集都生成相同的子問題時,動態規劃技術通常通常就會有效,其關鍵技術就是對每個這樣的子問題都保存其解,當其重複出現時即可避免重複求解。
與動態規劃類似,貪心算法通常用於最優化問題,,做出一組選擇來達到最優解。貪心算法的思想是每步選擇都追求局部最優。速度比動態規劃方法快的多,但是,並不總能簡單地判斷出貪心算法是否有效。
使用攤還分析方法分析一類特定的算法,這類算法執行一組相似的操作組成的序列。攤還分析並不是通過分別分析每個操作的實際代價的界來分析操作序列的代價結果,而是直接分析序列整體的實際代價的界。這種方法的好處,雖然某些操作的代價可能很高,但其他很多操作的代價可能很低。
第 15 章 動態規劃
動態規劃與分治方法相似,都是通過組合子問題的解來求解原問題。分治方法將問題劃分爲互不相交的子問題,遞歸地求解子問題,再將它們的解組合起來,求出原問題的解。與之相反,動態規劃應用於子問題重疊的情況,即不同的子問題具有公共的子子問題。在這種情況下,分治算法會做許多不必要的工作,它會反覆地求解那些公共子問題。而動態規劃算法對每個子問題只求解一次,將其解保存在一個表格中,從而無需每次求解一個子子問題時都重新計算,避免了這種不必要的操作。
通常按如下4個步驟設計一個動態規劃算法:
- 刻畫一個最優解的結構特徵。
- 遞歸地定義最優解的值。
- 計算最優解的值,通常採用自底向上的方法。
- 利用計算出的信息構造一個最優解。
15.1 鋼條切割
鋼條切割問題時這樣的:給定一段長度爲n英寸的鋼條和一個價格表
長度爲n英寸的鋼條共有
對於
除了上述求解方法外,鋼條切割問題還存在一種相似的但更爲簡單的遞歸求解方法:將鋼條從左邊割下長度爲i的一段,只對右邊剩下的長度爲n-i的一段繼續進行切割(遞歸求解),對左邊的一段則不再進行切割。即問題分解的形式爲:將長度爲n的鋼條分解爲左邊開始一段以及剩餘部分繼續分解的結果。得到公式簡化版:
自頂向下遞歸實現
下面的過程實現了公式簡化板的實現,它採用的是一種直接的自頂向下的遞歸方法:
CUT-ROD(p,n)
if n== 0
return 0
q = -∞
for i = 1 to n
q = max(q, p[i] + CUT-ROD(p, n-i))
return q
過程CUT-ROD以價格數數組p[1..n]和整數n爲輸入,返回長度爲n的鋼條最大收益。CUT-ROD的運行時間爲 n 的指數函數。
使用動態規劃方法求解最優鋼條切割問題
樸素遞歸算法之所以效率很低,是因爲它反覆求解相同的子問題。因此,動態規劃方法仔細安排求解順序,對每個子問題只求解一次,並將結果保存下來。如果隨後再次需要此子問題的解,只需查找保存的結果,而不必重新計算。因此,動態規劃方法時付出額外的內存空間來節省時間,時典型的時空權衡的例子。而時間上的節省可能是非常巨大的:可能將一個指數時間的解轉化爲一個多項式時間的解。
動態規劃有兩種等價的實現方法:
第一種方法稱爲帶備忘的自頂向下。此方法仍按自然的遞歸形式編寫過程,但過程或保存每個子問題的解(通常在一個數組或散列表)。當需要一個子問題的解時,過程首先檢查是否已經保存過此解。如果是,則直接返回保存的值,從而節省了計算時間;否則,按通常方式計算這個子問題。稱這個遞歸過程時帶備忘的。
第二種方法稱爲自底向上法。這種方法一般需要恰當定義子問題“規模”的概念,使得任何子問題的求解都只依賴與“更小的”子問題的求解。因此可以將子問題按規模排序,按由小到大順序進行求解。當求解某個子問題時,它所依賴的那些更小的子問題都已求解完畢,結果已經保存。每個子問題只需求解一次,當我們求解它時,它的所有前提子問題都已求解完成。
兩種算法得到的算法具有相同的漸近運行時間,僅有的差異在某些特殊情況下,自頂向下方法並未真正遞歸地考察所有可能的子問題。由於沒有頻繁的遞歸函數調用的開銷,自底向上方法的時間複雜性函數通常具有更小的係數。
下面給出的時自頂向下CUT-ROD過程的僞代碼,加入了備忘機制:
MEMOIZED-CUT-ROD(p,n)
let r[0..n] be a new array
for i = 0 to n
r[i] = -∞
return MEMOIZED-CUT-ROD-AUX(p, n, r)
MEMOIZED-CUT-ROD-AUX(p, n, r)
if r[n] >= 0
return r[n]
if n == 0
q = 0
else q = -∞
for i = 1 to n
q = max(q, p[i] + MEMOIZED-CUT-ROD-AUX(p, n-i, r))
r[n] = q
return q
自底向上版本更爲簡單
BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] be a new array
r[0] = 0
for j = 1 to n
q = -∞
for i = 1 to j
q = max(q, p[i] + r[j - i])
r[j] = q
return r[n]
過程依次求解規模爲j=0,1,……,n的子問題。
兩個算法的運行時間都爲
子問題圖
當思考一個動態規劃問題時,應該弄清楚所涉及的子問題及子問題之間的依賴關係。子問題圖G=(V,{E})的規模可以幫助確定動態規劃算法的運行時間。動態規劃算法的運行時間與頂點和邊的數量呈線性關係。
重構解
前文給出的鋼條切割問題的動態規劃算法返回最優解的收益值,但並未返回解本身(一個長度列表,給出切割後每段鋼條的長度)。下面給出BOTTOM-UP-CUT-ROD(p,n)擴展版本,它對長度爲j的鋼條不僅計算最大收益值
EXTENDED-BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] and s[0..n]be a new array
r[0] = 0
for j = 1 to n
q = -∞
for i = 1 to j
if q < p[i] + r[j - i]
q = p[i] + r[j-i]
s[j] = i
r[j] = q
return r and s
下面過程接受兩個參數:價格表p 和鋼條長度n,然後調用EXTENDED-BOTTOM-UP-CUT-ROD來計算切割下來的每段鋼條的長度s[1..n]。最後輸出長度爲n的鋼條的完整的最優切割方案:
PRINT-CUT-ROD-SOLUTION(p,n)
(r, s) = EXTENDED-BOTTOM-UP-CUT-ROD(p,n)
while n > 0
print s[n]
n = n - s[n]
c++ 代碼實現
#include <iostream>
using namespace std;
#define MIN -1
#define MAX(a,b) (((a) > (b)) ? (a) : (b))
int CUT_ROD(int p[], int n);
int memoized_cut_rod(int p[], int n);
int memoized_cut_rod_aux(int p[], int n, int r[]);
int bottom_up_cut_rod(int p[], int n);
void extended_bottom_up_cut_rod(int p[], int n, int r[], int s[]);
void print_cut_rod_solution(int p[], int n, int r[], int s[]);
int main()
{
int value[10] = {1,5,8,9,10,17,17,20,24,30};
// cout << CUT_ROD(value, 7) << endl;
// cout << memoized_cut_rod(value, 7) << endl;
// cout << bottom_up_cut_rod(value, 7) << endl;
int r[11], s[11];
extended_bottom_up_cut_rod(value, 10, r, s);
for (auto i : r)
cout << i << ends;
cout << endl;
for (auto i : s)
cout << i << ends;
cout << endl;
print_cut_rod_solution(value, 7, r, s);
return 0;
}
//樸素遞歸方法實現鋼條切割
//以價格數組p[1..n]和整數n爲輸入
int CUT_ROD(int p[], int n)
{
int q;
if (n == 0)
return 0;
q = MIN;
for (int i = 0; i < n; ++i)
q = MAX(q, p[i] + CUT_ROD(p , n - 1 - i));
return q;
}
//自頂向上的CUT_ROD加入了備忘錄
int memoized_cut_rod(int p[], int n)
{
int r[n+1];
for (int i = 0; i < n + 1; ++i)
{
r[i] = MIN;
}
return memoized_cut_rod_aux(p, n, r);
}
int memoized_cut_rod_aux(int p[], int n, int r[])
{
int q;
if (r[n] >= 0)
return r[n];
if (n == 0)
q = 0;
else
{
q = MIN;
for (int i = 0; i < n; ++i)
q = MAX(q, p[i] + memoized_cut_rod_aux(p, n - i - 1, r));
}
r[n] = q;
return q;
}
//自底向上版本
//函數值返回不對, 將i<j改成<=對了
int bottom_up_cut_rod(int p[], int n)
{
int r[n+1];
int q;
r[0] = 0;
for(int j = 0; j < n; ++j)
{
q = MIN;
for (int i = 0; i <= j; ++i)
q = MAX(q, p[i] + r[j - i]);
r[j + 1] = q;
}
return r[n];
}
//重構解
//下面是BOTTOM-UP-CUT-ROD的擴展版本,它對它對長度爲j的鋼條不僅計算最大收益值$r_j$,還保存最優解對應的第一段鋼條的切割長度$s_j$
void extended_bottom_up_cut_rod(int p[], int n, int r[], int s[])
{
int q ;
r[0] = 0;
s[0] = 0;
for (int j = 0; j < n; ++j)
{
q = MIN;
for (int i = 0; i <= j; ++i)
{
if (q < p[i] + r[j-i])
{
q = p[i] + r[j-i];
s[j+1] = i + 1;
}
}
r[j+1] = q;
}
}
//下面過程接受兩個參數:價格表p 和鋼條長度n,
//然後調用EXTENDED-BOTTOM-UP-CUT-ROD來計算切割下來的每段鋼條的長度s[1..n]。最後輸出長度爲n的鋼條的完整的最優切割方案:
void print_cut_rod_solution(int p[], int n, int r[], int s[])
{
while (n > 0)
{
cout << s[n] << ends;
n = n - s[n];
}
}
15.2 矩陣鏈乘法
給定一個 n 個矩陣的序列(矩陣鏈)
可以先用括號明確計算次序,然後利用標準的矩陣相乘算法進行計算。由於矩陣乘法滿足結合律,因此任何加括號的方法都會得到相同的計算結果。稱有如下性質的矩陣乘積鏈爲完全括號化的:它是單一矩陣,或者是兩個完全括號化的矩陣乘積鏈的積,且已外加括號。
對矩陣加括號的方式會對乘積運算的代價產生巨大影響。先來分析兩個矩陣相乘的代價。下面的僞代碼給出了兩個矩陣相乘的標準算法。屬性rows和columns時矩陣的行數和列數。
MATRIX-MULTIPLY(A,B)
if A.columns != B.rows
error "incompatible dimensions"
else let C be a new A.rows * B.columns matrix
for i = 1 to A.rows
for j = 1 to B.columns
cij = 0
for k = 1 to A.columns
cij = cij + aik * bkj
return C
兩個矩陣只有相容,即A的列數等於B的行數時,才能相乘。
矩陣鏈乘法問題:給定 n 個矩陣的鏈
注意:求解矩陣鏈乘法問題並不是要真正進行矩陣相乘運算,我們的目標只是確定代價最低的計算順序。
計算括號化方案的數量
括號方案的數量與 n 呈指數關係。
應用動態規劃方法
按照本章開頭提出的4個步驟進行:
步驟 1 :最優括號化方案的結構特徵
任何最優解都是有子問題實例的最優解構成的,因此,爲了構造一個矩陣乘法問題實例的最優解,可以將問題劃分爲兩個子問題,求出問題時的最優解,然後將子問題的最優解組合起來。必須保證在確定分割點時,已經考察了所有可能的劃分點,這樣就可以保證不會遺漏最優解。
步驟2:一個遞歸求解方案
令m[i..j]表示計算矩陣
步驟 3 : 計算最有代價
自底向上方法
MATRIX-CHAIN-ORDER(p)
n = p.length - 1
let m[1..n, 1..n] and s[1..n-1, 2..n] be new tables
for i = 1 to n
m[i,i] = 0
for l = 2 to n
for i = 1 to n-l+1
j = i+l-1
m[i,j] = ∞
for k = i to j - 1
q = m[i,k] + m[k+1, j] + pi-1pkpj
if q < m[i,j]
m[i,j] = q
s[i,j] = k
return m and s
步驟4: 構造最優解
調用PRINT-OPTIMAL-PARENS(s,1,n)即可輸出
PRINT-OPTIMAL-PARENS(s,i,j)
if i==j
print "A"
else print"("
PRINT-OPTIMAL-PARENS(s, i, s[i,j])
PRINT-OPTIMAL-PARENS(s, s[i,j]+1,j)
print")"
15.3 動態規劃原理
在本節中,我們關注適合應用動態規劃方法求解的最優化問題應該具備的兩個要素:最優子結構和子問題重疊。
最優子結構
如果一個問題的最優解包含其子問題的最優解,就稱此問題具有最優子結構性質。
一些微妙之處
重疊子問題
重構最優解
備忘
15. 4 最長公共子序列
一個給定的子序列,就是將給定序列中零個或多個元素去掉之後得到結果。其形式化定義如下:給定一個序列X=
給定兩個序列X和Y,如果Z即是X的子序列,也是Y的子序列,稱它時X和Y的公共子序列。
最長公共子序列問題(LCS)給定兩個序列X=
步驟 1: 刻畫最長公共子序列的特徵
給定一個序列X=
步驟2:一個遞歸解
定義c[i,j]表示
步驟3:計算LCS的長度
過程LCS-LENGTH接受兩個序列X和Y爲輸入,它將c[i, j]的值保存在表c[0..m, 0..n]中,並按行主次序計算表項(即首先由左至右計算c的第一行,然後計算第二行,以此類推)。過程返回表b和表c,c[m,n]保存了X和Y的LCS的長度。
LCS-LENGTH(X,Y)
m = X.length
n = Y.length
let b[1..m, 1..n] and c[0..m, 0..n] be new tables
for i = 1 to m
c[i, 0] = 0
for j = 0 to n
c[0,j] = 0
for i = 1 to m
for j = 1 to n
if xi == yi
c[i,j] = c[i-1,j-1]+1
b[i,j] = "↖"
else if c[i-1, j] >= c[i, j-1]
c[i,j] = c[i-1,j]
b[i,j] = "↑"
else c[i,j] = c[i, j-1]
b[i,j] = "←"
return c and b
步驟4:構造LCS
調用PRINT-LCS(b, X, Y, X.length, Y.length).
PRINT-LCS(b, X, i, j)
if i==0 or j == 0
return
if b[i,j] == ↖"
PRINT-LCS(b,X,i-1,j-1)
print x
else if b[i,j] == "↑"
PRINT-LCS(b,X,i-1,j)
else PRINT-LCS(b,X,i,j-1)
算法改進
15. 5 最優二叉搜索樹
下面僞代碼接受概率列表
OPTIMAL-BST(p,q,n)
let e[1..n+1, 0..n], w[1..n+1, 0..n], and root[1..n, 1..n]be new tables
for i = 1 to n+1
e[i, i-1] = q(i-1)
w[i,i-1] = q(i-1)
for l = 1 to n
for i = 1 to n-l+1
j = i+l-1
e[i,j] = ∞
w[i,j] = w[i,j-1] + pi + qj
for r = i to j
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 and root