一、问题概要
一共有n个牧师和n个野人准备渡河,但只有一条能容纳c个人的小船,为了防止野人侵犯牧
师,要求无论在何处,牧师的人数不得少于野人的人数(除非牧师人数为 0),且假定野人与牧师都会划船,试设计一个算法,确定他们能否渡过河去,若能,则给出小船来回次数最少的最佳方案。
二、相关方法和算法介绍
本文使用A*算法,这是一种静态路网中求解最短路径最有效的直接搜索方法,也是解决许多搜索问题的有效算法。算法中的距离估算值与实际值越接近,最终搜索速度越快。本质上也是一种启发式算法,其特殊之处就在于估价函数,具体表达式如下:
f (x) = g(x) + h(x)
其中 g(x) 为当前代价函数,即为从当前状态到达下一指定状态的耗费。h(x) 为
启发函数,即从下一指定位置到达终点的预估耗费。二者的和就是到达下一指定
位置的总代价 f (x)
三、A*算法基本步骤(以渡河问题为例)
传教士与野人渡河的数学模型的分析
算法的具体执行步骤如下:
1、从起始点开始,将 start作为一个待检查的节点,放到一个待检测的OPEN列表中。
2、检查该节点是否为最终节点,如果不是,进行下一步。
a) 检索所有该节点下一步可能到达的节点,并筛出合理的。
b) 如果这些点不在OPEN 列表中,则将其加入到OPEN 列表,然后计算总代价,并设置父节点为 start。
c) 如果某相邻点已经在OPEN 列表当中,则从新计算其当前代价 g(x) 是否会更低。如果更低,则修改其父节点为 start,并修改 g(x) ;否则不作任何改动。
3、将 start点从OPEN 列表中移除,放入到CLOSE (不再用的)列表中。
4、然后选取总代价最小的点作为新的起始点 start
5、重复步骤 2,3,4
7、结束判断:如果OPEN 列表当中已经出现了目标节点 end ,说明路径已经找到。如果最终列表中没有该节点,则说明没有合适的路径。
8、路径的输出:利用目标点 end的父节点一步步回推到初始点 start,中间经过的点,便是一条最优路径。
下面我们来具体分析一下
这里我们使用空间状态法,即三元组 S = (X1,X2,X3)来表示左岸的状态,那么初始状态
为(n,n,1) ,目标状态则为(0,0,0)。三元组所有合理状态就是类比于A* 算法的求解空间,左岸状态的转变就类比于点 A的一次移动。
而对于当前代价函数与启发函数的选取,由于问题是要保证船转运牧师和野人的次数最少,故当前代价函数也可以使用搜索的深度来进行代替,即 g(x) = depth 。当搜索深度过深依然没找出最优解时,g(x) 会过大会使得总代价较大,进而可以起到纠正错误的作用。事实上,直接令当前代价 g(x) = 0 对于求解本问题也没有太大影响,关键是要使得每次转运后左岸的人数尽可能少,因此可以选此作为启发函数,具体表达式如下:
h(x) = X1 + X 2− c*X3
最终得到的估价函数为:
f (x) = g(x) + h(x) = deepth+X1 + X2 − c*X3
其中c 为小船满载时的载量。
节点拓展方法与合法状态判断
这时我们需要准备一个存放节点类型的列表,用于扩展和遍历其它所有节点,最终找到一个最佳的方案,其中每一个节点元素都采用长度为 7 的数组,用于表示当前的空间状态。
假设该元素是数组arg,那么,
arg[0]:左岸的传教士人数
arg[1]:左岸的野人人数
arg[2]:船只的最大负载量
arg[3]:计算当前节点的估值代价
arg[4]:标记位,1表示已访问,0表示未访问(这里省略了CLOSE列表,只有OPEN)
arg[5]:记录父节点的下标
arg[6]:记录当前节点搜索的深度,初始节点深度为0
四、程序主体框架
本程序在窗体点击事件里面调用CrossRiver方法,进行判断,并初始化一些参数。
private void button1_Click(object sender, EventArgs e)
{
// 初始化open列表
OpenList = new List<int[]>();
// 用于记录结果
str = "";
// 牧师和野人个数
int N;
// 船的最大负载
int c;
// 判断用户选择渡河人数是否正确
if (ccount.Value <= 0 && upcount.Value <= 0)
{
MessageBox.Show("error:请输入正确的渡河人数!");
return;
}
// 传教士个数
N = int.Parse(ccount.Value.ToString());
// 设置船的最大载客量
c = int.Parse(upcount.Value.ToString());
// 空间状态本需三个元组表示,但是为了方便查找父节点,这里直接增添两个标志位,也就是使用五元组表示。
// 前三位为空间状态
// 第四位用于表示该节点是否被扩展过,若是则为1,无法再次被扩展,这时就等价于被放入到了closed列表中。如不是则为0,则可以再次被扩展。
// 最后一位记录该节点是由哪个位置上的节点扩展而来方便递归打印路径。
// 初始状态
int[] start = { N, N, 1, F(N, N, 1, c, 0), 0, -1, 0 };
// 目标状态
int[] goal = { 0, 0, 0 };
// 将初始状态加入到Open列表当中
OpenList.Add(start);
System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
sw.Start();
bool result = CrossRiver(OpenList, start, goal, N, c);
sw.Stop();
TimeSpan ts2 = sw.Elapsed;
string time = ts2.TotalMilliseconds + "";
if (result)
{
richTextBox1.Text = "成功还是失败了?: 成功 \n最优方案步骤 : \n" + str + "运行时间:" + time + "ms";
}
else
{
richTextBox1.Text = "成功 还是 失败 ?: 失败";
}
}
程序入口方法:计算过河方案
/// </summary>
/// <param name="OpenList">Open列表</param>
/// <param name="start">起点</param>
/// <param name="goal">终点</param>
/// <param name="N">牧师和野人人数</param>
/// <param name="c">小船负载</param>
/// </summary>
public bool CrossRiver(List<int[]> OpenList, int[] start, int[] goal, int N, int c)
{ //从初始状态循环遍历所有合法节点,该搜索过程可以看成遍历一棵树。
while (true)
{
if (EqualsArray(start, goal))
{
return false;
}
if (IsEmpty(OpenList))
{
return false;
}
else
{
// 找代价最小的节点在Open列表中的索引
int index = MinimumPosition(OpenList);
// 取出该节点
int[] p = OpenList[index];
// 判断是否达到目标点
if (EqualsArray(p, goal))
{
PrintPath(OpenList, OpenList[index][5]);
str += p[0] + "," + p[1] + "," + p[2] + "\n搜索深度为: "
+ p[6] + "\n";
return true;
}
// 将访问情况置为1,表示该节点已经遍历过。
OpenList[index][4] = 1;
//继续扩展新的节点
Expand(OpenList, index, N, c, OpenList[index][6] + 1);
}
}
}
五、代码中辅助方法的实现声明(这里读者也可以自己定义算法)
下面我们需要写一些方法来辅助我们解决渡河最优问题,这里只给出方法声明
方法一:从当前节点扩展合法节点,并添加到整数数组型的列表中
/// <param name="openlist">open列表</param>
/// <param name="first">当前节点</param>
/// <param name="c">小船的最大负载</param>
public void Expand(List<int[]> OpenList, int index, int N, int c, int depth)
{
// 取出需要扩展的节点
int[] temp = OpenList[index];
// 如果船已经在岸边,那么移动后,X3必须全部为0,表示船到达对岸。
// 然后枚举从当前状态出发将所有未访问过的情况加入到open列表中,并将访问情况置为0;
if (temp[2] == 1)
{
//将下一步到河对岸的所有合法的节点都添加到列表中
}
// 如果船已经在对岸,那么移动后,X3必须全部为1,即船在左岸。
// 同样枚举从当前状态出发将所有未访问过的情况加入到open列表中,并将访问情况置为0;
else if (temp[2] == 0)
{
//同理,将下一步从河对岸回来的所有合法的节点都添加到列表中
}
}
方法二:判断Open列表是否为空
/// <param name="OpenList">Open列表</param>
/// <returns>ture表示为空,false表示不空</returns>
public bool IsEmpty(List<int[]> OpenList)
{
// 遍历列表中的每一个元素,当列表中的节点全部都被访问过,也就是访问标志都是1的时候认为该列表为空
//具体实现...
}
方法三:判断当前的节点是否为合法状态
/// <param name="Openlist">Open列表</param>
/// <param name="X1">左岸牧师人数</param>
/// <param name="X2">左岸野人人数</param>
/// <param name="X3">船是否在左岸</param>
/// <param name="N">牧师与野人总人数</param>
/// <returns></returns>
public bool Islegal(List<int[]> Openlist, int X1, int X2, int X3, int N, int depth, int index)
{
//可以分别讨论三种情况
//牧师人数等于N
//牧师人数在0~N之间
//牧师已经全部过河
//在这三种情况下,牧师不会出现被吃掉的情况,之前也没出现过这样的情况,那么是合法的。
}
方法四:该方法用于计算节点代价
/// <param name="X1">牧师人数</param>
/// <param name="X2">野人人数</param>
/// <param name="X3">船是否在岸边</param>
/// <param name="c">船的最大负载</param>
public int F(int X1, int X2, int X3, int c, int depth)
{
//该方法不唯一,可以尝试重写,这里只给一种
return depth + X1 + X2 - c * X3;
}
方法五:判断两个数组相等(可以只判断前三位)
public bool EqualsArray(int[] StOne, int[] StTwo)
{
//两个数组的前三位都相等,说明是一个节点
}
方法六:查找Open列表中最小代价节点位置(使用F方法判断)
public int MinimumPosition(List<int[]> OpenList)
{
//可通过两个循环完成
// 首先要找到第一个没有被访问过的节点
// 然后再根据该节点为起点找代价最小的节点。
}
方法七:判断扩展节点是否在OpenList中
/// <param name="Openlist">Open列表</param>
/// <param name="temp">扩展节点</param>
/// <returns>如果在返回true,不在返回false</returns>
public bool IsInOpen(List<int[]> OpenList, int[] temp, int depth, int index)
{
// 只要扩展的节点在Open列表里出现过,不论其访问标志是0,还是1都认为遍历过,不再遍历
}
方法八:递归打印路径
public void PrintPath(List<int[]> Openlist, int index)
{
if (index == -1)
{
return;
}
// 递归打印父节点
PrintPath(Openlist, Openlist[index][5]);
int[] p = Openlist[index];
str += p[0] + "," + p[1] + "," + p[2] + "-->\n";
}
代码运行结果(vs里面自带图形可视化界面,读者可自行设计)
小结
传教士与野人渡河问题通过搜索方法(如 BFS 或 DFS)可以求解,广度优先搜索(BFS)是最常用的解法,因为它能确保找到最短的解决方案。在解答此类问题时,关键在于理解状态空间的表示和如何约束每一步的合法性。
代码自取:用C#实现传教士与野人渡河问题