题目链接:A Simple Task - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
简介:点数不超过19的无向图求环数(不含二元环和自环)
初步思考(n * n!):
19个点我们可以从每个点开始往外搜索,直到重新搜索到自己,这样可以记录找到一个环。由于无重边,边数最多有19*18条,选择爆搜的话最差可能会:对于19个点的每个点,他走到的每个点都是18个岔路,即O(n * n!)的复杂度,2秒显然不行。
考虑优化(空间n * n * 2**n):
在上述的搜索中我们在搜到环的时候实际上重复访问过很多条通路,考虑记录下每条通路,通路的状态有:起点,经过点,终点,我们要同时记录下这三个数据,假设经过点有序的记录那空间复杂度又是和前面的时间复杂度一样的阶乘级别(因为对于确定的起点终点,经过点要进行排列组合)所以这里的经过点一定要无序记录,而点数是19,我们可以对每条通路完全记录每个点是否在这条边上,也就是用0和1标记每个点最后组合起来(类似独热编码)换句话就是二进制表示这19个点。从有序到无序是从阶乘到指数的下降,复杂度大概在1e6可以接受,考虑求出数组 f [ 起点 ][ 经过点 ][ 终点 ] 记录起点和终点之间的通路数量,这样把数组中起终点相同的值求和即可,但是数组复杂度就是n * n * 2**n,也就是4e8的空间,还是不能接受。
进一步优化(空间n * 2**n + 时间n * n * n * 2**n + 起点问题):
我们必须把数组压小一维,选择放弃起点,只记录终点和经过点,也就是 f [ 经过点 ][ 终点 ] 来记录通路数量,而起点由我们另外找到。我们不能像前面n * n * 2**n去遍历所有起点,经过点和终点,因为在这个时候还需要枚举终点的邻接点才能更新,也就是O(n * n * n * 2**n)。考虑把起点和经过点合并,但是暂时我们不知道怎么合并,不过空间复杂度总算是可行了,先保留思路。
处理数组(DP初显,转移问题):
对于 f 数组的求解,我们可以通过枚举每个 “ 经过点的状态 ” (简称状态)和终点,复杂度是可以接受的,但是只能用递推的方式求出来,因为复杂度不能再增大了,也就引入了状态转移,发现这是个DP题。对于正常情况我们会遍历每个终点 j ,再遍历状态情况 i ,但是这样不好转移,因为某一个点的状态,走了他的临接边之后状态会转移到其他点,只有邻接点序号比自己大(状态也比自己大)才能转移,那这样记录的通路都只能是上升序列。因为如果邻接点比自己小,我们又是把点从小到大遍历的,会修改之前我们访问过的状态(也就是我们访问 f [ i ][ j ] 会对 f [ i + y ][ j - x ] 进行修改,这个值在终点为 j - x 时就确定了)对确定的值修改,理论上不合DP规则。
处理遍历顺序:
如果先访问点再访问状态不可取我们尝试反过来:先访问状态再访问终点,总体上理解就是,每一类通路(确定终点和经过点的通路)组成的所有通路是同时进行转移的。
这样做的好处是,对于上述从 f [ i ][ j ] 转移到 f [ i + y ][ j - x ] 的做法就有了保证,因为后者一定比前者更晚访问(也就是转移到的状态是还没访问的)这样就实现了从当前状态转移到未来状态。但是遗留下新的问题,起点怎么找,怎么判环?我们要重新面对刚刚的起点问题。
起点与判环(重点):
f [ i ][ j ] 中经过点 i 中每个点都可以作为起点,当这个点跟 j 相同的时候我们就会进行算环。由于环只要算一次,也就是起点我们只需要选一个就行了,详细的说:
由于我们是要计算环,我们计算通路 f [ i ][ j ] 的起点可以是经过点中的任意一个,作为环的起点,我们不需要假设每个经过点都是起点的情况,我们可以让 f 数组只记录编号最小的经过点作为起点对应的通路。因为记录其他点作为起点的通路,他们对求环的贡献跟记录最小编号重复了(相当于把找到的环,从环上每个点出发绕环走一圈,你都把他记成环,也就是一个环被算的次数会等于环上的点数)。
为什么选择最小值,因为这样可以通过lowbit操作获取,是最方便的,理论上也可以取其他值。其次,我们在访问邻接点的时候不考虑编号比当前起点还小的情况,保证状态转移之后起点没变,仍然可以用lowbit求解(这样是不会算漏掉的,那个点k在遍历的时候会重新遍历当前状态i和k组成通路的情况)。
上述起点问题没看懂可以把判环结合看:f [ i ][ j ] 以经过点最小点(也就是 i 的最小位)作为起点,状态(经过点)为 i,终点为 j 的情况下,如果 j 是 i 的最小位,那么 f [ i ][ j ] 记录的就是从 j 到 j 的通路数,就是环的数目,直接累加到全局答案中即可。
现在回到起点问题,如果把经过点上的每个点作为起点都考虑的话,相当于 f [ i ][ j ] 的环会被重复计算多次,因为这个 f [ i ][ j ] 被所有经过点共用,重新枚举每个经过点作为起点都会对这个值进行一次累加。
总结就是如果只取一个点作为起点就可以统计这个环,而且由于枚举少一维,时间复杂度也是成功从 O(n * n * n * 2**n) 降到了 O(n * n * 2**n)。
重新理解数组:
一开始本来数组中会存储图上所有通路,但是由于新数组中少枚举一维起点,让状态 i 中的最小点作为起点,所以整个数组能存储的通路就不再是图上的所有通路,而是只能记录 “起点比所有经过点都小” 的通路(因为如果走了,起点就不再是状态中的最小位置,lowbit(i & -i)无法找到起点)。所以在枚举终点的邻接点的时候,比起点小的点是不能走的。
这样会漏掉一部分通路,考察这部分对答案的影响:对于每一个环,环上编号最小的点绕环一周形成的通路一定会在数组中(因为环上所有点会在经过点状态都会枚举到,终点也会枚举到,而且保证起点是最小的,这条通路一定记录在数组中,所以计算是不会算漏的)也就是说这部分对答案没有贡献。因为每个环都会被环上最小点筛掉。
具体转移求解(题解主体):
循环访问状态 i ,终点 j 。对于枚举到的临点 k 情况分成三类:
情况1:如果邻点 k 是 i 的最小位置,答案就加上当前 f [ i ][ j ] 。
情况2:如果邻点 k 是 i 的其他经过点,不需要理会,因为每个环的起点都是他的最小点,现在我们找到的这个环会被他对应的起点计算(也就是被情况1计算)。
情况3:如果邻点 k 不是 i 的其他经过点,前面说过 f 数组只记录不成环的简单通路,那么从当前通路可以走到 j 去,也就是转移求解 f :f [ i | 1<<j ][ k ] += f [ i ][ j ]
可行性判断:
由于我们会枚举每一个终点,每一个环都会被这个环上编号最小的点找出来,而且会被顺时针和逆时针找到两次,也就是实际答案是一半。然后搜的时候因为会来回访问,也就是计算到二元环,要把二元环减掉。具体来讲:
考虑以下情况,A与BCD相连且A编号比BCD小,状态 f [ A ][ A ] = 1 (初始化,以A为终点,经过A的通路数量)这个值会被转移加到 f [ BA ][ B ] 上(以B为终点,经过A和B的通路数量)那么在状态从A循环访问到BA,终点再次访问到A的时候,我们会发现A是BA的最小位置,把这个环( A -> B -> A )算上。而这种情况会发生在所有边上,但是每条边只会发生一次(因为A比B小,所以 A -> B -> A 这个环只会计算一次,在环的最小编号处计算)也就是最终多算了总边数次
所以答案需要减去边数再除以二。
总结(重点):
优化过程需要考虑以下:
- 爆搜会有重复通路情况需要记录
- 记录数组必须进行压维,少掉起点
- 每个环由起点统计,假设起点为最小编号的情况下不用枚举其他经过点作为起点少掉一维
- f状态表示从(经过点为i,终点为j的不成环通路数量)变成(经过点为i,终点为j,且通路起点为i中最小编号的不成环通路数量)
这个DP过程需要具体考虑这些情况:
- 遍历顺序,先状态后终点
- 处理不合法情况,比如连终点都没计入经过点等等
- 特判邻接点的三种情况:是起点,还是其他经过点,还是不会成环的其他点
- 最终答案要去重以及减去二元环
注意最终求出来的f数组不是存储所有通路,一些通路的起点比经过点还大没有被计入,但是不影响判环,这部分通路如果增长之后组成环,这个环会被经过点上最小的点走通路计入。
可以结合代码看看(已AC):
#include<bits/stdc++.h>
#define N 100050
#define oo 100000000000
typedef long long ll;
using namespace std;
ll f[(1<<20)+5][25];
ll n,m;
vector<ll>V[25];//不大,用vector存每个点的邻接点
int main(){
cin>>n>>m;
for(int i=1;i<=m;i++){
ll a,b;cin>>a>>b;
V[a].push_back(b);
V[b].push_back(a);
}
for(int i=1;i<=n;i++){//初始化
f[1<<(i-1)][i]=1;//只经过i,终点为i的通路数量为1
}
ll ans=0;
for(int i=0;i<(1<<n);i++){
for(int j=1;j<=n;j++){
if(!f[i][j]){
continue;//不合法
}
for(auto k:V[j]){//遍历所有邻接点
if((i&(-i)) > (1<<(k-1))){//如果比起点还小就跳过,等他这个点来走我们i这条通路
continue;
}
if((i&(-i)) & (1<<(k-1))){//如果i的lowbit是k位,是最小点
ans+=f[i][j];
}else if(i & (1<<(k-1))){//如果是其他经过点
continue;
}else{//如果是不成环情况直接转移,转移到以k为终点
f[i|(1<<(k-1))][k]+=f[i][j];
}
}
}
}
cout<<(ans-m)/2;
return 0;
}