数据结构与算法——30. 广度、深度优先搜索及骑士周游问题

一、广度优先搜索(Breadth First Search,BFS)

在前面的词梯问题中,在单词关系图建立完成以后,需要继续在图中寻找词梯问题的最短序列,这就需要用到“广度优先搜索(Breadth First Search,BFS)”。BFS是搜索图的最简单算法之一,也是其它一些重要的图算法的基础。

给定图G,以及开始搜索的起始顶点s。BFS搜索所有从s可到达顶点的边。而且在达到更远的距离k+1的顶点之前,BFS会找到全部距离为k的顶点。(看不懂没关系,看完后面的举例就明白了)

可以想象为以s为根,构建一棵树的过程,从顶部向下逐步增加层次。广度优先搜索能保证在增加层次之前,添加了所有兄弟节点到树中。

1. BFS算法过程

我们从fool开始搜索,从fool可以到达的顶点有foul、foil、cool、pool,完成第一轮,距离为1。然后再依次以这四个顶点为起点,寻找下一步可以到达的顶点fail、poll,完成第2轮,距离等于2。然后是第三轮……。如此反复,直到抵达目标sage为止。

在这里插入图片描述

但有些顶点之间的距离是不确定的,比如fool到pool,可以直接到达,距离为1;也可以通过cool到达,距离为2。

为了跟踪顶点的加入过程,并避免重复顶点,要为顶点增加3个属性:

  1. 距离(distance):从起始顶点到此顶点路径长度
  2. 前驱顶点(predecessor):可反向追溯到起点;
  3. 颜色(color):标识了此顶点是尚未发现(白色)、已经发现(灰色)、还是已经完成探索(黑色)。

还需要用一个队列Queue来对已发现的顶点进行排列,已经发现的放到队首,完成探索的放到队尾。以此决定下一个要探索的顶点(队首顶点)。

算法过程:

从起始顶点s开始,作为刚发现的顶点,标注为灰色,距离为0,前驱为None。将s加入队列,接下来是个循环迭代过程:

  1. 从队首取出一个顶点作为当前顶点;
  2. 遍历当前顶点的邻接顶点
  3. 如果是尚未发现的白色顶点,则将其颜色改为灰色(已发现),距离增加1,前驱顶点为当前顶点,加入到队列中;
  4. 遍历完成后,将当前顶点设置为黑色(已探索过),循环回到步骤1的队首取当前顶点。

2. python实现

def bfs(g,start):  # 参数:图,起点
  # 起点的距离设置为1
  start.setDistance(0)
  # 起点的前驱设置为None
  start.setPred(None)
  vertQueue = Queue()
  # 将起点放入队首
  vertQueue.enqueue(start)
  while (vertQueue.size() > 0):
    # 取出队列的队首元素作为当前顶点
    currentVert = vertQueue.dequeue()
    # 遍历当前顶点的邻接顶点
    for nbr in currentVert.getConnections():
      if (nbr.getColor() == 'white'):
        nbr.setColor('gray')
        nbr.setDistance(currentVert.getDistance() + 1)
        nbr.setPred(currentVert)
        vertQueue.enqueue(nbr)
    # 当前顶点探索完成,设置为黑色
    currentVert.setColor('black')

在以FOOL为起始顶点,遍历了所有顶点,并为每个顶点着色、赋距离和前驱之后。就可以通过一个回途追溯函数来确定FOOL到任何单词顶点的最短词梯

def traverse(y):  # y是目标单词
    x = y
    while (x.getPred()):
        print(x.getId())
        x = x.getPred()
    print(x.getId())

traverse(g.getVertex('sage'))

3. 算法分析

BFS算法主体是两个循环的嵌套:

  • while循环对每个顶点访问一次,所以是 O ( ∣ V ∣ ) O(|V|) O(V),V是顶点数量;

  • 而嵌套在while中的for,由于每条边只有在其起始顶点u出队的时候才会被检查一次,而每个顶点最多出队1次,所以边最多被检查1次,一共是 O ( ∣ E ∣ ) O(|E|) O(E),E是边的数量。

综合起来BFS的时间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(V+E)

建立BFS树之后,回溯顶点到起始顶点的过程,最多为 O ( ∣ V ∣ ) O(|V|) O(V)。创建单词关系图也需要时间,最多为 O ( ∣ V ∣ 2 ) O(|V|^2) O(V2)

二、图的应用:骑士周游问题

在一个国际象棋棋盘上,一个棋子“马”(骑士),按照“马走日”的规则,从一
个格子出发,要走遍所有棋盘格恰好一次。把一个这样的走棋序列称为一次“周游”。

在8×8的国际象棋棋盘上,合格的“周游”数量有 1.305 × 1 0 35 1.305\times 10^{35} 1.305×1035这么多,走棋过程中失败的周游就更多了。

采用图搜索算法,是解决骑士周游问题最容易理解和编程的方案之一,解决方案还是分为两步:

  1. 首先将合法走棋次序表示为一个图;
  2. 采用图搜索算法搜寻一个长度为( 行 × 列 − 1 行\times 列-1 ×1)的路径,路径上包含每个顶点恰一次。

1. 构建骑士周游图

将棋盘和走棋步骤构建为图的思路:

  • 将棋盘格作为顶点;
  • 按照“马走日”规则的走棋步骤作为连接边;
  • 建立每一个棋盘格的所有合法走棋步骤能够到达的棋盘格关系图。

比如下面的“马”,它的下一步合法移动可以表示为右边的图:

在这里插入图片描述

它可以走的合法格子最多8个,如果“马”最初在一个靠边的位置,那么就能走的格子就少于8个。

合法走棋位置函数:

def genLegalMoves(x,y,bdSize):
    newMoves = []
    """
    “马”走日,可以前往的格子最多有8个
    通过对“马”的当前位置(x、y)进行加减,得到合法的移动位置
    """
    moveOffsets = [(-1,-2),(-1,2),(-2,-1),(-2,1),
                   ( 1,-2),( 1,2),( 2,-1),( 2,1)]
    # 依次将偏移量加到“马”的当前位置上,保存新的位置
    for i in moveOffsets:
        newX = x + i[0]
        newY = y + i[1]
        # 确保不会走出棋盘,将落在棋盘内的移动位置,追加到合法移动列表中
        if legalCoord(newX,bdSize) and \
                        legalCoord(newY,bdSize):
            newMoves.append((newX,newY))
    return newMoves


def legalCoord(x,bdSize):
    # 确保不会走出棋盘
    if x >= 0 and x < bdSize:
        return True
    else:
        return False

构建走棋关系图:

def knightGraph(bdSize):
    ktGraph = Graph()
    for row in range(bdSize):
       # 遍历每个格子
       for col in range(bdSize):
           nodeId = posToNodeId(row,col,bdSize)
           # 单步合法走棋
           newPositions = genLegalMoves(row,col,bdSize)
           for e in newPositions:
               nid = posToNodeId(e[0],e[1],bdSize)
               # 添加边和顶点
               ktGraph.addEdge(nodeId,nid)
    return ktGraph


def posToNodeId(row, column, board_size):
    return (row * board_size) + column

骑士周游图:

8×8棋盘生成的图,具有336条边,相比起全连接的4096条边,仅8.2%,还是稀疏图。

在这里插入图片描述

2. 骑士周游问题算法实现

用于解决骑士周游问题的图搜索算法是深度优先搜索(Depth First Search)

相比前述的广度优先搜索,其逐层建立搜索树的特点。深度优先搜索是沿着树的单支尽量深入向下搜索,如果到无法继续的程度还未找到问题解,就回溯上一层再搜索下一支。

后面会介绍DFS的两个实现算法:
一个DFS算法用于解决骑士周游问题,其特点是每个顶点仅访问一次;
另一个DFS算法更为通用,允许顶点被重复访问,可作为其它图算法的基础。

深度优先搜索解决骑士周游的关键思路:

  • 如果沿着单支深入搜索到无法继续(所有合法移动都已经被走过了)时,路径长度还没有达到预定值(8×8棋盘为63),那么就清除颜色标记,返回到上一层。换一个分支继续深入搜索。
  • 引入一个栈来记录路径,并实施返回上一层的回溯操作。

python代码实现:

def knightTour(n,path,u,limit):
        """
        :param n: 层次,当前走了多少步
        :param path: 路径,走过的格子
        :param u: 当前顶点
        :param limit: 搜索总深度限制,比如8*8=63
        """
        u.setColor('gray')
        # 当前顶点加入路径列表末尾(这个列表用来模拟栈)
        path.append(u)
        # 搜索深度没有到达限制时,继续深入搜索
        if n < limit:
            # 对所有合法移动逐一深入
            nbrList = list(u.getConnections())
            i = 0
            done = False
            while i < len(nbrList) and not done:
                # 选择白色未经过的顶点深入
                if nbrList[i].getColor() == 'white':
                    # 层次加1,递归深入
                    done = knightTour(n+1, path, nbrList[i], limit)
                i = i + 1
            # 都无法完成总深度,回溯,试本层下一个顶点
            if not done: 
                path.pop()
                u.setColor('white')
        else:
            done = True
        return done

下图就是骑士周游问题的一个解:

在这里插入图片描述

3. 骑士周游问题算法分析与改进

(1)算法分析

上述算法的性能高度依赖于棋盘大小:
5 × 5 5\times 5 5×5棋盘而言,约1.5秒可以得到一个周游路径。但 8 × 8 8\times 8 8×8棋盘,则要半个小时以上才能得到一个解。

目前实现的算法,其复杂度为 O ( k n ) O(k^n) O(kn),其中n是棋盘格数目。这是一个指数时间复杂度的算法!其搜索过程表现为一个层次为n的树。

(2)算法改进

nbrList的灵巧构造,以特定方式排列顶点访问次序。可以使得 8 × 8 8\times 8 8×8棋盘的周游路径搜索时间降低到秒级!这个改进算法被特别以发明者名字命名:Warnsdorff算法。

初始算法中nbrList,直接以原始顺序来确定深度优先搜索的分支次序。新的算法,仅修改了遍历下一格的次序。将u的合法移动目标棋盘格排序为:具有最少合法移动目标的格子优先搜索

def orderByAvail(n):
    resList = []
    for v in n.getConnections():
        if v.getColor() == 'white':
            c = 0
            for w in v.getConnections():
                if w.getColor() == 'white':
                    c = c + 1
            resList.append((c,v))
    resList.sort(key=lambda x: x[0])
    return [y[1] for y in resList]

4. 启发式规则(heuristic)

采用先验的知识来改进算法性能的做法,称作为“启发式规则”

启发式规则经常用于人工智能领域;可以有效地减小搜索范围、更快达到目标等等;

如棋类程序算法,会预先存入棋谱、布阵口诀、高手习惯等“启发式规则”,能够在最短时间内,从海量的棋局落子点搜索树中定位最佳落子。例如:黑白棋中的“金角银边”口诀,指导程序优先占边角位置等等

三、深度优先搜索(Depth First Search,DFS)

骑士周游问题是一种特殊的对图进行深度优先搜索,其目的是建立一个没有分支的最深的深度优先树,表现为一条线性的包含所有节点的退化树,从0开始直接到63,没有别的岔路。

通用的深度优先搜索目标是在图上进行尽量深的搜索,连接尽量多的顶点,必要时可以进行分支(创建了树)。有时候深度优先搜索会创建多棵树,称为“深度优先森林”。

深度优先搜索同样要用到顶点的“前驱”属性,来构建树或森林。另外要设置“发现时间”和“结束时间”属性:

  • 前者是在第几步访问到这个顶点(设置灰色)
  • 后者是在第几步完成了此顶点探索(设置黑色)

这两个新属性对后面的图算法很重要。

带有DFS算法的图实现为Graph的子类,顶点Vertex增加了成员Discovery及Finish,图Graph增加了成员time用于记录算法执行的步骤数目。

1. python代码实现

class DFSGraph(Graph):
    def __init__(self):
        super().__init__()
        self.time = 0

    def dfs(self):
        for aVertex in self:
            # 所有顶点初始化为白色
            aVertex.setColor('white')
            # 建立一棵树
            aVertex.setPred(-1)
        # 如果还有未包括的顶点,则建森林
        for aVertex in self:
            if aVertex.getColor() == 'white':
                self.dfsvisit(aVertex)

    def dfsvisit(self,startVertex):
        startVertex.setColor('gray')
        self.time += 1
        startVertex.setDiscovery(self.time)
        for nextVertex in startVertex.getConnections():
            if nextVertex.getColor() == 'white':
                nextVertex.setPred(startVertex)
                # 深度优先递归访问
                self.dfsvisit(nextVertex)
        startVertex.setColor('black')
        self.time += 1
        startVertex.setFinish(self.time)

图解示例,灰色的为“刚刚发现或开始探索”,白色的为“未发现”,黑色的为“探索完成”:

在这里插入图片描述

2. 算法分析

DFS构建的树,其顶点的“发现时间”和“结束时间”属性,具有类似括号的性质:

  • 一个顶点的“发现时间”总小于所有子顶点的“发现时间”;
  • “结束时间”则大于所有子顶点“结束时间”;
  • 比子顶点更早被发现,更晚被结束探索。

比如,上图中B的“发现时间”为2,小于C、D、E、F的“发现时间”;“结束时间”为11,大于C、D、E、F的“结束时间”。

DFS运行时间同样也包括了两方面:

  • dfs函数中有两个循环,每个都是 ∣ V ∣ |V| V次,所以是 O ( ∣ V ∣ ) O(|V|) O(V)
  • dfsvisit函数中的循环则是对当前顶点所连接的顶点进行,而且仅有在顶点为白色的情况下才进行递归调用,所以对每条边来说只会运行一步,所以是 O ( ∣ E ∣ ) O(|E|) O(E)
  • 加起来就是和BFS一样的 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(V+E)
  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

花_城

你的鼓励就是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值