Learn as much as you can while you are young,since life becomes too busy later.
——Danna SteWard Scott
首先,让我们来看看什么是算法:
算法(Algorithm)是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法描述解决问题的策略机制。
本文将详细介绍十种基本的算法,并通过相关例题进行解说。
包括:
求值法 | 递推法 | 递归法 | 枚举法 | 模拟法 |
分治法 | 贪心法 | 回溯法 | 构造法 | 动态规划法 |
首先,我们来看看第一个算法——“求值法”
求值法是一种最简单的问题求解方法,也是一种常用的算法设计方法。
它是根据问题中给定的条件,运用基本的顺序,选择和循环控制结构来解决问题。例如,求最大数,求平均数等问题就是求值法的具体应用
用求值法解决问题,通常可以从如下两个方面进行算法设计:
(1)确定约束条件:即找出问题的约束条件
(2)选择控制结构:根据实际问题选择合适的控制结构来解决问题
用求值法解题的一般过程可分为下列3步:
(1)输入:根据实际问题输入已知数据
(2)计算:这是求解问题的关键,在已知所求值之间找出关系或规律,简单的问题可能给出计算表达式、方程等,而复杂问题可以用数学模型或数据结构等描述
(3)输出:将计算结果打印出来
那么,我们来看几道问题吧!
首先,判断闰年问题,那么,判断闰年的依据是什么?
“某一个年份能被4整除但不能被100整除,或者该年份能被400整除,如果条件成立,则该年为闰年”
例1.1 输入一个数,判断是否为闰年。
代码:
#includeusing namespace std;bool is_Prime(int year){ if(year%4==0&&year%100!=0||year%400==0) return true; else return false;}int main(){ int n; cin>>n; if(is_Prime(n)==true) cout< else if(is_Prime(n)==false) cout< return 0;}
接下来,我们来看素数问题。
那么,什么是素数呢?
素数是指一个大于1的自然数中,除了1和它本身之外,不能被其他自然数整除的数,注意,1不是素数。
例1.2 输入一个数,判断该数是否为素数。
代码:
#includeusing namespace std;bool is_Prime(int n){ if(n<=1) return false; else { int sq=sqrt(1.0*n); for(int i=2;i<=sq;i++) { if(n%i==0) return false; } return true; }}int main(){ int n; cin>>n; if(is_Prime(n)==true) cout< else if(is_Prime(n)==false) cout< return 0;}
最后,我们以大整数阶乘来小结“求值法”
例1.3 输入一个正整数n,求出n!(0
#includeusing namespace std;int main(){ int a[100000],i,s,j,n; int l=0; a[0]=1; //0的阶乘为1 cin>>n; for(i=1;i<=n;i++) { s=0; for(j=0;j<=l;j++) { s=s+a[j]*i; a[j]=s%10; s/=10; } while(s) { l++; a[l]=s%10; s/=10; } } for(i=l;i>=0;i--) { cout< } return 0;}
运行结果:
好啦,求值法的简单介绍就到这里了。
我们来看看第二个算法——“递推法”
递推法是利用求解问题本身具有的性质(递推关系)来求得问题解决的有效方法。
具体做法是:
对于一个问题,可以根据N=n之前的一步(n-1)或多步(n-1,n-2,n-3.....)的结果推导出n时的解:f(n)=F(f(n-1),f(n-2)...),这称为递推关系式:
而N=0,1...的初值f(0),f(1)...往往是直接给出的或直观得出的
递推算法的关键是得到相邻的数据项之间的关系,即递推关系。
递推关系是一种高效的数学模型,是递推应用的核心。递推关系不仅在各数学分支中发挥着重要的作用,由它体现出来的递推思想在各学科领域中更是显示出独特的魅力。
在求解具体问题时,必须明确N=n的解与前面几步的结果相关。
若问题与前两步相关,则在计算的过程中只要记住这两步的结果R1,R2,下一步的结果R可以由这两步的结果推导得到: R=F(R1,R2),
接下来进行递推:R1=R2,R2=R,向前递推,为求下一步的结果做好准备。
这也正是递推法名字的由来。
若问题与前三步相关,则在计算的过程中需要记住前三步的结果,R1,R2,R3,下一步的结果R可由这三步的结果推到得到:
R=F(R1,R2,R3),接下来进行递推:R1=R2,R2=R3,R3=R,向前传递。
递推法的一般步骤:
(1)确定递推变量。
递推变量可以是简单变量,也可以是一维或则多维数组
(2)建立递推关系
递推关系是递推的依据,是解决递推问题的关键
(3)确定初始(边界)条件
根据问题最简单情形的数据确定递推变量的初始(边界)值,这是递推的基础
(4)控制递推过程
递推过程通常由循环结构实现,即递推在什么时候进行,在满足什么条件时结束
递推法可分为正推法和倒推法两种
一般来讲,正推法是一种简单的递推方式,是从小规模的问题推解出大规模问题的一种方法,也称为”正推“。
倒推法是对某些特殊问题采用的不同于通常习惯的一种方法,即从后向前推导,实现求解问题的方法。
下面,我们将通过一些经典的问题来具体探讨递推法:
例2.1 最大公约数问题:给出任意两个整数m和n,求出它们的最大公约数
“在数学中,求最大公约数问题有一个很有名的方法叫辗转相除法”,该法体现了递推法的基本思想。设m,n为两个正整数,且n不为0,辗转相除法的过程是: (1)将问题转化为:r=m%n,r为m除以n的余数 (2)若r=0,则n为所求的最大公约数,输出n (3)若r!=0,则令m=n,n=r,继续递推,再重复前面的(1)(2)步骤。其中,第(3)部分为递推的部分
代码:
#includeusing namespace std;int Common_divisor(int a,int b){ if(b==0) return a; else return Common_divisor(b,a%b);}int main(){ int a,b; cout< cout< cin>>a; cout< cout< cin>>b; cout< cout< return 0;}/** 递归式: gcc(a,b) = gcc(b, a %b) 递归边界: gcc(a, 0) = a**/
递归式:gcc(a,b)=gcc(b,a%b)
递归边界:gcc(a,0)=a
例2.2 猴子吃桃问题
一只小猴子摘了若干桃子,每天吃现有桃子的一半多一个,到第n天时只剩下一个桃子,求小猴子最初摘了多少个桃子?
Input
输入数据有多组,每组占一行,包含一个正整数(1
Output
对于每组输入数据,输出第一天开始吃的时候桃子的总数,每个测试实例占一行。
Sample Input
2
4
0
Sample Output
4
22
代码:
#includeusing namespace std;int main(){ int n; while(scanf("%d",&n)!=EOF) { int sum=1; for(int i=2;i<=n;i++) { sum=(sum+1)*2; } printf("%d\n",sum); } return 0;}
例2.3杨辉三角问题
杨辉三角又称Pascal三角形,它的第i+1行是(a+b)^i的展开式的系数。它的一个重要性质是:三角形中的每个数字等于它肩上的数字相加。
下面给出杨辉三角形的前四行
1
1 1
1 2 1
1 3 3 1
给定n,输出它的前n行
Input
输入一个正整数n(1<=n<=34)
Output
...
Input Sample
4
Output Sample
1
1 1
1 2 1
1 3 3 1
代码:
#includeusing namespace std;int main(){ int num; cout< cin>>num; int a[35][35]; for(int i=0;i { a[i][0]=1; a[i][i]=1; } for(int i=0;i { for(int j=0;j<=i;j++) { if(a[i][j]!=1) a[i][j]=a[i-1][j]+a[i-1][j-1]; } } for(int i=0;i { for(int j=0;j<=i;j++) { cout< } cout< } return 0;}
例2.4 方格涂色问题
有排成一行的n个方格,用红(Red),粉(Pink),绿(Green)三色涂每个格子,每格涂一色,要求任何相邻的方格不能同色,且首尾两格也不同色,求所有满足要求的涂法。
Input
输入数据包含多个测试用例,每个测试用例占一行,由一个整数N组成(0
Output
对于每个测试实例,请输入全部的满足要求的涂法,每个实例的输出占一行。
Sample Input
1
2
Sample Output
3
6
该问题适合递推法求解。令f(n)=1,2....n-2,n-1,n. 前n-2个已涂好后,涂第n-1个有两种情况: (1)n-1的色与n-2和1的色都不相同,那么n就是剩下的那个色,没有选择,也就是f(n-1) (2)n-1的色与n-2的色不相同,但与1的色一样,那么n的色就有2个色选择,也就是f(n-2)*2 综上所述:f(n)=f(n-1)+2*f(n-2)
代码:
#includeusing namespace std;int main(){ int i,n; long long a[51]; a[1]=3; a[2]=6; a[3]=6; for(i=4;i<51;i++) a[i]=2*a[i-2]+a[i-1]; while(scanf("%d",&n)!=EOF) printf("%11d\n",a[n]); return 0;}
接下来,我们来看看第三个算法——“递归法”
递归法算法设计思想
递归法就是一个过程或函数在其定义中直接或间接调用自身的一种方法。
递归法是一种用来描述问题和解决问题的基本方法。
它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只要少量的程序就可以描述出需要多次重复计算的解题过程,大大减少了程序的代码量。
递归的能力在于用有限的语句来定义对象的无限集合。
一般来说,递归需要有边界条件、递归前进段和递归返回段。
当边界条件不满足时,递归前进;当边界条件满足时,递归返回。
递归法的思路:
第一步,将规模较大的问题分解为一个或多个规模更小,但具有类似于原问题特性的子问题,即较大的问题递归地用较小的子问题来描述,解原问题的方法同样可用来解这些子问题。
第二步,确定一个或多个无须分解、可直接求解的最小子问题(称为递归的终止条件)。
递归的两个基本要素是:
(1)递归关系式(递归体),确定递归的方式,即原问题是如何分解为子问题的。
(2)递归出口,确定递归到何时终止,即递归的终止(结束,边界)条件。
接下来,让我们从几个经典的问题入手。
例3.1 母牛繁殖问题
有一头母牛,它每年年初生一头小母牛。每头小母牛从第4个年头开始,每年年初也生一头小母牛。求:到第n年的时候,共有多少头母牛?(这里不计死亡)
Input
输入一个整数n(1<=n<=50)
Output
输出第n年时母牛的数量
依题意得这样一个数列:1,2,3,4,6,9,13,19,28... 该数列类似Fibonacci数列 F(n)=F(n-1)+F(n-3)
代码:
#includeusing namespace std;int cow(int n){ if(n<=4) return n; else return cow(n-1)+cow(n-3);}int main(){ int n; cin>>n; cout< return 0;}
例3.2 计算x的n次幂
输入两个整数x和n,计算x的n次幂。由于计算结果比较大,结果对10000007取模
Input
输入两个整数x和n(0
Output
输出x的n次幂对10000007取模
Sample Input
2 3
Sample Output
8
代码:
#includeusing namespace std;#define MOD 10000007long long power(int x,int n){ long long ans;//存放结果 if(n==0) ans=1; else { ans=power(x,n/2); ans=ans*ans%MOD; if(n%2==1) ans=ans*x%MOD; } return ans%MOD;}int main(){ int x,n; cin>>x>>n; cout< return 0;}
例3.3 数组逆置
设计算法,将一个数组中的数据逆置
Input
输入的第一行为一个整数n,表示有n个数据(0
Output
将逆置的数组数据输出,每个数据后面有一个空格。
代码:
#includeusing namespace std;#define MOD 10000007void rev(int a[],int i,int j){ int temp; if(i { temp=a[i]; a[i]=a[j]; a[j]=temp; rev(a,i+1,j-1);//递归调用 }}int main(){ int i,n,a[100]; cin>>n; for(i=0;i { scanf("%d",&a[i]); } rev(a,0,n-1); for(i=0;i { cout< } return 0;}
例3.4 汉诺塔问题
设A,B,C是3个塔座。开始时,在塔座A上有一叠圆盘,共n个,这些圆盘自下而上,由大到小地叠在一起。各圆盘从小到大编号为1,2。。。。n,现要求将塔座A上的这一叠圆盘通过塔座B移动到塔座C上,并仍按照同样顺序叠置,问需要最少移动的步数。移动圆盘时应遵守以下移动规则:
规则1:每次只能移动1个圆盘
规则2:任何时刻都不允许将较大的圆盘压在较小的圆盘上
规则3:在满足移动规则1和2的前提下,可将圆盘移至A,B,C中的任一塔座上。
Input
输入一个整数n(0
Output
输出一个整数,代表完成汉诺塔移动的最少步数
Sample Input
3
Sample Output
7
问题分析:
有3个塔座,n个圆盘:初始:所有圆盘放在A号塔座,大的在下面,小的在上面。
任务:把圆盘移动到C号塔座上,顺序不变,可用B号塔座辅助
递归解题方法:
步骤1:先将A上的n-1个圆盘借助C移动到B上。
步骤2:将A上最大的圆盘从A移到C上。
步骤3:再将B上的n-1个圆盘从A移到C上。如n=3时,3阶汉诺塔的移动:A->C, A->B,C->B,A->C,B->A,B->C,A->C.需要最少移动7步。
代码:
#includeusing namespace std;int m;void hanoi(int n,char A,char B,char C){ if(n==1) { m++; } else { hanoi(n-1,A,C,B); m++; hanoi(n-1,B,A,C); }}int main(){ int n; char A,B,C; cin>>n; m=0; hanoi(n,'A','B','C');//字符常量表示三个位置 cout< return 0;}
讲到这里,可能有的小伙伴对“递归法”和“递推法”的区别和联系比较感兴趣,那么,下面将为你介绍两者的区别和联系。
1、递推法:
递推算法是一种根据递推关系进行问题求解的方法。通过已知条件,利用特定的递推关系可以得出中间推论,直至得到问题的最终结果。递推算法分为顺推法和逆推法两种。
2、递归法:
在计算机编程中,一个函数在定义或说明中直接或间接调用自身的编程技巧称为递归。通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。递归做为一种算法在程序设计语言中广泛应用。
3、两者的联系:
在问题求解思想上,递推是从已知条件出发,一步步的递推出未知项,直到问题的解。从思想上讲,递归也是递推的一种,只不过它是对待解问题的递推,直到把一个复杂的问题递推为简单的易解问题。然后再一步步的返回去,从而得到原问题的解。
接下来,我们来看看第四个算法——“枚举法”。
枚举法(也称为穷举法)是一种蛮力策略,是一种简单而直接地解决问题的方法,也是一种应用非常普遍的方法。
它根据问题中给定的条件将所有可能的情况一一列举出来,从中找出满足问题条件的解。
此方法通常需要用多重循环来实现,测试每个变量的每个值是否满足给定的条件,若满足条件,就找到了问题的一个解。
但是,用枚举法设计的算法时间复杂度通常都是指数阶的。
例如,在“数据结构”课程中学习的选择排序,冒泡排序,插入排序,顺序查找和二叉树的遍历等,都是枚举法的具体应用。
用枚举法解决问题时,通常可以从两个方面进行算法设计。
(1)找出枚举范围:分析问题涉及的各种情况。
(2)找出约束条件:分析问题的解需要满足的条件,并用表达式表示出来
让我们从以下问题来具体探讨枚举法
例4.1 串匹配问题
给定一个字符串(主串),在该字符中查找并定位任意给定字符串(模式串)。查看给定字符串是否包含在该字符串中。若匹配成功,则返回模式串第一个字符在主串中的位置,否则返回-1.
Input
第一行为字符串a(主串),第二行为字符串b(模式串),串a,b的长度都小于5000
Output
返回b串在a串第一次匹配成功的位置,如匹配不成功,则返回-1.
Sample Input
asdfgh
dfg
Sample Output
3
c++版本:
#includeusing namespace std;int main(){ string s1,s2; cin>>s1>>s2; int first,ans; for(int i=0;i { if(s1[i]==s2[0]) { first=i; ans=i; //s2第一个字符在s1中的位置。 } } int flag=0; for(int j=0;j { if(s2[j]==s1[ans]){ans++;continue;} else if(s2[j]!=s1[ans]) { flag=1;//匹配失败; first=ans; } } if(flag==1) cout< else if(flag==0) cout< return 0;}
例4.2 谁是盗贼
公安人员审问4名盗盗窃嫌疑犯。已知4人中仅有一名是窃贼,还知道这4人中每个人要么是诚实的,要么是说谎的。在回答公安人员的问题中:
甲:“乙没有偷,是丁偷的”
乙:“我没有偷,是丙偷的”
丙:“甲没有偷,是乙偷的”
丁:”我没有偷“
请根据这四个人的对话判断谁是窃贼。(最后答案是乙偷的)
代码:
#includeint main(){ char cri; for (cri = 'a'; cri <= 'd'; cri++) { if ((cri != 'b' + (cri == 'd') != 1) && ((cri != 'b') + (cri == 'c') != 1) && ((cri != 'a') + (cri == 'b') != 1)) break; } putchar(cri); return 0;}
思路:
题目告诉我们只有一个人是罪犯,所以设置一个char型变量cri。让cri遍历所有可能的情况。而且甲乙丙所说的话都是有两句,要么全为真,要么全为假。以甲所说的话为例 “乙没有偷” 即为 cri!='b' 。“是丁偷的”,即为cri=='d'。而这两句话要么全为真,要么全为假。即为 (cri!='b')+(cri!='d')!=1; 而最后一个丁的叙述对判断没有影响,所以在判断条件那里没有体现出来。最后输出满足条件时,cri所对应的值。
好,我们枚举法先讲到这里。
让我们来看看第五个算法——“模拟法”
算法设计思想:
模拟法是最直观的问题求解方法,通常是对某一类事件进行描述,通过事件发生的先后顺序进行输入输出。
一般来说,模拟法只要读懂问题的要求,将问题中的各种事件进行编码,即可完成。
模拟法解题没有固定的模式看,一般有两种形式。
(1)随机模拟:
题目给定或隐含某一概率。设计设利用随机函数和取整函数设定某一范围的随机值,将符合概率的随机值作为参数,然后他根据这一模拟的数学模型展开算法设计。由于解题过程借助了计算机的伪随机数产生数,其随机的意义要比实际问题中的真实随机变量稍微差一点,因此模拟效果有不确定的因素。例如,数字模拟(又称数学仿真),进站时间模拟。
(2)过程模拟:
题目不给出概率,要求编程者按照题意设计数字模拟的各种参数,观察变更这些参数引起过程状态的变化,由此展开算法设计。模拟效果完全取决于过程模拟的真实性和算法的正确性,不含任何不确定因素。由于过程模拟的结果无二义性,因此竞赛中多数都采用过程模拟。例如,竖式乘除模拟、电梯问题和扑克洗牌问题。
让我们从一些经典的例题来继续探究模拟法吧。
例5.1 电梯问题
某城市最高的建筑物只有一个电梯。一个请求列表是由N个正整数组成的。数字表示电梯将停留在哪个楼层。电梯向上移动一层需要6s,向下移动一层需要4s。电梯每次停下会停留5s。
对于给定的请求列表,需要计算用于满足列表中所有请求的总时间。电梯开始时在第0层,当要求完成时,不需要返回地面。
Input
有多个测试用例。每个例子都包含一个正整数N,后面跟着N个正整数。输入的所有数都小于100.输入的测试用例为0时,表示输入结束。这个测试用例不需要处理。
Output
输出每个测试用例所要的总时间,每个结果各占一行。
Sample Input
1 2
3 2 3 1
0
Sample Output
17
41
代码:
#includeusing namespace std;int main(){ int n,m,sum,k; while(scanf("%d",&n)!=EOF&&n) { scanf("%d",&m); sum=n*5+6*m; for(int i=1;i { scanf("%d",&k); sum+=(m-k)>0?(m-k)*4:(k-m)*6; m=k; } cout< return 0; }}
例5.2 人口普查
某城镇进行人口普查,得到了全体居民的生日。现请你写个程序,找出镇上最年长和最年轻的人。
这里确保每个输入的日期都是合法的,但不一定是合理的——假设已知镇上没有超过 200 岁的老人,而今天是 2014 年 9 月 6 日,所以超过 200 岁的生日和未出生的生日都是不合理的,应该被过滤掉。
输入格式:
输入在第一行给出正整数 N,取值在(0,105];随后 N 行,每行给出 1 个人的姓名(由不超过 5 个英文字母组成的字符串)、以及按 yyyy/mm/dd(即年/月/日)格式给出的生日。题目保证最年长和最年轻的人没有并列。
输出格式:
在一行中顺序输出有效生日的个数、最年长人和最年轻人的姓名,其间以空格分隔。
输入样例:
5
John 2001/05/12
Tom 1814/09/06
Ann 2121/01/30
James 1814/09/05
Steve 1967/11/20
输出样例:
3 Tom John
代码:
#include#includeusing namespace std; int main(){ struct people{ char name[6]; int y; int m; int d; }a,max,min; max.y=2014;max.m=9;max.d=7; min.y=1814;max.m=9;max.d=5; int n,cnt=0; scanf("%d",&n); for(int i = 0;i scanf("%s %d/%d/%d",&a.name,&a.y,&a.m,&a.d); cnt++; if(a.y>2014||(a.y==2014&&a.m>9)||(a.y==2014&&a.m==9&&a.d>6)||a.y<1814||(a.y==1814&&a.m<9)||(a.y==1814&&a.m==9&&a.d<6)){ cnt--; continue; }//不合格的出生年月 if(a.y max=a; } if(a.y>min.y||(a.y==min.y&&a.m>min.m)||(a.y==min.y&&a.m==min.m&&a.d>min.d)){ min=a; } } printf("%d",cnt); if(cnt!=0){ printf(" %s %s",max.name,min.name); } return 0;}
好,模拟法我们就先介绍到这里。
接下来,让我们看看第六个算法——分治法
算法设计思想:
分治法是被广泛使用的一种算法设计方法。
字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多个相同或相似的子问题,再把子问题分成更小的子问题,直到最后子问题可以简单地求解,原问题的解即子问题的解的合并。
这个技巧是很多高效算法的基础,如归并排序、二叉树遍历、傅里叶变换等算法。
分治策略:对于一个规模为n的问题,若该问题可以很容易地得到解决(如规模n较小),则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解决这些子问题,然后将各子问题的解合并,得到原问题的解。
这种算法设计策略就是分治法。
最常用的分治法是二分法,即每次都将问题分解为原问题规模的一半,如折半查找,快速排序等。
分治法基本步骤如下:
(1)分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题
(2)解决:若子问题规模较小而容易被解决,则直接解决,否则再继续分解为更小的子问题,直到容易解决为止。
(3)合并:将已求解的各个子问题的解逐步合并为原问题的解。合并的代价因情况不同而有很大差异。分治法的有效性很大程度上依赖于合并的实现。
(如归并排序也体现了分治法思想)
例6.1二叉树遍历
建立一棵二叉树,并给出3种遍历序列Input按全二叉树先序遍历的顺序一次输入结点序列。全二叉树是指给定二叉树中的每个结点都有两个孩子,即度为2.扩展过程中加入的虚结点在输入时用字符“@”表示。例如,图输入的结点序列为:AB@DF@@@CE@@@
A
/ \
B C
\ /
D E
/
F
Output
分别按照先根(先序)遍历DLR(中序)遍历LDR,和(后序)遍历LRD输出
Sample Input
AB@DF@@@CE@@@
Sample Output
ABDFCE
BFDAEC
FDBECA
代码:
#include#include#includeusing namespace std;typedef char datatype;typedef struct node{ datatype data; struct node* lchild,*rchild;}bitree;bitree *creat_tree(){ bitree*t; char ch; scanf("%c",&ch); if(ch=='@') t=NULL; else { t=(struct node*)malloc(sizeof(bitree)); t->data=ch; t->lchild=creat_tree(); t->rchild=creat_tree(); } return (t);}void preorde(bitree *t){ //先序遍历 if(t) { printf("%c",t->data); preorde(t->lchild); preorde(t->rchild); }}void inorde(bitree *t){ //中序遍历 if(t) { preorde(t->lchild); printf("%c",t->data); preorde(t->rchild); }}void postorde(bitree *t){ //后序遍历 if(t) { preorde(t->lchild); preorde(t->rchild); printf("%c",t->data); }}int main(){ bitree *tree; tree=creat_tree(); preorde(tree); cout< inorde(tree); cout< postorde(tree); cout< return 0;}
接下来,让我们看第七个算法——贪心法
算法设计思想
一、基本概念:
所谓贪心算法是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的仅是在某种意义上的局部最优解。
贪心算法没有固定的算法框架,算法设计的关键是贪心策略的选择。
必须注意的是,贪心算法不是对所有问题都能得到整体最优解,选择的贪心策略必须具备无后效性,即某个状态以后的过程不会影响以前的状态,只与当前状态有关。
所以对所采用的贪心策略一定要仔细分析其是否满足无后效性。(否则考虑采用动态规划法)
二、贪心算法的基本思路:
1.建立数学模型来描述问题。
2.把求解的问题分成若干个子问题。
3.对每一子问题求解,得到子问题的局部最优解。
4.把子问题的解局部最优解合成原来解问题的一个解。
三、贪心算法适用的问题
贪心策略适用的前提是:局部最优策略能导致产生全局最优解。
实际上,贪心算法适用的情况很少。一般,对一个问题分析是否适用于贪心算法,可以先选择该问题下的几个实际数据进行分析,就可做出判断。
四、贪心算法的实现框架
从问题的某一初始解出发;
while (能朝给定总目标前进一步) { 利用可行的决策,求出可行解的一个解元素; } 由所有解元素组合成问题的一个可行解;
五、贪心策略的选择
因为用贪心算法只能通过解局部最优解的策略来达到全局最优解,因此,一定要注意判断问题是否适合采用贪心算法策略,找到的解是否一定是问题的最优解。
例7.1月饼
题目:月饼是中国人在中秋佳节时吃的一种传统食品,不同地区有许多不同风味的月饼。现给定所有种类月饼的库存量、总售价、以及市场的最大需求量,请你计算可以获得的最大收益是多少。
注意:销售时允许取出一部分库存。
样例给出的情形是这样的:假如我们有 3 种月饼,其库存量分别为 18、15、10 万吨,总售价分别为 75、72、45 亿元。如果市场的最大需求量只有 20 万吨,那么我们最大收益策略应该是卖出全部 15 万吨第 2 种月饼、以及 5 万吨第 3 种月饼,获得 72 + 45/2 = 94.5(亿元)。
输入格式:
每个输入包含一个测试用例。每个测试用例先给出一个不超过 1000 的正整数 N 表示月饼的种类数、以及不超过 500(以万吨为单位)的正整数 D 表示市场最大需求量。随后一行给出 N 个正数表示每种月饼的库存量(以万吨为单位);最后一行给出 N 个正数表示每种月饼的总售价(以亿元为单位)。数字间以空格分隔。
输出格式:
对每组测试用例,在一行中输出最大收益,以亿元为单位并精确到小数点后 2 位。
输入样例:3 2018 15 1075 72 45
输出样例:94.50
代码:
#include#include#include#includeusing namespace std;//map的比较类class myCompare{public: bool operator()(double v1, double v2){ return v1 > v2; }};int main(){ //输入月饼种类数量和市场最大需求量 int N; double D; cin >> N >> D; //输入每种月饼的库存量,放入vector容器 vector q; double quality; for (int i = 0; i < N; i++) { cin >> quality; q.push_back(quality); } //输入每种月饼的总售价,计算单价,放入map multimap m; double price=0; for (int i = 0; i < N; i++) { cin >> price; price = (price / q[i]); //计算每种月饼的单价 m.insert(make_pair(price, q[i])); } //计算最大收益 double sale = 0.00; for (multimap::iterator it = m.begin(); it != m.end(); it++) { if (D != 0) { if (it->second <= D) { sale += it->first * it->second; D -= it->second; } else { sale += it->first * D; D = 0; } } else { break; } } printf("%.2f", sale); return 0;}
接下来,让我们看一下第八个算法——回溯法
一. 回溯法 – 深度优先搜素
1. 简单概述
回溯法思路的简单描述是:把问题的解空间转化成了图或者树的结构表示,然后使用深度优先搜索策略进行遍历,遍历的过程中记录和寻找所有可行解或者最优解。
基本思想类同于:
图的深度优先搜索
二叉树的后序遍历
分支限界法:广度优先搜索
思想类同于:图的广度优先遍历、二叉树的层序遍历
2. 详细描述
详细的描述则为:
回溯法按深度优先策略搜索问题的解空间树。
首先从根节点出发搜索解空间树,当算法搜索至解空间树的某一节点时,先利用剪枝函数判断该节点是否可行(即能得到问题的解)。
如果不可行,则跳过对该节点为根的子树的搜索,逐层向其祖先节点回溯;否则,进入该子树,继续按深度优先策略搜索。
回溯法的基本行为是搜索,搜索过程使用剪枝函数来为了避免无效的搜索。
剪枝函数包括两类:
1. 使用约束函数,剪去不满足约束条件的路径;
2.使用限界函数,剪去不能得到最优解的路径。
问题的关键在于如何定义问题的解空间,转化成树(即解空间树)。
解空间树分为两种:子集树和排列树。两种在算法结构和思路上大体相同。
3. 回溯法应用
当问题是要求满足某种性质(约束条件)的所有解或最优解时,往往使用回溯法。它有“通用解题法”之美誉。
二. 回溯法实现 - 递归和递推(迭代)
回溯法的实现方法有两种:递归和递推(也称迭代)。
一般来说,一个问题两种方法都可以实现,只是在算法效率和设计复杂度上有区别。
【类比于图深度遍历的递归实现和非递归(递推)实现】
1. 递归
思路简单,设计容易,但效率低,其设计范式如下:
//针对N叉树的递归回溯方法 void backtrack (int t) { if (t>n) output(x); //叶子节点,输出结果,x是可行解 else for i = 1 to k//当前节点的所有子节点 { x[t]=value(i); //每个子节点的值赋值给x //满足约束条件和限界条件 if (constraint(t)&&bound(t)) backtrack(t+1); //递归下一层 } }
2. 递推
算法设计相对复杂,但效率高。
//针对N叉树的迭代回溯方法 void iterativeBacktrack () { int t=1; while (t>0) { if(ExistSubNode(t)) //当前节点的存在子节点 { for i = 1 to k //遍历当前节点的所有子节点 { x[t]=value(i);//每个子节点的值赋值给x if (constraint(t)&&bound(t))//满足约束条件和限界条件 { //solution表示在节点t处得到了一个解 if (solution(t)) output(x);//得到问题的一个可行解,输出 else t++;//没有得到解,继续向下搜索 } } } else //不存在子节点,返回上一层 { t--; } } }
子集树和排列树
1. 子集树
所给的问题是从n个元素的集合S中找出满足某种性质的子集时,相应的解空间成为子集树。
如0-1背包问题,从所给重量、价值不同的物品中挑选几个物品放入背包,使得在满足背包不超重的情况下,背包内物品价值最大。它的解空间就是一个典型的子集树。
回溯法搜索子集树的算法范式如下:
void backtrack (int t) { if (t>n) output(x); else for (int i=0;i<=1;i++) { x[t]=i; if (constraint(t)&&bound(t)) backtrack(t+1); } }
2. 排列树
所给的问题是确定n个元素满足某种性质的排列时,相应的解空间就是排列树。
如旅行售货员问题,一个售货员把几个城市旅行一遍,要求走的路程最小。它的解就是几个城市的排列,解空间就是排列树。 回溯法搜索排列树的算法范式如下:
void backtrack (int t) { if (t>n) output(x); else for (int i=t;i<=n;i++) { swap(x[t], x[i]); if (constraint(t)&&bound(t)) backtrack(t+1); swap(x[t], x[i]); } }
四. 经典问题
(1)装载问题
(2)0-1背包问题
(3)旅行售货员问题
(4)八皇后问题
(5)迷宫问题
(6)图的m着色问题
1. 0-1背包问题
问题:给定n种物品和一背包。物品i的重量是wi,其价值为pi,背包的容量为C。问应如何选择装入背包的物品,使得装入背包中物品的总价值最大?
分析:问题是n个物品中选择部分物品,可知,问题的解空间是子集树。比如物品数目n=3时,其解空间树如下图,边为1代表选择该物品,边为0代表不选择该物品。使用x[i]表示物品i是否放入背包,x[i]=0表示不放,x[i]=1表示放入。回溯搜索过程,如果来到了叶子节点,表示一条搜索路径结束,如果该路径上存在更优的解,则保存下来。如果不是叶子节点,是中点的节点(如B),就遍历其子节点(D和E),如果子节点满足剪枝条件,就继续回溯搜索子节点。
代码:
#include #define N 3 //物品的数量 #define C 16 //背包的容量 int w[N]={10,8,5}; //每个物品的重量 int v[N]={5,4,1}; //每个物品的价值 int x[N]={0,0,0}; //x[i]=1代表物品i放入背包,0代表不放入 int CurWeight = 0; //当前放入背包的物品总重量 int CurValue = 0; //当前放入背包的物品总价值 int BestValue = 0; //最优值;当前的最大价值,初始化为0 int BestX[N]; //最优解;BestX[i]=1代表物品i放入背包,0代表不放入 //t = 0 to N-1 void backtrack(int t) { //叶子节点,输出结果 if(t>N-1) { //如果找到了一个更优的解 if(CurValue>BestValue) { //保存更优的值和解 BestValue = CurValue; for(int i=0;i } } else { //遍历当前节点的子节点:0 不放入背包,1放入背包 for(int i=0;i<=1;++i) { x[t]=i; if(i==0) //不放入背包 { backtrack(t+1); } else //放入背包 { //约束条件:放的下 if((CurWeight+w[t])<=C) { CurWeight += w[t]; CurValue += v[t]; backtrack(t+1); CurWeight -= w[t]; CurValue -= v[t]; } } } //PS:上述代码为了更符合递归回溯的范式,并不够简洁 } } int main(int argc, char* argv[]) { backtrack(0); printf("最优值:%d\n",BestValue); for(int i=0;i { printf("最优解:%-3d",BestX[i]); } return 0; }
2. 旅行售货员问题
(图片参考)
一、问题描述
某售货员要到若干城市去推销商品,已知各城市之间的路程(或旅费)。他要选定一条从驻地出发,经过每个城市一次,最后回到驻地的路线,使总的路程(或总旅费)最小。
如下图:1,2,3,4 四个城市及其路线费用图,任意两个城市之间不一定都有路可达。
二、问题理解
1.分支限界法利用的是广度优先搜索和最优值策略。
2.利用二维数组保存图信息City_Graph[MAX_SIZE][MAX_SIZE]
其中City_Graph[i][j]的值代表的是城市i与城市j之间的路径费用 一旦一个城市没有通向另外城市的路,则不可能有回路,不用再找下去了
3. 我们任意选择一个城市,作为出发点。因为最后都是一个回路,无所谓从哪出发)
下面是关键思路:
想象一下,我们就是旅行员,假定从城市1出发,根据广度优先搜索的思路,我们要把从城市1能到达的下一个城市,都要作为一种路径走一下试试。
可是程序里面怎么实现这种“试试”呢?
利用一种数据结构,保存我们每走一步后,当前的一些状态参数,如,我们已经走过的城市数目(这样就知道,我们有没有走完,比如上图,当我们走了四个城市之后,无论从第四个城市是否能回到起点城市,都就意味着我们走完了,只是这条路径合不合约束以及能不能得到最优解的问题)。这里把,这种数据结构成为结点。这就需要另一个数据结构,保存我们每次试出来的路径,这就是堆。
数据结构定义如下:
Node{ int s; //结点深度,即当前走过了几个城市 int x[MAX_SIZE]; //保存走到这个结点时的路径信息}MiniHeap{ //保存所有结点并提供一些结点的操作}
a.我们刚开始的时候不知道最总能得到的路径是什么,所以我们,就认为按照城市编号的次序走一遍。于是有了第一个结点0,放入堆 中。相当于来到了城市1(可以是所有城市中的任意一个,这里姑且设为图中城市1)。
b.从城市1,出发,发现有三条路可走,分别走一下,这样就产生了三个结点,都放入堆中。
结点1 数据为:x[1 2 3 4],深度为s=1(深度为0表示在城市1还没有开始走),这说明,结点1是从城市1走来,走过了1个城 市,当前停在城市2,后面的城市3 和城市4都还没有走,但是具体是不是就按照3.4的顺序走,这个不一定。
结点2 数据为:x[1 3 2 4],深度为s=1,表示,从城市1走来,走过了1个城市,当前停在了城市3,后面2.4城市还没走
结点3 数据为:x[1 4 3 2],深度为s=1,表示,从城市1走来,走过了1个城市,当前停在了城市4,后面3.2城市还没有走c. 从堆中取一个结点,看看这个结点是不是走完了的,也就是要看看它的深度是不是3,是的话,说明走完了,看看是不是能回到起点,如果可以而且费用少于先前得到最优的费用,就把当前的解作为最优解。如果没有走完,就继续把这条路走下去。
以上就是简单的想法,而实际的程序中,堆还需要提供对结点的优先权排序的支持。而当前结点在往下一个城市走时,也是需要约束和限界函数,这些书上讲的很清楚,不懂,就翻翻书。有点要提出来说说的就是结点优先权和限界函数时都用到了一个最小出边和,就相当于把所有城市最便宜的一条路(边)费用加起来的值。代码:
#include #include using namespace std;//---------------------宏定义------------------------------------------#define MAX_CITY_NUMBER 10 //城市最大数目#define MAX_COST 10000000 //两个城市之间费用的最大值//---------------------全局变量----------------------------------------int City_Graph[MAX_CITY_NUMBER][MAX_CITY_NUMBER]; //表示城市间边权重的数组int City_Size; //表示实际输入的城市数目int Best_Cost; //最小费用int Best_Cost_Path[MAX_CITY_NUMBER]; //最小费用时的路径 //------------------------定义结点---------------------------------------typedef struct Node{ int lcost; //优先级 int cc; //当前费用 int rcost; //剩余所有结点的最小出边费用的和 int s; //当前结点的深度,也就是它在解数组中的索引位置 int x[MAX_CITY_NUMBER]; //当前结点对应的路径 struct Node* pNext; //指向下一个结点}Node;//---------------------定义堆和相关对操作--------------------------------typedef struct MiniHeap{ Node* pHead; //堆的头}MiniHeap;//初始化void InitMiniHeap(MiniHeap* pMiniHeap){ pMiniHeap->pHead = new Node; pMiniHeap->pHead->pNext = NULL;}//入堆void put(MiniHeap* pMiniHeap,Node node){ Node* next; Node* pre; Node* pinnode = new Node; //将传进来的结点信息copy一份保存 //这样在函数外部对node的修改就不会影响到堆了 pinnode->cc = node.cc; pinnode->lcost = node.lcost; pinnode->pNext = node.pNext; pinnode->rcost = node.rcost; pinnode->s = node.s; pinnode->pNext = NULL; for(int k=0;k pinnode->x[k] = node.x[k]; } pre = pMiniHeap->pHead; next = pMiniHeap->pHead->pNext; if(next == NULL){ pMiniHeap->pHead->pNext = pinnode; } else{ while(next != NULL){ if((next->lcost) > (pinnode->lcost)){ //发现一个优先级大的,则置于其前面 pinnode->pNext = pre->pNext; pre->pNext = pinnode; break; //跳出 } pre = next; next = next->pNext; } pre->pNext = pinnode; //放在末尾 } }//出堆Node* RemoveMiniHeap(MiniHeap* pMiniHeap){ Node* pnode = NULL; if(pMiniHeap->pHead->pNext != NULL){ pnode = pMiniHeap->pHead->pNext; pMiniHeap->pHead->pNext = pMiniHeap->pHead->pNext->pNext; } return pnode;}//---------------------分支限界法找最优解--------------------------------void Traveler(){ int i,j; int temp_x[MAX_CITY_NUMBER]; Node* pNode = NULL; int miniSum; //所有结点最小出边的费用和 int miniOut[MAX_CITY_NUMBER]; //保存每个结点的最小出边的索引 MiniHeap* heap = new MiniHeap; //分配堆 InitMiniHeap(heap); //初始化堆 miniSum = 0; for (i=0;i miniOut[i] = MAX_COST; //初始化时每一个结点都不可达 for(j=0;j if (City_Graph[i][j]>0 && City_Graph[i][j] //从i到j可达,且更小 miniOut[i] = City_Graph[i][j]; } } if (miniOut[i] == MAX_COST){// i 城市没有出边 Best_Cost = -1; return ; } miniSum += miniOut[i]; } for(i=0;i Best_Cost_Path[i] = i; } Best_Cost = MAX_COST; //初始化的最优费用是一个很大的数 pNode = new Node; //初始化第一个结点并入堆 pNode->lcost = 0; //当前结点的优先权为0 也就是最优 pNode->cc = 0; //当前费用为0(还没有开始旅行) pNode->rcost = miniSum; //剩余所有结点的最小出边费用和就是初始化的miniSum pNode->s = 0; //层次为0 pNode->pNext = NULL; for(int k=0;k pNode->x[k] = Best_Cost_Path[k]; //第一个结点所保存的路径也就是初始化的路径 } put(heap,*pNode); //入堆 while(pNode != NULL && (pNode->s) < City_Size-1){ //堆不空 不是叶子 for(int k=0;k Best_Cost_Path[k] = pNode->x[k] ; //将最优路径置换为当前结点本身所保存的 }/** * pNode 结点保存的路径中的含有这条路径上所有结点的索引* * x路径中保存的这一层结点的编号就是x[City_Size-2]* * 下一层结点的编号就是x[City_Size-1]*/ if ((pNode->s) == City_Size-2){ //是叶子的父亲 int edge1 = City_Graph[(pNode->x)[City_Size-2]][(pNode->x)[City_Size-1]]; int edge2 = City_Graph[(pNode->x)[City_Size-1]][(pNode->x)[0]]; if(edge1 >= 0 && edge2 >= 0 && (pNode->cc+edge1+edge2) < Best_Cost){ //edge1 -1 表示不可达 //叶子可达起点费用更低 Best_Cost = pNode->cc + edge1+edge2; pNode->cc = Best_Cost; pNode->lcost = Best_Cost; //优先权为 Best_Cost pNode->s++; //到达叶子层 } } else{ //内部结点 for (i=pNode->s;i if(City_Graph[pNode->x[pNode->s]][pNode->x[i]] >= 0){ //可达 //pNode的层数就是它在最优路径中的位置 int temp_cc = pNode->cc+City_Graph[pNode->x[pNode->s]][pNode->x[i]]; int temp_rcost = pNode->rcost-miniOut[pNode->x[pNode->s]]; //下一个结点的剩余最小出边费用和 //等于当前结点的rcost减去当前这个结点的最小出边费用 if (temp_cc+temp_rcost for (j=0;j temp_x[j]=Best_Cost_Path[j]; } temp_x[pNode->x[pNode->s+1]] = Best_Cost_Path[i]; //将当前结点的编号放入路径的深度为s+1的地方 temp_x[i] = Best_Cost_Path[pNode->s+1]; //?????????????? //将原路//径中的深度为s+1的结点编号放入当前路径的 //相当于将原路径中的的深度为i的结点与深度W为s+1的结点交换 Node* pNextNode = new Node; pNextNode->cc = temp_cc; pNextNode->lcost = temp_cc+temp_rcost; pNextNode->rcost = temp_rcost; pNextNode->s = pNode->s+1; pNextNode->pNext = NULL; for(int k=0;k pNextNode->x[k] = temp_x[k]; } put(heap,*pNextNode); delete pNextNode; } } } } pNode = RemoveMiniHeap(heap); }}int main(){ int i,j; scanf("%d",&City_Size); for(i=0;i for(j=0;j scanf("%d",&City_Graph[i][j]); } } Traveler(); printf("%d/n",Best_Cost); return 1;}
3.N皇后问题
问题:在n×n格的棋盘上放置彼此不受攻击的n个皇后。按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
N皇后问题等价于在n×n格的棋盘上放置n个皇后,任何2个皇后不放在同一行或同一列或同一斜线上。
分析:从n×n个格子中选择n个格子摆放皇后。可见解空间树为子集树。
使用Board[N][N]来表示棋盘,Board[i][j]=0 表示(I,j)位置为空,Board[i][j]=1 表示(I,j)位置摆放有一个皇后。
全局变量way表示总共的摆放方法数目。
使用Queen(t)来摆放第t个皇后。Queen(t) 函数符合子集树时的递归回溯范式。当t>N时,说明所有皇后都已经摆 放完成,这是一个可行的摆放方法,输出结果;否则,遍历棋盘,找皇后t所有可行的摆放位置,Feasible(i,j) 判断皇后t能否摆放在位置(i,j)处,如果可以摆放则继续递归摆放皇后t+1,如果不能摆放,则判断下一个位置。
Feasible(row,col)函数首先判断位置(row,col)是否合法,继而判断(row,col)处是否已有皇后,有则冲突,返回0,无则继续判断行、列、斜方向是否冲突。斜方向分为左上角、左下角、右上角、右下角四个方向,每次从(row,col)向四个方向延伸一个格子,判断是否冲突。如果所有方向都没有冲突,则返回1,表示此位置可以摆放一个皇后。
代码:
#include #define N 8 int Board[N][N];//棋盘 0表示空白 1表示有皇后 int way;//摆放的方法数 //判断能否在(x,y)的位置摆放一个皇后;0不可以,1可以 int Feasible(int row,int col) { //位置不合法 if(row>N || row<0 || col >N || col<0) return 0; //该位置已经有皇后了,不能 if(Board[row][col] != 0) { //在行列冲突判断中也包含了该判断,单独提出来为了提高效率 return 0; } // //下面判断是否和已有的冲突 //行和列是否冲突 for(int i=0;i { if(Board[row][i] != 0 || Board[i][col]!=0) return 0; } //斜线方向冲突 for(int i=1;i { /* i表示从当前点(row,col)向四个斜方向扩展的长度 左上角 \ / 右上角 i=2 \/ i=1 /\ i=1 左下角 / \ 右下角 i=2 */ //左上角 if((row-i)>=0 && (col-i)>=0) //位置合法 { if(Board[row-i][col-i] != 0)//此处已有皇后,冲突 return 0; } //左下角 if((row+i)=0) { if(Board[row+i][col-i] != 0) return 0; } //右上角 if((row-i)>=0 && (col+i) { if(Board[row-i][col+i] != 0) return 0; } //右下角 if((row+i) { if(Board[row+i][col+i] != 0) return 0; } } return 1; //不会发生冲突,返回1 } //摆放第t个皇后 ;从1开始 void Queen(int t) { //摆放完成,输出结果 if(t>N) { way++; /*如果N较大,输出结果会很慢;N较小时,可以用下面代码输出结果 for(int i=0;i for(int j=0;j printf("%-3d",Board[i][j]); printf("\n"); } printf("\n------------------------\n\n"); */ } else { for(int i=0;i { for(int j=0;j { //(i,j)位置可以摆放皇后,不冲突 if(Feasible(i,j)) { Board[i][j] = 1; //摆放皇后t Queen(t+1); //递归摆放皇后t+1 Board[i][j] = 0; //恢复 } } } } } //返回num的阶乘,num! int factorial(int num) { if(num==0 || num==1) return 1; return num*factorial(num-1); } int main(int argc, char* argv[]) { //初始化 for(int i=0;i { for(int j=0;j { Board[i][j]=0; } } way = 0; Queen(1); //从第1个皇后开始摆放 //如果每个皇后都不同 printf("考虑每个皇后都不同,摆放方法:%d\n",way);//N=8时, way=3709440 种 //如果每个皇后都一样,那么需要除以 N!出去重复的答案(因为相同,则每个皇后可任意调换位置) printf("考虑每个皇后都不同,摆放方法:%d\n",way/factorial(N));//N=8时, way=3709440/8! = 92种 return 0; }
PS:该问题还有更优的解法。充分利用问题隐藏的约束条件:每个皇后必然在不同的行(列),每个行(列)必然也只有一个皇后。这样我们就可以把N个皇后放到N个行中,使用Pos[i]表示皇后i在i行中的位置(也就是列号)(i = 0 to N-1)。这样代码会大大的简洁,因为节点的子节点数目会减少,判断冲突也更简单。
好叭,回溯法我们先讲到这里。
接下来,让我们来看看相对比较容易理解的第九个算法——构造法
构造法是指当解决某些数学问题使用通常方法按照定向思维难以解决问题时,应根据题设条件和结论的特征、性质,
从新的角度,用新的观点去观察、分析、理解对象,牢牢抓住反映问题的条件与结论之间的内在联系,运用问题的数据、外形、坐标等特征,
使用题中的已知条件为原材料,运用已知数学关系式和理论为工具,在思维中构造出满足条件或结论的数学对象,
从而,使原问题中隐含的关系和性质在新构造的数学对象中清晰地展现出来,并借助该数学对象方便快捷地解决数学问题的方法。
比如,我们可以通过构造法求出n的阶乘:
例9.1 求n的阶乘
求整数n的阶乘。n的阶乘是指从1到n的乘积,即n!=1*2*3...*n,n由键盘输入,n为正整数
Input
输入一个正整数n(0
Output
输出n的阶乘的结果
Sample Input
12
Sample Output
479001600
代码:
#includeusing namespace std;int main(){ int n,i; long p=1; for(int i=1;i<=n;i++) { p*=i; } printf("%ld\n",p); return 0;}
好,构造法我们先讲到这里。
我们先来简单入门一下第十个算法——动态规划法:
一、基本概念
动态规划过程是:每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。
二、基本思想与策略
基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。
在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。
与分治法最大的差别是:适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)。
三、适用的情况
能采用动态规划求解的问题的一般要具有3个性质:
(1) 最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。
(2) 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
(3)有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
四、求解的基本步骤
动态规划所处理的问题是一个多阶段决策问题,一般由初始状态开始,通过对中间阶段决策的选择,达到结束状态。这些决策形成了一个决策序列,同时确定了完成整个过程的一条活动路线(通常是求最优的活动路线)。如图所示。动态规划的设计都有着一定的模式,一般要经历以下几个步骤。
初始状态→│决策1│→│决策2│→…→│决策n│→结束状态
图1 动态规划决策过程示意图
(1)划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的,否则问题就无法求解。
(2)确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性。
(3)确定决策并写出状态转移方程:因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可写出。但事实上常常是反过来做,根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程。
(4)寻找边界条件:给出的状态转移方程是一个递推式,需要一个递推的终止条件或边界条件。
一般,只要解决问题的阶段、状态和状态转移决策确定了,就可以写出状态转移方程(包括边界条件)。
实际应用中可以按以下几个简化的步骤进行设计:
(1)分析最优解的性质,并刻画其结构特征。
(2)递归的定义最优解。
(3)以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值
(4)根据计算最优值时得到的信息,构造问题的最优解
五、算法实现的说明
动态规划的主要难点在于理论上的设计,也就是上面4个步骤的确定,一旦设计完成,实现部分就会非常简单。
使用动态规划求解问题,最重要的就是确定动态规划三要素:
(1)问题的阶段 (2)每个阶段的状态
(3)从前一个阶段转化到后一个阶段之间的递推关系。
递推关系必须是从次小的问题开始到较大的问题之间的转化,从这个角度来说,动态规划往往可以用递归程序来实现,不过因为递推可以充分利用前面保存的子问题的解来减少重复计算,所以对于大规模问题来说,有递归不可比拟的优势,这也是动态规划算法的核心之处。
确定了动态规划的这三要素,整个求解过程就可以用一个最优决策表来描述,最优决策表是一个二维表,其中行表示决策的阶段,列表示问题状态,表格需要填写的数据一般对应此问题的在某个阶段某个状态下的最优值(如最短路径,最长公共子序列,最大价值等),填表的过程就是根据递推关系,从1行1列开始,以行或者列优先的顺序,依次填写表格,最后根据整个表格的数据通过简单的取舍或者运算求得问题的最优解。
f(n,m)=max{f(n-1,m), f(n-1,m-w[n])+P(n,m)}
六、动态规划算法基本框架
代码for(j=1; j<=m; j=j+1) // 第一个阶段 xn[j] = 初始值; for(i=n-1; i>=1; i=i-1)// 其他n-1个阶段 for(j=1; j>=f(i); j=j+1)//f(i)与i有关的表达式 xi[j]=j=max(或min){g(xi-1[j1:j2]), ......, g(xi-1[jk:jk+1])};t = g(x1[j1:j2]); // 由子问题的最优解求解整个问题的最优解的方案print(x1[j1]);for(i=2; i<=n-1; i=i+1){ t = t-xi-1[ji]; for(j=1; j>=f(i); j=j+1) if(t=xi[ji]) break;}
接下来,我们通过数塔问题来简单介绍动态规划法:
如下图是一个数塔,从顶部出发在每一个节点可以选择向左或者向右走,一直走到底层,要求找出一条路径,使得路径上的数字之和最大.
数塔问题
思路分析:
这道题目如果使用贪婪算法不能保证找到真正的最大和。
在用动态规划考虑数塔问题时可以自顶向下的分析,自底向上的计算。
从顶点出发时到底向左走还是向右走应取决于是从左走能取到最大值还是从右走能取到最大值,只要左右两道路径上的最大值求出来了才能作出决策。同样的道理下一层的走向又要取决于再下一层上的最大值是否已经求出才能决策。这样一层一层推下去,直到倒数第二层时就非常明了。
所以第一步对第五层的8个数据,做如下四次决策:
如果经过第四层2,则在第五层的19和7中肯定是19;
如果经过第四层18,则在第五层的7和10中肯定是10;
如果经过第四层9,则在第五层的10和4中肯定是10;
如果经过第四层5,则在第五层的4和16中肯定是16;
经过一次决策,问题降了一阶。5层数塔问题转换成4层数塔问题,如此循环决策…… 最后得到1阶的数塔问题。
算法实现:首先利用一个二维数组data存储数塔的原始数据(其实我们只使用数组data一半的空间,一个下三角矩阵),然后利用一个中间数组dp存储每一次决策过程中的结果(也是一个下三角矩阵)。
初始化dp,将data的最后一层拷贝到dp中。dp[n][j] = data[n][j] (j = 1, 2, …, n) 其中,n为数塔的层数。
在动态规划过程汇总,我们有dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + data[i][j],最后的结果保存在dp[0][0]中。
对于上面的数塔,我们的data数组如下:
9 | ||||
12 | 15 | |||
10 | 6 | 8 | ||
2 | 18 | 9 | 5 | |
19 | 7 | 10 | 4 | 16 |
而我们的dp数组如下:
59 | ||||
50 | 49 | |||
38 | 34 | 29 | ||
21 | 28 | 19 | 21 | |
19 | 7 | 10 | 4 | 16 |
#include #include using namespace std;/************************************************************************//* 数塔问题 *//************************************************************************/const int N = 50;//为了算法写起来简单,这里定义一个足够大的数用来存储数据(为了避免运算过程中动态申请空间,这样的话算法看起来比较麻烦,这里只是为了算法看起来简单)int data[N][N];//存储数塔原始数据int dp[N][N];//存储动态规划过程中的数据int n;//塔的层数/*动态规划实现数塔求解*/void tower_walk(){ // dp初始化 for (int i = 0; i < n; ++i) { dp[n - 1][i] = data[n - 1][i]; } int temp_max; for (int i = n - 1; i >= 0; --i) { for (int j = 0; j <= i; ++j) { // 使用递推公式计算dp的值 temp_max = max(dp[i + 1][j], dp[i + 1][j + 1]); dp[i][j] = temp_max + data[i][j]; } }}/*打印最终结果*/void print_result(){ cout << "最大路径和:" << dp[0][0] << '\n'; int node_value; // 首先输出塔顶元素 cout << "最大路径:" << data[0][0]; int j = 0; for (int i = 1; i < n; ++i) { node_value = dp[i - 1][j] - data[i - 1][j]; /* 如果node_value == dp[i][j]则说明下一步应该是data[i][j];如果node_value == dp[i][j + 1]则说明下一步应该是data[i][j + 1]*/ if (node_value == dp[i][j + 1]) ++j; cout << "->" << data[i][j]; } cout << endl;}int main(){ cout << "输入塔的层数:"; cin >> n; cout << "输入塔的节点数据(第i层有i个节点):\n"; for (int i = 0; i < n; ++i) { for (int j = 0; j <= i; ++j) { cin >> data[i][j]; } } tower_walk(); print_result();}
运行结果:
上面的算法是按照最原始的思路进行书写的,其实还可以进行优化,这里不做详细说明。
下面官方的叙述下什么是动态规划:
动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。
20世纪50年代初美国数学家R.E.Bellman等人在研究多阶段决策过程(multistep decision process)的优化问题时,提出了著名的最优化原理(principle of optimality),把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。20世纪50年代初美国数学家R.E.Bellman等人在研究多阶段决策过程(multistep decision process)的优化问题时,提出了著名的最优化原理(principle of optimality),把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。(摘自百度百科)
动态规划处理的对象是针对多阶段决策问题。多阶段决策问题是指这样的一类特殊的活动过程:问题可以分解成若干个相互联系的阶段,在每一个阶段都要做出决策,形成一个决策序列,该决策序列也称为一个策略。每次决策依赖于当前状态,又随即引起状态的转移,决策序列(策略)都是在变化的状态中产生出来的,故有“动态”的含义。所以这种多阶段最优化决策解决问题过程称为动态规划。
对于每一个决策序列,可以在满足问题的约束条件下用一个数值函数(即目标函数)来衡量该策略的优劣。多阶段决策问题的最优化目标是获取导致问题最优值得最优决策序列,即得到最优解。(摘自《算法设计方法与优化》滕国文等编著)
好,动态规划法我们暂时讲到这里。
最后,放松时刻,来个小游戏吧(嘿嘿嘿)
(如果做出来,别忘了跟小编拿个红包哟)
(点赞是一种积极的生活态度,赞一个吧!)