本博客对以下6种经典算法及相关问题进行一个集合汇总。包含各种算法的基本思想、问题的思考思路,以及代码实现(C++)。
最后学习算法需要知道的事情:
时间复杂度大小排序:O(1) < O(logn) < O(n) < O(nlogn) < O(n^ 2) < O(n^ 3) < O(2^ n)
博主提醒您:不要畏难,每天进步一点点。
穷举法是算法设计中最简单也是最为“暴力”的一种算法,穷举法的基本思想就是**将问题的所有可能的答案一一列举(这就是穷举),然后根据条件判断此答案是否合适,合适就保留,不合适就丢弃。**例如:找出1到100之间的素数,就需要遍历1到100之间的所有整数,再一一进行判断。
#include
using namespace std;
int main ()
{
int x,y,z;//x为鸡翁,y为鸡母,z为鸡雏
for(x=0;x<=20;x++){
for(y=0;y<=33;y++){
for(z=0;z<=100;z+=3){
if(x*5+y*3+z/3==100&&x+y+z==100){
cout<<"x="<<x<<" y="<<y<<" z="<<z<<endl;
}
}
}
}
return 0;
}
#include
using namespace std;
int main()
{
int a[10]={
1,3,4,6,8,9,13,16,20,25};
int x;
cin>>x;
int left=0; //初始化到最左端
int right=10-1; //初始化到最又端
while(left<=right){
int middle=(left+right)/2;
if(a[middle]==x)
{
//查找成功
cout<<middle;
return middle;
}
if(x>a[middle])
{
//x大于中间值,说明查找范围在中间值右边,左端点靠过来
left=middle+1;
}else
{
//x小于中间值,说明查找范围在中间值左边,右端点靠过来
right=middle-1;
}
}
cout<<-1;
return 0;
}
#include
using namespace std;
void Copy(int a[],int b[],int left,int right)
{
//将b[0]至b[right-left+1]拷贝到a[left]至a[right]
int size=right-left+1;
for(int i=0; i<size; i++) {
a[left++]=b[i];
}
}
void Merge(int a[],int b[],int left,int i,int right)
{
//合并有序数组a[left:i],a[i+1:right]到b,得到新的有序数组b
int a1cout=left, //指向第一个数组开头
a1end=i, //指向第一个数组结尾
a2cout=i+1, //指向第二个数组开头
a2end=right, //指向第二个数组结尾
bcout=0; //指向b中的元素
for(int j=0; j<right-left+1; j++) {
//执行right-left+1次循环,数组
if(a1cout>a1end) {
b[bcout++]=a[a2cout++];
continue;
} //如果第一个数组结束,拷贝第二个数组的元素到b
if(a2cout>a2end) {
b[bcout++]=a[a1cout++];
continue;
} //如果第二个数组结束,拷贝第一个数组的元素到b
if(a[a1cout]<a[a2cout]) {
b[bcout++]=a[a1cout++];
continue;
} //如果两个数组都没结束,比较元素大小,把较小的放入b
else {
b[bcout++]=a[a2cout++];
continue;
}
}
}
void MergeSort(int a[],int left,int right)
{
//对数组a[left:right]进行合并排序
int *b=new int[right-left+1];
if(left<right) {
int i=(left+right)/2;//取中点
MergeSort(a,left,i);//左半边进行合并排序
MergeSort(a,i+1,right);//右半边进行合并排序
Merge(a,b,left,i,right);//左右合并到b中
Copy(a,b,left,right);//从b拷贝回来
}
}
int main()
{
int n=10;
int a[]={
5,8,2,9,4,6,3,1,10,7};
MergeSort( a, 0, n-1);
for(int j=0; j<n; j++) {
cout<<" "<<a[j];
}
return 1;
}
#include
using namespace std;
int Partition(int a[],int p,int r){
int i=p,j=r+1;
int x=a[p];
//将小于x的元素交换到左边区域,将大于x的元素交换到右边区域
while(true){
while(a[++i]<x&&i<r); //直到找到左边存在大于x的元素停止
while(a[--j]>x); //直到找到右边存在小于x的元素停止
if(i>=j){
//用来结束while循环
break;
}
//交换两个找到的元素的位置
swap(a[i],a[j]);
}
//此时已经找到了原来划分点x正确的位置,为a[j]
a[p]=a[j]; //将以前雀占鸠巢的元素值放在a[p]上
a[j]=x; //x回到正确的位置
return j; //从这里继续开始划分
}
void QuickSort(int a[],int p,int r){
if(p<r){
int q=Partition(a,p,r);
//对左半段排序
QuickSort(a,p,q-1);
//对右半段排序
QuickSort(a,q+1,r);
}
}
int main()
{
int a[]={
5,8,2,9,4,6,3,1,10,7};
int n=10;
QuickSort(a,0,n-1);
for(int i=0;i<n;i++){
cout<<a[i]<<" ";
}
return 0;
}
#include
using namespace std;
void Table(int k,int **a){
int n=1;
for(int i=1;i<=k;i++){
n*=2;
}
for(int i=1;i<=n;i++){
a[1][i]=i;
}
int m=1;
for(int s=1;s<=k;s++){
n/=2;
for(int t=1;t<=n;t++){
for(int i=m+1;i<=2*m;i++){
for(int j=m+1;j<=2*m;j++){
a[i][j+(t-1)*m*2]=a[i-m][j+(t-1)*m*2-m];
a[i][j+(t-1)*m*2-m]=a[i-m][j+(t-1)*m*2];
}
}
}
m*=2;
}
}
int main()
{
//假设选手人数为8人
int n=9;
int k=3;
//创建一个n行n列的数组,但只用到了[1:n-1][1:n-1]
int **a=new int*[n];
for(int i=0;i<n;i++){
a[i]=new int[n];
}
Table(k,a);
for(int i=1;i<n;i++){
for(int j=1;j<n;j++){
cout<<a[i][j]<<" ";
}
cout<<endl;
}
return 0;
}
关于循环日程表的问题,如果还有疑惑,请参考这篇博客循环日程表解析
动态规划是用来解决多阶段决策过程最优化的一种数量方法。其特点在于,它可以把一个n维决策问题变换为几个一维最优化问题,从而一个一个地去解决。
需要指出:动态规划是求解某类问题的一种方法,是考察问题的一种途径,而不能仅仅把它当做一种算法。必须对具体问题进行具体分析,运用动态规划的原理和方法,建立相应的模型,然后再用动态规划方法去求解。
动态决策问题的特点:系统所处的状态和时刻是进行决策的重要因素;即在系统发展的不同时刻(或阶段)根据系统所处的状态,不断地做出决策;找到不同时刻的最优决策以及整个过程的最优策略。
多阶段决策问题:是动态决策问题的一种特殊形式;在多阶段决策过程中,系统的动态过程可以按照时间进程分为状态相互联系而又相互区别的各个阶段;每个阶段都要进行决策,目的是使整个过程的决策达到最优效果。
动态规划算法的基本要素:
动态规划算法与分治法类似,其基本思想是将待求解问题分解成若干子问题,先求解子问题,再结合这些子问题的解得到原问题的解。与分治法不同的是,适合用动态规划法求解的问题经分解得到的子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,以致最后解决原问题需要耗费指数级时间。然而,不同子问题的数目常常只有多项式量级。在用分治法求解时,有些子问题被重复计算了许多次。如果能够保存已解决的子问题的答案,在需要的时再找出已求得的答案,这样可以避免大量的重复计算,从而得到多项式时间算法。为达到此目的,可以用一个表来记录所有已解决的子问题的答案。这就是动态规划的基本思想。
动态规划算法适用于解最优化问题,通常可以按以下4个步骤设计(1~3为动态规划算法的基本步骤):
#include
#include
char a[500],b[500];
char num[501][501]; ///记录中间结果的数组
char flag[501][501]; ///标记数组,用于标识下标的走向,构造出公共子序列
void LCS(); ///动态规划求解
void getLCS(); ///采用倒推方式求最长公共子序列
int main()
{
int i;
strcpy(a,"ABCBDAB");
strcpy(b,"BDCABA");
memset(num,0,sizeof(num));
memset(flag,0,sizeof(flag));
LCS();
printf("%d\n",num[strlen(a)][strlen(b)]);
getLCS();
return 0;
}
void LCS()
{
int i,j;
for(i=1;i<=strlen(a);i++)
{
for(j=1;j<=strlen(b);j++)
{
if(a[i-1]==b[j-1]) ///注意这里的下标是i-1与j-1
{
num[i][j]=num[i-1][j-1]+1;
flag[i][j]=1; ///斜向下标记
}
else if(num[i][j-1]>num[i-1][j])
{
num[i][j]=num[i][j-1];
flag[i][j]=2; ///向右标记
}
else
{
num[i][j]=num[i-1][j];
flag[i][j]=3; ///向下标记
}
}
}
}
void getLCS()
{
char res[500];
int i=strlen(a);
int j=strlen(b);
int k=0; ///用于保存结果的数组标志位
while(i>0 && j>0)
{
if(flag[i][j]==1) ///如果是斜向下标记
{
res[k]=a[i-1];
k++;
i--;
j--;
}
else if(flag[i][j]==2) ///如果是斜向右标记
j--;
else if(flag[i][j]==3) ///如果是斜向下标记
i--;
}
for(i=k-1;i>=0;i--)
printf("%c",res[i]);
}
1 void FindMax()//动态规划
2 {
3 int i,j;
4 //填表
5 for(i=1;i<=number;i++)
6 {
7 for(j=1;j<=capacity;j++)
8 {
9 if(j<w[i])//包装不进
10 {
11 V[i][j]=V[i-1][j];
12 }
13 else//能装
14 {
15 if(V[i-1][j]>V[i-1][j-w[i]]+v[i])//不装价值大
16 {
17 V[i][j]=V[i-1][j];
18 }
19 else//前i-1个物品的最优解与第i个物品的价值之和更大
20 {
21 V[i][j]=V[i-1][j-w[i]]+v[i];
22 }
23 }
24 }
25 }
26 }
当一个问题具有最优子结构性质时,可用动态规划法求解。但有时会有更简单有效的算法。现在我们来介绍另一种经典算法——贪心算法。
顾名思义,贪心算法总是做出在当前看来是最好的选择。也就是说,贪心算法并不从整体最优上加以考虑,所做的选择只是在某种意义上的局部最优选择。贪心算法中,较大子问题的解恰好包含了较小子问题的解作为子集,这与动态规划算法设计中的优化原则本质上是一致的。
贪心算法的一般框架:
GreedyAlgorithm(parameters){
初始化;
重复执行以下操作:
选择当前可以选择的最优解;
将所选择的当前解加入到问题的解中;
直至满足问题求解的结束条件。
}
i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
s[i] | 1 | 3 | 0 | 5 | 3 | 5 | 6 | 8 | 8 | 2 | 12 |
f[i] | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
使用策略:早完成的活动先安排
把活动按照截止时间从小到大排序,使得f1<=f2<=…<=fn,然后从前向后挑选,只要与前面选择的活动相容,便将这项活动选入最大相容集合A。该算法的贪心选择的意义是:使剩余的可安排时间段极大化,以便安排尽可能多的相容活动。
#include
using namespace std;
//注意活动的排序是按照结束时间非递减排序
void GreedySelector(int n,int s[],int f[],bool A[]){
//进行活动选择,将是否选择活动的情况存入bool数组A中
//从第一个活动开始(第一个活动必选)
A[0]=true;
int j=0; //当前活动索引
for(int i=1;i<n;i++){
//遍历从第2个活动开始的所有活动,i是下一个等待判断的活动索引
if(s[i]>=f[j]){
//如果索引为i的活动起始时间大于当前活动的结束时间(当前活动已结束)
A[i]=true; //选定索引为i的活动
j=i; //将当前活动置为索引为i的活动
}else{
//如果索引为i的活动起始时间小于当前活动的结束时间(当前活动未结束)
A[i]=false; //不选定
}
}
}
int main()
{
int n=11;
int s[n]={
1,3,0,5,3,5,6,8,8,2,12};
int f[n]={
4,5,6,7,8,9,10,11,12,13,14};
bool A[n]={
false};
GreedySelector(n,s,f,A);
cout<<"选定了活动:";
for(int i=0;i<n;i++){
if(A[i]){
cout<<i+1<<" ";
}
}
return 0;
}
template<class T>
BinaryTree<int>HuffmanTree(T f[],int n){
//根据权f[1:n]构造哈夫曼树
//创建一个单结点数的数组
Huffman<T>*W=new Huffman<T>[n+1];
BinaryTree<int> z,zero;
for(int i=1;i<=n;i++){
z.MakeTree(i,zero,zero);
W[i].weight=f[i];
W[i].tree=z;
}
//数组变成一个最小堆
MinHeap<Huffman<T>>Q(1);
Q.Initialize(w,n,n);
//将堆中的树不断合并
Huffman<T>x,y
for(i=1;i<n;i++){
Q.DeleteMin(x);
Q.DeleteMin(y);
z.MakeTree(0,x.tree,y.tree);
//合并权
x.weight+=y.weight;
x.tree=z;
Q.Insert(x);
}
Q.DeleteMin(x); //最后的树
Q.Deactivate();
delete[] w;
return x.tree;
}
Procedure Dijkstra{
S:={
1}; //初始化S
for i:=2 to n do //初始化dis[]
dis[i]=C[1,i] //初始时为源到顶点i一步的距离
for i:=1 to n do{
从V-S中选取一个顶点u使得dis[u]最小;
将u加入到S中; //将新的最近者加入S
for w ∈V-S do{
//依据最近者u修订dis[w]
//这句话是Dijkstra的关键!!!选取最小距离并更新dis[]
dis[w]:=min(dis[w],dis[u]+C[u,w]);
}
}
}
Kruskal(int n,*e){
Sort(e,w); //将边按权重从小到大排序
Initialze(n); //初始时每个顶点为一个集合
k=1; //k累计已选边的数目
j=1; //j为所选的边在e中的序号
while(k<n){
//选择n-1条边
a=Find(e[j].u);b=Find(e[j].v); //找出第j条边的两个端点所在的集合
if(a!=b){
//若不同,第j条边放入树中并合并这两个集合
T[k]=j;
Union(a,b);
k=k+1;
}
j++; //继续考察下一条边(仍按权重从小到大遍历)
}
}
#include
using namespace std;
void Knapsack(int n,float C,float v[],float w[],float x[]){
//Sort(n,v,w); //因为我们初始化的时候已经排好序,这里省略
int i;
for(i=1;i<=n;i++){
//每种物品选取比例,初始化为0
x[i]=0;
}
for(i=1;i<=n;i++){
if(w[i]>C){
//当前单位重量价值最大的物品,物品重量大于背包容量
break;
}
//当前单位重量价值最大的物品,物品重量不大于容量C,全部装入
x[i]=1;
C-=w[i]; //剩余容量减小w[i]
}
if(i<=n){
//说明触发了上面for循环中的break,直接用物品i填满剩下容量C即可
x[i]=C/w[i];
}
}
int main()
{
int n=3; //3件物品
float C=20; //容量20
//初始化,物品需要按单位重量价值非递减排序x1>x2>x3
float v[4]={
0,24,15,25}; //因为是从索引1开始,所以v[0]用0填充
float w[4]={
0,15,10,18}; //重量同理
float x[4]={
0}; //每种物品选取比例,初始化为0
Knapsack(n,C,v,w,x);
cout<<"物品选取比例情况为:";
for(int i=1;i<=n;i++){
cout<<x[i]<<" ";
}
return 0;
}
贪心算法是从初态出发,逐步递增扩大解,最后扩大为完整解。每次扩大时,都要在若干方案中选择一定的扩大方式,选择的依据是当前状态下某种意义的最优选择。这种选择与其他步骤(其他子解)无关,这也是与动态规划法的重要区别!这是一种只顾当前“利益”的方法,即保证当前是最优解的,这就是“贪心”叫法的来历。显然,这种只顾当前利益的做法,不一定总能获得最好的全局利益。因此,使用贪心算法时要特别注意。
寻找问题的解的一种可靠方法是:首先列出所有候选解,然后依次检查每一个,在检查完所有或部分候选解后,即可找到所需要的解。但是,只有当一个问题候选解数量有限,并且通过检查所有或部分候选解能够得到所需解时,上述方法才是可行的。根据这个思想,产生了回溯法和分支限界法这两种对候选解进行系统检查的方法。
回溯法的基本做法是搜索,一种组织得井井有条的、能避免不必要搜索的穷举式搜索法。回溯法在问题的解空间树中,按深度优先策略,从根结点出发搜索解空间树。
算法搜索至解空间树任意一点时,先判断该结点是否包含问题的解:
回溯法的基本步骤:
常用的剪枝函数:
空间复杂性分析:
值得注意:
在一个N*N的国际象棋棋盘上放置n个皇后,使得它们中任意两个都不互相“攻击”,即任意两个皇后不可在同一行、同一列、同一斜线上。
输入:皇后的数量n
输出:x种摆放方法
分析:
代码如下(递归解法):
#include
#include
#include
using namespace std;
const int maxn=105;
int tot,n;
int c[maxn];
void search_queen(int cur){
if(cur==n)
{
tot++; //d递归边界。只要走到这所有皇后必然不冲突
}
else
{
for(int i=0;i<n;i++){
int ok=1;
c[cur]=i; //尝试把第cur行的黄后放到第i列
for(int j=0;j<cur;j++){
//检查是否与之前放好的皇后冲突,横向上肯定不会冲突,因此只需要检查纵向,斜向
if(c[cur]==c[j]||cur-c[cur]==j-c[j]||cur+c[cur]==j+c[j]){
ok=0;
break;
}
}
if(ok) search_queen(cur+1); //如果此位置合法,继续递归寻找下一个皇后的位置
}
}
}
int main()
{
cout<<"请输入皇后的个数(注意:n不要太大哟!):n=";
cin>>n;
search_queen(0);
cout<<n<<"皇后所有解的个数为 :"<<tot<<endl;
return 0;
}
搜索算法的类型:
分支限界法简介:
(广度优先搜索+剪枝优化)
分类:
分支限界法的一般解题步骤为:
分支限界法的时间性能:分支限界法和回溯法实际上都属于蛮力穷举法,遍历具有指数阶个结点的解空间树,在最坏情况下,时间复杂性肯定为指数阶。与回溯法不同的是,分支限界法首先扩展解空间树中的上层结点,并采用限界函数,有利于实行大范围剪枝,同时,**根据限界函数不断调整搜索方向,选择最有可能取得最优解的子树优先进行搜索。**所以,如果选择了结点的合理扩展顺序以及设计了一个好的限界函数,分支限界法可以快速得到问题的解。
分支限界法与回溯法的比较: