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)搜索方式的不同:回溯法以深度优先的方式搜索解空间树,而分支限界法则以广度优先或以最小耗费优先的方式搜索解空间树。