【题解】P5049 [NOIP2018 提高组] 旅行 加强版(图论,dfs,基环树,贪心)

【题解】P5049 [NOIP2018 提高组] 旅行 加强版

是道好题!

但是我不知道它和 DP 究竟有什么关系。。毕竟我是看见 DP 的 tag 才点进来做的。

另外评紫可能……有一点点高。(毕竟连我这种蒟蒻都能想到。。)


题目链接

P5049 [NOIP2018 提高组] 旅行 加强版 - 洛谷

题意概述

有一张 \(n\) 个点 \(m\) 条边的无向图,其中 \(m=n-1\)\(n\),求一条路径,满足:

  • 至少经过 \(1\)\(n\) 中所有点一次

  • 每条边最多被访问两次

  • 在访问这一条路径的过程中,每次将新遍历到的节点加入到一个序列序列中,使得这条路径形成的序列在所有符合条件的路径中字典序最小

思路分析

首先我们要学会观察数据范围。

(于是我这个傻逼因为没看数据范围挂了一下午。。)

可以发现,起点只可能是 \(1\),因为只有从 \(1\) 开始所经过的路径字典序才可能满足最小,这是显然的。

由于 \(m\) 只可能是 \(n-1\)\(n\)。那么我们可以直接分类讨论:

\(m=n-1\) 时:

显然这部分是一棵树,不可能”走回头路“,那么我们可以直接贪心的 dfs,每次选择一条边一直走到叶子节点然后返回走下一条边。假如当前走到了 \(x\),那么直接选与 \(x\) 连边的所有未访问的点中,字典序最小的即可。

正确性证明:

当这是一棵普通的树时,显然对于每个点,从起点 \(1\) 开始有且仅有一条路径经过这个节点,也就是说这个节点只能通过这一条路径被访问到。

那么如果没有走到叶子节点就走”走回头路“,那么叶子节点就永远也不可能被访问到。因为当前边在”走回头路“之后就已经被访问了两次。

所以每次选择最小的儿子走即可。

\(m=n\) 时:

这就相当于是在一棵树上,连了一条边,也就是说,这张图中有且仅有一个环,我们将其称之为基环树

显然我们不能再按照树上做法来做,原题样例 \(2\) 就是一个反例。

那么怎么办呢?

我们可以考虑,将整张图分为环上部分非环上部分来做。

显然对于非环上部分,我们可以直接像树上一样 dfs。

对于环上部分,由于整张图有且仅有一个环,并且由于求字典序最小,所以进入环的起点是固定的,那么这个环上一定有且仅有一条边未被走过,可以暴力枚举哪一条边是未被走过的边,然后直接 dfs 即可。

这个复杂度最坏情况下是 \(O(n^2)\) 的。

这个时间复杂度已经可以过掉原题数据了,但无法过掉加强版。

考虑如何优化。

相比于树上普通的 dfs,我们实际上就是加了一个“走回头路”的”反悔“操作。

那么我们可以考虑这个操作何时进行。

首先,在环上 dfs 过程中,只有当之前访问过的并且在环上的节点,有一个未被访问的儿子的节点编号小于当前将要访问的节点时,才有可能进行“反悔”操作。

其次,在“反悔”之前,当前节点的所有非环上儿子已经被访问过。因为这些节点不再环上,无法通过除了这个节点之外的其它节点被访问到。

最后,在一整张图中,由于只有一个环,所以”反悔“操作最多只能进行一次。

以上是分析过程,接下来我们来梳理一下进行反悔操作的条件:

  • 以前未“反悔”过;

  • 当前节点的所有非环上儿子都已经被访问过;

  • 之前访问过的并且在环上的节点,有一个未被访问的儿子的节点编号 \(p\) 小于当前将要访问的节点 \(q(p<q)\)

具体实现的过程中,我们可以在 dfs 的过程中搞一个小根堆,对于一个节点 \(x\),将其所有未被访问的儿子放入堆中,每次弹出最小的,然后判断是否满足“反悔”条件,若满足直接返回;反之则继续往下 dfs。

注意这里满足“反悔”条件时直接返回是可以的,因为总会返回到一个比它小的能访问的节点。

这一部分的代码如下:

void dfs2(int x,int fa,int now)//这里的 now 表示的是上一个已经被访问过的环上节点的未被访问过的儿子。
{
	priority_queue<int>q;
	cout<<x<<" ";
	vis[x]++;
	for(int y:edge[x])
	{
		if(y==fa||vis[y])continue;
		q.push(-y);//负值入队是大根堆转小根堆。
	}
	while(!q.empty())
	{
		int tt=-q.top();
		q.pop();
		if(!flag&&t[tt]&&q.empty()&&now<tt){flag=true;return ;}
        //flag 表示是否进行过“反悔”操作,t[] 存储的是节点是否在环上。
		if(!vis[tt])//若该节点当前仍然未被访问过则从该节点向下 dfs。
		{
			int kk=-q.top();
			if(!q.empty()&&t[x])dfs2(tt,x,kk);//如果 x 在环上并且还有其它儿子未被访问,则说明可以回溯到 x。
			else dfs2(tt,x,now);
		}
	}
}

除此之外,由于我们要分为环上和非环上两部分,所以我们还需要一个基环树上找环的操作。

我们可以从起点开始 dfs,然后每次访问到一个新的节点就将其标记为访问过。一旦访问到之前访问过的节点,就说明该节点在环上,那么直接回溯,在回溯到环上起点的过程中,把途中访问过的所有节点标记为环上节点即可。

这部分可能有点抽象,我们来看一张图:

这张图上的环是:\(3-2-5-4\)

我们找环的过程如下:

从起点 \(1\) 出发,vis[1]++

分别访问 \(3-2-5-4\),并将它们的 vis 加一;

然后访问 \(3\),此时由于 vis[3]!=0,所以标记 \(3\) 在环上:t[3]++

然后回溯,回溯的过程中经过 \(2-5-4\),并将它们标记在环上;

最后回到 \(3\),发现 \(3\) 本身在环上,说明找到了环,dfs 结束。

有人可能会问:那 \(6\)\(7\) 是干嘛的?难道遍历不到它们吗?

答:可能会被遍历到,但无论是否遍历它们,对找环并没有影响。

因为是否遍历它们取决于加边的顺序,比如在这张图中,若 \(2-5\)\(2-7\) 之后加边,那么就会先遍历到 \(7\),但遍历到 \(7\) 之后发现 \(7\) 不在环上会很快回溯。所以遍历它并不影响找环的过程。

基环树上找环到这里就结束了。

这部分代码如下:

void dfs(int x,int fa)
{
	vis[x]++;
	for(int y:edge[x])
	{
		if(y==fa)continue;
		if(vis[y])
		{
			t[y]++;
			flag=true;//flag 表示当前是否在环上。
			return ;
		}
		dfs(y,x);
		if(flag==true)
		{
			if(t[x])flag=false;//说明回到起点,将 flag 重新标记为 false。
			else t[x]++;
			return ;
		}
	}
}

时间复杂度:\(O(n \log n)\)

关键点

  • 想到最多会“反悔”一次;

  • 基环树上找环;

  • 弄清楚“反悔”条件。

代码实现

//luogu5022
#include<cstdio>
#include<iostream>
#include<cstring>
#include<string>
#include<queue>
#include<algorithm>
#define int long long
using namespace std;
const int maxn=5e5+10;
const int INF=0x3f3f3f3f;
int vis[maxn],t[maxn];
bool flag;

basic_string<int>edge[maxn];

inline int read()
{
	int x=0,f=1;char ch=getchar();
	while(ch<'0'||ch>'9'){if(ch=='-')f=-1;ch=getchar();}
	while(ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();}
	return x*f;
}

void dfs(int x,int fa)
{
	vis[x]++;
	for(int y:edge[x])
	{
		if(y==fa)continue;
		if(vis[y])
		{
			t[y]++;
//			t[x]++;
			flag=true;
			return ;
		}
		dfs(y,x);
		if(flag==true)
		{
			if(t[x])flag=false;
			else t[x]++;
			return ;
		}
	}
}

void dfs2(int x,int fa,int now)
{
	priority_queue<int>q;
	cout<<x<<" ";
	vis[x]++;
	for(int y:edge[x])
	{
		if(y==fa||vis[y])continue;
//		cout<<"ex "<<x<<" "<<y<<endl;
		q.push(-y);
	}
	while(!q.empty())
	{
		int tt=-q.top();
		q.pop();
//		cout<<x<<" "<<tt<<endl;
//		cout<<flag<<" "<<t[tt]<<" "<<now<<endl;
		if(!flag&&t[tt]&&q.empty()&&now<tt){flag=true;return ;}
		if(!vis[tt])
		{
			int kk=-q.top();
			if(!q.empty()&&t[x])dfs2(tt,x,kk);
			else dfs2(tt,x,now);
		}
//		cout<<"---------------"<<endl;
	}
}

signed main()
{
	int n,m;
	n=read();m=read();
	for(int i=1;i<=m;i++)
	{
		int u,v;
		u=read();v=read();
		edge[u]+=v;edge[v]+=u;
	}
//	for(int i=1;i<=n;i++)
//	{
//		sort(edge[i].begin(),edge[i].end());
//	}
	vis[1]++;
	dfs(1,0);
	memset(vis,0,sizeof(vis));
	flag=false;
	dfs2(1,0,INF);
	return 0;
}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值