遍历与启发式搜索

遍历算法

常规的遍历算法主要包括DFS和BFS。这两个算法不需要知道终结点在哪里,它会按照一定的模式挨个搜索,直到找到终结点。

举个不恰当的例子,遍历算法相当于你蒙着眼睛站在球场上,球场有一个球,需要你找到球,因为不知道球在哪,只能挨个找完所有位置。而启发式搜索需要一个启发函数,相当于睁开了眼睛,就可以直奔着球去了。

关于搜索算法的python实现已经放在了GitHub上:搜索求解算法

具体实现是用不同的搜索算法在网格地图上寻找终点。如A*算法的实现效果如下:

A*算法示例

深度优先搜索(DFS)

深度优先,顾名思义即深度越大的节点会被优先扩展。在DFS中,使用栈(Stack)来实现上述特性。栈的特性是先进后出、后进先出。DFS过程中,会遍历当前节点的所有邻接节点,将它们依次压入栈中,遍历结束后,取出栈中最后一个元素,也就是最后一个被访问的邻接节点,继续递归。

DFS为数据结构中的基础内容,这里不再细说,遍历过程可以看下图(标号为第几轮遍历,橘黄色为寻找的路径):

上图为DFS的遍历过程(按照左上右下遍历)与寻找到的路径。首先遍历起点的邻接节点,并标注为1(第一轮遍历),将下节点1(即栈尾)出栈,递归遍历,直到找到终点。

广度优先搜索(BFS)

广度优先会遍历完同级的所有节点,再增加深度。BFS中,使用队列(Queue)实现。队列的特性是先进先出、后进后出。BFS过程中,会遍历当前节点的所有邻接节点,将它们依次放入队列中,一轮遍历结束后,取出队列中第一个一个元素,也就是第一个被访问的邻接节点。

所以每一次的遍历,会遍历同一层所有节点的邻接节点。直到同一层的所有节点都已经遍历,才会遍历下一层的节点。看起来就像是一圈一圈地向外扩展。

BFS遍历过程如下图(标号为第几轮遍历,橘黄色为寻找到的路径):

上图为BFS的遍历过程与寻找到的路径。首先遍历起点的邻接节点,并标注为1(第一轮遍历),将左节点1(即队首)出队列,遍历左节点1,其相邻的节点标注为2,排入队列中,直到找到终点。而在从终点向起点的回溯过程中(按照左上右下回溯),可以找到最短路径。

可以看出,一般而言,DFS遍历的节点较少,在某些节点没有遍历的情况下已经达到了终点。不过,找到的路径并非是最优路径。而BFS的遍历次数较多,但是回溯的时候可以找到最短路径。

DFS就像是一个孤军深入的莽夫,速度很快,一直向前冲,撞到南墙就回到上一步换个方向。BFS则稳扎稳打,步步为营,走得虽然很慢,但是可以找到最短路径。

Dijkstra算法

Dijkstra算法用来寻找图形中从一个顶点到其余各顶点的最短路径算法,一般用来解决两点之间(带权)最短路径问题。考虑这样一种场景,在一些情况下,图形中相邻节点之间的移动代价并不相等。

它既与BFS有相同之处,又与后面要介绍的贪婪最佳优先算法有一定相关性。

Dijkstra算法是这样运行的:把所有的点分成2类,一类称为S,指已经确定与起点之间最短路径的点集合,一类为U,指还没有确定与起点之间最短路径的点的集合。U中的点使用优先级队列表示,优先值为点到S的距离,如x到S的距离记作 $D_{S-x}$ 。之后每次从U中取一个距离最小的点,比如n点,加入到S中<并更新n在U中的邻点x的值为$D_{S-x} = \min(D_{S-x}, D_{x-n}+D_{S-n})$。依次迭代,直到U为空。

下面举个例子,如下为带权路径图(a为起点,i为终点):

以上图为例,计算起点a到终点i的最短路径,箭头上的数值表示两个节点间的距离。

未与S中元素直接相连的点距离设置为正无穷。

第一轮选点,从优先队列U中选择值最小的b点放入S,并且更新b的邻点x的值为$\min(D_{S-x}, D_{x-b}+D_{S-b})$:

如邻点c,之前的$D_{S-c} = \infty$,在b加入S之后的$D_{S-c} = \min(\infty, 2+1) = 3$,故b点加入S集合之后的状态如下:

第二轮选点,从优先队列U中选择c点放入S,并且更新c的邻点x的值:

第三轮选点,从优先队列U中选择e点放入S,并且更新e的邻点x的值:

第四轮选点,从优先队列U中选择d点放入S,并且更新d的邻点的值:

第五轮选点,从优先队列U中选择g点放入S,并且更新g的邻点的值:

第六轮选点,从优先队列U中选择h点放入S,并且更新h的邻点的值:

第七轮选点,从优先队列U中选择f点放入S,并且更新f的邻点的值:

看上去,BFS与Dijkstra算法最显著的区别在于BFS使用了队列,而Dijkstra算法使用了优先级队列。实际上,使用不同的数据结构是为了各自取下一个点的方法而服务。

本质上,搜索算法和路径规划算法的目的都是如何正确地选取下一个节点。我们可以称评估如何选取下一个点的指标为代价函数$f(n)$,选取下一个点是要付出代价的,代价最小的点就是我们应该选取的点。

BFS算法之所以选择队列作为数据结构,是因为它每次选取的点为当前已经遍历的点的邻点,它的代价函数是当前点与起点的遍历轮数最小值。

Dijkstra算法之所以使用优先级队列,是因为它每次选取的点为从起点移动到当前点的最小值,也可以说是与已经遍历的点集合的距离最小的点。即它的代价函数是起点到当前点的移动代价。而当路径为无权时,移动代价就等于遍历轮数,即退化成了BFS。

启发式搜索

BFS和DFS的区别主要在于节点的弹出策略,根据弹出策略的区别,分别使用了队列和栈两种数据结构,而栈和队列作为两种相当基本的容器,只将节点进入容器的顺序作为弹出节点的依据,并未考虑终点位置等因素,这就使搜索过程与终点位置无关,变得漫无目的,导致效率低下。Dijkstra算法虽然可以解决带权的路径问题,却同样没有利用到终点位置信息,它的代价函数只与起点有关。而启发式搜索借助了一个启发函数,需要提前知道终点的位置,优化代价函数与起点和终点都有关,这样可以更快达到终点。

贪婪最佳优先算法

贪婪最佳优先算法(Greedy Best First Search, GBFS)的特点是评价函数=启发函数。

设评价函数为$f(n)$,启发函数为$h(n)$,那么令$f(n)=h(n)$。即从当前节点出发,寻找下一个邻接节点,邻接节点选择的依据(评价函数)为使得启发函数值最最小的节点。

之所以贪婪最佳优先算法的速度一般会比较快,是因为启发函数使用了当前点与终点的信息。

同样以寻找路径为例:

GBFS算法

上图为使用贪婪最佳优先算法寻找的路径(橙色标注),有数值的格子表示被遍历到的节点,数值表示该点与终点的欧氏距离,即启发函数$h(n)=$欧氏距离。

首先在起点处,计算邻点的$h(n)$,由于起点右侧的点距离小于其他邻点,即$h(n)$最小,又由于$f(n)=h(n)$,故该点的代价函数最小,因此选择该点作为下一个点。然后重复上述过程,直到达到终点。

可以看到,由于设置代价函数=启发函数,使得寻找过程中被遍历的点减少了,也就是说,这个算法可以直奔终点快速前进。

当然,这个算法也有不足之处:1.寻找的路径常常不是最优解,2.启发函数最小化这一目标对于错误的起点比较敏感,不同的起点对于路径寻找过程影响很大,甚至有可能进入死循环,3.最坏情况下复杂度很高为$O(b^m)$,b为节点分支因子数、m为搜索空间的最大深度。

实际的地图中,常常会有很多障碍物,因为代价函数只与启发函数有关,而启发函数只表示当前位置与终点的关系,所以GBFS就很容易陷入局部最优的陷阱。下图的地图中有一个专门设置的局部最优陷阱,很显然GBFS虽然搜索速度够快,但是找不到最优路径。

有障碍GBFS

针对这些不足之处,最重要的是需要设计一个良好的启发函数。

A*算法

英语不错的同学可以直接看一看这篇:Introduction to the A* Algorithm

GBFS用节点到目标点的距离作为代价函数,将搜索方向引向目标点,搜索效率高;而Dijkstra算法采用起点到当前扩展节点的移动代价作为代价函数,能够确保路径最优。

那么可不可以将两者的代价函数进行融合,从而在保证路径最优的同时提高搜索效率?答案是肯定的,融合后的算法就是A*算法

A*算法的代价函数为$f(n) = g(n) + h(n)$,g(n)为起点到当前节点的移动代价,$h(n)$为启发函数,表示从当前点到终点的距离函数。

其中,若将$g(n)$置为0,则A*算法退化成贪婪最佳优先算法,若$h(n)=0$,则退化成Dijkstra算法。

能否找到最优路径的关键是启发函数$h(n)$的选取,如果$h(n)$始终小于等于节点n到终点的代价,则A*算法保证一定能够找到最短路径。但是当$h(n)$的值选取不合适,会导致算法将遍历越多的节点,也就导致算法越慢。
由于A* 算法的启发函数是位置上的距离,因此在不带位置信息的图数据中不适用。

A*算法描述如下:

* 初始化open_set和close_set;
* 将起点加入open_set中,并设置优先级f(n)为0(优先级最高);
* 如果open_set不为空,则从open_set中选取优先级最高的节点n:
    * 如果节点n为终点,则:
        * 从终点开始逐步追踪parent节点,一直达到起点;
        * 返回找到的结果路径,算法结束;
    * 如果节点n不是终点,则:
        * 将节点n从open_set中删除,并加入close_set中;
        * 遍历节点n所有的邻近节点:
        	* 如果邻近节点m在open_set中,则:
                * 比较g(n)是否比原来更小,如果更小则更新其g(n)、优先级f(n)和其父节点
            * 如果邻近节点m在close_set中,则:
                * 跳过,选取下一个邻近节点
            * 如果邻近节点m不在open_set和close_set中,则:
                * 设置节点m的parent为节点n
                * 计算移动代价g(n)
                * 计算节点m的优先级f(n)
                * 将节点m加入open_set中

对于网格形式的图,有以下这些启发函数可以使用:

  • 如果图形中只允许朝上下左右四个方向移动,则可以使用曼哈顿距离(Manhattan distance)。
  • 如果图形中允许朝八个方向移动,则可以使用对角距离。

  • 如果图形中允许朝任何方向移动,则可以使用欧几里得距离(Euclidean distance)。

最优遍历点:

对于下图:

假设路径函数$g(n)$和启发函数$h(n)$都使用曼哈顿距离,且$f(n)=g(n)+h(n)$。

首先,对于B点和E点,当它们都在open_set时,应该选取哪一个点作为下一个路径点呢?E与B的代价函数f(n)相等,而直觉上应该选择B点,因为第一行第三列与E点是等价的,选择E点必然也可以选择它,而这样会大大增加遍历次数。B点的启发函数小于E,而路径函数大于E,为了使得B点可以优于E点,我们需要使得B点减少的启发函数代价要多于其增加的路径代价。

其次,对于A、B、C、D四点来说,$f(A)=f(B)=f(C)=f(D)$,并且四点的启发函数也相同,那么在这种情况下,当四个点都在open_set中时,又该如何选取呢?如果我们更倾向于选择B、C,那么需要使得它们的代价更小。

针对上述2点,我们可以改变路径函数,比如使用对角距离作为路径函数,则E、B/C、A/D的代价函数2+4、2.4+3、3+3(路径+启发函数),即可实现选取B/C作为更优点的目的。

或者,在不改变路径函数的情况下,在代价函数相同时,使用额外的约束条件。比如路径和启发函数都为曼哈顿距离,则E、B/C、A/D的代价函数相同,此时取其中到终点对角距离最小的点最为最优点即可选出B/C。

最短路径:

关于A*算法能否找到最短路径最重要的一点就是已经处于open_set的邻点的更新。也就是如果遍历到的邻点在open_set中,要比较现有路径和原路径哪一个更短,取更短的路径的上一个节点作为父节点。

以下举个例子:假设在A点处遍历到邻点B,且B没有加入open_set,则的B父节点设置为A。一段时间后,在C点处也遍历到了B,则比较$g(c)+D_{C-B}$和$g(A)+D_{A-B}$,若$g(c)+D_{C-B}$更小,则应该更新B的父节点为A。

关于GBFS和A*算法在路径寻找过程中的差别可以参看下图:

上图为GFBS和A*算法正有障碍路径寻找过程中,每个节点的代价函数。可以看到,使用GBFS之后,路径上的节点的代价函数随着接近终点会逐渐减小,但是却无法找到最优路径。


  上一篇
机器学习——SVM简述 机器学习——SVM简述
支持向量机(Support Vector Machine, SVM)是一种应用非常广泛的分类方法。传统的SVM是一种二类分类方法,适用于数据只有两类的情况。本文和接下来几篇文章将从间隔最大化开始,逐步推导SVM形成的优化问题,拉格朗日对偶问题,非线性SVM,以及训练SVM的序列最小最优化(Sequential Minimal Optimization, SMO)算法等。
2020-11-11
下一篇 
搜索算法综述 搜索算法综述
本文为搜索求解算法学习笔记。主要内容为启发式搜索、对抗式搜索和蒙特卡洛树搜索。启发式搜索中包含了贪婪最佳优先和A*算法,对抗式搜索包括了最大最小搜索和以此为基础的Alpha-Bata剪枝搜索。
2020-10-02
   目录