代码链接:人工智能实验
文章目录
人工智能实验一:搜索算法问题求解
一、实验目的
• 了解4种无信息搜索策略和2种有信息搜索策略的算法思想;
• 能够运用计算机语言实现搜索算法;
• 应用搜索算法解决实际问题(如罗马尼亚问题);
• 学会对算法性能的分析和比较
二、实验的硬件、软件平台
硬件:计算机
软件:操作系统:WINDOWS/Linux
应用软件:C, Java或者MATLAB
三、实验内容及步骤
使用搜索算法实现罗马尼亚问题的求解
1.创建搜索树;
2.实现搜索树的宽度优先搜索,深度优先搜索,一致代价搜索,迭代加深的深度优先搜索算法;
3.实现贪婪最佳优先搜索和A*搜索
4.使用编写的搜索算法代码求解罗马尼亚问题;
5.记录各种算法的时间复杂度并绘制直方图
四、思考题
1.宽度优先搜索,深度优先搜索,一致代价搜索,迭代加深的深度优先搜索算法哪种方法最优?
2.贪婪最佳优先搜索和A*搜索那种方法最优?
3.分析比较无信息搜索策略和有信息搜索策略。
五、实验报告要求
1.对各种算法的原理进行说明。
2.给出程序清单和注释。
3.对实验结果进行分析。
实验步骤:
一、数据的预处理以及初始化
将各个顶点取首字母进行标号:
#define A 0
#define B 1
#define C 2
#define D 3
……
初始化之后需要用到的启发式函数值:
int h[20] = {366, 0, 160, 242, 161, 178, 77, 151, 226, 244,
241, 234, 380, 98, 193, 253, 329, 80, 199, 374};
为了便于输出格式的控制,将每个城市标号值对应的名称存放在label数组内:
string label[20] = {
"Arad", "Bucharest", "Craiova", "Dobreta", "Eforie",
"Fagaras", "Giurgiu", "Hirsova", "Iasi", "Lugoj",
"Mehadia", "Neamt", "Oradea", "Pitesti", "Rimnicu Vilcea",
"Sibiu", "Timisoara", "Urziceni", "Vaslui", "Zerind"
};
二、无向图的建立以及搜索树的建立
构建Graph类来存放图和搜索树
class Graph {
public:
Graph() {
memset(graph, -1, sizeof(graph));
}
int getEdge(int from, int to) { // 计算搜索算法消耗时使用的邻接矩阵
return graph[from][to];
}
vector<PII> getAction(int from) { // 获得当前行动的子结点集
return edge[from];
}
void addEdge(int from, int to, int cost) { // 图中添加边
if (from >= 20 || from < 0 || to >= 20 || to < 0)
return;
graph[from][to] = cost;
edge[from].push_back({to, cost});
}
void init() { // 图初始化
addEdge(O, Z, 71);
addEdge(Z, O, 71);
addEdge(O, S, 151);
addEdge(S, O, 151);
addEdge(Z, A, 75);
addEdge(A, Z, 75);
……
}
private:
int graph[20][20]; // 邻接矩阵
vector<PII> edge[20]; // 搜索树
};
通过邻接矩阵graph存图,graph[i][j]即顶点i到顶点j的边,为-1则表示两点间没有边的存在。使用edge存放搜索树,edge[i]存放结点i的子结点即,结点的值为标号和路径消耗。
三、无信息搜索策略
1. 宽度优先搜索BFS
1) 算法描述:
先扩展根节点,接着扩展根节点的所有后继,然后再扩展他们的后继,以此类推。在下一层的任何结点扩展之前,搜索树上本层深度的所有结点都应该已经扩展过。
2) 算法步骤:
使用FIFO队列维护待扩展的结点集,设置父结点集fa,用于最终输出路径。
a) 源节点入队,标记为已访问;
b) 若队列非空,则继续进行,否则结束;
c) 取队首元素并从队列中弹出,判断是否到达目标结点,若是目标结点则结束算法;
d) 生成当前结点的子结点,若子结点未被访问过,则将其加入队列,并设置为已访问,同时将子结点的父结点前驱指向当前结点,重复b)。
3) 代码实现:
class BFS {
private:
int vis[20]; // 标记结点是否被访问过
int cost; // 搜索消耗
int fa[20]; // 第i个结点的父节点(用于之后路径输出)
int flag;
queue<int> q; // 维护队列保存已扩展结点
public:
BFS() {
cost = 0;
flag = 1;
memset(vis, 0, sizeof(vis));
memset(fa, -1, sizeof(fa));
while (!q.empty())
q.pop();
}
void bfs(int goal, int src, Graph &graph) {
vis[src] = 1; // 源节点设置为已访问
q.push(src); // 源节点入队
while (!q.empty()) { // 从队列中取元素直到队列为空或搜索到目标节点
int now = q.front(); // 取队首元素
q.pop();
if (now == goal) { // 找到目标节点,结束
flag = 0;
break;
}
vector<PII> nxt = graph.getAction(now); // 获取子结点
for (auto w : nxt) {
int weight = w.second;
int i = w.first;
if (vis[i] == 0) { // 未放问过
q.push(i); // 入队
vis[i] = 1; // 设置为已访问
fa[i] = now;
}
}
}
}
void print_result(int goal, Graph &graph) { // 路径输出及消耗计算函数,当然这不重要
cout << "BFS: " << endl;
if (flag) { // 未能找到目标结点
cout << "Failed" << endl;
return;
}
cout << "Path: ";
stack<int> s; // 维护访问过的结点集合,从后向前
s.push(goal);
int father = fa[goal]; // 寻找父结点
cost += graph.getEdge(father, goal); // 计算路径消耗
while (father != -1) { // 直到找到根节点结束
s.push(father);
cost += graph.getEdge(father, fa[father]);
father = fa[father];
}
while (!s.empty()) { // 路径输出
cout << label[s.top()] << "->";
s.pop();
}
cout << "end" << endl;
cout << "Cost: " << cost << endl;
}
};
4) 算法分析:
a) 完备性:BFS是完备的,如果最浅的目标节点处于有限深度d,那么在扩展比他浅的结点之后一定能够找到该目标节点。
b) 最优性:不是最优的,因为最浅的目标节点不一定是最优的目标节点,在路径代价是基于结点深度的非递减函数时,BFS是最优的
c) 复杂度分析:假设每一个状态都有b个后继,解得深度为d,那么在最坏情况下,解是该层的最后一个结点,时间复杂度为 O ( b d ) O(b^d) O(bd);边缘结点集中会存放 b d b^d bd个状态,所以空间复杂度取决于边缘结点集,为 O ( b d ) O(b^d) O(bd)。
2. 深度优先搜索DFS
1) 算法描述:
扩展搜索树的当前边缘结点集中最深的结点,当结点扩展完之后,将其从边缘结点集中去掉,之后搜索算法回溯到下一个还未扩展后继的深度稍浅的结点。
2) 算法步骤:
使用栈(LIFO队列)来维护边缘结点集。
a) 源节点生成后继结点,并压入栈中;
b) 栈为非空,则从栈顶弹出结点作为当前结点,设置为已访问;
c) 若当前结点为目标结点,算法结束,否则生成其后继节点,若后继节点未被访问,压入栈中;
d) 重复b)直到搜索到解或栈为空。
3) 代码实现:
class DFS {
private:
int vis[20]; // 标记结点是否被访问
int cost; // 搜索消耗
vector<int> road; // 搜索过程的路径
public:
DFS() {
memset(vis, 0, sizeof(vis));
cost = 0;
road.clear();
}
bool dfs_version2(
int goal, int src, Graph &graph) { // 当前结点首先扩展其所有子结点,但只访问目前最深的子结点
if (src == goal) { // 搜索到目标结点
road.push_back(goal);
return 1;
}
bool flag = 0; // 判断是否搜索到解
vis[src] = 1; // 当前结点设置为已访问
road.push_back(src);
stack<int> s; // 用于保存当前结点的所有子结点
vector<PII> nxt = graph.getAction(src); // 获取子结点集
for (auto w : nxt) {
int i = w.first;
if (vis[i] != 1)
s.push(i); // 未被访问过,则加入子结点集
}
while (!s.empty()) { // 取出一个子结点
int nxt = s.top();
s.pop();
int weight = graph.getEdge(src, nxt);
cost += weight; // 计算消耗
flag = dfs_version2(goal, nxt, graph); // 从当前子结点递归调用dfs
if (flag)
break; // 已经搜索到目标节点,结束
cost -= weight; // 回溯
road.pop_back();
vis[nxt] = 0;
}
return flag;
}
void print_result(int goal) { // 路径输出函数
cout << "DFS"
<< ": " << endl;
cout << "Path: ";
for (auto v : road) {
cout << label[v] << "->";
if (v == goal)
cout << "end" << endl;
}
cout << "Cost: " << cost << endl;
}
};
4) 算法分析:
a) 完备性:在图搜索中,如果避免了重复状态和冗余路径并且在有限状态空间内,是完备的;但是在树搜索中可能会导致陷入死循环,所以不完备。在无限状态空间中,两者均不完备。
b) 最优性:均不是最优的,如果优先探索左子树,但是最优解在右子树,那么在左子树中搜索到的解不为最优解。
c) 复杂度分析:同样的,假设每个结点有b个后继,搜索到的最深的深度为m,那么时间复杂度为 O ( b m ) O(b^m) O(bm);深度优先只需要存储 O ( b m ) O(bm) O(bm)个结点,所以空间复杂度为 O ( b m ) O(bm) O(bm)。
3. 一致代价搜索UCS
1) 算法描述:
与宽度优先搜索相似,只是不再扩展深度最浅的结点,扩展路径消耗g(n)最小的结点。
2) 算法步骤:
通过优先队列(堆)维护带扩展的结点集合,并于BFS相同,使用fa数组记录结点的前驱结点,dis数组记录每个结点的最小消耗
a) 源节点入队,路径消耗初始为0;
b) 队列不为空时继续,否则结束;
c) 取队首元素作为当前元素,出队,如果到达了目标节点则结束算法,否则继续;
d) 生成当前元素的所有后继,计算该子结点的路径消耗,若比原有的最小路径消耗更小,则将其加入到队列中,同时更新其fa数组指向当前结点。
3) 代码实现:
class UCS {
private:
int cost;
priority_queue<PII, vector<PII>, greater<PII>> q; // 优先队列保存结点
int fa[20];
int dis[20];
int flag;
public:
UCS() {
cost = 0;
flag = 1;
while (!q.empty())
q.pop();
memset(dis, 0x3f3f3f3f, sizeof(dis)); // 初始化为极大值INF
memset(fa, -1, sizeof(fa));
}
void ucs(int goal, int src, Graph &graph) {
q.push((PII){0, src}); // 源节点入队,用于比较的键值为当前节点的消耗
while (!q.empty()) {
PII now = q.top(); // 取队首
q.pop();
int from = now.second; // 把元素取出来只是为了方便后面写
int cost_ = now.first;
if (from == goal) { // 到达目标节点
flag = 0;
break;
}
vector<PII> nxt = graph.getAction(from); // 获取子结点集
for (auto w : nxt) {
int weight = w.second;
int i = w.first;
q.push((PII){cost_ + weight, i}); // 入队
if (dis[i] > cost_ + weight) { // 如果当前代价更新后最优,更新其父结点为当前结点
dis[i] = min(dis[i], cost_ + weight);
fa[i] = from;
}
}
}
}
void print_result(int goal, Graph &graph) { // 同BFS
cout << "UCS: " << endl;
if (flag) {
cout << "Failed" << endl;
return;
}
cout << "Path: ";
stack<int> s;
s.push(goal);
int father = fa[goal];
cost += graph.getEdge(father, goal);
while (father != A) {
s.push(father);
cost += graph.getEdge(father, fa[father]);
father = fa[father];
}
s.push(A);
while (!s.empty()) {
cout << label[s.top()] << "->";
s.pop();
}
cout << "end" << endl;
cout << "Cost: " << cost << endl;
}
};
4) 算法分析:
a) 完备性:如果存在零代价的行动则可能陷入死循环,若每一步的代价都大于等于某的小的正值常数ε,那么是完备的;
b) 最优性:每次都是选择的路径消耗最小的结点,所以第一个被选择的扩展的目标节点一定为最优解;
c) 复杂度分析:假设每个行动代价至少为ε,记 C ∗ C* C∗为最优解的代价,那么他的时间空间复杂度均为 O ( b ( 1 + l o w b o u n d ( C ∗ / ε ) ) ) O(b^{(1 + lowbound(C*/ε))}) O(b(1+lowbound(C∗/ε)))
4. 迭代加深的深度优先搜索IDS
1) 算法描述:
在DFS和深度受限搜索的基础上,通过每次增大深度限制,进行搜索,直到找到目标节点。
2) 算法步骤:
基本框架与DFS相同,使用栈维护边缘结点集。
a) 设置深度,从0增长到1000,若算法搜索到解则结束;
b) 源节点生成后继结点,并压入栈中;
c) 若到达最大深度,返回特定值,表明到达最大深度,但不结束算法,否则当栈为非空,从栈顶弹出结点作为当前结点,设置为已访问;
d) 若当前结点为目标结点,算法结束,否则生成其后继节点,若后继节点未被访问,压入栈中;
e) 重复c)直到搜索到解或栈为空。
3) 代码实现:
class IDS {
private:
private:
int vis[20];
int cost;
int iter;
vector<int> road;
public:
IDS() {
memset(vis, 0, sizeof(vis));
cost = 0;
iter = 1;
road.clear();
}
void ids(int goal, int src, Graph &graph) {
for (; iter < 1000; iter++) { // 设置最大迭代次数
memset(vis, 0, sizeof(vis));
road.clear();
cost = 0;
if (dfs(goal, src, graph, 1, iter) == 1)
break; // 搜索到解,结束算法
}
}
int dfs(int goal, int src, Graph &graph, int it, int max_depth) {
if (src == goal) { // 搜索到目标节点,返回 1
road.push_back(goal);
return 1;
}
int flag = 0;
road.push_back(src);
vis[src] = 1; // 设置当前结点已访问
if (it == max_depth)
return 2; // 到达最大深度,返回 2
stack<int> s; // 保存待扩展子结点集
vector<PII> nxt = graph.getAction(src); // 获取子结点集
for (auto w : nxt) { // 同DFS
int weight = w.second;
int i = w.first;
if (vis[i] != 1)
s.push(i);
}
while (!s.empty()) { // 大致同DFS
int nxt = s.top();
s.pop();
int weight = graph.getEdge(src, nxt);
cost += weight;
flag = dfs(goal, nxt, graph, it + 1, max_depth);
if (flag == 1)
break; // 搜索到解,结束;否则进行回溯
cost -= weight;
road.pop_back();
vis[nxt] = 0;
}
return flag;
}
void print_result(int goal) { // 同DFS
cout << "IDS"
<< ": " << endl;
for (int i = 1; i < iter; i++)
cout << "Iter " << i - 1 << " Failed" << endl;
cout << "Path: ";
for (auto v : road) {
cout << label[v] << "->";
if (v == goal)
cout << "end" << endl;
}
cout << "Cost: " << cost << endl;
}
};
4) 复杂度分析:
a) 完备性:和宽度优先搜索一样,当分支因子有限时是完备的;
b) 最优性:当路径代价是节点深度的非递减函数时为最优的;
c) 复杂度分析:由于最后一层结点生成1次,倒数第二层结点生成了2次,以此类推,可得时间复杂度为 O ( b d ) O(b^d) O(bd);空间复杂度即 O ( b d ) O(bd) O(bd)。
四、有信息的搜索策略
1. 贪婪最佳优先搜索
1) 算法描述:
扩展离目标最近的结点,只用启发式信息h(n)
2) 算法步骤:
通过优先队列维护带扩展的结点集合
a) 获取源结点启发式函数值,源节点入队,标记为已访问;
b) 队列不为空时,继续,否则结束算法;
c) 取队首元素,若为目标节点则结束;
d) 生成当前元素的后继节点,获取其启发式函数值,若未放问过,则将其入队并标记为已访问,重复b)。
3) 代码实现:
class GREEDY {
private:
int cost;
priority_queue<PII, vector<PII>, greater<PII>> q; // 优先队列存放子结点
int vis[20];
int fa[20];
int flag;
public:
GREEDY() {
cost = 0;
flag = 1;
while (!q.empty())
q.pop();
memset(fa, -1, sizeof(fa));
memset(vis, 0, sizeof(vis));
}
void greedy(int goal, int src, Graph &graph) {
q.push((PII){h[src], src}); // 源节点入队,用于比较的键值为估计的到达目标节点的距离
vis[src] = 1;
while (!q.empty()) {
PII now = q.top();
q.pop();
int from = now.second;
int cost_ = now.first;
if (from == goal) {
flag = 0;
break;
}
vector<PII> nxt = graph.getAction(from); // 获取子结点集
for (auto w : nxt) {
int weight = w.second;
int i = w.first;
if (weight != -1 && vis[i] == 0) {
q.push((PII){h[i], i}); // 结点未访问过,入队
fa[i] = from; // 设置父结点
vis[i] = 1;
}
}
}
}
void print_result(int goal, Graph &graph) {
cout << "GREEDY: " << endl;
if (flag) {
cout << "Failed" << endl;
return;
}
cout << "Path: ";
stack<int> s;
s.push(goal);
int father = fa[goal];
cost += graph.getEdge(father, goal);
while (father != A) {
s.push(father);
cost += graph.getEdge(father, fa[father]);
father = fa[father];
}
s.push(A);
while (!s.empty()) {
cout << label[s.top()] << "->";
s.pop();
}
cout << "end" << endl;
cout << "Cost: " << cost << endl;
}
};
4) 算法分析:
a) 完备性:与深度优先相似,即使是在有限状态空间,也不是完备的,可能会有死循环,但是有限空间的图搜索是完备的;
b) 最优性:只能获得局部最优解,所以不是最优的;
c) 复杂度分析:在当前启发式函数下,时间复杂度和空间复杂度均为O(b^m),m是搜索空间的最大深度。
2. A*搜索
1) 算法描述:
对结点的评估结合了路径消耗以及从该结点到目标结点的估计代价作为启发式函数。
2) 算法步骤:
使用openList维护带扩展的结点集,使用closeList判断是否需要处理
a) 源节点入队,加入到openList中;
b) 队列不为空,则继续,否则结束算法;
c) 取出队首元素,若为目标节点则结束算法;
d) 将队首元素加入到clostList中,生成其后继节点;
e) 若后继节点不在closeList中,且不在openList中,加入到openList中;若已经存在于openList中,则判断当前的启发式函数是否比原来的更优,若更优则进行更新;
f) 重复b)。
3) 代码实现:
class AStar {
private:
bool list[20];
vector<node> openList; // 模拟优先队列
bool closeList[20]; // 当前结点是否不可再访问
stack<int> road;
int parent[20];
public:
AStar() {
memset(parent, -1, sizeof(parent));
list[A] = true;
memset(closeList, 0, sizeof(closeList));
}
void A_star(int goal, node &src, Graph &graph) {
openList.push_back(src); // 源节点入队
sort(openList.begin(), openList.end()); // 按启发式函数排序
while (!openList.empty()) // 队列非空,继续
{
node curr = openList[0]; // 取队首元素
if (curr.name == B)
break; // 搜索到目标节点,结束
openList.erase(openList.begin()); // 队首元素出队
if (closeList[curr.name])
continue; // 如果在closelist内,继续循环
closeList[curr.name] = 1; // 将当前结点放入closelist内
vector<PII> nxt = graph.getAction(curr.name); // 获取子结点集
for (auto w : nxt) {
int weight = w.second;
int i = w.first;
if (closeList[i] == 0) { // 不在closelist内
bool flag = 1;
for (auto it = openList.begin(); it != openList.end(); it++) {
// 如果队列中存在该元素,判断是否需要对其更新:当前函数值更优则更新
if (it->name == i) { // 在队列中
if (curr.g + weight < it->g) { // 更优
openList.erase(it); // 移除队列中原有的该结点
break;
} else {
flag = 0;
break;
}
}
}
if (flag) { // 新的结点入队
node nxt(i, curr.g + weight, h[i]);
openList.push_back(nxt);
parent[i] = curr.name; // 设置父结点
}
}
}
sort(openList.begin(), openList.end()); // 排序,模拟堆
}
}
void print_result(Graph &graph) // 打印输出
{
int p = openList[0].name;
int lastNodeNum;
road.push(p);
while (parent[p] != -1) {
road.push(parent[p]);
p = parent[p];
}
lastNodeNum = road.top();
int cost = 0;
cout << "A*: " << endl;
cout << "Path: ";
while (!road.empty()) {
cout << label[road.top()] << "->";
if (road.top() != lastNodeNum) {
cost += graph.getEdge(lastNodeNum, road.top());
lastNodeNum = road.top();
}
road.pop();
}
cout << "end" << endl;
cout << "Cost:" << cost << endl;
}
};
其中,A*算法的结点构造如下:
struct node // Astar 搜索中使用的结点
{
int g; // 到当前结点的路径消耗
int h; // 当前结点到目标结点的预估消耗
int f; // Astar的启发式函数
int name; // 当前结点命名
node(int name, int g, int h) // 结构体初始化方法
{
this->name = name;
this->g = g;
this->h = h;
this->f = g + h;
};
bool operator<(const node &a) const // 运算符重载,用于排序
{
return f < a.f;
}
};
4) 算法分析:
a) 完备性:是完备的,因为最终会对所有节点进行扩展,所以为完备的;
b) 最优性:若启发式函数是可采纳的,那么树搜索版本为最优的,若启发式函数是一致的,那么图搜索版本是最优的;
实验结果:
运行代码,将输出结果保存在文件内,有:
各算法运行时间为运行1000次求取平均值得到。
获取时间,做柱状图有:
思考题:
- 宽度优先搜索,深度优先搜索,一致代价搜索,迭代加深的深度优先搜索算法哪种方法最优?
考虑搜索得到的解的质量,一致代价搜索最优,能够保证得到最优解。但是考虑到时间复杂度方面,则是四种算法中最慢的。
若要考虑搜索的时空复杂度,那么深度优先搜索最优。但是由于本题所给数据范围较小,所以无法突显出其最优性。
- 贪婪最佳优先搜索和A*搜索那种方法最优?
显然,如果考虑得到的解的质量,显然是A*算法更优,因为能够保证完备性和最优性,而贪婪最佳优先得到的为局部最优解。
但是A*算法的时间复杂度要高于贪婪最佳有限搜索,并且取决于使用的启发式函数的质量到好坏。
- 分析比较无信息搜索策略和有信息搜索策略。
与无信息搜索策略相比,有信息搜索策略使用了额外的信息,通过使用每个状态额外的信息对搜算算法进行优化,使得搜索算法更加有效。
但是其效果的好坏一定程度上取决于选择的启发式函数的好坏,需要一定的先验条件才能够保证搜索算法的正确性和效率,在这方面,无信息搜索策略的适用性更强,更能适用于大多数的环境。