搜索优化——启发式搜索和A*算法

搜索优化------启发式搜索和A*算法

关于搜索

目前主流的搜索算法,若在历史中寻找前人研究的痕迹,基本可以发现搜索算法基本都可以追溯到人工智能的研究甚至是人工智能早期研究的核心内容之一

搜索算法的共同特点

搜索算法大致可用如下状态进行描述:

状态 (state)。状态是对搜索算法和搜索环境当前所处情形的描述信息 。为了简化求解过程,常常会删除一些与问题求解无关的细节描述,只保留 一些对求解模型产生作用的信息

动作 (action)。为了完成最短路径的探索,算法需要不断从一个状态转移到下一个状态。算法从一个状态转移 到另外一个状态所采取的行为称为动作。在 dfs 中常表现为当前递归开辟下一层递归,在 bfs 中常表现为处理完当前状态后,根据 "队头" 转换到新的状态。

状态转移 (state transition)。算法选择了一个动作之后,其所处状态也会发生相应变化,这个过程称为状态转移。

路径 (path)和代价 (cost)。以任何一个状态为起点,搜索算法执行一系列动作后,将会在不同状态之间不断转移。将这个过程中经历的状态记录下来,可以得到一个状态序列 ,这个状态序列称为一条路径

目标测试 (goal test)。目标测试函数 goal_test (s) 用于判断状态 s 是否为目标状态

设计任何一个搜索算法都会考虑这些情况。

人工智能中的搜索

搜索是人工智能求解中的一种主要技术,它依据已有信息寻找满足约束条件 的待求解问题答案 。搜索算法一般可以包括无信息搜索 (uninformed search)、有信息搜索 (informed search)和对抗搜索(adversarial search)。

无信息搜索 是一种盲目搜索方法。按照所利用的搜索策略中扩展被搜索空间中结点次序的不同可分为:

  • 广度优先搜索(Breadth-First Search)及其变种。
  • 深度优先搜索(Depth-First Search)及其剪枝和变种。

有信息搜索 也称为启发式搜索,这种方法在搜索过程中可利用与所求解问题相关的辅助信息,代表性算法为:

  • 贪婪最佳优先搜索(Greedy Best-First Search)
  • A ∗ A^* A∗ 搜索(A-Star Search Algorithm)

对抗搜索 也称为博弈搜索 (game search),指在一个竞争的环境 中,智能体(agent)之间通过竞争实现相反的利益 ,一方最大化这个利益 ,另外一方最小化这个利益。代表性算法包括:

  • 最小最大搜索(Minimax Search)
  • α − β \alpha-\beta α−β 剪枝搜索(Alpha-Beta Pruning Search)
  • 蒙特卡洛树搜索(Monte Carlo Tree Search)

其中蒙特卡洛树搜索(MCTS) 几乎没有在算法竞赛中出现过。因为 MCTS 由于需要大量随机模拟、实现复杂且效率不确定,不适合竞赛环境。

目前已研究的算法:

这里将继续研究有信息搜索。

启发式搜索

启发式搜索 (Heuristically Search),又称为有信息搜索 (Informed Search),它是利用问题拥有的启发信息来引导搜索 ,达到减少搜索范围降低问题复杂度的目的。

启发式搜索的实现可以在普通搜索算法的基础上引入启发式函数

启发式函数的作用是基于已有的信息 对搜索的每一个分支选择都做估价进而选择分支 。简单来说,启发式搜索就是对取和不取都做分析,从中选取更优解或删去无效解.

启发式搜索的代表性算法为:

  • 贪婪最佳优先搜索(Greedy Best-First Search)
  • A ∗ A^* A∗ 搜索(A-Star Search Algorithm)

这里重点介绍 A ∗ A^* A∗ 搜索算法。贪婪最佳优先算法带一些贪心属性,不保证找到最优路径,这里不研究。

A*搜索算法

A ∗ A^* A∗ 搜索算法(A-star search algorithm),简称 A ∗ A^* A∗ 算法,是一种在带权有向图 上,找到给定起点与终点之间的最短路径的算法,属于 bfs 的优化变种。

A ∗ A^* A∗ 算法的原理是在带松弛操作的 bfs 算法的基础上,引入 3 个函数:

  • f ( x ) f(x) f(x) 表示 x x x 到终点的预估距离,几乎所有中文教材都叫它估价函数
  • g ( x ) g(x) g(x) 表示起点到 x x x 的实际距离 。类比的话就是图的最短路算法中的 dist 数组或动态规划的 dp 表。
  • h ( x ) h(x) h(x) 表示 x x x 到终点的大致距离,最常用的称呼是启发函数

这 3 个函数满足如下关系:

f ( x ) ⏟ 估价函数 = g ( x ) ⏟ 实际距离 + h ( x ) ⏟ 启发函数 \underbrace{f(x)}{估价函数}=\underbrace{g(x)}{实际距离}+\underbrace{h(x)}_{启发函数} 估价函数 f(x)=实际距离 g(x)+启发函数 h(x)

比起 bfs , A ∗ A^* A∗ 算法在实现上更像是堆优化的 dijstra 算法的专门求 2 点之间的最短路径的特化版本, dijstra 算法实际上是 A ∗ A^* A∗ 算法的启发函数恒为 0 的特例。

dijkstra 算法于 1959 年由荷兰计算机科学家艾兹赫尔 · 戴克斯特拉 (Edsger Dijkstra) 发表论文,而 A ∗ A^* A∗ 则是斯坦福研究院的 Peter Hart, Nils Nilsson, Bertram Raphael 于 1968 年提出。

A ∗ A^* A∗ 在 dijstra 算法的基础上提出了启发函数 h ( x ) h(x) h(x) ,所以 dijstra 算法是 A ∗ A^* A∗ 的启发函数 h ( x ) h(x) h(x) 恒为 0 的特例。

A ∗ A^* A∗ 算法在堆优化的 dijstra 算法基础上,引入启发函数 h ( x ) h(x) h(x) ,每个结点入队时的权值不再是实际距离,而是 f ( x ) f(x) f(x) ,或 h ( x ) h(x) h(x) 不为 0 的最短距离。

A*算法的组件称呼

f ( x ) f(x) f(x) 、 g ( x ) g(x) g(x) 和 h ( x ) h(x) h(x) 在国内有一堆称呼,因为这一块是国外先研究的,翻译到国内的版本都是那些大佬自己叫的顺口的。

f ( x ) f(x) f(x) :

  • 估价函数(最常用,几乎所有中文教材)
  • 评估函数(同样常见)
  • 评价函数(部分文献)
  • 代价估计函数(强调综合代价)
  • 优先函数(强调用于优先级比较)
  • 总代价估计(字面意思)
  • 启发式估价函数(错误,因为 h h h 才是启发式,但有人混用)

g ( x ) g(x) g(x) :

  • 实际代价(最常见)
  • 实际距离(当代价为距离时)
  • 已支付代价(强调已发生)
  • 路径代价(从起点到当前节点的累计代价)
  • 真实代价(区别于估计)
  • 过去代价(对应未来代价)

h ( x ) h(x) h(x) :

  • 启发函数(最通用,标准术语)
  • 启发式估价函数(强调其估计性质)
  • 估计代价(简洁)
  • 剩余代价估计(描述性)
  • 启发式信息(侧重于信息)
  • 启发值(日常简称)
  • 启发代价(少见)
  • 未来代价估计(与 g 的"过去"对应)

这里统一口径, f ( x ) f(x) f(x) 统一称为估价函数 , g ( x ) g(x) g(x) 称为实际代价 , h ( x ) h(x) h(x) 称为启发函数

A*算法的设计思路总结

为了保证第一次从堆中取出目标状态时得到的就是最优解,设计的启发函数需要满足的条件:

  1. 启发函数的估值不能大于未来的实际代价 ,即 h ( x ) ≤ h ∗ ( x ) h(x)\le h^{*}(x) h(x)≤h∗(x) , h ∗ ( x ) h^{*}(x) h∗(x) 表示 x x x 到目标状态的实际最小代价。这个特性在某些课本上称为可容性可采纳性(Admissible 的不同翻译)。

    若不满足这个条件,在算法中这个结点可能会被压在堆底,不会被获取,若这个刚好就和终点有关联,则会出现超时的可能。

  2. 对于任意状态 x x x 及其通过动作 a a a 到达的后继状态 y y y,有 h ( x ) ≤ c ( x , a , y ) + h ( y ) h(x)\le c(x,a,y)+h(y) h(x)≤c(x,a,y)+h(y) ,其中 c ( x , a , y ) c(x,a,y) c(x,a,y) 是从 x x x 到 y y y 的实际代价。这个特性在某些课本上称为一致性单调性(Consistency 的不同翻译)。

  3. 对于所有状态 x x x 要求 h ( x ) ≥ 0 h(x)≥0 h(x)≥0 ,且对目标状态 a i m aim aim 要有 h ( a i m ) = 0 h(aim)=0 h(aim)=0 。这个特性在某些课本上被称为非负性(Non-negativity)。

  4. 启发函数的计算时间应远低于实际搜索的扩展代价,否则过度复杂的启发函数会抵消剪枝带来的收益。

A ∗ A^* A∗ 算法提高搜索效率的关键,就在于能否设计出一个优秀的启发函数。启发函数在满足上述设计准则的前提下,还应该尽可能反映未来实际代价的变化趋势和相对大小关系,这样搜索才会较快地逼近最优解。

P1379 八数码难题 - 洛谷

P1379 八数码难题 - 洛谷

第 1 次做这个题,是在广度优先搜索(bfs)合集-CSDN博客,用的 bfs;

第 2 次做这个题,是在搜索优化------双向bfs和Meet-in-the-middle折半搜索-CSDN博客,用的双向 bfs 。

这次是第 3 次,将使用 A ∗ A^* A∗ 算法解决这个题。

每次移动只能将空格与相邻的一个数字交换。该数字的位置和数字的目标位置在曼哈顿距离上的变化最多为 ± 1 \pm1 ±1,因为只移动了一格,而其他数字的曼哈顿距离不变。因此,一次操作最多使曼哈顿距离总和减少 1。

设实际最优解需要 d d d 步,每一步最多让曼哈顿距离减少 1,所以初始曼哈顿距离 h ≤ d h\le d h≤d 。即 h h h 是实际步数的下界 ,满足 A ∗ A^* A∗ 算法对启发函数的可采纳性 要求( h ≤ h ∗ h\le h^* h≤h∗)。

从状态 A 经一步到 B,曼哈顿距离的变化不超过 1,因此 h ( A ) ≤ 1 + h ( B ) h(A)\le 1+h(B) h(A)≤1+h(B) ,满足一致性 条件,保证 A ∗ A^* A∗ 算法每个状态只扩展一次。

所以可定义估值函数 f ( s t ) = g ( s t ) + m h t ( s t , a i m ) f(st)=g(st)+mht(st,aim) f(st)=g(st)+mht(st,aim) , g ( s t ) g(st) g(st) 是起点距离 s t st st 的实际距离, m h t ( x , y ) mht(x,y) mht(x,y) 表示 2 个数码对位的曼哈顿距离,也就是 A ∗ A^* A∗ 算法的组件 h ( x ) h(x) h(x) 换个名称。

m h t ( x , y ) mht(x,y) mht(x,y) 的代码实现:

cpp 复制代码
// Manhattan Distance 曼哈顿距离
inline LL mht(string &a, string &b) {
    LL ans = 0;
    for (int i = 0; i <= 9; i++) {
        LL p1 = a.find(char(i + '0'), 0), p2 = b.find(char(i + '0'), 0);
        ans += abs((p1 / 3) - (p2 / 3)) + abs((p1 % 3) - (p2 % 3));
    }
    return ans;
}

绝对值函数保证所有的 h ( x ) h(x) h(x) 非负,所以可以用这个特殊的曼哈顿距离作为启发函数 h ( x ) h(x) h(x) 。

状态定义有更好地表示方式,但这里毕竟作为 A ∗ A^* A∗ 算法第 1 题,所以使用方便描述的。

P1379 八数码难题 - 洛谷 参考程序:

cpp 复制代码
#include <bits/stdc++.h>
#include <unordered_map>
using namespace std;
using LL = long long;
using vl = vector<LL>;
using pls = pair<LL, string>;
using vpls = vector<pls>;

LL dx[] = {0, 0, -1, 1};
LL dy[] = {1, -1, 0, 0};

// Manhattan Distance 曼哈顿距离
inline LL mht(string &a, string &b) {
    LL ans = 0;
    for (int i = 0; i <= 9; i++) {
        LL p1 = a.find(char(i + '0'), 0), p2 = b.find(char(i + '0'), 0);
        ans += abs((p1 / 3) - (p2 / 3)) + abs((p1 % 3) - (p2 % 3));
    }
    return ans;
}

// A*更像引入估价函数的dijstra
LL Astar(string &start, string &aim) {
    if (start == aim)
        return 0;
    priority_queue<pls, vpls, greater<pls>> q;
    unordered_map<string, LL> g;     // start到自身的实际距离
    unordered_map<string, bool> vis; // 避免重复扩展
    g[start] = 0;
    q.push({g[start] + mht(start, aim), start}); // 入队的是评估函数值
    while (q.size()) {
        string st = q.top().second; // 这里f(st)于后续找最短路无用
        q.pop();
        if (vis.count(st)) // 已扩展过则跳过
			continue;
        if (st == aim)
            return g[aim];
        vis[st] = 1;
        LL zero = st.find("0", 0);
        for (LL i = 0; i < 4; i++) {
            LL nx = zero / 3 + dx[i], ny = zero % 3 + dy[i];
            if (nx < 0 || nx > 2 || ny < 0 || ny > 2)
                continue;
            string tmp = st;
            swap(tmp[nx * 3 + ny], tmp[zero]);
            if (vis.count(tmp))
                continue;
            if (!g.count(tmp) || g[tmp] > g[st] + 1) {
                g[tmp] = g[st] + 1;
                q.push({g[tmp] + mht(aim, tmp), tmp});
            }
        }
    }
    return -1; // 无解,但题目保证能搜到
}

int main() {
    // freopen("in.in", "r", stdin);
    string start, aim = "123804765";
    cin >> start;
    cout << Astar(start, aim);
    return 0;
}

1251:仙岛求药

1251:仙岛求药

实际生活中包括导航、LOL 或王者荣耀等游戏的 NPC 自动寻路,几乎都是 A ∗ A^* A∗ 算法实现的。若将这些地图抽象为二维迷宫,则欧式距离或曼哈顿距离都可以作为 A ∗ A^* A∗ 算法的启发函数。

例如这题,因为行走方式固定,起点和终点只有 1 个,所以 h ( x ) = h ∗ ( x ) h(x)=h^*(x) h(x)=h∗(x) ,基本可以确定 h ( x ) h(x) h(x) 就是曼哈顿距离。且 h ( x ) = 1 + h ( y ) h(x)=1+h(y) h(x)=1+h(y) ,所以虽然这题是 bfs 模板题,但完全可以使用 A ∗ A^* A∗ 算法这把 "牛刀" 来啥 "鸡" 。

除了明确不能到达终点的测试样例,其他测试样例基本都能找到最短路,且不会遍历太多额外的结点。

cpp 复制代码
#include <bits/stdc++.h>
#include <unordered_map>
using namespace std;

using pii = pair<int, int>;
using pip = pair<int, pii>;
char pct[30][30];
int n, m;
int dx[] = { 0,0,1,-1 };
int dy[] = { 1,-1,0,0 };

// h(x):曼哈顿距离
int h(const pii& a, const pii& aim) {
    return abs(a.first - aim.first)
        + abs(a.second - aim.second);
}

int Astar(pii& start, pii& aim) {
    priority_queue<pii, vector<pii>, greater<pii>>q;
    unordered_map<int, int>g; // g(x)实际代价
    unordered_map<int, bool>vis; // 标记是否走过

    // 二维坐标转一维存储
    g[start.first * m + start.second] = 0;
    q.push({ h(start,aim),start.first * m + start.second });
    while (q.size()) {
        int np = q.top().second;
        q.pop();
        if (vis.count(np))
            continue;
        if (pii(np / m, np % m) == aim)
            return g[np];
        vis[np] = 1;
        for (int i = 0; i < 4; i++) {
            int nx = np / m + dx[i];
            int ny = np % m + dy[i];
            if (nx < 0 || nx >= n || ny < 0 || ny >= m)
                continue;
            if (vis.count(nx * m + ny))
                continue;
            if (pct[nx][ny] == '#')
                continue;
            if (!g[nx * m + ny] || g[nx * m + ny] > g[np] + 1) {
                g[nx * m + ny] = g[np] + 1;
                if (pii(nx, ny) == aim)
                    return g[nx * m + ny];
                q.push({ g[nx * m + ny] + h({nx,ny},aim),nx * m + ny });
            }
        }
    }
    return -1;
}

int main() {
    //freopen("in.in", "r", stdin);
    while (cin >> n >> m) {
        if (n == 0)
            break;
        pii start, aim;
        for (int i = 0; i < n; i++)
            for (int j = 0; j < m; j++) {
                cin >> pct[i][j];
                if (pct[i][j] == '@')
                    start = { i,j };
                if (pct[i][j] == '*')
                    aim = { i,j };
            }
        cout << Astar(start, aim) << '\n';
    }
    return 0;
}

P2324 骑士精神 - 洛谷

P2324 [SCOI2005\] 骑士精神 - 洛谷](https://www.luogu.com.cn/problem/P2324) 这题可通过哈希表存储每个棋盘的状态。就是一普通的抽象搜索树寻找最短路问题。 #### 双向bfs 因为搜索方向有 8 个,整体搜索树比较庞大,担心超时所以使用双向 bfs 优化。 ```cpp #include #include using namespace std; using qs = queue; using usb = unordered_map; int dx[] = {-2, -1, 1, 2, 2, 1, -1, -2}; int dy[] = {1, 2, 2, 1, -1, -2, -2, -1}; string aim = "111110111100*110000100000"; bool extend(int op, qs q[], usb vis[]) { for (int _i = q[op].size(); _i; _i--) { string np = q[op].front(); q[op].pop(); int pnt = np.find("*", 0); for (int i = 0; i < 8; i++) { int nx = pnt / 5 + dx[i]; int ny = pnt % 5 + dy[i]; if (nx < 0 || nx > 4 || ny < 0 || ny > 4) continue; string tmp = np; swap(tmp[nx * 5 + ny], tmp[pnt]); if (vis[op].count(tmp)) continue; vis[op][tmp] = 1; if (vis[1 - op].count(tmp)) return true; q[op].push(tmp); } } return false; } int bfs(string &start) { qs q[2]; usb vis[2]; q[0].push(start), q[1].push(aim); vis[0][start] = 1, vis[1][aim] = 1; int ans = 0; while (q[0].size() && q[1].size()) { // 一次拓展一层 ++ans; if (ans > 15) // 超过步数则返回-1 return -1; if (q[0].size() < q[1].size()) { if (extend(0, q, vis)) return ans; } else if (extend(1, q, vis)) return ans; } return -1; } void ac() { string start, tmp; for (int i = 1; i <= 5; i++) { cin >> tmp; start += tmp; } if (start == aim) { cout << 0 << '\n'; return; } cout << bfs(start) << '\n'; } int main() { // freopen("in.in", "r", stdin); int T = 1; cin >> T; while (T--) ac(); return 0; } ``` #### A\*算法 移动棋子的过程可以看成是**消灭初始棋盘和目标棋盘的差异的过程**。若移动正确则初始棋盘和目标棋盘的差异少 1 ,否则多 1 。 所以可设启发函数 h ( x ) h(x) h(x) 为棋盘和目标棋盘的差异数,此时通过优先队列筛选估价函数值 f ( x ) f(x) f(x) ,差异少的永远会被先选,此时可以满足 h ( x ) ≤ h ∗ ( x ) h(x)\\le h\^\*(x) h(x)≤h∗(x) 。 既然差异减小,则从状态 A 经一步到 B,有 h ( A ) ≤ 1 + h ( B ) h(A)\\le 1+h(B) h(A)≤1+h(B) 。 所以可设计求棋盘每个位置的差异数的启发函数,尝试使用 A ∗ A\^\* A∗ 解决。 \[P2324 [SCOI2005\] 骑士精神 - 洛谷](https://www.luogu.com.cn/problem/P2324) 的 A ∗ A\^\* A∗ 算法参考程序: ```cpp #include #include using namespace std; using qs = queue; using usb = unordered_map; using pis = pair; int dx[] = {-2, -1, 1, 2, 2, 1, -1, -2}; int dy[] = {1, 2, 2, 1, -1, -2, -2, -1}; string aim = "111110111100*110000100000"; // 和目标的差异数作为启发函数值 int h(string &a) { int cnt = 0; for (int i = 0; i < 25; i++) { if (a[i] == '*') continue; // 忽略空位 if (a[i] != aim[i]) cnt++; } return cnt; } int Astar(string &start) { priority_queue, greater> q; unordered_map g; unordered_map vis; g[start] = 0; q.push({g[start] + h(start), start}); while (q.size()) { string np = q.top().second; q.pop(); if (vis.count(np)) continue; if (np == aim) return g[aim]; vis[np] = 1; int pnt = np.find("*", 0); for (int i = 0; i < 8; i++) { int nx = pnt / 5 + dx[i], ny = pnt % 5 + dy[i]; if (nx < 0 || nx > 4 || ny < 0 || ny > 4) continue; string tmp = np; swap(tmp[nx * 5 + ny], tmp[pnt]); if (vis.count(tmp)) continue; if (!g.count(tmp) || g[tmp] > g[np] + 1) { g[tmp] = g[np] + 1; if (g[tmp] > 15) // 当出现长度超标的直接返回 return -1; if (tmp == aim) return g[tmp]; q.push({g[tmp] + h(tmp), tmp}); } } } int ans = g[aim]; if (!ans || ans > 15) return -1; return ans; } void ac() { string start, tmp; for (int i = 1; i <= 5; i++) { cin >> tmp; start += tmp; } if (start == aim) { cout << 0 << '\n'; return; } cout << Astar(start) << '\n'; } int main() { // freopen("in.in", "r", stdin); int T = 1; cin >> T; while (T--) ac(); return 0; } ``` ## OJ参考 [P1379 八数码难题 - 洛谷](https://www.luogu.com.cn/problem/P1379) 一题不知道多少个解 [1251:仙岛求药](http://ybt.ssoier.cn:8088/problem_show.php?pid=1251) \[P2324 [SCOI2005\] 骑士精神 - 洛谷](https://www.luogu.com.cn/problem/P2324)

相关推荐
楼田莉子16 分钟前
CMake学习:CMake语法
c++·后端·学习·软件构建
无限进步_21 分钟前
C++ 继承机制完全解析:从基础原理到菱形继承问题
java·开发语言·数据结构·c++·vscode·后端·算法
superior tigre23 分钟前
45 跳跃游戏2
算法·leetcode·游戏
盐焗鹌鹑蛋26 分钟前
【C++】vector类
c++
不知名的忻34 分钟前
并查集(QuickUnion)
java·数据结构·算法·并查集
leo__52037 分钟前
基于时延的麦克风声源定位 - C实现
c语言·开发语言·算法
攻防_SRC38 分钟前
面向分组密码差分故障分析的属性推导与验证平台
人工智能·算法·机器学习
jf加菲猫41 分钟前
第15章 文件和目录
开发语言·c++·qt·ui
likerhood41 分钟前
Java实现选择题选项乱序算法
java·开发语言·算法
思麟呀1 小时前
Select多路转接
linux·网络·c++·网络协议·http