基础搜索算法的常见题型
重点:这是一份课件整理,出自杨乐大佬之手,就此声明。
首先,搜索是一种暴力,在数据范围小的情况下,枚举所有的可能性。我们来模拟处理问题的步骤。
搜索主要分两类:dfs(深度优先搜索)
:bfs(广度优先搜索)
dfs的经典例题:
范式:
void dfs(_position_,_state_)
if _success_ then _goal_
else
for every possible _move_//枚举每一种可能行动
if _acailable_ then//如果没有违法约束
_new_state_=(_state_,_move_)//计算新的状态
_new_possible_=nexr_position_ //计算下一个阶段
dfs(_new_position_,_new_state_)//进行下一步的搜索
1.数独问题
题目梗概:给出一个 9×9 的棋盘,上面已经填好了部分数字。要 求补充完整个棋盘,并且使得每行、每列、每个 3×3 小方阵内没 有重复的数字。
* 分析数独问题: 1 “划分游戏阶段”:从左到右从前到后,决定每一个空格的数字
2“行动”:对于某一个特定的空格,枚举上面填什么数字
3* “胜利”:全部填完了之后,判断这个填的方法是否符合条件
#include<iostream> #include <cstdio> #include <algorithm> using namespace std; const int N = 9; const int Group[9][9] = { 0, 0, 0, 1, 1, 1, 2, 2, 2, 0, 0, 0, 1, 1, 1, 2, 2, 2, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 3, 3, 3, 4, 4, 4, 5, 5, 5, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 6, 6, 6, 7, 7, 7, 8, 8, 8, 6, 6, 6, 7, 7, 7, 8, 8, 8 }; int a[N][N]; int row[N][N], col[N][N], gr[N][N]; void print() { printf("One Possible Solution:\n"); for(int i=0; i<N; i++) { for(int j=0; j<N; j++) printf("%d ", a[i][j] + 1); printf("\n"); } } void dfs3(int x, int y) { if(x == N) { print(); return; } int next_x = x, next_y = y + 1; if(next_y == N) next_x = x + 1, next_y = 0;//提前将下一步的位置处理好 if(a[x][y] >= 0) dfs3(next_x, next_y);//已经填好数了,那么我就跳过 else { for(int i=0; i<N; i++) if(!row[x][i] && !col[y][i] && !gr[Group[x][y]][i]) { row[x][i] = col[y][i] = gr[Group[x][y]][i] = 1; a[x][y] = i;//这个数没有出现过,就让它等于当前数 并标记 dfs3(next_x, next_y); a[x][y] = -1; row[x][i] = col[y][i] = gr[Group[x][y]][i] = 0; } /*每到一个位置,枚举每一个数,如果该数行,列,小正方都不重复,则填入 继续搜索下一个,之后便是回溯一步*/ } } int main() { for(int i=0; i<N; i++) for(int j=0; j<N; j++) scanf("%d", &a[i][j]), a[i][j]--;//因为我们从0开始存 printf("Dfs Method3:\n"); for(int i=0; i<N; i++) for(int j=0; j<N; j++) if(a[i][j] >= 0) { row[i][a[i][j]] = 1; col[j][a[i][j]] = 1; gr[Group[i][j]][a[i][j]] = 1; } /*把每一行每一列,每一小方块的已经给出的数全部标记为, 而这个group就是来判定这个位置是属于哪一个小方块的*/ /* printf("row:\n"); for(int i=0; i<N; i++) { for(int j=0; j<N; j++) printf("%d ", row[i][j]); printf("\n"); } printf("********************\n"); printf("col:\n"); for(int i=0; i<N; i++) { for(int j=0; j<N; j++) printf("%d ", col[i][j]); printf("\n"); } printf("********************\n"); printf("gr:\n"); for(int i=0; i<N; i++) { for(int j=0; j<N; j++) printf("%d ", gr[i][j]); printf("\n"); } printf("********************\n"); 如果对col,row,gr的定义还是不大懂的话,可以解锁以上程序加以理解*/ dfs3(0, 0); return 0; }
* 分析背包问题: 1 “划分游戏阶段”:从第一个物品开始,考虑它的选取情况
2 “行动”:对于某一个特定的物品,考虑它选不选
3* “约束”:选取完成后,还需要判断它是否能装得下
4* “最优方案”:如果可以装得下,我们需要选择一 个价值之和最大的
#include<iostream> #include<cstdio> using namespace std; int w[1001],v[1001],n; int ans,t; void dfs(int x,int totw,int totv) { if(x>n) { if(totw>ans) ans=totw; return ; } else { dfs(x+1,totw,totv); if(totv+v[x]<=t) dfs(x+1,totw+w[x],totv+v[x]); } } int main() { scanf("%d%d",&n,&t); for(int i=1;i<=n;i++) scanf("%d%d",&w[i],&v[i]); dfs(1,0,0); printf("%d",ans); return 0; }
对于这个问题我们来拓展一下:
1.如何记录拿了几件
//多定义一个a[1001] b[1001] void dfs(int x,int totw,int totv) { if(x>n) { if(totw>ans) { for(int i=1;i<=a[0];i++) b[i]=a[i]; ans=totw; } return ; } else { dfs(x+1,totw,totv); if(totv+v[x]<=t) { ++a[0],a[a[0]]=x; dfs(x+1,totw+w[x],totv+v[x]); a[a[0]]=0,--a[0]; //用a[0]来记录拿的个数 这个 也算一个技巧 } } }
2.完全背包(取无数件)
void dfs(int x,int totw,int totv) { if(x>n) { if(totw>ans) ans=totw; return ; } else { dfs(x+1,totw,totv); while(totv+v[x]*m<=t) { dfs(x+1,totw+w[x]*m,totv+v[x]*m); m+=1; } } }
3.多重背包:给了规定件数可以取,求最大价值
void dfs(int x,int totw,int totv) { if(x>n) { if(totw>ans) ans=totw; return ; } else { dfs(x+1,totw,totv); for(int i=1;i<=member[x];i++) { if(totv+v[x]*i<t) dfs(x+1,totw+w[x]*i,totv+v[x]*i); m+=1; } } }
当然,我们这里都是最最基本的搜索,欢迎补充优化
* 分析哈密顿回路问题: 1 “划分游戏阶段”:走到了哪一个点
2 “行动”:下一步走哪一个点
3* “胜利”:把全部点都走遍了
4* “约束”:在过程中有没有走到重复的点
#include <cstdio> #include <algorithm> using namespace std; const int N = 25; int n, cnt; int a[N][N]; int v[N], p[N]; void print() { printf("Route #%d: ", ++cnt); for(int i=1; i<=p[0]; i++) printf("%d - ", p[i]); printf("1\n"); } void dfs(int x) { v[x] = 1, p[++p[0]] = x;//记录走的那几个点 if(p[0] == n) { if(a[p[0]][1]) print();//如果头和尾也相连,输出 } else for(int i=1; i<=n; i++) if(a[x][i] && !v[i])//有路径并且没有走过->搜索 dfs(i); v[x] = 0, p[p[0]] = 0, --p[0];//回溯 } int main() { // freopen("hamilton.in", "r", stdin); // freopen("hamilton.out", "w", stdout); scanf("%d", &n); for(int i=1; i<=n; i++) for(int j=1; j<=n; j++) scanf("%d", &a[i][j]);//用邻接矩阵来存储的哈密尔顿环 dfs(1); return 0; }
*POJ 1979 红与黑:考虑 问题的各个步骤
1 阶段/ 搜索顺序:一步一步的走
2 状态:目前走到的位置
3 行动:向上下左右各走一步
4 胜利条件/ 结束条件:没有地方可以走了
5 约束:不能走出房间/ 不能走到红色瓷砖上
#include <cstdio> using namespace std; const int N = 25; const int dx[4] = {1, 0, -1, 0}; const int dy[4] = {0, 1, 0, -1}; int n, m, sx, sy, cnt; int a[N][N], vs[N][N]; void dfs(int x, int y) { ++cnt, vs[x][y] = 1;//计数加标记 for(int d=0; d<4; d++) { int i=x+dx[d], j=y+dy[d];//上下左右四个方向 if(i<1 || j<1 || i>n || j>m || a[i][j]=='#' || vs[i][j]) continue; //不越界,不违法约束 dfs(i, j); } } //当然,这道题用bfs也可以做,但都差不多 int gc() { int c; while(c=getchar(), c<=' '); /*先输入数据,之后getchar()的返回值一个接一个赋给c, 然后比较c是不是不等于' ',如果不等于回车键就会执行循环。 如果等于就会结束*/ //相当于用输入字符矩阵 return c; } void work() { for(int i=1; i<=n; i++) for(int j=1; j<=m; j++) { a[i][j] = gc(), vs[i][j] = 0; if(a[i][j]=='@') sx = i, sy = j;//标记所给位置 } /* ch=getchar();等待从键盘上输入一个字符 putchar(ch);输出此字符 他们包含在头文件 #include<stdio.h>中. */ cnt = 0; dfs(sx, sy);//从所给位置开始搜索 printf("%d\n", cnt); } int main() { freopen("rnb.in", "r", stdin); freopen("rnb.out", "w", stdout); while(scanf("%d%d", &m, &n), n*m) work(); //且m!=0||n!=0 ,当然它的数据是保证合法的 //用while循环输入,它的输入输出端是自动连在文件上的, //所以用键盘输入是不会有结果的 return 0; }
DFS小总结:* 问题的各个步骤:
1 阶段/ 搜索顺序
2 状态:全局状态记得回溯(有的不需要)
3 行动:枚举所有可能的行动
4 胜利条件/ 结束条件
5 约束
下面我们来看BFS(前方高能预警)
* BFS 搜索的思想是十分简单的: - 给定一个起始状态(将其记作 S0);
- 记录从起始状态向外走一步能到达的状态(我们把它记作 S1);
- 记录从 S1 走一步能到达的状态(将它们记作 S2);
显然,这些 状态都能从起始状态走两步到达。
* 最终,如果目标状态出现在 Sk 中,则显然它可以从起始状态 走k 步到达(所以最终答案是最小的 k)。
* 我们称这种做法为逐步扩展。
八数码
题目太经典就不显示了,而且这道题目曾经搞的我一晚上也没睡好,所以我今天一定要把它办掉
八数码问题的状态即为棋盘上的八个数字
从每一个空格开始上下左右各一种可能
扩展出第一种状态,依次类推
补充一点,bfs 是用队列来实现的
但有一个很重要的问题就是 :如何判重,以及哪些是重复的。
(好像这里有图的样子)
#include<iostream> #include<cstdio> using namespace std; int main() { printf("1 2 3\n"); printf("5 6\n"); printf("7 8 9\n"); cout<<"---->" printf(" 1 2 3\n"); printf(" 5 6 \n"); printf(" 7 8 9\n"); //那么把从这个空格开始向四个方向扩展,必定有一种情况是这样的 printf("1 2 3\n"); printf("5 6\n"); printf("7 8 9\n"); //这样就回到原来的状态了,即为重复 //而我们要克服的就是这些 }
接下来我们从程序入手
本来应该是三步优化,一步比一步快,但我想,我写的既然是一个总结帖,我为什么不直接把最优的展示出来能?(好像很有道理的样子)
其实是我懒
下面我们看程序
struct _state { int w[N][N];//当前这个棋盘的状态 int x, y;//该空格的位置 int step;//走了几步 int pre, dirc;//父亲和对应的操作
如何判重?
如果我们开一个九维数组的话,哪怕是开bool型的,有以下几个麻烦之处
1.写起来麻烦
2.存起来费空间
3.查找起来费时间
4.看着别扭
综上所述,我们聪明的前辈想到了一种很不错的存储办法,
------>用一个九位数来存
这样只需要int就足够了
像这样:
int calc() { int ret = 0; for(int i=0; i<N; i++) for(int j=0; j<N; j++) ret = ret * 10 + w[i][j]; return ret; }
那么程序实现就是
int bfs(_state start, _state end) { head = tail = 1; start.step = 0; start.pre = -1; queue[1] = start; S.clear(); S.insert(start.calc()); for(; head<=tail; ++head) { _state cur = queue[head]; int x = cur.x, y = cur.y; for(int i=0; i<4; i++) { int nx = x + dx[i], ny = y + dy[i]; if(nx<0 || ny<0 || nx>=N || ny>=N) continue; //枚举上下左右四个位置 _state now = cur;//新的状态 now.x = nx, now.y = ny;//新的空格位置 swap(now.w[x][y], now.w[nx][ny]);//交换位置 now.step++;//步数+1 now.pre = head;//记录父亲 now.dirc = i;//操作:空格移动的方向 int nst = now.calc(); if(S.find(nst) == S.end())//用set的一个判重,下面会讲:如果没有重复的数字 queue[++tail] = now,//加入队列 S.insert(nst);//并标记该状态 if (equal(now,end)) return tail; } } return -1; }
补充一点:set是个神马东东
set集合容器实现了红黑树(Red-Black Tree)的平衡二叉检索树的的数据结构,在插入元素时,它会自动调整二叉树的排列,把该元素放到适当的位置,以确保每个子树根节点的键值大于左子树所有节点的键值,而小于右子树所有节点的键值;另外,还得确保根节点的左子树的高度与右子树的高度相等,这样,二叉树的高度最小,从而检索速度最快。要注意的是,它不会重复插入相同键值的元素,而采取忽略处理。
平衡二叉检索树的检索使用中序遍历算法,检索效率高于vector、deque、和list的容器。另外,采用中序遍历算法可将键值由小到大遍历出来,所以,可以理解为平衡二叉检索树在插入元素时,就会自动将元素按键值从小到大的顺序排列。
构造set集合的主要目的是为了快速检索,使用set前,需要在程序头文件中包含声明“#include<set>”。
采用inset()方法把元素插入到集合中,插入规则在默认的比较规则下,是按元素值从小到大插入,如果自己指定了比较规则函数,则按自定义比较规则函数插入。使用前向迭代器对集合中序遍历,结果正好是元素排序后的结果。
下面我先放一波程序
#include <set> #include <cstdio> #include <algorithm> using namespace std; const int N = 3; const int M = 1e6 + 5; const int dx[4] = {0, 0, 1, -1}; const int dy[4] = {1, -1, 0, 0};//四个方向搜索的小技巧 const char mov[4] = {'R', 'L', 'D', 'U'}; struct _state { int w[N][N]; int x, y; int step; int pre, dirc; void read()//读入矩阵并存储空格位置 { for(int i=0; i<N; i++) for(int j=0; j<N; j++) { scanf("%d", &w[i][j]); if(!w[i][j]) x=i, y=j; } } int calc()//将矩阵改为一个九位数 { int ret = 0; for(int i=0; i<N; i++) for(int j=0; j<N; j++) ret = ret * 10 + w[i][j]; return ret; } }; int equal(_state x, _state y)// { for(int i=0; i<N; i++) for(int j=0; j<N; j++) if(x.w[i][j] != y.w[i][j]) return 0; return 1; } _state queue[M]; set<int> S; int head, tail; int bfs(_state start, _state end)//开始状态,结束状态各有两个矩阵来表示 { head = tail = 1;//队列为空 start.step = 0;//初始步骤为零 start.pre = -1;//初始状态没有父亲 queue[1] = start;//初始状态入队 S.clear();//清空set S.insert(start.calc());//插入这个矩阵所转化为的九位数 for(; head<=tail; ++head)//如果队列不为空 { _state cur = queue[head];//取队首 int x = cur.x, y = cur.y;//复制空格位置 for(int i=0; i<4; i++) { int nx = x + dx[i], ny = y + dy[i]; if(nx<0 || ny<0 || nx>=N || ny>=N) continue; _state now = cur; now.x = nx, now.y = ny; swap(now.w[x][y], now.w[nx][ny]);//拓展空格位置 now.step++; now.pre = head;//记录是第几步 now.dirc = i;//从哪个方向拓展来的 int nst = now.calc(); if(S.find(nst) == S.end())//end()返回集合里最后一个元素的迭代器
//返回指向被查找元素的迭代器
//find()可以查找一个元素在集合里的迭代器,如果该元素未出现过,则返回end() queue[++tail] = now,//(注意是逗号)使该元素入队 S.insert(nst); if (equal(now,end)) return tail;//如果该矩阵已经和要求矩阵一样了 } } return -1; } int seq[N]; void print(int no, int x) { printf("Step #%d\n", x); for(int i=0; i<N; i++) { for(int j=0; j<N; j++)
printf("%d ", queue[no].w[i][j]);//输出当前步数下的矩阵 printf("\n"); } } int main() { //freopen("eight.in", "r", stdin); // freopen("eight.out", "w", stdout); _state start, end; start.read(); end.read(); int ans = bfs(start, end); printf("%d\n", queue[ans].step);
s: for(int i=tail; i>=0; i=queue[i].pre) seq[++seq[0]] = i;//用s[0]来存储个数 for(int i=seq[0]; i>=1; i--) print(seq[i], seq[0]-i);//输出矩阵
printf("Moving Sequence: \n"); for(int i=seq[0]-1; i>=1; i--) printf("%c", mov[ queue[seq[i]].dirc ]);//输出该矩阵的操作 return 0; }
注意,bfs是逐层扩展的,相当于给第一次一个编号,给第二层一个编号。。。。。。
不要与dfs混淆
下面我们看跳马问题
跳马就是八个方向遍历,然后判重,如程序
#include <cstdio> #include <algorithm> using namespace std; const int N = 303; const int dx[8] = {-2, -1, 1, 2, 2, 1, -1, -2}; const int dy[8] = {1, 2, 2, 1, -1, -2, -2, -1}; struct _state { int x, y, step; }; int t, n; int sx, sy, tx, ty; int vs[N][N]; int head, tail; _state que[N * N]; void work() { scanf("%d", &n); scanf("%d%d", &sx, &sy); scanf("%d%d", &tx, &ty); for(int i=0; i<n; i++) for(int j=0; j<n; j++) vs[i][j] = 0;//标记初始状态全部为未走过 head = tail = 1; que[head].x = sx; que[head].y = sy;//入队 que[head].step = 0;//这个步数还是逐层扩展的 vs[sx][sy] = 1; for(; head <= tail; ++head) { int x = que[head].x; int y = que[head].y; int step = que[head].step; if(x == tx && y == ty)//到达目标,输出 { printf("%d\n", step); return; } for(int i=0; i<8; i++) { int nx = x + dx[i]; int ny = y + dy[i]; int nstep = step + 1;//大佬做深/广搜题喜欢先把下一步的状态写出来,然后再判断,再入队/搜索 if(nx<0 || ny<0 || nx>=n || ny>=n) continue; if(vs[nx][ny]) continue; ++tail; que[tail].x = nx; que[tail].y = ny; que[tail].step = nstep; vs[nx][ny] = 1;//这里就是判重,使得它必定能找到解 } } } int main() { //freopen("knight.in", "r", stdin); //freopen("knight.out", "w", stdout); scanf("%d", &t); for(int i=1; i<=t; i++) work(); return 0; }
接下来是倒水问题,
倒水问题:有两个水壶,容积分别为 A,B。有三种操作: Fill(往一个水壶倒满水),Drop(倒出某个水壶的水),Pour(将 其中一个水壶的水倒入另一个水壶中,倒满即止)。
- 问倒出 C 升水,最少的步数是多少。
这个我们就分六步来进行讨论
设水壶a,b;
1.倒空a
2.倒空b;
3.倒满a;
4.倒满b;
5.把a倒向b(这里需要判断a,b水量)
6.把b倒向a(这里需要判断a,b水量)
具体操作如下
#include <cstdio>
#include <string>
#include <algorithm>
using namespace std;
const int N = 103;
const string operate[6] =
{
"FILL(1)", "FILL(2)", "DROP(1)",
"DROP(2)", "POUR(1,2)", "POUR(2,1)"
};
struct _state//每一个水杯状态所含的消息
{
int x, y;
int step;
int pre, op;
};
int a, b, c;
int vs[N][N];
int head, tail;
_state que[N * N];
int seq[N];
void print(int x)
{
printf("%d\n", que[x].step);
for(int i=x; i>=0; i=que[i].pre)
seq[++seq[0]] = i;
for(int i=seq[0]-1; i>=1; i--)
printf("%s\n", operate[que[seq[i]].op].c_str());//c_str()下文有讲,
}
//总结一下这种倒向输出转移状态的步骤
/*
1.新开一个数组来倒叙存储下标
2.一个倒叙for,输出最后一步所在队列的状态,用刚才存储的下标来做
3.前提:你在进行bfs扩展时,已经存储了它的父节点,已经操作方式
*/
void add_state(int nx, int ny, int nstep, int pre, int op)
{
if(vs[nx][ny]) return;
vs[nx][ny] = 1;//标记该状态已经走过
++tail;
que[tail].x = nx;
que[tail].y = ny;
que[tail].step = nstep;
que[tail].pre = pre;
que[tail].op = op;
//入队
}
void work()
{
scanf("%d%d%d", &a, &b, &c);
for(int i=0; i<=a; i++)
for(int j=0; j<=b; j++) vs[i][j] = 0;
head = tail = 1;
que[head].x = 0;
que[head].y = 0;
que[head].step = 0;
que[head].pre = -1;
vs[0][0] = 1;//初始状态入队
for(; head <= tail; ++head)
{
int x = que[head].x;
int y = que[head].y;
int step = que[head].step;//取队首
if(x == c || y == c)
{
print(head);//正解,输出
return;
}
// Fill
add_state(a, y, step+1, head, 0);
add_state(x, b, step+1, head, 1);
// Drop
add_state(0, y, step+1, head, 2);
add_state(x, 0, step+1, head, 3);
// Pour
int aug = min(x, b - y);
add_state(x-aug, y+aug, step+1, head, 4);
aug = min(y, a - x);
add_state(x+aug, y-aug, step+1, head, 5);
}
puts("impossible");
}
int main()
{
freopen("cups.in", "r", stdin);
freopen("cups.out", "w", stdout);
work();
return 0;
}
补充:
函数声明:const char *c_str();
c_str()函数返回一个指向正规C字符串的指针, 内容与本string串相同.
这是为了与c语言兼容,在c语言中没有string类型,故必须通过string类对象的成员函数c_str()把string 对象转换成c中的字符串样式。
注意:一定要使用strcpy()函数 等来操作方法c_str()返回的指针
例:
应该这样用:
char c[20];
string s="1234";
strcpy(c,s.c_str());
这样才不会出错,c_str()返回的是一个临时指针,不能对其进行操作
c_str()返回的是一个分配给const char*的地址,其内容已设定为不可变更,如果再把此地址赋给一个可以变更内容的char*变量,就会产生冲突,在2010中是不被允许的。但是如果放入函数调用,或者直接输出,因为这些函数和输出都是把字符串指针作为 const char*引用的,所以不会有问题。
c_str() 以const char* 类型返回 string 内含的字符串
如果一个函数要求char*参数,可以使用c_str()方法:
string s = "Hello World!";
printf("%s", s.c_str()); //输出 "Hello World!"
c_str在打开文件时的用处:
当需要打开一个由用户自己输入文件名的文件时,可以这样写:ifstream in(st.c_str());。其中st是string类型,存放的即为用户输入的文件名。
bfs小结
* BFS 问题的各个步骤:
1 阶段/ 搜索顺序:题目会明确地给出
2 状态:与 DFS 类似,要保存所有的信息
3 行动:与 DFS 类似,枚举所有可能的行动
4 胜利条件/ 结束条件:题目明确地给出
5 约束
6* 判重:如何判断状态已经重复?- DFS 问题中,使用判重来减少搜索量:记忆化搜索
7* 输出方案:通过记录父亲状态,一步一步回推。 - 为什么在 DFS 问题中,我们没有着重提出如何输出方案?
好了,那么到现在为止,基础搜索题目我已经把我所学到的全部整理完了,
如果你认为还有和这些题目同样经典的,请私信找我
byebye!\(^o^)/~