掌握了广搜就意味着至少可以拿一块省赛银牌,这或许是一句玩笑话,但是我觉得还是有几分道理的,广搜的涉及面很广,而且可以辅助你更好得理解动态规划,因为两者都有状态的概念,而且广搜的状态更加容易构造,不学广搜就无法理解 A*、SPFA、差分约束、稳定婚姻、最大流 等等其它的图论算法。
回想自己十几年前刚开始学习搜索的时候,总是分不清楚什么时候应该用广搜,什么时候应该用深搜,所以,我把之前遇到的问题做了一个总结,发现最重要的还是那两个字:状态。今天这篇文章会围绕这两个字进行一个非常详细的讲解。
当然,任何事情都有一个循序渐进的过程,我不会把所有关于广搜的内容一次性讲完,看完这篇文章,你至少应该可以自己手写一个单向广搜的代码。后面的章节会对 最短路、A* 、双向广搜 逐一进行讲解。
【例题1】公主被关在一个 n × m ( n , m < = 500 ) n \times m(n,m <= 500) n×m(n,m<=500) 的迷宫里,主公想在最快的时间内救出公主。但是迷宫太大,而且有各种墙阻挡,主公每次只能在 上、下、左、右 四个方向内选择周围的非墙体格子前进一格,并且花费 1 单位时间,问主公救出公主的最少时间。
图二-1 (图中 ♂ 代表主公,♀代表公主,□ 代表墙体不能通行)
队列的基础结构是一种线性表,所以实现方式主要有两种:链表 和 数组。并且需要两个指针,分别指向队列头 f r o n t front front 和队列尾 r e a r rear rear。
那么接下来,请忘记链表。
作者将介绍一种用数组的方式来实现的队列,结构定义如下:
class Queue {
public:
Queue();
virtual ~Queue();
public:
...
private:
QueueData *data_;
int front_, rear_;
};
QueueData *data_
:虽然是个指针,但是它不是链表,这个指针指向的是队列数据的内存首地址,由于队列数组较大,所以采用堆内存,在队列类的构造函数里进行内存申请,析构函数里进行内存释放,代码如下:const int MAXQUEUE = 1000000;
Queue::Queue() : data_(NULL) {
data_ = new QueueData[MAXQUEUE];
}
Queue::~Queue() {
if (data_) {
delete[] data_;
data_ = NULL;
}
}
front_
代表了队列头数据的索引,是一个数组下标,所以是整数类型,当队列不为空的时候,data_[front_]
获取到的就是队首元素;rear_
代表了队列尾,也是一个数组下标,和队列首不同,它指向的是一个无用位置(空结点),当队列不为空的时候,队列尾部最后一个可用数据为data_[rear_-1]
,如图三-1-3所示:QueueData
,这样就可以根据不同需求定义不同的数据类型。struct QueueData {
int height;
};
struct QueueData {
int age;
};
struct QueueData {
int x, y;
};
struct QueueData {
int width, height;
};
struct QueueData {
int x, y, z;
};
struct QueueData {
int x, y, dir;
};
class Queue {
...
public:
void clear(); // 1)清空队列
void push(const QueueData& bs); // 2)压入数据
QueueData& pop(); // 3)弹出数据
public:
bool empty() const; // 4)队列是否为空
private:
...
};
void Queue::clear() {
front_ = rear_ = 0;
}
void Queue::push(const QueueData& bs) {
data_[rear_++] = bs;
}
QueueData& Queue::pop(){
return data_[front_++];
}
bool Queue::empty() const {
return front_ == rear_;
}
MAXQUEUE
时,压入数据会导致数组下标越界,有两个解决方案:data_[0, front_ - 1]
这块内存的数据再也没有被用到,所以是可以被重复利用的,具体做法是:MAXQUEUE
时,则队列尾指针置0;修改后的 push
接口,代码实现如下:void Queue::push(const QueueData& bs) {
data_[rear_++] = bs;
if (rear_ == MAXQUEUE) rear_ = 0;
}
MAXQUEUE
时,则队列头指针置0;修改后的 pop
接口,代码实现如下:QueueData& Queue::pop(){
if (++front_ == MAXQUEUE) front_ = 0;
if (front_ == 0)
return data_[MAXQUEUE - 1];
else
return data_[front_ - 1];
}
MAXQUEUE
时,原有的数据会被下一次压入的数据覆盖掉,破坏原有内存结构,这个时候,循环队列已经不能解决问题,需要进行动态扩容了;rear_ + 1 == front_
时,再压入一个元素,就会导致rear_ == front_
,队列就会变成空(参考上文的判空),这样就不能进行数据的弹出,导致队列不能正常运作,即使再压入数据,此时弹出的数据也不再是正确的,所以当队列剩余容量小于一定阈值的时候,我们需要把队列进行扩容处理;front_ <= rear_
时,T = MAXQUEUE - (rear_ - front_);
front_ > rear_
时,T = front_ - rear_;
T < MAXQUEUE * 0.1
时,开辟一块新的内存,内存大小为MAXQUEUE*2
,将原有内存拷贝过去,并且修改 front_
和rear_
的值,然后再释放原有内存空间。BFSState
:struct Pos {
int x, y;
bool isInBound() {
return !(x < 0 || y < 0 || x >= XMAX || y >= YMAX);
}
bool isObstacle() {
return (Map[x][y] == MAP_BLOCK);
}
};
struct BFSState {
Pos p;
...
};
const int MAXSTATE = 1000000;
struct BFSState {
...
public:
inline bool isValidState(); // 1)
inline bool isFinalState(); // 2)
inline int getStep() const;
inline void setStep(int step);
protected:
int getStateKey() const;
public:
static int step[MAXSTATE]; // 3)
};
isValidState
接口来完成的,实现可以是这样的:bool BFSState::isValidState() {
return p.isInBound() && !p.isObstacle();
}
MAP_EXIT
,那么就判断这个状态下的位置所在的地图格子是否是MAP_EXIT
,实现如下:bool BFSState::isFinalState() {
return (Map[p.x][p.y] == MAP_EXIT);
}
getStep
是用来获取初始状态到当前状态的最小步数,setStep
是用来设置初始状态到当前状态的最小步数,因为实际情况的状态所对应的维数是不确定的,有的是一维,有的是二维,三维、四维、甚至更高维度的。为了将问题统一,我们需要做一层映射,即 多维状态向量 转换成 一维状态向量,这个转换的过程见下一节:状态的降维;getStateKey()
获取的就是降维以后一维的状态编号,那么我们可以定义所有状态最小步数的存储结构为一维数组,即static int step[MAXSTATE];
,设置和获取的接口定义如下:int BFSState::getStep() const {
return step[getStateKey()];
}
void BFSState::setStep(int sp) {
step[getStateKey()] = sp;
}
int BFSState::getStateKey() const {
return (p.x * K) + p.y;
}
int BFSState::getStateKey() const {
return p.x << 6 | p.y;
}
pos2State
这个全局数组代表的二维状态转换成一维状态:int stateId = 0;
for (int i = 0; i < K; ++i)
for (int j = 0; j < K; j++)
pos2State[i][j] = stateId++;
int BFSState::getStateKey() const {
return pos2State[p.x][p.y];
}
单向广搜的算法大致可以描述如下:
1)初始化所有状态的步数为无穷大,并且清空队列;
2)将 起始状态 放进队列,标记 起始状态 对应步数为 0;
3)如果队列不为空,弹出一个队列首元素,如果是 结束状态,则返回 结束状态 对应步数;否则根据这个状态扩展状态继续压入队列;
4)如果队列为空,说明没有找到需要找的 结束状态,返回无穷大;
class BFSGraph {
public:
int bfs(BFSState startState);
private:
void bfs_extendstate(const BFSState& fromState);
void bfs_initialize(BFSState startState);
private:
Queue queue_;
};
bfs
作为一个框架接口供外部调用,基本是不变的,实现如下:const int inf = -1;
int BFSGraph::bfs(BFSState startState) {
bfs_initialize(startState); // 1)
while (!queue_.empty()) {
BFSState bs = queue_.pop();
if (bs.isFinalState()) {
// 2)
return bs.getStep();
}
bfs_extendstate(bs); // 3)
}
return inf;
}
bfs_extendstate
,不同问题的扩展方式不同,下文会对不同问题的状态扩展进行讲解。bfs_initialize(startState)
接口,主要做 4 件事情:const int inf = -1;
void BFSGraph::bfs_initialize(BFSState startState) {
memset(BFSState::step, inf, sizeof(BFSState::step));
queue_.clear();
startState.setStep(0);
queue_.push(startState);
}
const int dir[DIR_COUNT][2] = {
{
1, 0 }, // 下
{
0, 1 }, // 右
{
0, -1 }, // 左
{
-1, 0 } // 上
};
void BFSGraph::bfs_extendstate(const BFSState& fromState) {
int stp = fromState.getStep() + 1; // 1)
BFSState toState;
for (int i = 0; i < DIR_COUNT; ++i) {
toState.p = fromState.p.move(i); // 2)
if (!toState.isValidState() || toState.getStep() != inf) {
continue; // 3)
}
toState.setStep(stp); // 4)
queue_.push(toState);
}
}
move
来实现,我们可以对 Pos
结构体进行一个扩展,如下:struct Pos {
...
Pos move(int dirIndex) const {
return Pos(x + dir[dirIndex][0], y + dir[dirIndex][1]);
}
};
dir[][]
代表的是一个方向向量,用于实现move
接口的向量相加;【例题2】给定一个 n × m ( n , m < = 20 ) n \times m (n,m <= 20) n×m(n,m<=20) 的迷宫,有些格子是墙体不能进入,迷宫中有一个 主公 和 一位 公主,主公每次可以选择上、下、左、右四个方向进行移动,每次主公移动的同时,公主可以按照相反方向移动一格(如果没有墙体遮挡的话)。当主公和公主相邻或者进入同一个格子则算游戏结束,问至少多少步能让游戏结束。
struct BFSState {
Pos p[2];
...
};
bool BFSState::isFinalState() {
return abs(p[0].x - p[1].x) + abs(p[0].y - p[1].y) <= 1;
}
【例题3】给定一个 n × m ( n , m < = 8 ) n \times m (n,m <= 8) n×m(n,m<=8) 的迷宫,上面有 x ( x < = 4 ) x(x <= 4) x(x<=4) 个箱子 和 1个人,以及一些障碍和箱子需要放置的最终位置,求一种方案,用最少步数将所有的箱子推到指定位置。
图五-1-1
struct BFSState {
Pos man, box[4];
...
};
【例题4】给定一个 n × m ( n , m < = 500 ) n \times m (n,m <= 500) n×m(n,m<=500) 的迷宫,一个入口一个出口。走迷宫的规则是优先选择右边的方向走,如果右边有墙就往前走,如果还有墙就往左,如果还有就掉头,问从入口到出口,以及出口到入口,能否将整个迷宫的区域走遍。如图5就是一种可行方案。
图五-1-5
struct BFSState {
Pos p;
char dir;
...
};
【例题5】给定一个 n × m ( n , m < = 20 ) n \times m (n,m <= 20) n×m(n,m<=20) 的迷宫,一个入口一个出口。并且有 x ( x < = 10 ) x( x <= 10 ) x(x<=10) 个金币,问从入口到出口并且收集到所有 x 的最少时间。
图五-1-7
struct BFSState {
Pos p;
int coinMask;
...
};
【例题6】一个 n × m ( n , m < = 20 ) n \times m (n,m <= 20) n×m(n,m<=20) 的迷宫,左上角 (0, 0) 为出口,一条蛇在迷宫中,蛇的身体长度为 L,最多占用 8 个格子,有上下左右四个方向可以走,蛇走的时候不能碰到自己的身体,问最少需要多少步才能走到出口。
图五-1-8
struct BFSState {
Pos p;
int dir[7];
...
};
struct BFSState {
Pos p;
int dirMask;
...
};
【例题7】给定一个不能被 2 或 5 整除的数 n ( 0 < = n < = 10000 ) n (0 <= n <= 10000) n(0<=n<=10000),求一个十进制表示都是 1 的数 K K K ,使得 K K K 是 n n n 的倍数,且最小。例如: n = 3 n = 3 n=3,那么答案就是 111,因为 111 m o d 3 = 0 111 \mod 3 = 0 111mod3=0。
本文所有示例代码均可在以下 github 上找到:github.com/WhereIsHeroFrom/模板/广度优先搜索
题目链接 | 难度 | 解法 |
---|---|---|
PKU 1096 Space Station Shielding | ★☆☆☆☆ | FloodFill |
HDU 2952 Counting Sheep | ★☆☆☆☆ | FloodFill |
HDU 1026 Ignatius and the Princess I | ★☆☆☆☆ | 优先队列应用 |
HDU 1072 Nightmare | ★☆☆☆☆ | 记录时间维度 |
HDU 1240 Asteroids! | ★☆☆☆☆ | 【例题1】三维迷宫 |
HDU 1415 Jugs | ★☆☆☆☆ | 经典广搜 - 倒水问题 |
HDU 1495 非常可乐 | ★☆☆☆☆ | 经典广搜 - 倒水问题 |
HDU 1195 Open the Lock | ★☆☆☆☆ | 一维的数码可达问题 |
PKU 1915 Knight Moves | ★★☆☆☆ | 马的走位 |
HDU 1372 Knight Moves | ★★☆☆☆ | 马的走位 |
HDU 2235 机器人的容器 | ★★☆☆☆ | FloodFill |
HDU 3713 Double Maze | ★★☆☆☆ | 2个人的迷宫问题 |
HDU 2216 Game III | ★★☆☆☆ | 【例题2】2个人的迷宫问题 |
HDU 3309 Roll The Cube | ★★☆☆☆ | 2个人的迷宫问题 |
HDU 1254 推箱子 | ★★☆☆☆ | 【例题3】推箱子问题 |
PKU 1475 Pushing Boxes | ★★☆☆☆ | 【例题3】推箱子问题 |
HDU 1253 胜利大逃亡 | ★★☆☆☆ | 三维迷宫 |
HDU 1252 Hike on a Graph | ★★☆☆☆ | 3个人的迷宫问题 |
HDU 1044 Collect More Jewels | ★★☆☆☆ | 【例题5】二进制状态压缩的应用 |
PKU 2157 Maze | ★★☆☆☆ | 二进制状态压缩的应用 |
HDU 3220 Alice’s Cube | ★★☆☆☆ | 预处理 + 位运算 |
HDU 1429 胜利大逃亡(续) | ★★☆☆☆ | 二进制状态压缩的应用 |
PKU 1077 Eight | ★★☆☆☆ | 经典八数码 |
HDU 2170 Frogger | ★★☆☆☆ | 带停留的搜索 |
HDU 1226 超级密码 | ★★☆☆☆ | 枚举位数 |
PKU 2551 Ones | ★★☆☆☆ | 同余搜索 |
PKU 1426 Find The Multiple | ★★☆☆☆ | 同余搜索 |
PKU 1860 Currency Exchange | ★★☆☆☆ | SPFA |
PKU 1237 The Postal Worker Rings | ★★☆☆☆ | SPFA |
PKU 1724 ROADS | ★★☆☆☆ | 优先队列应用 |
HDU 2822 Dogs | ★★☆☆☆ | 优先队列应用 |
HDU 2851 Lode Runner | ★★☆☆☆ | 优先队列应用 |
HDU 2237 无题III | ★★☆☆☆ | 多维状态搜索 |
HDU 3912 Turn Right | ★★☆☆☆ | 【例题4】右转迷宫 + 增加方向维度 |
PKU 2283 Different Digits | ★★★☆☆ | 同余搜索 |
PKU 2206 Magic Multiplying Machine | ★★★☆☆ | 同余搜索 |
HDU 1104 Remainder | ★★★☆☆ | 同余搜索 |
PKU 3000 Frogger | ★★★☆☆ | 同余搜索 |
HDU 1317 XYZZY | ★★★☆☆ | 最长路判环 |
HDU 1384 Intervals | ★★★☆☆ | 差分约束 |
HDU 1531 King | ★★★☆☆ | 差分约束 |
PKU 1716 Integer Intervals | ★★★☆☆ | 差分约束 |
PKU 3501 Escape from Enemy Territory | ★★★☆☆ | 二分答案 + BFS |
PKU 1292 Will Indiana Jones Get | ★★★☆☆ | 二分答案 + BFS |
PKU 1485 Fast Food | ★★★☆☆ | SPFA |
PKU 1511 Invitation Cards | ★★★☆☆ | SPFA |
PKU 1545 Galactic Import | ★★★☆☆ | SPFA |
PKU 1734 Sightseeing trip | ★★★☆☆ | 无向图最小环 |
PKU 1420 Spreadsheet | ★★★☆☆ | 建立拓扑图后广搜 |
PKU 2353 Ministry | ★★★☆☆ | 需要存路径 |
PKU 2046 Gap | ★★★☆☆ | A* |
PKU 1778 All Discs Considered | ★★★☆☆ | |
PKU 1097 Roads Scholar | ★★★☆☆ | SPFA |
PKU 1324 Holedox Moving | ★★★☆☆ | 【例题6】状态压缩的广搜 |
PKU 1062 昂贵的聘礼 | ★★★☆☆ | 优先队列应用 |
PKU 3897 Maze Stretching | ★★★☆☆ | |
PKU 3346 Treasure of the Chimp | ★★★☆☆ | |
PKU 2983 Is the Information Reliable | ★★★☆☆ | 最长路判环 |
PKU 1482 It’s not a Bug, It’s a | ★★★☆☆ | |
HDU 3008 Warcraft | ★★★☆☆ | |
HDU 3036 Escape | ★★★☆☆ | |
PKU 3322 Bloxorz I | ★★★☆☆ | 当年比较流行这个游戏 |
HDU 1043 Eight | ★★★☆☆ | 数据较强,需要预处理 |
HDU 1307 N-Credible Mazes | ★★★☆☆ | 多维空间搜索,散列HASH |
HDU 3681 Prison Break | ★★★☆☆ | 状态压缩 |
HDU 3500 Fling | ★★★☆☆ | 某个消除游戏 |
HDU 2605 Snake | ★★★★☆ | 状态压缩 |
HDU 1122 Direct Visibility | ★★★★☆ | 计算几何判断连通性 |
PKU 3912Up and Down | ★★★★☆ | 离散化 + BFS |
PKU 3463 Sightseeing | ★★★★☆ | SPFA |
PKU 3328 Cliff Climbing | ★★★★☆ | 日本人的题就是这么长 |
PKU 3455 Cheesy Chess | ★★★★☆ | 仔细看题 |
PKU 1924 The Treasure | ★★★★☆ | |
PKU 3702 Chessman | ★★★★★ | 弄清状态同余的概念 |
HDU 3278 Puzzle | ★★★★★ | 几乎尝试了所有的搜索 -_- |
HDU 3900 Unblock Me | ★★★★★ | 8进制压缩状态,散列HASH,位运算加速 |