gitbook/业务开发算法50讲/docs/474363.md
2022-09-03 22:05:03 +08:00

192 lines
17 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 10搜索算法 一起来写一个简单的爬虫?
你好,我是微扰君。
你玩过井字棋的游戏吗在一个九宫格中双方轮流用X和O占领一个格子某一方的O或者X三个连成一线时即可获胜。
这样一个简单井字棋的游戏如果要让你自己写代码实现一个AI你会怎么做呢怎么把博弈过程清晰地表示出来呢
实际上许多博弈类游戏的过程我们都可以用树来表示。根节点就是棋盘为空的状态终点就是各个棋下完的状态这样的树也被称为博弈树。下图是井字棋某个局面3步内的树状展示
![](https://static001.geekbang.org/resource/image/af/35/afa35cb31b7e23b452e49743d53f4235.jpg?wh=2127x1529)
一般来说,对弈双方在做的事情,其实就是找到这棵树上对于自己最优的一种落子方式,使得之后的每条路径,自己都有必胜或者必不败的策略。**如果你想要找出一个AI策略最暴力的方式就是直接遍历每一种情况找到最优的下法这就是一个典型的搜索问题了**。
事实上这类博弈的游戏要么是先手必不败要么是后手必不败所以对全空间的搜索一定是可以写出一个无敌AI的对证明感兴趣的同学可以去搜索“策梅洛定理”了解。
如果暴力遍历有多少种情况呢相信你也发现了就是这么一个简单的井字棋小游戏终局的数量非常多达到了255168种。我们可以这样来简单地估计它第一步有9种下法第二步有8种下法显然通过排列组合的知识占满棋盘一共有9=362880种下法当然还需要去掉一些中间获胜不应该继续进行对弈的情况。
这本身是一道挺有意思的数学问题也可以通过写代码更快地计算出来留给你作为课后作业。当然这个数字还不算天文数字尚且在计算机的处理范围之内如果我们稍微把游戏的复杂度提升一下比如围棋还能通过暴力搜索的方式得到一个优秀的AI吗
我们知道围棋盘有19\*19个落子点所以刚开始的每一步可能都有接近361个选择那整体的情况可能接近361种。这是一个天文数字在现在的计算机架构下直接计算和存储这样的问题是不可能的。所以我们**想要写出一个靠谱的围棋AI就需要采取一些新的策略只选择部分分支进行遍历从中找出一个比较好的方案**。
对于人类而言这个过程就是依靠经验对于AI来说就是依托于数据你从AlphaGO核心算法的名字“蒙特卡洛搜索树”中就可以看出来这本质上还是一个搜索的问题只不过人类棋手和AI都采用了比较高明的搜索策略。
我们今天就不讲那么进阶的内容了就讲一讲平时常用的广度优先搜索算法BFS和深度优先搜索算法DFS。
它们是两种最常见的暴力搜索算法在面试中也相当常见前者的实现需要用到我们之前讲解的队列这一数据结构后者则是递归思想最常用的场景之一。在工程中它们也发挥着巨大的作用。比如DFS在前端开发中DOM树相关的操作里就非常常见我们可以用它来实现对DOM树的遍历从而对比两颗DOM树的差异这就是React中虚拟DOM树算法的关键点之一。
## BFS和DFS
BFS和DFS作为两种最暴力、也相当常用的搜索策略**最大的特点就是无差别地去遍历搜索空间的每一种情况因此但凡是可以抽象成图上的问题基本上都可以考虑用BFS、DFS去做**。只不过效率可能不是最优的所以我们也常常称之为暴力搜索算法在各大刷题网站题解区中你应该常常能见到“暴搜”这样的关键词说的一般就是DFS和BFS这两种算法。
所以在爬虫这样本来就需要无差别遍历全部空间的场景下可以说是非常合适的了。至于DFS和BFS具体选择哪一种我们可以结合一个具体的爬虫场景来分析。
如果让你手写一个爬虫,从豆瓣上爬取一个用户关注的所有用户,是不是很简单?只要直接遍历某个用户的关注者列表就可以了,除了需要处理一些鉴权和页面解析的问题,没有什么复杂的地方。
那我们升级一下挑战,爬取这个用户关注的人的所有关注的人,也就是和这个用户有二度关系的所有用户,你要怎么实现呢?如果不是二度,而是让你查找三度关系,也就是找出需要三跳的所有用户,你的代码能否很简单地通过配置就完成这件事呢?
这其实就是一个非常适合用DFS和BFS解决的问题因为它天然就是一个需要无差别遍历所有图上节点的问题。
![](https://static001.geekbang.org/resource/image/2f/4b/2f60f4ec427e8acb573216626f882c4b.jpg?wh=2312x1379)
你可以把豆瓣用户看成节点用户之间的关注关系就是边它们一起构成了一个复杂的社交网络。相信你也听过社交网络中的“六度分割理论”说的就是世界上任何一个人和你之间的距离不会超过6度描述了社交网络的小世界特性。这种网络关系也是许多人在研究的。
## BFS实现思路
好我们先来使用BFS解决这一问题。我们优先去遍历所有到源用户距离为一度的用户然后再遍历这些用户的邻居用层层深入的方式进行搜索。广度优先搜索其实是一个很直观的定义我们把对应到图上的搜索顺序画出来就很清晰了。
看简化的情况假设我们搜索的源用户为图的0节点一度关系包含3个节点二度关系包含了6个节点每条连边都是一个关注关系。那我们基于广度优先搜索策略遍历时就会按照标号顺序进行遍历。
为了在代码中实现这样的遍历效果求出所有和0节点构成两度以内关系的用户我们就需要借助之前学习的“队列”了。
因为广度优先搜索是由源点向外逐层推进的,每遍历下一层的时候,我们都需要用到上一层的节点,所以我们需要一个容器记录每一层的元素,并依次遍历,先进先出的队列就可以帮助我们很好地解决这个问题。
![](https://static001.geekbang.org/resource/image/b6/cd/b6a00c0c8f97bbcf4849939c469d07cd.jpg?wh=2312x1379)
我们首先把遍历的初始节点加入queue中然后循环读取queue中的元素每次读出一个元素就把它的所有相邻节点都放入新队列中直到目前队列为空就代表我们遍历完了所有的元素。队列FIFO的特性保证了下一层的元素一定会比上层的元素更晚出现。
当然我们经常需要设置自己的搜索退出条件,比如在最短路径问题中,我们并不需要遍历所有的路径,当搜索到终点的时候其实就可以退出了;在我们的例子中,我们的退出条件也不是遍历完整个社交网络,遍历到第二度关系就可以结束了,因此我们还需要在代码中记录当前遍历的层数。
另外,有时候需要对插入队列中的元素做一些判重,防止重复的搜索。
在搜索最短路径或者求几度关系所有用户的情况下就很有用,因为重复的节点已经没有必要再搜索了;如果不这样做,甚至可能导致你的搜索永远无法结束,比如在图有环的情况下。
### 实现
下面我们试着写一下就用爬虫常用的脚本语言Python来实现这次的代码。
解释一下几个关键的变量。
degree就是广度优先搜索中我们用于记录搜索层数的变量。为了每一次出队的时候把一层的元素全部出队在遍历中我们采用了两层while循环这样内层循环结束后我们就可以保证当前层的元素已经全部被访问也可以将degree进行自增操作。
之所以开了一个新的变量next\_degree\_urls也是同样的道理当前层的邻居不能干扰到这一层的出队操作所以我们将邻居们放到一个新的队列中内层循环结束后再将已经为空的队列更新为next\_degree\_urls。
而类型为set的变量res除了记录所有的用户主页也起到了判重的作用如果已经出现在res集中说明我们已经遍历过这个用户主页了不需要再遍历一次了所以在循环中直接通过`continue`跳过。
```python
# 这里我们需要一个合适的 douban html parser
    def crawl(self, startUrl: str) -> List[str]:        
        urls = deque()
        urls.append(startUrl)
        res = set()
        
degree = 0
N = 2
        while urls:
# 遍历层数超过N层停止遍历
if degree > N: break
# 用于记录下一层的节点
next_degree_urls = deque()
# 遍历当前层
while urls:
            u = urls.popleft()
if u in set: continue
            for url in doubanHtmlParser.getFollowings(u):
                next_degree_urls.append(url)
            res.add(u)
urls = next_degree_urls
# 当前层元素全部出队进入下一层遍历记录遍历层数的变量加1
degree = degree + 1
```
当然既然是爬虫我们肯定是需要对网页进行解析的就用一个getFollowing函数表示这个过程做的事情就是输入一个用户主页的URL返回该用户关注的其他好友的个人主页。
大致实现思路就是通过网络请求库获取指定URL的HTML文本信息从中解析出表示用户关注好友列表的部分一般列表中的每个元素都会指向该好友的个人主页我们把相关的href标签里的URL解析出来即可。
好了这样我们就用基于队列的广度优先搜索策略完成了一个简易的爬虫感兴趣你可以自己补全HTML解析器的代码完整实现一下。
## DFS实现思路
再来用DFS解决这个问题。深度优先搜索就不会再像广度优先搜索那样严格由内而外逐层推进了它和棋手下棋的思路其实会更像一点我们就用下棋举个例子。
假设下棋的时候,当前局面可以有若干个落子点,棋手一般会先顺着其中一个落子点在脑海中模拟若干步,发现某一步不行,我们回溯到分叉点,再看一下其他选择;最终遍历完当前选择的落子点的各种局面之后,再依次进行其他落子点的判断,直到选出一种比较优的策略。
画成图对比一下,你会更直观地感受到两者的区别,同样用刚刚假想的豆瓣用户关注关系图来举例:
![](https://static001.geekbang.org/resource/image/99/7d/99dc4e012057249a93141e120960e07d.jpg?wh=2312x1379)
上图的数字表示深度优先搜索在同样的关系图中的遍历顺序可以看到相比于BFS的逐层推进在DFS中是一条条分支顺次遍历到终点再进行下一种尝试的这也是深度优先搜索命名的由来。
### 实现
这种遍历方式也天然符合回溯法的适用场景所以常规做法就是通过递归来实现。写成代码如下关键就是11-13行的for循环递归地对当前节点的每个子节点进行同样的DFS过程细节你可以参考代码中的注释来理解网页解析的逻辑和前面所说的是一样的
```python
# 结果集 用于存放所有N度关系以内的用户
res = set()
N = 2 # 记录找N度关系以内的所有用户N=2即找2度关系以内的用户
def crawl(startUrl, degree):
# 如果已经超过N度关系我们不用继续遍历
if degree > N : return
# 如果已经搜索过,我们也不用继续搜索
if startUrl in res : return
# 将当前搜索的用户主页加入结果集
res.add(startUrl)
for url in doubanHtmlParser.getFollowings(startUrl):
# 遍历关注的所有用户注意需要将度数增加1
crawl(url, degree+1)
```
DFS的代码看起来明显要简短很多这就是递归的威力。通过对自身的调用很多时候我们可以让代码变得非常简单。
## 时空复杂度
两种方法的时空复杂度都非常容易分析。
先说时间复杂度。我们做的事情就是遍历一次网络或者图中的每个节点因为借助结果集判重后即使在图中多次出现的节点也只会入队一次或者被递归一次。那么假设图中有V个顶点E条边。
在BFS中图中所有顶点入队一次、出队一次每条边都会在边起点出队的时候被遍历一次所以整体的复杂度为O(V+E)。而在实际的复杂网络中E一般是远大于V的所以可以近似认为复杂度为O(E)。
在DFS中是从起点出发递归遍历图通过结果集判重保证重复的节点不会被递归两次从而每条边只会被遍历一次整体复杂度为O(E)。
所以时间复杂度上两者没有太大的差别。但是在一些求最短路径的场景下比如求从当前位置走出迷宫的最短路径就会有一定差异。这是因为BFS是从内到外逐层搜索所以最早搜索到终点的时候就对应了最短路径因此找到终点我们就可以提前结束搜索过程。
![](https://static001.geekbang.org/resource/image/d4/14/d4553905d9da922e22b6192cb316ec14.jpg?wh=2312x1379)
而在DFS中由于我们优先遍历每条路径的最大深度即使找到了终点也只能说明找到了一条路径这并不能保证这条路径是最短的所以哪怕找到终点也不能结束搜索过程需要遍历完整个搜索空间找到所有可能的路径之后再从中选择一条最短的。
在搜索空间很大但已知搜索路径不会特别长的情况下DFS可能会比BFS要慢很多所以你要根据实际情况选择一种合适的算法。
当然在今天豆瓣爬虫的场景下我们需要的就是遍历整个空间找到所有构成N度关系的用户所以两种算法的时间复杂度其实没什么区别。
### 空间复杂度
再来看空间复杂度。
在BFS中主要的空间复杂度就是queue和res所占用的大小。那这里的res本身并不是所有的BFS场景下都会需要的因为我们并不一定需要返回所有遍历过的节点可能只需要记录一个距离之类的值。
但是在大部分的BFS下我们是不希望重复遍历节点的所以仍然需要一个类似于res的集合去标记所有经过的点。它所需要的最大空间和图中总结点数量V一致。而queue存储的就是图上的节点其所占用的空间最大也不会超过V。所以BFS的空间复杂度是O(V)。
在DFS中所消耗的内存同样主要与res相关。因为虽然相比于BFS我们少了queue的内存消耗但是也多了隐含递归中调用栈所消耗的空间。由于调用栈最多就是描述一条经过了所有节点的路径其最大空间大小也不会超过顶点数量V。因而DFS的空间复杂度同样是O(V)。
## 总结
作为两个相当常用的暴力搜索算法BFS和DFS比较适合用来解决图规模不大或者本身就需要无差别遍历搜索空间的每一种情况的问题这两者的时间空间复杂度是相当的。
而至于DFS和BFS具体选择哪一种我也总结出一些自己的经验供你参考。
BFS因为是由内向外地毯式地搜索所以首次搜索到目标位置的时候一定是源点到目标位置的最短路径所以求最短路径类的问题往往可以用BFS解决。当然这里的“最短路径”是有条件的只有在图中所有边权重相等时首次搜索到的才是最短路径另一类边权不等的图上的最短路径求解问题我们之后会单独讲解。
而DFS实现起来比BFS更简单且由于递归栈的存在让我们可以很方便地在递归函数的参数中记录路径所以需要输出路径的题目用DFS会比较合适。毕竟想用BFS实现相同的路径记录除了需要在queue中记录节点还需要关联到此节点的路径才可以占用的空间比DFS高得多。
一般情况下我们都可以优先使用DFS实现但这完全建立在我个人觉得DFS写起来更简单的前提下。而在需要求解路径本身的问题中强烈建议你采用DFS作为搜索算法的实现。
### 课后作业
最后,我再给你留三个小作业。
1. 既然说是可以用DFS或者BFS写一个爬虫希望你尝试补全一下爬虫中解析HTML和HTTP请求的逻辑。或者动手写一个你自己想写的爬虫感受一下体会搜索算法在实战中的应用。
2. 在搜索N度关系的所有用户时如果我们希望同时把源用户和这些用户的关注关系记录下来比如A->B->C就表示A关注了BB关注了C更广泛地意义上来说就是让你记录搜索过程中的路径。你会怎么实现这个逻辑呢
3. 前面提到的井字棋中,尝试用代码计算一共有多少种最终合法的局面呢?
欢迎你在留言区与我讨论。如果有收获也欢迎你转发给身边的朋友,邀他一起学习。我们下节课见~