内容为武汉大学国家网络安全学院2022级大一第三学期“996”实训课程中所做的笔记,仅供个人复习使用,如有侵权请联系本人,将于15个工作日内将博客设置为仅粉丝可见。
启发式搜索
输入格式
第一行有 2 个整数 T(1≤T≤1000) 和 M(1≤M≤100),用一个空格隔开,T 代表总共能够用来采药的时间,M 代表山洞里的草药的数目。
接下来的 M 行每行包括两个在 1 到 100 之间(包括 1 和 100)的整数,分别表示采摘某株草药的时间和这株草药的价值。
输出格式
输出在规定的时间内可以采到的草药的最大总价值。
样例输入
70 3
71 100
69 1
1 2
样例输出
3
参考程序(会超时):
#include <stdio.h>
#define N 101
#define max(a,b) (((a)>(b))?(a):(b))
int ans = 0, t, m, a[N], b[N];
void dfs(int i, int sa, int sb) {
ans = max(ans, sb);
if (i == m) return;
dfs(i + 1, sa, sb);
if (a[i] <= sa) dfs(i + 1, sa - a[i], sb + b[i]);
}
int main() {
scanf("%d%d", &t, &m);
for (int i = 0; i < m; i++) scanf("%d%d", &a[i], &b[i]);
dfs(0, t, 0);
printf("%d\n", ans);
return 0;
}
启发式搜索是一种极为高效的剪枝和搜索决策方法,它与「盲目搜索(BFS/DFS)」相对应。
几种寻路算法的演示:PathFinding.js
在 DFS 和 BFS 中,我们不会特别地确定搜索过程中在每次状态转移时枚举的顺序,而当我们定好一个决策的依据时,往往可以使得我们搜索的效率显著增加。
启发式搜索是一类搜索策略,包含 A*、IDA*、模拟退火等算法。其中,对于设计算法策略最常用的是 A* 算法。
我们定义:
- f(x):搜索决策的依据
- h(x):状态 x 到目标状态的代价,如果是 0 则 x 是目标状态
- g(x):初始状态到状态 x 的代价
我们在搜索时,依据 f(x)=h(x)+g(x) 来完成决策,可以看出 g(x) 在计算的过程中是确定的,而 h(x) 是无法精确求出的,否则我们的答案应该可以直接算出。
所以我们往往需要估算 h(x),将其估算的值记为h′(x)。
如果我们要求解的 f(x) 是最小值(比如从起点到终点的最短路),那么我们的估算值 h′(x) 应该满足 h′(x)≤h(x),这样当
f′(x)=h′(x)+g(x)≥ans
的时候,就可以直接回溯了,因为再继续搜索一定无法更新最终的最小值答案。
A* 参考程序:
#include <stdio.h>
#define N 101
#define max(a,b) (((a)>(b))?(a):(b))
int ans = 0, t, m, a[N], b[N];
int h(int now, int s) { // 估价函数
int sum = 0;
for (int i = now + 1; i < m; i++) {
if (s >= a[i]) {
s -= a[i];
sum += b[i];
} else {
return sum + b[i] * 1.0 / a[i] * s;
}
}
return sum;
}
void dfs(int i, int sa, int sb) {
ans = max(ans, sb);
if (i == m) return;
if (h(i - 1, sa) + sb <= ans) return;
dfs(i + 1, sa, sb);
if (a[i] <= sa) dfs(i + 1, sa - a[i], sb + b[i]);
}
void swap(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
int main() {
scanf("%d%d", &t, &m);
for (int i = 0; i < m; i++) scanf("%d%d", &a[i], &b[i]);
for (int i = 0; i < m; i++)
for (int j = i + 1; j < m; j++)
if (b[i] * a[j] < a[i] * b[j]) {
swap(&a[i], &a[j]);
swap(&b[i], &b[j]);
}
dfs(0, t, 0);
printf("%d\n", ans);
return 0;
}
[NOIP2005]采药
辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。为此,他想拜附近最有威望的医师为师。医师为了判断他的资质,给他出了一个难题。医师把他带到一个到处都是草药的山洞里对他说:“孩子,这个山洞里有一些不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子,你应该可以让采到的草药的总价值最大。”
如果你是辰辰,你能完成这个任务吗?
输入格式
第一行有两个整数 T(1≤T≤1000)和 M(1≤M≤100),用一个空格隔开,T代表总共能够用来采药的时间,M 代表山洞里的草药的数目。
接下来的 M 行每行包括两个在 1 到 100 之间(包括 1 和 100 )的整数,分别表示采摘某株草药的时间和这株草药的价值。
输出格式
包括一行,这一行只包含一个整数,表示在规定的时间内,可以采到的草药的最大总价值。
数据范围
对于 30 的数据,M≤10;
对于全部的数据, M≤100。
格式说明
输出时每行末尾的多余空格,不影响答案正确性
样例输入
70 3
71 100
69 1
1 2
样例输出
3
#include <stdio.h>
#define N 101
#define max(a,b) (((a)>(b))?(a):(b))
int ans = 0, t, m, a[N], b[N];
int h(int now, int s) { // 估价函数
int sum = 0;
for (int i = now; i < m; i++) {
if (s >= a[i]) {
s -= a[i];
sum += b[i];
} else {
return sum + b[i] * 1.0 / a[i] * s;
}
}
return sum;
}
void dfs(int i, int sa, int sb) {
ans = max(ans, sb);
if (i == m) return;
if (h(i, sa) + sb <= ans) return;
dfs(i + 1, sa, sb);
if (a[i] <= sa) dfs(i + 1, sa - a[i], sb + b[i]);
}
void swap(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
int main() {
scanf("%d%d", &t, &m);
for (int i = 0; i < m; i++) scanf("%d%d", &a[i], &b[i]);
for (int i = 0; i < m; i++)
for (int j = i + 1; j < m; j++)
if (b[i] * a[j] < a[i] * b[j]) {
swap(&a[i], &a[j]);
swap(&b[i], &b[j]);
}
dfs(0, t, 0);
printf("%d\n", ans);
return 0;
}
仙道求药
少年李逍遥的婶婶病了,王小虎介绍他去一趟仙灵岛,向仙女姐姐要仙丹救婶婶。叛逆但孝顺的李逍遥闯进了仙灵岛,克服了千险万难来到岛的中心,发现仙药摆在了迷阵的深处。迷阵由 M×N 个方格组成,有的方格内有可以瞬秒李逍遥的怪物,而有的方格内则是安全。现在李逍遥想尽快找到仙药,显然他应避开有怪物的方格,并经过最少的方格,而且那里会有神秘人物等待着他。现在要求你来帮助他实现这个目标。
输入格式
第一行输入两个非零整数 M 和 N,两者均不大于 20。M 表示迷阵行数, N 表示迷阵列数。
接下来有 M 行, 每行包含 N 个字符,不同字符分别代表不同含义:
1) '@':少年李逍遥所在的位置;2) '.':可以安全通行的方格;3) '#':有怪物的方格;4) '*':仙药所在位置。
输出格式
输出一行,该行包含李逍遥找到仙药需要穿过的最少的方格数目(计数包括初始位置的方块)。如果他不可能找到仙药, 则输出 −1。
朴素 DFS 程序:
#include <stdio.h>
#define min(a,b) (((a)<(b))?(a):(b))
char mat[30][30];
int n, m, ans = 1e9, step[4][2] = {1, 0, -1, 0, 0, 1, 0, -1}, vst[30][30];
int in(int x, int y) {
return x >= 0 && x < n && y >= 0 && y < m;
}
void dfs(int x, int y, int d) {
if (mat[x][y] == '*') {
ans = min(ans, d);
return;
}
vst[x][y] = 1;
for (int i = 0; i < 4; i++) {
int tx = x + step[i][0];
int ty = y + step[i][1];
if (in(tx, ty) && mat[tx][ty] != '#' && !vst[tx][ty]) {
dfs(tx, ty, d + 1);
}
}
vst[x][y] = 0;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) {
scanf("%s", mat[i]);
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (mat[i][j] == '@') {
dfs(i, j, 0);
printf("%d\n", ans == 1e9 ? -1 : ans);
}
}
}
return 0;
}
启发式搜索程序
#include <stdio.h>
#define min(a,b) (((a)<(b))?(a):(b))
char mat[30][30];
int n, m, ans = 1e9, step[4][2] = {1, 0, -1, 0, 0, 1, 0, -1}, vst[30][30], dx, dy;
int in(int x, int y) {
return x >= 0 && x < n && y >= 0 && y < m;
}
int abs(int x) {
return x > 0 ? x : -x;
}
int h(int x, int y) {
return abs(dx - x) + abs(dy - y);
}
void dfs(int x, int y, int d) {
if (mat[x][y] == '*') {
ans = min(ans, d);
return;
}
if (d + h(x, y) >= ans) return;
vst[x][y] = 1;
for (int i = 0; i < 4; i++) {
int tx = x + step[i][0];
int ty = y + step[i][1];
if (in(tx, ty) && mat[tx][ty] != '#' && !vst[tx][ty]) {
dfs(tx, ty, d + 1);
}
}
vst[x][y] = 0;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) {
scanf("%s", mat[i]);
for (int j = 0; j < m; j++) {
if (mat[i][j] == '*') dx = i, dy = j;
}
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (mat[i][j] == '@') {
dfs(i, j, 0);
printf("%d\n", ans == 1e9 ? -1 : ans);
}
}
}
return 0;
}
还有一种优化思路:记忆化。将到达每个点的当前最短距离记录下来,如果再次进入当前点,但当前的步数已经不小于最短距离的话,直接回溯。
#include <stdio.h>
#define min(a,b) (((a)<(b))?(a):(b))
char mat[30][30];
int n, m, ans = 1e9, step[4][2] = {1, 0, -1, 0, 0, 1, 0, -1}, vst[30][30], dis[30][30];
int in(int x, int y) {
return x >= 0 && x < n && y >= 0 && y < m;
}
void dfs(int x, int y, int d) {
if (dis[x][y] <= d) return;
dis[x][y] = d;
if (mat[x][y] == '*') {
ans = min(ans, d);
return;
}
vst[x][y] = 1;
for (int i = 0; i < 4; i++) {
int tx = x + step[i][0];
int ty = y + step[i][1];
if (in(tx, ty) && mat[tx][ty] != '#' && !vst[tx][ty]) {
dfs(tx, ty, d + 1);
}
}
vst[x][y] = 0;
}
int main() {
memset(dis, 0x3f, sizeof(dis));
scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) {
scanf("%s", mat[i]);
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (mat[i][j] == '@') {
dfs(i, j, 0);
printf("%d\n", ans == 1e9 ? -1 : ans);
}
}
}
return 0;
}
蒜头君分玩具
蒜头君有 n 个玩具要分给 n 个小朋友,每个小朋友对每个玩具都有一个喜爱值,第 i 个小朋友对第 j 个玩具的喜爱值是 a[i][j] 。
现在蒜头君希望将玩具都分下去,但是希望小朋友们的喜爱值之和可以最大,请你帮他计算一下,如何分配可以使得喜爱值之和达到最大?
输入格式
输入第一行包含一个正整数 n,表示有 n 个玩具和 n 个小朋友。
第二行至第 n+1 行共 n 行,每行有 n 个以空格分隔的正整数。第 i+1 行的第 j 个数 k(1≤k≤1000),表示第 i 个小朋友对第 j 个玩具的喜爱值为 k。
输出格式
输出只有一行,该行只有一个正整数,表示求得的喜爱值之和的最大值。
样例输入
3
10 6 8
9 2 3
1 7 2
样例输出
24
这道题看数据范围就可以发现肯定是个搜索,暴力搜索的复杂度是 O(n!) 必然超时。那么我们就需要一些剪枝。
乍一看,可行性剪枝没法加,最优性剪枝也不好加,因为你到这里以后也不好判断后边得到的会不会一定比目前最优解小。
不过细想,如果我们能知道到这里以后,后边能得到的最大值是多少,如果目前的解加上后边的最大值都不如目前最优解好,那就可以剪枝了。
这样问题就转化成了如何求解后续操作的最优值,这个很难算,不过如果我们能估计出一个值,并且能保证这个估计的值不小于后续操作的最优值。那么如果目前的解加上这个估计的值都不够目前最优解好,那实际走下去也一定不会好,那就可以剪枝了。
怎么估计呢,我们可以让后边每个人都取到自己最喜欢的玩具,这就是个理论上的后续操作的最优值,虽然很可能达不到,不过足够当我们的估计值了。
所以我们可以先处理出每个小朋友可能得到的最大的喜爱值,然后做一个后缀和,在搜索过程中,如果目前得到的值 now 加上后边的后缀和小于等于目前的最优解 ans ,就可以剪枝。
迭代加深搜索(选学)
迭代加深是一种 每次限制搜索深度的 深度优先搜索。
它的本质还是深度优先搜索,只不过在搜索的同时带上了一个深度 ,当 达到设定的深度时就返回,一般用于找最优解。如果一次搜索没有找到合法的解,就让设定的深度加一,重新从根开始。
既然是为了找最优解,为什么不用 BFS 呢?我们知道 BFS 的基础是一个队列,队列的空间复杂度很大,当状态比较多或者单个状态比较大时,使用队列的 BFS 就显出了劣势。事实上,迭代加深就类似于用 DFS 方式实现的 BFS,它的空间复杂度相对较小。
当搜索树的分支比较多时,每增加一层的搜索复杂度会出现指数级爆炸式增长,这时前面重复进行的部分所带来的复杂度几乎可以忽略,这也就是为什么迭代加深是可以近似看成 BFS 的。
步骤
首先设定一个较小的深度作为全局变量,进行 DFS。每进入一次 DFS,将当前深度加一,当发现 大于设定的深度 就返回。如果在搜索的途中发现了答案就可以回溯,同时在回溯的过程中可以记录路径。如果没有发现答案,就返回到函数入口,增加设定深度,继续搜索。
代码框架
IDDFS(u,d)
if d>limit
return
else
for each edge (u,v)
IDDFS(v,d+1)
return
注意事项
在大多数的题目中,广度优先搜索还是比较方便的,而且容易判重。当发现广度优先搜索在空间上不够优秀,而且要找最优解的问题时,就应该考虑迭代加深。
迭代加深搜索时长和启发式搜索一起使用,以提高搜索的效率。
例题
在一个 5×5 的棋盘上有 12 个白色的骑士和 12 个黑色的骑士, 且有一个空位。在任何时候一个骑士都能按照骑士的走法(它可以走到和它横坐标相差为 1、纵坐标相差为 2,或者横坐标相差为 2、纵坐标相差为 1 的格子)移动到空位上。
给定一个初始的棋盘,怎样才能经过移动变成如下目标棋盘:
为了体现出骑士精神,他们必须以最少的步数完成任务。
学习资料
visualising data structures and algorithms through animation - VisuAlgo