LeetCode 1236. 网络爬虫
题目描述
题意分析
给一个起始 URL 和一个只能通过
getUrls(url)拿到某页面全部外链的接口,要求返回所有「从起始页出发能到达、且与起始页同主机名」的 URL,顺序任意。
这是一个交互式题目:整张图不是一次性给出的,只能按需展开。这决定了不能预处理邻接表,只能边走边问。
「同主机名」是一个过滤条件而不是终止条件——遇到外域链接要跳过它,但不能因此停止对其他链接的探索。主机名的定义是
http://之后到下一个/之前的那一段,题目保证所有 URL 都以http://开头且不含端口号,这让主机名提取变成一个纯粹的下标切分。
要注意页面之间的链接可能构成环,同一个页面也可能被多个页面链接到,所以必须去重,否则会无限展开。
另外
getUrls是外部调用,代价远高于集合操作,应该保证每个页面只调用一次。
边界:起始页没有任何外链、所有外链都是外域、链接构成自环或互相引用的环、某个页面重复出现在多个页面的外链里。
解法:BFS + 同域名过滤
核心思路
把每个 URL 看成图上的一个节点,
getUrls(u)返回的就是 u 的出边。要求「所有可达且同域」的节点,本质是从起点做一次可达性遍历,并在扩展时用主机名做过滤。
朴素地递归展开会有两个问题:一是不去重就会在环上无限递归;二是页面链很长时递归深度不可控。所以选择用显式队列做逐层展开——不是因为需要层数信息(本题不问距离),而是因为队列写法天然避免了栈深度问题,且更容易看清「入队即标记」这一关键点。
维持的不变量是:
visited集合恰好等于「已经确认可达、同域,且已经入队(可能尚未展开)」的 URL 集合。队列里的每个元素都在visited中,且每个元素只会入队一次。这条不变量保证了两件事——getUrls对每个页面至多调用一次,以及循环必然终止(可达页面是有限的,每个至多入队一次)。
去重必须在入队时而不是出队时完成。如果等到出队才检查,同一个页面可能被多个不同的父页面同时塞进队列,虽然靠出队检查也能保证只展开一次,但队列会膨胀,而且更容易写错。用
Set.add的返回值同时完成「查是否已存在」和「插入」,一步到位。
过滤放在入队之前:先比对主机名,不同域直接跳过,连
visited都不记。这样visited最后就直接是答案集合,不需要再做一次筛选。
主机名提取:所有 URL 都以
http://开头,这 7 个字符是定长的,所以主机名从下标 7 开始,到第一个/之前结束;若从下标 7 往后再没有/,整个后缀就是主机名。用定长偏移比查找://更直接,也避免了在字符串字面量里写斜杠。
解题步骤
- 先用
getHost(startUrl)算出目标主机名并存下来。整个过程中它是常量,只需算一次;每次和邻居比较时再算邻居的主机名即可。
- 把
startUrl同时放进visited和队列。起点必须先标记再入队,否则如果某个页面链回起点,起点会被重复展开。注意起点无需做同域检查——它定义了主机名,与自己必然同域。
- 队列非空时弹出一个 URL,对它调用一次
getUrls。每个 URL 只在出队时调用一次接口,这是把外部调用次数压到最低的关键。
- 对每个邻居先判
host.equals(getHost(next)),不同域就continue。为什么先判域再判去重:外域页面根本不该进入visited,否则最终结果里会混入不该返回的 URL。
- 同域邻居用
visited.add(next)判断是否是新页面。Set.add返回 true 表示之前没有,此时才入队;返回 false 表示已经在集合里(无论是否已展开),直接忽略。这一行同时完成了去重和标记。
- 队列耗尽后,
visited就是答案,转成列表返回。不需要排序,题目允许任意顺序。
getHost从下标 7 开始找第一个/:找到就截取[7, slash),没找到就返回从 7 到末尾的整个后缀(对应http://a.com这种没有路径部分的 URL)。
以下面这个小图走一遍:
startUrl = "http://a.com/x",getUrls("http://a.com/x")返回["http://a.com/y", "http://b.com/z", "http://a.com/x"],getUrls("http://a.com/y")返回["http://a.com/x", "http://a.com/w"],getUrls("http://a.com/w")返回空列表。
首先
getHost("http://a.com/x"):从下标 7 开始(字符 'a'),往后找到第一个/在下标 12,截取[7, 12)得到"a.com"。visited = {http://a.com/x},队列[http://a.com/x]。
第一轮弹出
http://a.com/x,调用getUrls得到三个链接。http://a.com/y的主机名是a.com,同域,visited.add返回 true,入队。http://b.com/z的主机名是b.com,不同域,跳过——注意它不会进visited,所以不会出现在最终答案里。http://a.com/x同域但visited.add返回 false(起点已在集合里),忽略,这一步挡住了回边。
第二轮弹出
http://a.com/y,得到两个链接。http://a.com/x已在visited,忽略。http://a.com/w是新的同域页面,入队。
第三轮弹出
http://a.com/w,外链为空,无事发生。队列耗尽,返回visited的三个元素:http://a.com/x、http://a.com/y、http://a.com/w。getUrls恰好被调用三次,每个可达同域页面一次。
代码实现
// 入队即标记,同域过滤放在标记之前,visited 最终就是答案。
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Queue;
import java.util.Set;
class Solution {
public List<String> crawl(String startUrl, HtmlParser htmlParser) {
String host = getHost(startUrl);
Set<String> visited = new HashSet<>();
Queue<String> queue = new ArrayDeque<>();
visited.add(startUrl);
queue.offer(startUrl);
while (!queue.isEmpty()) {
String cur = queue.poll();
for (String next : htmlParser.getUrls(cur)) {
if (!host.equals(getHost(next))) {
continue;
}
if (visited.add(next)) {
queue.offer(next);
}
}
}
return new ArrayList<>(visited);
}
private String getHost(String url) {
int start = 7;
int slash = url.indexOf('/', start);
if (slash == -1) {
return url.substring(start);
}
return url.substring(start, slash);
}
}
// 入队即标记,同域过滤放在标记之前,visited 最终就是答案。
func crawl(startUrl string, htmlParser HtmlParser) []string {
host := getHost(startUrl)
visited := make(map[string]struct{})
queue := make([]string, 0)
visited[startUrl] = struct{}{}
queue = append(queue, startUrl)
for head := 0; head < len(queue); head++ {
cur := queue[head]
for _, next := range htmlParser.GetUrls(cur) {
if getHost(next) != host {
continue
}
if _, ok := visited[next]; ok {
continue
}
visited[next] = struct{}{}
queue = append(queue, next)
}
}
res := make([]string, 0, len(visited))
for url := range visited {
res = append(res, url)
}
return res
}
func getHost(url string) string {
for i := 7; i < len(url); i++ {
if url[i] == '/' {
return url[7:i]
}
}
return url[7:]
}
复杂度分析
- 时间复杂度:$O(V + E)$,其中 V 为可达的同域页面数、E 为这些页面上的外链总数(含指向外域的链接)。每个页面出队一次并调用一次
getUrls,每条外链被检查一次,检查内容是一次主机名提取($O(L)$,L 为 URL 长度)加一次哈希操作,所以更精确地说是 $O((V + E) \cdot L)$。
- 空间复杂度:$O(V \cdot L)$。
visited存下全部可达同域 URL 的字符串,队列长度不超过visited的大小,两者同阶;结果列表是visited的一份拷贝。
关键点总结
- 交互式图遍历题的核心是「把接口调用当成取邻居」,其余部分和普通 BFS 完全一致。识别出这一点后,重点就落在两件事上:去重和减少接口调用次数。
- 去重标记必须在入队时打,不能等到出队。「入队即标记」保证每个节点最多入队一次,队列规模有界,且外部接口调用次数恰好等于节点数——这是本题最值得强调的实现细节。
- 过滤条件(同域)要放在标记之前。先过滤再标记,
visited最终就直接是答案;先标记再过滤则要在末尾再筛一遍,多一步且容易漏。
- 用
Set.add的布尔返回值同时完成「判重 + 标记」,比contains后再add少一次哈希查找,也少一处写漏的可能。
- 字符串定长前缀(
http://恰好 7 个字符)可以直接用下标偏移切分,比查找分隔符更快也更少出错;前提是题目明确保证了协议前缀,面试时要主动确认这个前提。
- 面试延伸:本题用 DFS 递归写同样正确且更短,但页面链很长时可能爆栈;面试官若追问「怎么并行加速」,答案就是 1242 题——把
visited换成并发安全集合、用线程池并行展开,并额外解决「何时判定全部任务完成」的问题。
易错点总结
- 错误写法:出队时才检查
visited并标记 → 图里存在 A→C、B→C 时 C 会被入队两次,虽然靠出队检查仍能只展开一次,但若忘了出队检查就会对 C 调用两次getUrls,环状结构下直接死循环。
- 错误写法:先
visited.add(next)再判断主机名 →http://b.com/z这个外域链接被写进visited,最终返回的列表里混入了不该出现的外域 URL。
- 错误写法:起点不加进
visited就入队 → 走查里http://a.com/x链回自己,起点会被反复入队展开,形成死循环。
- 错误写法:
getHost从下标 0 开始找第一个/→http://a.com/x的第一个/出现在协议部分(下标 5),截取出空串,所有页面的主机名都变成空串,外域链接不再被过滤,会把整个网络爬完。
- 错误写法:
getHost没处理「后面没有/」的情况,直接url.substring(7, url.indexOf('/', 7))→http://a.com这种无路径 URL 上indexOf返回 -1,substring(7, -1)抛异常。
- 错误写法:用
startsWith(host)代替主机名相等判断 →host = "a.com"时http://a.com.evil.net/x会被误判为同域并爬取,返回结果多出外域页面。
- 错误写法:用
contains(host)判断同域 →http://x.com/redirect?to=a.com这类 URL 会被误判成同域。
- 错误写法:把过滤条件写成「主机名不同就
break」而不是continue→ 走查里http://b.com/z排在http://a.com/x的第二个链接位置,break会导致第三个链接不再被检查;若外域链接排在最前面,整层邻居全被跳过,答案严重缺失。
- 错误写法:每次判断邻居时都重新调用
getHost(startUrl)→ 结果正确但每条边多一次字符串扫描,在外链极多时是无谓开销;更糟的是有人会顺手把它写成getHost(cur),此时过滤基准变成当前页面而非起点,逻辑虽在本题等价(同域页面主机名相同),一旦允许跨域跳转就会出错。
- 错误写法:返回队列而不是
visited→ 队列在循环结束时必然为空,返回空列表。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 1242. 多线程网络爬虫 | 中等 | 同一遍历加并发,难点转为线程安全去重与终止判定 |
| 133. 克隆图 | 中等 | 遍历同时构造副本,访问表要存「原节点到新节点」的映射 |
| 841. 钥匙和房间 | 中等 | 邻接表直接给出,问的是可达集合是否覆盖全部节点 |
| 200. 岛屿数量 | 中等 | 隐式网格图,需要对每个未访问格子发起一次独立遍历 |