8.图
目录
1.图Graph的概念
就像“羊”在英文中并不是一个单独的词;
中文的“图画”在英文中有很多对应的单词,其意义大不相同.
painting: 用画刷画的油画
drawing: 用硬笔画的素描/线条画
picture:真实形象所反映的画,如照片等,如take picture
image: 由印象而来的画,遥感影像做image,因是经过传感器印象而来
figure: 轮廓图的意思,某个侧面的轮廓,所以有figure out的说法
diagram:抽象的概念关系图,如电路图、海洋环流图、类层次图
chart: 由数字统计来的柱状图、饼图、折线图
map: 地图; plot:地图上的一小块
graph: 重在由一些基本元素构造而来的图,如点、线段等
图Graph是比树更为一般的结构,也是由节点和边构成
实际上树是一种具有特殊性质的图
图可以用来表示现实世界中很多事物:道路交通系统、航班线路、互联网连接、或者是大学中课程的先修次序
一旦我们对图相关问题进行了准确的描述,就可以采用处理图的标准算法来解决那些看起来很艰深的问题。对于人来说,人脑的识别模式能够轻而易举地判断地图中不同地点的相互关联;
但如果用图来表示地图,就可以解决很多地图专家才能解决的问题,甚至远远超越;
互联网是由成千上万的计算机所连接起来的复杂网络,也可以通过图算法来确定计算机之间达成通讯的最短、最快或者最有效的路径
北京公共交通
北京地铁共有18条运营线路,换乘车站则为268座,总长约527千米。
北京公交系统有1020条运营线路,公共站点近2000个
互联网
北京大学校园已经具有10+万信息点
通过层层的交换机、路由器连接在一起,路由器之间又相互连接
互联网
互联网事一张百亿个信息点的巨型网络
提供内容的web站点已突破10亿个
由超链接相互连接的网页更是不计其数,Google每天处理的数据量约10PB
在天文数字规模的网络面前,人脑已经无法处理
社交网络:六度分隔理论
世界上任何两个人之间通过最多6个人即可建立联系
互联网社交网络的兴起将每个人联系到一起
在社会中有20%擅长交往的人,建立了80%的连接
区别于随机网络,保证了六度分隔的成立引出了无尺度网络的研究
2.术语表
1.顶点Vertex(也称“节点Node”)
是图的基本组成部分,顶点具有名称标识Key,也可以携带数据项payload
2.边Edge(也称“孤Arc”)
作为2个顶点之间关系的表示,边连接两个顶点;边可以是无向或者有向的,相应的图称作“无向图”和“有向图”
3.权重Weight
为了表达从一个顶点到另一个顶点的“代价”,可以给边赋权: 例如公交网络中两个站点之间的“距离”、“通行时间”和“票价”都可以作为权重。
# 图的定义
一个图G可以定义为G=(V,E)
其中V是顶点的集合,E是边的集合,E中的每条边e=(v,w),v和w都是V中的顶点;
如果是赋权图,则可以在e中添加权重分量子图: V和E的子集
赋权图的例子:6个顶点及9条边
有向赋权图,权重为整数
V = {Vo, V1, V2, V3, V4, V5}
E={(v0,vl,5),(vl,v2,4),(v2,v3,9), (v3,v4,7),(v4,v0,1),(v0,v5,2), (v5,v4,8), (v3,v5, 3), (v5,v2,1)}
4.路径path
图中的路径,是由边依次连接起来的顶点序列;无权路径的长度为边的数量: 带权路径的长度为所有边权重的和;
如下图的一条路径(v3,v4,v0,v1)
其边为{(v3,v4,7),(v4,v0,1),(v0,v1,5)}
5.圈Cycle
圈是首尾顶点相同的路径,如下图中(v5,v2,v3,v5)是一个圈
如果有向图中不存在任何圈,则称作“有向无圈图directed acyclic graph: DAG”
后面我们可以看到如果一个问题能表示成DAG,就可以用图算法很好地解决。
3.抽象数据类型ADT Graph
抽象数据类型ADT Graph定义如下:
Graph(): 创建一个空的图;
addVertex(vert): 将顶点vert加入图中
addEdge(fromVert, tovert): 添加有向边
addEdge(fromVert, tovert,weight): 添加带权的有向边
getVertex(vKey): 查找名称为vKey的顶点
getVertices(): 返回图中所有顶点列表
in: 按照vert in graph的语句形式,返回顶点是否存在图中True/False
ADT Graph的实现方法由两种主要形式:
1.邻接矩阵adjacency matrix
2.邻接表adjacency list
两种方法各有优劣,需要在不同应用中加以选择
# 邻接矩阵adjacency matrix
矩阵的每行和每列都代表图中的顶点
如果两个顶点之间有边相连,设定行列值
无权边则将矩阵分量标注为1,或者0
带权边则将权重保存为矩阵分量值
邻接矩阵实现法的优点是简单
可以很容易得到顶点是如何相连
但如果图中的边数很少则效率低下,成为“稀疏sparse”矩阵,而大多数问题所对应的图都是稀疏的,边远远少于|v|^2这个量级
# 邻接表adjacency list
邻接列表adjacency list可以成为稀疏图的更高效实现方案.
维护一个包含所有顶点的主列表 (master list)
主列表中的每个顶点,再关联一个与自身有边连接的所有顶点的列表
邻接列表法的存储空间紧凑高效,很容易获得定点所连接的所有顶点,以及连接边的信息。
4.Graph的实现:实例
>>> g = Graph()
>>> for i in range(6):
g.addVertex(i)
0 connectedTo: []
1 connectedTo: []
2 connectedTo: []
3 connectedTo: []
4 connectedTo: []
5 connectedTo: []
>>> print (g.vertList)
{0: connectedTo: [], 1: 1 connectedTo: [], 2: 2 connectedTo: [], 3: 3 connectedTo: [], 4: 4 connectedTo: [], 5: 5 connectedTo: []}
g.addEdge(0,1,5)
g.addEdge(0,5,2)
g.addEdge(1,2,4)
g.addEdge(2,3,9)
g.addEdge(3,4,7)
g.addEdge(3,5,3)
g.addEdge(4,0,1)
g.addEdge(5,4,8)
g.addEdge(5,2,1)
for v in g:
for w in v.getConnections():
print "(%s,%s)"% (v.getId(), w.getId())
'''
(0, 5)
(0, 1)
(1, 2)
(2, 3)
(3, 4)
(3, 5)
(4, 0)
(5, 4)
(5, 2)
>>>
'''
# ADT Graph的实现:顶点Vertex类
Vertex包含了顶点信息,以及顶点连接边信息
class Vertex:
def __init__(self, key):
self.id = key
self.connectedTo = {}
def addNeighbor(self, nbr, weight=0):
self.connectedTo[nbr] = weight # nbr是顶点对象的key
def __str__(self):
return str(self.id) + 'connectedTo:' + str([x.id for x in self.connectedTo])
def getConnections(self):
return self.connectedTo.keys()
def getId(self):
return self.id
def getWeight(self, nbr):
return self.connectedTo[nbr]
# Graph 保存了包含所有顶点的主表
class Graph:
def __init__(self):
self.vertDict = {}
self.numVertices = 0
def addVertex(self, key):
self.numVertices += 1 # 新加顶点
newVertex = Vertex(key)
self.vertDict[key] = newVertex
return newVertex
def getVertex(self, n):
if n in self.vertDict: # 通过key查找顶点
return self.vertDict[n]
else:
return None
def __contains__(self, n):
return n in self.vertDict
def addEdge(self, f, t, cost=0):
if f not in self.vertDict: # 不存在的顶点先添加
nv = self.addVertex(f)
if t not in self.vertDict:
nv = self.addVertex(t)
# 调用起始顶点的方法添加邻接边
self.vertDict[f].addNeighbor(self.vertDict[t], cost)
def getVertices(self):
return self.vertDict.keys()
def __iter__(self):
return iter(self.vertDict.values())
5.词梯Word Ladder问题
由“爱丽丝漫游奇境”的作者Lewis Carroll再1878年所发明的单词游戏。
从一个单词演变到另一个单词,其中的过程可以经过多个中间单词。
要求是相邻两个单词之间差异只能是1个字母,如FOOL变SAGE:
FOOL >> POOL >> POLL >> POLE >> PALE >> SALE >> SAGE
我们的目标是找到最短的单词变换序列
采用图来解决这个问题的步骤如下 :
将可能的单词之间的演变关系表达为图;
采用“广度优先搜索 BFS”,来搜寻从开始单词到结束单词之间的所有有效路径
选择其中最快到达目标单词的路径
1.词梯问题:构建单词关系图
首先是如何将大量的单词集放到图中,将单词作为顶点的标识key,如果两个单词之间仅相差1个字母,就在他们之间设一条边。
下图是从FOOL到SAGE的词梯解,所用的图是无向图,边没有权重。FOOL到SAGE的每条路径都是一个解。
单词关系图可以通过不同的算法来构建(以4个字母的单词表为例),首先是将所有单词作为顶点加入图中,再设法建立顶点之间的边;
建立边的最直接算法,是对每个顶点(单词),与其它所有单词进行比较,如果相差仅1个字母,则建立一条边时间复杂度是0(n^2),对于所有4个字母的5110个单词,需要超过2609万次比较
改进的算法是拆功能键大量的桶,每个桶可以存放若干单词。桶标记是去掉1个字母,通配符“_”占空的单词。所有匹配标记的单词都放到这个桶里,所有单词就位后,再在同一个桶的单词之间建立边即可
# 词梯问题:采用字典建立桶
def buldGraph(self, wordFile):
d = {}
g = Graph()
wfile = open(wordFile,'r')
# create buckets of words that differ by one letter
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) # 4字母单词科薯蓣4个桶
else:
d[bucket] = [word]
# add vertices and edges for words in the same buckets
for bucket in d.keys():
for word1 in d[bucket]:
for word2 in d[bucket]: # 同一个桶单词之间建立边
if word1 != word2:
g.addEdge(word1, word2)
return g
样例数据文件包含了5110个4字母单词,可从课程网站下载。如果采用邻接矩阵表示这个单词关系图,则需要2,600万个矩阵单元(5,110*5,110= 26,112,100),而单词关系图总计有53,286条边,仅仅达到矩阵单元数量的0.2%,单词关系图是一个非常稀疏的图
6.词梯Word Ladder问题:实现广度优先搜索
在单词关系图建立完成以后,需要继续在图中寻找词梯问题的最短序列,需要用到“广度优先搜索Breadth FirstSearch”算法对单词关系图进行搜索,BFS是搜索图的最简单算法之一,也是其它一些重要的图算法的基础。
如实现广度优先搜索呢?
给定图G,以及开始搜索的起始顶点s,BFS搜索所有从s可到达顶点的边,而且在达到更远的距离k+1的顶点之前,BFS会找到全部距离为k的顶点。可以想象为以s为根,构建一棵树的过程,从顶部向下逐步增加层次广度优先搜索能保证在增加层次之前,添加了所有兄弟节点到树中。
# BFS算法过程
我们从FOOL开始搜索
为了跟踪顶点的加入过程,并避免重复顶点,要为顶点增加3个属性.
距离distance: 从起始顶点到此顶点路径长度;
前驱顶点predecessor: 可反向追溯到起点
颜色color:标识了此顶点是尚未发现 (白色)、已经发现 (灰色) 、还是已经完成探索 (黑色)
还需要用一个队列Queue来对已发现的顶点进行排列,决定下一个要探索的顶点 (队首顶点)
从起始顶点s开始,作为刚发现的顶点,标注为灰色,距离为0,前驱为None,加入队列,接下来是个循环迭代过程:从队首取出一个顶点作为当前顶点;
遍历当前顶点的邻接顶点,如果是尚未发现的白色顶点,则将其颜色改为灰色 (已发现) ,距离增加1,前驱顶点为当前顶点,加入到队列末尾,把所有直接连接的顶点遍历完成后,将当前顶点设置为黑色 (已探索过) ,循环回到步骤1的队首取当前顶点。
# BFS算法代码
def bfs(g, start):
start.setDistance(0)
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):
x = y
while (x.getPred()):
print(x.getId())
x = x.getPred()
print(x.getId())
wordgrah = buildGraph('fourlerrerwords.txt')
bfs(wordgrah, wordgrah.getVertex('FOOL'))
traverse(wordgrah.getVertex('SAGE'))
# 广度优先搜索算法分析
BFS算法主体是两个循环的嵌套,while循环对每个顶点访问一次,所以是0(|v|)而嵌套在while中的for,由于每条边只有在其起始顶点u出队的时候才会被检查一次,而每个顶点最多出队1次,所以边最多被检查1次,一共是O(|E|)综合起来BFS的时间复杂度为0(|V|+|E|)
# 词梯问题还包括两个部分算法
建立BFS树之后,回溯顶点到起始顶点的过程,最多为O(|V|)。创建单词关系图也需要时间,最多为O(|V|^2)
7.图的应用:骑士周游问题
在一个国际象棋棋盘上,一个棋子“马”(骑士),按照“马走日”的规则,从一个格子出发,要走遍所有棋盘格恰好一次。把一个这样的走棋序列称为一次“周游”
在8x8的国际象棋棋盘上,合格的“周游数量有10^35x10^35这么多,走棋过程中失败的周游就更多了.
采用图搜索算法,是解决骑士周游问题最容易理解和编程的方案之一
解决方案还是分为两步:
首先将合法走棋次序表示为一个图采用图搜索算法搜寻一个长度为 (行x列-1) 的路径,路径上包含每个顶点恰一次
# 构建骑士周游图
将棋盘和走棋步骤构建为图的思路:
将棋盘格作为顶点
按照“马走日”规则的走棋步骤作为连接边,建立每一个棋盘格的所有合法走棋步骤能够到达的棋盘格关系图
# 合法走棋位置函数
def genLegalMoves(x, y, bdSize):
newMoves = []
# 马走日8个格子
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 knighGraph(bdSize):
ktGraph = Graph()
# 遍历每个格子 bdSize表示棋盘的大小,这里bdSize=8x8,8行8列;
# 遍历每行每列,得到一个(row,col)值,表示那个"骑士"当前所在位置
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, col, bdSize):
return row*bdSize + col
# 骑士周游图:8x8期盼生成的图
具有336条边,相比起全连接的4096条边,仅8.2%,还是稀疏图
8.骑士周游算法实现
用于解决骑士周游问题的图搜索算法是深度优先搜索( Depth First Search ),相比前述的广度优先搜索,其逐层建立搜索树的特点。深度优先搜索是沿着树的单支尽量深入向下搜索,如果到无法继续的程度还未找到问题解就回溯上一层再搜索下一支。
# 后面会介绍DFS的两个实现算法
一个DFS算法用于解决骑士周游问题,其特点是每个顶点仅访问一次
另一个DFS算法更为通用,允许顶点被重复访问,可作为其它图算法的基础。
深度优先搜索解决骑士周游的关键思路:
如果沿着单支深入搜索到无法继续(所有合法移动都已经被走过了)时,路径长度还没有达到预定值(8X8棋盘为63),那么就清除颜色标记,返回到上一层换一个分支继续深入搜索。引入一个栈来记录路径并实施返回上一层的回溯操作
def knightTour(n, path, u, limit):
"""
n:层次
path:路径
u:当前顶点
limit:搜索总深度
"""
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 += 1
if not done: # prepare to backtrack
# 都无法完成总深度,回溯,试本层下一个顶点
path.pop()
u.setColor('white')
else:
done = True
return done
代码可参照下图深度搜索走过的步骤。。。。
骑士周游问题:一个解:起始点在最下面一行的中间一列
9.骑士周游算法分析
上述算法的性能高度依赖于棋盘大小:
就5X5棋盘,约1.5秒可以得到一个周游路径;
但8x8棋盘,则要半个小时以上才能得到一个解
目前实现的算法,其复杂度为O(k”),其中n是棋盘格数目。这是一个指数时间复杂度的算法!其搜索过程表现为一个层次为n的树
# 骑士周游算法改进
幸运的是,即便是指数时间复杂度算法也可以在实际性能上加以大幅度改进。对nbrList的灵巧构造,以特定方式排列顶点访问次序,可以使得8X8棋盘的周游路径搜索时间降低到秒级!
这个改进算法被特别以发明者名字命名: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 += 1
resList.append((c, v))
resList.sort(key=lambda x: x[0])
return [y[1] for y in resList]
采用先验的知识来改进算法性能的做法称作为“启发式规则heuristic”。
启发式规则经常用于人工智能领域:
可以有效地减小搜索范围、更快达到目标等等:如棋类程序算法,会预先存入棋谱、布阵口诀、高手习惯等“启发式规则”,能够在最短时间内从海量的棋局落子点搜索树中定位最佳落子。例如:黑白棋中的“金角银边”口诀,指导程序优先占边角位置等等
10.通用的深度优先搜索
骑士周游问题是一种特殊的对图进行深度优先搜索。其目的是建立一个没有分支的最深的深度优先树表现为一条线性的包含所欲节点的退化树。
一般的深度优先搜索目标是在图上进行尽量深的搜索,连接尽量多的顶点,必要时可以进行分支(创建了树),有时候深度优先搜索会创建多棵树,称为“深度优先森林”
深度优先搜索同样要用到顶点的“前驱“(前驱指的是在深度优先树当中的父节点)属性,来构建树或森林。另外要设置“发现时间”和“结束时间”属性:
前者是在第几步访问到这个顶点 (设置灰色)
后者是在第几步完成了此顶点探索 (设置黑色)
这两个新属性对后面的图算法很重要
带有DFS算法的图实现为Graph的子类:
顶点Vertex增加了成员Discovery及Finish
图Graph增加了成员time用于记录算法执行的步骤数目
from pythonds.graphs import Graph
# BFS 采用队列存储待访问顶点,DFS则是通过递归调用,隐式使用了栈。
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():
self.dfsvisit(aVertex)
# 创建单棵的深度优先树
def dfsvisit(self, startVertex):
startVertex.setColor('gray')
# 算法的步骤
self.time += 1
startVertex.setDiscovery(self.time)
for nextVertex in startVertex:
if nextVertex.getColor():
nextVertex.setPred()
#深度优先递归访问
self.dfsvisit((nextVertex))
startVertex.setColor('black')
self.time += 1
startVertex.setFinish(self.time)
# 通用的深度优先搜索算法:分析
DFS构建的树,其顶点的“发现时间”和“结束时间”属性,具有类似括号的性质。即一个顶点的“发现时间”总小于所有子顶点的“发现时间,而“结束时间”则大于所有子顶点“结束时间。比子顶点更早被发现,更晚被结束探索
DFS运行时间同样也包括了两方面:dfs函数中有两个循环,每个都是|v|次,所以是O(|V|),而dfsvisit函数中的循环则是对当前顶点所连接的顶点进行,而且仅有在顶点为白色的情况下才进行递归调用,所以对每条边来说只会运行一步,所以是O(|E|)。加起来就是和BFS一样的0|(V|+|E|)
11.图的应用:拓扑排序Topological Sort
很多问题都可转化未图,利用图算法解决。
例如早餐吃薄煎饼的过程。以动作为顶点,以先后次序为有向边。
问题是对整个过程而言:
如果一个人独自做,所有动作的先后次序?
从加里奥开始?还是从加热烤盘开始?
从工作流程图得到工作次序排列的算法,称为“拓扑排序”
拓扑排序处理一个DAG,输出顶点的线性序列,使得两个顶点v,w,如果G中有(v,w)边,在线性序列中v就出现在w之前。
拓扑排序广泛应用在依赖事件的排期上,还可以用在项目管理、数据库查询优化和矩阵乘法的次序优化上。
拓扑排序可以采用DFS很好地实现 :
将工作流程建立为图,工作项是节点,依赖关系是有向边;
工作流程图一定是个DAG图,否则有循环依赖对DAG图调用DFS算法,以得到每个顶点的“结束时间”;
按照每个顶点的“结束时间”从大到小排序输出这个次序下的顶点列表
下图倒序18、17、16、15、14……3、2、1排列,与上图都满足拓扑排序的定义
12.图的应用:强连通分支
我们关注一下互联网相关的非常巨大图:由主机通过网线 (或无线) 连接而形成的图:
以及由网页通过超链接连接而形成的图。
先看网页形成的图:
以网页(URI作为id)为顶点,网页内包含的超链接作为边,可以转换为一个有向图。
我们可以猜想,Web的底层结构可能存在某些同类网站的聚集
在上图中发现高度聚集节点群的算法,即寻找“强连通分支Strongly ConnectedComponents”算法。
强连通分支,定义为图G的一个子集:
CC中的任意两个顶点v,w之间都有路径来回,即(v,w)(w,v)都是C的路径,而且C是具有这样性质的最大子集。它会扩张到不能再扩张为止,在图当中有不属于这个子集的节点,再添加进去他就会破坏强连通特性。
强连通分支例子:
下图是具有3个强连通分支的9顶点有向图。一旦找到强连通分支,可以据此对图的顶点进行分类,并对图进行化简。
强连通分支算法:转置概念
在用深度优先搜索来发现强连通分支之前先熟悉一个概念:Transposition转置
一个有向图G的转置GT,定义为将图G的所有边的顶点交换次序,如将(v,w)转换为(w,v)。可以观察到图和转置图在强连通分支的数量和划分上,是相同的。
1.强连通分支算法:Kosaraju算法思路
首先,对图G调用DFS算法,为每个顶点计算“结束时间”
然后,将图G进行转置,得到GT
再对GT调用DFS算法,但在dfs函数中对每个顶点的搜索循环里,要以顶点的“结束时间”倒序的顺序来搜索
最后,深度优先森林中的每一棵树就是一个强连通分支
Kosaraju算法实例:第一堂DFS:
Kosaraju算法实例:转置后第二趟DFS:
Kosaraju算法实例:结果:
另外的常用强连通分支算法:
Tarjan算法
Gabow算法,对Tarjan的改进
参考阅读:
https://baike.baidu.com/item/tarjan算法
13.最短路径问题
1.介绍
当我们通过网络浏览网页、发送电子邮件、QQ消息传输的时候,数据会在联网设备之间流动。
计算机网络专业领域会详尽地研究网络各层面上的技术细节。我们对Internet工作方式感兴趣的主要是其中包含的图算法
如图,当PC上的浏览器向服务器请求一个网页时,请求信息需要:
先通过本地局域网,由路由器A发送到Internet请求信息沿着Internet中的众多路由器传播最后到达服务器本地局域网所属的路由器B从而传给服务器
标注“Internet”的云状结构,实际上是一个由路器连接成的网络,这些路由器各自独立而又协同工作,负责将信息从Internet的一端传送到另一端。我们可以通过“traceroute”命今来跟踪信息传送的路径:traceroute www.lib.pku.edu.cn
我们来看看从本机到北大图书馆服务器之间的一条路由器路径,包含了4个路由器。
由于网络流量的状况会影响路径选择算法,在不同的时间,路径可能不同。
所以我们可以将互联网路由器体系表示为一个带权边的图,路由器作为顶点,路由器之间网络连接作为边,权重可以包括网络连接的速度、网络负载程度、分时段优先级等影响因素作为一个抽象,我们把所有影响因素合成为单一的权重。
解决信息在路由器网络中选择传播速度最快路径的问题,就转变为在带权图上最短路径的问题。这个问题与广度优先搜索BFS算法解决的词梯问题相似,只是在边上增加了权重。如果所有权重相等,还是还原到词梯问题。
2.最短路径问题:Dijkstra算法
解决带权最短路径问题的经典算法是以发明者命名的“Dijkstra算法”'/deikstra/'
这是一个迭代算法,得出从一个顶点到其余所有顶点的最短路径,很接近于广度优先搜索算法BFS的结果。具体实现上,在顶点Vertex类中的成员dist用于记录从开始顶点到本顶点的最短带权路径长度(权重之和),算法对图中的每个顶点迭代一次。
顶点的访问次序由一个优先队列来控制,队列中作为优先级的是顶点的dist属性。最初,只有开始顶点dist设为0,而其他所有顶点dist设为sys.maxsize(最大整数),全部加入优先队列。随着队列中每个最低dist顶点率先出队,并计算它与邻接顶点的权重,会引起其它顶点dist的减小和修改,引起堆重排,并据更新后的dist优先级再依次出队。
3.最短路径问题:Dijkstra算法实例:
上图有6个顶点和10条边,每条边的权重都好了,我们从u开始,找到其余节点的最短路径。
u单独出队,它的距离值(权重)是0;出队以后,和它相连的有v、x、w。相应的更新这时就从0加上边的边的权重值。v就等于u(d=0)+2 = v(d=2) x就等于u(d=0)+1= x(d=1) w就等于u(d=0)+5= w(d=5)。这时v,x,w这个队列排列之后x当然是排在前面,之后是v,最后是w。然后x出队,和x直接相连的是v,w,y(u在之前按步骤已经出队了),再次对v,w,y进行更新。这时,v就等于x(d=1)+2=3>v(d=2),所以不更新,仍然v(d=2);w就等于x(d=1)+3=4<w(d=5),所以更新w(d=4);y就等于x(d=1)+1= y(d=2);
这时,v,w,y三者中v(d=2)/w(d=4)/y(d=2),v最小,所以v出队(如上图)。
v(d=2) +3 =5>w(d=4),所以w不更新。
这样队列中就剩下y,w;y、w中y排在前面,y先出队(如上图),更新w,z;y(d=2) +1=3>w(d=4),更新w(d=3);同样更新z(d=3);这是w、z相等,z后加入队列,排在队列的后面。
接下来w出队(如上图)。w更新z,w(d=3)+5=8>z(d=3),所以z(d=3)不更新。最后队列只剩下z(d=3),也出队(如下图)。
# 最短路径问题:Dijkstra算法代码
from pythonds.graphs import PriorityQueue,Vertex
def dijkstra(aGraph, start):
pq = PriorityQueue()
start.setDistance(0)
# 对所有顶点建堆,形成优先队列
pq.buildHeap([(v.getDistance(), v) for v in aGraph])
while not pq.isEmpty():
currentVert = pq.delMin() # 优先出队列
for nextVert in currentVert.getConnections():
newDist = currentVert.getDistance() \
+ currentVert.getWeight(nextVert)
if newDist < nextVert.getDistance():
# 修改出队顶点所邻接顶点的dist,并逐个重排队列。
nextVert.setDistance(newDist)
nextVert.setPred(currentVert)
pq.decreaseKey(nextVert,newDist)
# 最短路径问题:Dijkstra算法
需要注意的是,Dijkstra算法只能处理大于0的权重,如果图中出现负数权重,则算法会陷入无限循环。虽然Dijkstra算法完美解决了带权图的最短路径问题,但实际上Internet的路由器中采用的是其它算法。
其中最重要的原因是,Dijkstra算法需要具备整个图的数据,但对于Internet的路由器来说,显然无法将整个Internet所有路由器及其连接信息保存在本地这不仅是数据量的问题,Internet动态变化的特性也使得保存全图缺乏现实性。
路由器的选径算法(或“路由算法”)对于互联网极其重要,有兴趣可以进一步参考“距离向量路由算法“https://baike.baidu.com/item/距离向量路由算法
# 最短路径问题:Dijkstra算法分析
首先,将所有顶点加入优先队列并建堆时间复杂度为O(V)
其次,每个顶点仅出队1次,每次delMin花费O(log|V|),一共就是O(|V|log|V|)另外,每个边关联到的顶点会做一次decreaseKey操作( O(log V)),一共是O(|E|log |V|))
上面三个加在一起,数量级就是O((|V|+|E|)log |V|))
# 最小路径算法如果出现负权重的情况,为什么不一定会得到正确结果吗?
Dijkstra算法是一个贪心算法,优先队列会按照距离值,每次移走一个距离值最小的节点,而且不再更新距离加回队列
默认A-B如果是A连接所有节点的最小权重的话,那么B就会以这个权重作为最短路径,出队如果权重全都非负,这个贪心就成立,否则就不成立。
14.最小生成树
本算法涉及到在互联网中网游设计者和网络收音机所面临的问题:信息广播问题。
网游需要让所有玩家获知其他玩家所在的位置;
收音机则需要让所有听众获取直播的音频数据。
# 信息广播问题:单播解法
信息广播问题最简单的解法是由广播源维护一个收听者的列表,将每条消息向每个收听者发送一次。如图,每条消息会被发送4次,每个消息都采用最短路径算法到达收听者。
路由器A会处理4次相同消息,C仅会处理1次;而B/D位于其它3个收听者的最短路径上,则各会处理转发3次相同消息,会产生许多额外流量。
# 信息广播问题:洪水解法
信息广播问题的暴力解法,是将每条消息在路由器间散布出去。所有的路由器都将收到的消息转发到自己相邻的路由器和收听者。显然,如果没有任何限制,这个方法将造成网络洪水灾难。很多路由器和收听者会不断重复收到相同的消息,永不停止!
所以,洪水解法一般会给每条消息附加一个生命值( TTL:Time To Live),初始设置为从消息源到最远的收听者的距离;
每个路由器收到一条消息,如果其TTL值大于0,则将TTL减少1,再转发出去。如果TTL等于0了,则就直接抛弃这个消息。TTL的设置防止了灾难发生,但这种洪水解法显然比前述的单播方法所产生的流量还要大。
# 信息广播问题:最小生成树
信息广播问题的最优解法,依赖于路由器关系图上选取具有最小权重的生成树(minimum weight spanning tree)生成树: 拥有图中所有的顶点和最少数量的边以保持连通的子图。
图G(V,E)的最小生成树T,定义为包含所有顶点V,以及E的无圈子集,并且边权重之和最小。
下图为一个最小生成树:
这样信息广播就只需要从A开始,沿着树的路径层次向下传播。就可以达到每个路由器只需要处理1次消息,同时总费用最小。
# 最小生成树:Prim算法
解决最小生成树问题的Prim算法,属于贪心算法,即每步都沿着最小权重的边向前搜索。构造最小生成树的思路很简单,如果T还不是生成树,则反复做:找到一条最小权重的可以安全添加的边,将边添加到树T。“可以安全添加”的边,定义为一端顶点在树中,另一端不在树中的边,以便保持树的无圈特性
最小生成树:Prim算法示例
# prim算法:最小生成树
from pythonds.graphs import PriorityQueue, Graph, Vertex
import sys
def prim(G, start):
pq = PriorityQueue()
for v in G:
v.setDistance(sys.maxsize)
v.setPred(None)
start.setDistance(0)
pq.buildHeap([(v.getDistance(),v) for v in G])
while not pq.isEmpty():
currentVert = pq.delMin()
for nextVert in currentVert.getConnections():
newCost = currentVert.getWeight(nextVert)
if nextVert in pq and newCost < nextVert.getDistance():
nextVert.setPred(currentVert)
nextVert.setDistance(newCost)
pq.decreaseKey(nextVert, newCost)
15决策树
# minimax决策树,包括max节点、min节点和终止节点
对于终止节点,minimax值等于直接对局面的估值函数估值;
对于max节点,选择minimax值最大的子节点的值作为MAX节点的值;
对于MIN节点,选择minimax值最小的子节点的值作为MIN节点的值;
#算法过程
1.构建决策树
2.将评估函数应用于终局的叶子结点
3.自底向上计算每个结点的minimax值
Max节点更新α值(下限)
Min节点更新β值(上限)
4.从根结点选择minimax值最大的分支,作为行动策略
算法过程演示1:
算法过程演示2:
# Minimax的优化:剪枝
Minimax需要展开整个决策树
对于局面复杂的问题,需要很大的空间
有部分节点跟最后的结果无关,无需展开局面和计算估值
不计算这些节点可节省算法搜索的时间
去掉这些节点的过程叫做“剪枝”
# Alpha-Beta剪枝
加速minimax搜索过程
每个节点存储局面估值之外,还存储可能取值的上下界
估值:minimax值
下界:(最小可能值):Alpha值
上界:(最大可能值):Beta值
Alpha剪枝:搜索MAX节点的子节点时:
Beta剪枝:搜索Min节点的子节点时:
有剪枝:
算法过程:Beta剪枝
算法过程:Alpha剪枝
# Alpha-Beta剪枝的局限性
受到搜索次序的影响很大
并不都能实现有效剪枝
# 启发式剪枝
在搜索过程中,启发式算法被定义成一系列额外的规则来辅助剪枝
经验法则,利用一些特定的知识(“高手怎么下,我也怎么下”)
它常能发现很不错的解,但也没办法证明它不会得到较坏的解;
它通常可在合理时间解出答案,但也没办法知道它是否每次都可以这样的速度求解
# 蒙特卡洛方法
通过随机采样计算得到近似结果
一种通用的计算方法
# 蒙特卡洛树搜索(MCTS)
一种通过在决策空间中随机采样并根据结果构建决策的方法
决策树的构建:选择、扩张、模拟、反馈
# MCTS例子
可以用来优化估值函数,以及得到更好的选择策略
# 小结
1.棋局的估值函数
2.决策树:最大最小法
3.Alpha-Beta剪枝
4.启发式规则
5.蒙特卡洛树搜索
# python中最大正整数
import sys
print(sys.maxsize)