A星算法解决修道士与野人问题
1. 运行环境
- CPU:I5-10400
- 内存:16GB
- 系统:Win10 64位专业版,20H2
- IDE:Vistual Studio 2019专业版
2. 问题描述
假设有 n 个修道士和 n 个野人准备渡河,但只有一条能容纳 c 人的小船,为了防止野人侵犯修道士,要求无论在何处,修道士的个数不得少于野人的人数(除非修道士个数为0)。如果两种人都会划船,试设计一个算法,确定他们能否渡过河去,若能,则给出一个完整的渡河方案。
3. 算法简介
3.1 A算法的基本原理分析
在或图的一般搜索算法中,如果在搜索过程的利用估价函数f(n)=g(n)+h(n)对open表中的节点进行排序,则该搜索算法为A算法。
- g(n):从初始节点到n的实际代价。因为n为当前节点,搜索已达到n点,所以g(n)可计算出。
- h(n):启发函数,从n到目标节点的最佳路径的估计代价。因为尚未找到解路径,所以h(n)仅仅是估计值。
对A算法中的g(n)和h(n)做出限制:
- g(n) >= g(n)(g*(n)为S0到n的最小费用)
- h(n) <= h*(n)(h*(n)为n到Sg的实际最小费用)
则算法被称为A*算法。
3.2 评估函数
评估函数:F = G + H
- F:搜索的总代价。
- G:开始状态到当前状态的代价。
- H:当前状态到结束状态的预估代价。
A星算法具有启发策略,在于其可以通过预估H值,降低走弯路的可能性,更容易找到一条更短的路径。其他不具有启发策略的算法,没有做预估处理,只是穷举所有可通过路径,然后从中挑选出一条最短的路径。
3.3 算法流程
- 将初始节点S0放入Open表中。
- 建立Close表,初始值为空。
- 如果Open表为空,则问题无解,退出。
- 从Open表中取出第一个节点n,并将n放入Close表中。
- 考察节点n是否是目标节点。如果是,则得到了问题的解,解的路径通过n到S0的指针获得,退出。
- 如果节点n不可拓展,则转到第2步。
- 扩展节点n,将子节点放入Open表中,并为每个子节点分配指向父节点的指针。
- 将Open表中的节点按照评估函数F值从小到大的顺序重新进行排序。
- 转向第3步。
4. 解决思路
4.1 状态空间表示方式
在这个问题中,需要考虑:
- 两岸的传修道士人数和野人人数。
- 船在左岸还是在右岸。
已知传教士和野人数:N(两者默认相同),船的最大容量:K。定义M:左岸传教士人数。C:左岸野人人数。B:左岸船个数。可用一个三元组来表示左岸状态,即S=(M, C, B)。将所有扩展的节点和原始节点存放在同一列表中。初始状态为(N, N, 1),目标状态为(0, 0, 0)。问题的求解转换为在状态空间中,找到一条从状态(N, N, 1)到状态(0, 0, 0)的最优路径。
4.2 评估函数
评估函数建立:f = d + h = d + M + C - 2*B。
- M:左岸修道士的人数。
- C:左岸野人的人数。
- B:取0或1。0表示船在右岸,1表示船在左岸。
- d:表示状态搜索深度,每次从当前状态搜索到下一状态时,下一状态在当前状态的深度基础上加1,初始状态的深度为0。
4.3 节点拓展
通过减少和增加修道士或也认得数量来拓展节点。当船在左岸时,向右岸移动,需要减少修道士和野人的数量;当船在右岸时,向左岸移动,需要增加修道士和野人的数量。
4.4 状态判断
- 左岸修道士人数等于总数或者为零:C>=0, C<=N。
- 左岸修道士人数在(0, N)之间时:C >=0 , M >= C , M <= N , C <= N , N-M >= N-C。
- 其他状态都不合法。
5. 关键代码
5.1 状态设计
定义结构体Node用来存储状态信息,包括左岸修道士和野人的人数,船在左岸还是右岸,当前状态的深度,当前状态到预估状态的预估代价,搜索总代价,指向下一结点的指针和指向父状态的指针。具体定义如下:
struct Node
{
int m_M; // 左岸的修道士人数
int m_C; // 左岸的野人人数
int m_B; // 1:船在左岸;0:船在右岸
int m_d; // 从开始状态到当前状态的代价,这里表示:结点深度
int m_h; // 当前状态到结束状态的预估代价,h = M + C -2*B;
int m_f; // 搜索总代价,f = d + h = d + M + C - 2*B;
Node* next; // 下一节点
Node* father; // 当前状态的父状态
public:
Node();
Node(int, int, int, int, Node*);
~Node();
};
5.2 A*算法主要设计
起始状态为(N, N, 1), 每次从Open表中取出第一个结点,寻找可能的下一状态,并将当前结点加入Close表中。如果到达目标状态(0, 0, 0)时,搜索结束,输出生成结点数和搜索结点数。否则寻找当前结点的所有可能的下一状态。当整个Open表为空时,搜索结束。
while (m_Open->next != nullptr)
{
Node* current = m_Open->next;
m_Open->next = current->next;
AddLinkList(m_Close, current);
if (current->m_M == 0 && current->m_C == 0 && current->m_B == 0)
{
std::cout << "搜索成功!生成节点:" << m_creatPoint << ",搜索节点:" << m_searchPoint << std::endl;
m_endPoint = current;
return true;
}
GetNext(current);
}
5.3 链表设计
为了便于存放生成的状态信息和已访问的状态信息,在MC类中设计了两个单链表,m_Open和m_Close。分别用于存放所有合法且没有访问的状态和所有已访问过的状态。
Node* m_Open;
Node* m_Close;
为了能够方便寻找Open表中搜索代价最小的状态,通过头插法,设计了一带头的升序单链表。在插入新结点时,如果到了链表尾部的,则直接在当前结点后面插入;否则与当前节点的下一结点比较,如果待插入结点的搜索代价小于当前结点下一结点的搜索代价,则将该结点插入当前结点后面,否则指链表的指针向下一结点移动。
void MC::AddLinkList(Node* LinkList, Node* node)
{
Node* p = LinkList;
while (p)
{
if (p->next == nullptr)
{
p->next = node;
node->next = nullptr;
return;
}
else if (node->m_f < p->next->m_f)
{
node->next = p->next;
p->next = node;
return;
}
else p = p->next;
}
}
5.4 输出设计
利用Node结点指向父结点进行递归倒序输出。
void MC::Output(Node* node)
{
if (node == nullptr) return;
if (node->father != nullptr) Output(node->father);
std::cout << "(" << node->m_M << ", " << node->m_C << ", " << node->m_B << ")" << std::endl;
}
5.5 多线程设计
分配4个线程进行搜索,当某个线程搜索到结果时,while循环中的判断条件为真,搜索便完成。
bool MultiSearch()
{
m_Open->next = new Node(m_M, m_C, 1, 0, nullptr);
m_Open->next->next = nullptr;
int N = 0;
const int ThSize = 4;
std::thread th[ThSize];
for (int i = 0; i < ThSize; ++i) th[i] = std::thread(MultiGetNext);
for (int i = 0; i < ThSize; ++i) th[i].detach();
while (true) if (m_endPoint != nullptr) return true;
}
6. 运行结果与分析
C++20个结点单线程
C++20结点多线程
C++1000个结点单线程
C++1000个结点多线程
在多线程的程序中,分配了4个线程。从上图中不难发现,当搜索结点比较少时,多线程相较于单线程的运行优势体现的并不明显。这一方面是由于数据量比较小,运行时间都比较短;另一方面由于线程的创建和销毁都需要一定的时间,此外在多线程程序中,对共享数据需要互斥访问,对数据上锁和解锁的操作也耗费了一定的时间。当数据量比较大时,多线程的速度优势就体现出来了。