《挑战程序设计竞赛》学习笔记(例题分析)

深度优先搜索

深度优先搜索(Depth-First Search,简称DFS)是最常用的搜索方法之一,它的思想为“一条路走到底”。例如要遍历下图

若以0为起点,则dfs的遍历路径为0->1->4->5,此时已无路可走,于是便开始回退,直到1的位置,然后再沿另外一个方向开始遍历1->6->2。

其算法常为如下结构

void dfs(int step){
    判断边界
 
    尝试每一种可能 for(i=1;i<=n;i++)
    {
        继续下一步 dfs(step+1);
    }
      
    返回
}

P30.部分和问题

给定整数a1,a2,...an,判断是否可以从中选出若干数,使它们的和恰好为k。

限制范围:1<=n<=20    -10^8<=ai<=10^8    -10^8<=k<=10^8

分析

我做了很久一直没做出来。。。一方面是因为很久没看算法了,一方面则是掌握的还是不行。后来分析原因,在于递归的使用方式没有用好。怎么个没有用好呢?

首先,可以明确的是,要遍历所有可能,那么每个数都面临两种可能 ,被选中或者没有被选中,我的问题就出在这里,我想不到如何做到这点。

这一步的实现途径就是通过下面这两行代码

if(dfs(i+1,sum))	return true;
if(dfs(i+1,sum+a[i]))	return true;

对两个情况进行两个判断,然后进行两个递归。

这是一种常见的方法,我也做过很多类似的,但是。。。可能是由于遗忘吧。

完整答案

#include<iostream>
using namespace std;

int a[20];
int n,k; 

bool dfs(int i,int sum){
	if(i == n)	return sum == k;
	if(dfs(i+1,sum))	return true;
	if(dfs(i+1,sum+a[i]))	return true;
	
	return false;
}

int main(){
	cout<<"n=";
	cin>>n;
	for(int i=0;i<n;i++)
		cin>>a[i];
	cout<<"k=";
	cin>>k;
	
	if(dfs(0,0))	cout<<"yes"<<endl;
	else cout<<"no"<<endl;
	
    return 0;
}

P32.Lake Counting

有一个大小为N*M的园子,雨后有积水。八连通的积水被认为是连接在一起的。请求出园子里总共有多少水洼?

八连通指下图的*部分

* * *

*w*

* * *

限制条件:N,M<=100

分析

我的想法和书上的答案基本相同,都是先遍历图中的每一个位置,然后对符合条件的位置进行dfs。不同之处在于我还设了一个标记数组,用于标记已经走过的位置,而答案则是将走过的位置都由“w”替换为“.”,不得不说,还是答案更为精妙。

完整答案

#include<iostream>
using namespace std;

int n,m;
char a[20][20];
int sum=0;

void dfs(int i,int j);

int main(){
//	读入数据 
	cin<<n<<m;
	for(int i=0;i<n;i++){
		for(int j=0;j<m;j++){
			cin<<a[i][j];
		}
	}
	
//	遍历地图 
	for(int i=0;i<n;i++){
		for(int j=0;j<m;j++){
			if(a[i][j] == 'w'){
				dfs(i,j);
				sum++;
				} 
			}
			
		}
	}
	cout<<"sum="<<sum<<endl;

	return 0;
} 

void dfs(int i,int j){
    a[i][j]='.';
	for(int x=-1;x<2;x++){
		for(int y=-1;y<2;y++){
			int ti=i+x;	
			int tj=j+y;
			if(b[ti][tj]==0 && ti>=0 && ti<n && tj>=0 && tj<m && a[ti][tj]=='w'){
				dfs(ti,tj);
			}
		}
	}
    return;
}

宽度优先搜索

P34 迷宫的最短路径

给定一个大小为N*M的迷宫。迷宫由通道和墙壁组成,每一步可以向邻接的上下左右四格的通道移动。请求出从起点到终点所需的最小步数。请注意,本题假定从起点一定可以移动到终点。

限制条件:N,M<=100

分析

 做这道题时我也是挺费劲的,我定义了一个全局的总距离sum,但是在不同路径的下,如何将sum正确的累加,这个问题我不能解决。于是看了答案,答案是定义了一个数组,用于记录对应位置距离。知道了这个后我继续做,最终做出来的答案和书上的答案大体相同,不过我用的是递归,书上用的是while循环。

还有一点值得掌握的是书上用pair这个结构来记录位置,非常方便,关于pair的具体用法,可以看这里

完整答案

#include<iostream>
#include<queue>
#define T 999
using namespace std;

int n,m;
char t[100][100];
int b[100][100];
typedef pair<int,int> p;
queue<p> que;
int tx[4]={-1,1,0,0};
int ty[4]={0,0,-1,1};
//终点坐标和起点坐标
int gx,gy;
int sx,sy;

int bfs(int i,int j);

int main(){
//	读入数据 
	cin>>n>>m;
	for(int i=0;i<n;i++){
		for(int j=0;j<m;j++){
			cin>>t[i][j];
		}
	}
	
//  用于标记没有走过的位置
	for(int i=0;i<n;i++)
		for(int j=0;j<m;j++)
			b[i][j]=T;

//	寻找入口和出口
	for(int i=0;i<n;i++){
		for(int j=0;j<m;j++){
			if(t[i][j]=='S'){
				sx = i;    sy = j;	
				b[i][j]=0;	
			}
			if(t[i][j]=='G'){
				gx = i;    gy = j;
			}
		}
	}
	
	cout<<bfs(sx,sy)<<endl;
	
	return 0;
}
 
 
int bfs(int i,int j){
//	如果到达终点,返回最小距离 
	if(t[i][j] == 'G')	return b[gx][gy];
	
//  向当前位置的上下左右依次尝试看能否前进
	for(int x=0;x<4;x++){
		int it=i+tx[x],jt=j+ty[x];
//		如果不是墙壁并且没有被标记过,则加入队列,并将距离加一 
		if(t[it][jt]!='#' && b[it][jt]==T && it>=0&&it<n&&jt>=0&&jt<m){
			que.push(p(it,jt));
//          将距离加一
			b[it][jt] = b[i][j]+1;
		}
	}
	
//  队列为空时还没有退出,说明没有找到终点。此时退出搜索,并返回0
	if(!que.empty()){
		p tmp = que.front();	que.pop();
		bfs(tmp.first,tmp.second);
	}else{
		return 0;
	}
}

贪心算法

P39 硬币问题

有1元,5元,10元,50元,100元,500元的硬币各C1,C5,C10,C50,C100,C500枚。现在要用这些硬币来支付A元,最少需要多少硬币?假定本题至少存在一种支付方案。

限制条件:0<=C1,C5,C10,C50,C100,C500<=10^9;    0<=A<=10^9.

 分析

如果要使用的硬币最少,则需要尽可能地使用面额大的硬币。这个思路很容易得出,我按着这个思路也写出了答案,但是和书上的答案相比之下,有些繁琐了,不过更容易理解,书上的代码简洁,但是不是一眼就能看懂的。在这里把我的答案和书上的答案都附上。

我的代码

#include<iostream>
using namespace std;

// c[]记录各个值的初始硬币数,k[]记录对应种类硬币使用的个数 
int c[6],k[6];
int v[6]={1,5,10,50,100,500};
int A;

int main(){
	for(int i=0;i<6;i++)
		cin>>c[i];
	cin>>A;
	
	int sum = 0;
//	从高价值的硬币的到低价值的硬币循环
	for(int i=5;i>=0;i--){
		while(sum+v[i]<=A){
			if(c[i]>0){
				sum += v[i];
				k[i]++;
				c[i]--;
			}else{
				break;
			} 
		}
		if(sum == A)
			break;
	}
	
	int t=0; 
	for(int i=0;i<6;i++){
		t+=k[i];
		cout<<v[i]<<":"<<k[i]<<endl; 
	}
	cout<<t;
	
	return 0;
} 

书上代码

// 硬币的面值 
const int v[6] = {1,5,10,50,100,500}

int c[6];
int A;

void solve(){
	int ans = 0;
	for(int i=5;i>=0;i--){
		int t = min(A/v[i],c[i]);	//使用硬币i的枚数 
		A-=t*v[i];
		ans+=t;
	}
	cout<<ans<<endl;
} 

P40 区间问题

有n项工作,每项工作分别在Si时间开始,在ti时间结束。对于每项工作,你都可以选择参与与否。如果选择了参与,那么自始至终都必须全程参与。此外,参与工作的时间段不能重叠(即使是开始的瞬间和结束的瞬间的重叠也是不允许的)。你的目标是参与尽可能多的工作,那么最多能参与多少项工作呢?

限制条件:1<=N<=100000;    1<=Si<=ti<=10^9

分析

这道题我没有思路,虽然知道使用贪心算法,但是不知道该依照什么样的规则。看了解析后明白了其贪心的规则为“在可选的工作中,每次都选取结束时间最早的工作”。其证明如下:

  1. 与其他选择方案相比,该算法的选择方案在选取了相同数量的更早开始的工作时,其最终结束时间不会比其他方案的更晚。
  2. 所以,不存在选取更多工作的选择方案。

知道了规则后我自己又做了一下,刚开始觉得对,但是后来发现还是不全面。先把代码放出来吧。

#include<iostream>
#include<fstream>
using namespace std;

int main(){
//	工作的开始时间和结束时间 
	int s[5]={1,2,4,6,8};
	int t[5]={3,5,7,9,10}; 
//	记录对于编号的作业是否能参与 
	int k[5]={0};
//	当前作业的编号 
	int p=0;
	k[p]=1;
	

//	对当前每一份工作和之后的所有工作相比较,依次查找出结束时间最短的作业 
	for(int i=0;i<5;i++){
//		寻找在当前工作结束后开始的工作
		if(s[i]>t[p]){
			int low = t[i];
			p=i;
			for(int j=i;j<5;j++){
				if(low>t[j]){
					low=t[j];
					p=j;
				}
			}
			k[p]=1;
		}
	}
	
	for(int i=0;i<5;i++)
		cout<<k[i]<<" ";
	
	return 0;
}

纰漏之处在于题目没有说明给的数据是排序过的,我直接把第一个作业看成了能参与的作业(K[p]=1),这样做等于默认它是按结束时间排序的。其他应该是没问题的,但是相对书上的答案复杂度高了些,而且代码更加的繁杂。

完整答案

const int MAX_N = 100000;

// 输入
int N,S[MAX_N],T[MAX_N];

// 用于对工作排序的pair数组
pair<int,int> itv[MAX_N];

void solve(){
    // 对pair进行的是字典序比较
    // 为了让结束时间早的工作排在前面,把T存入first,把s存入second
    for(int i=0;i<N;i++){
        itv[i].first = T[i];
        itv[i].second = S[i];
    }
    sort(itv,itv+N);
    
    // t是最后所选的结束时间
    int ans = 0,t = 0;
    for(int i=0;i<N;i++){
        if(t<itv[i].second){
            ans++;
            t=itv[i].first;
        }
    }
    cout<<ans<<endl;
}

P43 字典序最小问题:Best Cow Line

给定长度为N的字符串S,要构造一个长度为N的字符串T。起初,T是一个空串,随后反复进行下列任意操作。

  • 从S的头部删除一个字符,加到T的尾部
  • 从S的尾部删除一个字符,加到T的尾部

目标是要构造字典序尽可能小的字符串T。

限制条件:1<=N<=2000,    字符串S只包含大写英文字母

分析

刚开始想着只要不断通过比较S首尾的字母,把字典序小的加到T上就好了,但是后来发现事情并没有那么简单,因为当首尾为相同字母时,还要继续比较其次的字母,这个该怎么办?最后还是看的答案?。。。答案是用了两个用于标识的变量,我称之为“双指针”,因为像两个指针一样不断指着不同的位置,一个用于比较,一个用于输出。值得学习。

完整答案

#include<iostream>
using namespace std;

char s[20];
int n;

void solve(){
	int a=0,b=n-1;
	
	while(a<=b){
		bool left = false;
		
//		“双指针”,比较和输出是分开进行的,比较可以在范围内一直往下比较,但是输出是依次输出的
		for(int i=0;a+i<=b;i++){
			if(s[a+i]<s[b-i]){
				left = true;
				break;
			}else if(s[a+i]>s[b-i]){ 
				left = false;
				break;
			}
		}
		
		if(left) putchar(s[a++]);
		else putchar(s[b--]);
	}
	putchar('\n');
}

int main(){
	cin>>n;
	for(int i=0;i<n;i++){
		cin>>s[i];
	}
	
	solve();
		
	return 0;
} 

P45 Saruman's Army

直线上有N个点。点i的位置是Xi。从这N个点中选择若干个,给它们加上标记。对每一个点,其距离为R以内的区域里必须有带有标记的点(自己本身带有标记的点,可以认为与其距离为0的地方有一个标记的点)。在满足这个条件的情况下,希望能为尽可能少的点添加标记。请问至少要有多少点被加上标记?

限制条件:1<=N<=1000,  0<=R<=1000,  0<=Xi<=1000

 分析

找不到我写的的代码了,也可能是。。。我没做出来。就不分析了,直接附上书上的代码吧。

int N,R;
int X[MAX_N];

void solve(){
    sort(X,X+N);

    int i=0,ans=0;
    while(i<N){
        // s是没有被覆盖的最左的点的位置
        int s=X[i++];
        //一直向右前进直到距离大于R的点
        while(i<N && X[i]<=S+R)    i++;

        //p是新加上标记的点的位置
        int p=X[i-1];
        //一直向右前进到距p的距离大于R的点
        while(i<N && X[i]<=p+R)    i++;
        
        ans++;
    }
    cout<<ans<<endl;
}

P47 Fence Repair

农夫约翰为了修理栅栏,要将一块很长的木板切割成N块。准备切成的木板长度为L1,L2...LN,未切割前木板的长度恰好为切割后木板长度的总和。每次切断木板时,需要的开销为这块木板的长度。例如长度为21的木板要切成5,8,8的三块木板。长度21的木板切成长度为13和8的木板时,开销为21。再将长度为13的木板切成长度为5和8的木板时,开销是13.于是合计开销是34.请求出按照目标将木板切割完最小的开销是多少。

限制条件:1<=N<=20000,  0<=Li<=50000.

分析

最怕根本没有思路,而这题开始时我就是没有思路。现在是想通了,要想开销最小,那么就要先切割长度最长的分段,将长度最短的留在最后切割。因为最后切割的分段,其开销累积的次数也最多,所以自然要让它的长度最短。

以输入数据N=5,L={3,4,5,1,2}为例,整个切割过程对应了下列的二叉树

图片在书上48页

最后切割出的两段应该依次为当前最短的两段。这个二叉树清晰的体现了整个逻辑,当想到这个二叉树的时候,问题自然就解决了。开销的合计就是各叶子节点的 长度*深度。

我是直接根据这个二叉树结构写的代码,就是利用叶子节点数,求出其所在的深度,进而求出开销。

我的代码

#include<iostream>
#include<cmath>
#include<algorithm>
using namespace std;

int n;
int l[20];
// 二叉树的深度 
int h;
// 二叉树最后一层和倒数第二层的叶子的个数 
int a,b;
int sum;

void getnumber(int i);
void solve();

int main(){
	cin>>n;
	for(int i=0;i<n;i++)
		cin>>l[i];

	solve();
	
	cout<<sum<<endl; 
	
	return 0;
}

// 给定叶子数,返回最后一层的叶子数和次层的叶子数 
void getnumber(int x){
	for(int i=1;i<20;i++){
//		深度为i的二叉树最后一层的叶子数为2^(i-1) 
		if(n<pow(2,i-1)){
			h = i-1;
			a = (x-pow(2,i-2))*2;
			b = x-a;
			return;
		}
	}
} 

void solve(){
	sort(l,l+n);
	getnumber(n);
	
//	最后一层的叶子的开销 
	for(int i=0;i<a;i++){
		sum+=l[i]*h;
	}
//	倒数第二层的叶子的开销 
	for(int i=a;i<a+b;i++){
		sum+=l[i]*(h-1);
	}
}

而书上是依次令当前最短的两段相加,逻辑挺简单,但是代码写的我觉得有些复杂。

书上代码

typedef long long ll;
int N,L[MAX_N];

void solve(){
	ll ans = 0;
	
//	每次都找到最短的两段 
	while(N>1){
		int mii1 = 0,mii2 = 1;
		if(L[mii1]>L[mii2])	swap(mii1,mii2);	//使mii1小于mii2 
		
//		使当前最小值保存在mii1中,次小值保存在mii2中
		for(int i=2;i<N;i++){
			if(L[i]<L[mii1]){
				mii2 = mii1;
				mii1 = i;
			}else if(L[i]<L[mii2]){
				mii2 = i;
			}
		}
		
//		每次将最短的两段相加 
		int t = L[mii1] + L[mii2];
		ans += t;
		
		if(mii1 == N-1)	swap(mii1,mii2);
		L[mii1] = t;
		L[mii2] = L[N-1];
		N--;
	}
	cout<<ans<<endl;
}

根据这个逻辑,我又写了一次,感觉跟容易理解了,更简单了。

我的代码2

int N,L[MAX_N];

void solve(){
//	先排序,然后依次令最短的两段相加
	int ans=0;
	sort(L,L+N);
	int a=1,t=L[0]+L[1];
	while(N>1){
		ans+=t;
		a++;
		t += L[a];
		N--;
	}
	
	cout<<ans<<endl;
}

动态规划

动态规划算是进阶的算法了吧。我前前后后学了起码该有三遍了,但是还是没有熟练掌握。一方面是难度大,一方面还是练的少。

动态规划的思想是“大事化小”,不断的把当前面临的问题转化成更小的子问题,但是使用的时候往往逆过来用,就是由问题的最小子状态(边界)不断推出更大的状态,最后解决整个问题。

这里有几个动态规划里的基本概念

  1. 最优子结构:状态可被分解为的子状态
  2. 边界:无需在继续简化的最小子状态
  3. 状态转移公式:状态与其子状态间关系的描述公式

解决动态规划的问题也往往从这几个概念入手。

P51 01背包问题

有n个重量和价值分别为wi,vi的物品。从这些物品中挑选出总重量不超过W的物品,求所有挑选方案中价值总和的最大值

限制条件:1<=n<=100,  1<=wi,vi<=100,  1<=W<=10000

分析

书上给了三个思路,这里我就用第二个吧,因为第二个是我最理解的。

首先找最优子结构,即可以把当前的状态分解成更小的子状态的关系式。我们设一个数组dp[MAX_N][MAX_W]用来存储第i个物品时重量限制为j的方案。这样讲可能理解不了,看下面这个图吧。

i为当前物品的序号,第0个物品是不存在的,但是后面要用到,所以也表示出来。j是当前物品的重量限制。例如第一个物品的重量为2,价值为3,那么填入表中就是这样

表中的数据是从第i个物品开始挑选,总重小于等于j时总价值的最大值。第二物品的重量为1,价值为2,继续填入表中,如下

在上表中第二个物品重量为1,所以dp[2][1]=2。dp[2][2]=3,因为在重量限制为2的情况下,如果要价值最大,则应该选择第一个物品。dp[2][3]=5,是因为在重量限制为3的情况下,如果要价值最大,可以同时选择第一个物品和第二个物品。

第三个物品重量为3,价值为4。第四个物品重量为2,价值为2。依次填入表中。

由上面表格可以得出该题的边界为 dp[0][j]=0

状态转移方程为

推导出这些就可以开始写代码了。

完整答案

#include<iostream>
#include<algorithm>
using namespace std;

int n,W;
int w[1000],v[1000];
int dp[100][100];

int main(){
	cin>>n;
	for(int i=0;i<n;i++){
		cin>>w[i]>>v[i];
	}
	cin>>W;
	
	for(int i=0;i<=n;i++){
		for(int j=1;j<=W;j++){
			if(j<w[i]){
				dp[i+1][j]=dp[i][j];
			}else{
				dp[i+1][j]=max(dp[i][j],dp[i][j-w[i]]+v[i]);
			}
		}
	}
	cout<<dp[n][W];
	
	return 0;
}

P56 最长公共子序列问题

给定两个字符串s1s2...sn和t1t2...tn。求出这两个字符串最长的公共子序列的长度。字符串s1s2...sn的子序列指可以表示为si1si2...sin(i1<i2<...<in)的序列。

限制条件:1<=n,m<=1000

分析

我没做出来,原因是虽然知道用动态规划,但是dp[i][j]里的i和j代表什么找不出来。不止如此,我还找不出动态转移方程,动态规划解题的最大难点莫过于动态转移方程了。看了书上的分析后算是豁然开朗,i,j分别代表这个初始字符串的中字母的位置,把其状态分解步骤列个表,就如下

接下来寻找状态转移方程。最长公共子序列简称LCS(Longest Commom Subsequence),假设dp[i][j]对应s1s2...si和t1t2...tj的LCS,则

当s(i+1)=t(j+1)时, s1s2...s(i+1)和t1t2...t(j+1)的LCS长度为dp[i][j]+1。

当s(i+1) != t(j+1)时,s1s2...s(i+1)和t1t2...t(j+1)的LCS长度为max(dp[i][j+1],dp[i+1][j])。

即可得到如下递推关系

完整答案

#include<iostream>
#include<cmath>
using namespace std;

int N,M;
char s[100],t[100];
int dp[100][100];

int main(){
	cin>>N>>M;
	for(int i=0;i<N;i++)
		cin>>s[i];
	for(int i=0;i<N;i++)
		cin>>t[i];
	
	for(int i=0;i<=N;i++){
		for(int j=0;j<=M;j++){
			if(s[i+1]==t[j+1]){
				dp[i+1][j+1]=dp[i][j]+1;
			}else{
				dp[i+1][j+1]=max(dp[i+1][j],dp[i][j+1]);
			}
		}
	}
	cout<<dp[N][M]<<endl;
	
	return 0;	
}

P57 完全背包问题

有n个重量和价值分别为wi,vi的物品。从这些物品中挑选出总重量不超过W的物品,求所有挑选方案中价值总和的最大值。在这里,每种物品可以挑选任意多件。

限制条件:1<=n<=100,  1<=wi,vi<=100,  1<=W<=10000

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值