專題十:算法分析與設計
1.1 迭代法
1.2 窮舉搜索法
1.3 遞推法
1.4 遞歸法
1.5 貪婪法
1.6 分治法
1.7 動態規劃法
1.8 回溯法
算法基礎部分:
算法是對特定問題求解步驟的一種描述,算法是指令的有限序列,其中每一條指令表示一個或多個操作。
算法具有以下5個屬性:
有窮性:一個算法必須總是在執行有窮步之後結束,且每一步都在有窮時間內完成。
確定性:算法中每一條指令必須有確切的含義。不存在二義性。只有一個入口和一個出口
可行性:一個算法是可行的就是算法描述的操作是可以通過已經實現的基本運算執行有限次來實現的。
輸入:一個算法有零個或多個輸入,這些輸入取自於某個特定對象的集合。
輸出:一個算法有一個或多個輸出,這些輸出同輸入有着某些特定關係的量。
所以對應的算法設計的要求:
正確性:算法應滿足具體問題的需求;
可讀性:算法應該好讀,以有利於讀者對程序的理解;
健壯性:算法應具有容錯處理,當輸入爲非法數據時,算法應對其作出反應,而不是產生莫名其妙的輸出結果。
效率與存儲量需求:效率指的是算法執行的時間;存儲量需求指算法執行過程中所需要的最大存儲空間。一般這兩者與問題的規模有關。
1.1 迭代法:
迭代法是用於求方程或方程組近似根的一種常用的算法設計方法。設方程爲f(x)=0,用某種數學方法導出等價的形式x=g(x),然後按以下步驟執行:
(1)選一個方程的近似根,賦給變量x0;
(2)將x0的值保存於變量x1,然後計算g(x1),並將結果存於變量x0;
(3)當x0與x1的差的絕對值還小於指定的精度要求時,重複步驟(2)的計算。
若方程有根,並且用上述方法計算出來的近似根序列收斂,則按上述方法求得的x0就認爲是方程的根。上述算法用C程序的形式表示爲:
【算法】迭代法求方程的根
{ x0=初始近似根;
do {
x1=x0;
x0=g(x1); /*按特定的方程計算新的近似根*/
} while ( fabs(x0-x1)>Epsilon);
printf(“方程的近似根是%f/n”,x0);
}
迭代算法也常用於求方程組的根,令
X=(x0,x1,…,xn-1)
設方程組爲:
xi=gi(X) (I=0,1,…,n-1)
則求方程組根的迭代算法可描述如下:
【算法】迭代法求方程組的根
{ for (i=0;i<n;i++)
x[i]=初始近似根;
do {
for (i=0;i<n;i++)
y[i]=x[i];
for (i=0;i<n;i++)
x[i]=gi(X);
for (delta=0.0,i=0;i<n;i++)
if (fabs(y[i]-x[i])>delta)
delta=fabs(y[i]-x[i]);
} while (delta>Epsilon);
for (i=0;i<n;i++)
printf(“變量x[%d]的近似根是 %f”,I,x[i]);
printf(“/n”);
}
具體使用迭代法求根時應注意以下兩種可能發生的情況:
(1)如果方程無解,算法求出的近似根序列就不會收斂,迭代過程會變成死循環,因此在使用迭代算法前應先考察方程是否有解,並在程序中對迭代的次數給予限制;
(2)方程雖然有解,但迭代公式選擇不當,或迭代的初始近似根選擇不合理,也會導致迭代失敗。
窮舉搜索法是對可能是解的衆多候選解按某種順序進行逐一枚舉和檢驗,並從中找出那些符合要求的候選解作爲問題的解。
要解決的問題只有有限種可能,在沒有更好算法時總可以用窮舉搜索的辦法解決,即逐個的檢查所有可能的情況。可以想象,情況較多時這種方法極爲費時。實際上並不需要機械的檢查每一種情況,常常是可以提前判斷出某些情況不可能取到最優解,從而可以提前捨棄這些情況。這樣也是隱含的檢查了所有可能的情況,既減少了搜索量,又保證了不漏掉最優解。
【問題】 將A、B、C、D、E、F這六個變量排成如圖所示的三角形,這六個變量分別取[1,6]上的整數,且均不相同。求使三角形三條邊上的變量之和相等的全部解。如圖就是一個解。
程序引入變量a、b、c、d、e、f,並讓它們分別順序取1至6的整數,在它們互不相同的條件下,測試由它們排成的如圖所示的三角形三條邊上的變量之和是否相等,如相等即爲一種滿足要求的排列,把它們輸出。當這些變量取盡所有的組合後,程序就可得到全部可能的解。細節見下面的程序。
# include <stdio.h>
void main()
{ int a,b,c,d,e,f;
for (a=1;a<=6;a++) //a,b,c,d,e依次取不同的值
for (b=1;b<=6;b++) {
if (b==a) continue;
for (c=1;c<=6;c++) {
if (c==a)||(c==b) continue;
for (d=1;d<=6;d++) {
if (d==a)||(d==b)||(d==c) continue;
for (e=1;e<=6;e++) {
if (e==a)||(e==b)||(e==c)||(e==d) continue;
f=21-(a+b+c+d+e);//最後一個用減法算
if ((a+b+c==c+d+e))&&(a+b+c==e+f+a)) {
printf(“%6d,a);
printf(“%4d%4d”,b,f);
printf(“%2d%4d%4d”,c,d,e);
scanf(“%c”);
}
}
}
}
}
}
按窮舉法編寫的程序通常不能適應變化的情況。如問題改成有9個變量排成三角形,每條邊有4個變量的情況,程序的循環重數就要相應改變,循環的重數和變量的個數相關。
從上述問題解決的方法中,最重要的因素就是確定某種方法來確定所有的候選解。下
遞推法是利用問題本身所具有的一種遞推關係求問題解的一種方法。設要求問題規模爲N的解,當N=1時,解或爲已知,或能非常方便地得到解。能採用遞推法構造算法的問題有重要的遞推性質,即當得到問題規模爲i-1的解後,由問題的遞推性質,能從已求得的規模爲1,2,…,i-1的一系列解,構造出問題規模爲I的解。這樣,程序可從i=0或i=1出發,重複地,由已知至i-1規模的解,通過遞推,獲得規模爲i的解,直至得到規模爲N的解。
【問題】 階乘計算
問題描述:編寫程序,對給定的n(n≦100),計算並輸出k的階乘k!(k=1,2,…,n)的全部有效數字。
由於要求的整數可能大大超出一般整數的位數,程序用一維數組存儲長整數,存儲長整數數組的每個元素只存儲長整數的一位數字。如有m位成整數N用數組a[ ]存儲:
N=a[m]×
並用a[0]存儲長整數N的位數m,即a[0]=m。按上述約定,數組的每個元素存儲k的階乘k!的一位數字,並從低位到高位依次存於數組的第二個元素、第三個元素……。例如,5!=120,在數組中的存儲形式爲:
3 |
0 |
2 |
1 |
…… |
首元素3表示長整數是一個3位數,接着是低位到高位依次是0、2、1,表示成整數120。
計算階乘k!可採用對已求得的階乘(k-1)!連續累加k-1次後求得。例如,已知4!=24,計算5!,可對原來的24累加4次24後得到120。細節見以下程序。
# include <stdio.h>
# include <malloc.h>
# define MAXN 1000
void pnext(int a[ ],int k)//已知a中的(k-1)!,求出k!在a中。
{ int *b,m=a[0],i,j,r,carry;
b=(int * ) malloc(sizeof(int)* (m+1));
for ( i=1;i<=m;i++) b[i]=a[i];
for ( j=1;j<k;j++) //控制累加k-1次
{ for ( carry=0,i=1;i<=m;i++)//i存放的是整數的位數
{ r=(i<a[0]?a[i]+b[i]:a[i])+carry;//進位標誌
a[i]=r%10;
carry=r/10;
}
if (carry) a[++m]=carry;
}
free(b);
a[0]=m;
}
void write(int *a,int k)//功能是輸出累加K次後的數組的各個位
{ int i;
printf(“%4d!=”,k);
for (i=a[0];i>0;i--)
printf(“%d”,a[i]);
printf(“/n/n”);
}
void main()
{ int a[MAXN],n,k;
printf(“Enter the number n: “);
scanf(“%d”,&n);
a[0]=1;
a[1]=1;
write(a,1);
for (k=2;k<=n;k++)
{ pnext(a,k);
write(a,k);//輸出長整數的各位
getchar();
}
}
遞歸是設計和描述算法的一種有力的工具,由於它在複雜算法的描述中被經常採用,爲此在進一步介紹其他算法設計方法之前先討論它。
能採用遞歸描述的算法通常有這樣的特徵:爲求解規模爲N的問題,設法將它分解成規模較小的問題,然後從這些小問題的解方便地構造出大問題的解,並且這些規模較小的問題也能採用同樣的分解和綜合方法,分解成規模更小的問題,並從這些更小問題的解構造出規模較大問題的解。特別地,當規模N=1時,能直接得解。
【問題】編寫計算斐波那契(Fibonacci)數列的第n項函數fib(n)。斐波那契數列爲:0、1、1、2、3、……,即:
fib(0)=0;
fib(1)=1;
fib(n)=fib(n-1)+fib(n-2) (當n>1時)。
寫成遞歸函數有:
int fib(int n)
{
if (n==0) return 0;
if (n==1) return 1;
if (n>1) return fib(n-1)+fib(n-2);
}
遞歸算法的執行過程分遞推和迴歸兩個階段。在遞推階段,把較複雜的問題(規模爲n)的求解推到比原問題簡單一些的問題(規模小於n)的求解。例如上例中,求解fib(n),把它推到求解fib(n-1)和fib(n-2)。也就是說,爲計算fib(n),必須先計算fib(n-1)和fib(n-2),而計算fib(n-1)和fib(n-2),又必須先計算fib(n-3)和fib(n-4)。依次類推,直至計算fib(1)和fib(0),分別能立即得到結果1和0。在遞推階段,必須要有終止遞歸的情況。例如在函數fib中,當n爲1和0的情況。
在迴歸階段,當獲得最簡單情況的解後,逐級返回,依次得到稍複雜問題的解,例如得到fib(1)和fib(0)後,返回得到fib(2)的結果,……,在得到了fib(n-1)和fib(n-2)的結果後,返回得到fib(n)的結果。
在編寫遞歸函數時要注意,函數中的局部變量和參數只是侷限於當前調用層,當遞推進入“簡單問題”層時,原來層次上的參數和局部變量便被隱蔽起來。在一系列“簡單問題”層,它們各有自己的參數和局部變量。
由於遞歸引起一系列的函數調用,並且可能會有一系列的重複計算,遞歸算法的執行效率相對較低。當某個遞歸算法能較方便地轉換成遞推算法時,通常按遞推算法編寫程序。例如上例計算斐波那契數列的第n項的函數fib(n)應採用遞推算法,即從斐波那契數列的前兩項出發,逐次由前兩項計算出下一項,直至計算出要求的第n項。
【問題】揹包問題
問題描述:有不同價值、不同重量的物品n件,求從這n件物品中選取一部分物品的選擇方案,使選中物品的總重量不超過指定的限制重量,但選中物品的價值之和最大。
設n件物品的重量分別爲w0、w1、…、wn-1,物品的價值分別爲v0、v1、…、vn-1。採用遞歸尋找物品的選擇方案。設前面已有了多種選擇的方案,並保留了其中總價值最大的方案於數組option[ ],該方案的總價值存於變量maxv。當前正在考察新方案,其物品選擇情況保存於數組cop[ ]。假定當前方案已考慮了前i-1件物品,現在要考慮第i件物品;當前方案已包含的物品的重量之和爲tw;至此,若其餘物品都選擇是可能的話,本方案能達到的總價值的期望值爲tv。算法引入tv是當一旦當前方案的總價值的期望值也小於前面方案的總價值maxv時,繼續考察當前方案變成無意義的工作,應終止當前方案,立即去考察下一個方案。因爲當方案的總價值不比maxv大時,該方案不會被再考察,這同時保證函數後找到的方案一定會比前面的方案更好。
對於第i件物品的選擇考慮有兩種可能:
(1)考慮物品i被選擇,這種可能性僅當包含它不會超過方案總重量限制時纔是可行的。選中後,繼續遞歸去考慮其餘物品的選擇。
(2)考慮物品i不被選擇,這種可能性僅當不包含物品i也有可能會找到價值更大的方案的情況。
按以上思想寫出遞歸算法如下:
try(物品i,當前選擇已達到的重量和,本方案可能達到的總價值tv)
{ /*考慮物品i包含在當前方案中的可能性*/
if(包含物品i是可以接受的)
{ 將物品i包含在當前方案中;
if (i<n-1)
try(i+1,tw+物品i的重量,tv);
else
/*又一個完整方案,因爲它比前面的方案好,以它作爲最佳方案*/
以當前方案作爲臨時最佳方案保存;
恢復物品i不包含狀態;
}
/*考慮物品i不包含在當前方案中的可能性*/
if (不包含物品i僅是可考慮的)
if (i<n-1)
try(i+1,tw,tv-物品i的價值);
else
/*又一個完整方案,因它比前面的方案好,以它作爲最佳方案*/
以當前方案作爲臨時最佳方案保存;
}
爲了理解上述算法,特舉以下實例。設有4件物品,它們的重量和價值見表:
物品 |
0 |
1 |
2 |
3 |
重量 |
5 |
3 |
2 |
1 |
價值 |
4 |
4 |
3 |
1 |
並設限制重量爲7。則按以上算法,下圖表示找解過程。由圖知,一旦找到一個解,算法就進一步找更好的解。如能判定某個查找分支不會找到更好的解,算法不會在該分支繼續查找,而是立即終止該分支,並去考察下一個分支。
Try(物品號,總重,價值)
按上述算法編寫函數和程序如下:
【程序】
# include <stdio.h>
# define N 100
double limitW,totV,maxV;
int option[N],cop[N];
struct { double weight;
double value;
}a[N];
int n;
void find(int i,double tw,double tv)
{ int k;
/*考慮物品i包含在當前方案中的可能性*/
if (tw+a[i].weight<=limitW)
{ cop[i]=1;
if (i<n-1) find(i+1,tw+a[i].weight,tv);
else
{ for (k=0;k<n;k++)
option[k]=cop[k];
maxv=tv;
}
cop[i]=0;
}
/*考慮物品i不包含在當前方案中的可能性*/
if (tv-a[i].value>maxV)
if (i<n-1) find(i+1,tw,tv-a[i].value);
else
{ for (k=0;k<n;k++)
option[k]=cop[k];
maxv=tv-a[i].value;
}
}
void main()
{ int k;
double w,v;
printf(“輸入物品種數/n”);
scanf((“%d”,&n);
printf(“輸入各物品的重量和價值/n”);
for (totv=0.0,k=0;k<n;k++)
{ scanf(“%
a[k].weight=w;
a[k].value=v;
totV+=V;
}
printf(“輸入限制重量/n”);
scanf(“%
maxv=0.0;
for (k=0;k<n;k++) cop[k]=0;
find(0,0.0,totV);
for (k=0;k<n;k++)
if (option[k]) printf(“%4d”,k+1);
printf(“/n總價值爲%
}
作爲對比,下面以同樣的解題思想,考慮非遞歸的程序解。爲了提高找解速度,程序不是簡單地逐一生成所有候選解,而是從每個物品對候選解的影響來形成值得進一步考慮的候選解,一個候選解是通過依次考察每個物品形成的。
對物品i的考察有這樣幾種情況:
1. 當該物品被包含在候選解中依舊滿足解的總重量的限制,該物品被包含在候選解中是應該繼續考慮的;
2. 反之,該物品不應該包括在當前正在形成的候選解中。
3. 僅當物品不被包括在候選解中,還是有可能找到比目前臨時最佳解更好的候選解時,纔去考慮該物品不被包括在候選解中;
4. 該物品不包括在當前候選解中的方案也不應繼續考慮。
5. 對於任意一個值得考慮的餓方案,程序就去進一步考慮下一個物品;
【程序】
# include <stdio.h>
# define N 100
double limitW;
int cop[N];
struct ele { double weight;
double value;
} a[N];
int k,n;
struct { int flg;
double tw;
double tv;
}twv[N];
void next(int i,double tw,double tv)
{ twv[i].flg=1;
twv[i].tw=tw;
twv[i].tv=tv;
}
double find(struct ele *a,int n)
{ int i,k,f;
double maxv,tw,tv,totv;
maxv=0;
for (totv=0.0,k=0;k<n;k++)
totv+=a[k].value;
next(0,0.0,totv);
i=0;
While (i>=0)
{ f=twv[i].flg;
tw=twv[i].tw;
tv=twv[i].tv;
switch(f)
{ case 1: twv[i].flg++;
if (tw+a[i].weight<=limitW)
if (i<n-1)
{ next(i+1,tw+a[i].weight,tv);
i++;
}
else
{ maxv=tv;
for (k=0;k<n;k++)
cop[k]=twv[k].flg!=0;
}
break;
case 0: i--;
break;
default: twv[i].flg=0;
if (tv-a[i].value>maxv)
if (i<n-1)
{ next(i+1,tw,tv-a[i].value);
i++;
}
else
{ maxv=tv-a[i].value;
for (k=0;k<n;k++)
cop[k]=twv[k].flg!=0;
}
break;
}
}
return maxv;
}
void main()
{ double maxv;
printf(“輸入物品種數/n”);
scanf((“%d”,&n);
printf(“輸入限制重量/n”);
scanf(“%
printf(“輸入各物品的重量和價值/n”);
for (k=0;k<n;k++)
scanf(“%
maxv=find(a,n);
printf(“/n選中的物品爲/n”);
for (k=0;k<n;k++)
if (option[k]) printf(“%4d”,k+1);
printf(“/n總價值爲%
}
貪心法是求解關於獨立系統組合優化問題的一種簡單算法,求最小生成樹的Kruskal算法就是一種貪心算法。
貪心法的基本思路是:從問題的某一個初始解出發逐步逼近給定的目標,以儘可能快的地求得更好的解。當達到某算法中的某一步不能再繼續前進時,算法停止。
該算法存在問題:
1. 不能保證求得的最後解是最佳的;
2. 不能用來求最大或最小解問題;
3. 只能求滿足某些約束條件的可行解的範圍。
實現該算法的過程:
從問題的某一初始解出發;
while 能朝給定總目標前進一步 do
求出可行解的一個解元素;
由所有解元素組合成問題的一個可行解;
貪婪法是一種不追求最優解,只希望得到較爲滿意解的方法。貪婪法一般可以快速得到滿意的解,因爲它省去了爲找最優解要窮盡所有可能而必須耗費的大量時間。貪婪法常以當前情況爲基礎作最優選擇,而不考慮各種可能的整體情況,所以貪婪法不要回溯。
例如平時購物找錢時,爲使找回的零錢的硬幣數最少,不考慮找零錢的所有各種發表方案,而是從最大面值的幣種開始,按遞減的順序考慮各幣種,先儘量用大面值的幣種,當不足大面值幣種的金額時纔去考慮下一種較小面值的幣種。這就是在使用貪婪法。這種方法在這裏總是最優,是因爲銀行對其發行的硬幣種類和硬幣面值的巧妙安排。如只有面值分別爲1、5和11單位的硬幣,而希望找回總額爲15單位的硬幣。按貪婪算法,應找1個11單位面值的硬幣和4個1單位面值的硬幣,共找回5個硬幣。但最優的解應是3個5單位面值的硬幣。
【問題】裝箱問題
問題描述:裝箱問題可簡述如下:設有編號爲0、1、…、n-1的n種物品,體積分別爲v0、v1、…、vn-1。將這n種物品裝到容量都爲V的若干箱子裏。約定這n種物品的體積均不超過V,即對於0≤i<n,有0<vi≤V。不同的裝箱方案所需要的箱子數目可能不同。裝箱問題要求使裝盡這n種物品的箱子數要少。
若考察將n種物品的集合分劃成n個或小於n個物品的所有子集,最優解就可以找到。但所有可能劃分的總數太大。對適當大的n,找出所有可能的劃分要花費的時間是無法承受的。爲此,對裝箱問題採用非常簡單的近似算法,即貪婪法。該算法依次將物品放到它第一個能放進去的箱子中,該算法雖不能保證找到最優解,但還是能找到非常好的解。不失一般性,設n件物品的體積是按從大到小排好序的,即有v0≥v1≥…≥vn-1。如不滿足上述要求,只要先對這n件物品按它們的體積從大到小排序,然後按排序結果對物品重新編號即可。裝箱算法簡單描述如下:
{ 輸入箱子的容積;
輸入物品種數n;
按體積從大到小順序,輸入各物品的體積;
預置已用箱子鏈爲空;
預置已用箱子計數器box_count爲0;
for (i=0;i<n;i++)
{ 從已用的第一隻箱子開始順序尋找能放入物品i 的箱子j;
if (已用箱子都不能再放物品i)
{ 另用一個箱子j,並將物品i放入該箱子;
box_count++;
}
else
將物品i放入箱子j;
}
}
上述算法能求出需要的箱子數box_count,並能求出各箱子所裝物品。下面的例子說明該算法不一定能找到最優解,設有6種物品,它們的體積分別爲:60、45、35、20、20和20單位體積,箱子的容積爲100個單位體積。按上述算法計算,需三隻箱子,各箱子所裝物品分別爲:第一隻箱子裝物品1、3;第二隻箱子裝物品2、4、5;第三隻箱子裝物品6。而最優解爲兩隻箱子,分別裝物品1、4、5和2、3、6。
若每隻箱子所裝物品用鏈表來表示,鏈表首結點指針存於一個結構中,結構記錄尚剩餘的空間量和該箱子所裝物品鏈表的首指針。另將全部箱子的信息也構成鏈表。以下是按以上算法編寫的程序。
【程序】
# include <stdio.h>
# include <stdlib.h>
typedef struct ele
{ int vno;
struct ele *link;
} ELE;
typedef struct hnode
{ int remainder;
ELE *head;
Struct hnode *next;
} HNODE;
void main()
{ int n, i, box_count, box_volume, *a;
HNODE *box_h, *box_t, *j;
ELE *p, *q;
Printf(“輸入箱子容積/n”);
Scanf(“%d”,&box_volume);
Printf(“輸入物品種數/n”);
Scanf(“%d”,&n);
A=(int *)malloc(sizeof(int)*n);
Printf(“請按體積從大到小順序輸入各物品的體積:”);
For (i=0;i<n;i++) scanf(“%d”,a+i);
Box_h=box_t=NULL;
Box_count=0;
For (i=0;i<n;i++)
{ p=(ELE *)malloc(sizeof(ELE));
p->vno=i;
for (j=box_h;j!=NULL;j=j->next)
if (j->remainder>=a[i]) break;
if (j==NULL)
{ j=(HNODE *)malloc(sizeof(HNODE));
j->remainder=box_volume-a[i];
j->head=NULL;
if (box_h==NULL) box_h=box_t=j;
else box_t=boix_t->next=j;
j->next=NULL;
box_count++;
}
else j->remainder-=a[i];
for (q=j->next;q!=NULL&&q->link!=NULL;q=q->link);
if (q==NULL)
{ p->link=j->head;
j->head=p;
}
else
{ p->link=NULL;
q->link=p;
}
}
printf(“共使用了%d只箱子”,box_count);
printf(“各箱子裝物品情況如下:”);
for (j=box_h,i=1;j!=NULL;j=j->next,i++)
{ printf(“第%2d只箱子,還剩餘容積%4d,所裝物品有;/n”,I,j->remainder);
for (p=j->head;p!=NULL;p=p->link)
printf(“%4d”,p->vno+1);
printf(“/n”);
}
}
1.分治法的基本思想
任何一個可以用計算機求解的問題所需的計算時間都與其規模N有關。問題的規模越小,越容易直接求解,解題所需的計算時間也越少。例如,對於n個元素的排序問題,當n=1時,不需任何計算;n=2時,只要作一次比較即可排好序;n=3時只要作3次比較即可,…。而當n較大時,問題就不那麼容易處理了。要想直接解決一個規模較大的問題,有時是相當困難的。
分治法的設計思想是,將一個難以直接解決的大問題,分割成一些規模較小的相同問題,以便各個擊破,分而治之。
如果原問題可分割成k個子問題(1<k≤n),且這些子問題都可解,並可利用這些子問題的解求出原問題的解,那麼這種分治法就是可行的。由分治法產生的子問題往往是原問題的較小模式,這就爲使用遞歸技術提供了方便。在這種情況下,反覆應用分治手段,可以使子問題與原問題類型一致而其規模卻不斷縮小,最終使子問題縮小到很容易直接求出其解。這自然導致遞歸過程的產生。分治與遞歸像一對孿生兄弟,經常同時應用在算法設計之中,並由此產生許多高效算法。
2.分治法的適用條件
分治法所能解決的問題一般具有以下幾個特徵:
(1)該問題的規模縮小到一定的程度就可以容易地解決;
(2)該問題可以分解爲若干個規模較小的相同問題,即該問題具有最優子結構性質;
(3)利用該問題分解出的子問題的解可以合併爲該問題的解;
(4)該問題所分解出的各個子問題是相互獨立的,即子問題之間不包含公共的子問題。
第一條特徵是絕大多數問題都可以滿足的,因爲問題的計算複雜性一般是隨着問題規模的增加而增加;
第二條特徵是應用分治法的前提,它也是大多數問題可以滿足的,此特徵反映了遞歸思想的應用;
第三條特徵是關鍵,能否利用分治法完全取決於問題是否具有第三條特徵,如果具備了第一條和第二條特徵,而不具備第三條特徵,則可以考慮貪心法或動態規劃法。
第四條特徵涉及到分治法的效率,如果各子問題是不獨立的,則分治法要做許多不必要的工作,重複地解公共的子問題,此時雖然可用分治法,但一般用動態規劃法較好。
3.分治法的基本步驟
分治法在每一層遞歸上都有三個步驟:
(1)分解:將原問題分解爲若干個規模較小,相互獨立,與原問題形式相同的子問題;
(2)求解:若子問題規模較小而容易被解決則直接解,否則遞歸地解各個子問題;
(3)合併:將各個子問題的解合併爲原問題的解。
它的一般的算法設計模式如下:
Divide_and_Conquer(P)
if |P|≤n0
then return(ADHOC(P))
將P分解爲較小的子問題P1、P2、…、Pk
for i←1 to k
do
yi ← Divide-and-Conquer(Pi) △ 遞歸解決Pi
T ← MERGE(y1,y2,…,yk) △ 合併子問題
Return(T)
其中 |P| 表示問題P的規模;n0爲一閾值,表示當問題P的規模不超過n0時,問題已容易直接解出,不必再繼續分解。ADHOC(P)是該分治法中的基本子算法,用於直接解小規模的問題P。因此,當P的規模不超過n0時,直接用算法ADHOC(P)求解。
算法MERGE(y1,y2,…,yk)是該分治法中的合併子算法,用於將P的子問題P1、P2、…、Pk的相應的解y1、y2、…、yk合併爲P的解。
根據分治法的分割原則,原問題應該分爲多少個子問題才較適宜?各個子問題的規模應該怎樣才爲適當?這些問題很難予以肯定的回答。但人們從大量實踐中發現,在用分治法設計算法時,最好使子問題的規模大致相同。換句話說,將一個問題分成大小相等的k個子問題的處理方法是行之有效的。許多問題可以取k=2。這種使子問題規模大致相等的做法是出自一種平衡子問題的思想,它幾乎總是比子問題規模不等的做法要好。
分治法的合併步驟是算法的關鍵所在。有些問題的合併方法比較明顯,有些問題合併方法比較複雜,或者是有多種合併方案;或者是合併方案不明顯。究竟應該怎樣合併,沒有統一的模式,需要具體問題具體分析。
【問題】循環賽日程表
問題描述:設有n=2k個運動員要進行網球循環賽。設計一個滿足以下要求的比賽日程表:
(1)每個選手必須與其他n-1個選手各賽一次;
(2)每個選手一天只能參賽一次;
(3)循環賽在n-1天內結束。
請按此要求將比賽日程表設計成有n行和n-1列的一個表。在表中的第i行,第j列處填入第i個選手在第j天所遇到的選手。其中1≤i≤n,1≤j≤n-1。
按分治策略,我們可以將所有的選手分爲兩半,則n個選手的比賽日程表可以通過n/2個選手的比賽日程表來決定。遞歸地用這種一分爲二的策略對選手進行劃分,直到只剩下兩個選手時,比賽日程表的制定就變得很簡單。這時只要讓這兩個選手進行比賽就可以了。
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
2 |
3 |
4 |
5 |
6 |
7 |
|
|
|
|
|
|
|
|
|
|
|
|
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
|
|
|
|
|
|
|
|
|
|
|
|
2 |
1 |
4 |
3 |
6 |
7 |
8 |
5 |
|
|
|
|
|
|
|
|
|
|
|
|
3 |
4 |
1 |
2 |
7 |
8 |
5 |
6 |
|
|
|
|
|
|
1 |
2 |
3 |
|
|
|
4 |
3 |
2 |
1 |
8 |
5 |
6 |
7 |
|
|
|
|
|
1 |
2 |
3 |
4 |
|
|
|
5 |
6 |
7 |
8 |
1 |
4 |
3 |
2 |
|
1 |
|
|
|
2 |
1 |
4 |
3 |
|
|
|
6 |
5 |
8 |
7 |
2 |
1 |
4 |
3 |
1 |
2 |
|
|
|
3 |
4 |
1 |
2 |
|
|
|
7 |
8 |
5 |
6 |
3 |
2 |
1 |
4 |
2 |
1 |
|
|
|
4 |
3 |
2 |
1 |
|
|
|
8 |
7 |
6 |
5 |
4 |
3 |
2 |
1 |
(1) (2) (3)
圖表示 2個、4個和8個選手的比賽日程表
圖1所列出的正方形表(3)是8個選手的比賽日程表。其中左上角與左下角的兩小塊分別爲選手1至選手4和選手5至選手8前3天的比賽日程。據此,將左上角小塊中的所有數字按其相對位置抄到右下角,又將左下角小塊中的所有數字按其相對位置抄到右上角,這樣我們就分別安排好了選手1至選手4和選手5至選手8在後4天的比賽日程。依此思想容易將這個比賽日程表推廣到具有任意多個選手的情形。
經常會遇到複雜問題不能簡單地分解成幾個子問題,而會分解出一系列的子問題。簡單地採用把大問題分解成子問題,並綜合子問題的解導出大問題的解的方法,問題求解耗時會按問題規模呈冪級數增加。
爲了節約重複求相同子問題的時間,引入一個數組,不管它們是否對最終解有用,把所有子問題的解存於該數組中,這就是動態規劃法所採用的基本方法。以下先用實例說明動態規劃方法的使用。
◆動態規劃的適用條件
任何思想方法都有一定的侷限性,超出了特定條件,它就失去了作用。同樣,動態規劃也並不是萬能的。適用動態規劃的問題必須滿足最優化原理和無後效性。
(1)最優化原理(最優子結構性質)
最優化原理可這樣闡述:一個最優化策略具有這樣的性質,不論過去狀態和決策如何,對前面的決策所形成的狀態而言,餘下的諸決策必須構成最優策略。簡而言之,一個最優化策略的子策略總是最優的。一個問題滿足最優化原理又稱其具有最優子結構性質。
圖2
例如圖2中,若路線I和J是A到C的最優路徑,則根據最優化原理,路線J必是從B到C的最優路線。這可用反證法證明:假設有另一路徑J’是B到C的最優路徑,則A到C的路線取I和J’比I和J更優,矛盾。從而證明J’必是B到C的最優路徑。
最優化原理是動態規劃的基礎,任何問題,如果失去了最優化原理的支持,就不可能用動態規劃方法計算。根據最優化原理導出的動態規劃基本方程是解決一切動態規劃問題的基本方法。
(2)無後向性
將各階段按照一定的次序排列好之後,對於某個給定的階段狀態,它以前各階段的狀態無法直接影響它未來的決策,而只能通過當前的這個狀態。換句話說,每個狀態都是過去歷史的一個完整總結。這就是無後向性,又稱爲無後效性。
(3)子問題的重疊性
動態規劃算法的關鍵在於解決冗餘,這是動態規劃算法的根本目的。動態規劃實質上是一種以空間換時間的技術,它在實現的過程中,不得不存儲產生過程中的各種狀態,所以它的空間複雜度要大於其它的算法。選擇動態規劃算法是因爲動態規劃算法在空間上可以承受,而搜索算法在時間上卻無法承受,所以我們舍空間而取時間。
所以,能夠用動態規劃解決的問題還有一個顯著特徵:子問題的重疊性。這個性質並不是動態規劃適用的必要條件,但是如果該性質無法滿足,動態規劃算法同其他算法相比就不具備優勢。
◆動態規劃的基本思想
前文主要介紹了動態規劃的一些理論依據,我們將前文所說的具有明顯的階段劃分和狀態轉移方程的動態規劃稱爲標準動態規劃,這種標準動態規劃是在研究多階段決策問題時推導出來的,具有嚴格的數學形式,適合用於理論上的分析。在實際應用中,許多問題的階段劃分並不明顯,這時如果刻意地劃分階段法反而麻煩。一般來說,只要該問題可以劃分成規模更小的子問題,並且原問題的最優解中包含了子問題的最優解(即滿足最優子化原理),則可以考慮用動態規劃解決。
動態規劃的實質是分治思想和解決冗餘,因此,動態規劃是一種將問題實例分解爲更小的、相似的子問題,並存儲子問題的解而避免計算重複的子問題,以解決最優化問題的算法策略。
由此可知,動態規劃法與分治法和貪心法類似,它們都是將問題實例歸納爲更小的、相似的子問題,並通過求解子問題產生一個全局最優解。
貪心法的當前選擇可能要依賴已經作出的所有選擇,但不依賴於有待於做出的選擇和子問題。因此貪心法自頂向下,一步一步地作出貪心選擇;
而分治法中的各個子問題是獨立的(即不包含公共的子問題),因此一旦遞歸地求出各子問題的解後,便可自下而上地將子問題的解合併成問題的解。
不足之處:如果當前選擇可能要依賴子問題的解時,則難以通過局部的貪心策略達到全局最優解;如果各子問題是不獨立的,則分治法要做許多不必要的工作,重複地解公共的子問題。
解決上述問題的辦法是利用動態規劃。該方法主要應用於最優化問題,這類問題會有多種可能的解,每個解都有一個值,而動態規劃找出其中最優(最大或最小)值的解。若存在若干個取最優值的解的話,它只取其中的一個。在求解過程中,該方法也是通過求解局部子問題的解達到全局最優解,但與分治法和貪心法不同的是,動態規劃允許這些子問題不獨立,(亦即各子問題可包含公共的子問題)也允許其通過自身子問題的解作出選擇,該方法對每一個子問題只解一次,並將結果保存起來,避免每次碰到時都要重複計算。
因此,動態規劃法所針對的問題有一個顯著的特徵,即它所對應的子問題樹中的子問題呈現大量的重複。動態規劃法的關鍵就在於,對於重複出現的子問題,只在第一次遇到時加以求解,並把答案保存起來,讓以後再遇到時直接引用,不必重新求解。
3、動態規劃算法的基本步驟
設計一個標準的動態規劃算法,通常可按以下幾個步驟進行:
(1)劃分階段:按照問題的時間或空間特徵,把問題分爲若干個階段。注意這若干個階段一定要是有序的或者是可排序的(即無後向性),否則問題就無法用動態規劃求解。
(2)選擇狀態:將問題發展到各個階段時所處於的各種客觀情況用不同的狀態表示出來。當然,狀態的選擇要滿足無後效性。
(3)確定決策並寫出狀態轉移方程:之所以把這兩步放在一起,是因爲決策和狀態轉移有着天然的聯繫,狀態轉移就是根據上一階段的狀態和決策來導出本階段的狀態。所以,如果我們確定了決策,狀態轉移方程也就寫出來了。但事實上,我們常常是反過來做,根據相鄰兩段的各狀態之間的關係來確定決策。
(4)寫出規劃方程(包括邊界條件):動態規劃的基本方程是規劃方程的通用形式化表達式。
一般說來,只要階段、狀態、決策和狀態轉移確定了,這一步還是比較簡單的。動態規劃的主要難點在於理論上的設計,一旦設計完成,實現部分就會非常簡單。根據動態規劃的基本方程可以直接遞歸計算最優值,但是一般將其改爲遞推計算,實現的大體上的框架如下:
標準動態規劃的基本框架
1. 對fn+1(xn+1)初始化; {邊界條件}
for k:=n downto 1 do
for 每一個xk∈Xk do
for 每一個uk∈Uk(xk) do
begin
fk(xk):=一個極值; {∞或-∞}
xk+1:=Tk(xk,uk); {狀態轉移方程}
t:=φ(fk+1(xk+1),vk(xk,uk)); {基本方程(9)式}
if t比fk(xk)更優 then fk(xk):=t; {計算fk(xk)的最優值}
end;
t:=一個極值; {∞或-∞}
for 每一個x1∈X1 do
if f1(x1)比t更優 then t:=f1(x1); {按照10式求出最優指標}
輸出t;
但是,實際應用當中經常不顯式地按照上面步驟設計動態規劃,而是按以下幾個步驟進行:
(1)分析最優解的性質,並刻劃其結構特徵。
(2)遞歸地定義最優值。
(3)以自底向上的方式或自頂向下的記憶化方法(備忘錄法)計算出最優值。
(4)根據計算最優值時得到的信息,構造一個最優解。
步驟(1)~(3)是動態規劃算法的基本步驟。在只需要求出最優值的情形,步驟(4)可以省略,若需要求出問題的一個最優解,則必須執行步驟(4)。此時,在步驟(3)中計算最優值時,通常需記錄更多的信息,以便在步驟(4)中,根據所記錄的信息,快速地構造出一個最優解。
總結:動態規劃實際上就是最優化的問題,是指將原問題的大實例等價於同一最優化問題的較小實例,自底向上的求解最小實例,並將所求解存放起來,存放的結果就是爲了準備數據。與遞歸相比,遞歸是不斷的調用子程序求解,是自頂向下的調用和求解。
【問題】凸多邊形的最優三角剖分問題
問題描述:多邊形是平面上一條分段線性的閉曲線。也就是說,多邊形是由一系列首尾相接的直線段組成的。組成多邊形的各直線段稱爲該多邊形的邊。多邊形相接兩條邊的連接點稱爲多邊形的頂點。若多邊形的邊之間除了連接頂點外沒有別的公共點,則稱該多邊形爲簡單多邊形。一個簡單多邊形將平面分爲3個部分:被包圍在多邊形內的所有點構成了多邊形的內部;多邊形本身構成多邊形的邊界;而平面上其餘的點構成了多邊形的外部。當一個簡單多邊形及其內部構成一個閉凸集時,稱該簡單多邊形爲凸多邊形。也就是說凸多邊形邊界上或內部的任意兩點所連成的直線段上所有的點均在該凸多邊形的內部或邊界上。
通常,用多邊形頂點的逆時針序列來表示一個凸多邊形,即P=<v0,v1,…,vn-1>表示具有n條邊v0v1,v1v2,…,vn-1vn的一個凸多邊形,其中,約定v0=vn 。
若vi與vj是多邊形上不相鄰的兩個頂點,則線段vivj稱爲多邊形的一條弦。弦將多邊形分割成凸的兩個子多邊形<vi,vi+1,…,vj>和<vj,vj+1,…,vi>。多邊形的三角剖分是一個將多邊形分割成互不重迭的三角形的弦的集合T。圖1是一個凸多邊形的兩個不同的三角剖分。
|
|
(a) |
(b) |
圖1 一個凸多邊形的2個不同的三角剖分
在凸多邊形P的一個三角剖分T中,各弦互不相交且弦數已達到最大,即P的任一不在T中的弦必與T中某一弦相交。在一個有n個頂點的凸多邊形的三角刮分中,恰好有n-3條弦和n-2個三角形。
凸多邊形最優三角剖分的問題是:給定一個凸多邊形P=<v0,v1,…,vn-1>以及定義在由多邊形的邊和絃組成的三角形上的權函數ω。要求確定該凸多邊形的一個三角剖分,使得該三角剖分對應的權即剖分中諸三角形上的權之和爲最小。
可以定義三角形上各種各樣的權函數ω。例如:定義ω(△vivjvk)=| vivj |+| vivk |+| vkvj |,其中,| vivj |是點vi到vj的歐氏距離。相應於此權函數的最優三角剖分即爲最小弦長三角剖分。
(1)最優子結構性質
凸多邊形的最優三角剖分問題有最優子結構性質。事實上,若凸(n+1)邊形P=<v0,v1 ,…,vn>的一個最優三角剖分T包含三角形v0vkvn,1≤k≤n-1,則T的權爲3個部分權的和,即三角形v0vkvn的權,子多邊形<v0,v1,…,vk>的權和<vk,vk+1,…,vn>的權之和。可以斷言由T所確定的這兩個子多邊形的三角剖分也是最優的,因爲若有<v0,v1,…,vk>或<vk,vk+1,…,vn>的更小權的三角剖分,將會導致T不是最優三角剖分的矛盾。
(2)最優三角剖分對應的權的遞歸結構
首先,定義t[i,j](1≤i<j≤n)爲凸子多邊形<vi-1,vi,…,vj>的最優三角剖分所對應的權值,即最優值。爲方便起見,設退化的多邊形<vi-1,vi>具有權值0。據此定義,要計算的凸(n+1)邊多邊形P對應的權的最優值爲t[1,n]。
t[i,j]的值可以利用最優子結構性質遞歸地計算。由於退化的2頂點多邊形的權值爲0,所以t[i,i]=0,i=1,2,…,n 。當j一i≥1時,子多邊形<vi-1,vi,…,vj>至少有3個頂點。由最優於結構性質,t[i,j]的值應爲t[i,k]的值加上t[k+1,j]的值,再加上△vi-1vkvj的權值,並在i≤k≤j-1的範圍內取最小。由此,t[i,j]可遞歸地定義爲:
(3)計算最優值
下面描述的計算凸(n+1)邊形P=<v0,v1,…,vn>的三角剖分最優權值的動態規劃算法MINIMUM_WEIGHT,輸入是凸多邊形P=<v0,v1,…,vn>的權函數ω,輸出是最優值t[i,j]和使得t[i,k]+t[k+1,j]+ω(△vi-1vkvj)達到最優的位置(k=)s[i,j],1≤i≤j≤n 。
Procedure MINIMUM_WEIGHT(P,w);
Begin
n=length[p]-1;
for i=1 to n do
t[i,i]:=0;
for ll=2 to n do
for i=1 to n-ll+1 do
begin
j=i+ll-1;
t[i,j]=∞;
for k=i to j-1 do
begin
q=t[i,k]+t[k+1,j]+ω(△vi-1vkvj);
if q<t[i,j] then
begin
t[i,j]=q;
s[i,j]=k;
end;
end;
end;
return(t,s);
end;
算法MINIMUM_WEIGHT_佔用θ(n2)空間,耗時θ(n3)。
(4)構造最優三角剖分
如我們所看到的,對於任意的1≤i≤j≤n ,算法MINIMUM_WEIGHT在計算每一個子多邊形<vi-1,vi,…,vj>的最優三角剖分所對應的權值t[i,j]的同時,還在s[i,j]中記錄了此最優三角剖分中與邊(或弦)vi-1vj構成的三角形的第三個頂點的位置。因此,利用最優子結構性質並藉助於s[i,j],1≤i≤j≤n ,凸(n+l)邊形P=<v0,v1,…,vn>的最優三角剖分可容易地在Ο(n)時間內構造出來。
回溯法也稱爲試探法,該方法首先暫時放棄關於問題規模大小的限制,並將問題的候選解按某種順序逐一枚舉和檢驗。當發現當前候選解不可能是解時,就選擇下一個候選解;倘若當前候選解除了還不滿足問題規模要求外,滿足所有其他要求時,繼續擴大當前候選解的規模,並繼續試探。如果當前候選解滿足包括問題規模在內的所有要求時,該候選解就是問題的一個解。在回溯法中,放棄當前候選解,尋找下一個候選解的過程稱爲回溯。擴大當前候選解的規模,以繼續試探的過程稱爲向前試探。
1、回溯法的一般描述
可用回溯法求解的問題P,通常要能表達爲:對於已知的由n元組(x1,x2,…,xn)組成的一個狀態空間E={(x1,x2,…,xn)∣xi∈Si ,i=1,2,…,n},給定關於n元組中的一個分量的一個約束集D,要求E中滿足D的全部約束條件的所有n元組。其中Si是分量xi的定義域,且 |Si| 有限,i=1,2,…,n。我們稱E中滿足D的全部約束條件的任一n元組爲問題P的一個解。
解問題P的最樸素的方法就是枚舉法,即對E中的所有n元組逐一地檢測其是否滿足D的全部約束,若滿足,則爲問題P的一個解。但顯然,其計算量是相當大的。
我們發現,對於許多問題,所給定的約束集D具有完備性,即i元組(x1,x2,…,xi)滿足D中僅涉及到x1,x2,…,xi的所有約束意味着j(j<i)元組(x1,x2,…,xj)一定也滿足D中僅涉及到x1,x2,…,xj的所有約束,i=1,2,…,n。換句話說,只要存在0≤j≤n-1,使得(x1,x2,…,xj)違反D中僅涉及到x1,x2,…,xj的約束之一,則以(x1,x2,…,xj)爲前綴的任何n元組(x1,x2,…,xj,xj+1,…,xn)一定也違反D中僅涉及到x1,x2,…,xi的一個約束,n≥i>j。因此,對於約束集D具有完備性的問題P,一旦檢測斷定某個j元組(x1,x2,…,xj)違反D中僅涉及x1,x2,…,xj的一個約束,就可以肯定,以(x1,x2,…,xj)爲前綴的任何n元組(x1,x2,…,xj,xj+1,…,xn)都不會是問題P的解,因而就不必去搜索它們、檢測它們。回溯法正是針對這類問題,利用這類問題的上述性質而提出來的比枚舉法效率更高的算法。
回溯法首先將問題P的n元組的狀態空間E表示成一棵高爲n的帶權有序樹T,把在E中求問題P的所有解轉化爲在T中搜索問題P的所有解。樹T類似於檢索樹,它可以這樣構造:
設Si中的元素可排成xi(1) ,xi(2) ,…,xi(mi-1) ,|Si| =mi,i=1,2,…,n。從根開始,讓T的第I層的每一個結點都有mi個兒子。這mi個兒子到它們的雙親的邊,按從左到右的次序,分別帶權xi+1(1) ,xi+1(2) ,…,xi+1(mi) ,i=0,1,2,…,n-1。照這種構造方式,E中的一個n元組(x1,x2,…,xn)對應於T中的一個葉子結點,T的根到這個葉子結點的路徑上依次的n條邊的權分別爲x1,x2,…,xn,反之亦然。另外,對於任意的0≤i≤n-1,E中n元組(x1,x2,…,xn)的一個前綴I元組(x1,x2,…,xi)對應於T中的一個非葉子結點,T的根到這個非葉子結點的路徑上依次的I條邊的權分別爲x1,x2,…,xi,反之亦然。特別,E中的任意一個n元組的空前綴(),對應於T的根。
因而,在E中尋找問題P的一個解等價於在T中搜索一個葉子結點,要求從T的根到該葉子結點的路徑上依次的n條邊相應帶的n個權x1,x2,…,xn滿足約束集D的全部約束。在T中搜索所要求的葉子結點,很自然的一種方式是從根出發,按深度優先的策略逐步深入,即依次搜索滿足約束條件的前綴1元組(x1i)、前綴2元組(x1,x2)、…,前綴I元組(x1,x2,…,xi),…,直到i=n爲止。
在回溯法中,上述引入的樹被稱爲問題P的狀態空間樹;樹T上任意一個結點被稱爲問題P的狀態結點;樹T上的任意一個葉子結點被稱爲問題P的一個解狀態結點;樹T上滿足約束集D的全部約束的任意一個葉子結點被稱爲問題P的一個回答狀態結點,它對應於問題P的一個解。
分支限界法:
這是一種用於求解組合優化問題的排除非解的搜索算法。類似於回溯法,分枝定界法在搜索解空間時,也經常使用樹形結構來組織解空間。然而與回溯法不同的是,回溯算法使用深度優先方法搜索樹結構,而分枝定界一般用寬度優先或最小耗費方法來搜索這些樹。因此,可以很容易比較回溯法與分枝定界法的異同。相對而言,分枝定界算法的解空間比回溯法大得多,因此當內存容量有限時,回溯法成功的可能性更大。
算法思想:分枝定界(branch and bound)是另一種系統地搜索解空間的方法,它與回溯法的主要區別在於對E-節點的擴充方式。每個活節點有且僅有一次機會變成E-節點。當一個節點變爲E-節點時,則生成從該節點移動一步即可到達的所有新節點。在生成的節點中,拋棄那些不可能導出(最優)可行解的節點,其餘節點加入活節點表,然後從表中選擇一個節點作爲下一個E-節點。從活節點表中取出所選擇的節點並進行擴充,直到找到解或活動表爲空,擴充過程才結束。
有兩種常用的方法可用來選擇下一個E-節點(雖然也可能存在其他的方法):
1) 先進先出(F I F O) 即從活節點表中取出節點的順序與加入節點的順序相同,因此活
節點表的性質與隊列相同。
2) 最小耗費或最大收益法在這種模式中,每個節點都有一個對應的耗費或收益。如果查找
一個具有最小耗費的解,則活節點表可用最小堆來建立,下一個E-節點就是具有最小耗費
的活節點;如果希望搜索一個具有最大收益的解,則可用最大堆來構造活節點表,下一個
E-節點是具有最大收益的活節點。
2.1 堆排序
堆排序也是選擇排序的一種,其特點是,在以後各趟的“選擇”中利用在第一趟選擇中已經得到的關鍵字比較的結果。
堆的定義: 堆是滿足下列性質的數列{r1, r2, …,rn}: 或 若將此數列看成是一棵完全二叉樹,則堆或是空樹或是滿足下列特性的完全二叉樹:其左、右子樹分別是堆,並且當左/右子樹不空時,根結點的值小於(或大於)左/右子樹根結點的值。
由此,若上述數列是堆,則r1必是數列中的最小值或最大值,分別稱作小頂堆或大頂堆。
堆排序即是利用堆的特性對記錄序列進行排序的一種排序方法。具體作法是:先建一個“大頂堆”,即先選得一個關鍵字爲最大的記錄,然後與序列中最後一個記錄交換,之後繼續對序列中前n-1記錄進行“篩選”,重新將它調整爲一個“大頂堆”,再將堆頂記錄和第n-1個記錄交換,如此反覆直至排序結束。
所謂“篩選”指的是,對一棵左/右子樹均爲堆的完全二叉樹,“調整”根結點使整個二叉樹爲堆。
堆排序的算法如下所示:
template
void HeapSort ( Elem R[], int n ) {
// 對記錄序列R[1..n]進行堆排序。
for ( i=n/2; i>0; --i )
// 把R[1..n]建成大頂堆
HeapAdjust ( R, i, n );
for ( i=n; i>1; --i ) {
R[1]←→R;
// 將堆頂記錄和當前未經排序子序列
// R[1..i]中最後一個記錄相互交換
HeapAdjust(R, 1, i-1);
// 將R[1..i-1] 重新調整爲大頂堆
}
} // HeapSort
其中篩選的算法如下所示。爲將R[s..m]調整爲“大頂堆”,算法中“篩選”應沿關鍵字較大的孩子結點向下進行。
Template
void HeapAdjust (Elem R[], int s, int m) {
// 已知R[s..m]中記錄的關鍵字除R[s].key之
// 外均滿足堆的定義,本函數調整R[s] 的關
// 鍵字,使R[s..m]成爲一個大頂堆(對其中
// 記錄的關鍵字而言)
rc = R[s];
for ( j=2*s; j<=m; j*=2 ) {// 沿key較大的孩子結點向下篩選
if ( j if ( rc.key >= R[j].key ) break; // rc應插入在位置s上
R[s] = R[j]; s = j;
}
R[s] = rc; // 插入
} // HeapAdjust
堆排序的時間複雜度分析:
1. 對深度爲k的堆,“篩選”所需進行的關鍵字比較的次數至多爲2(k-1);
2.對n個關鍵字,建成深度爲+1)ûlog2nëh(=的堆,所需進行的關鍵字比較的次數至多爲4n;
3. 調整“堆頂”n-1次,總共進行的關鍵字比較的次數不超過
+û2(log2(n-1) + …+log22)ûlog2(n-2)ë<log2në2n(
因此,堆排序的時間複雜度爲O(nlogn)
歸併排序:是通過“歸併”兩個或兩個以上的記錄有序子序列,逐步增加記錄有序序列的長度;歸併排序的基本思想是:將兩個或兩個以上的有序子序列“歸併”爲一個有序序列。
在內部排序中,通常採用的是2-路歸併排序。即:將兩個位置相鄰的有序子序列 歸併爲一個有序序列。
“歸併”算法描述如下:
template
void Merge (Elem SR[], Elem TR[], int i, int m, int n) {
// 將有序的SR[i..m]和SR[m+1..n]歸併爲
// 有序的TR[i..n]
for (j=m+1, k=i; i<=m && j<=n; ++k)
{ // 將SR中記錄由小到大地併入TR
if (SR.key<=SR[j].key) TR[k] = SR[i++];
else TR[k] = SR[j++];
}
if (i<=m) TR[k..n] = SR[i..m];
// 將剩餘的SR[i..m]複製到TR
if (j<=n) TR[k..n] = SR[j..n];
// 將剩餘的SR[j..n]複製到TR
} // Merge
歸併排序的算法可以有兩種形式:遞歸的和遞推的,它是由兩種不同的程序設計思想得出的。在此,只討論遞歸形式的算法。
這是一種自頂向下的分析方法:
如果記錄無序序列R[s..t]的兩部分]û(s+t)/2ëR[s..和û(s+t)/2+1..tëR[分別按關鍵字有序,則利用上述歸併算法很容易將它們歸併成整個記錄序列是一個有序序列,由此,應該先分別對這兩部分進行2-路歸併排序。
template
void Msort ( Elem SR[], Elem TR1[], int s, int t ) {
// 將SR[s..t]進行2-路歸併排序爲TR1[s..t]。
if (s==t) TR1[s] = SR[s];
else {
m = (s+t)/2;
// 將SR[s..t]平分爲SR[s..m]和SR[m+1..t]
Msort (SR, TR2, s, m);
// 遞歸地將SR[s..m]歸併爲有序的TR2[s..m]
Msort (SR, TR2, m+1, t);
//遞歸地SR[m+1..t]歸併爲有序的TR2[m+1..t]
Merge (TR2, TR1, s, m, t);
// 將TR2[s..m]和TR2[m+1..t]歸併到TR1[s..t]
}
} // MSort
template
void MergeSort (Elem R[]) {
// 對記錄序列R[1..n]作2-路歸併排序。
MSort(R, R, 1, n);
} // MergeSort
容易看出,對n個記錄進行歸併排序的時間複雜度爲Ο(nlogn)。即:每一趟歸併的時間複雜度爲O(n),總共需進行logn趟。
下面我們比較一下上面談到的各種內部排序方法
首先,從時間性能上說:
1. 按平均的時間性能來分,有三類排序方法:
時間複雜度爲O(nlogn)的方法有:快速排序、堆排序和歸併排序,其中以快速排序爲最好;
時間複雜度爲O(n2)的有:直接插入排序、起泡排序和簡單選擇排序,其中以直接插入爲最好,特別是對那些對關鍵字近似有序的記錄序列尤爲如此;
時間複雜度爲O(n)的排序方法只有,基數排序。
2. 當待排記錄序列按關鍵字順序有序時,直接插入排序和起泡排序能達到O(n)的時間複雜度;而對於快速排序而言,這是最不好的情況,此時的時間性能蛻化爲O(n2),因此是應該儘量避免的情況。
3. 簡單選擇排序、堆排序和歸併排序的時間性能不隨記錄序列中關鍵字的分佈而改變。
其次,從空間性能上說:
指的是排序過程中所需的輔助空間大小。
1. 所有的簡單排序方法(包括:直接插入、起泡和簡單選擇)和堆排序的空間複雜度爲O(1);
2. 快速排序爲O(logn),爲棧所需的輔助空間;
3. 歸併排序所需輔助空間最多,其空間複雜度爲O(n);
4. 鏈式基數排序需附設隊列首尾指針,則空間複雜度爲O(rd)。
再次,從排序方法的穩定性能上說:
穩定的排序方法指的是,對於兩個關鍵字相等的記錄,它們在序列中的相對位置,在排序之前和經過排序之後,沒有改變。當對多關鍵字的記錄序列進行LSD方法排序時,必須採用穩定的排序方法。對於不穩定的排序方法,只要能舉出一個實例說明即可。我們需要指出的是:快速排序和堆排序是不穩定的排序方法。