算法部分 基础5
一、广度优先搜索的简述
广度优先搜索,用队列保存待扩展的节点。从队首取出节点,扩展出的新节点放入队尾直到出现目标节点。广度优先搜索框架如下
Bfs(){
初始化队列
while(队列不为空未能找到目标节点){
取队首节点扩展,并将扩展出的非重复节点放入队尾;
必要时要记住每个节点的父节点;
}
}
新扩展出的节点如果和以前扩展出的节点相同,则新节点就不必再考虑。如何判重?状态节点数目巨大,如何存储?怎么才能较快判断一个状态是否重复?这些都是要通过实际问题权衡分析的。
广度优先搜索引入 – 抓住那头牛
农夫知道一头牛的位置,想要抓住它。农夫和牛都位于数轴上,农夫起始位于点 N (0 <= N <= 100000) , 牛位于点 K (0 <= K <= 100000) 。农夫有两种移动方式:
1. 从 X 移动到 X - 1 或者 X + 1 , 每次移动花费一分钟
2. 从 X 移动到 2 * X , 每次移动花费一分钟
假设牛没有意识到农夫的行动,站在原地不动。农夫最少要花多少时间才能抓住牛?
用有向图的方式描述农夫和牛的位置关系,
假设农夫起始位于点 3 , 牛位于 5 , N = 3 , K = 5 , 最右边是 6 .
如何搜索到一条走到 5 的路径?
策略如下
策略 1 深度优先搜索:从起点出发,随机挑选一个方向,能往前走就往前走(扩展),走不
动就回溯。不能走已经走过的点 (要判重) .
运气好的话:
3 -> 4 -> 5 或者,
3 -> 6 -> 5
运气不太好:
3 -> 2 -> 4 -> 5 或者
3 -> 2 -> 1 -> 0 -> 4 -> 5
要想求解最优(短)解,则要遍历所有走法。可以用各种手段优化,比如,若已经找到路径
长度为 n 的解,则所有长度大于 n 的走法就不必尝试。
运算过程中需要存储路径上的节点,数量较少。用栈存节点。
策略 2 广度优先搜索:
a. 给节点分层。起点是 0 层。从点最少只需要 n 步就能达到的点属于第 n 层次, 比
如
第一层: 2, 4, 6
第二层: 1, 5
第三层: 0
b. 然后依层次顺序,从小到大扩展节点。把层次低的点全部扩展出来后,才会扩招层次
高的点。
搜索过程 (节点扩展过程):
3
2 4 6
1 5
问题就解决了。注意:扩展时,不能扩展出已经走过的节点 (要判重) .
可以确保找到最优解,但是因为扩展出来的节点较多,且多数节点都需要保存,因此需要
的存储空间比较大。用队列存节点。
接着具体是队列变化的过程,看着图了解
两个队列变化情况
Closed 是进入的节点, Open 是扩展的节点
-------------------------- // 从初始节点开始
Closed
3 Open
-------------------------- // 扩展 3 节点
3 Closed
2 4 6 Open
-------------------------- // 扩展 2 节点(会判重) 新节点 1 入扩展队列
3 2 Closed
4 6 1 Open
-------------------------- // 扩展 4 节点(会判重) 新节点 5 入扩展队列
3 2 4 Closed
6 1 5 Open
-------------------------- // 扩展 6 节点(会判重) 都重复了
3 2 4 6 Closed
1 5 Open
-------------------------- // 扩展 1 节点(会判重) 新节点 0 入扩展队列
3 2 4 6 1 Closed
5 0 Open
-------------------------- // 目标节点 5 出列,问题解决!
3 2 4 6 1 5 Closed
0 Open
找到的第一个目标节点就是最优节点
这样通过 5 节点的指针或者层次号,就可以找到节点的路径关系。
程序如下,
#include <iostream>
#include <cstring>
#include <queue>
using namespace std;
int N, K;
const int MAXN = 100000;
// 判重标记,visited[i] = true 表示 i 已经扩展过
int visited[MAXN + 10];
// 类可以用结构体表示
struct Step{
int pos; // 位置
int steps; // 到达 x 所需要的步数
Step( int xx, int s ): pos(xx), steps(s){}
};
queue < Step > q; // 队列,即 Open 表
int main(){
cin >> N >> K; // 输入起点和终点
memset( visited, 0, sizeof(visited) );
q.push(Step(N, 0));
visited[N] = 1;
while(!q.empty()){
Step s = q.front(); // 队列头元素
if(s.pos == K){ // 找到目标
cout << s.steps << endl;
return 0;
}
else{
// 往左边走
if(s.pos - 1 >= 0 && ! visited[s.pos - 1]){
q.push(Step(s.pos - 1, s.steps + 1));
visited[s.pos - 1] = 1;
}
// 往右边走
if(s.pos + 1 <= MAXN && ! visited[s.pos + 1] ){
q.push(Step(s.pos + 1, s.steps + 1));
visited[s.pos + 1] = 1;
}
// 跳着走 s.pos 移动到 2 * s.pos , 感觉是飞着走
if(s.pos * 2 <= MAXN && ! visited[s.pos * 2]){
q.push(Step(s.pos * 2, s.steps + 1));
visited[s.pos * 2] = 1;
}
q.pop();
}
}
return 0;
}
可以这样理解,先对三种方式满足要求进行入队列,依次出队列。在出的队列中以元素为起始,再进行三种方式入队列,并且判断重复,这样遍历找到目标位置。但是这道题不是特别典型。
二、广度优先搜索的例子
1. 迷宫问题
问题描述:定义一个矩阵
0 1 0 0 0
0 1 0 1 0
0 0 0 0 0
0 1 1 1 0
0 0 0 1 0
它表示一个迷宫,其中 1 表示墙壁,0 表示可以走的路,只能横着走或者竖着走,不能
斜着走,要求编程序找出从左上角到左下角最短路线。
问题分析
基础的广搜问题。首先将起始位置入队列
每次从队列拿出一个元素,扩展其相邻的 4 个元素的队列(要判重)
直到队头元素为终点为止。队列里的元素记录了指向父节点(上一步)的指针
队列元素:
struct {
int r, c; // 坐标
int f; // 父节点在队列中的下标
}
队列不能用 STL 的 queue 和 deque , 要自己写,因为要留存数据。用一维数组实
现,维护一个队头指针 head 和队尾 tail 指针。 head = tail 就是空的
程序如下,感觉理解还不是很清楚,自己写不出来程序。以后学习完成补充上。
2. 八数码问题
八数码问题是人工智能中的经典问题,问题描述如下
有一个 3 * 3 的棋盘,其中有 0 - 8 共 9 个数字,0 表示空格,其他的数字可以和 0 交换位置,求由初始状态到达目标状态的步数最少的解。
8 2 3 1 2 3
1 4 6 ---> 4 5 6
5 7 0 7 8 0
分析如下
用合理的编码表示“状态”,减小存储
方案一:
每个状态用一个字符串存储,要 9 个字节,浪费空间,注意是每个状态!
方案二:
. 每个状态对应一个 9 位数,则该 9 位数最大为 876,543,210, 小于 2^31 , 则
int 就能表示一个状态。
. 判重需要一个标志位序列,每个状态对应于标志位序列中的 1 位,标志位为 0 , 表
示该状态尚未扩展,为 1 则说明已经扩展过了。
. 标志位序列可以用字符数组 a 存放。a 的每个元素存放 8 个状态的标志位。最多需
要 876,543,210 / 8 + 1 个元素,即是 109,567,902 字节
. 如果某个状态对应于数 x , 则标志位就是 a[x / 8] 的第 x % 8 位
. 但是这个空间还是太大了
方案三:
. 将每个状态的字符串形式看作一个 9 进制数,则该 9 位数最大为 876543210(9),
即是 381367044(10) 需要的标志位数目也降为 381367044(10) 比特,即是47,670,881
字节。
. 如果某个状态对应于数 x , 则其标志位就是 a[x/8] 的第 x % 8 位
. 空间要求还是大。
方案四:
. 把每个状态都看做 ’0‘ - ’8‘ 的一个排列,以此排列在全部排列中的位置作为其序
号。状态用其排列序号来表示。
. 012345678 是第 0 个排列,876543210 是第 9! - 1 个
. 状态总数即排列总数是:9! = 362880
. 判重用的标志数组 a 只需要 362880 比特即可。
. 如果某个状态的序号为 x , 则其标志位就是 a[x / 8] 的第 x % 8 位
----
在进行状态空间转移,把字符串移动转化为 int 类型存储,再把 int 型转化为 字符串
形式移动。但是函数运行会消耗时间,相当于用时间交换空间。
方案5:
. 还是用一个状态看作一个数的 10 进制表示形式
. 用 set<int> 进行判重。每入队一个状态,就能将其加入到 set 里面,判重时,查
找该 set , 看能否找到状态。
输入数据情况
输入数据:
2 3 4 1 5 x 7 6 8
输出数据:
ullddrurdllurdruldr
这里序列中
u 表示使空格上移
d 表示使空格下移
l 表示使空格左移
r 表示使空格右移
相当于
8 2 3 1 2 3
1 4 6 ---> 4 5 6
5 7 0 7 8 0
程序如下
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <set>
using namespace std;
int goalStatus = 123456780; // 目标状态
const int MAXS = 400000;
char result[MAXS]; // 输出要移动状态
struct Node{
int status; // 状态
int father; // 父结点指针,即 myQueue 的下标
char Move; // 父结点到本结点的移动方式:u/d/r/l
Node(int s, int f, char m): status(s), father(f), Move(m){ }
Node(){ }
};
Node myQueue[MAXS]; // 状态队列,状态总数为 362880
int qHead = 0; // 队头指针
int qTail = 1; // 队尾指针
char moves[] = "udrl"; // 4 种移动
void InitStatusToStrStatus(int n, char * strStatus){
sprintf(strStatus, "%09d", n); // 需要保留前 9 个
}
int NewStatus(int status, char cMove){
// 求从 status 经过 cMove 移动后得到的新状态。若状态不可行,则返回 -1
char tmp[20];
int zeroPos; // 字符 '0' 的位置
InitStatusToStrStatus(status, tmp);
for(int i = 0; i < 9; i++){
if(tmp[i] == '0'){
zeroPos = i;
break;
} // 返回空格位置
}
switch(cMove){
case 'u':
if(zeroPos - 3 < 0)
return -1; // 空格在第一行
else{
tmp[zeroPos] = tmp[zeroPos - 3];
tmp[zeroPos - 3] = '0';
}
break;
case 'd':
if(zeroPos + 3 > 8)
return -1; // 空格在第三行
else{
tmp[zeroPos] = tmp[zeroPos + 3];
tmp[zeroPos + 3] = '0';
}
break;
case 'l':
if(zeroPos % 3 == 0)
return -1; // 空格在第一列
else{
tmp[zeroPos] = tmp[zeroPos - 1];
tmp[zeroPos - 1] = '0';
}
break;
case 'r':
if(zeroPos % 3 == 2)
return -1; // 空格在第三列
else{
tmp[zeroPos] = tmp[zeroPos + 1];
tmp[zeroPos + 1] = '0';
}
break;
}
return atoi(tmp);
}
bool Bfs(int status){
// 寻找从初始状态 status 到目标的路径,找不到则返回 false
int newStatus;
set <int> expanded;
expanded.insert(status);
myQueue[qHead] = Node(status, -1, 0);
while(qHead != qTail){ // 队列不为空
status = myQueue[qHead].status;
if(status == goalStatus) // 找到目标状态
return true;
for(int i = 0; i < 4; i++){ // 尝试 4 种移动
newStatus = NewStatus(status, moves[i]);
if(newStatus == -1)
continue; // 不可移,尝试下一种
if(expanded.find(newStatus) != expanded.end() )
continue; // 已经扩展过,尝试下一种
expanded.insert(newStatus);
myQueue[qTail++] = Node(newStatus, qHead, moves[i]); // 新结点入队列
}
qHead ++;
}
return false;
}
int main(){
goalStatus = atoi("123456780"); // 目标状态
char line1[50];
char line2[20];
while(cin.getline(line1, 48)){
int i, j;
// 将输入的原始字符串变成数字的字符串
for(i = 0, j = 0; line1[i]; i++){
if(line1[i] != ' '){
if(line1[i] == 'x')
line2[j++] = '0';
else
line2[j++] = line1[i];
}
}
line2[j] = 0; // 字符串形式的初始状态
if(Bfs(atoi(line2))){
int moves = 0;
int pos = qHead;
do{ // 通过 father 找到成功的状态序列,输出相应步骤
result[moves++] = myQueue[pos].Move;
pos = myQueue[pos].father;
}while(pos); // pos = 0 说明已经回退到初始状态了
for(int i = moves - 1; i >= 0; i--)
cout << result[i];
}
else
cout << "unsolvable" << endl;
}
return 0;
}
运行结果如下,
分析
主要出发点从广度优先搜索,并且算法会判断重复,使用两个队列来实现,实现的队列
通过数组。
对于Bfs的程序:
while(qHead != qTail){ // 队列不为空
status = myQueue[qHead].status;
if(status == goalStatus) // 找到目标状态
return true;
for(int i = 0; i < 4; i++){ // 尝试 4 种移动
newStatus = NewStatus(status, moves[i]);
if(newStatus == -1)
continue; // 不可移,尝试下一种
// 找到返回迭代器,没有找到则返回end()
if(expanded.find(newStatus) != expanded.end()) // set 数据存储方式
continue; // 已经扩展过,尝试下一种
expanded.insert(newStatus);
// myQueue[qTail] 的前一个节点是 qHead 作为记录的
myQueue[qTail++] = Node(newStatus, qHead, moves[i]); // 新结点入队列
}
qHead ++;
}
分析:
判断重复的方式是通过 set 来实现:
if(expanded.find(newStatus) != expanded.end()) // set 数据存储方式
continue; // 已经扩展过,尝试下一种
这个语句的广度优先搜索的核心:
myQueue[qTail++] = Node(newStatus, qHead, moves[i]);
在 for 循环中,相当于 myQueue[qTail] 存储的节点是可以找到它的父节点
的,通过 qHead 来实现。
其他地方都相对好理解一些,具体的都在代码中做了标注。
三、总结
广度优先搜索主要通过两个队列实现。具体学习还需要多看例子多理解。感觉这个章节学习的不是很透彻,在接下来练习中要继续加强练习。
最近出来外面做实验出差了,9月19日出来的,9月30号才可以回去。一直忙着调试实验设备,今天刚把用 matlab 实时采集脑电部分弄好,准备继续调试刺激部分,从而写算法实现闭环调控。