【蓝桥杯】历届试题 邮局(深度优先搜索dfs、组合方案)

历届试题 邮局

问题描述

C 村住着 n n n 户村民,由于交通闭塞,C 村的村民只能通过信件与外界交流。为了方便村民们发信,C 村打算在 C 村建设 k k k 个邮局,这样每户村民可以去离自己家最近的邮局发信。
现在给出了 m m m 个备选的邮局,请从中选出 k k k 个来,使得村民到自己家最近的邮局的距离和最小。其中两点之间的距离定义为两点之间的直线距离。

输入格式

输入的第一行包含三个整数 n , m , k n, m, k n,m,k,分别表示村民的户数、备选的邮局数和要建的邮局数。
接下来 n n n 行,每行两个整数 x , y x, y x,y,依次表示每户村民家的坐标。
接下来 m m m 行,每行包含两个整数 x , y x, y x,y ,依次表示每个备选邮局的坐标。
在输入中,村民和村民、村民和邮局、邮局和邮局的坐标可能相同,但你应把它们看成不同的村民或邮局。

输出格式

输出一行,包含 k k k 个整数,从小到大依次表示你选择的备选邮局编号。(备选邮局按输入顺序由 1 到 m m m 编号)

样例输入

5 4 2
0 0
2 0
3 1
3 3
1 1
0 1
1 0
2 1
3 2

样例输出

2 4

数据规模和约定

对于 30% 的数据, 1 ≤ n ≤ 10 , 1 ≤ m ≤ 10 , 1 ≤ k ≤ 5 1 \le n \le 10,1 \le m \le 10,1 \le k \le 5 1n101m101k5
对于 60% 的数据, 1 ≤ m ≤ 20 1 \le m \le 20 1m20
对于 100% 的数据, 1 ≤ n ≤ 50 , 1 ≤ m ≤ 25 , 1 ≤ k ≤ 10 1 \le n \le 50,1 \le m \le 25,1 \le k \le 10 1n501m251k10



—— 分割线之初入江湖 ——


分析:

这道题的数据范围很有意思, n n n 最大是取到 50, m m m 最大是取到 25,而 k k k 最大是取到 10,均不大于 50。
于是我们的第一想法是能不能暴力求解(毕竟递归层次最多就在 25,并没有超过 30)?

怎么个暴力法?题目的意思不是让我们在 m m m 个候选位置中选 k k k 个么(这个正好是我们高中学的 “组合”),那么我们就把这里面所有可能的组合方案都列出来,并对其中的每一种组合都进行计算,以求得在这种情况下 k k k 个邮局距居民的最近总距离。

比如对于 m = 5 , n = 3 m=5,n=3 m=5,n=3(即在5个侯选位置中选 3 个位置建立邮局),此时根据组合公式我们可以得到总方案数为:

C 5 3 = 5 × 4 × 3 3 × 2 × 1 = 10 C_5^3=\frac{5\times4\times3}{3\times2\times1}=10 C53=3×2×15×4×3=10

这个数据量完全可以接受。当然,我们关心的问题是,具体的枚举方案是怎样的。

可以将其列出,如下所示(1 表示选中,0 表示未选中):
具体组合方案
上表展示的是这 10 种情况的具体安排细节。比如:

  • 对于第 1 种方案,其意思是选择位置为 1、2、3 的三个候选位置建设邮局;
  • 对于第 6 种方案,其意思是选择位置为 1、4、5 的三个候选位置建设邮局
  • ……

对于某种安排,比如方案 6,我们怎么求得其距离所有居民的最短距离呢?

很简单,我们只需要维护一个长度为当前录入的居民数的数组 l e n g t h [ n ] length[n] length[n] 即可,该数组的实际意义是:对于某个给定的组合方案, l e n g t h [ i ] length[i] length[i] 指示了在这些备选位置中,距离居民 i i i 最近的邮局到居民 i i i 的距离。

就拿题目给的测试数据来举例说明:

5 4 2
0 0
2 0
3 1
3 3
1 1
0 1
1 0
2 1
3 2

为了更形象的解释,下面将其进行绘制,其中:居民位置用 H i Hi Hi来表示,候选邮局位置用 P i Pi Pi 来表示:

样例数据的图例

在程序录入所有居民和邮局的位置后,我们需要将所有的邮局和居民之间的距离计算出来以供后面的使用(免得到时候又重复计算,浪费时间)。这里我们用数组 r a n g e [ m ] [ n ] range[m][n] range[m][n] 来存放这些信息, r a n g e [ i ] [ j ] range[ i ][ j ] range[i][j] 表示第 i i i 个邮局到第 j j j 个居民的距离。下面给出测试数据这个例子的 r a n g e range range 数组内容,如下表所示:

样例数据的range数组

假设现在我们的某个组合方案选择了候选邮局位置 P 2 P2 P2 P 3 P3 P3,那么此时求 l e n g t h length length数组的整个过程如下:

  • 初始化 l e n g t h length length 数组中的每个元素为一个很大的值,即: l e n g t h [ 5 ] = { I N F , I N F , I N F , I N F , I N F } length[5]=\{ INF,INF,INF,INF,INF \} length[5]={INF,INF,INF,INF,INF};
  • 判断当前组合方案选择的邮局能不能对 l e n g t h length length 数组中的每个元素进行更新:
  1. 首先是 P 2 P2 P2,由于 P 2 P2 P2 是第一个进来的,其距离各个居民的距离肯定比初始情况下的 l e n g t h length length 数组小,于是更新 l e n g t h length length 数组为 r a n g e range range 数组的第 2 行,即: l e n g t h [ 5 ] = { 1 , 1 , 1.414 , 3.606 , 1 } length[5]=\{ 1, 1 , 1.414 , 3.606 , 1 \} length[5]={1,1,1.414,3.606,1}; 这意味着,当你选择 P 2 P2 P2 所在位置作为邮局的建设位置时,此时距离 5 个居民的距离分别为: 1 、 1 、 1.414 、 3.606 、 1 1、1、1.414、3.606、1 111.4143.6061
  2. 接着是 P 3 P3 P3,此时我们把整个 r a n g e [ 3 ] [ 1 − n ] range[3][1-n] range[3][1n] 这一行都与 l e n g t h length length 数组进行对比,一旦出现某项 r a n g e [ i ] [ j ] range[ i ][ j ] range[i][j] 小于 l e n g t h [ j ] length[ j ] length[j],我们就更新 l e n g t h [ j ] = r a n g e [ i ] [ j ] length[ j ] = range[ i ][ j ] length[j]=range[i][j],于是得到: l e n g t h [ 5 ] = { 1 , 1 , 1 , 2.236 , 1 } length[5]=\{ 1 , 1 , 1 , 2.236 , 1 \} length[5]={1,1,1,2.236,1}; 这意味着,当你选择了 P 2 P2 P2 P 3 P3 P3 作为邮局的建设位置时,此时距离 5 个居民的最短距离分别为: 1 、 1 、 1 、 2.236 、 1 1、1、1、2.236、1 1112.2361。当然,这也是 l e n g t h length length 数组的最终具体情况。上述过程的代码描述为:
for(int j=1;j<=n;j++)
	if(range[row][j]<length[j])
		length[j]=range[row][j];

当执行完上面的流程后,我们就得到了在某个指定组合方案下的 l e n g t h length length 数组。此时我们再将 l e n g t h length length 数组中的所有值取出并累加进一个变量 s u m sum sum 中,用以判断当前的 l e n g t h length length 数组是否是最优解。显然,这里衡量是否为最优解的条件是 s u m sum sum 越小越好(这意味着你选择的邮局位置会使每个居民距其最近邮局的距离和最短)。此时,我们就不可避免地需要再用一个全局变量 m i n S u m minSum minSum 来存放在所有枚举情况中, s u m sum sum 的最小值。

比如在上面的举例中,首先是组合方案 { 2 , 3 } \{ 2,3 \} {2,3}(即选择邮局 P 2 P2 P2 P 3 P3 P3)的 l e n g t h length length 数组,此时我们将 l e n g t h length length 数组的结果进行累加,得到 s u m = 1 + 1 + 1 + 2.236 + 1 = 6.236 sum=1+1+1+2.236+1=6.236 sum=1+1+1+2.236+1=6.236。由于 s u m < m i n S u m sum < minSum sum<minSum,故更新 m i n S u m = s u m = 6.236 minSum=sum=6.236 minSum=sum=6.236(注: m i n S u m minSum minSum 最初需要被赋值为一个很大的值);

然后程序继续执行,当组合方案为 { 2 , 4 } \{ 2,4 \} {2,4}(即选择邮局 P 2 P2 P2 P 4 P4 P4)时,我们可以得到 l e n g t h length length 数组为: { 1 , 1 , 1 , 1 , 1 } \{ 1,1,1,1,1 \} {1,1,1,1,1},此时容易得到 s u m = 5 sum=5 sum=5,因为 s u m < m i n S u m sum<minSum sum<minSum,故更新 m i n S u m = 5 minSum=5 minSum=5

这样我们就动态地对最优解进行了 “跟踪”,当程序遍历完所有的组合方案后, m i n S u m minSum minSum 中存放的就是每个居民距其最近邮局的最短距离总和!

当然了,这不是本题的最终目的,我们的终极目标应该是是输出具体的邮局序号。这也不难,我们可以再设一个外部变量(vector) v 来存放当前的具体组合方案,每当 m i n S u m minSum minSum 更新时,就意味着 v v v 也需要更新。比如在 m i n S u m = 6.236 minSum=6.236 minSum=6.236 时, v = { 2 , 3 } v=\{ 2,3 \} v={2,3};而当 m i n S u m minSum minSum 更新为 5 时, v v v 则需要更新为 { 2 , 4 } \{ 2,4 \} {2,4}

最终随着组合方案的枚举结束,向量v里存放的也就是我们想要的答案了。



—— 分割线之大梦初醒 ——


总结一下具体步骤:

  1. 枚举各个组合方案;
  2. 在每个组合方案下,找到其对应的 l e n g t h length length 数组;
  3. 对于每个 l e n g t h length length 数组,求出其元素总和 s u m sum sum
  4. 判断 s u m sum sum 是否小于 m i n S u m minSum minSum:是则更新向量 v v v;否则继续执行 1 直到结束。

有同学肯定要问了,怎么枚举出具体的组合方案呢?

确实,求组合数固然简单,但是求具体的枚举方案却是一项技术活。
在上面的分析中,我们直接给出了某个具体的组合方案,比如我直接分析了选择序号 { 2 , 3 } \{ 2,3 \} {23} { 2 , 4 } \{ 2,4 \} {24} 的情形。现在我们的问题是,怎么枚举出所有的具体组合方案。

实际上,枚举具体的组合方案主要有两种方法:回溯法、数组打表。
数组打表的方法,就是通过循环的方式得到前面我们绘出的那个表格(见下图)。如果采用这个办法,尽管我们可以用滚动数组的方式来节约内存,但是我们却不可避免地会进行大量的重复运算。

具体组合方案

比如在方案一中,位置 1、2、3 被标记为选取,因此我们需要在这个情况下计算 l e n g t h length length 数组;而在方案 2 中,位置 1、2、4 被标记为选取,我们又需要在这个情况下重新计算 l e n g t h length length 数组。可实际上,方案 1 和方案 2 都含有位置 1、2,但我们在求 l e n g t h length length 数组的时候却忽略了这样一个事实,于是导致了重复计算 l e n g t h length length 数组中的前两项( l e n g t h [ 1 ] length[1] length[1] l e n g t h [ 2 ] length[2] length[2])。这给我们的程序带来了极大的时间浪费,显然是不可取的。

那回溯法呢?在利用回溯法求具体的组合方案时,我们其实就是在进行一个固定规律的搜索 (dfs)。而在搜索过程中,当你深入下一层的时候,实际上你是保存着上一次的状态的。这里的状态包含了很多信息,比如之前你遍历了哪些情况,以及在那些情况下计算出的相关结果!这不正好么?可以克服由于情况转变而带来的重复计算。因此,本题在对具体组合方案的枚举上,采用了搜索算法。

下面我将 “求具体组合方案以及组合总数” 的代码贴上,贴这个代码的目的很简单——这是求解本题的算法基础。当然,如果对这个算法很熟悉的同学你就可以跳过这一部分啦。

利用回溯法求具体组合方案的完整代码如下:

#include<iostream>
using namespace std;

const int N=100;
int ary[N];
int n,k;
int ans;

void dfs(int pos,int num)
{
	if(num==k){
		for(int i=1;i<=n;i++) cout<<ary[i]<<" ";
		cout<<endl;
		ans++;
		return;
	}else if(pos<=n){
		ary[pos]=1;
		dfs(pos+1,num+1);
		ary[pos]=0;
		dfs(pos+1,num);
	}
}

int main()
{
	cin>>k>>n;
	cout<<"具体方案有:"<<endl; 
	dfs(1,0);
	cout<<"总方案数为:"<<ans<<endl;
	return 0;
}

下面我们简单测试下,比如我在程序中输入3 5,程序输出的结果如下:

测试

对比前面的表格:

具体组合方案

你会发现结果是一致的。

好了,关于求解本题的步骤 1 到此就算是解决了。对于剩下的步骤 2、3、4,我们可以将他们放到这个搜索过程中实现,具体的实现方式和上面描述的一样,在此不再赘述,下面给出暴力枚举法的完整代码(附详细解释):

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

const int N=55;
const int M=30;
const int K=15;
const int INF=0x33333;
int n,m,k;
struct Point{						//定义结构体:坐标 
	int x,y;
	Point(){}
	Point(int a,int b):x(a),y(b){}	//定义构造函数 
};
Point house[N],post[M];				//house表示居民,post表示邮局 
float range[M][N];					//range[i][j]表示第i个邮局到第j户居民的距离 
float minSum;						//用于存放每个居民距其最近邮局的最短距离总和 	
vector<int> ans;

void insertData(int n,int m)		//录入基本数据
{
	int x,y;
	for(int i=1;i<=n;i++){
		cin>>x>>y;
		house[i]=Point(x,y);
	}
	for(int i=1;i<=m;i++){
		cin>>x>>y;
		post[i]=Point(x,y);
		for(int j=1;j<=n;j++)
			range[i][j]=sqrt(pow(x-house[j].x,2)+pow(y-house[j].y,2));
	}
	minSum=INF;						//给minSum赋初值 
}
void dfs(int order,int num,float length[],vector<int> v)	//order表示当前是第几个邮局,num表示当前已选了几个邮局,length数组用于存放当前方案下距离每个居民的最短距离,向量v存放当前方案中的具体邮局编号 
{
	if(num==k)								//表示当前已经选了k个邮局了
	{
		float sum=0;
		for(int i=1;i<=n;i++) sum+=length[i];
		if(sum<minSum){
			minSum=sum;
			ans=v;
		}
		return;
	}
	else if(order<=m)						//order<=m表示后面还有邮局可选 
	{
		bool flag=false;					//标记当前邮局是否能起到分担缩小距离的作用 
		float temp[N];
		memcpy(temp,length,sizeof(temp));
		for(int i=1;i<=n;i++){
			if(range[order][i]<length[i]){
				length[i]=range[order][i];
				flag=true;
			}
		}
		if(flag){							//是则可以修建第order个邮局 
			v.push_back(order);				//将其放进结果向量v中 
			dfs(order+1,num+1,length,v);	//然后继续往下寻找 
			v.pop_back();					//回退时将前面推进的第order个邮局退出 
		}
		dfs(order+1,num,temp,v);			//无论flag取值如何都可以不选当前邮局 
	}
}

int main()
{
	cin>>n>>m>>k;
	insertData(n,m);
	float length[N];
	for(int i=1;i<=n;i++) length[i]=INF;	//初始化length数组中的所有元素为一个很大的值 
	vector<int> v;							//初始化向量 
	dfs(1,0,length,v);						//从第1个邮局开始,当前未选任何邮局 
	for(int i=0;i<ans.size();i++) cout<<ans[i]<<" ";
	return 0;
}


—— 分割线之沧海横流 ——


上面的代码交上去只得了 80 分,剩下两组数据因为超时而过不了。
对于这个结果我只能说是意料之中,因为:

C 25 10 = 3268760 C_{25}^{10}=3268760 C2510=3268760

300 多万种情况下,枚举能做到这个份已经算是仁慈义尽了,鼓掌!!!接下来我们需要想办法优化,对于搜索而言也就是剪枝。

题目有这么一句话 “现在给出了 m m m 个备选的邮局,请从中选出 k k k 个来,使得村民到自己家最近的邮局的距离和最小”。那如果我们在 dfs 到某个深度的时候,发现剩下的邮局再加上之前选中的邮局都达不到 k k k 个,这种情况显然就不行。于是我们可以在 dfs 的一开始就进行一个判断,以检测是否会出现这样的情况,是就直接 return;否则才能继续 dfs。

这里肯定有一部分同学会这么想:虽然剩下的邮局再加上之前选中的邮局都达不到 k k k 个,但是如果在这样的情况下后面的邮局都无法为前面已选的邮局进行分担呢?题目又没有说最优解中的邮局一定能起到分担路程的作用,所以在这种情况下剩下的那些邮局修不修都无所谓啦!这样最终算出的 m i n S u m minSum minSum 都是一样的呀!所以这道题出得不好!辣鸡!

对于这种同学,我愿意将他称之为 “杠精”,并且送一句:“对不起,题目就是要选 k k k 个”。

由于剪枝主要是对 dfs 进行的,程序的其他地方并未做任何改动,故下面仅给出剪枝后 dfs 部分的代码:

void dfs(int order,int num,float length[],vector<int> v)
{
	if(m-(order-1)+num < k) return;			//如果剩下的邮局再加上之前选中的邮局都达不到k个,则此情况不行
	if(num==k)								//表示当前已经选了k个邮局了 
	{
		float sum=0;
		for(int i=1;i<=n;i++) sum+=length[i];
		if(sum<minSum){
			minSum=sum;
			ans=v;
		}
		return;
	}
	else if(order<=m)						//order<=m表示后面还有邮局可选 
	{
		bool flag=false;					//标记当前邮局是否能起到分担缩小距离的作用 
		float temp[N];
		memcpy(temp,length,sizeof(temp));
		for(int i=1;i<=n;i++){
			if(range[order][i]<length[i]){
				length[i]=range[order][i];
				flag=true;
			}
		}
		if(flag){							//是则可以修建第order个邮局 
			v.push_back(order);				//将其放进结果向量v中 
			dfs(order+1,num+1,length,v);	//然后继续往下寻找 
			v.pop_back();					//回退时将前面推进的第order个邮局退出 
		}
		dfs(order+1,num,temp,v);			//无论flag取值如何都可以不选当前邮局 
	}
}

改进后的代码得了 90 分,最后一组数据还是过不了!我们还需要继续优化!



—— 分割线之否极泰来 ——


试想,如果你在某次 dfs 的时候,发现有一个邮局没起到分担缩小距离的作用,那么这就意味着这个邮局在最优解中一定是不存在的。所以我们可以将其标记,以便之后再遇到这个邮局的时候,直接跳过。如此便可以让那个邮局所在的那一层递归树变为单子树,从而以折半的方式对整个程序进行优化。

下面给出改进后,最终的满分代码(含详细注释):

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

const int N=55;
const int M=30;
const int K=15;
const int INF=0x33333;
int n,m,k;
struct Point{						//定义结构体:坐标 
	int x,y;
	Point(){}
	Point(int a,int b):x(a),y(b){}	//定义构造函数 
};
Point house[N],post[M];				//house表示居民,post表示邮局 
float range[M][N];					//range[i][j]表示第i个邮局到第j户居民的距离 
float minSum;						//用于存放每个居民距其最近邮局的最短距离总和
bool unuseable[M];
vector<int> ans;

void insertData(int n,int m)		//录入基本数据
{
	int x,y;
	for(int i=1;i<=n;i++){
		cin>>x>>y;
		house[i]=Point(x,y);
	}
	for(int i=1;i<=m;i++){
		cin>>x>>y;
		post[i]=Point(x,y);
		for(int j=1;j<=n;j++)
			range[i][j]=sqrt(pow(x-house[j].x,2)+pow(y-house[j].y,2));
	}
	minSum=INF;						//给minSum赋初值 
}
void dfs(int order,int num,float length[],vector<int> v)	//order表示当前是第几个邮局,num表示当前已选了几个邮局,length数组用于存放当前方案下距离每个居民的最短距离,向量v存放当前方案中的具体邮局编号
{
	if(m-(order-1)+num < k) return;			//如果剩下的邮局再加上之前选中的邮局都达不到k个,则此情况不行
	if(num==k)								//表示当前已经选了k个邮局了 
	{
		float sum=0;
		for(int i=1;i<=n;i++) sum+=length[i];
		if(sum<minSum){
			minSum=sum;
			ans=v;
		}
		return;
	}
	else if(order<=m)						//order<=m表示后面还有邮局可选 
	{
		float temp[N];
		memcpy(temp,length,sizeof(temp)); 	//由于数组传递的是引用,因此这里必须复制一个副本 
		dfs(order+1,num,temp,v);			//不修建当前邮局 
		if(unuseable[order]) return;		//如果该邮局已被标记无法起到分担缩小距离的作用,那么直接跳过
		bool flag=false;					//标记当前邮局是否能起到分担缩小距离的作用
		for(int i=1;i<=n;i++){
			if(range[order][i]<length[i]){
				length[i]=range[order][i];
				flag=true;
			}
		}
		if(flag){							//是则可以修建第order个邮局 
			v.push_back(order);				//将其放进结果向量v中 
			dfs(order+1,num+1,length,v);	//然后继续往下寻找 
			v.pop_back();					//回退时将前面推进的第order个邮局退出 
		}
		else unuseable[order]=true;			//否则将其标记,永不录用 
	}
}

int main()
{
	cin>>n>>m>>k;
	insertData(n,m);
	float length[N];
	for(int i=1;i<=n;i++) length[i]=INF;	//初始化length数组中的所有元素为一个很大的值 
	vector<int> v;							//初始化向量 
	dfs(1,0,length,v);						//从第1个邮局开始,当前未选任何邮局 
	for(int i=0;i<ans.size();i++) cout<<ans[i]<<" ";
	return 0;
}




最后我有个小问题想请教各位:请看以下代码,这是最初我进行标记优化时写的 dfs 的代码,我个人认为这个 dfs 与最终满分代码中的 dfs 效果一致。但是结果却是:最后两组测试数据过不了,不是超时,而是错误!!!这里我希望路过的大神帮忙看一下,顺便给个合理的解释,谢谢啦!~~~
void dfs(int order,int num,float length[],vector<int> v)
{
	if(m-(order-1)+num < k) return;
	if(num==k)
	{
		float sum=0;
		for(int i=1;i<=n;i++) sum+=length[i];
		if(sum<minSum){
			minSum=sum;
			ans=v;
		}
		return;
	}
	else if(order<=m)
	{
		bool flag=false;
		float temp[N];
		memcpy(temp,length,sizeof(temp));
		if(unuseable[order]) goto label;
		for(int i=1;i<=n;i++){
			if(range[order][i]<length[i]){
				length[i]=range[order][i];
				flag=true;
			}
		}
		if(flag){								//如果可以就修建当前邮局 
			v.push_back(order);
			dfs(order+1,num+1,length,v);
			v.pop_back();
		}
		else unuseable[order]=true;
		label: dfs(order+1,num,temp,v);			//不修建当前邮局 
	}
}

欢迎各路大仙评论区指点,谢谢咯!!!


END


  • 6
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

theSerein

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值