深度优先搜索(DFS):从递归到回溯,我这样理解

DFS 这东西,说白了就是「一条路走到黑,撞了南墙再回头」。我刚开始学算法时,总觉得它和递归是一回事——后来踩过坑才明白,递归只是实现手段,DFS 是一种遍历或搜索策略

你想想看,生活中找东西是不是也这样?进一个房间,先翻抽屉最里面,翻到底了再退出来翻下一个抽屉。这就是 DFS 的直觉。

DFS 的核心模板

我个人习惯把 DFS 模板分成两类:二叉树上的 DFS图上的 DFS。前者是后者的特例,但面试中图上的 DFS 更常考。

先给一个最通用的模板,我用了五年了,几乎没改过:

def dfs(当前状态,  visited集合):
    # 1. 终止条件
    if 当前状态不合法:
        return
    
    # 2. 标记已访问(防止死循环)
    visited.add(当前状态)
    
    # 3. 处理当前节点
    处理逻辑()
    
    # 4. 遍历所有邻居
    for 邻居 in 当前状态的邻居们:
        if 邻居 not in visited:
            dfs(邻居, visited)
    
    # 5. 可选:回溯(撤销标记)
    # visited.remove(当前状态)  # 看场景是否需要

嗯,这里要注意:第 5 步的回溯不是必须的。什么时候要?当你需要枚举所有路径时。什么时候不要?当你只需要遍历所有节点时。我曾经在「岛屿数量」这道题上搞混过,结果死循环了半小时。

邻接表 vs 邻接矩阵:我选哪个?

做图相关的 DFS,第一步就是选数据结构。我直接给结论:

对比维度 邻接表 邻接矩阵
空间复杂度 O(V+E),稀疏图省空间 O(V²),稠密图才划算
判断两点是否相连 O(degree),需要遍历链表 O(1),直接查矩阵
遍历所有邻居 O(degree),直接拿到列表 O(V),要扫一整行
代码实现难度 稍复杂,但灵活 简单,二维数组搞定
我推荐场景 90% 的面试题 节点少(<100)且稠密

我在项目中遇到过一个问题:社交网络的好友关系用邻接矩阵存,结果 10 万用户直接内存爆炸。后来改成邻接表,不仅省了 90% 内存,遍历速度还快了。说白了,面试里 90% 的情况用邻接表就够了

邻接表的 Python 实现长这样:

# 构建邻接表
graph = {
    0: [1, 2],
    1: [0, 3],
    2: [0, 3],
    3: [1, 2]
}

# 或者用列表存(节点编号从0开始)
n = 4
graph = [[] for _ in range(n)]
edges = [(0,1), (0,2), (1,3), (2,3)]
for u, v in edges:
    graph[u].append(v)
    graph[v].append(u)  # 无向图要加双向

经典例题一:岛屿数量

这道题我面过不下 5 次,每次都能看到有人卡在「怎么标记已访问」上。

题目很简单:给你一个二维网格,'1' 是陆地,'0' 是水,问有多少个岛屿(连在一起的 '1' 算一个)。

DFS 的思路:每遇到一个 '1',就把它和它所有的邻居 '1' 都淹成 '0'。淹完一个,岛屿数加一。

def numIslands(grid):
    if not grid:
        return 0
    
    rows, cols = len(grid), len(grid[0])
    count = 0
    
    def dfs(r, c):
        # 越界或遇到水,直接返回
        if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] == '0':
            return
        # 淹掉当前陆地
        grid[r][c] = '0'
        # 四个方向继续淹
        dfs(r+1, c)
        dfs(r-1, c)
        dfs(r, c+1)
        dfs(r, c-1)
    
    for i in range(rows):
        for j in range(cols):
            if grid[i][j] == '1':
                count += 1
                dfs(i, j)
    
    return count

这里有个小技巧:直接在原数组上改值,省去了 visited 集合。我曾经在面试时用 visited 集合,被面试官问「能不能省点空间?」——嗯,从此我就记住了这个「原地标记法」。

经典例题二:课程表(拓扑排序的 DFS 版)

这道题是 DFS 的进阶应用。题目说:有 n 门课,给你一些先修关系,比如 [1,0] 表示上 1 之前必须先上 0。问能不能上完所有课?

说白了,就是检测有向图里有没有环。有环就上不完,没环就能上完。

DFS 检测环的思路:给每个节点三种状态——0 未访问、1 正在访问、2 已访问完。如果在 DFS 过程中遇到了「正在访问」的节点,说明有环。

def canFinish(numCourses, prerequisites):
    # 构建邻接表
    graph = [[] for _ in range(numCourses)]
    for course, pre in prerequisites:
        graph[pre].append(course)
    
    # 0=未访问, 1=正在访问, 2=已访问完
    visited = [0] * numCourses
    
    def dfs(course):
        if visited[course] == 1:  # 遇到正在访问的节点,有环
            return False
        if visited[course] == 2:  # 已经处理过,不用重复
            return True
        
        visited[course] = 1  # 标记为正在访问
        for neighbor in graph[course]:
            if not dfs(neighbor):
                return False
        visited[course] = 2  # 标记为已访问完
        return True
    
    for i in range(numCourses):
        if not dfs(i):
            return False
    return True
我曾经踩过的坑:一开始我没用「三色标记法」,只用 True/False 表示是否访问过。结果有环时检测不出来,因为环里的节点互相依赖,但每个节点都被标记为「已访问」了。后来改成三色,问题就解决了。

DFS 的时间复杂度分析

这个其实很简单:每个节点访问一次,每条边也访问一次。所以:

  • 邻接表实现:O(V + E),V 是节点数,E 是边数
  • 邻接矩阵实现:O(V²),因为每行都要扫一遍

空间复杂度主要看递归深度:最坏情况 O(V),比如一条链。如果图特别深,递归可能栈溢出——我遇到过,后来改成显式栈(迭代 DFS)解决了。

DFS 的避坑指南

  • 死循环:忘记标记已访问,或者标记了但回溯时又撤销了。我刚开始学的时候,经常在「需要回溯」和「不需要回溯」之间搞混。
  • 栈溢出:递归深度太大。Python 默认递归深度约 1000,超过就崩。解决方案:调大递归限制(sys.setrecursionlimit)或者改成迭代。
  • 方向搞错:二维网格的 DFS,四个方向还是八个方向?看清楚题目。岛屿数量是四个方向,但有些题是八个方向。
  • 状态重置:如果 DFS 是用于「路径搜索」(比如迷宫找所有路径),记得回溯时把状态改回来。如果是「遍历所有节点」(比如岛屿数量),不需要回溯。
我的小习惯:写 DFS 之前,先在纸上画一下递归树。画清楚了再写代码,能省一半 debug 时间。特别是「什么时候回溯」这个问题,画一遍就懂了。

本章知识体系

下面这张图总结了 DFS 的核心要点,我每次讲课时都会画一遍:

深度优先搜索 DFS 核心模板 终止条件 标记访问 处理节点 遍历邻居 回溯(可选) 递归实现 数据结构选择 邻接表 O(V+E) 空间 邻接矩阵 O(V²) 空间 经典例题 岛屿数量 二维网格遍历 课程表 拓扑排序/环检测 避坑指南 死循环 栈溢出 状态重置

这张图把 DFS 拆成了三个维度:模板怎么写、数据结构怎么选、例题怎么套。每次做题前,先想清楚这三个问题,代码基本不会跑偏。


DFS 是算法面试的「基本功」,也是很多难题的起点。我个人觉得,把 DFS 练熟了,后面学回溯、剪枝、记忆化搜索都会轻松很多。嗯,下一章我们聊 BFS,到时候你会发现 DFS 和 BFS 其实是一体两面。

一句话总结:DFS = 递归 + 标记 + 遍历邻居。记住这个公式,80% 的 DFS 题都能搞定。

公众号:蓝海资料掘金营,微信deep3321