简介
每一种搜索算法都由以下内容组成。
- 问题的当前状态
- 为改变该状态而可能采取的行动
- 识别最终状态的能力--我们的目标
当涉及到人工智能时,有两种类型的搜索算法。
- 无信息的搜索算法
- 有信息的搜索算法
在这篇文章中,我们将介绍两种最流行的算法之间的一些区别,每一类中的一种。
- Dijkstra算法:无信息的搜索算法
- A*(A星)算法:知情搜索算法
在这之前,我们先来看看这两类算法是什么,以及解释一下Dijkstra算法和A*算法的工作原理。
无信息的搜索算法
正如我们已经提到的,一个搜索算法必须能够。
- 识别问题的当前状态
- 使用一组行动来修改当前状态
- 识别最终的状态
在这种情况下,无信息意味着算法没有任何额外的信息来帮助它确定它应该去哪里。想想看,这就像一个近视眼的人试图在一个他们不熟悉的城市的街道上导航。他们拥有的所有信息都是眼前的东西,而不是其他。
常见的例子是深度优先搜索算法,以及广度优先搜索算法。然而,在这篇文章中,我们将只关注Dijkstra的算法。
Dijkstra算法
Dijkstra算法是一种在具有非负边权重的有向图中找到节点A
和B
之间最短路径的算法。简而言之,它是通过寻找从一个节点A
到所有其他节点的最短路径来实现的,当然,这包括B
。
为了表示当前从任何给定节点到节点A
的最短路径的长度,我们需要为图中所有节点设置 "成本"。所有的成本在开始时都将被设置为infinity
,以确保我们可能比较的每一个其他成本都将小于开始的成本。唯一的例外是起始节点的成本--这个节点的成本将是0
。
让我们一步一步地看一下这个算法。
-
首先,我们将创建一个受访节点集(
visited
),以跟踪所有已被分配到节点A
的正确最短路径的节点。我们需要这个列表,这样我们就不会回溯到已经被分配了最短路径的节点。 -
直到我们到达最后的节点
B
,我们做以下工作。- 我们挑选一个当前成本最短的节点
K
,并访问它(将其添加到visited
节点集)。 - 我们更新所有尚未访问的
K
的相邻节点的成本:我们通过比较它们当前的成本与K
的成本和K
与有关相邻节点之间的边的总和来做到这一点。
- 我们挑选一个当前成本最短的节点
-
当我们到达最终节点
B
,算法就完成了,分配给最终节点的成本被保证为从开始节点到它的最短路径。
有一个有趣的方法来解释这个算法背后的想法。
Predrag Janičić的《人工智能》一书中有一个很好的比喻。
比方说,一个图的每个节点用一个球体表示。当且仅当两个球体之间在图形中存在一条边时,这两个球体由一条线连接。这些线的长度与相应的边的重量成正比。所有这些球体都躺在地面上完全相同的位置上。
我们拿起对应于起始节点的球体,随着它的上升,它把其他球体也拉了过来。这些球体一个接一个地离开地面,它们与起始球体之间的最短距离是它们之间的直线距离。
就Dijkstra的算法而言,这就是我们实际做的事情。我们有两组球体--地面上的球体和已经被抬起的球体。在每次迭代中,我们从地上捡起一个球体,计算它们与第一个球体的距离。
Predrag Janičić, Mladen Nikolić - 人工智能
该算法的复杂度为O(|E|+|V|log|V|)
,其中|E|
代表边的数量,而|V|
代表节点的数量。
知情搜索算法
知情搜索算法不仅使用可能的行动信息来修改问题的当前状态,而且还使用能够引导搜索走向目标的额外信息。这种额外的信息是某种类型的分数,是某种状态的质量指标。这个指标不一定准确,通常只是一个近似值。
这个近似值通常被称为启发式,源自希腊语 "heurisko",意思是 "搜索 "或 "发现"。
一个计算某种状态的分数(质量)的函数被称为评价函数。如果这个函数是h,那么h(n)代表状态n的得分。
知情搜索算法每次进入一个状态时,都会计算其每个相邻状态的值f(n),之后进入具有f(n)最优化值的节点。
最受欢迎的知情搜索算法之一无疑是A*算法。我们现在就来看看它吧!
A*(A星)算法
A*算法是以启发式搜索为基础的,但与许多以此为基础的类似算法(例如最佳搜索算法)不同,它既是完整的,又是(在一定条件下)最优的。
- 一个完整的算法是一个对任何正确的输入都能保证有正确答案的算法,如果这个答案存在的话。
- 最佳算法是一种能在最短的时间内返回答案的算法。
这种算法的最优性在很大程度上取决于其评价函数的质量。
对于A*算法,评价函数有一个特定的形式。
其中g(n)代表从起始节点到节点n
的最短成本路径,而h(n)
代表节点n
的启发式近似值。
根据问题的不同,可以使用不同的启发式近似值,以达到最佳效果。在实现该算法时,选择一个高质量的启发式算法是最重要的步骤之一。
现在,让我们来看看A*算法本身吧首先,我们要一步一步地去看它。
-
为了避免无限循环,保证完整性并确保我们能够修复已经找到的路径,我们需要两个列表。
- 封闭状态列表:在这个列表中,我们保留被访问的状态,其邻居也都被访问了
- 开放状态列表:在这个列表中,我们保留已访问的状态,其邻居不一定都被访问过
开始时,开放状态列表只包含起始状态(计算值为
f(start_state)
),而封闭状态列表为空。 -
n
我们选择具有最佳值的状态f(n)
。如果状态n
也是我们的最终状态,我们就完成了。如果不是,我们就去找它的直接邻居。 -
对于状态
n
的每个邻居m
,我们检查它是否在两个列表中的一个。如果没有,我们就把它放在开放状态列表中。n
我们将n
标记为m
的父节点。然后,我们计算g(m)
和f(m)
。然而,如果邻居在两个列表中的一个,我们检查从起始状态到状态m
的路径是否比当前已有的路径短,以到达m
。如果是这样,我们将n
标记为m
的父节点,并更新g(m)
和f(m)
。如果状态m
之前在封闭列表中,我们将其置于开放列表中。 -
最后,我们把当前节点放到封闭状态列表中。
-
只要开放状态列表中还有元素,我们就重复步骤2、3和4。
-
如果我们没有到达最终状态,但我们的开放状态列表是空的,那么通往最终状态的路径就不存在了
洛伊德之谜
现在我们已经了解了这两种算法,我们可以用一个例子来说明它们的不同之处,在这个例子中,洛伊德之谜,也被称为15谜。
洛伊德谜题是一个滑动谜题,有15块编号为1-15的正方形瓷砖,在一个高4块、宽4块的框架内,留下一个未被占用的瓷砖位置。处于空位同一行或同一列的瓷砖可以分别通过水平或垂直滑动来移动。该谜题的目标是将瓷砖按数字顺序排列。
洛伊德的谜题由一个4x4的棋盘和标有数字1-15的棋子组成,这些棋子可以放入上述棋盘。这些棋子最初是随机排列在棋盘上的。谜题的目标是通过滑动棋子,将其按升序排列。
在这个例子中,我们将使用尺寸较小的洛伊德谜题(2x3
),以便更容易地完成每一步。
这将是我们棋盘的起始状态。
[[2, 3, 5],
[1, 4, 0],
[7, 8, 6]]
我们希望我们的最终状态是这样的。
[[0, 1, 2],
[3, 4, 5],
[6, 7, 8]]
在我们的棋盘中,数字0
代表空牌。
每次我们改变上面数字的顺序,棋盘的状态就会改变。例如,在第一种状态之后,唯一可能的状态是。
[[2, 3, 5],
[1, 0, 4],
[7, 8, 6]],
[[2, 3, 0],
[1, 4, 5],
[7, 8, 6]]
[[2, 3, 5],
[1, 4, 6],
[7, 8, 0]]
由于这个棋盘有数千种可能的状态,所以很难一步步解释这两种算法的工作原理。这就是为什么我们要尝试通过直觉和实现来解释它。
实施
在我们开始实现这些算法之前,我们必须导入几个Python模块。
from collections import defaultdict
import copy
from queue import PriorityQueue
现在,让我们来谈谈我们的棋盘的表现。在这段代码中,我们将以两种方式表示它:第一种方式是序列化的--在一个一维数组中,第二种方式是反序列化的--在一个矩阵中(也就是一个二维数组,就像上面的例子)。
为了清楚起见,这就是最终状态在反序列化后的样子。
[[0, 1, 2],
[3, 4, 5],
[6, 7, 8]]
而这是我们最终的状态将被序列化的样子。
[0:1:2:3:4:5:6:7:8]
我们需要序列化表示的原因是它更容易操作,从而使代码更加简洁。
现在,我们需要写一些函数,让我们能够轻松地从一种表示法转换到另一种表示法。
def serialize (state):
result = []
for row in state:
for col in row:
result.append(str(col))
return ':'.join(result)
def deserialize (serialized):
splitted = serialized.split(':')
splitted = [int(x) for x in splitted]
return [splitted[:3], splitted[3:6], splitted[6:]]
为了更有效地实现这些算法,我们需要的另一个额外的函数是一个允许我们获得可能的下一个状态的函数--当前状态的邻居。
def get_neighbours(state):
deserialized = deserialize(state)
neighbours = []
blank_i = -1
blank_j = -1
for i in range(0, 3):
for j in range(0, 3):
if deserialized[i][j] == 0:
blank_i, blank_j = i, j
break
i = blank_i
j = blank_j
if i > 0:
new_matrix = copy.deepcopy(deserialized)
new_matrix[i][j] = new_matrix[i - 1][j]
new_matrix[i - 1][j] = 0
neighbours.append(serialize(new_matrix))
if i < 2:
new_matrix = copy.deepcopy(deserialized)
new_matrix[i][j] = new_matrix[i + 1][j]
new_matrix[i + 1][j] = 0
neighbours.append(serialize(new_matrix))
if j > 0:
new_matrix = copy.deepcopy(deserialized)
new_matrix[i][j] = new_matrix[i][j - 1]
new_matrix[i][j - 1] = 0
neighbours.append(serialize(new_matrix))
if j < 2:
new_matrix = copy.deepcopy(deserialized)
new_matrix[i][j] = new_matrix[i][j + 1]
new_matrix[i][j + 1] = 0
neighbours.append(serialize(new_matrix))
return zip(neighbours, [1 for x in neighbours])
正如我们之前提到的,邻居状态是我们通过将0
(空瓷砖)与它周围的一个瓷砖(左、右、上或下)交换位置可以得到的状态。
在这个函数中,我们使用一个反序列化的表示法,但我们返回一个序列化的表示法,因为这是我们在主要函数中需要的表示法(用于A*和Dijkstra的算法)。
首先,我们通过我们的矩阵,找到空瓦片的坐标。然后,我们使用deepcopy
,以便制作一个新的矩阵,然后我们在每个可能的瓷砖上进行切换。
当然,在我们设置每块瓷砖之前,检查它们的有效性是很重要的。
Dijkstra
现在我们已经看过了额外的函数,让我们进入实际的算法吧
这个问题的Dijkstra算法的代码看起来像这样。
def dijkstra(start_node, target_node):
start_node = serialize(start_node)
target_node = serialize(target_node)
visited = set()
D = defaultdict(lambda: float('inf'))
D[start_node] = 0
pq = PriorityQueue()
pq.put((0, start_node))
parent = dict()
parent[start_node] = None
path_found = False
iteratrion = 0
while not pq.empty():
(dist, current_node) = pq.get()
if current_node == target_node:
path_found = True
break
visited.add(current_node)
for (neighbour, distance_from_current_node) in get_neighbours(current_node):
if neighbour not in visited:
old_cost = D[neighbour]
new_cost = D[current_node] + distance_from_current_node
if new_cost < old_cost:
pq.put((new_cost, neighbour))
D[neighbour] = new_cost
parent[neighbour] = current_node
iteratrion += 1
path = []
if path_found:
path.append(target_node)
while True:
parent_node = parent[target_node]
if parent_node is None:
break
path.append(parent_node)
target_node = parent_node
path.reverse()
return (path, iteratrion)
正如我们已经提到的,我们将使用序列化的数据。除此之外,我们还需要一组被访问的状态,以及一个将状态映射到它与起始状态的距离的字典。在这个字典中,一个状态的默认值将是inf
。
我们把起始状态放在字典里,并把它映射到0
,因为它与自身的距离是0
。
我们需要的另一件事是一个优先级队列,它将对未访问的状态的距离进行排序。我们把起始状态和状态本身的距离放在优先级队列中。
我们还需要一个字典*(parents*),将一个状态映射到它的父级,以便在我们找到它之后,轻松地重建从起始状态到最终状态的路径。
只要我们在优先级队列里有状态,我们就取其中的第一个状态,检查它是否是最终状态。如果是,我们就中断循环,因为我们已经找到了路径。如果不是,我们就把这个状态放到已访问的集合中,并计算它所有未访问的邻居的新成本。
一个邻居的新成本等于当前状态的成本和当前状态与该邻居之间的距离之和。如果新的成本小于旧的成本,我们就把具有新成本的邻居放在一个优先队列中,并更新成本字典以及父母字典。
一旦我们找到了正确的路径,我们就用 "父母 "字典轻松地重构它。
在这个函数中,我们计算了迭代的次数,这样我们就可以将它们的数量与A*算法中的迭代次数进行比较。这就是为什么我们把它们和路径一起返回。
现在让我们在主函数中调用Dijkstra算法,使用我们之前提到的棋盘的例子。
if __name__ == '__main__':
start_state = [[2,3,5], [1,4,0], [7,8,6]]
target_state = [[0,1,2],[3,4,5],[6,7,8]]
print('Dijkstra benchmark')
(path, iteration) = dijkstra(start_state, target_state)
print(path, iteration)
这将会产生以下结果。
Dijkstra benchmark
['2:3:5:1:4:0:7:8:6', '2:3:5:1:4:6:7:8:0', '2:3:5:1:4:6:7:0:8', '2:3:5:1:0:6:7:4:8', '2:0:5:1:3:6:7:4:8', '0:2:5:1:3:6:7:4:8', '1:2:5:0:3:6:7:4:8', '1:2:5:3:0:6:7:4:8', '1:2:5:3:6:0:7:4:8', '1:2:0:3:6:5:7:4:8', '1:0:2:3:6:5:7:4:8', '0:1:2:3:6:5:7:4:8', '3:1:2:0:6:5:7:4:8', '3:1:2:6:0:5:7:4:8', '3:1:2:6:4:5:7:0:8', '3:1:2:6:4:5:0:7:8', '3:1:2:0:4:5:6:7:8', '0:1:2:3:4:5:6:7:8']
12649
正如我们所看到的,我们需要12649次迭代才能找到从第一个状态到洛伊德之谜最终状态的最短路径。
A*
现在,我们来看看更高效的A*算法。这个算法在很大程度上取决于它的启发式算法,所以我们在选择时必须非常小心。这个函数将帮助我们将搜索引向一个更理想的解决方案。
对于每个状态,我们将计算出一个质量。在对优先级队列中的状态进行排序时,这个质量将发挥很大的作用。
我们实际上可以把Dijkstra算法看作是A*算法的一个特例--其中启发式算法对每个状态都是0
。
现在,让我们想一想我们如何为这个特殊的问题写一个评估函数。
我们将使用每块瓷砖的当前位置与该瓷砖最终应该出现的位置之间的曼哈顿距离之和。这是最简单和最直观的启发式方法之一,这就是为什么它经常被用作起点。
曼哈顿距离被计算为两个向量之间的绝对差异之和。
它是根据曼哈顿的名字命名的,那里的建筑是以方形街区布置的,笔直的街道以直角相交,所以在那里移动只能从四个方向进行--左、右、上、下。这类似于我们在弗洛伊德谜题中的移动方式,这就是为什么这种启发式方法特别适合我们的问题。
这就是我们的评估函数的模样。
def h(state):
deserialized = deserialize(state)
H = 0
for i in range(0, 3):
for j in range(0, 3):
H += abs(deserialized[i][j] % 3 - j) + abs(deserialized[i][j] / 3 - i)
return H
现在,我们可能需要的另一个额外的函数是一个返回开放集中具有最低启发式猜测的状态的函数。
这个函数的输入是开放集,以及将状态映射到其启发式猜测的字典。
def in_open_set_with_lowest_heuristic_guess(open_set, heuristic_guess):
result, min_guess = None, float('inf')
for v in open_set:
if v in heuristic_guess:
guess = heuristic_guess[v]
if guess < min_guess:
result = v
min_guess = guess
return result
现在我们把这个问题解决了,让我们来看看实际的A*算法的实现。
def astar_lloyd(start_node, target_node, h):
start_node = serialize(start_node)
target_node = serialize(target_node)
open_set = set([start_node])
parents = {}
parents[start_node] = None
cheapest_paths = defaultdict(lambda: float('inf'))
cheapest_paths[start_node] = 0
heuristic_guess = defaultdict(lambda: float('inf'))
heuristic_guess[start_node] = h(start_node)
path_found = False
iteration = 0
while len(open_set) > 0:
# O(1)
current_node = in_open_set_with_lowest_heuristic_guess(open_set, heuristic_guess)
if current_node == target_node:
path_found = True
break
open_set.remove(current_node)
for (neighbour_node, weight) in get_neighbours(current_node):
new_cheapest_path = cheapest_paths[current_node] + weight
if new_cheapest_path < cheapest_paths[neighbour_node]:
parents[neighbour_node] = current_node
cheapest_paths[neighbour_node] = new_cheapest_path
heuristic_guess[neighbour_node] = new_cheapest_path + h(neighbour_node)
if neighbour_node not in open_set:
open_set.add(neighbour_node)
iteration += 1
path = []
if path_found:
while target_node is not None:
path.append(target_node)
target_node = parents[target_node]
path.reverse()
return (path, iteration)
就像上次的算法一样,我们使用了棋盘的序列化表示。
我们将需要两个集合:一个是开放集合,在这个集合中,我们保留被访问的状态,其邻居不一定都被访问过;另一个是封闭集合,在这个集合中,我们保留被访问的状态,其邻居也都被访问。
我们把起始状态放在开放集里,封闭集保持空。
就像在Dijkstra算法的实现中,我们保留了一个帮助我们重建路径的字典 "父母",以及一个最便宜路径的字典。
除此之外,我们还需要前面提到的启发式猜测的字典。
只要开放集里有状态,我们就从里面挑选一个启发式猜测最低的状态作为下一个当前状态。如果我们碰巧遇到了目标状态,我们就打破循环,重建路径。否则,我们将当前状态从开放集中删除。我们遍历当前状态的邻居,并计算出他们新的最便宜的路径。
邻居的最便宜路径等于当前状态的最便宜路径和当前状态与邻居的距离之和。
如果这个新的最便宜路径小于邻居之前的最便宜路径,我们就更新父、启发式猜测和最便宜路径字典。
如果邻居的状态不在开放集里,我们就把它放进去。
一旦我们找到了正确的路径,我们就用'父母'字典轻松地重构它。
在这个函数中,我们还计算了迭代次数,这样我们就可以将它们的数量与Dijkstra算法中的迭代次数进行比较。这就是为什么我们把它们和路径一起返回。
现在让我们在主函数中调用Dijkstra算法,使用我们之前提到的电路板的例子。
if __name__ == '__main__':
start_node = [[2,3,5], [1,4,0], [7,8,6]]
target_node = [[0,1,2],[3,4,5],[6,7,8]]
print('Astar benchmark')
(path, iteration) = astar_lloyd(start_node, target_node, h)
print(path, iteration)
这将会产生一个输出。
Astar benchmark
['2:3:5:1:4:0:7:8:6', '2:3:5:1:4:6:7:8:0', '2:3:5:1:4:6:7:0:8', '2:3:5:1:0:6:7:4:8', '2:0:5:1:3:6:7:4:8', '0:2:5:1:3:6:7:4:8', '1:2:5:0:3:6:7:4:8', '1:2:5:3:0:6:7:4:8', '1:2:5:3:6:0:7:4:8', '1:2:0:3:6:5:7:4:8', '1:0:2:3:6:5:7:4:8', '0:1:2:3:6:5:7:4:8', '3:1:2:0:6:5:7:4:8', '3:1:2:6:0:5:7:4:8', '3:1:2:6:4:5:7:0:8', '3:1:2:6:4:5:0:7:8', '3:1:2:0:4:5:6:7:8', '0:1:2:3:4:5:6:7:8']
217
正如我们所看到的,我们只需要217
,就能找到从第一个状态到洛伊德之谜最终状态的相同的最短路径!
结论
尽管Dijkstra算法和A*算法都能找到相同的最短路径,但A*算法的速度几乎是它的60倍!虽然Dijkstra算法在12649次迭代后产生了输出,但A*算法只用了217次。
然而,应该注意的是,A*算法的效率在很大程度上取决于它的评估函数,如果采用错误的函数,结果可能比Dijkstra还要糟糕。
总而言之,鉴于我们对问题有一个很好的启发式猜测,与Dijkstra算法相比,使用A*算法肯定更有效率,尽管这并不总是如此,因为它可能高度依赖于手头的问题。