算法百题斩其二: 双向bfs——bfs的一种优化
写在前面:何所谓“斩”?
斩,即快速而有力地切断,指我们用最精简的语言,一针见血地点破算法题的核心难点。斩需三思而后行;斩需借助外力、旁征博引;斩需持之以恒、铁杵磨针!
1. 何为双向bfs?
是啥?
在知道初态和末态、且从两个方向产生的搜索树可覆盖所有(合法)状态的情况下,通过从起点和终点分别进行bfs,直到形成的两个连通块相交(若不交则无解),产生深度减半的搜索树。一般地,选择点集较小的那一侧进行下一次的延申操作,可以最小化“浪费”的状态,或者更直观地说可以使状态数量曲线(指数型)更加平滑。
有啥用?
可以显著降低时间复杂度(不难想象,这是指数级的优化!),对于一些状态数明显超标的题目,可以有效避免TLE!
这里转载一张图,摘自搜索优化技巧:双向搜索
如何不严谨地 证明一下?
见例题一代码注释~
例题一
分析+代码:
版本一:手写队列版本!
//使用双向bfs优化的最小步数模型,总状态太多,若直接bfs会超时。故使用双端bfs
//双端bfs求最短路的简单证明:假设某层遍历后,从起点延伸的点集和从终点延伸的点集没有会师,则说明不存在长度小于等于d[a]max+d[b]max的路线(反证),以此类推,第一次得到的一定是最短路
//要点:即使是双向bfs,无休止地遍历所有状态也会TLE,为了避免双端bfs遍历到所有状态,我们需要严格思考其是否保证在一定时候“停下来”
//比如本题中,假如两个字符串相等,我们的程序会忽略那个解而一直找下去,最终超时,故需要特判一下这种情况!!
//细节:1.利用map的count函数(判断某个自变量的键是否被赋值过),可以实现距离数组和判重数组的“合二为一”
//细节:2.从队长较短的那一侧进行当一轮的延申,可以最小化“浪费”的状态,或者更直观地说可以使状态数量曲线(指数型)更加平滑。
//细节:3.string类的substr函数的两种重载:
//substr(index_a,len)表示以下标a为起点,长度为len的子串
//substr(index_b)表示从下标b到字符串终点的子串
#include <iostream>
#include <queue>
#include <map>
#include <algorithm>
using namespace std;
const int N=7,M=5e4+10;
string rule_a[N],rule_b[N],A,B,qa[M],qb[M];
int hha,tta,ans,cnt,hhb,ttb;
unordered_map<string,int> da;
unordered_map<string,int> db;
int extend(unordered_map<string,int> &da, unordered_map<string,int> &db, string a[],string b[],string *q,int &hh,int &tt)
{
string x=q[hh++];
for(int i=0;i<cnt;i++)
{
string rulea=a[i],ruleb=b[i];
for(int j=0;j<x.size();j++)
{
string s2=x.substr(j,rulea.size());
string state;
if(s2==rulea){
state=x.substr(0,j)+ruleb+x.substr(j+rulea.size());
if(db.count(state))
{
return da[x]+db[state]+1;
}
if(da.count(state))continue;
da[state]=da[x]+1;
q[++tt]=state;
}
}
}
return 11;
}
int bfs()
{
if(A==B)return 0;
qa[0]=A,qb[0]=B;
da[A]=db[B]=0;
while(hha<=tta&&hhb<=ttb)
{
int t;
if(tta-hha<=ttb-hhb)t=extend(da,db,rule_a,rule_b,qa,hha,tta);
else t=extend(db,da,rule_b,rule_a,qb,hhb,ttb);
if(t<=10)return t;
}
return 11;
}
int main()
{
cin>>A>>B;
while(cin>>rule_a[cnt]>>rule_b[cnt])cnt++;
int res=bfs();
if(res>=11)cout<<"NO ANSWER!"<<endl;
else cout<<res;
}
版本二:stl队列版本(懒人版本)!
//最小步数模型,每一步分支太多,若直接bfs会超时。故使用双端bfs
//双端bfs的理解:假设某次遍历后,从起点延伸的点集和从终点延伸的点集没有会师,则说明不存在长度小于等于d[a]max+d[b]max的路线,以此类推,第一次得到的一定是最短路
//技巧:距离数组和判重数组可以“合二为一”
//从队长较短的那一侧进行当一轮的延申,可以最小化“浪费”的状态,或者更直观地说可以使状态数量曲线(指数型)更加平滑。
#include <iostream>
#include <queue>
#include <map>
#include <algorithm>
using namespace std;
const int N=7,M=5e3+10;
string rule_a[N],rule_b[N],A,B;
int hha,tta,ans,cnt,hhb,ttb;
queue<string> qa,qb;
unordered_map<string,int> da;
unordered_map<string,int> db;
int extend(unordered_map<string,int> &da, unordered_map<string,int> &db, string a[],string b[],queue<string> &q)
{
string x=q.front(); q.pop();
for(int i=0;i<cnt;i++)
{
string rulea=a[i],ruleb=b[i];
for(int j=0;j<x.size();j++)
{
//
if(x.substr(j,rulea.size())==rulea){
string state=x.substr(0,j)+ruleb+x.substr(j+rulea.size());
if(db.count(state))
{
return da[x]+db[state]+1;
}
if(da.count(state))continue;
da[state]=da[x]+1;
q.push(state);
}
}
}
return 11;
}
int bfs()
{
if(A==B)return 0;
qa.push(A),qb.push(B);
da[A]=db[B]=0;
while(qa.size()&&qb.size())
{
int t;
if(qa.size()<=qb.size())t=extend(da,db,rule_a,rule_b,qa);
else t=extend(db,da,rule_b,rule_a,qb);
if(t<=10)return t;
}
return 11;
}
int main()
{
cin>>A>>B;
while(cin>>rule_a[cnt]>>rule_b[cnt])cnt++;
int res=bfs();
if(res>=11)cout<<"NO ANSWER!"<<endl;
else cout<<res;
}
可以惊讶地发现:法一比法二慢了近五分之一!主要原因是,计算机访问一片内存会消耗时间,且耗时与内存大小正相关(具体的原理不太清楚,我是大二萌新),法一开了个可能有冗余的数组,那每一次访问数组元素都要耗时。而法二是用多少开多少,可以有效缓解这个问题