本文为学习笔记,感兴趣的读者可在MOOC中搜索《数据结构与算法Python版》或阅读《数据结构(C语言版)》(严蔚敏)
目录链接:https://blog.csdn.net/floating_heart/article/details/123991211
3.1 图Graph
图(Graph)是一种较线性表和树更为复杂的数据结构。在线性表中,数据元素之间仅有线性关系,每个数据元素只有一个直接前驱和直接后继;在树形结构中,数据元素之间有着明显的层次关系,并且每一层上的数据元素可能和下一层中多个元素(即其孩子结点)相关,但只能和上一层中一个元素(即其双亲结点)相关;而在图形结构中,结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。由此,图的应用极为广泛,特别是近年来的迅速发展,已渗入到诸如语言学、逻辑学、物理、化学、电讯工程、计算机科学以及数学的其他分支中。
——《数据结构(C语言版)》严蔚敏 吴伟民, 2007有关图的理论内容可以在“离散数学”中进一步了解
3.1.1 图的基本概念和术语
数据结构中的图由结点的有穷非空集合和结点之间关系的集合组成,由前言可知,图的结点之间的关系是任意的。
图的相关术语如下:
-
**顶点Vertex(也称结点Node):**是图的基本组成部分,顶点具有名称标识
Key,也可以携带数据项payload -
**边Edge(也称“弧Arc”):**作为2个顶点之间关系的表示,边连接两个顶点;边可以是无向或者有向的,相应的图称作“无向图(Undigraph)”和“有向图(Digraph)”。
易知,有向图的边数量在 0 0 0到 n ( n − 1 ) n(n-1) n(n−1)之间,无向图的边数量在 0 0 0到 1 2 n ( n − 1 ) \frac{1}{2}n(n-1) 21n(n−1)之间;
(v, w)表示从v到w的一条边或弧,在“有向图”中,v为弧尾(Tail)或初始点(Initial node),w为弧头(Head)或终端点(Terminal node),在“无向图”中,每一条边都是对称的。
-
**完全图(Completed graph):**有 1 2 n ( n − 1 ) \frac{1}{2}n(n-1) 21n(n−1)条边的无向图称为完全图;有 n ( n − 1 ) n(n-1) n(n−1)条边的有向图称为有向完全图。
-
**稀疏图(Sparse graph)与稠密图(Dense graph):**有很少条边或弧(如 e < n l o g n e<nlogn e<nlogn的图称为稀疏图,反之称为稠密图。
-
**权重Weight:**为了表达从一个顶点到另一个顶点的“代价”,可以给边赋权;例如公交网络中两个站点之间的“距离”、“通行时间”和“票价”都可以作为权重。
- 一个图G可以定义为G=(V, E)
其中V是顶点的集合,E是边的集合,E中的每条边e=(v, w),v和w都是V中的顶点;
如果是赋权图,则可以在e中添加权重分量
以上图为例:
V
=
{
V
0
,
V
1
,
V
2
,
V
3
,
V
4
,
V
5
}
V\:=\:\{V0, V1,V2,V3,V4,V5\}
V={V0,V1,V2,V3,V4,V5}
E
=
{
(
v
0
,
v
1
,
5
)
,
(
v
1
,
v
2
,
4
)
,
(
v
2
,
v
3
,
9
)
,
(
v
3
,
v
4
,
7
)
,
(
v
4
,
v
0
,
1
)
,
(
v
0
,
v
5
,
2
)
,
(
v
5
,
v
4
,
8
)
,
(
v
3
,
v
5
,
3
)
,
(
v
5
,
v
2
,
1
)
}
E\:=\:\{(v0,v1,5),(v1,v2,4),(v2,v3,9),(v3,v4,7),(v4,v0,1),(v0,v5,2),(v5,v4,8),(v3,v5,3),(v5,v2,1)\}
E={(v0,v1,5),(v1,v2,4),(v2,v3,9),(v3,v4,7),(v4,v0,1),(v0,v5,2),(v5,v4,8),(v3,v5,3),(v5,v2,1)}
在无向图G=(V,E)中,如果边(v, v’)∈E,则称顶点v和v’互为邻接点(Adjacent),即v和v’相邻接。边(v, v’)**依附(Incident)于顶点v和v’,或者说(v, v’)和顶点v和v’相关联。顶点v的度(Degree)**是和v相关联的边的数目,记为TD(V)。
在有向图G=(V,A)(此处A与E意义相近,均为边的集合)中,如果弧(v, v’)∈A,则称顶点v邻接到顶点v’,顶点v’邻接自顶点v。弧(v, v’)和顶点v,v’相关联。以顶点v为头的弧的数目称为v的入度(InDegree),记为 I D ( v ) ID(v) ID(v);以v为尾的弧的数目称为v的出度(Outdegree),记为 O D ( v ) OD(v) OD(v);顶点v的度为 T D ( v ) = I D ( v ) + O D ( v ) TD(v)=ID(v)+OD(v) TD(v)=ID(v)+OD(v)。
- **子图:**V和E的子集。
- **路径Path:**图中的路径,是由边依次连接起来的顶点序列;
无权路径的长度为边的数量;
带权路径的长度为所有边权重的和,如上图路径(v0, v1, v2)长度为9; - **圈Cycle:**圈是首尾顶点相同的路径,如上图中(v5, v2, v3, v5)是一个圈;
如果有向图中不存在任何圈,则称作“有向无圈图directed acyclic graph : DAG”,相关的一些如如下:
(如果一个问题能表示成DAG,就可以用图算法很好地解决。)
3.1.2 图抽象数据类型
在《数据结构: C语言版》中,ADT Graph定义如下:
此处,我们进行相对简单的定义:
Graph(): 创建一个空的图
addVertex(vert): 将顶点vert加入图中
addEdge(fromVert, toVert): 添加有向边
addEdge(fromVert, toVert, weight): 添加带权的有向边
gerVertex(vKey): 查找名称为vKey的顶点
getVertices(): 返回图中所有顶点列表
in: 按照vert in graph的语句形式,返回顶点是否存在图中True/False
3.1.3 图的存储结构
1)数组存储(邻接矩阵Adjacency Matrix)
如图所示,采用顺序存储结构,矩阵的每行和每列都代表图中的顶点,如果两个顶点之间有边相连,设定行列值为边的权值,无权值可标注为1或0。如上图(v0, v1)的权值为5,矩阵中v0行v1列的值设定为5。
优点:
- 邻接矩阵可采用二维数组的方式进行存储,十分简单直观,方便获得顶点的度(入度和出度)。
- 对于无向图,矩阵关于对角线对称,存储时可以只保存其对角线的一半。
缺点:
- 如果图中的边数很少(稀疏图),会形成稀疏矩阵,效率低下。
2)邻接表Adjacency List
- 邻接列表法维护一个包含所有顶点的主列表(master list);
- 主列表中的每个顶点,再关联一个与自身有边连接的所有顶点的列表。
在python中,步骤2中连接的列表用字典可以很好表示,但在传统的方式中,一个链表结构是很有必要的,如下所示:
其中:
-
头结点由两部分组成,data保存结点的数据,firstarc指向以该结点为弧尾的弧的弧头(表结点);
-
单个表结点由三部分组成,邻接点域(adjvex)指示表结点的位置,链域(nextarc)指示下一条边或弧的结点,数据域(info)存储边或弧的相关信息,如权值等;
-
整体属于链式存储结构,头结点可以以一个顺序存储结构存储(如列表),保存所有顶点的信息,每一个头结点后链接一个由表结点组成的链表,表结点以链式存储结构保存,一条链表中的弧其弧尾均为头结点代表的顶点。
优点:
- 邻接列表法的存储空间紧凑高效,很容易获得顶点所连接的所有顶点,以及连接边的信息;
- 邻接列表法是稀疏图更高效的解决方案。
缺点:
- 可以简单地递归获取顶点的出度,但难以获取顶点的入度
- 对于有向图友好,对于无向图来说,弧头和弧尾不再区分后,会把每个边记录两次
3)十字链表Orthogonal List
十字链表是有向图的另一个存储结构,是有向图邻接表和“逆邻接表”结合起来得到的一种链表:链接弧尾为同一结点的弧的同时,也链接弧头为同一结点的弧,形成一个交叉的链。
在十字链表中,对应于有向图中每一条弧有一个结点,每一个顶点也有一个结点,结构如下:
弧结点中有5个域:
- 尾域(tailvex)和头域(headvex):分别指示弧尾和弧头这两个顶点在图中的位置
- 链域hlink和tlink:分别指示相同弧头和相同弧尾的下一条弧
- info域:存储边或弧的相关信息,如权值等
顶点结点中有3个域:
- data域存储和顶点相关的信息,如名称等
- 链域firstin和firstout:分别指向以该点为弧头或弧尾的第一个弧结点
显而易见,十字链表在邻接表的基础上有增加了一系列链表,两类链表(同一顶点为弧尾和同一顶点为弧头)均以顶点结点为头节点,之后链接符合条件的弧结点,一个弧结点最多被链接两次。
优点:
- 方便获得顶点的入度和出度
缺点:
- 结构相对复杂
- 针对有向图的设计,没有适用到无向图(不完全是缺点)
4)邻接多重表Adjacency Multilist
邻接多重表是一种链式存储结构,它在邻接表的基础上进行改进,取消了弧头和弧尾的差别,添加了mark标志域,使之更适用于无向图。
因为无向图中边没有方向,所以每个边结点(弧结点)处在两条链表中,与十字链表类似。
在这一结构中,弧结点由6个域组成:
- mark:用以标记该条边是否被搜索过,方便遍历
- ivex和jvex:该边依附的两个顶点在图中的位置
- ilink:下一条依附于顶点ivex的边
- jlink:下一条依附于顶点jvex的边
- info:和边相关的信息,如权值
顶点结点由两个域组成:
- data:存储和该顶点相关的信息
- firstedge:指示第一条依附于该顶点的边
假设示例图中所有边均为无向边,其邻接多重表如下:
这一方法可以简单获得顶点的度。
5)小结*
笔者认为,上述存储方式是围绕两个中心来建立的:
- 保证顶点和弧信息的存储,包括顶点的内容、弧的起始点和权值等。这也是一个数据结构基本的要求,上述存储结构都能够简单地达成目标,顺序存储结构相对于链式存储结构在这一目标中有一定的不同。只是保存内容的话十分简单。
- 保证顶点和弧之间关系的存储,获取顶点的度(出度和入度),方便操作弧。链式存储方式中,围绕出度和入度采用了不同的方法,宗旨都是通过指针来维系相对高效的根据顶点遍历弧的能力,或保留的指针多一些,或操作复杂一些,属于时间复杂度和空间复杂度的置换。
通过小结,我们可以认为,上述的存储结构时在普遍情况下的最优解,针对具体的问题,我们可以采用具体的存储结构,或者自定义存储结构,在保证最常用功能的效率时,适当降低不常用功能的效率。
3.1.4 图抽象数据类型的实现
此处我们采用邻接表结构,初步构建一个图抽象数据类型。采用的结构如下图所示:
python代码如下:
# 顶点
class Vertex:
# 初始化,包括id和adj
def __init__(self,key) -> None:
self.id = key
self.connectedTo = {}
# 添加弧到adj中
def addNeighbor(self,nbr,weight=0):
self.connectedTo[nbr] = weight
# 封装__str__方法控制print的内容
def __str__(self) -> str:
return str(self.id) + ' connectedTo:' + str([x.id for x in self.connectedTo])
# 获得连接的其他顶点
def getConnections(self):
return self.connectedTo.keys()
# 获得顶点id
def getId(self):
return self.id
# 获得弧的权重
def getWeight(self,nbr):
return self.connectedTo[nbr]
# 图
class Graph:
def __init__(self) -> None:
self.vertList = {} # 顶点的字典
self.numVertices = 0 # 顶点数目
# 添加新的顶点
def addVertex(self,key):
self.numVertices += 1
newVertex = Vertex(key)
self.vertList[key] = newVertex
return newVertex
# 通过key查找顶点
def getVertex(self,n):
if n in self.vertList:
return self.vertList[n]
else:
return None
# 封装in方法
def __contains__(self,n):
return n in self.vertList
# 添加边:从f到t权值为cost
def addEdge(self,f,t,cost=0):
if f not in self.vertList:
nv = self.addVertex(f)
if t not in self.vertList:
nv = self.addVertex(t)
self.vertList[f].addNeighbor(self.vertList[t],cost)
# 获得顶点数目
def getVertices(self):
return self.vertList.keys()
# 设置迭代器,迭代每个顶点
def __iter__(self):
return iter(self.vertList.values())
3.2 词梯问题与广度优先搜索
问题描述:
从一个单词演变到另一个单词,过程可以经过多个中间单词,要求每一次变化,两个单词之间的差异只能是一个字母。在这一条件下,词梯问题要寻找两个单词之间的最短变换路径。
补充:此处以四个字母的单词为例
如FOOL变为SAGE的最短路径为:FOOL->POOL->POLL->POLE->SALE->SAGE
解决思路:
- 将可能的单词之间的演变关系表达为图;
- 采用“广度优先搜索BFS”,来搜寻从开始单词到结束单词之间的所有有效路径;
- 选择其中最快到达目标单词的路径
步骤一:建立单词关系图
将单词作为顶点的标识key(id)
在差一个字母的单词之间建立边
针对此问题,采用无向图即可,边没有权重
根据我们之前的Graph类,在建立边的同时,首先会检查顶点是否建立,未建立顶点则先建立顶点后建立边。同时,在我们给出的单词中,所有的单词都在图中存在边,没有边依附的顶点对于路径寻找没有帮助。所以,我们只需要考虑如何建立边即可。此处暂时提出两个方案:
-
方案一:对每个顶点与其他的单词相比较,逐个遍历来建立边。时间复杂度为O(n2)
-
方案二:创建大量的“桶”,“桶”标记是用通配符替换一个字母的单词,所有匹配标记的单词都放在“桶”中;“桶”建立完成后,在同一个桶中建立边。时间复杂度为O(n2)
显而易见,方案一的时间复杂度为O(n2)。而方案二中,建立桶的时间复杂度为O(n),建立完成后所有的创建边的操作都是有效的而不是像方案一一样先判断是否关联。所以,虽然理论上方案二的时间复杂度依然是O(n2),但单词关系图越稀疏,方案二越高效,同时,图越稀疏,邻接矩阵的方式越低效。
此处笔者尚未想到如何在单词关系图形成之前确定其边的密度,只是从实际出发,单词之间肯定不会大规模地相像,所以选择对稀疏图友好的方案:建立图采用方案二,存储结构采用邻接表(方便使用之前建立的图和顶点类,并且也比较适用)
代码如下:
from Graph import Graph
def buildGraph(wordFile):
d = {}
g = Graph()
wfile = open(wordFile,'r')
# 建立单词桶
for line in wfile:
word = line[:-1]
# 四个字母的单词,所以一个单词有四个桶
for i in range(len(word)):
bucket = word[:i] + '_' + word[i+1:]
if bucket in d:
d[bucket].append(word)
else:
d[bucket] = [word]
# 桶中建立边
for bucket in d.keys():
for word1 in d[bucket]:
for word2 in d[bucket]:
if word1 != word2:
g.addEdge(word1,word2)
return g
对上述代码进行测试:
测试采用wordFile.txt存储一部分单词,内容如下:
foul
fool
cool
pool
poll
pole
pope
pale
sale
sage
page
pall
fall
fail
foil
测试代码:
g = buildGraph('wordFile.txt')
for item in g:
print(item)
得到结果:
foul connectedTo:['fool', 'foil']
fool connectedTo:['foul', 'foil', 'cool', 'pool']
foil connectedTo:['foul', 'fool', 'fail']
cool connectedTo:['fool', 'pool']
pool connectedTo:['fool', 'cool', 'poll']
poll connectedTo:['pool', 'pall', 'pole']
pall connectedTo:['poll', 'pale', 'fall']
pole connectedTo:['poll', 'pale', 'pope']
pale connectedTo:['pole', 'sale', 'page', 'pall']
pope connectedTo:['pole']
sale connectedTo:['pale', 'sage']
page connectedTo:['pale', 'sage']
sage connectedTo:['sale', 'page']
fall connectedTo:['pall', 'fail']
fail connectedTo:['fall', 'foil']
测试成功!
步骤二:广度优先搜索Breadth First Search
与深度优先搜索DFS对应,广度优先搜索BFS是搜索图的最简单算法之一,也是其他一些重要图算法的基础。
在本次示例中,搜索的目标是探寻在给定单词中从“fool”到”sage“的最短路径。
广度优先算法如下:
-
给定图G,以及开始搜索的起始顶点s=”fool“。
BFS搜索所有从s可到达顶点的边,而且在达到更远的距离k+1的顶点之前,BFS会找到全部距离为k的顶点;
可以想象为以s为根,构建一棵树的过程,从顶部向下逐步增加层次;
广度优先搜索能保证在增加层次之前,添加了所有兄弟节点到树中。 -
为了跟踪顶点的加入过程,并避免重复顶点,要为顶点增加3个属性;
距离distance:从起始顶点到此顶点路径长度;
前驱顶点predecessor:可反向追溯到起点;
颜色color:标识了此顶点是尚未发现(白色)、已经发现(灰色)、还是已经完成探索(黑色)。 -
需要用一个队列Queue来对已发现的顶点进行排列,决定下一个要探索的顶点(队首顶点)。
-
从起始顶点s开始,作为刚发现的顶点,标注为灰色,距离为0,前驱为None,加入队列,接下来是个循环迭代过程:
从队首取出一个顶点作为当前顶点;
遍历当前顶点的邻接顶点,如果是尚未发现的白色顶点,则将其颜色改为灰色(已发现),距离增加1,前驱顶点为当前顶点,加入到队列中;
遍历完成后,将当前顶点设置为黑色(已探索过),循环回到步骤1的队首取当前顶点。
首先针对性地改造抽象数据类型:
class Vertex:
# 初始化,包括id和adj
def __init__(self,key) -> None:
self.id = key
self.connectedTo = {}
# 添加的内容
self.distance = 0
self.predecessor = None
self.color = 'white'
# 初始方法
# 添加弧到adj中
def addNeighbor(self,nbr,weight=0):
self.connectedTo[nbr] = weight
# 封装__str__方法控制print的内容
def __str__(self) -> str:
return str(self.id) + ' connectedTo:' + str([x.id for x in self.connectedTo])
# 获得连接的其他顶点
def getConnections(self):
return self.connectedTo.keys()
# 获得顶点id
def getId(self):
return self.id
# 获得弧的权重
def getWeight(self,nbr):
return self.connectedTo[nbr]
# 新增方法
def setDistance(self,nd):
self.distance = nd
def getDistance(self):
return self.distance
def setPred(self,node):
self.predecessor = node
def getPred(self):
return self.predecessor
def setColor(self,ncolor):
self.color = ncolor
def getColor(self):
return self.color
# Graph未变
# 图
class Graph:
def __init__(self) -> None:
self.vertList = {} # 顶点的字典
self.numVertices = 0 # 顶点数目
# 添加新的顶点
def addVertex(self,key):
self.numVertices += 1
newVertex = Vertex(key)
self.vertList[key] = newVertex
return newVertex
# 通过key查找顶点
def getVertex(self,n):
if n in self.vertList:
return self.vertList[n]
else:
return None
# 封装in方法
def __contains__(self,n):
return n in self.vertList
# 添加边:从f到t权值为cost
def addEdge(self,f,t,cost=0):
if f not in self.vertList:
nv = self.addVertex(f)
if t not in self.vertList:
nv = self.addVertex(t)
self.vertList[f].addNeighbor(self.vertList[t],cost)
# 获得顶点数目
def getVertices(self):
return self.vertList.keys()
# 设置迭代器,迭代每个顶点
def __iter__(self):
return iter(self.vertList.values())
根据第四步编辑代码(队列用list来替代,不再引用Queue):
# 广度优先搜索,在每个结点内部记录前驱
def bfs(g,start):
start.setDistance(0)
start.setPred(None)
vertQueue = []
vertQueue.append(start)
while len(vertQueue) > 0:
currentVert = vertQueue.pop()
for nbr in currentVert.getConnections():
if nbr.getColor() == 'white':
nbr.setColor('gray')
nbr.setDistance(currentVert.getDistance() + 1)
nbr.setPred(currentVert)
vertQueue.insert(0,nbr)
currentVert.setColor('black')
# 回溯寻找最短路径
def traverse(y):
x = y
path = ''
while x.getPred():
path = path + x.getId() + '-'
x = x.getPred()
path += x.getId()
return path
# 结果测试
wordgraph = buildGraph('wordFile.txt')
bfs(wordgraph,wordgraph.getVertex('fool'))
# 可以获得任意单词表中单词到“fool”的路径
path = traverse(wordgraph.getVertex('sage'))
print(path)
从bfs()代码来看,其包含两重循环,外层while循环会遍历所有的顶点,时间复杂度为O(|V|),内层for循环找到每个顶点的邻边,最终遍历了所有的边两次,时间复杂度为O(2|E|),综合BFS的时间复杂度为O(|V|+2|E|)。
traverse()的时间复杂度为O(|V|),最多回溯所有的顶点。
3.3 骑士周游问题与深度优先搜索
在国际象棋中,“马(骑士)”按照规则行走,恰好走遍全部棋盘的走棋序列称为一次“周游”,骑士周游问题就是寻找“骑士”棋子走遍全部棋盘的合格”周游“序列。
采用图搜索算法,是解决骑士周游问题的常用方法,解决问题的思路如下:
- 将合法走棋次序表示为一个图。每一个格子作为顶点,”骑士“从一个顶点能够走到另一个顶点,则在两个顶点间建立边;
- 采用图搜索算法寻找长度为(行×列-1)的路径,路径上出现每个顶点各一次。
步骤一:建立棋盘格关系图
将棋盘格作为顶点;
按照“马走日”规则的走棋步骤作为连接边;
建立每一个棋盘格的所有合法走棋步骤能够到达的棋盘格关系图。
# 引用了改造的Graph类
from GraphForBFS import Graph
# 主体函数
def knightGraph(bdSize):
ktGraph = Graph()
# 遍历棋盘上每一个点
for row in range(bdSize):
for col in range(bdSize):
# 根据每一个点的坐标值和边框长度获得node的id
nodeId = posToNodeId(row,col,bdSize)
# 根据上面给的坐标,获得该点可以到达的点的坐标的列表
newPositions = getLegalMoves(row,col,bdSize)
# 为每一个可以到达的点创建边
for e in newPositions:
nid = posToNodeId(e[0],e[1],bdSize)
ktGraph.addEdge(nodeId,nid)
return ktGraph
# 根据坐标和边框获得node的id
def posToNodeId(row,col,bdSize):
# 一种编号方式
return row * bdSize + col
# 获得合法的移动位置
def getLegalMoves(x,y,bdSize):
newMoves = [] # 新位置的列表
# 移动规则
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 newX >= 0 and newX < bdSize and newY >= 0 and newY < bdSize:
newMoves.append((newX,newY))
return newMoves
# 简单预览成果图
ktGraph = knightGraph(8)
numOfEdge = 0
for item in ktGraph:
numOfEdge += len(item.getConnections())
print(item)
print('图中一共有{:.0f}条边'.format(numOfEdge/2))
输出结果如下:
0 connectedTo:[10, 17]
10 connectedTo:[0, 4, 16, 20, 25, 27]
17 connectedTo:[11, 0, 2, 27, 32, 34]
1 connectedTo:[11, 16, 18]
11 connectedTo:[1, 5, 17, 21, 26, 28]
16 connectedTo:[10, 1, 26, 33]
18 connectedTo:[8, 12, 1, 3, 24, 28, 33, 35]
2 connectedTo:[8, 12, 17, 19]
8 connectedTo:[2, 18, 25]
12 connectedTo:[2, 6, 18, 22, 27, 29]
19 connectedTo:[9, 13, 2, 4, 25, 29, 34, 36]
3 connectedTo:[9, 13, 18, 20]
9 connectedTo:[3, 19, 24, 26]
13 connectedTo:[3, 7, 19, 23, 28, 30]
20 connectedTo:[10, 14, 3, 5, 26, 30, 35, 37]
4 connectedTo:[10, 14, 19, 21]
14 connectedTo:[4, 20, 29, 31]
21 connectedTo:[11, 15, 4, 6, 27, 31, 36, 38]
5 connectedTo:[11, 15, 20, 22]
15 connectedTo:[5, 21, 30]
22 connectedTo:[12, 5, 7, 28, 37, 39]
6 connectedTo:[12, 21, 23]
23 connectedTo:[13, 6, 29, 38]
7 connectedTo:[13, 22]
25 connectedTo:[19, 8, 10, 35, 40, 42]
24 connectedTo:[18, 9, 34, 41]
26 connectedTo:[16, 20, 9, 11, 32, 36, 41, 43]
27 connectedTo:[17, 21, 10, 12, 33, 37, 42, 44]
28 connectedTo:[18, 22, 11, 13, 34, 38, 43, 45]
29 connectedTo:[19, 23, 12, 14, 35, 39, 44, 46]
30 connectedTo:[20, 13, 15, 36, 45, 47]
31 connectedTo:[21, 14, 37, 46]
33 connectedTo:[27, 16, 18, 43, 48, 50]
32 connectedTo:[26, 17, 42, 49]
34 connectedTo:[24, 28, 17, 19, 40, 44, 49, 51]
35 connectedTo:[25, 29, 18, 20, 41, 45, 50, 52]
36 connectedTo:[26, 30, 19, 21, 42, 46, 51, 53]
37 connectedTo:[27, 31, 20, 22, 43, 47, 52, 54]
38 connectedTo:[28, 21, 23, 44, 53, 55]
39 connectedTo:[29, 22, 45, 54]
41 connectedTo:[35, 24, 26, 51, 56, 58]
40 connectedTo:[34, 25, 50, 57]
42 connectedTo:[32, 36, 25, 27, 48, 52, 57, 59]
43 connectedTo:[33, 37, 26, 28, 49, 53, 58, 60]
44 connectedTo:[34, 38, 27, 29, 50, 54, 59, 61]
45 connectedTo:[35, 39, 28, 30, 51, 55, 60, 62]
46 connectedTo:[36, 29, 31, 52, 61, 63]
47 connectedTo:[37, 30, 53, 62]
49 connectedTo:[43, 32, 34, 59]
48 connectedTo:[42, 33, 58]
50 connectedTo:[40, 44, 33, 35, 56, 60]
51 connectedTo:[41, 45, 34, 36, 57, 61]
52 connectedTo:[42, 46, 35, 37, 58, 62]
53 connectedTo:[43, 47, 36, 38, 59, 63]
54 connectedTo:[44, 37, 39, 60]
55 connectedTo:[45, 38, 61]
57 connectedTo:[51, 40, 42]
56 connectedTo:[50, 41]
58 connectedTo:[48, 52, 41, 43]
59 connectedTo:[49, 53, 42, 44]
60 connectedTo:[50, 54, 43, 45]
61 connectedTo:[51, 55, 44, 46]
62 connectedTo:[52, 45, 47]
63 connectedTo:[53, 46]
图中一共有168条边
步骤二:深度优先搜索寻找路径
深度优先搜索时沿树的单支尽量深入搜索,如果未找到问题解,则回溯上一层再搜索。在哈夫曼树中输出哈夫曼编码的部分用到了类似的思想,此处同样使用深度优先搜索,每个顶点仅访问一次,具体思路如下:
- 沿单支深入搜索;
- 如果沿单支深入搜索到无法继续(所有合法移动都已经被走过了)时,路径长度还没达到预定值(8×8棋盘为0-63),则清除颜色标记,返回上一层,换一个分支继续搜索;
- 路径长度达到预定值,该路径符合要求
于此同时,需要引入栈记录路径并实施返回上一层的回溯操作,此处采用列表实现。
代码如下:
# 骑士周游路径
# n 层次; path 路径;u 当前顶点;limit 搜索总深度
def knightTour(n,path,u,limit):
# 当前顶点加入路径,设置颜色为gray
u.setColor('gray')
path.append(u)
# 当未达到结束要求时,进入此分支
if n < limit:
# 列举出所有深入的方向
nbrList = list(u.getConnections())
i = 0 # 从第0个方向开始
done = False
# 分支未循环完,且不满足结束条件
while i < len(nbrList) and not done:
# 选择未经过的顶点深入
if nbrList[i].getColor() == 'white':
# 层次n加1,递归深入
done = knightTour(n+1,path,nbrList[i],limit)
i += 1
# 深入到尽头(i=len(nbrList),即都不是white,未调用自身)
# 此时不满足条件,回溯至上一层,从上一层中下个顶点开始
if not done:
path.pop()
u.setColor('white')
# 达到结束要求,进入此分支,done=True
else:
done = True
return done
ktGraph = knightGraph(8)
path = []
u = ktGraph.getVertex(0)
limit = 63
knightTour(0,path,u,limit)
for node in path:
print(node.getId(), end=' ')
输出结果如下:
0 10 4 14 20 3 9 19 13 7 22 12 2 8 18 1 11 5 15 21 6 23 29 35 25 40 34 24 41 26 16 33 27 17 32 49 43 28 38 55 61 44 59 53 63 46 31 37 47 30 36 51 57 42 48 58 52 62 45 39 54 60 50 56
周游问题深度优先搜索的改进与启发式规则
上述算法时间复杂度为O(kn),其中n时棋盘格数目,算法的时间复杂度较高。
基于先验知识,我们在不改变算法时间复杂度的时候,只需要该边探索新路径的顺序,也可以显著提高算法效率,这一改进算法称为Warnsdorff算法。排序部分的代码如下:
# 将函数替换nbrList即可
def orderByAvvil(n):
resList = []
for v in n.getConnections():
# 如果v没有探索过
if v.getColor() == 'white':
c = 0
# 查询v的相邻未探索顶点的数量
for w in v.getConnections():
if w.getColor == 'white':
c += 1
# 加入列表
resList.append((c,v))
# 以顶点的未探索邻顶点数量为基准升序(默认)排列
resList.sort(key=lambda x:x[0])
# 返回排序后的顶点
return [y[1] for y in resList]
这一算法使顶点在向下探索时,优先选择具有最少合法移动目标的格子,基于这样的先验知识,能够在实际使用中提高骑士周游问题深度优先搜索的效率。
- 采用先验的知识来改进算法性能的做法,称作为“启发式规则heuristic”
启发式规则经常用于人工智能领域;
可以有效地减小搜索范围、更快达到目标等等;
如棋类程序算法,会预先存入棋谱、布阵口诀、高手习惯等“启发式规则”,能够在最短时间内从海量的棋局落子点搜索树中定位最佳落子。
例如:黑白棋中的“金角银边”口诀,指导程序优先占边角位置等等
通用的深度优先搜索
骑士周游问题是为了建立一个没有分支的最深的深度优先树,而一般的深度优先搜索目标是在图上进行尽量深的搜索,连接尽量多的顶点,必要时可以进行分支(创建了树)。有时候深度优先搜索会创建多棵树,称为“深度优先森林”。
笔者看来,深度优先搜索树的建立需要数据结构满足以下两点:
- 有标志来记录是否搜索到,类似骑士周游问题中的color,也可以用一个数组来记录路径,类似哈夫曼编码中的遍历(因为是二叉树,所以没有进行可行步骤遍历,而是直接给出了两种情况,如果要适应此处深度优先搜索的格式,可以进行修改)
- 每一个结点需要记录其前驱和后继,在我们一开始设置的结点中不存在前驱的意义,此处需要额外设立
满足以上两点后,只需要根据具体的算法来进行其他的修饰。如果要将骑士周游问题中的深度优先搜索改为通用的形式,则不需要在回溯的时候修改颜色,并且在每次深入之后,都在当前标记前驱。