常用算法設計方法

 

常用算法設計方法

要使計算機能完成人們預定的工作,首先必須爲如何完成預定的工作設計一個算法,然後再根據算法編寫程序。計算機程序要對問題的每個對象和處理規則給出正確詳盡的描述,其中程序的數據結構和變量用來描述問題的對象,程序結構、函數和語句用來描述問題的算法。算法數據結構是程序的兩個重要方面。
算法是問題求解過程的精確描述,一個算法由有限條可完全機械地執行的、有確定結果的指令組成。指令正確地描述了要完成的任務和它們被執行的順序。計算機按算法指令所描述的順序執行算法的指令能在有限的步驟內終止,或終止於給出問題的解,或終止於指出問題對此輸入數據無解。
通常求解一個問題可能會有多種算法可供選擇,選擇的主要標準是算法的正確性和可靠性,簡單性和易理解性。其次是算法所需要的存儲空間少和執行更快等。
算法設計是一件非常困難的工作,經常採用的算法設計技術主要有迭代法、窮舉搜索法、遞推法、貪婪法、回溯法、分治法、動態規劃法等等。另外,爲了更簡潔的形式設計和藐視算法,在算法設計時又常常採用遞歸技術,用遞歸描述算法。

一、迭代法  發表於2004-11-19 12:00:00  IP: 61.53.19.*
一、迭代法

迭代法是用於求方程或方程組近似根的一種常用的算法設計方法。設方程爲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)方程雖然有解,但迭代公式選擇不當,或迭代的初始近似根選擇不合理,也會導致迭代失敗。


 

二、窮舉搜索法  發表於2004-11-19 12:01:00  IP: 61.53.19.*
二、窮舉搜索法

窮舉搜索法是對可能是解的衆多候選解按某種順序進行逐一枚舉和檢驗,並從衆找出那些符合要求的候選解作爲問題的解。
【問題】 將A、B、C、D、E、F這六個變量排成如圖所示的三角形,這六個變量分別取[1,6]上的整數,且均不相同。求使三角形三條邊上的變量之和相等的全部解。如圖就是一個解。
程序引入變量a、b、c、d、e、f,並讓它們分別順序取1至6的整數,在它們互不相同的條件下,測試由它們排成的如圖所示的三角形三條邊上的變量之和是否相等,如相等即爲一種滿足要求的排列,把它們輸出。當這些變量取盡所有的組合後,程序就可得到全部可能的解。細節見下面的程序。
【程序1】
# include <stdio.h>
void main()
{
    int a,b,c,d,e,f;
    for (a=1;a<=6;a++) 
    {
        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個變量的情況,程序的循環重數就要相應改變。
        對一組數窮盡所有排列,還有更直接的方法。將一個排列看作一個長整數,則所有排列對應着一組整數。將這組整數按從小到大的順序排列排成一個整數,從對應最小的整數開始。按數列的遞增順序逐一列舉每個排列對應的每個整數,這能更有效地完成排列的窮舉。從一個排列找出對應數列的下一個排列可在當前排列的基礎上作部分調整來實現。倘若當前排列爲1,2,4,6,5,3,並令其對應的長整數爲124653。要尋找比長整數124653更大的排列,可從該排列的最後一個數字順序向前逐位考察,當發現排列中的某個數字比它前一個數字大時,如本例中的6比它的前一位數字4大,這說明還有對應更大整數的排列。但爲了順序從小到大列舉出所有的排列,不能立即調整得太大,如本例中將數字6與數字4交換得到的排列126453就不是排列124653的下一個排列。爲了得到排列124653的下一個排列,應從已經考察過的那部分數字中選出比數字大,但又是它們中最小的那一個數字,比如數字5,與數字4交換。該數字也是從後向前考察過程中第一個比4大的數字。5與4交換後,得到排列125643。在前面數字1,2,5固定的情況下,還應選擇對應最小整數的那個排列,爲此還需將後面那部分數字的排列順序顛倒,如將數字6,4,3的排列順序顛倒,得到排列1,2,5,3,4,6,這纔是排列1,2,4,6,5,3的下一個排列。按以上想法編寫的程序如下。
【程序2】
# include <stdio.h>
# define SIDE_N 3
# define LENGTH 3
# define VARIABLES 6
int A,B,C,D,E,F;
int *pt[] = {&A,&B,&C,&D,&E,&F};
int *side[SIDE_N][LENGTH] = {&A,&B,&C,&C,&D,&E,&E,&F,&A};
int side_total[SIDE_N];
main( )
{
    int i, j, t, equal;
    for (j=0;j<VARIABLES;j++)
        *pt[j] = j + 1;
    while(1)
    {
        for (i=0;i<SIDE_N;i++)
        {
            for (t=j=0;j<LENGTH;j++)
                t += *side[i][j]; 
            side_total[i]=t; 
        }
        for (equal=1, i=0; equal&&i<SIDE_N-1; i++) 
            if (side_total[i] != side_total[i+1])    equal=0;
            if (equal)
            {
                for (i=1; i<VARIABLES; i++)
                    printf(“%4d”,*pt[i]);
                printf(“/n”);
                scanf(“%*c”); 
            } 
        for (j=VARIABLES-1;j>0;j--)
            if (*pt[j]>*pt[j-1])    break;
        if (j==0) break;
        for (i=VARIABLES-1;i>=j;i--)
            if (*pt[i]>*pt[i-1]) break; 
        t=*pt[j-1];* pt[j-1] =* pt[i]; *pt[i]=t;
        for (i=VARIABLES-1;i>j;i--,j++)
        {
            t =* pt[j];
           *pt[j] = * pt[i];
           *pt[i] = t;
        }
    }
}
        從上述問題解決的方法中,最重要的因素就是確定某種方法來確定所有的候選解。下面再用一個示例來加以說明。
【問題】 揹包問題
問題描述:有不同價值、不同重量的物品n件,求從這n件物品中選取一部分物品的選擇方案,使選中物品的總重量不超過指定的限制重量,但選中物品的價值之和最大。
設n個物品的重量和價值分別存儲於數組w[ ]和v[ ]中,限制重量爲tw。考慮一個n元組(x0,x1,…,xn-1),其中xi=0 表示第i個物品沒有選取,而xi=1則表示第i個物品被選取。顯然這個n元組等價於一個選擇方案。用枚舉法解決揹包問題,需要枚舉所有的選取方案,而根據上述方法,我們只要枚舉所有的n元組,就可以得到問題的解。
顯然,每個分量取值爲0或1的n元組的個數共爲2n個。而每個n元組其實對應了一個長度爲n的二進制數,且這些二進制數的取值範圍爲0~2n-1。因此,如果把0~2n-1分別轉化爲相應的二進制數,則可以得到我們所需要的2n個n元組。
【算法】
maxv=0;
for (i=0;i<2n;i++)
{ B[0..n-1]=0;
把i轉化爲二進制數,存儲於數組B中;
temp_w=0;
temp_v=0;
for (j=0;j<n;j++)
{ if (B[j]==1)
{ temp_w=temp_w+w[j];
temp_v=temp_v+v[j];
}
if ((temp_w<=tw)&&(temp_v>maxv))
{ maxv=temp_v;
保存該B數組;
}
}
}


 

三、遞推法  發表於2004-11-19 12:02:00  IP: 61.53.19.*
三、遞推法
遞推法是利用問題本身所具有的一種遞推關係求問題解的一種方法。設要求問題規模爲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]×10m-1+a[m-1]×10m-2+ … +a[2]×101+a[1]×100
並用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)
{ 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++)
{ for ( carry=0,i=1;i<=m;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)
{ 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();
}
}


 

四、遞歸  發表於2004-11-19 12:03:00  IP: 61.53.19.*
四、遞歸
遞歸是設計和描述算法的一種有力的工具,由於它在複雜算法的描述中被經常採用,爲此在進一步介紹其他算法設計方法之前先討論它。
能採用遞歸描述的算法通常有這樣的特徵:爲求解規模爲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項。
【問題】 組合問題
問題描述:找出從自然數1、2、……、n中任取r個數的所有組合。例如n=5,r=3的所有組合爲: (1)5、4、3 (2)5、4、2 (3)5、4、1
(4)5、3、2 (5)5、3、1 (6)5、2、1
(7)4、3、2 (8)4、3、1 (9)4、2、1
(10)3、2、1
分析所列的10個組合,可以採用這樣的遞歸思想來考慮求組合函數的算法。設函數爲void comb(int m,int k)爲找出從自然數1、2、……、m中任取k個數的所有組合。當組合的第一個數字選定時,其後的數字是從餘下的m-1個數中取k-1數的組合。這就將求m個數中取k個數的組合問題轉化成求m-1個數中取k-1個數的組合問題。設函數引入工作數組a[ ]存放求出的組合的數字,約定函數將確定的k個數字組合的第一個數字放在a[k]中,當一個組合求出後,纔將a[ ]中的一個組合輸出。第一個數可以是m、m-1、……、k,函數將確定組合的第一個數字放入數組後,有兩種可能的選擇,因還未去頂組合的其餘元素,繼續遞歸去確定;或因已確定了組合的全部元素,輸出這個組合。細節見以下程序中的函數comb。
【程序】
# include <stdio.h>
# define MAXN 100
int a[MAXN];
void comb(int m,int k)
{ int i,j;
for (i=m;i>=k;i--)
{ a[k]=i;
if (k>1)
comb(i-1,k-1);
else
{ for (j=a[0];j>0;j--)
printf(“%4d”,a[j]);
printf(“/n”);
}
}
}

void main()
{ a[0]=3;
comb(5,3);
}
【問題】 揹包問題
問題描述:有不同價值、不同重量的物品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。則按以上算法,下圖表示找解過程。由圖知,一旦找到一個解,算法就進一步找更好的佳。如能判定某個查找分支不會找到更好的解,算法不會在該分支繼續查找,而是立即終止該分支,並去考察下一個分支。

按上述算法編寫函數和程序如下:
【程序】
# 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(“%1f%1f”,&w,&v);
a[k].weight=w;
a[k].value=v;
totV+=V;
}
printf(“輸入限制重量/n”);
scanf(“%1f”,&limitV);
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總價值爲%.2f/n”,maxv);
}
作爲對比,下面以同樣的解題思想,考慮非遞歸的程序解。爲了提高找解速度,程序不是簡單地逐一生成所有候選解,而是從每個物品對候選解的影響來形成值得進一步考慮的候選解,一個候選解是通過依次考察每個物品形成的。對物品i的考察有這樣幾種情況:當該物品被包含在候選解中依舊滿足解的總重量的限制,該物品被包含在候選解中是應該繼續考慮的;反之,該物品不應該包括在當前正在形成的候選解中。同樣地,僅當物品不被包括在候選解中,還是有可能找到比目前臨時最佳解更好的候選解時,纔去考慮該物品不被包括在候選解中;反之,該物品不包括在當前候選解中的方案也不應繼續考慮。對於任一值得繼續考慮的方案,程序就去進一步考慮下一個物品。
【程序】
# 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(“%1f”,&limitW);
printf(“輸入各物品的重量和價值/n”);
for (k=0;k<n;k++)
scanf(“%1f%1f”,&a[k].weight,&a[k].value);
maxv=find(a,n);
printf(“/n選中的物品爲/n”);
for (k=0;k<n;k++)
if (option[k]) printf(“%4d”,k+1);
printf(“/n總價值爲%.2f/n”,maxv);
}


 

五、回溯法  發表於2004-11-19 12:08:00  IP: 61.53.19.*
五、回溯法
回溯法也稱爲試探法,該方法首先暫時放棄關於問題規模大小的限制,並將問題的候選解按某種順序逐一枚舉和檢驗。當發現當前候選解不可能是解時,就選擇下一個候選解;倘若當前候選解除了還不滿足問題規模要求外,滿足所有其他要求時,繼續擴大當前候選解的規模,並繼續試探。如果當前候選解滿足包括問題規模在內的所有要求時,該候選解就是問題的一個解。在回溯法中,放棄當前候選解,尋找下一個候選解的過程稱爲回溯。擴大當前候選解的規模,以繼續試探的過程稱爲向前試探。
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的一個解。
【問題】 組合問題
問題描述:找出從自然數1、2、……、n中任取r個數的所有組合。
例如n=5,r=3的所有組合爲:
(1)1、2、3 (2)1、2、4 (3)1、2、5
(4)1、3、4 (5)1、3、5 (6)1、4、5
(7)2、3、4 (8)2、3、5 (9)2、4、5
(10)3、4、5
則該問題的狀態空間爲:
E={(x1,x2,x3)∣xi∈S ,i=1,2,3 } 其中:S={1,2,3,4,5}
約束集爲: x1<x2<x3
顯然該約束集具有完備性。
問題的狀態空間樹T:













2、回溯法的方法
對於具有完備約束集D的一般問題P及其相應的狀態空間樹T,利用T的層次結構和D的完備性,在T中搜索問題P的所有解的回溯法可以形象地描述爲:
從T的根出發,按深度優先的策略,系統地搜索以其爲根的子樹中可能包含着回答結點的所有狀態結點,而跳過對肯定不含回答結點的所有子樹的搜索,以提高搜索效率。具體地說,當搜索按深度優先策略到達一個滿足D中所有有關約束的狀態結點時,即“激活”該狀態結點,以便繼續往深層搜索;否則跳過對以該狀態結點爲根的子樹的搜索,而一邊逐層地向該狀態結點的祖先結點回溯,一邊“殺死”其兒子結點已被搜索遍的祖先結點,直到遇到其兒子結點未被搜索遍的祖先結點,即轉向其未被搜索的一個兒子結點繼續搜索。
在搜索過程中,只要所激活的狀態結點又滿足終結條件,那麼它就是回答結點,應該把它輸出或保存。由於在回溯法求解問題時,一般要求出問題的所有解,因此在得到回答結點後,同時也要進行回溯,以便得到問題的其他解,直至回溯到T的根且根的所有兒子結點均已被搜索過爲止。
例如在組合問題中,從T的根出發深度優先遍歷該樹。當遍歷到結點(1,2)時,雖然它滿足約束條件,但還不是回答結點,則應繼續深度遍歷;當遍歷到葉子結點(1,2,5)時,由於它已是一個回答結點,則保存(或輸出)該結點,並回溯到其雙親結點,繼續深度遍歷;當遍歷到結點(1,5)時,由於它已是葉子結點,但不滿足約束條件,故也需回溯。
3、回溯法的一般流程和技術
在用回溯法求解有關問題的過程中,一般是一邊建樹,一邊遍歷該樹。在回溯法中我們一般採用非遞歸方法。下面,我們給出回溯法的非遞歸算法的一般流程:

在用回溯法求解問題,也即在遍歷狀態空間樹的過程中,如果採用非遞歸方法,則我們一般要用到棧的數據結構。這時,不僅可以用棧來表示正在遍歷的樹的結點,而且可以很方便地表示建立孩子結點和回溯過程。
例如在組合問題中,我們用一個一維數組Stack[ ]表示棧。開始棧空,則表示了樹的根結點。如果元素1進棧,則表示建立並遍歷(1)結點;這時如果元素2進棧,則表示建立並遍歷(1,2)結點;元素3再進棧,則表示建立並遍歷(1,2,3)結點。這時可以判斷它滿足所有約束條件,是問題的一個解,輸出(或保存)。這時只要棧頂元素(3)出棧,即表示從結點(1,2,3)回溯到結點(1,2)。
【問題】 組合問題
問題描述:找出從自然數1,2,…,n中任取r個數的所有組合。
採用回溯法找問題的解,將找到的組合以從小到大順序存於a[0],a[1],…,a[r-1]中,組合的元素滿足以下性質:
(1) a[i+1]>a[i],後一個數字比前一個大;
(2) a[i]-i<=n-r+1。
按回溯法的思想,找解過程可以敘述如下:
首先放棄組合數個數爲r的條件,候選組合從只有一個數字1開始。因該候選解滿足除問題規模之外的全部條件,擴大其規模,並使其滿足上述條件(1),候選組合改爲1,2。繼續這一過程,得到候選組合1,2,3。該候選解滿足包括問題規模在內的全部條件,因而是一個解。在該解的基礎上,選下一個候選解,因a[2]上的3調整爲4,以及以後調整爲5都滿足問題的全部要求,得到解1,2,4和1,2,5。由於對5不能再作調整,就要從a[2]回溯到a[1],這時,a[1]=2,可以調整爲3,並向前試探,得到解1,3,4。重複上述向前試探和向後回溯,直至要從a[0]再回溯時,說明已經找完問題的全部解。按上述思想寫成程序如下:
【程序】
# define MAXN 100
int a[MAXN];
void comb(int m,int r)
{ int i,j;
i=0;
a[i]=1;
do {
if (a[i]-i<=m-r+1
{ if (i==r-1)
{ for (j=0;j<r;j++)
printf(“%4d”,a[j]);
printf(“/n”);
}
a[i]++;
continue;
}
else
{ if (i==0)
return;
a[--i]++;
}
} while (1)
}

main()
{ comb(5,3);
}
【問題】 填字遊戲
問題描述:在3×3個方格的方陣中要填入數字1到N(N≥10)內的某9個數字,每個方格填一個整數,似的所有相鄰兩個方格內的兩個整數之和爲質數。試求出所有滿足這個要求的各種數字填法。
可用試探發找到問題的解,即從第一個方格開始,爲當前方格尋找一個合理的整數填入,並在當前位置正確填入後,爲下一方格尋找可填入的合理整數。如不能爲當前方格找到一個合理的可填證書,就要回退到前一方格,調整前一方格的填入數。當第九個方格也填入合理的整數後,就找到了一個解,將該解輸出,並調整第九個的填入的整數,尋找下一個解。
爲找到一個滿足要求的9個數的填法,從還未填一個數開始,按某種順序(如從小到大的順序)每次在當前位置填入一個整數,然後檢查當前填入的整數是否能滿足要求。在滿足要求的情況下,繼續用同樣的方法爲下一方格填入整數。如果最近填入的整數不能滿足要求,就改變填入的整數。如對當前方格試盡所有可能的整數,都不能滿足要求,就得回退到前一方格,並調整前一方格填入的整數。如此重複執行擴展、檢查或調整、檢查,直到找到一個滿足問題要求的解,將解輸出。
回溯法找一個解的算法:
{ int m=0,ok=1;
int n=8;
do{
if (ok) 擴展;
else 調整;
ok=檢查前m個整數填放的合理性;
} while ((!ok||m!=n)&&(m!=0))
if (m!=0) 輸出解;
else 輸出無解報告;
}
如果程序要找全部解,則在將找到的解輸出後,應繼續調整最後位置上填放的整數,試圖去找下一個解。相應的算法如下:
回溯法找全部解的算法:
{ int m=0,ok=1;
int n=8;
do{
if (ok)
{ if (m==n)
{ 輸出解;
調整;
}
else 擴展;
}
else 調整;
ok=檢查前m個整數填放的合理性;
} while (m!=0);
}
爲了確保程序能夠終止,調整時必須保證曾被放棄過的填數序列不會再次實驗,即要求按某種有許模型生成填數序列。給解的候選者設定一個被檢驗的順序,按這個順序逐一形成候選者並檢驗。從小到大或從大到小,都是可以採用的方法。如擴展時,先在新位置填入整數1,調整時,找當前候選解中下一個還未被使用過的整數。將上述擴展、調整、檢驗都編寫成程序,細節見以下找全部解的程序。
【程序】
# include <stdio.h>
# define N 12
void write(int a[ ])
{ int i,j;
for (i=0;i<3;i++)
{ for (j=0;j<3;j++)
printf(“%3d”,a[3*i+j]);
printf(“/n”);
}
scanf(“%*c”);
}

int b[N+1];
int a[10];
int isprime(int m)
{ int i;
int primes[ ]={2,3,5,7,11,17,19,23,29,-1};
if (m==1||m%2=0) return 0;
for (i=0;primes[i]>0;i++)
if (m==primes[i]) return 1;
for (i=3;i*i<=m;)
{ if (m%i==0) return 0;
i+=2;
}
return 1;
}

int checkmatrix[ ][3]={ {-1},{0,-1},{1,-1},{0,-1},{1,3,-1},
{2,4,-1},{3,-1},{4,6,-1},{5,7,-1}};
int selectnum(int start)
{ int j;
for (j=start;j<=N;j++)
if (b[j]) return j
return 0;
}

int check(int pos)
{ int i,j;
if (pos<0) return 0;
for (i=0;(j=checkmatrix[pos][i])>=0;i++)
if (!isprime(a[pos]+a[j])
return 0;
return 1;
}

int extend(int pos)
{ a[++pos]=selectnum(1);
b[a][pos]]=0;
return pos;
}

int change(int pos)
{ int j;
while (pos>=0&&(j=selectnum(a[pos]+1))==0)
b[a[pos--]]=1;
if (pos<0) return –1
b[a[pos]]=1;
a[pos]=j;
b[j]=0;
return pos;
}

void find()
{ int ok=0,pos=0;
a[pos]=1;
b[a[pos]]=0;
do {
if (ok)
if (pos==8)
{ write(a);
pos=change(pos);
}
else pos=extend(pos);
else pos=change(pos);
ok=check(pos);
} while (pos>=0)
}

void main()
{ int i;
for (i=1;i<=N;i++)
b[i]=1;
find();
}
【問題】 n皇后問題
問題描述:求出在一個n×n的棋盤上,放置n個不能互相捕捉的國際象棋“皇后”的所有佈局。
這是來源於國際象棋的一個問題。皇后可以沿着縱橫和兩條斜線4個方向相互捕捉。如圖所示,一個皇后放在棋盤的第4行第3列位置上,則棋盤上凡打“×”的位置上的皇后就能與這個皇后相互捕捉。

1 2 3 4 5 6 7 8
× ×
× × ×
× × ×
× × Q × × × × ×
× × ×
× × ×
× ×
× ×
從圖中可以得到以下啓示:一個合適的解應是在每列、每行上只有一個皇后,且一條斜線上也只有一個皇后。
求解過程從空配置開始。在第1列至第m列爲合理配置的基礎上,再配置第m+1列,直至第n列配置也是合理時,就找到了一個解。接着改變第n列配置,希望獲得下一個解。另外,在任一列上,可能有n種配置。開始時配置在第1行,以後改變時,順次選擇第2行、第3行、…、直到第n行。當第n行配置也找不到一個合理的配置時,就要回溯,去改變前一列的配置。得到求解皇后問題的算法如下:
{ 輸入棋盤大小值n;
m=0;
good=1;
do {
if (good)
if (m==n)
{ 輸出解;
改變之,形成下一個候選解;
}
else 擴展當前候選接至下一列;
else 改變之,形成下一個候選解;
good=檢查當前候選解的合理性;
} while (m!=0);
}
在編寫程序之前,先確定邊式棋盤的數據結構。比較直觀的方法是採用一個二維數組,但仔細觀察就會發現,這種表示方法給調整候選解及檢查其合理性帶來困難。更好的方法乃是儘可能直接表示那些常用的信息。對於本題來說,“常用信息”並不是皇后的具體位置,而是“一個皇后是否已經在某行和某條斜線合理地安置好了”。因在某一列上恰好放一個皇后,引入一個一維數組(col[ ]),值col[i]表示在棋盤第i列、col[i]行有一個皇后。例如:col[3]=4,就表示在棋盤的第3列、第4行上有一個皇后。另外,爲了使程序在找完了全部解後回溯到最初位置,設定col[0]的初值爲0當回溯到第0列時,說明程序已求得全部解,結束程序運行。
爲使程序在檢查皇后配置的合理性方面簡易方便,引入以下三個工作數組:
(1) 數組a[ ],a[k]表示第k行上還沒有皇后;
(2) 數組b[ ],b[k]表示第k列右高左低斜線上沒有皇后;
(3) 數組 c[ ],c[k]表示第k列左高右低斜線上沒有皇后;
棋盤中同一右高左低斜線上的方格,他們的行號與列號之和相同;同一左高右低斜線上的方格,他們的行號與列號之差均相同。
初始時,所有行和斜線上均沒有皇后,從第1列的第1行配置第一個皇后開始,在第m列col[m]行放置了一個合理的皇后後,準備考察第m+1列時,在數組a[ ]、b[ ]和c[ ]中爲第m列,col[m]行的位置設定有皇后標誌;當從第m列回溯到第m-1列,並準備調整第m-1列的皇后配置時,清除在數組a[ ]、b[ ]和c[ ]中設置的關於第m-1列,col[m-1]行有皇后的標誌。一個皇后在m列,col[m]行方格內配置是合理的,由數組a[ ]、b[ ]和c[ ]對應位置的值都爲1來確定。細節見以下程序:
【程序】
# include <stdio.h>
# include <stdlib.h>
# define MAXN 20
int n,m,good;
int col[MAXN+1],a[MAXN+1],b[2*MAXN+1],c[2*MAXN+1];

void main()
{ int j;
char awn;
printf(“Enter n: “); scanf(“%d”,&n);
for (j=0;j<=n;j++) a[j]=1;
for (j=0;j<=2*n;j++) cb[j]=c[j]=1;
m=1; col[1]=1; good=1; col[0]=0;
do {
if (good)
if (m==n)
{ printf(“列/t行”);
for (j=1;j<=n;j++)
printf(“%3d/t%d/n”,j,col[j]);
printf(“Enter a character (Q/q for exit)!/n”);
scanf(“%c”,&awn);
if (awn==’Q’||awn==’q’) exit(0);
while (col[m]==n)
{ m--;
a[col[m]]=b[m+col[m]]=c[n+m-col[m]]=1;
}
col[m]++;
}
else
{ a[col[m]]=b[m+col[m]]=c[n+m-col[m]]=0;
col[++m]=1;
}
else
{ while (col[m]==n)
{ m--;
a[col[m]]=b[m+col[m]]=c[n+m-col[m]]=1;
}
col[m]++;
}
good=a[col[m]]&&b[m+col[m]]&&c[n+m-col[m]];
} while (m!=0);
}
試探法找解算法也常常被編寫成遞歸函數,下面兩程序中的函數queen_all()和函數queen_one()能分別用來解皇后問題的全部解和一個解。
【程序】
# include <stdio.h>
# include <stdlib.h>
# define MAXN 20
int n;
int col[MAXN+1],a[MAXN+1],b[2*MAXN+1],c[2*MAXN+1];
void main()
{ int j;
printf(“Enter n: “); scanf(“%d”,&n);
for (j=0;j<=n;j++) a[j]=1;
for (j=0;j<=2*n;j++) cb[j]=c[j]=1;
queen_all(1,n);
}

void queen_all(int k,int n)
{ int i,j;
char awn;
for (i=1;i<=n;i++)
if (a[i]&&b[k+i]&&c[n+k-i])
{ col[k]=i;
a[i]=b[k+i]=c[n+k-i]=0;
if (k==n)
{ printf(“列/t行”);
for (j=1;j<=n;j++)
printf(“%3d/t%d/n”,j,col[j]);
printf(“Enter a character (Q/q for exit)!/n”);
scanf(“%c”,&awn);
if (awn==’Q’||awn==’q’) exit(0);
}
queen_all(k+1,n);
a[i]=b[k+i]=c[n+k-i];
}
}
採用遞歸方法找一個解與找全部解稍有不同,在找一個解的算法中,遞歸算法要對當前候選解最終是否能成爲解要有回答。當它成爲最終解時,遞歸函數就不再遞歸試探,立即返回;若不能成爲解,就得繼續試探。設函數queen_one()返回1表示找到解,返回0表示當前候選解不能成爲解。細節見以下函數。
【程序】
# define MAXN 20
int n;
int col[MAXN+1],a[MAXN+1],b[2*MAXN+1],c[2*MAXN+1];
int queen_one(int k,int n)
{ int i,found;
i=found=0;
While (!found&&i<n)
{ i++;
if (a[i]&&b[k+i]&&c[n+k-i])
{ col[k]=i;
a[i]=b[k+i]=c[n+k-i]=0;
if (k==n) return 1;
else
found=queen_one(k+1,n);
a[i]=b[k+i]=c[n+k-i]=1;
}
}
return found;
}


 

六、貪婪法  發表於2004-11-19 12:09:00  IP: 61.53.19.*
六、貪婪法
貪婪法是一種不追求最優解,只希望得到較爲滿意解的方法。貪婪法一般可以快速得到滿意的解,因爲它省去了爲找最優解要窮盡所有可能而必須耗費的大量時間。貪婪法常以當前情況爲基礎作最優選擇,而不考慮各種可能的整體情況,所以貪婪法不要回溯。
例如平時購物找錢時,爲使找回的零錢的硬幣數最少,不考慮找零錢的所有各種發表方案,而是從最大面值的幣種開始,按遞減的順序考慮各幣種,先儘量用大面值的幣種,當不足大面值幣種的金額時纔去考慮下一種較小面值的幣種。這就是在使用貪婪法。這種方法在這裏總是最優,是因爲銀行對其發行的硬幣種類和硬幣面值的巧妙安排。如只有面值分別爲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)
{ 另用一個箱子,並將物品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”);
}
}
【問題】 馬的遍歷
問題描述:在8×8方格的棋盤上,從任意指定的方格出發,爲馬尋找一條走遍棋盤每一格並且只經過一次的一條路徑。
馬在某個方格,可以在一步內到達的不同位置最多有8個,如圖所示。如用二維數組board[ ][ ]表示棋盤,其元素記錄馬經過該位置時的步驟號。另對馬的8種可能走法(稱爲着法)設定一個順序,如當前位置在棋盤的(i,j)方格,下一個可能的位置依次爲(i+2,j+1)、(i+1,j+2)、(i-1,j+2)、(i-2,j+1)、(i-2,j-1)、(i-1,j-2)、(i+1,j-2)、(i+2,j-1),實際可以走的位置盡限於還未走過的和不越出邊界的那些位置。爲便於程序的同意處理,可以引入兩個數組,分別存儲各種可能走法對當前位置的縱橫增量。
4 3
5 2

6 1
7 0

對於本題,一般可以採用回溯法,這裏採用Warnsdoff策略求解,這也是一種貪婪法,其選擇下一出口的貪婪標準是在那些允許走的位置中,選擇出口最少的那個位置。如馬的當前位置(i,j)只有三個出口,他們是位置(i+2,j+1)、(i-2,j+1)和(i-1,j-2),如分別走到這些位置,這三個位置又分別會有不同的出口,假定這三個位置的出口個數分別爲4、2、3,則程序就選擇讓馬走向(i-2,j+1)位置。
由於程序採用的是一種貪婪法,整個找解過程是一直向前,沒有回溯,所以能非常快地找到解。但是,對於某些開始位置,實際上有解,而該算法不能找到解。對於找不到解的情況,程序只要改變8種可能出口的選擇順序,就能找到解。改變出口選擇順序,就是改變有相同出口時的選擇標準。以下程序考慮到這種情況,引入變量start,用於控制8種可能着法的選擇順序。開始時爲0,當不能找到解時,就讓start增1,重新找解。細節以下程序。
【程序】
# include <stdio.h>
int delta_i[ ]={2,1,-1,-2,-2,-1,1,2};
int delta_j[ ]={1,2,2,1,-1,-2,-2,-1};
int board[8][8];
int exitn(int i,int j,int s,int a[ ])
{ int i1,j1,k,count;
for (count=k=0;k<8;k++)
{ i1=i+delta_i[(s+k)%8];
j1=i+delta_j[(s+k)%8];
if (i1>=0&&i1<8&&j1>=0&&j1<8&&board[I1][j1]==0)
a[count++]=(s+k)%8;
}
return count;
}

int next(int i,int j,int s)
{ int m,k,mm,min,a[8],b[8],temp;
m=exitn(i,j,s,a);
if (m==0) return –1;
for (min=9,k=0;k<m;k++)
{ temp=exitn(I+delta_i[a[k]],j+delta_j[a[k]],s,b);
if (temp<min)
{ min=temp;
kk=a[k];
}
}
return kk;
}

void main()
{ int sx,sy,i,j,step,no,start;
for (sx=0;sx<8;sx++)
for (sy=0;sy<8;sy++)
{ start=0;
do {
for (i=0;i<8;i++)
for (j=0;j<8;j++)
board[i][j]=0;
board[sx][sy]=1;
I=sx; j=sy;
For (step=2;step<64;step++)
{ if ((no=next(i,j,start))==-1) break;
I+=delta_i[no];
j+=delta_j[no];
board[i][j]=step;
}
if (step>64) break;
start++;
} while(step<=64)
for (i=0;i<8;i++)
{ for (j=0;j<8;j++)
printf(“%4d”,board[i][j]);
printf(“/n/n”);
}
scanf(“%*c”);
}
}
七、分治法
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位大整數的乘法運算。
設X和Y都是n位的二進制整數,現在要計算它們的乘積XY。我們可以用小學所學的方法來設計一個計算乘積XY的算法,但是這樣做計算步驟太多,顯得效率較低。如果將每2個1位數的乘法或加法看作一步運算,那麼這種方法要作O(n2)步運算才能求出乘積XY。下面我們用分治法來設計一個更有效的大整數乘積算法。

圖6-3 大整數X和Y的分段
我們將n位的二進制整數X和Y各分爲2段,每段的長爲n/2位(爲簡單起見,假設n是2的冪),如圖6-3所示。
由此,X=A2n/2+B,Y=C2n/2+D。這樣,X和Y的乘積爲:
XY=(A2n/2+B)(C2n/2+D)=AC2n+(AD+CB)2n/2+BD (1)
如果按式(1)計算XY,則我們必須進行4次n/2位整數的乘法(AC,AD,BC和BD),以及3次不超過n位的整數加法(分別對應於式(1)中的加號),此外還要做2次移位(分別對應於式(1)中乘2n和乘2n/2)。所有這些加法和移位共用O(n)步運算。設T(n)是2個n位整數相乘所需的運算總數,則由式(1),我們有:
(2)
由此可得T(n)=O(n2)。因此,用(1)式來計算X和Y的乘積並不比小學生的方法更有效。要想改進算法的計算複雜性,必須減少乘法次數。爲此我們把XY寫成另一種形式:
XY=AC2n+[(A-B)(D-C)+AC+BD]2n/2+BD (3)
雖然,式(3)看起來比式(1)複雜些,但它僅需做3次n/2位整數的乘法(AC,BD和(A-B)(D-C)),6次加、減法和2次移位。由此可得:
(4)
用解遞歸方程的套用公式法馬上可得其解爲T(n)=O(nlog3)=O(n1.59)。利用式(3),並考慮到X和Y的符號對結果的影響,我們給出大整數相乘的完整算法MULT如下:
function MULT(X,Y,n); {X和Y爲2個小於2n的整數,返回結果爲X和Y的乘積XY}
begin
S=SIGN(X)*SIGN(Y); {S爲X和Y的符號乘積}
X=ABS(X);
Y=ABS(Y); {X和Y分別取絕對值}
if n=1 then
if (X=1)and(Y=1) then return(S)
else return(0)
else begin
A=X的左邊n/2位;
B=X的右邊n/2位;
C=Y的左邊n/2位;
D=Y的右邊n/2位;
ml=MULT(A,C,n/2);
m2=MULT(A-B,D-C,n/2);
m3=MULT(B,D,n/2);
S=S*(m1*2n+(m1+m2+m3)*2n/2+m3);
return(S);
end;
end;
上述二進制大整數乘法同樣可應用於十進制大整數的乘法以提高乘法的效率減少乘法次數。
【問題】 最接近點對問題
問題描述:
在應用中,常用諸如點、圓等簡單的幾何對象代表現實世界中的實體。在涉及這些幾何對象的問題中,常需要了解其鄰域中其他幾何對象的信息。例如,在空中交通控制問題中,若將飛機作爲空間中移動的一個點來看待,則具有最大碰撞危險的2架飛機,就是這個空間中最接近的一對點。這類問題是計算幾何學中研究的基本問題之一。下面我們着重考慮平面上的最接近點對問題。
最接近點對問題的提法是:給定平面上n個點,找其中的一對點,使得在n個點的所有點對中,該點對的距離最小。
嚴格地說,最接近點對可能多於1對。爲了簡單起見,這裏只限於找其中的一對。
這個問題很容易理解,似乎也不難解決。我們只要將每一點與其他n-1個點的距離算出,找出達到最小距離的兩個點即可。然而,這樣做效率太低,需要O(n2)的計算時間。我們能否找到問題的一個O (nlogn)算法。
這個問題顯然滿足分治法的第一個和第二個適用條件,我們考慮將所給的平面上n個點的集合S分成2個子集S1和S2,每個子集中約有n/2個點,然後在每個子集中遞歸地求其最接近的點對。在這裏,一個關鍵的問題是如何實現分治法中的合併步驟,即由S1和S2的最接近點對,如何求得原集合S中的最接近點對,因爲S1和S2的最接近點對未必就是S的最接近點對。如果組成S的最接近點對的2個點都在S1中或都在S2中,則問題很容易解決。但是,如果這2個點分別在S1和S2中,則對於S1中任一點p,S2中最多隻有n/2個點與它構成最接近點對的候選者,仍需做n2/4次計算和比較才能確定S的最接近點對。因此,依此思路,合併步驟耗時爲O(n2)。整個算法所需計算時間T(n)應滿足:
T(n)=2T(n/2)+O(n2)
它的解爲T(n)=O(n2),即與合併步驟的耗時同階,顯示不出比用窮舉的方法好。從解遞歸方程的套用公式法,我們看到問題出在合併步驟耗時太多。這啓發我們把注意力放在合併步驟上。
爲了使問題易於理解和分析,我們先來考慮一維的情形。此時S中的n個點退化爲x軸上的n個實數x1、x2、…、xn。最接近點對即爲這n個實數中相差最小的2個實數。我們顯然可以先將x1、x2、…、xn排好序,然後,用一次線性掃描就可以找出最接近點對。這種方法主要計算時間花在排序上,因此如在排序算法中所證明的,耗時爲O(nlogn)。然而這種方法無法直接推廣到二維的情形。因此,對這種一維的簡單情形,我們還是嘗試用分治法來求解,並希望能推廣到二維的情形。
假設我們用x軸上某個點m將S劃分爲2個子集S1和S2,使得S1={x∈S | x≤m};S2={x∈S | x>m}。這樣一來,對於所有p∈S1和q∈S2有p<q。
遞歸地在S1和S2上找出其最接近點對{p1,p2}和{q1,q2},並設δ=min{|p1-p2|,|q1-q2|},S中的最接近點對或者是{p1,p2},或者是{q1,q2},或者是某個{p3,q3},其中p3∈S1且q3∈S2。如圖1所示。

圖1 一維情形的分治法
我們注意到,如果S的最接近點對是{p3,q3},即 | p3-q3 | < δ,則p3和q3兩者與m的距離不超過δ,即 | p3-m | < δ,| q3-m | < δ,也就是說,p3∈(m-δ,m),q3∈(m,m+δ)。由於在S1中,每個長度爲δ的半閉區間至多包含一個點(否則必有兩點距離小於δ),並且m是S1和S2的分割點,因此(m-δ,m)中至多包含S中的一個點。同理,(m,m+δ)中也至多包含S中的一個點。由圖1可以看出,如果(m-δ,m)中有S中的點,則此點就是S1中最大點。同理,如果(m,m+δ)中有S中的點,則此點就是S2中最小點。因此,我們用線性時間就能找到區間(m-δ,m)和(m,m+δ)中所有點,即p3和q3。從而我們用線性時間就可以將S1的解和S2的解合併成爲S的解。也就是說,按這種分治策略,合併步可在O(n)時間內完成。這樣是否就可以得到一個有效的算法了呢?
還有一個問題需要認真考慮,即分割點m的選取,及S1和S2的劃分。選取分割點m的一個基本要求是由此導出集合S的一個線性分割,即S=S1∪S2 ,S1∩S2=Φ,且S1 {x | x≤m};S2 {x | x>m}。容易看出,如果選取m=[max(S)+min(S)]/2,可以滿足線性分割的要求。選取分割點後,再用O(n)時間即可將S劃分成S1={x∈S | x≤m}和S2={x∈S | x>m}。然而,這樣選取分割點m,有可能造成劃分出的子集S1和S2的不平衡。例如在最壞情況下,|S1|=1,|S2|=n-1,由此產生的分治法在最壞情況下所需的計算時間T(n)應滿足遞歸方程:
T(n)=T(n-1)+O(n)
它的解是T(n)=O(n2)。這種效率降低的現象可以通過分治法中“平衡子問題”的方法加以解決。也就是說,我們可以通過適當選擇分割點m,使S1和S2中有大致相等個數的點。自然地,我們會想到用S的n個點的座標的中位數來作分割點。在選擇算法中介紹的選取中位數的線性時間算法使我們可以在O(n)時間內確定一個平衡的分割點m。
至此,我們可以設計出一個求一維點集S中最接近點對的距離的算法pair如下。
Float pair(S);
{ if | S | =2 δ= | x[2]-x[1] | /*x[1..n]存放的是S中n個點的座標*/
else
{ if ( | S | =1) δ=∞
else
{ m=S中各點的座標值的中位數;
構造S1和S2,使S1={x∈S | x≤m},S2={x∈S | x>m};
δ1=pair(S1);
δ2=pair(S2);
p=max(S1);
q=min(S2);
δ=min(δ1,δ2,q-p);
}
return(δ);
}
由以上的分析可知,該算法的分割步驟和合並步驟總共耗時O(n)。因此,算法耗費的計算時間T(n)滿足遞歸方程:

解此遞歸方程可得T(n)=O(nlogn)。

【問題】循環賽日程表
問題描述:設有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)
圖1 2個、4個和8個選手的比賽日程表
圖1所列出的正方形表(3)是8個選手的比賽日程表。其中左上角與左下角的兩小塊分別爲選手1至選手4和選手5至選手8前3天的比賽日程。據此,將左上角小塊中的所有數字按其相對位置抄到右下角,又將左下角小塊中的所有數字按其相對位置抄到右上角,這樣我們就分別安排好了選手1至選手4和選手5至選手8在後4天的比賽日程。依此思想容易將這個比賽日程表推廣到具有任意多個選手的情形。


 

八、動態規劃法  發表於2004-11-19 12:10:00  IP: 61.53.19.*
八、動態規劃法
經常會遇到複雜問題不能簡單地分解成幾個子問題,而會分解出一系列的子問題。簡單地採用把大問題分解成子問題,並綜合子問題的解導出大問題的解的方法,問題求解耗時會按問題規模呈冪級數增加。
爲了節約重複求相同子問題的時間,引入一個數組,不管它們是否對最終解有用,把所有子問題的解存於該數組中,這就是動態規劃法所採用的基本方法。以下先用實例說明動態規劃方法的使用。
【問題】 求兩字符序列的最長公共字符子序列
問題描述:字符序列的子序列是指從給定字符序列中隨意地(不一定連續)去掉若干個字符(可能一個也不去掉)後所形成的字符序列。令給定的字符序列X=“x0,x1,…,xm-1”,序列Y=“y0,y1,…,yk-1”是X的子序列,存在X的一個嚴格遞增下標序列<i0,i1,…,ik-1>,使得對所有的j=0,1,…,k-1,有xij=yj。例如,X=“ABCBDAB”,Y=“BCDB”是X的一個子序列。
給定兩個序列A和B,稱序列Z是A和B的公共子序列,是指Z同是A和B的子序列。問題要求已知兩序列A和B的最長公共子序列。
如採用列舉A的所有子序列,並一一檢查其是否又是B的子序列,並隨時記錄所發現的子序列,最終求出最長公共子序列。這種方法因耗時太多而不可取。
考慮最長公共子序列問題如何分解成子問題,設A=“a0,a1,…,am-1”,B=“b0,b1,…,bm-1”,並Z=“z0,z1,…,zk-1”爲它們的最長公共子序列。不難證明有以下性質:
(1) 如果am-1=bn-1,則zk-1=am-1=bn-1,且“z0,z1,…,zk-2”是“a0,a1,…,am-2”和“b0,b1,…,bn-2”的一個最長公共子序列;
(2) 如果am-1!=bn-1,則若zk-1!=am-1,蘊涵“z0,z1,…,zk-1”是“a0,a1,…,am-2”和“b0,b1,…,bn-1”的一個最長公共子序列;
(3) 如果am-1!=bn-1,則若zk-1!=bn-1,蘊涵“z0,z1,…,zk-1”是“a0,a1,…,am-1”和“b0,b1,…,bn-2”的一個最長公共子序列。
這樣,在找A和B的公共子序列時,如有am-1=bn-1,則進一步解決一個子問題,找“a0,a1,…,am-2”和“b0,b1,…,bm-2”的一個最長公共子序列;如果am-1!=bn-1,則要解決兩個子問題,找出“a0,a1,…,am-2”和“b0,b1,…,bn-1”的一個最長公共子序列和找出“a0,a1,…,am-1”和“b0,b1,…,bn-2”的一個最長公共子序列,再取兩者中較長者作爲A和B的最長公共子序列。
定義c[i][j]爲序列“a0,a1,…,ai-2”和“b0,b1,…,bj-1”的最長公共子序列的長度,計算c[i][j]可遞歸地表述如下:
(1)c[i][j]=0 如果i=0或j=0;
(2)c[i][j]= c[i-1][j-1]+1 如果I,j>0,且a[i-1]=b[j-1];
(3)c[i][j]=max(c[i][j-1],c[i-1][j]) 如果I,j>0,且a[i-1]!=b[j-1]。
按此算式可寫出計算兩個序列的最長公共子序列的長度函數。由於c[i][j]的產生僅依賴於c[i-1][j-1]、c[i-1][j]和c[i][j-1],故可以從c[m][n]開始,跟蹤c[i][j]的產生過程,逆向構造出最長公共子序列。細節見程序。
# include <stdio.h>
# include <string.h>
# define N 100
char a[N],b[N],str[N];

int lcs_len(char *a, char *b, int c[ ][ N])
{ int m=strlen(a), n=strlen(b), i,j;
for (i=0;i<=m;i++) c[i][0]=0;
for (i=0;i<=n;i++) c[0][i]=0;
for (i=1;i<=m;i++)
for (j=1;j<=m;j++)
if (a[i-1]==b[j-1])
c[i][j]=c[i-1][j-1]+1;
else if (c[i-1][j]>=c[i][j-1])
c[i][j]=c[i-1][j];
else
c[i][j]=c[i][j-1];
return c[m][n];
}

char *buile_lcs(char s[ ],char *a, char *b)
{ int k, i=strlen(a), j=strlen(b);
k=lcs_len(a,b,c);
s[k]=’/0’;
while (k>0)
if (c[i][j]==c[i-1][j]) i--;
else if (c[i][j]==c[i][j-1]) j--;
else { s[--k]=a[i-1];
i--; j--;
}
return s;
}

void main()
{ printf (“Enter two string(<%d)!/n”,N);
scanf(“%s%s”,a,b);
printf(“LCS=%s/n”,build_lcs(str,a,b));
}
1、動態規劃的適用條件
任何思想方法都有一定的侷限性,超出了特定條件,它就失去了作用。同樣,動態規劃也並不是萬能的。適用動態規劃的問題必須滿足最優化原理和無後效性。
(1)最優化原理(最優子結構性質)
最優化原理可這樣闡述:一個最優化策略具有這樣的性質,不論過去狀態和決策如何,對前面的決策所形成的狀態而言,餘下的諸決策必須構成最優策略。簡而言之,一個最優化策略的子策略總是最優的。一個問題滿足最優化原理又稱其具有最優子結構性質。

圖2
例如圖2中,若路線I和J是A到C的最優路徑,則根據最優化原理,路線J必是從B到C的最優路線。這可用反證法證明:假設有另一路徑J’是B到C的最優路徑,則A到C的路線取I和J’比I和J更優,矛盾。從而證明J’必是B到C的最優路徑。
最優化原理是動態規劃的基礎,任何問題,如果失去了最優化原理的支持,就不可能用動態規劃方法計算。根據最優化原理導出的動態規劃基本方程是解決一切動態規劃問題的基本方法。
(2)無後向性
將各階段按照一定的次序排列好之後,對於某個給定的階段狀態,它以前各階段的狀態無法直接影響它未來的決策,而只能通過當前的這個狀態。換句話說,每個狀態都是過去歷史的一個完整總結。這就是無後向性,又稱爲無後效性。
(3)子問題的重疊性
動態規劃算法的關鍵在於解決冗餘,這是動態規劃算法的根本目的。動態規劃實質上是一種以空間換時間的技術,它在實現的過程中,不得不存儲產生過程中的各種狀態,所以它的空間複雜度要大於其它的算法。選擇動態規劃算法是因爲動態規劃算法在空間上可以承受,而搜索算法在時間上卻無法承受,所以我們舍空間而取時間。
所以,能夠用動態規劃解決的問題還有一個顯著特徵:子問題的重疊性。這個性質並不是動態規劃適用的必要條件,但是如果該性質無法滿足,動態規劃算法同其他算法相比就不具備優勢。
2、動態規劃的基本思想
前文主要介紹了動態規劃的一些理論依據,我們將前文所說的具有明顯的階段劃分和狀態轉移方程的動態規劃稱爲標準動態規劃,這種標準動態規劃是在研究多階段決策問題時推導出來的,具有嚴格的數學形式,適合用於理論上的分析。在實際應用中,許多問題的階段劃分並不明顯,這時如果刻意地劃分階段法反而麻煩。一般來說,只要該問題可以劃分成規模更小的子問題,並且原問題的最優解中包含了子問題的最優解(即滿足最優子化原理),則可以考慮用動態規劃解決。
動態規劃的實質是分治思想和解決冗餘,因此,動態規劃是一種將問題實例分解爲更小的、相似的子問題,並存儲子問題的解而避免計算重複的子問題,以解決最優化問題的算法策略。
由此可知,動態規劃法與分治法和貪心法類似,它們都是將問題實例歸納爲更小的、相似的子問題,並通過求解子問題產生一個全局最優解。其中貪心法的當前選擇可能要依賴已經作出的所有選擇,但不依賴於有待於做出的選擇和子問題。因此貪心法自頂向下,一步一步地作出貪心選擇;而分治法中的各個子問題是獨立的(即不包含公共的子子問題),因此一旦遞歸地求出各子問題的解後,便可自下而上地將子問題的解合併成問題的解。但不足的是,如果當前選擇可能要依賴子問題的解時,則難以通過局部的貪心策略達到全局最優解;如果各子問題是不獨立的,則分治法要做許多不必要的工作,重複地解公共的子問題。
解決上述問題的辦法是利用動態規劃。該方法主要應用於最優化問題,這類問題會有多種可能的解,每個解都有一個值,而動態規劃找出其中最優(最大或最小)值的解。若存在若干個取最優值的解的話,它只取其中的一個。在求解過程中,該方法也是通過求解局部子問題的解達到全局最優解,但與分治法和貪心法不同的是,動態規劃允許這些子問題不獨立,(亦即各子問題可包含公共的子子問題)也允許其通過自身子問題的解作出選擇,該方法對每一個子問題只解一次,並將結果保存起來,避免每次碰到時都要重複計算。
因此,動態規劃法所針對的問題有一個顯著的特徵,即它所對應的子問題樹中的子問題呈現大量的重複。動態規劃法的關鍵就在於,對於重複出現的子問題,只在第一次遇到時加以求解,並把答案保存起來,讓以後再遇到時直接引用,不必重新求解。
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
5. fk(xk):=一個極值; {∞或-∞}
6. xk+1:=Tk(xk,uk); {狀態轉移方程}
7. t:=φ(fk+1(xk+1),vk(xk,uk)); {基本方程(9)式}
if t比fk(xk)更優 then fk(xk):=t; {計算fk(xk)的最優值}
end;
9. t:=一個極值; {∞或-∞}
for 每一個x1∈X1 do
11. if f1(x1)比t更優 then t:=f1(x1); {按照10式求出最優指標}
12. 輸出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、汽車加油問題:
設有路程長度爲L公里的公路上,分佈着m個加油站,它們的位置分別爲p[i](i=1,2,……,m),而汽車油箱加滿油後(油箱最多可以加油k升),可以行駛n公里。設計一個方案,使汽車經過此公路的加油次數儘量少(汽車出發時是加滿油的)。
2、最短路徑:
設有一個網絡,要求從某個頂點出發到其他頂點的最短路徑
3、跳馬問題:
在8*8方格的棋盤上,從任意指定的方格出發,爲馬尋找一條走遍棋盤每一格並且只經過一次的一條路徑。
4、二叉樹的遍歷
5、揹包問題
6、用分治法實現兩個大整數相乘
7、設x1,x2,…,xn是直線上的n個點,若要用單位長度的閉區間去覆蓋這n個點,至少需要多少個這樣的單位閉區間?
8、用關係“<”和“=”將3個數A、B和C依次排列時,有13種不同的序關係:
A=B=C,A=B<C,A<B=C,A<B<C,A<C<B,A=C<B,B<A=C,
B<A<C,B<C<A,B=C<A,C<A=B,C<A<B,C<A<B。
若要將n個數依序進行排列,試設計一個動態規劃算法,計算出有多少鐘不同的序關係。
9、有一種單人玩的遊戲:設有n(2<=n<=200)堆薄片,各堆順序用0至 n-1編號,極端情況,有的堆可能沒有薄片。在遊戲過程中,一次移動只能取某堆上的若干張薄片,移到該堆的相鄰堆上。如指定
I堆k張 k 移到I-1(I>0)堆,和將k 張薄片移至I+1(I<n-1)堆。所以當有兩個堆與 I 堆相鄰 時,I堆原先至少有2k 張薄片;只有一個堆與 I 堆相鄰 時, I 堆原先至少有k張薄片。
遊戲的目標是對給定的堆數,和各堆上的薄片數,按上述規則移動薄片,最終使 各堆的薄片數相同。爲了使移動次數較少些,移動哪一堆薄片,和移多少薄片先作以下估算:

ci:I堆的薄片數(0<=I<n,0<=ci<=200);
v:每堆 的平均薄片數;
ai:I堆的相鄰堆可以從I堆得到的薄片數。
估算方法如下:
v=c0+a1-a0 a1=v+a0-c0
v=c1+a0+a2-2a1 a2=v+2a1-a0-c1
…….. ……….
V=ci+ai-1+ai+1-2aI ai+1=v+2ai-ai-1-ci
這裏並不希望準確地求出A0 至an-1,而是作以下處理:若令 a0 爲0,能按上述算式計算出 A1至 an-1。程序找出 a 中的最小值,並讓全部a值減去這最小值,使每堆移去的薄片數大於等於0。
實際操作採用以下貪心策略:
(1)每次從第一堆出發順序搜索每一堆,若發現可從 I堆移走薄片,就完成一次移動。即, I堆的相鄰堆從 I堆取走 ai片薄片。可從I 堆移薄片到相鄰堆取於 I堆薄片數:若I 堆是處於兩端位置( I=0 I=n-1), 要求 ci>=ai ;若 I堆是中間堆,則要求ci>=2ai。
(2)因在ai>0的所有堆中,薄片數最多的堆 在平分過程中被它的相鄰堆取走的薄片數也最多。在用策略(1)搜索移動時,當發生沒有滿足條件(1)的可移走薄片的堆時,採用本策略,讓在ai>0的所有堆中,薄片數最多的堆被它的相鄰堆取走它的全部薄片。
發佈了24 篇原創文章 · 獲贊 5 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章