写在前面
说到搜索算法,就不得不提到深度优先搜索(DFS)和广度优先搜索(BFS),在前面的几篇文章中,我给DFS的主要原理实现,和简单问题解决做了介绍。
如果是DFS是基于栈(Stack)来实现,那么BFS就是基于队列(Queue)来实现,在数据结构中,栈和队列是相对的,栈后进先出,而队列先进先出。
问题引入
这是一个很经典的问题,我们现在有一条河,河的一侧有农夫,羊,狼和菜,现在我们要设计一种方案,让他们到另外一边去,规则如下:
(1)在没有农夫的情况下,羊会被狼吃掉。
(2)在没有农夫的情况下,菜会被羊吃掉。
(3)农夫一次至多带一件物品。
(4)物品不能在没有农夫的情况下移动。
(5)被移动的物品必须与农夫在同一侧。
尝试解决
基本定义
如果我们要表示他们的位置,实际上大家很容易就能想到使用二进制,比如在原来的一侧用0,在新的一侧用1
我们用四位二进制分别表示:农夫、羊、狼、菜。
则初始状态为:0000,结束状态为:1111
如何移动
如果说农夫带着羊移动,那状态会变成1100,带着狼就是1010,菜就是1001,不带任何物品就是1000,我们就写出了四种可能的情况的二进制表示。
但是,我们如何将这个“操作”,实施到“状态”上呢?
答案就是:异或运算(XOR)
只要让原来的位置 XOR 我们的操作,就可以得到:新的位置。
比如现在位置:1100(羊和农夫在对岸),我们执行操作:1100(农夫运羊),就得到1100 XOR 1100 = 0000(农夫和羊都回到原侧)
我们来看下代码是如何描述的:
int step[4] = { 0b1000, 0b1100, 0b1010, 0b1001 }; // 农夫四种操作
int ways[16]; // 保存每个状态的上一个状态
int start = 0b0000; // 起始状态,农夫、羊、狼、菜都在左岸
int endd = 0b1111; // 目标状态,农夫、羊、狼、菜都在右岸
如果你不了解异或运算,我可以简单讲解一下,异或(XOR) 实际上也叫半加运算,也就是“不进位加法”,即1+1=0(正常的或运算应该是1),1+0=1,0+1=1,0+0=0
如何判断位置
如何从我们的四位二进制位置信息中获取我们想要目标的位置?我们可以使用与运算来进行,比如1010这种情况,只要求1010&1000,就可以得到1000,知道农夫在目标侧。
而只要求1010&0100,就可以得到0000,知道羊还在原来那一侧。
// 每个角色的当前状态
int Farmer(int pos)
{
if ((pos & 0b1000) == 0b1000)return 1;
else return 0;
}
int Sheep(int pos)
{
if ((pos & 0b0100) == 0b0100)return 1;
else return 0;
}
int Wolf(int pos)
{
if ((pos & 0b0010) == 0b0010)return 1;
else return 0;
}
int Vege(int pos)
{
if ((pos & 0b0001) == 0b0001)return 1;
else return 0;
}
在判断位置的这一小段,实际上我们还需要判断位置是否安全,还记得刚刚题目的要求吗?
// 状态是否安全
int Safe(int pos)
{
if ((Sheep(pos) == Wolf(pos)) && Farmer(pos) != Sheep(pos)) return 0; // 羊和狼在一起,农夫不在
if ((Sheep(pos) == Vege(pos)) && Farmer(pos) != Sheep(pos)) return 0; // 羊和菜在一起,农夫不在
return 1;
}
广度优先搜索(BFS)
实际上,对于每种状态,我们都有至多四种可做出的变动。注意这里说的四种,是指XOR数学运算上的可能性,在不考虑限制条件和实际情况下,我们可以简单画出一个这样的树:
在这里我们引入深度优先和广度优先的区别,在DFS中,我们会挑选一个路径,一直走到头,比如在最左边,0000->1000->0000,然后返回0000,再去探索1100
而在BFS中,我们会逐行探索,从0000到第一次层的1000,1100,1010,1001然后再从1000开始,继续探索其下的0000,0100,0010,0001
可以发现,与DFS的逐列探索,正好相反。你可以用矩阵这样理解:
代码实现
我们可以先将起始位置压入队列,然后取出,在这个起始位置(含约束条件的情况下)去推算所有可能的下一个位置,并将他们全压入队列中,这样一直取出,直到队空。
由于队列先进先出,我们可以保证:在第n行的位置没算完前,第n+1行的位置不会被计算。
只有当第n行的位置全部出队(被计算完),才会轮到第n+1行参与计算。
void BFS()
{
q.push(start); // 将起始状态入队
ways[start] = start; // 起始状态的上一个状态是它自己
while (!q.empty())
{
int curr = q.front();
q.pop();
if (curr == endd) return;
if (Safe(curr ^ step[0]) && ways[curr ^ step[0]] == -1)
{
q.push(curr ^ step[0]); // 将下一个状态入队
ways[curr ^ step[0]] = curr; // 标记当前状态为下一个状态的来源
}
if ((Farmer(curr) == Sheep(curr)) && Safe(curr ^ step[1]) && ways[curr ^ step[1]] == -1)
{
q.push(curr ^ step[1]); // 将下一个状态入队
ways[curr ^ step[1]] = curr; // 标记当前状态为下一个状态的来源
}
if ((Farmer(curr) == Wolf(curr)) && Safe(curr ^ step[2]) && ways[curr ^ step[2]] == -1)
{
q.push(curr ^ step[2]); // 将下一个状态入队
ways[curr ^ step[2]] = curr; // 标记当前状态为下一个状态的来源
}
if ((Farmer(curr) == Vege(curr)) && Safe(curr ^ step[3]) && ways[curr ^ step[3]] == -1)
{
q.push(curr ^ step[3]); // 将下一个状态入队
ways[curr ^ step[3]] = curr; // 标记当前状态为下一个状态的来源
}
}
}
在这里,我们使用ways数组来保存状态和路径,确保路径不会重复(数组初始置-1,如果不是-1说明走过了,就不能再走这种状态)
而if语句中那一大长段则先判断可行性,然后再移动。比如只有当羊和农夫在同一侧,羊和农夫移动完状态安全,移动的位置是第一次出现。如下:
if ((Farmer(curr) == Sheep(curr)) && Safe(curr ^ step[1]) && ways[curr ^ step[1]] == -1)
最后,我给出全部代码:
#include<iostream>
#include<queue>
using namespace std;
queue<int> q;
int step[4] = { 0b1000, 0b1100, 0b1010, 0b1001 }; // 农夫分别带羊、狼、菜过河的四种操作
int ways[16]; // 保存每个状态的上一个状态(用于回溯路径)
int start = 0b0000; // 起始状态,农夫、羊、狼、菜都在左岸
int endd = 0b1111; // 目标状态,农夫、羊、狼、菜都在右岸
// 每个角色的当前状态
int Farmer(int pos)
{
if ((pos & 0b1000) == 0b1000)return 1;
else return 0;
}
int Sheep(int pos)
{
if ((pos & 0b0100) == 0b0100)return 1;
else return 0;
}
int Wolf(int pos)
{
if ((pos & 0b0010) == 0b0010)return 1;
else return 0;
}
int Vege(int pos)
{
if ((pos & 0b0001) == 0b0001)return 1;
else return 0;
}
// 状态是否安全
int Safe(int pos)
{
if ((Sheep(pos) == Wolf(pos)) && Farmer(pos) != Sheep(pos)) return 0; // 羊和狼在一起,农夫不在
if ((Sheep(pos) == Vege(pos)) && Farmer(pos) != Sheep(pos)) return 0; // 羊和菜在一起,农夫不在
return 1;
}
void BFS()
{
q.push(start); // 将起始状态入队
ways[start] = start; // 起始状态的上一个状态是它自己
while (!q.empty())
{
int curr = q.front(); // 取队头状态为当前状态
q.pop(); // 队头出队
if (curr == endd) return; //到达目标,结束
if (Safe(curr ^ step[0]) && ways[curr ^ step[0]] == -1)
{
q.push(curr ^ step[0]); // 将下一个状态入队
ways[curr ^ step[0]] = curr; // 标记当前状态为下一个状态的来源
}
if ((Farmer(curr) == Sheep(curr)) && Safe(curr ^ step[1]) && ways[curr ^ step[1]] == -1)
{
q.push(curr ^ step[1]);
ways[curr ^ step[1]] = curr;
}
if ((Farmer(curr) == Wolf(curr)) && Safe(curr ^ step[2]) && ways[curr ^ step[2]] == -1)
{
q.push(curr ^ step[2]);
ways[curr ^ step[2]] = curr;
}
if ((Farmer(curr) == Vege(curr)) && Safe(curr ^ step[3]) && ways[curr ^ step[3]] == -1)
{
q.push(curr ^ step[3]);
ways[curr ^ step[3]] = curr;
}
}
}
// 打印路径
void printPath()
{
for (int i = 0; i < 16; i++)
{
cout << ways[i]<<" ";
}
}
int main()
{
for (int i = 0; i < 16; i++) ways[i] = -1;
BFS(); // 执行广度优先搜索
printPath(); // 输出
return 0;
}
输出结果:
我们从最后一个(15)逐步往前推,然后写出全部过程(十进制) :
0 -> 12 -> 4 -> 14 -> 2 -> 11 -> 3 -> 15
二进制表示:
0000 -> 1100 -> 0100 -> 1110 -> 0010 -> 1011 -> 0011 -> 1111
自然语言描述:
农夫先带着羊去对岸,农夫自己回到原岸,农夫带着狼去对岸,农夫把羊带回原岸,农夫带着菜去对岸,农夫自己回到原岸,农夫带上羊去对岸。
优化输出(拓展)
引入bitset头文件后,我们可以将输出进一步优化,请读者自行阅读理解,不再做讲解。
// 打印路径,从终点回溯到起点
void printPath()
{
if (ways[endd] == -1) {
cout << "无解!" << endl;
return;
}
int path[16]; // 用来存储路径
int stepCount = 0;
int curr = endd;
// 回溯路径
while (curr != start) {
path[stepCount++] = curr;
curr = ways[curr];
}
path[stepCount++] = start;
// 反向输出路径
for (int i = stepCount - 1; i >= 0; i--) {
cout << "状态: " << bitset<4>(path[i]) << endl;
}
}
输出如下:(更加直观)