1:遞歸算法
程序直接或間接調用自身的編程技巧稱為遞歸算法(Recursion)。
遞歸算法是一個過程或函數在其定義或說明中有直接或間接調用自身的一種方法。它通常把一個大型復雜的問題轉化為一個與原問題類似的規模較小的問題來求解。
遞歸策略只需少量的代碼就可描述出解題過程所需要的多次重復計算,大大減少了程序的代碼量。遞歸的優勢在於用有限的語句來定義對象的無限集合,用遞歸思想寫出的程序往往十分簡潔易懂。
遞歸需要有邊界條件,遞進前進段和遞歸返回段,當邊界條件不滿足時,遞歸前進;當邊界條件滿足時,遞歸返回(使用遞歸時,不必須有一個明確的遞歸出口,否則遞歸將無限進行下去)。
遞歸算法解題的運行效率較低,在遞歸調用過程中,系統為每一層的返回點,局部變量等開辟了堆棧來儲存。遞歸次數過多容易造成堆棧溢出等。
例:Fibonacci數列
“菲波那切數列”是意大利數學家列昂納多-斐波那契最先研究的一種遞歸數列,他的每一項都等於前兩項制盒次數列的前幾項為1,1,2,3,5等。在生物數學中許多生物現象都會出現菲波那切數列的規律,斐波那契數列相鄰兩項的比例近於黃金分割數,其遞歸定義為:
Fibonacci數列的遞歸算法:
int fib(int n)
{
if (n<=1) return 1;
return fib(n-1)+fib(n-2);
}
算法效率非常低,重復遞歸的次數太多,通常采用遞推算法:
int fib[50]; //采用數組保存中間結果
void Fibonacci(int n)
{
fib[0] = 1;
fib[1] = 1;
for (int i=2; i<=n; i++)
fib[i] = fib[i-1]+fib[i-2];
}
采用數組保存之前已求出的數據,減少了遞歸次數,提高了算法效率。
2:分治算法
在計算機科學中,分治法是一種很重要的算法。字面上的解釋是“分而治之”,就是把一個復雜的問題分成兩個或更多的相同或相似的子問題,再把子問題分成更小的子問題……直到最后子問題可以簡單的直接求解,原問題的解即子問題的解的合並。這個技巧是很多高效算法的基礎,如排序算法(快速排序,歸並排序),傅立葉變換(快速傅立葉變換)……
任何一個可以用計算機求解的問題所需的計算時間都與其規模有關。問題的規模越小,越容易直接求解,解題所需的計算時間也越少。例如,對於n個元素的排序問題,當n=1時,不需任何計算。n=2時,只要作一次比較即可排好序。n=3時只要作3次比較即可,…。而當n較大時,問題就不那么容易處理了。要想直接解決一個規模較大的問題,有時是相當困難的。
分治法的設計思想是:將一個難以直接解決的大問題,分割成一些規模較小的相同問題,以便各個擊破,分而治之。
分治策略是:對於一個規模為n的問題,若該問題可以容易地解決(比如說規模n較小)則直接解決,否則將其分解為k個規模較小的子問題,這些子問題互相獨立且與原問題形式相同,遞歸地解這些子問題,然后將各子問題的解合並得到原問題的解。這種算法設計策略叫做分治法。
如果原問題可分割成k個子問題,1<k≤n,且這些子問題都可解並可利用這些子問題的解求出原問題的解,那么這種分治法就是可行的。由分治法產生的子問題往往是原問題的較小模式,這就為使用遞歸技術提供了方便。在這種情況下,反復應用分治手段,可以使子問題與原問題類型一致而其規模卻不斷縮小,最終使子問題縮小到很容易直接求出其解。這自然導致遞歸過程的產生。分治與遞歸經常同時應用在算法設計之中,並由此產生許多高效算法。
分治法所能解決的問題一般具有以下幾個特征:
(1)該問題的規模縮小到一定程度就可以容易的解決。
(2)該問題可以分解為若干個規模小的相同的問題,即該問題具有最優子結構性質。
(3)利用該問題分解出的子問題的解可以合並為該問題的解。
(4)該問題所分解出的各個子問題是相互獨立的,即子問題之間不包含公共的子問題。
例:二分搜索技術
給定n個元素a[0:n-1],需要在這n個元素中找出一個特定元素x。首先對n個元素進行排序,可以使用C++標准模板庫函數sort()。比較容易想到的是用順序搜索方法,逐個比較a[0:n-1]中的元素,直至找到元素x或搜索遍整個數組后確定x不在其中。
因此在最壞的情況下,順序搜索方法需要 O(n)次比較。二分搜索技術充分利用了n個元素已排好序的條件,采用分治策略的思想,在最壞情況下用O(log n) 時間完成搜索任務。
0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
7 |
14 |
17 |
21 |
27 |
31 |
38 |
42 |
46 |
53 |
75 |
二分搜索算法的基本思想是將n個元素分成個數大致相同的兩半,取a[n/2]與x作比較。
如果x=a[n/2],則找到x,算法終止。
如果x<a[n/2],則我們只要在數組a的左半部分繼續搜索x。
如果x>a[n/2],則我們只要在數組a的右半部分繼續搜索x。
二分搜索算法
//數組a[]中有n個元素,已經按升序排序,待查找的元素x
template<class Type>
int BinarySearch(Type a[],const Type& x,int n)
{
int left=0; //左邊界
int right=n-1; //右邊界
while(left<=right)
{
int middle=(left+right)/2; //中點
if (x==a[middle]) return middle; //找到x,返回數組中的位置
if (x>a[middle]) left=middle+1;
else right=middle-1;
}
return -1; //未找到x
}
3:動態規划
動態規划(Dynamic Progromming,DP)算法同常用於求解具有某種最優性質的問題。在這類問題中,可能會有許多可行解。每一個解都對應於一個值,需要找到具有最優值的解。動態規划算法與分治法類似,其基本思想也是將待求解問題分解成若干個子問題,先求解子問題,然后從這些子問題的解得到原問題的解。與分治法不同的是,適合於用動態規划求解的問題,經分解得到的子問題往往不是互相獨立的。若用分治法解此類問題,則分解得到的子問題數目太多,有些子問題不知道重復計算了很多次。如果能保存已解決的子問題的答案,而在需要時再找出已求得的答案,這樣就可以避免大量的重復計算,節省時間。可以用一個表來記錄所有已解的子問題的答案。不管該子問題以后是否被用到,只要他被計算過,就將其結果填入表中。這就是動態規划法的基本思路。具體的動態規划算法多種多樣,但它們具有相同的調表格式。
設計動態規划算法的步驟:
(1)找出最優解的性質,並刻畫出其結構特征。
(2)遞歸的定義最優值(寫出動態規划方程)。
(3)以自底向上的方式計算出最優值。
(4)根據計算最優值時得到的信息,構造一個最優解。
動態規划算法的有效性依賴於問題本身具有的兩個重要性質:最優子結構性質和子問題重疊性質。
(1)最優子結構:當問題的最優解包含其子問題的最優解時,稱該問題具有最優子結構性質。
(2)重疊子問題:在用遞歸算法自頂向下解問題時,每次產生的子問題並不總是新問題,有些子問題被反復計算多次。動態規划算法正是利用了這種子問題的重疊性質,對每一個子問題只解一次,而后將其保存在一個表中,在以后盡可能多的利用這些子問題的解。
例:矩陣連乘積問題
矩陣鏈乘問題:給定n個矩陣{A1,A2,...,An},其中Ai與Ai+1是可乘的,i=1,2...,n-1。如何確定計算矩陣連乘積的計算次序,使得依此次序計算矩陣連乘積需要的數乘次數最少。
將一系列相乘的矩陣(Ai....Aj)划分為兩部分;即(AiAi+1...Ak)(Ak+1Ak+2....Aj),k的位置要保證左邊括號和右邊括號相乘的消耗最小。
#include<iostream.h>
#include<stdlib.h>
#include<limits.h>
#include<time.h>
#define MAX_VALUE 100
#define N 201 //連乘矩陣的個數(n-1)
#define random() rand()%MAX_VALUE //控制矩陣的行和列的大小
int c[N][N], s[N][N], p[N];
int matrixchain(int n) //3個for循環實現
{ for(int k=1;k<=n;k++)
c[k][k]=0;
for(int d=1;d<n;d++)
for(int i=1;i<=n-d;i++)
{ int j=i+d;
c[i][j]=INT_MAX;
for(int m=i;m<j;m++)
{ int t=c[i][m]+c[m+1][j]+p[i-1]*p[m]*p[j];
if(t<c[i][j])
{
c[i][j]=t;
s[i][j]=m;
}
}
}
return c[1][n];
}
void Print(int s[][N],int i,int j) // 輸出矩陣連乘積的計算次序
{ if(i==j)
cout<<"A"<<i;
else
{
cout<<"(";
Print(s,i,s[i][j]); // 左半部子矩陣連乘
Print(s,s[i][j]+1,j); //左半部子矩陣連乘
cout<<")";
}
}
int lookupchain(int i,int j) //備忘錄方法
{
if(c[i][j]>0)
return c[i][j];
if(i==j)
return 0;
int u=lookupchain(i,i)+lookupchain(i+1,j)+p[i-1]*p[i]*p[j];
s[i][j]=i;
for(int k=i+1;k<j;k++)
{
int t=lookupchain(i,k)+lookupchain(k+1,j)+p[i-1]*p[k]*p[j];
if(t<u)
{
u=t;
s[i][j]=k;
}
}
c[i][j]=u;
return u;
}
void main()
{
srand((int)time(NULL));
for(int i=0;i<N;i++) // 隨機生成數組p[],各個元素的值的范圍1~MAX_VALUE
p[i]=random()+1;
clock_t start,end;
double elapsed;
start=clock();
//cout<<"Count: "<<matrixchain(N-1)<<endl; //3重for循環實現
cout<<"Count: "<<lookupchain(1,N-1)<<endl; //備忘錄方法
end=clock();
elapsed=((double)(end-start));///CLOCKS_PER_SEC;
cout<<"Time: "<<elapsed<<endl;
Print(s,1,N-1); //輸出矩陣連乘積的計算次序
cout<<endl;
}
兩種算法的時間復雜度均為o(n3),,隨着數據量的增多,備忘錄方法消耗的時間越長;我覺得是由於遞歸算法,隨着數據量增大,調用函數的次數也增大,語句被執行的時間也越多,因此調用函數消耗的時間也增多。
4:貪心算法
貪心算法是指,在對 問題求解時,總是做出在當前看來是最好的選擇。也就是說,不從整體最優上加以考慮,他所做出的是在某種意義上的局部 最優解。
貪心算法不是對所有問題都能得到整體最優解,關鍵是貪心策略的選擇,選擇的貪心策略必須具備無后效性,即某個狀態以前的過程不會影響以后的狀態,只與當前狀態有關。
貪心算法的基本思路是從問題的某一個初始解出發一步一步地進行,根據某個優化測度,每一步都要確保能獲得局部最優解。每一步只考慮一個數據,他的選取應該滿足局部優化的條件。若下一個數據和部分最優解連在一起不再是可行解時,就不把該數據添加到部分解中,直到把所有數據枚舉完,或者不能再添加算法停止。
貪婪算法可解決的問題通常大部分都有如下的特性:
隨着算法的進行,將積累起其它兩個集合:一個包含已經被考慮過並被選出的候選對象,另一個包含已經被考慮過但被丟棄的候選對象。
有一個函數來檢查一個候選對象的集合是否提供了問題的解答。該函數不考慮此時的解決方法是否最優。
還有一個函數檢查是否一個候選對象的集合是可行的,也即是否可能往該集合上添加更多的候選對象以獲得一個解。和上一個函數一樣,此時不考慮解決方法的最優性。
選擇函數可以指出哪一個剩余的候選對象最有希望構成問題的解。 最后,目標函數給出解的值。
為了解決問題,需要尋找一個構成解的候選對象集合,它可以優化目標函數,貪婪算法一步一步的進行。起初,算法選出的候選對象的集合為空。接下來的每一步中,根據選擇函數,算法從剩余候選對象中選出最有希望構成解的對象。如果集合中加上該對象后不可行,那么該對象就被丟棄並不再考慮;否則就加到集合里。每一次都擴充集合,並檢查該集合是否構成解。如果貪婪算法正確工作,那么找到的第一個解通常是最優的。
使用貪心算法求解問題應該考慮如下幾個方面
(1)候選集合A:為了構造問題的解決方案,有一個候選集合A作為問題的可能解,即問題的最終解均取自於候選集合A。
(2)解集合S:隨着貪心選擇的進行,解集合S不斷擴展,直到構成滿足問題的完整解。
(3)解決函數solution:檢查解集合S是否構成問題的完整解。
(4)選擇函數select:即貪心策略,這是貪心法的關鍵,它指出哪個候選對象最有希望構成問題的解,選擇函數通常和目標函數有關。
(5)可行函數feasible:檢查解集合中加入一個候選對象是否可行,即解集合擴展后是否滿足約束條件。
4.7//A是問題的輸入集合即候選集合
Greedy(A)
{
S={ }; //初始解集合為空集
while (not solution(S)) //集合S沒有構成問題的一個解
{
x = select(A); //在候選集合A中做貪心選擇
if feasible(S, x) //判斷集合S中加入x后的解是否可行
S = S+{x};
A = A-{x};
}
return S;
}貪心算法的一般流程
(1)候選集合A:問題的最終解均取自於候選集合A。
(2)解集合S:解集合S不斷擴展,直到構成滿足問題的完整解。
(3)解決函數solution:檢查解集合S是否構成問題的完整解。
(4)選擇函數select:貪心策略,這是貪心算法的關鍵。
(5)可行函數feasible:解集合擴展后是否滿足約束條件。
5:回溯法
回溯法又稱為試探法,是一種選優搜索法,按選優條件向前搜索,以達到目標。回溯法采用試錯的思想,它嘗試分步的去解決一個問題。在分步解決問題的過程中,當它通過嘗試發現現有的分步答案不能得到有效的正確的解答的時候,它將取消上一步甚至是上幾步的計算,再通過其它的可能的分步解答再次嘗試尋找問題的答案。
在包含問題的所有解的解空間樹中,按照深度優先搜索的策略,從根結點出發深度探索解空間樹。當探索到某一結點時,要先判斷該結點是否包含問題的解,如果包含,就從該結點出發繼續探索下去,如果該結點不包含問題的解,則逐層向其祖先結點回溯。(其實回溯法就是對隱式圖的深度優先搜索算法)。 若用回溯法求問題的所有解時,要回溯到根,且根結點的所有可行的子樹都要已被搜索遍才結束。 而若使用回溯法求任一個解時,只要搜索到問題的一個解就可以結束。
可用回溯法求解的問題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的一個解。但顯然,其計算量是相當大的。
6. 分支限界算法
分支限界法常以廣度優先或以最小耗費(最大效益)優先的方式搜索問題的解空間樹。在分支限界法中,每一個活結點只有一次機會成為擴展結點。活結點一旦成為擴展結點,就一次性產生其所有兒子結點。在這些兒子結點中,導致不可行解或導致非最優解的兒子結點被舍棄,其余兒子結點被加入活結點表中。此后,從活結點表中取下一結點成為當前擴展結點,並重復上述結點擴展過程。這個過程一直持續到找到所需的解或活結點表為空時為止。
分支限界法常以廣度優先或以最小耗費(最大效益)優先的方式搜索問題的解空間樹。
在分支限界法中,每一個活結點只有一次機會成為擴展結點。活結點一旦成為擴展結點,就一次性產生其所有兒子結點。在這些兒子結點中,導致不可行解或導致非最優解的兒子結點被舍棄,其余兒子結點被加入活結點表中。
此后,從活結點表中取下一結點成為當前擴展結點,並重復上述結點擴展過程。這個過程一直持續到找到所需的解或活結點表為空時為止。
常見的兩種分支限界法 :
(1)隊列式(FIFO)分支限界法
按照隊列先進先出(FIFO)原則選取下一個節點為擴展節點。
(2)優先隊列式分支限界法
按照優先隊列中規定的優先級選取優先級最高的節點成為當前擴展節點。
分支限界法與回溯法的不同
(1)求解目標:回溯法的求解目標是找出解空間樹中滿足約束條件的所有解,而分支限界法的求解目標則是找出滿足約束條件的一個解,或是在滿足約束條件的解中找出在某種意義下的最優解。
(2)搜索方式的不同:回溯法以深度優先的方式搜索解空間樹,而分支限界法則以廣度優先或以最小耗費優先的方式搜索解空間樹。