Day 4 - 搜索进阶与模拟
启发式搜索
下面将简要介绍启发式搜索及其用法。
定义
启发式搜索(英文:\(\text{heuristic search}\))是一种在普通搜索算法的基础上引入了启发式函数的搜索算法。
启发式函数的作用是基于已有的信息对搜索的每一个分支选择都做估价,进而选择分支。简单来说,启发式搜索就是对取和不取都做分析,从中选取更优解或删去无效解。
例题
由于概念过于抽象,这里使用例题讲解。
题目大意:有 \(N\) 种物品和一个容量为 \(W\) 的背包,每种物品有重量 \(w_i\) 和价值 \(v_i\) 两种属性,要求选若干个物品(每种物品只能选一次)放入背包,使背包中物品的总价值最大,且背包中物品的总重量不超过背包的容量。
解题思路:
我们写一个估价函数 \(f\),可以剪掉所有无效的 \(0\) 枝条(就是剪去大量无用不选枝条)。
估价函数 \(f\) 的运行过程如下:
我们在取的时候判断一下是不是超过了规定体积(可行性剪枝);在不取的时候判断一下不取这个时,剩下的药所有的价值 + 现有的价值是否大于目前找到的最优解(最优性剪枝)。
示例代码:
#include <algorithm>
#include <cstdio>
using namespace std;
const int N = 105;
int n, m, ans;
struct Node {
int a, b; // a 代表时间,b 代表价值
double f;
} node[N];
bool operator<(Node p, Node q) { return p.f > q.f; }
int f(int t, int v) { // 计算在当前时间下,剩余物品的最大价值
int tot = 0;
for (int i = 1; t + i <= n; i++)
if (v >= node[t + i].a) {
v -= node[t + i].a;
tot += node[t + i].b;
} else
return (int)(tot + v * node[t + i].f);
return tot;
}
void work(int t, int p, int v) {
ans = max(ans, v);
if (t > n) return; // 边界条件:只有n种物品
if (f(t, p) + v > ans) work(t + 1, p, v); // 最优性剪枝
if (node[t].a <= p) work(t + 1, p - node[t].a, v + node[t].b); // 可行性剪枝
}
int main() {
scanf("%d %d", &m, &n);
for (int i = 1; i <= n; i++) {
scanf("%d %d", &node[i].a, &node[i].b);
node[i].f = 1.0 * node[i].b / node[i].a; // f为性价比
}
sort(node + 1, node + n + 1); // 根据性价比排序
work(1, m, 0);
printf("%d\n", ans);
return 0;
}
A* 算法
本页面将简要介绍 A * 算法。
定义
A * 搜索算法(英文:A*\(\text{search algorithm}\),A * 读作 \(\text{A-star}\)),简称 A * 算法,是一种在图形平面上,对于有多个节点的路径求出最低通过成本的算法。它属于图遍历(英文:\(\text{Graph traversal}\))和最佳优先搜索算法(英文:\(\text{Best-first search}\)),亦是 \(\text{BFS}\) 的改进。
过程
定义起点 \(s\),终点 \(t\),从起点(初始状态)开始的距离函数 \(g(x)\),到终点(最终状态)的距离函数 \(h(x)\),\(h^{\ast}(x)^1\),以及每个点的估价函数 \(f(x)=g(x)+h(x)\)。
A * 算法每次从优先队列中取出一个 \(f\) 最小的元素,然后更新相邻的状态。
如果 \(h\leq h*\),则 A * 算法能找到最优解。
上述条件下,如果 \(h\) 满足三角形不等式,则 A * 算法不会将重复结点加入队列。
当 \(h=0\) 时,A * 算法变为 \(\text{Dijkstra}\);当 \(h=0\) 并且边权为 \(1\) 时变为 \(\text{BFS}\)。
例题
八数码。
题目大意:在 \(3\times 3\) 的棋盘上,摆有八个棋子,每个棋子上标有 \(1\) 至 \(8\) 的某一数字。棋盘中留有一个空格,空格用 \(0\) 来表示。空格周围的棋子可以移到空格中,这样原来的位置就会变成空格。给出一种初始布局和目标布局(为了使题目简单,设目标状态如下),找到一种从初始布局到目标布局最少步骤的移动方法。
123
804
765
解题思路:
\(h\) 函数可以定义为,不在应该在的位置的数字个数。
容易发现 \(h\) 满足以上两个性质,此题可以使用 A * 算法求解。
参考代码:
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <queue>
#include <set>
using namespace std;
const int dx[4] = {1, -1, 0, 0}, dy[4] = {0, 0, 1, -1};
int fx, fy;
char ch;
struct matrix {
int a[5][5];
bool operator<(matrix x) const {
for (int i = 1; i <= 3; i++)
for (int j = 1; j <= 3; j++)
if (a[i][j] != x.a[i][j]) return a[i][j] < x.a[i][j];
return false;
}
} f, st;
int h(matrix a) {
int ret = 0;
for (int i = 1; i <= 3; i++)
for (int j = 1; j <= 3; j++)
if (a.a[i][j] != st.a[i][j]) ret++;
return ret;
}
struct node {
matrix a;
int t;
bool operator<(node x) const { return t + h(a) > x.t + h(x.a); }
} x;
priority_queue<node> q; // 搜索队列
set<matrix> s; // 防止搜索队列重复
int main() {
st.a[1][1] = 1; // 定义标准表
st.a[1][2] = 2;
st.a[1][3] = 3;
st.a[2][1] = 8;
st.a[2][2] = 0;
st.a[2][3] = 4;
st.a[3][1] = 7;
st.a[3][2] = 6;
st.a[3][3] = 5;
for (int i = 1; i <= 3; i++) // 输入
for (int j = 1; j <= 3; j++) {
scanf(" %c", &ch);
f.a[i][j] = ch - '0';
}
q.push({f, 0});
while (!q.empty()) {
x = q.top();
q.pop();
if (!h(x.a)) { // 判断是否与标准矩阵一致
printf("%d\n", x.t);
return 0;
}
for (int i = 1; i <= 3; i++)
for (int j = 1; j <= 3; j++)
if (!x.a.a[i][j]) fx = i, fy = j; // 查找空格子(0号点)的位置
for (int i = 0; i < 4; i++) { // 对四种移动方式分别进行搜索
int xx = fx + dx[i], yy = fy + dy[i];
if (1 <= xx && xx <= 3 && 1 <= yy && yy <= 3) {
swap(x.a.a[fx][fy], x.a.a[xx][yy]);
if (!s.count(x.a))
s.insert(x.a),
q.push({x.a, x.t + 1}); // 这样移动后,将新的情况放入搜索队列中
swap(x.a.a[fx][fy], x.a.a[xx][yy]); // 如果不这样移动的情况
}
}
}
return 0;
}
注:对于 k 短路问题,原题已经可以构造出数据使得 A* 算法无法通过,故本题思路仅供参考,A* 算法非正解,正解为可持久化可并堆做法。
k 短路。
按顺序求一个有向图上从结点 \(s\) 到结点 \(t\) 的所有路径最小的前任意多(不妨设为 \(k\))个。
解题思路:
很容易发现,这个问题很容易转化成用 A * 算法解决问题的标准程式。
初始状态为处于结点 \(s\),最终状态为处于结点 \(t\),距离函数为从 \(s\) 到当前结点已经走过的距离,估价函数为从当前结点到结点 \(t\) 至少要走过的距离,也就是当前结点到结点 \(t\) 的最短路。
就这样,我们在预处理的时候反向建图,计算出结点 \(t\) 到所有点的最短路,然后将初始状态塞入优先队列,每次取出 \(f(x)=g(x)+h(x)\) 最小的一项,计算出其所连结点的信息并将其也塞入队列。当你第 \(k\) 次走到结点 \(t\) 时,也就算出了结点 \(s\) 到结点 \(t\) 的 \(k\) 短路。
由于设计的距离函数和估价函数,每个状态需要存储两个参数,当前结点 \(x\) 和已经走过的距离 \(v\)。
我们可以在此基础上加一点小优化:由于只需要求出第 \(k\) 短路,所以当我们第 \(k+1\) 次或以上走到该结点时,直接跳过该状态。因为前面的 \(k\) 次走到这个点的时候肯定能因此构造出 \(k\) 条路径,所以之后再加边更无必要。
参考代码:
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <queue>
using namespace std;
const int maxn = 5010;
const int maxm = 400010;
const double inf = 2e9;
int n, m, k, u, v, cur, h[maxn], nxt[maxm], p[maxm], cnt[maxn], ans;
int cur1, h1[maxn], nxt1[maxm], p1[maxm];
double e, ww, w[maxm], f[maxn];
double w1[maxm];
bool tf[maxn];
void add_edge(int x, int y, double z) { // 正向建图函数
cur++;
nxt[cur] = h[x];
h[x] = cur;
p[cur] = y;
w[cur] = z;
}
void add_edge1(int x, int y, double z) { // 反向建图函数
cur1++;
nxt1[cur1] = h1[x];
h1[x] = cur1;
p1[cur1] = y;
w1[cur1] = z;
}
struct node { // 使用A*时所需的结构体
int x;
double v;
bool operator<(node a) const { return v + f[x] > a.v + f[a.x]; }
};
priority_queue<node> q;
struct node2 { // 计算t到所有结点最短路时所需的结构体
int x;
double v;
bool operator<(node2 a) const { return v > a.v; }
} x;
priority_queue<node2> Q;
int main() {
scanf("%d%d%lf", &n, &m, &e);
while (m--) {
scanf("%d%d%lf", &u, &v, &ww);
add_edge(u, v, ww); // 正向建图
add_edge1(v, u, ww); // 反向建图
}
for (int i = 1; i < n; i++) f[i] = inf;
Q.push({n, 0});
while (!Q.empty()) { // 计算t到所有结点的最短路
x = Q.top();
Q.pop();
if (tf[x.x]) continue;
tf[x.x] = true;
f[x.x] = x.v;
for (int j = h1[x.x]; j; j = nxt1[j]) Q.push({p1[j], x.v + w1[j]});
}
k = (int)e / f[1];
q.push({1, 0});
while (!q.empty()) { // 使用A*算法
node x = q.top();
q.pop();
cnt[x.x]++;
if (x.x == n) {
e -= x.v;
if (e < 0) {
printf("%d\n", ans);
return 0;
}
ans++;
}
for (int j = h[x.x]; j; j = nxt[j])
if (cnt[p[j]] <= k && x.v + w[j] <= e) q.push({p[j], x.v + w[j]});
}
printf("%d\n", ans);
return 0;
}
参考资料与注释
\(^1\): 此处的 \(h\) 意为 \(\text{heuristic}\)。详见 启发式搜索 - 维基百科 和 A*\(\text{search algorithm - Wikipedia}\) 的 \(\text{Bounded relaxation}\) 一节。
迭代加深搜索
定义
迭代加深是一种 每次限制搜索深度的 深度优先搜索。
解释
迭代加深搜索的本质还是深度优先搜索,只不过在搜索的同时带上了一个深度 \(d\),当 \(d\) 达到设定的深度时就返回,一般用于找最优解。如果一次搜索没有找到合法的解,就让设定的深度加一,重新从根开始。
既然是为了找最优解,为什么不用 \(\text{BFS}\) 呢?我们知道 \(\text{BFS}\) 的基础是一个队列,队列的空间复杂度很大,当状态比较多或者单个状态比较大时,使用队列的 \(\text{BFS}\) 就显出了劣势。事实上,迭代加深就类似于用 \(\text{DFS}\) 方式实现的 \(\text{BFS}\),它的空间复杂度相对较小。
当搜索树的分支比较多时,每增加一层的搜索复杂度会出现指数级爆炸式增长,这时前面重复进行的部分所带来的复杂度几乎可以忽略,这也就是为什么迭代加深是可以近似看成 \(\text{BFS}\) 的。
过程
首先设定一个较小的深度作为全局变量,进行 \(\text{DFS}\)。每进入一次 \(\text{DFS}\),将当前深度加一,当发现 \(d\) 大于设定的深度 \(\textit{limit}\) 就返回。如果在搜索的途中发现了答案就可以回溯,同时在回溯的过程中可以记录路径。如果没有发现答案,就返回到函数入口,增加设定深度,继续搜索。
实现(伪代码):
IDDFS(u,d)
if d>limit
return
else
for each edge (u,v)
IDDFS(v,d+1)
return
注意事项
在大多数的题目中,广度优先搜索还是比较方便的,而且容易判重。当发现广度优先搜索在空间上不够优秀,而且要找最优解的问题时,就应该考虑迭代加深。
模拟
下面将简要介绍模拟算法。
简介
模拟就是用计算机来模拟题目中要求的操作。
模拟题目通常具有码量大、操作多、思路繁复的特点。由于它码量大,经常会出现难以查错的情况,如果在考试中写错是相当浪费时间的。
技巧
写模拟题时,遵循以下的建议有可能会提升做题速度:
- 在动手写代码之前,在草纸上尽可能地写好要实现的流程。
- 在代码中,尽量把每个部分模块化,写成函数、结构体或类。
- 对于一些可能重复用到的概念,可以统一转化,方便处理:如,某题给你 "\(\text{YY-MM-DD}\) 时:分" 把它抽取到一个函数,处理成秒,会减少概念混淆。
- 调试时分块调试。模块化的好处就是可以方便的单独调某一部分。
- 写代码的时候一定要思路清晰,不要想到什么写什么,要按照落在纸上的步骤写。
实际上,上述步骤在解决其它类型的题目时也是很有帮助的。
例题详解
一只长度不计的蠕虫位于 \(n\) 英寸深的井的底部。它每次向上爬 \(u\) 英寸,但是必须休息一次才能再次向上爬。在休息的时候,它滑落了 \(d\) 英寸。之后它将重复向上爬和休息的过程。蠕虫爬出井口需要至少爬多少次?如果蠕虫爬完后刚好到达井的顶部,我们也设作蠕虫已经爬出井口。
解题思路:
直接使用程序模拟蠕虫爬井的过程就可以了。用一个循环重复蠕虫的爬井过程,当攀爬的长度超过或者等于井的深度时跳出。
参考代码:
#include <cstdio>
int main(void) {
int n = 0, u = 0, d = 0;
std::scanf("%d%d%d", &u, &d, &n);
int time = 0, dist = 0;
while (true) { // 用死循环来枚举
dist += u;
time++;
if (dist >= n) break; // 满足条件则退出死循环
dist -= d;
}
printf("%d\n", time); // 输出得到的结果
return 0;
}
习题
本文来自博客园,作者:So_noSlack,转载请注明原文链接:https://www.cnblogs.com/So-noSlack/p/18295642