1.最短路的变式
(1)逆向思维
请思考:给一个有向图,单源最短路径算法(dij,spfa)可以很轻松处理从单起点到多终点的最短距离,那么,如果要求多起点到单终点的最短距离呢?(不要说跑n次dij)
分析:假设要问从x到1的最短路,为x->a->b->c->1,也就是说x->a,a->b,b->c,c->1都有路可走,那么我们想想,从x开始x->a,a->b,b->c,c->1的最短路不就是从1开始1->c,c->b,b->a,a->x的最短路吗?于是这时,我们把x->a,a->b,b->c,c->1这4条路径变为1->c,c->b,b->a,a->x,然后从1开始跑最短路,而它们的最短路是一样的
总结:我们把这样从多到一的最短路变式的路径反转操作称作“反向建边”
(附建反边最短路模板题练手):Cow Party S 、邮递员送信 、请柬 (三倍经验)
那么,noip的考题中是如何运用建反边思想的呢?
题意:找到从x到y的最短路,要求路径上的所有点的出边所指向的点都直接或间接与终点连通
思考:
做什么?要求路径上所有点与终点连通--->删除不与终点连通的点,以及与他们连一条边的点
怎么做?要找倒着从终点出发,把图搜完到不了的点--->反向建边,给搜索到的点打标记,然后枚举所有没打标记的点,枚举他们的连边消去标记
<细节>注意要另开一个标记数组,因为直接消去标记的话会对后面产生影响,删除一个不与终点连接的点后,下一次搜到那个点还会删除一次
最后跑一遍最短路即可
#include <bits/stdc++.h>
using namespace std;
int n,m,x,y,s,t,head[500005],cnt,dis[500005];
bool vis[500005],vis2[500005],okk[500005],ok[500005];
queue<int> q,qq;
struct node{int to,nxt;}e[500005];
void insert(int u,int v){
e[++cnt].nxt=head[u];e[cnt].to=v;head[u]=cnt;
}
void bfs(){
ok[t]=1;qq.push(t);vis[t]=1;
while(!qq.empty()){
int u=qq.front();qq.pop();
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(!vis2[v]){
vis2[v]=1;ok[v]=1;qq.push(v);
}
}
}
}
void spfa(){
for(int i=1;i<=n;i++)dis[i]=1e9;
dis[t]=0;vis[t]=1;q.push(t);
while(!q.empty()){
int u=q.front();q.pop();vis[u]=0;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(dis[v]>dis[u]+1&&ok[v]){
dis[v]=dis[u]+1;
if(!vis[v]){
q.push(v);vis[v]=1;
}
}
}
}
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
scanf("%d%d",&x,&y);insert(y,x);
}
scanf("%d%d",&s,&t);
bfs();
for(int i=1;i<=n;i++)okk[i]=ok[i];
for(int i=1;i<=n;i++){
if(!okk[i]){
for(int j=head[i];j;j=e[j].nxt){
if(ok[e[j].to])ok[e[j].to]=0;
}
}
}
spfa();
if(dis[s]>=1e9)printf("-1\n");
else printf("%d\n",dis[s]);
return 0;
}
反思:这道题我们从“合法点需与终点连通” 入手,想到从终点出发搜到的点即为合法点,故反向建边搜索打标记(注意备份数组的细节)
(2)dp状态的设定技巧与0环的处理
题意:统计从1-n长度<=d+k的路径条数
#include <bits/stdc++.h>
using namespace std;
int n,m,k,p,head[100005],resav[100005],dis[100005],d,timber,tot;
int ans,fa[100005],s[100005],top,low[100005],dfn[100005],t;
int f[100005][52],sz[100005],flag;
bool vis[100005],vist[100005][52],visit[100005];
priority_queue<pair<int,int>,vector<pair<int,int> >,greater<pair<int,int> > > q;
struct node{int to,nxt,w;}e[200005];
void insert(int u,int v,int w){
e[++tot].nxt=head[u];e[tot].to=v;e[tot].w=w;head[u]=tot;
}
void dij(){
vis[1]=dis[1]=0;q.push(make_pair(0,1));
while(!q.empty()){
int u=q.top().second;q.pop();
if(vis[u])continue;vis[u]=1;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to,w=e[i].w;
if(dis[v]>dis[u]+w)dis[v]=dis[u]+w,q.push(make_pair(dis[v],v));
}
}
}
void tarjan(int u){
dfn[u]=low[u]=++timber;s[++top]=u;visit[u]=1;
for(int i=head[u];i;i=e[i].nxt){
if(e[i].w)continue;
int v=e[i].to;
if(!dfn[v])tarjan(v),low[u]=min(low[u],low[v]);
else if(visit[v])low[u]=min(low[u],dfn[v]);
}
if(low[u]==dfn[u]){
while(top){
int x=s[top--];visit[x]=0;
fa[x]=u;sz[u]++;
if(x==u)break;
}
}
}
void check(){
timber=0; for(int i=1;i<=n;i++)dfn[i]=low[i]=sz[i]=0,fa[i]=i;
for(int i=1;i<=n;i++)if(!dfn[i])tarjan(i);
}
int dfs(int x,int y){
if(y>k)return 0;
if(vist[x][y])return f[x][y];
vist[x][y]=1;f[x][y]=0;
if(x==n)f[x][y]++;
for(int i=head[x];i;i=e[i].nxt){
int xp=dfs(e[i].to,y+dis[x]+e[i].w-dis[e[i].to]);
if(xp&&sz[fa[e[i].to]]>1){flag=1;return 0;}
f[x][y]=(f[x][y]+xp)%p;
}
return f[x][y]%p;
}
int main(){
scanf("%d",&t);
while(t--){
ans=0;scanf("%d%d%d%d",&n,&m,&k,&p);
flag=0;memset(head,0,sizeof(head));tot=0;
memset(vist,0,sizeof(vist));
for(int i=2;i<=n;i++)vis[i]=0,dis[i]=1e9;
for(int i=1,x,y,w;i<=m;i++)scanf("%d%d%d",&x,&y,&w),insert(x,y,w);
check();dij(); int res=dfs(1,0);
if(flag)printf("-1\n");
else printf("%d\n",res);
}
return 0;
}
2.贪心的思想
贪心是很普遍的思想,且更多的是结合在整个做法中的一小部分,很少单独考贪心,这里不再赘述,主要引领贪心在noip图论题里的思考方向 并分析贪心的策略
题意:给定一个无向连通图,从任意点开始,按以下方式进行遍历1~n:沿着第一次到达该点的边进行回溯(当然起点不能回溯),或者沿着相连的一条边到达一个未到达过的点,要求使遍历序列的字典序尽量小 (注:60pts是树,100pts是基环树)
(1)60pts
做什么?求字典序最小--->贪心,从1号节点开始,每次走编号最小的一条
怎么做?用vector存图(方便sort)对每个节点把所有它连接的点编号从小到大排序,dfs一遍
(2)100pts
思考:如果一个图里有环的话(n≤m在没有重边自环的情况下是肯定有环的),这一个环里肯定有一条边是用不到的,正确性显而易见,因为我们下一步只能往没走过的地方走,或者是朝上一个地方走,在这种情况下我们能走的点是n,而能经过的边只有n-1条,对于一个x个点x个边的环来说,肯定是有一条边用不到的(基环树嘛),所以我们就枚举这条用不到的边,然后跑Dfs即可
#include <bits/stdc++.h>
using namespace std;
int n,m,tmp[5005],ans[5005],head[5005],cnt,dep,dep2,su,sv;
struct node{int nxt,u,v;}e[10005];
bool vis[5005];
vector<int> ver[5005];
void insert(int u,int v){
e[++cnt].nxt=head[u];e[cnt].v=v;e[cnt].u=u;head[u]=cnt;}
void dfs1(int u,int fa){
if(vis[u])return;
vis[u]=1;ans[++dep]=u;
for(int i=0;i<ver[u].size();i++){
int v=ver[u][i];
if(v==fa)continue;
dfs1(v,u);
}
}
void dfs2(int u,int fa){
if(vis[u])return;
vis[u]=1;tmp[++dep2]=u;
for(int i=0;i<ver[u].size();i++){
int v=ver[u][i];
if(v==fa)continue;
if((v==sv&&u==su)||(v==su&&u==sv))continue;
dfs2(v,u);
}
}
void renew(){for(int i=1;i<=n;i++)ans[i]=tmp[i];}
bool check(){
for(int i=1;i<=n;i++){
if(tmp[i]==ans[i])continue;
if(tmp[i]>ans[i])return 0;
else return 1;
}
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
int x,y;scanf("%d%d",&x,&y);
ver[x].push_back(y);ver[y].push_back(x);insert(x,y);insert(y,x);
}
for(int i=1;i<=n;i++)sort(ver[i].begin(),ver[i].end());
if(n==m-1){
dfs1(1,0);for(int i=1;i<=n;i++)printf("%d ",ans[i]);
}else{
for(int i=0;i<cnt;i+=2){
dep2=0;su=e[i].u;sv=e[i].v;
memset(vis,0,sizeof(vis));
dfs2(1,0);
if(dep2<n)continue;//如果删边不联通了就蒜了
if(!ans[1])renew();
else if(check())renew();
}
for(int i=1;i<=n;i++)printf("%d ",ans[i]);
}
return 0;
}
题意:一个n个节点的树,需要在树上找出m条边不相交的路径,使得路径的最小值最大
(1)做什么?最大化最小值--->二分,转为判定问题:二分枚举k能否选出m条长度不小于k的路径--->给出k,求不小于k的路径最多有多少条--->贪心
(2)怎么做?从整体到局部可分为step123
step1怎么让不小于k的路径尽量多?---先思考贪心策略
定义:从 u子树中某个节点连向 u的一条路径称为“半链”
-
每次合并尽量让两条半链总长接近 k更优--->让合法的总长尽量小更优。
-
若 v是 u的儿子,如果 v的子树中有两条半链可以合并,那么就不要将其中某一条(再加上 u,v 距离后)留到 u处合并。因为一条半链对答案最多造成 1的贡献,而且 v 只能留出一条返回给 u(划重点 后面总结也会提到)。--->能在子树中合并就尽量在子树中合并。
step2递归遍历整棵树时具体如何处理?---再思考各种情况下递归返回值
访问 u号节点时,先将 u的儿子们的子树中能够合并的半链尽量合并,并将合并的数量累加到答案中。从某个儿子返回时,无非就两种情况:
- 该儿子子树中的半链不能两两配对合并。要么剩余的半链长度太小,要么数量不够(是奇数)
- 子树中的半链已经两两配对了,没有剩余的半链。
相应的解决方案如下:
- 在剩余的半链中选择一条最长的半链,将其长度加上 u, v 距离后返回。(一个儿子最多返回一条半链)
- 直接返回 u, v距离。
step3具体如何合并半链?---最后思考合并方法
每次找到两个总和不小于k的数据,将答案累加 1,并将这两个数据删除--->multiset
multiset::lower_bound(x)
返回第一个大于等于 x的数 y(的迭代器)
<细节>如果某条半链的长度>=k,则这条半链可以单独成为一条路径(不用合并)
#include <iostream>
#include <set>
#include <cstring>
using namespace std;
struct node{int nxt,to,w;}e[100005];
int head[100005],cnt,f[100005],g[100005],n,m,l=1,r,mid,ans;
multiset<int> s;
multiset<int> :: iterator it;
inline void insert(int u,int v,int w){
e[++cnt].nxt=head[u];e[cnt].to=v;e[cnt].w=w;head[u]=cnt;
}
inline void dfs(int u,int fa){
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa)continue;
dfs(v,u);
f[u]+=f[v];
}
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(v==fa)continue;
s.insert(e[i].w+g[v]);
}
while(s.size()){
int now=*--s.end();
if(now>=mid){
f[u]++;
s.erase(--s.end());
}else break;
}
while(s.size()){
int now=*s.begin();
s.erase(s.begin());
it=s.lower_bound(mid-now);
if(it==s.end())g[u]=now;
else f[u]++,s.erase(it);
}
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n-1;i++){
int x,y,z;scanf("%d%d%d",&x,&y,&z);
insert(x,y,z);insert(y,x,z);r+=z;
}
while(l<=r){
memset(f,0,sizeof(f));memset(g,0,sizeof(g));
mid=(l+r)>>1;
dfs(1,0);
if(f[1]>=m)ans=mid,l=mid+1;
else r=mid-1;
}
printf("%d\n",ans);
return 0;
}
反思:这种“既然贡献都为1,不如先选满足条件下最劣的,剩下较优的以对后续有利”的贪心思想是比较常见的,这里提供思路相近的一道题供练习:[HEOI2015]兔子与樱花
题意:给定一棵树和每个节点上的初始数字,删除一条边的效果是交换被这条边连接的两个节点上的数字交换,设pi表示数字i在哪个节点,要求合理安排删除n-1条边的顺序,使得排列p字典序最小
(1)考虑把一个数字从一个节点运送到另一个节点的过程。
可以发现,限制总共有3种:
- 对于起点,选择的边要求是这个点所有边中第一条删除的;
- 对于中间的点,要求选择的两条边的删除顺序必须连续;
- 对于终点,选择的边要求是这个点所有边中最后一条删除的。
--->(2)那么考虑对于每个点建立一张图,图上面的点代表这个点连出去的一条边。
在这张图上的一条有向边代表出点要在入点之后马上选择,同时记录这个点钦定的第一条边和最后一条边。每个点的图不相关。
--->(3)那么考虑贪心,从小到大确定每个数字的最终位置,同时保证不矛盾。
--->(4)矛盾的情况有三种:
- 图的形式不是若干条链的形式;
- 第一个点(边)有入边,最后一个点(边)有出边;
- 第一个点(边)所在的链的链尾是最后一个点(边),但是还有其他的点不在链中。
从每个数字的起始点出发,保证从根到这个点的路径不会引起矛盾,更新答案即可。
可通过并查集实现,如果想写O(n^2)的链表写法,那么只要满足每次相连的是两条链的链首和链尾,然后分别记录链首、链尾的所在的链的链尾和链首即可。