1. 程式人生 > >計算機演算法設計與分析觀後小總結

計算機演算法設計與分析觀後小總結

簡單略看了一遍王曉東的《計算機演算法設計與分析》,很多地方沒有細看,現在先做個小總結,方便以後回頭看的時候記憶起一些內容。

第二章:遞迴與分治策略

遞迴的概念:

直接或間接地呼叫自身的演算法稱為遞迴演算法。用函式自身給出定義的函式稱為遞迴函式。

分治法的基本思想:

分治法的基本思想是將一個規模為n的問題分解為k個規模較小的子問題,這些子問題互相獨立且與原問題相同。遞迴地解這些子問題,然後將各子問題的解合併得到原問題的解。它的一般的演算法設計模式如下:

divide-and-conquer(P){
if(|P|<=n0)
adhoc(P);
divide P into smaller subinstances P1,P2,...,Pk;
for(i =1;i<=k;i++)
yi=divide-and-conquer(Pi);
return merge(y1,y2,...,yk);
}
其中,|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。

然後是遞迴分治的一些應用例子:

(1)二分搜尋技術

若是給定n個排好序的元素。

假設現在要搜尋x,對於存放在陣列,每次先取a[n/2]與x比較。如果x=a[n/2],則找到x,演算法終止;如果x<a[n/2],則只繼續在[0,n/2-1]區間搜尋;x>a[n/2]則在[n/2+1,n-1]搜尋。很明顯這個搜尋過程與搜尋BFS(二叉搜尋樹)類似。

(2)合併排序

基本思想:若要對n個元素進行排序(按增序排序,即從小到大),那麼在n不等於1的時候(n>1),把n個元素分成大小相等的兩份,分別對這兩個子部分進行排序,然後對這兩個子部分進行合併。當n=1的時候,明顯不用再分了,只需要合併即可。主要的排序其實是發生在合併過程中。

演算法大概這樣:先說說合並,合併過程如下:給兩個變數i、j,他們分別指向兩個子部分a[n/2]、b[n/2]的起始元素,(這裡就讓i、j作為下標)。比如i=k時,a[i]代表是陣列a中第k+1個元素。不斷比較a[i]和b[j],小的那個放入臨時陣列temp[n],然後小的那個元素對應的變數往後移一位,如i++或j++,直至i=n/2或j=n/2。之後就把剩下沒放進陣列temp的元素按原來的順序依次放進去即可。這個合併過程就是最後一趟合併,很明顯如果想要這趟合併之後的temp陣列就是排好序的陣列的話,a[n/2]和b[/n]必須是排好序的。為了對這兩個陣列排序,可以將a[n/2]和b[n/2]再各自分解成規模相等的兩個子陣列c[n/4]、d[n/4]和e[n/4]、f[n/4],再分別合併。只有當n=1時,才可以直接合並,否則都是先分成更小的兩個部分。所以就有了先分治。

下面給出簡單實現程式碼:

void MergeSort(int *a,const int left,const int right){//合併排序,a是要排序的陣列地址,left和right是要進行排序的範圍
	if (left >= right)
		return;
	int p = (left + right) / 2;
	MergeSort(a, left, p);
	MergeSort(a, p + 1, right);
	Merge(a, left, right, p);
}
void Merge(int *a,const int left,const int right,const int p){//合併兩個子陣列
	int i = left, j = p + 1;//他們各自的其實位置由left和p給出.用i、j作為他們各自的下標
	int *temp = new int[right - left + 1](), m = 0;//開闢臨時陣列temp,m作為它的下標
	while ((i < p + 1) && (j < right + 1)){
		if (a[i] < a[j])
			temp[m++] = a[i++];
		else
			temp[m++] = a[j++];
	}
	while (i<p+1)
		temp[m++] = a[i++];
	while (j < right + 1)
		temp[m++] = a[j++];
	i = 0, j = left;//這裡i、j不再指向兩個子陣列。而是充當指向temp和原陣列的下標了
	while (i < m){
		a[j++] = temp[i++];
	}	
	delete temp;
}


(3)快速排序

我們都知道起泡排序是最簡單的基於“交換”的排序演算法,快速排序就是起泡排序的改進版本。快排也是基於分治策略的,對於輸入子陣列a[n],若要將陣列的[l,r]進行排序,則按三個步驟進行:1.分解:以a[p]為基準元素將a[n]分成三段區間[l,p-1]、區間[p,p]和區間[p+1,r]。使得處於[l,p-1]的元素都小於等於a[p],而[p+1,r]的元素都大於等於a[p]。下標p在劃分過程中確定。2.遞迴求解:通過遞迴呼叫快速排序演算法分別對[l,p-1]和[p+1,r]進行排序。3.合併:由於對[l,p-1]和[p+1,r]的排序是就地進行的,所以在[l,p-1]和[p+1,r]都已排好的序後,不需要執行任何計算,[l,r]就已經排好序。

容易看出,對於合併排序,其排序工作發生在合併這一步,而對於快速排序,排序工作是發生在劃分這一步。所以很容易就能對比寫出快排演算法,下面給出簡單例項。

void Swap(int &a, int &b){
	int temp = a;
	a = b;
	b = temp;
}
int Divide(int *a,const int l,const int r){
	//一般是先選取區間的第一個元素作為基準元素,然後對區間[l+1,r]排序使得最後[l,r]的元素滿足:基準左邊元素都小於等於它,右邊都大於等於它.
	int p = a[l];
	int i = l, j = r + 1;
	while (true){
		while (a[++i] < p&&i < r);//找到比p大的i值
		while (a[--j] > p);//找到比p小的j值
		if (i >= j)
			break;
		Swap(a[i], a[j]);
	}
	Swap(a[l], a[j]);//j的位置即為基準位置
	return j;
}
void QuickSort(int *a,const int l,const int r){//a為陣列地址,l、r指出要排序的區間[l,r]
	if (l >= r)
		return;
	int p = Divide(a,l,r);
	QuickSort(a, l, p - 1);
	QuickSort(a, p + 1, r);
}

第三章:動態規劃

書上原文:動態規劃演算法與分治法類似,其基本思想也是將待求解問題分解成若干個子問題,先求解子問題,然後從這些子問題的解得到原問題的解。與分治法不同的是,適合於用動態規劃法求解的問題,經分解得到的子問題往往不是相互獨立的。若用分治法來解這類問題,則分解得到的子問題數目太多,以至於最後解決原問題需要耗費指數時間。然而,不同子問題的數目常常只有多項式量級。在用分治法求解時,有些子問題被重複計算了許多次。如果我們能夠儲存已解決的子問題的答案,而在需要時再找出已求得的答案,這樣就可以避免大量的重複計算,從而得到多項式時間演算法。為了達到此目的,可以用一個表來記錄所有已解決的子問題的答案,不管該子問題以後是否被用到,只要它被計算過,就將其結果填入表中。這就是動態規劃法的基本思想。具體的動態規劃演算法多種多樣,但它們具有相同的填表格式。

動態規劃演算法適用於解最優化問題。通常可按以下4個步驟設計:

(1)找出最優解的性質,並刻畫其結構特徵。

(2)遞迴地定義最優值。

(3)以自底向上的方式計算最優值。

(4)根據計算最優值時得到的資訊,構造最優解。

步驟(1)~(3)是動態規劃演算法的基本步驟。在只需要求出最優值的情形,步驟(4)可以省去。若需要求出問題的最優解,則必須執行步驟(4)。此時,在步驟(3)中計算最優值時,通常需記錄更多的資訊,以便在步驟(4)中,根據所記錄的資訊,快速構造出一個最優解。

並不是所有問題都能用動態規劃法來解決。從一般意義上講,問題所具有兩個重要性質是該問題可用動態規劃演算法求解的基本要素。

這兩個性質是 最優子結構 和 重疊子問題 。

1.最優子結構

當問題的最優解包含了其子問題的最優解時,稱該問題具有最優子結構性質。問題的最優子結構性質提供了該問題可用動態規劃演算法求解的重要線索。

2.重疊子問題

在用遞迴演算法自頂向下解此問題時,每次產生的子問題並不總是新問題,有些子問題被反覆計算多次。動態規劃演算法正是利用了這種子問題的重疊性質,對每一個子問題只解一次,而後將其解儲存在一個表格中,當再次需要解此子問題時,只是簡單地用常數時間檢視一下結果。

備忘錄方法

備忘錄方法是動態規劃演算法的變形。與動態規劃演算法一樣,備忘錄方法用表格儲存已解決的子問題的答案,在下次需要解此子問題時,只要簡單地檢視該子問題的解答,而不必重新計算。與動態規劃演算法不同的是,備忘錄方法的遞迴方法是自頂向下的,而動態規劃演算法則是自底向上遞迴的。因此,備忘錄方法的控制結構與直接遞迴方法的控制結構相同,區別在於備忘錄方法為每個解過的子問題建立了備忘錄以備需要時檢視,避免了相同子問題的重複求解。

例子:0-1揹包問題

問題描述:給定n種物品和一揹包。物品i的重量是wi,其價值是vi,揹包的容量為c。問應如何選擇裝入揹包中的物品,使得裝入揹包中物品的總價值最大?

首先是要證明這個問題具有最優子結構性質,這裡就不寫上證明了。

遞迴關係:

書上原文:設m[i][j]是揹包容量為j,可選擇物品為i,i+1,...,n時0-1揹包問題的最優值。

遞迴式如下:

m[i][j]  =  max{m[i+1][j],m[i+1][j-wi]+vi}       j>=wi(揹包能放下物品i時,它的最優值是選物品i和不選物品i兩者中的最大者)

                m[i+1][j]                                     0<=j<wi  (揹包放不下的時候,可選物品 i到n 和 i+1到n 的最優值很明顯是一樣的,因為放不下wi所以最優價值不變)

m[n][j] =  vn    j>=wn         (m[n][j]的n表明可選物品只有n,很明顯如果揹包能放得下那麼就取wn,價值則為vn)

                0      0<=j<wn    (放不下價值就是0了)

程式碼:

int Max(const int &a,const int &b){
	return (a > b) ? a : b;
}
int Min(const int &a, const int &b){
	return (a < b) ? a : b;
}
void Knapsack(int *v,int *w,const int n,const int c,int *x){//v為物品價值,w為物品重量,n為物品數量,c為揹包容量,x為解序列
	int **m = new int*[n + 1];
	for (int i = 0; i < n + 1; i++)
		m[i] = new int[c + 1]();
	for (int j = w[n]; j <= c; j++)
		m[n][j] = v[n];
	int jMax;
	for (int i = n - 1; i > 1; i--){
		jMax = Min(w[i]-1, c);
		for (int j = 0; j <= jMax; j++)
			m[i][j] = m[i + 1][j];
		for (int j = w[i]; j <= c; j++)
			m[i][j] = Max(m[i + 1][j], m[i + 1][j - w[i]] + v[i]);
	}
	m[1][c] = m[2][c];
	if (c >= w[1])
		m[1][c] = Max(m[2][c], m[2][c - w[1]] + v[1]);
	int j = c;
	for (int i = 1; i < n; i++){
		if (m[i][j] == m[i + 1][j])
			x[i] = 0;
		else{
			x[i] = 1;
			j -= w[i];
		}
	}
	x[n] = (m[n][j]) ? 1 : 0;
	cout << "最優值m[1][c] = " << m[1][c] << endl;
	delete m;
}
int main(){
	int n = 5, c = 10;
	int w[6] = { 0, 2, 2, 6, 5, 4 }, v[6] = { 0, 6, 3, 5, 4, 6 };//這裡的w[0]和v[0]都用不到.
	int x[6];//x[0]也用不到.
	Knapsack(v, w, n, c, x);
	for (int i = 1; i < 6; i++){
		if (i == 1)
			cout << "x序列為:(";
		cout << x[i];
		if (i == 5)
			cout << ")" << endl;
		else
			cout << ",";
	}
	return 0;
}

第四章:貪心演算法

當一個問題具有最優子結構性質時,可用動態規劃法求解。但有時會有更簡單有效的演算法。考察找硬幣的例子。假設有4種硬幣,它們的面值分別為二角五分、一角、五分和一分。現在要找給某顧客六角三分錢。這時,很自然地拿出2個二角五分的硬幣,1個一角的硬幣和3個一分的硬幣交給顧客。這種找硬幣方法與其他的找法相比,所拿出的硬幣個數是最少的。這裡,使用了這樣的找硬幣演算法:首先選出一個面值不超過六角三分的最大硬幣,即二角五分;然後從六角三分中減去二角五分,剩下三角八分;再選出一個面值不超過三角八分的最大硬幣,即又一個二角五分,如此一直做下去。這個找硬幣的方法實際上就是貪心演算法。顧名思義,貪心演算法總是做出在當前看來是最好的選擇。也就是說貪心演算法並不從整體最優上加以考慮,它所作出的選擇只是在某種意義上的區域性最優選擇。當然,我們希望貪心演算法得到的最終結果也是整體最優的。上面所說的找硬幣演算法得到的結果就是一個整體最優解。

貪心演算法的基本要素:

1.貪心選擇性質

所謂貪心選擇性質是指所求問題的整體最優解可以通過一系列區域性最優的選擇,即貪心選擇來達到。這是貪心演算法可行的第一個基本要素,也是貪心演算法與動態規劃演算法的主要區別。在動態規劃演算法中,每步所做的選擇往往依賴於相關子問題的解。因而只有在解出相關子問題後,才能做出選擇。而在貪心演算法中,僅在當前狀態下做出最好選擇,即區域性最優選擇。然後再去解做出這個選擇後產生的相應的子問題。貪心演算法所做的貪心選擇可以依賴於以往所做過的選擇,但決不依賴於將來所做的選擇,也不依賴於子問題的解。正是由於這種差別,動態規劃演算法通常以自底向上的方式解各子問題,而貪心演算法則通常以自頂向下的方式進行,以迭代的方式做出相繼的貪心選擇,每做一次貪心選擇就將所求問題簡化為規模更小的子問題。

對於一個具體問題,要確定它是否具有貪心選擇性質,必須證明每一步所做的貪心選擇最終導致問題的整體最優解。通常可以這麼證明:首先考察問題的一個整體最優解,並證明可修改這個最優解,使其以貪心選擇開始。做了貪心選擇後,原問題簡化為規模更小的類似子問題。然後,用數學歸納法證明,通過每一步做貪心選擇,最終可得到問題的整體最優解。其中,證明貪心選擇後的問題簡化為規模更小的類似子問題的關鍵在於利用該問題的最優子結構性質。

2.最優子結構的性質

當一個問題的最優解包含其子問題的最優解時,稱此問題具有最優子結構性質。問題的最優子結構性質是該問題可用動態規劃演算法或貪心演算法求解的關鍵特徵。

3.貪心演算法與動態規劃演算法的差異

貪心演算法和動態規劃演算法都要求問題具有最優子結構性質,這是兩類演算法的一個共同點。不同點在於可用貪心演算法求解的問題具有貪心選擇性質,這意味著貪心演算法可求解的範圍是動態規劃演算法求解範圍的一個子集。可用動態規劃演算法求解的問題,一般不能用貪心演算法來求解(如0-1揹包問題)。但是,如果該問題滿足貪心選擇性質,那就可以用貪心演算法來求解,貪心演算法適用範圍更窄,但比動態規劃簡單。

例項:

(1)哈弗曼編碼

哈弗曼編碼(Huffman Coding),又稱霍夫曼編碼,是一種編碼方式,哈夫曼編碼是可變字長編碼(VLC)的一種。Huffman於1952年提出一種編碼方法,該方法完全依據字元出現概率來構造異字頭的平均長度最短的碼字,有時稱之為最佳編碼,一般就叫做Huffman編碼(有時也稱為霍夫曼編碼)。

例子:有a,b,c,d,e,f 這6個字元,它們出現的頻率為45,13,12,16,9,5。求哈弗曼編碼。

簡單實現:(樹結點深複製未考慮,遞迴複製也未考慮)

//標頭檔案test.h
template<class T>
class TreeNode{
private:
	T f;//頻率
	char c;//對應的字母
	TreeNode *lc, *rc;//指向左兒子,右兒子的結點指標
public:
	TreeNode(T F, char C) :f(F), c(C){
		lc = rc = 0;
	}
	TreeNode(TreeNode<T>&a, TreeNode<T>&b){
		f = a.f + b.f;
		c = '0';
		lc = new TreeNode<T>(a);
		rc = new TreeNode<T>(b);
	}
	operator T()const{
		return f;
	}
	bool HasChild(int dir){
		if (dir == 0){
			if (lc != 0)
				return true;
			return false;
		}
		if (rc != 0)
			return true;
		return false;
	}
	TreeNode<T> GetChild(int dir){
		if (dir == 0)
			return *lc;
		return *rc;
	}
	void show(){
		cout << "c = " << c << " , f = " << f << "." << endl;
	}
};
#include<iostream>
#include<functional>
#include<queue>
#include"test.h"
using namespace std;
void MakeHuffmanTree(priority_queue<TreeNode<int>, vector<TreeNode<int>>, greater<TreeNode<int>>>Q, TreeNode<int>&Root){
	if (Q.empty())
		return;
	while (Q.size() > 1){
		TreeNode<int>temp1 = Q.top();
		Q.pop();
		TreeNode<int>temp2 = Q.top();
		Q.pop();
		TreeNode<int>temp3(temp1, temp2);
		Q.push(temp3);
	}
	Root = Q.top();
}
int main(){
	priority_queue<TreeNode<int>, vector<TreeNode<int>>, greater<TreeNode<int>>>Q;
	Q.push(TreeNode<int>(45, 'a'));
	Q.push(TreeNode<int>(13, 'b'));
	Q.push(TreeNode<int>(12, 'c'));
	Q.push(TreeNode<int>(16, 'd'));
	Q.push(TreeNode<int>(9, 'e'));
	Q.push(TreeNode<int>(5, 'f'));
	TreeNode<int> Root(0, '0');
	MakeHuffmanTree(Q, Root);
	//用佇列實現層次遍歷這棵2叉哈弗曼樹,驗證結果
	queue<TreeNode<int>>q;
	q.push(Root);
	int i = 1;
	while (!q.empty()){
		cout << "第" << i << "個結點:";
		q.front().show();
		if (q.front().HasChild(0))
			q.push(q.front().GetChild(0));//左子結點進入佇列
		if (q.front().HasChild(1))
			q.push(q.front().GetChild(1));//右子結點進入佇列
		q.pop();
		i++;
	}
	return 0;
}

(2)單源最短路徑

(3)最小生成樹

未完待續,補上例項的問題描述和程式碼實現。