搜索算法: 如何写一个简单的爬虫?

玩过井字棋的游戏吗?在一个九宫格中,双方轮流用 X 和 O 占领一个格子,某一方的 O 或者 X 三个连成一线时即可获胜。

这样一个简单井字棋的游戏,如果要让你自己写代码实现一个 ,你会怎么做呢?怎么把博弈过程清晰地表示出来呢?

实际上,许多博弈类游戏的过程,我们都可以用树来表示。根节点就是棋盘为空的状态,终点就是各个棋下完的状态,这样的树也被称为博弈树。下图是井字棋某个局面 3 步内的树状展示:

一般来说,对弈双方在做的事情,其实就是找到这棵树上对于自己最优的一种落子方式,使得之后的每条路径,自己都有必胜或者必不败的策略。如果想要找出一个策略,最暴力的方式就是直接遍历每一种情况,找到最优的下法,这就是一个典型的搜索问题了。

如果暴力遍历,有多少种情况呢?就是这么一个简单的井字棋小游戏,终局的数量非常多,达到了 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 去做。只不过效率可能不是最优的,所以我们也常常称之为暴力搜索算法,在各大刷题网站题解区中,应该常常能见到"暴搜"这样的关键词,说的一般就是 DFS 和 BFS 这两种算法。

所以,在爬虫这样本来就需要无差别遍历全部空间的场景下可以说是非常合适的了。至于 DFS 和 BFS 具体选择哪一种,可以结合一个具体的爬虫场景来分析。

如果让你手写一个爬虫,从豆瓣上爬取一个用户关注的所有用户,是不是很简单?只要直接遍历某个用户的关注者列表就可以了,除了需要处理一些鉴权和页面解析的问题,没有什么复杂的地方。

那我们升级一下挑战,爬取这个用户关注的人的所有关注的人,也就是和这个用户有二度关系的所有用户,要怎么实现呢?如果不是二度,而是让你查找三度关系,也就是找出需要三跳的所有用户,代码能否很简单地通过配置就完成这件事呢?

这其实就是一个非常适合用 DFS 和 BFS 解决的问题,因为它天然就是一个需要无差别遍历所有图上节点的问题。

你可以把豆瓣用户看成节点,用户之间的关注关系就是边,它们一起构成了一个复杂的社交网络。相信你也听过社交网络中的"六度分割理论",说的就是世界上任何一个人和你之间的距离不会超过 6 度,描述了社交网络的小世界特性。

BFS 实现思路

我们先来使用 BFS 解决这一问题。我们优先去遍历所有到源用户距离为一度的用户,然后再遍历这些用户的邻居,用层层深入的方式进行搜索。广度优先搜索,其实是一个很直观的定义,把对应到图上的搜索顺序画出来,就很清晰了。

假设我们搜索的源用户为图的 0 节点,一度关系包含 3 个节点,二度关系包含了 6 个节点,每条连边都是一个关注关系。那我们基于广度优先搜索策略遍历时,就会按照标号顺序进行遍历。

广度优先搜索是由源点向外逐层推进的,每遍历下一层的时候,我们都需要用到上一层的节点,所以我们需要一个容器记录每一层的元素,并依次遍历,先进先出的队列就可以帮助我们很好地解决这个问题。

首先把遍历的初始节点加入 queue 中,然后循环读取 queue 中的元素,每次读出一个元素,就把它的所有相邻节点都放入新队列中,直到目前队列为空,就代表我们遍历完了所有的元素。队列 FIFO 的特性保证了,下一层的元素一定会比上层的元素更晚出现。

当然我们需要设置自己的搜索退出条件,比如在最短路径问题中,我们并不需要遍历所有的路径,当搜索到终点的时候其实就可以退出了;在我们的例子中,我们的退出条件也不是遍历完整个社交网络,遍历到第二度关系就可以结束了,因此我们还需要在代码中记录当前遍历的层数。

另外,有时候需要对插入队列中的元素做一些判重,防止重复的搜索。

在搜索最短路径或者求几度关系所有用户的情况下就很有用,因为重复的节点已经没有必要再搜索了;如果不这样做,甚至可能导致搜索永远无法结束,比如在图有环的情况下。

实现

  • 初始化数据结构

    • 使用队列来存储待处理的节点(用户)。
    • 使用集合来记录已访问的用户,避免重复访问。
  • 遍历关系

    • 从起始用户开始,将其放入队列。
    • 对于当前用户,获取其关注的用户列表,并将这些用户加入队列,直到达到指定的跳数。
  • 配置化跳数

    • 使用一个参数来设置跳数,使得代码可以灵活调整。
swift 复制代码
import Foundation

// 模拟用户数据结构
struct User: Codable {
    let id: String
    let followedUsers: [String]
}

// 爬虫类
class UserCrawler {
    
    func fetchUser(withID id: String, completion: @escaping (User?) -> Void) {
        let urlString = "https://api.douban.com/v2/user/\(id)/followings"
        guard let url = URL(string: urlString) else {
            print("无效的URL")
            completion(nil)
            return
        }
        
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                print("网络错误: \(error.localizedDescription)")
                completion(nil)
                return
            }
            
            guard let httpResponse = response as? HTTPURLResponse else {
                print("无效的响应")
                completion(nil)
                return
            }
            guard (200...299).contains(httpResponse.statusCode) else {
                print("服务器错误,状态码: \(httpResponse.statusCode)")
                completion(nil)
                return
            }
            
            guard let data = data else {
                print("响应数据为空")
                completion(nil)
                return
            }
            
            do {
                let decoder = JSONDecoder()
                let user = try decoder.decode(User.self, from: data)
                completion(user)
            } catch {
                print("JSON解析错误: \(error.localizedDescription)")
                completion(nil)
            }
        }
        
        task.resume()
    }
    
    func bfs(startUserID: String, maxDepth: Int, maxConcurrentRequests: Int) -> Set<String> {
        var visited = Set<String>()
        var queue: [(userID: String, depth: Int)] = [(startUserID, 0)]
        let semaphore = DispatchSemaphore(value: maxConcurrentRequests)
        let group = DispatchGroup()
        
        while !queue.isEmpty {
            let (currentUserID, currentDepth) = queue.removeFirst()
            
            if !visited.contains(currentUserID) {
                visited.insert(currentUserID)
                
                group.enter() // 进入组
                semaphore.wait() // 等待可用的信号量
                
                // 请求用户关注的用户
                fetchUser(withID: currentUserID) { user in
                    defer {
                        semaphore.signal() // 释放信号量
                        group.leave() // 离开组
                    }
                    
                    if let user = user, currentDepth < maxDepth {
                        for followedUserID in user.followedUsers {
                            queue.append((followedUserID, currentDepth + 1))
                        }
                    }
                }
            }
        }
        
        group.wait() // 等待所有请求完成
        return visited
    }
}

// 使用示例
let crawler = UserCrawler()
let result = crawler.bfs(startUserID: "user123", maxDepth: 3, maxConcurrentRequests: 5)
print("三度关系的所有用户ID: \(result)")

代码解释

  1. 参数化并发请求数量

    • maxConcurrentRequests 作为参数传递给 bfs 方法,使得在调用时可以灵活设置并发请求的数量。
  2. 信号量控制

    • 信号量的初始值设为 maxConcurrentRequests,以限制同时进行的请求数量。

DFS 实现思路

再来用 DFS 解决这个问题。深度优先搜索,就不会再像广度优先搜索那样严格由内而外逐层推进了,它和棋手下棋的思路其实会更像一点,我们就用下棋举个例子。

假设下棋的时候,当前局面可以有若干个落子点,棋手一般会先顺着其中一个落子点在脑海中模拟若干步,发现某一步不行,我们回溯到分叉点,再看一下其他选择;最终遍历完当前选择的落子点的各种局面之后,再依次进行其他落子点的判断,直到选出一种比较优的策略。

画成图对比一下,会更直观地感受到两者的区别,同样用刚刚假想的豆瓣用户关注关系图来举例:

上图的数字,表示深度优先搜索在同样的关系图中的遍历顺序;可以看到相比于 BFS 的逐层推进,在 DFS 中,是一条条分支顺次遍历到终点再进行下一种尝试的,这也是深度优先搜索命名的由来。

实现

这种遍历方式天然符合回溯法的适用场景,所以常规做法就是通过递归来实现。

swift 复制代码
import Foundation

// 模拟用户数据结构
struct User: Codable {
    let id: String
    let followedUsers: [String]
}

// 爬虫类
class UserCrawler {
    
    func fetchUser(withID id: String, completion: @escaping (User?) -> Void) {
        let urlString = "https://api.douban.com/v2/user/\(id)/followings"
        guard let url = URL(string: urlString) else {
            print("无效的URL")
            completion(nil)
            return
        }
        
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                print("网络错误: \(error.localizedDescription)")
                completion(nil)
                return
            }
            
            guard let httpResponse = response as? HTTPURLResponse else {
                print("无效的响应")
                completion(nil)
                return
            }
            guard (200...299).contains(httpResponse.statusCode) else {
                print("服务器错误,状态码: \(httpResponse.statusCode)")
                completion(nil)
                return
            }
            
            guard let data = data else {
                print("响应数据为空")
                completion(nil)
                return
            }
            
            do {
                let decoder = JSONDecoder()
                let user = try decoder.decode(User.self, from: data)
                completion(user)
            } catch {
                print("JSON解析错误: \(error.localizedDescription)")
                completion(nil)
            }
        }
        
        task.resume()
    }
    
    func dfs(userID: String, currentDepth: Int, maxDepth: Int, visited: inout Set<String>, group: DispatchGroup) {
        // 如果已访问或超过最大深度,返回
        if visited.contains(userID) || currentDepth > maxDepth {
            return
        }
        
        visited.insert(userID) // 标记为已访问
        
        group.enter() // 进入 DispatchGroup
        
        DispatchQueue.global().async {
            self.fetchUser(withID: userID) { user in
                defer { group.leave() } // 离开 DispatchGroup
                
                if let user = user {
                    for followedUserID in user.followedUsers {
                        self.dfs(userID: followedUserID, currentDepth: currentDepth + 1, maxDepth: maxDepth, visited: &visited, group: group)
                    }
                }
            }
        }
    }

    func startDFS(startUserID: String, maxDepth: Int) -> Set<String> {
        var visited = Set<String>()
        let group = DispatchGroup()
        
        dfs(userID: startUserID, currentDepth: 0, maxDepth: maxDepth, visited: &visited, group: group)
        
        group.wait() // 等待所有请求完成
        
        return visited
    }
}

// 使用示例
let crawler = UserCrawler()
let result = crawler.startDFS(startUserID: "user123", maxDepth: 3)
print("三度关系的所有用户ID: \(result)")

代码解释

  1. 用户数据结构 (User)

    • 定义了一个模拟用户的数据结构,包含用户 ID 和关注的用户列表。
  2. UserCrawler

    • 包含获取用户关注者的 fetchUser 方法和进行深度优先搜索的 dfs 方法。
  3. fetchUser 方法

    • 发送网络请求以获取用户数据。使用 URLSession 进行异步请求,并在完成时解析 JSON 数据。
  4. dfs 方法

    • 实现深度优先搜索的逻辑:

      • 检查当前用户是否已访问或是否超过最大深度,如果是,则返回。
      • 将当前用户 ID 标记为已访问。
      • 使用 DispatchGroup 来跟踪异步操作的完成状态,调用 group.enter() 表示开始处理请求。
      • 在全局并发队列中异步调用 fetchUser 方法,获取用户的关注者。
      • 在请求完成后,使用 defer 确保调用 group.leave(),这将使得 DispatchGroup 的计数减少,表示该请求已完成。
  5. startDFS 方法

    • 初始化访问集合和 DispatchGroup,并开始 DFS。
    • 使用 group.wait() 等待所有异步请求完成。

DFS 的代码看起来明显要简短很多,这就是递归的威力。通过对自身的调用,很多时候,我们可以让代码变得非常简单。

时间复杂度和空间复杂度分析

1. DFS(深度优先搜索)

时间复杂度

  • O(V + E) ,其中 V 是图中的顶点数,E 是边的数量。

    • 原因:每个节点被访问一次(O(V)),每条边也会被检查一次(O(E)),因此总时间复杂度为 O(V + E)。

空间复杂度

  • O(h) ,其中 h 是树的高度。

    • 在最坏情况下(例如链式结构),空间复杂度可能为 O(n),其中 n 是节点数。
    • 使用递归时,递归栈的深度决定了空间复杂度。
2. BFS(广度优先搜索)

时间复杂度

  • O(V + E) ,与 DFS 相同。

    • 原因:每个节点被访问一次(O(V)),每条边也会被检查一次(O(E)),因此总时间复杂度为 O(V + E)。

空间复杂度

  • O(w) ,其中 w 是树的宽度。

    • 在最坏情况下,宽度可能接近 n(例如完全二叉树),因此空间复杂度可以达到 O(n)。
    • BFS 使用队列来存储当前层的节点,队列的最大大小决定了空间复杂度。

总结

算法 时间复杂度 空间复杂度 适用场景
DFS O(V + E) O(h) 适合解决路径问题、解谜、图的连通性、需要遍历所有路径的情况
BFS O(V + E) O(w) 适合寻找最短路径、层次遍历、广度扩展问题

适用场景分析

DFS(深度优先搜索)
  • 适用场景

    • 路径查找:如迷宫问题,寻找所有可能的路径。
    • 图的连通性:检查一个图是否连通。
    • 解谜:如八皇后问题、数独解法等。
    • 树的遍历:如前序、中序、后序遍历。
BFS(广度优先搜索)
  • 适用场景

    • 最短路径查找:在无权图中寻找两点之间的最短路径(如社交网络分析)。
    • 层次遍历:遍历树的每一层,如打印树的每一层。
    • 广度扩展:在图中寻找连接性或分层结构。

总结

作为两个相当常用的暴力搜索算法,BFS 和 DFS 比较适合用来解决图规模不大,或者本身就需要无差别遍历搜索空间的每一种情况的问题;这两者的时间空间复杂度是相当的。

而至于 DFS 和 BFS 具体选择哪一种,总结出一些自己的经验,供参考。

BFS 因为是由内向外地毯式地搜索,所以首次搜索到目标位置的时候一定是源点到目标位置的最短路径,所以求最短路径类的问题往往可以用 BFS 解决。当然,这里的"最短路径"是有条件的,只有在图中所有边权重相等时首次搜索到的才是最短路径;

而 DFS 实现起来比 BFS 更简单,且由于递归栈的存在,让我们可以很方便地在递归函数的参数中记录路径,所以需要输出路径的题目用 DFS 会比较合适。毕竟想用 BFS 实现相同的路径记录,除了需要在 queue 中记录节点,还需要关联到此节点的路径才可以,占用的空间比 DFS 高得多。因此在需要求解路径本身的问题中,强烈建议采用 DFS 作为搜索算法的实现。

相关推荐
站在风口的猪11081 小时前
《前端面试题:BFC(块级格式化上下文)》
前端·css·css3
Dovis(誓平步青云)1 小时前
C++ Vector算法精讲与底层探秘:从经典例题到性能优化全解析
开发语言·c++·经验分享·笔记·算法
czliutz3 小时前
NiceGUI 是一个基于 Python 的现代 Web 应用框架
开发语言·前端·python
koooo~4 小时前
【无标题】
前端
Attacking-Coder5 小时前
前端面试宝典---前端水印
前端
编程绿豆侠6 小时前
力扣HOT100之多维动态规划:62. 不同路径
算法·leetcode·动态规划
鑫鑫向栄6 小时前
[蓝桥杯]剪格子
数据结构·c++·算法·职场和发展·蓝桥杯
羊儿~7 小时前
P12592题解
数据结构·c++·算法
Wendy_robot7 小时前
池中锦鲤的自我修养,聊聊蓄水池算法
程序人生·算法·面试
.Vcoistnt7 小时前
Codeforces Round 1028 (Div. 2)(A-D)
数据结构·c++·算法·贪心算法·动态规划