计算机算法设计与分析观后小总结

简单略看了一遍王晓东的《计算机算法设计与分析》,很多地方没有细看,现在先做个小总结,方便以后回头看的时候记忆起一些内容。

第二章:递归与分治策略

递归的概念:

直接或间接地调用自身的算法称为递归算法。用函数自身给出定义的函数称为递归函数。

分治法的基本思想:

分治法的基本思想是将一个规模为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)最小生成树

未完待续,补上实例的问题描述和代码实现。

点赞