跳转至

第 60 章 DFS 深度优先搜索

本章配套 18 道例题,见 §例题。 前置:函数(递归)、栈、复杂度与Python性能

DFS(Depth First Search,深度优先搜索)是「一条路走到黑,走不通再退回来换一条」。 它是所有搜索算法的地基:回溯、记忆化搜索、剪枝、树上 DP、Tarjan、DLX, 全都是 DFS 加了点东西。

但 DFS 是 Python 在算法竞赛里最大的一个坑。 C++ 选手写 DFS 从不考虑深度,Python 选手写 DFS 必须先算深度—— 默认递归上限只有 1000 层,而且就算把它调大,物理栈照样会静默崩溃(连报错都没有)。

所以这一章的重点不是「DFS 怎么写」,而是: 什么时候可以放心递归,什么时候必须改成迭代,以及怎么改。


1 DFS 的递归框架

最小骨架

def dfs(u):
    """访问节点 u。vis 防止重复访问,是 DFS 的灵魂。"""
    vis[u] = True               # 一进门就标记,递归返回之前不会有人再进来
    for v in adj[u]:            # 枚举 u 的所有后继
        if not vis[v]:          # 漏掉这一句,图上只要有环就会无限递归
            dfs(v)

三个必备部件,缺一个就会死循环或者指数爆炸:

部件 作用 漏掉的后果
vis 标记 每个状态只处理一次 图上有环 → 死递归;DAG 上 → 指数级重复
后继枚举 定义「往哪走」 —
边界/出口 何时停止 无穷递归

连通块的求法就是这个骨架的直接应用:

使用 DFS 或者 BFS 找出所有连通块:DFS 或 BFS 的过程中标记已经访问过的节点,避免重复访问。时间复杂度 \(\Theta(n+m)\)。

什么时候标记 vis

这是最容易写错的地方,两种时机语义完全不同:

写法 语义 用途
进入 dfs(u) 时立刻标记,且不撤销 「这个点全局只访问一次」 连通块、可达性、拓扑序
进入时标记,返回前撤销(vis[u] = False) 「这个点不能在当前路径上重复」 回溯、找所有路径、全排列

⚠️ 不撤销 = 遍历,撤销 = 枚举。 把「求连通块」写成撤销版会退化成指数级枚举所有路径; 把「找所有路径」写成不撤销版会漏掉绝大多数方案。 写之前先问一句:这是在「走遍全图」还是在「枚举所有可能」?

网格上的 DFS

网格图是最常见的隐式图:格子是点,上下左右(或八向)相邻是边。

# 四方向 / 八方向偏移量,写成常量元组,别在循环里现造
DIR4 = ((-1, 0), (1, 0), (0, -1), (0, 1))       # 每项是 (行增量, 列增量)
DIR8 = ((-1, -1), (-1, 0), (-1, 1), (0, -1),    # 八连通 = 3x3 邻域去掉 (0, 0)
        (0, 1), (1, -1), (1, 0), (1, 1))


def dfs_grid(g, vis, x, y, n, m):
    """网格四连通 DFS(递归版)。仅在深度可控时使用!"""
    vis[x][y] = 1                               # 进入即标记且不撤销 = 每格只走一次
    for dx, dy in DIR4:
        nx, ny = x + dx, y + dy
        # 四个条件的先后不能换:确认下标合法之后,才敢拿它去索引 vis 和 g
        if 0 <= nx < n and 0 <= ny < m and not vis[nx][ny] and g[nx][ny] != '#':
            dfs_grid(g, vis, nx, ny, n, m)

这段代码在 Python 里是个定时炸弹。 \(n = m = 100\) 的网格,如果整张图是一条蛇形通道,递归深度就是 \(10^4\) 层—— 远超默认的 1000。BISHI76、BISHI77 都是 \(100 \times 100\), 所以那两题的题解都用了显式栈,见 §4。


2 回溯:DFS 的枚举形态

回溯(backtracking)就是「做选择 → 递归 → 撤销选择」的 DFS。 它枚举的不是图上的点,而是决策序列。

通用模板

def backtrack(path, state):
    """回溯三件套:选择 -> 递归 -> 撤销。三步一步都不能少。"""
    if is_answer(path):
        record(path[:])                  # ★ 必须复制!path 后面还要改
        return                           # 收下这一组解,本条路径到此为止
    for choice in candidates(state):
        if not ok(choice, state):        # 剪枝:不合法就别往下走
            continue
        do(choice, path, state)          # 1. 做选择
        backtrack(path, state)           # 2. 递归
        undo(choice, path, state)        # 3. 撤销:现场恢复成进入本层时的样子

⚠️ record(path[:]) 里的切片不能省。 path 是同一个 list 对象, 回溯过程中它会被反复修改;直接 record(path) 存进去的是引用, 最后拿到的会是一堆指向同一个空列表的引用。 这是回溯题最高频的 bug,见 列表 的复制陷阱。

三大枚举:排列 / 子集 / 组合

这三个是回溯的「Hello World」,务必背下来它们的结构差异。

枚举对象 数量 关键控制
排列 \(n!\) used[] 标记,每层从头扫
子集 \(2^n\) 每个元素「选 / 不选」,或按起点 start 递增
组合 \(C(n,k)\) \(\binom{n}{k}\) 按起点 start 递增,选够 \(k\) 个就收
def permutations_dfs(n):
    """1..n 的全排列,按字典序输出。O(n! * n)。"""
    res = []
    path = []                                    # 当前已经确定的前几位
    used = [False] * (n + 1)                     # used[v]:v 是否已经出现在 path 里
                                                 # 开 n+1 格,下标直接用 1..n,不做偏移

    def dfs():
        if len(path) == n:
            res.append(path[:])                  # 复制!path 是同一个对象,之后还会被改
            return
        for v in range(1, n + 1):                # 从小到大 -> 天然字典序
            if used[v]:
                continue                         # 排列里每个数只能出现一次
            used[v] = True
            path.append(v)
            dfs()
            path.pop()                           # 撤销
            used[v] = False                      # 撤销:v 换到别的位置上还要再用
    dfs()
    return res


def subsets_dfs(a):
    """a 的所有子集,O(2^n * n)。按「起点递增」写,天然不重不漏。"""
    n = len(a)
    res = []
    path = []

    def dfs(start):
        res.append(path[:])                      # 每个节点都是一个子集,根节点是空集
        for i in range(start, n):                # 只从 start 往后挑,同一子集不会被换序枚举
            path.append(a[i])
            dfs(i + 1)                           # ★ i+1:后面的元素只能选更靠后的
            path.pop()                           # 撤销,换下一个 i
    dfs(0)
    return res


def combinations_dfs(n, k):
    """从 1..n 中选 k 个的所有组合。"""
    res = []
    path = []

    def dfs(start):
        if len(path) == k:
            res.append(path[:])
            return
        # 剪枝:还差 k-len(path) 个数,i 最大只能取到 n-(k-len(path))+1;
        # range 的右端是开区间,所以写成 +2
        for i in range(start, n - (k - len(path)) + 2):
            path.append(i)
            dfs(i + 1)                           # 下一层从 i+1 起,组合不计顺序
            path.pop()
    dfs(1)                                       # 元素编号是 1..n,所以起点取 1
    return res

但在 Python 里:先想想 itertools

这是全书反复强调的取舍。 itertools 的循环在 C 层跑, 比手写 Python 递归快一个数量级:

手写回溯 itertools 等价物 相对速度
permutations_dfs(n) itertools.permutations(range(1, n+1)) 约 10–30×
combinations_dfs(n, k) itertools.combinations(a, k) 约 10–30×
subsets_dfs(a) chain.from_iterable(combinations(a, r) for r in range(len(a)+1)) 约 10×
笛卡尔积(每位独立选) itertools.product(opts, repeat=n) 约 10×
\(2^n\) 子集(位运算枚举) for s in range(1 << n): ... 见 位运算

判据:如果枚举没有剪枝(要枚举出全部方案),一律用 itertools; 如果枚举必须靠剪枝才能跑完(比如 N 皇后、数独),就得手写回溯—— 因为 itertools 没法在中途「掐断一整个分支」。

BISHI78 全排列就是前者,题解直接用了 itertools.permutations。


3 连通块与泛洪填充

「数有多少个岛 / 水坑 / 联通区域」是 DFS 最经典的应用:

def count_components(g, n, m, target, dirs):
    """数网格中值为 target 的连通块个数。dirs 传 DIR4 或 DIR8。O(nm)。"""
    vis = [bytearray(m) for _ in range(n)]       # 每格 1 字节,比 list of bool 省内存
    cnt = 0
    for si in range(n):
        for sj in range(m):
            if g[si][sj] != target or vis[si][sj]:
                continue                         # 不是目标格,或已属于前面数过的块
            cnt += 1                             # 每找到一个新起点,就是一个新连通块
            vis[si][sj] = 1
            stack = [(si, sj)]                   # 显式栈,不用递归
            while stack:
                x, y = stack.pop()
                for dx, dy in dirs:
                    nx, ny = x + dx, y + dy
                    if 0 <= nx < n and 0 <= ny < m \
                            and not vis[nx][ny] and g[nx][ny] == target:
                        vis[nx][ny] = 1          # ★ 入栈即标记
                        stack.append((nx, ny))
    return cnt

「入栈即标记」是迭代 DFS/BFS 的铁律。 如果改成「出栈时才标记」,同一个格子会被它的 4(或 8)个邻居各压一次, 栈的规模会从 \(O(nm)\) 膨胀到 \(O(8nm)\),还可能重复计数。

连通块题的常见变体:

变体 改动
求最大连通块面积 在遍历时累加 size
求连通块的外接矩形 维护 minr/maxr/minc/maxc(BISHI81)
判块是否为实心矩形 size == (maxr-minr+1)*(maxc-minc+1)(BISHI81)
八连通 方向数组换成 DIR8(BISHI77)
每个块要回答多次询问 给每个格子打上「块编号」,预处理块大小
只问「起点能否到终点」 不用数块,一次泛洪即可(BISHI76)

4 递归改迭代

这一节是本章的核心,也是 Python 选手必须过的一关。

为什么必须改:三层限制

第一层:解释器软限制 = 1000。

import sys
print(sys.getrecursionlimit())        # 1000

超过就抛 RecursionError,这是能捕获、有报错的,还算友好。

第二层:物理 C 栈。 sys.setrecursionlimit(10**6) 只是把计数器调大, 并不会带来更多的栈空间。每一层 Python 函数调用都要在 C 栈上放一个求值帧, 撞破 C 栈的结果是进程直接被操作系统杀掉:没有异常、没有 traceback、 在判题机上表现为「运行错误」甚至「输出为空」,极难定位。

本机(Windows + CPython 3.9)实测:

环境 栈大小 裸递归可达深度
主线程(Windows 默认) 1 MB 约 2500–2800 层就静默崩溃
主线程(Linux 判题机常见) 8 MB 约 2 万层
threading.stack_size(1 << 26) 新线程 64 MB 7 万层以上没问题

换算:每层递归约吃掉 0.4–1 KB 的 C 栈(函数局部变量越多越吃)。 想要 \(10^5\) 层深度,至少要准备 64 MB 以上的栈。

第三层:速度。 一次 Python 函数调用约 0.1 μs,\(10^6\) 次递归光调用开销就接近 1 秒。 详见 复杂度与Python性能。

setrecursionlimit vs threading.stack_size

这两个东西解决的是完全不同的问题,必须一起用。

sys.setrecursionlimit(N) threading.stack_size(N)
改的是什么 解释器的计数器上限(软限制) 新线程的物理栈字节数(硬限制)
不改的后果 RecursionError(有报错) 段错误/静默崩溃(无报错)
对主线程有效吗 ✅ 有效 ❌ 无效,只对之后创建的线程生效
单独用够不够 ❌ 不够,仍会崩 ❌ 不够,仍会 RecursionError

正确的「开大栈」写法(两个都要设,且必须新起线程):

import sys
import threading


def main():
    sys.setrecursionlimit(300000)             # 1. 解除软限制
    ...                                       #    在这里写递归逻辑


threading.stack_size(1 << 26)                 # 2. 64 MB 物理栈(必须在 Thread 创建前调用)
t = threading.Thread(target=main)
t.start()
t.join()                                      # 等子线程跑完;主线程先退出会截断输出

三个细节: 1. threading.stack_size 必须在 Thread(...) 构造之前调用,之后调用无效; 2. 有些评测机对线程栈大小有上限,1 << 26(64 MB)是比较安全的取值, 盲目开到 1 << 30 可能直接 ThreadError: can't start new thread; 3. sys.setrecursionlimit 要在线程内部(或至少在 start() 之前)设, 它是解释器全局的,设一次即可。

改法一:纯遍历型 DFS → 直接换成显式栈

只在「进入节点」时做事、不需要「回来之后再做事」的 DFS,改写是机械的:

# ---- 递归版 ----
def dfs(u):
    vis[u] = 1                            # 进入时标记且不撤销 = 全局只访问一次
    for v in adj[u]:
        if not vis[v]:
            dfs(v)                        # 递归深度 = 最长路径长度,随时可能爆栈


# ---- 迭代版(等价,深度不受限)----
def dfs_iter(s):
    vis[s] = 1                            # 起点压栈之前先标记,与下面的规则保持一致
    stack = [s]
    while stack:
        u = stack.pop()                   # 后进先出,效果就是「一条路走到黑」
        for v in adj[u]:
            if not vis[v]:
                vis[v] = 1                # ★ 入栈即标记
                stack.append(v)

访问顺序会变:递归版按 adj[u] 的正序深入,迭代版因为栈是后进先出, 实际是按逆序深入。如果题目对遍历顺序敏感(比如要求字典序最小的 DFS 序), 就要 for v in reversed(adj[u])。只求连通性/可达性时顺序无所谓。

改法二:需要「回来之后再做事」→ 两趟法

树上 DP、求子树大小、求 DFS 序……这类需要后序处理的问题, 不能简单换成显式栈。最好用的写法是「两趟法」: 第一趟只求出访问顺序和父亲,第二趟倒着扫一遍就是后序。

def tree_dp_two_pass(n, adj, root=1):
    """树上后序 DP 的迭代写法。O(n),深度不受限。

    第一趟:BFS/DFS 求出 order(访问顺序)和 par(父亲);
    第二趟:reversed(order) 即为后序,儿子一定先于父亲被处理。
    """
    par = [0] * (n + 1)                    # par[v] = v 的父亲;0 表示「没有父亲」
    order = []                             # 第一趟的访问顺序,也就是一个合法的 DFS 序
    stack = [root]
    par[root] = 0                          # 根没有父亲,用 0 当哨兵
    vis = bytearray(n + 1)
    vis[root] = 1
    while stack:
        u = stack.pop()
        order.append(u)
        for v in adj[u]:
            if not vis[v]:                 # 无根树的邻接表不分方向,靠 vis 挡住「走回父亲」
                vis[v] = 1                 # ★ 入栈即标记
                par[v] = u
                stack.append(v)

    size = [1] * (n + 1)                   # 每个点先把自己算进子树大小
    for u in reversed(order):              # ★ 倒序 = 后序:儿子已经算完了
        p = par[u]
        if p:                              # 根的父亲是 0,到根就停,不再往外累加
            size[p] += size[u]
    return size, order, par

这是 Python 里写树上 DP 的标准姿势,比任何状态机写法都简洁。 记住这句话:「reversed(DFS 序) 就是后序」。 见 树的基础与遍历、 树形 DP。

改法三:真正需要「进入/退出」两个时机 → 事件栈

Tarjan、求 DFS 括号序、需要在退出时撤销现场的场合,用「状态机栈」:

def dfs_events(s, adj):
    """栈里存 (节点, 阶段)。阶段 0 = 进入,阶段 1 = 退出。"""
    vis = {}
    stack = [(s, 0)]
    while stack:
        u, stage = stack.pop()
        if stage == 0:
            if u in vis:
                continue                    # 同一点可能被多个邻居压入,靠这一句去重
            vis[u] = True                   # 这里只能出栈时标记,理由见下方说明
            stack.append((u, 1))            # ★ 先压「退出事件」:它会在所有孩子之后才弹出
            for v in reversed(adj[u]):      # 逆序压栈以保持正序访问
                if v not in vis:
                    stack.append((v, 0))
            # ---- 在这里写「进入 u 时」要做的事 ----
        else:
            pass
            # ---- 在这里写「离开 u 时」要做的事(后序)----

事件栈是「入栈即标记」的唯一例外:进入事件和退出事件必须成对, 而退出事件只能在真正展开 \(u\) 时才压得进去,所以标记只能推迟到出栈那一刻。 代价是同一个点可能被压入多次,栈的规模比改法一大,靠 if u in vis: continue 兜住。

另一种等价写法是「迭代器栈」,更接近递归的语义(能保留「循环走到哪了」):

def dfs_iterstack(s, adj):
    """栈里存 (节点, 该节点的邻居迭代器)。最贴近递归语义的迭代改写。"""
    vis = {s}
    stack = [(s, iter(adj[s]))]             # 迭代器记住「这一层的循环走到哪了」
    while stack:
        u, it = stack[-1]                   # 只看栈顶不弹出,这一层可能还没走完
        advanced = False
        for v in it:                        # 从上次断点继续
            if v not in vis:
                vis.add(v)                  # ★ 入栈即标记
                stack.append((v, iter(adj[v])))
                advanced = True
                break                       # 立刻下潜;剩下的邻居等回到这一层再走
        if not advanced:
            stack.pop()                     # 邻居全部走完,这一层才真正出栈
            # ---- 这里是「离开 u」,后序位置 ----
改法 适用 优点 缺点
显式栈(改法一) 只需前序 / 只求可达性 最简单最快 拿不到后序
两趟法(改法二) 树 / DAG 上的后序 DP 最快、最好写 需要先存下整个访问序
事件栈(改法三) 需要进入+退出两个时机 通用 栈元素多一倍,常数大
迭代器栈 需要保留「循环进度」 语义最贴近递归 迭代器对象开销大,最慢

决策表:到底该不该递归

最大递归深度 建议
\(\le 900\) ✅ 直接递归,什么都不用做
\(\le 10^4\) ⚠️ setrecursionlimit + 开 64 MB 线程栈
\(\le 10^5\) ⚠️ 同上,但已经很险(时间也可能不够);优先改迭代
\(> 10^5\) ❌ 必须改迭代,没有第二条路
深度未知/取决于数据 ❌ 一律按最坏情况算:链状树、蛇形迷宫都是 \(O(n)\) 深

实战建议:竞赛里遇到图/树/网格的 DFS, 直接上迭代版,不要先写递归再等着 RE。 迭代版只多写三行,却省掉了一整类不可调试的崩溃。


5 DFS 的复杂度

场景 复杂度
图的遍历(邻接表) \(O(n + m)\)
网格泛洪 \(O(nm)\),四连通常数 4,八连通常数 8
全排列枚举 \(O(n! \cdot n)\)
子集枚举 \(O(2^n \cdot n)\)
带剪枝的搜索 无法用大 O 描述,只能估「搜索树节点数」

最后一行是搜索题的本质:剪枝的好坏决定生死,见 记忆化搜索与剪枝。

DLX(Dancing Links X,用双向十字链表实现的精确覆盖搜索)的搜索流程,就是「剪枝驱动的 DFS」的典范:

  1. 如果当前矩阵没有列,则要求满足。
  2. 任意选取一列,如果这一列上没有 1,则无解(← 可行性剪枝)。
  3. 否则依次遍历这一列上所有有 1 的行,其中必须有一行被选中。钦定某一行被选中,删去所有被覆盖的列和不能再选择的行,得到一个更小的矩阵,递归下去处理。

其中「选哪一列」用当前 1 最少的列(\(S\) 启发式)能把搜索树砍掉几个数量级—— 这就是 DLX 快的全部秘密。见 精确覆盖与 Dancing Links。


6 例题

题号 题名 难度 题解
BISHI76 迷宫寻路 简单 题解
BISHI77 数水坑 简单 题解
BISHI78 全排列 简单 题解
BISHI79 取数游戏 中等 题解
BISHI83 迷宫问题 中等 题解
BM55 没有重复项数字的全排列 中等 题解
BM56 有重复项数字的全排列 中等 题解
BM58 字符串的排列 中等 题解
BM59 N皇后问题 较难 题解
BM60 括号生成 中等 题解
LC22 括号生成 中等 题解
LC17 电话号码的字母组合 中等 题解
LC39 组合总和 中等 题解
LC46 全排列 中等 题解
LC51 N 皇后 困难 题解
LC78 子集 中等 题解
LC79 单词搜索 中等 题解
LC131 分割回文串 中等 题解

BISHI76 迷宫寻路(简单)

\(n \times m \le 100 \times 100\) 的迷宫,. 是空地、# 是墙,四连通移动。 问能否从左上角 \((1,1)\) 走到右下角 \((n,m)\)。输出 Yes / No。 题面见 原题。 题解见 solutions/nowcoder/BISHI76/sol.py(已用官方样例验证)。

最裸的连通性判定。只问「能不能到」,不问步数,所以 DFS / BFS / 并查集都行, 本质是一次泛洪填充。

Python 的关键判断:\(nm = 10^4\),最坏情况(蛇形通道)递归深度就是 \(10^4\) 层, 远超默认的 1000。所以这里必须写显式栈。

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    g = data[2:2 + n]                       # 每行一个 bytes,g[i][j] 取出来是 int

    WALL = ord('#')                         # 在 bytes 上索引得到 int,所以取字符的码值
    if g[0][0] == WALL or g[n - 1][m - 1] == WALL:
        sys.stdout.write("No\n")             # 起点或终点是墙,连搜都不用搜
        return

    vis = [bytearray(m) for _ in range(n)]  # 每格 1 字节
    vis[0][0] = 1                           # 起点压栈之前先标记
    stack = [(0, 0)]                        # 显式栈,深度不受解释器限制
    ok = False
    while stack:
        x, y = stack.pop()
        if x == n - 1 and y == m - 1:       # 出栈时判终点,n = m = 1 时第一次就命中
            ok = True
            break                           # 只问能否到达,找到就可以停
        for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
            # 先判越界再索引:and 的短路保证不会拿非法下标去取 vis 和 g
            if 0 <= nx < n and 0 <= ny < m and not vis[nx][ny] and g[nx][ny] != WALL:
                vis[nx][ny] = 1             # 入栈即标记
                stack.append((nx, ny))
    sys.stdout.write("Yes\n" if ok else "No\n")


main()

复杂度 \(O(nm) = 10^4\),毫秒级。

三个坑:

  1. \(n = m = 1\) 时起点就是终点,答案是 Yes。上面的代码在第一次 pop 时 就会命中 x == n-1 and y == m-1,天然正确——但如果把判定写在「入栈时」 就会漏掉起点;
  2. 迷宫每行是不含空格的字符串,split() 按 token 读正好一行一个, 不需要按行读;
  3. 在 bytes 上索引得到的是 int 不是 str,所以要拿 ord('#') 去比。 这是 bytes 处理的通用注意点,见 字符串处理。

BISHI77 数水坑(简单)

\(N \times M \le 100 \times 100\) 的田地,W 是水、. 是干地。 八连通的水格属于同一个水坑。求水坑数量。 题面见 原题。 题解见 solutions/nowcoder/BISHI77/sol.py(已用官方样例验证)。

连通块计数模板。唯一和常规题不同的是八连通—— 方向数组写成 4 个,会把斜着搭在一起的水格错拆成两块。

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    g = data[2:2 + n]
    W = ord('W')

    vis = [bytearray(m) for _ in range(n)]
    cnt = 0
    for si in range(n):
        row = g[si]                         # 把本行绑成局部名,内层少一次二维索引
        vrow = vis[si]
        for sj in range(m):
            if row[sj] != W or vrow[sj]:
                continue                    # 不是水,或已属于前面数过的水坑
            cnt += 1                        # 发现一个新水坑
            vrow[sj] = 1
            stack = [(si, sj)]              # 显式栈:一个水坑最多 1e4 格,递归必崩
            while stack:
                x, y = stack.pop()
                for dx in (-1, 0, 1):       # 八连通 = 3x3 邻域去掉自己
                    nx = x + dx
                    if nx < 0 or nx >= n:
                        continue            # 行号越界,这一整行的 3 个方向一起跳过
                    gr, vr = g[nx], vis[nx]  # 取一次行对象,内层 3 次列判断共用
                    for dy in (-1, 0, 1):
                        ny = y + dy
                        # dx = dy = 0 枚举到的是自己,但它已被标记,被 not vr[ny] 挡掉
                        if 0 <= ny < m and not vr[ny] and gr[ny] == W:
                            vr[ny] = 1      # 入栈即标记,防止重复入栈
                            stack.append((nx, ny))
    sys.stdout.write("%d\n" % cnt)


main()

复杂度 \(O(8NM)\),\(10^4\) 个格子,毫秒级。

两个坑:

  1. 八连通用 dx, dy 双重循环枚举 \(3\times3\) 邻域(含 (0,0) 自己, 但自己已经被标记过,会被 not vr[ny] 挡掉),比写 8 个偏移量更短;
  2. 最坏情况整张图全是 W,一个连通块 \(10^4\) 个格子, 递归版必 RecursionError——这就是为什么这里用显式栈。

BISHI78 全排列(简单)

给定 \(n \le 9\),按字典序输出 \(1 \sim n\) 的所有排列,每行 \(n\) 个数用空格分隔。 题面见 原题。 题解见 solutions/nowcoder/BISHI78/sol.py(已用官方样例验证)。

DFS 回溯的入门模板题——但正解不是手写回溯。

\(n = 9\) 时有 \(9! = 362880\) 行,总输出约 6.5 MB, 真正的瓶颈在 IO,不在搜索。而 itertools.permutations 对已排好序的输入产出的顺序就是字典序,且循环在 C 层跑:

import sys
from itertools import permutations


def main():
    n = int(sys.stdin.buffer.read().split()[0])
    digits = [str(i) for i in range(1, n + 1)]      # 已升序 -> 排列即字典序;预转 str
    join = " ".join                                 # 绑成局部名,36 万次调用省下属性查找
    # 全部拼成一整块再一次写出:逐行 print 会把时间全花在 IO 上
    sys.stdout.write("\n".join(map(join, permutations(digits))) + "\n")


main()

三个坑:

  1. 绝对不能 for p in ...: print(p)——36 万次 print 会把时间全耗在 IO 上。 必须先 "\n".join 拼成一整块再一次 write;
  2. 预先把 1..n 转成 str,join 时就不用反复 map(str, ...);
  3. itertools.permutations 的字典序性质依赖输入本身有序。 如果输入是 [3,1,2],产出的顺序就不是字典序了,必须先 sorted。

手写回溯版(§2 的 permutations_dfs)在 \(n=9\) 时也能过 (时限「其他语言 6 秒」很宽),但慢 10 倍以上。 这题的教学价值恰恰在于:会写回溯 ≠ 应该用回溯。 递归深度只有 \(n \le 9\) 层,所以这题不存在递归深度问题—— 这是少数可以放心递归的场合。

BISHI79 取数游戏(中等)

\(T \le 20\) 组数据,每组给 \(N \times M \le 6 \times 6\) 的非负整数矩阵, 取出若干数使得任意两个数不八连通相邻,求最大和。 题面见 原题。 题解见 solutions/nowcoder/BISHI79/sol.py(已用官方样例验证)。

这题是「搜索 vs DP」的分水岭,值得细读。

朴素 DFS 逐格枚举「选 / 不选」是 \(2^{36}\),纯搜索必挂。加上剪枝呢? 可行性剪枝(不能和已选的相邻)能砍掉很多,但最坏情况仍然不可控。

正解是状压 DP(本质是「按行记忆化的搜索」)。关键观察:

八连通相邻只会发生在「同一行相邻列」和「相邻两行且列号差 \(\le 1\)」, 隔一行以上的两个格子永远不相邻。

于是把「本行选了哪些列」压成 \(M\) 位二进制 mask,行与行之间的约束只依赖前一行:

约束 判定式
行内不相邻 mask & (mask << 1) == 0
与上一行不八连通相邻 (m1 | (m1 << 1) | (m1 >> 1)) & m2 == 0

第二个式子很妙:把上一行的 mask 向左右各扩一位,一次与运算同时覆盖了 正上方和两个斜上方三种冲突。

import sys


def main():
    data = sys.stdin.buffer.read().split()
    p = 0
    t = int(data[p]); p += 1
    out = []
    for _ in range(t):
        n, m = int(data[p]), int(data[p + 1]); p += 2
        rows = []
        for _ in range(n):
            rows.append([int(v) for v in data[p:p + m]])
            p += m

        full = 1 << m                # m 位二进制,共 2^m 种「本行选法」
        masks = [s for s in range(full) if not (s & (s << 1))]   # 行内合法:无相邻两位同为 1
        spread = [s | (s << 1) | (s >> 1) for s in range(full)]  # 向左右各扩一位

        dp = [0] * full              # dp[mask]:上一行选 mask 时的最大和
        alive = [0]                  # 上一行取空集作为哨兵起点
        for r in range(n):
            row = rows[r]
            val = {}
            for s in masks:          # 预处理本行每个 mask 的权值和
                tot = 0
                x = s
                while x:
                    low = x & -x     # lowbit:取出 x 最低位的那个 1,见 46-位运算
                    tot += row[low.bit_length() - 1]   # 该 1 在第几位就加第几列的数
                    x ^= low         # 抹掉这一位,循环次数 = 1 的个数
                val[s] = tot
            ndp = [-1] * full        # 本行的新表,-1 表示这个 mask 无法从上一行到达
            for s2 in masks:
                best = -1
                for s1 in alive:
                    if spread[s1] & s2:
                        continue     # 扩位后仍相交 = 正上方或斜上方冲突
                    if dp[s1] > best:
                        best = dp[s1]
                if best >= 0:        # 至少存在一个合法的上一行搭配
                    ndp[s2] = best + val[s2]
            dp = ndp
            alive = [s for s in masks if dp[s] >= 0]   # 只留可达的 mask,下一行少枚举
        out.append(str(max(dp)))
    sys.stdout.write("\n".join(out) + "\n")


main()

\(M = 6\) 时子集只有 64 个,行内合法的只有 21 个(是斐波那契数 \(F_8\))。 复杂度 \(O(T \cdot N \cdot 21^2) < 6\times10^4\),瞬间出结果。

三个坑:

  1. 多组数据每组都要重置 dp,别把上一组的结果带进来;
  2. mask << 1 可能超出 \(M\) 位,但与 mask2 相与时高位天然是 0,无害; mask >> 1 在 Python 里对非负整数完全安全;
  3. alive 列表用来跳过「上一行不可达的 mask」(dp == -1), 否则会把 \(-1\) 当成合法值传下去。

这题给出的判据:搜索题看到「\(N, M \le 6\)」这种小到离谱的范围, 通常意味着要按某一维压缩状态,而不是真的要暴力搜。 详见 状压 DP。

BISHI83 迷宫问题(中等)

\(h \times w \le 100 \times 100\) 的 01 迷宫(0 空地、1 墙), 输出一条从 \((0,0)\) 到 \((h-1,w-1)\) 的可行路径,每行 (x,y)。 保证可行路径存在且唯一。 题面见 原题。 题解见 solutions/nowcoder/BISHI83/sol.py(已用官方样例验证,用的是 BFS 版)。

这题是「输出路径」类问题的模板。核心技巧:前驱数组 pre[]。

不管是 DFS 还是 BFS,只要在「第一次访问 \(v\)」时记下 pre[v] = u, 最后从终点顺着 pre 一路倒推回起点,再 reverse 就是路径。

这里给 DFS(显式栈)版本,和 solutions/ 里的 BFS 版互为对照 (题目保证路径唯一,所以两者输出相同):

import sys


def main():
    data = sys.stdin.buffer.read().split()
    h, w = int(data[0]), int(data[1])
    g = data[2:2 + h * w]                 # b'0' / b'1',按 token 读

    WALL = b'1'                           # g 的元素是一个个 bytes token,直接与 b'1' 比
    n = h * w                             # 二维压一维:下标 = x * w + y
    pre = [-2] * n                        # -2 未访问,-1 起点,其余为前驱下标
    start, goal = 0, n - 1                # (0,0) 与 (h-1,w-1) 压一维后的下标
    pre[start] = -1                       # 起点前驱设 -1,倒推时用它当终止条件
    stack = [start]                       # ★ 显式栈:路径最长 1e4,递归必崩
    while stack:
        u = stack.pop()
        if u == goal:
            break                         # 题目保证路径唯一,摸到终点就可以收工
        x, y = divmod(u, w)               # 还原行列,只用于判四个方向是否越界
        if x > 0:                         # 不在第 0 行才能往上走
            v = u - w
            if pre[v] == -2 and g[v] != WALL:
                pre[v] = u; stack.append(v)   # ★ 入栈即记前驱,pre 同时充当 vis
        if x + 1 < h:                     # 不在最后一行才能往下走
            v = u + w
            if pre[v] == -2 and g[v] != WALL:
                pre[v] = u; stack.append(v)
        if y > 0:                         # 不在第 0 列才能往左走
            v = u - 1
            if pre[v] == -2 and g[v] != WALL:
                pre[v] = u; stack.append(v)
        if y + 1 < w:                     # 不在最后一列才能往右走
            v = u + 1
            if pre[v] == -2 and g[v] != WALL:
                pre[v] = u; stack.append(v)

    path = []
    u = goal
    while u != -1:                        # 从终点顺着 pre 倒推回起点,-1 是起点标志
        x, y = divmod(u, w)
        path.append("(%d,%d)" % (x, y))
        u = pre[u]
    path.reverse()                        # ★ 倒推出来是「终点 -> 起点」,翻回正序
    sys.stdout.write("\n".join(path) + "\n")


main()

四个坑:

  1. pre 数组一身兼两职:-2 当「未访问」标记,其余值当前驱。 省掉一个 vis 数组,也少一次数组访问;
  2. 二维压成一维(idx = x * w + y),用 divmod 还原。 一维数组比 list of list 快,而且前驱只需存一个整数;
  3. 输出格式以样例为准。题面「输出描述」写的是「输出两个整数 \(x_i, y_i\)」, 但样例输出是带括号无空格的 (x,y)。样例和文字描述冲突时,永远信样例;
  4. 回溯出来的路径是「终点 → 起点」,最后必须 reverse。

DFS 找路径 vs BFS 找路径: - 题目问「任意一条路径」→ 两者都行,DFS 常数略小; - 题目问「最短路径」→ 必须 BFS,DFS 找到的路径不保证最短 (见 BFS广度优先搜索); - 题目问「所有路径」→ 回溯(要撤销 vis),注意可能指数爆炸。

这题因为「保证路径唯一」,三种说法碰巧一致, 所以是一道很好的对照练习题。


面试题单里的六道回溯题

六道题全落在 §2 的模板上,差别只在「候选集怎么选」和「怎么去重」。

排列型。 没有重复项数字的全排列是 used[] 模板的原样形态。 有重复项数字的全排列多一步同层去重:先排序, 然后在同一层里跳过「与前一个相同、且前一个尚未被使用」的候选。 used[i-1] == False 这个条件容易写反——它表示前一个相同值是在本层被撤销的, 说明这条分支已经枚举过了;若前一个仍在使用中,那是在上层选走的,属于合法的不同排列。 字符串的排列是同一份代码换个输出类型,去重逻辑一字不改。

组合与剪枝。 括号生成(同一道题在另一处题库里编号 LC 22. 括号生成) 枚举长度 \(2n\) 的括号串,候选只有两个:放左括号(还有剩就能放)、 放右括号(已放的右括号数严格小于左括号数才能放)。 把这两条写成 ok() 里的判断,生成的串天然全部合法,一个都不用事后过滤—— 这是剪枝的最佳形态:非法分支根本不进入搜索树。

N皇后问题是回溯的压轴题。逐行放置,列与两条对角线各开一个标记数组 (对角线下标用 r + c 与 r - c + n),三个数组同时做「做选择 / 撤销选择」。 \(n \le 9\) 时这份直白写法完全够用;把标记换成三个整数的位运算是常数优化, 思路见 位运算 的子集枚举一节。

泛洪填充。 BM57 岛屿数量(同一道题在另一处题库里编号 LC200) 是 §3 连通块的原样形态:扫一遍网格,遇到未访问的陆地就从它出发把整块染掉, 染色次数就是答案。用 DFS 还是 BFS 都行, 但 Python 里递归深度可能达到网格总格数,\(10^4\) 以上就要按 §4 改成显式栈, 或者直接换成 BFS 广度优先搜索 的队列写法。

另一处题库里的七道回溯题

回溯题的差别几乎全落在「下一层从哪里开始枚举」这一个参数上,模板本身不变。 按那个参数分四组,组内的代码只剩三五行的出入。

下一层从 0 开始:各层之间互不影响。 LC 17. 电话号码的字母组合每一层对应一个数字,枚举它映射到的三到四个字母, 深度等于输入长度 \(L\),方案数最多 \(4^L\);\(L \le 4\) 时总量在 \(256\) 以内。 LC 46. 全排列与 BISHI78 是同一道题,每层枚举所有还没用过的数,靠一个布尔数组避免重取。

下一层从 i 开始:同一个数可以重复取。 LC 39. 组合总和允许同一个候选数被选任意多次, 所以进入下一层时起点仍是当前下标;起点写成 i + 1 就变成了「每个数最多用一次」, 这一个字符就是两道不同题的分界。 剪枝有两条:候选数先排序,一旦当前和加上候选数已经超过目标就整段停止枚举。

下一层从 i + 1 开始:枚举子集与切分点。 LC 78. 子集每一层做一次「取或不取」的选择,等价于起点单调右移,共 \(2^n\) 个方案。 LC 131. 分割回文串把「取到哪」当成切分点:枚举右端点, 判断这一段是不是回文,是就切下来递归剩下的部分。 逐次朴素判回文会让整体退化到 \(O(n \cdot 2^n)\), 先用一遍 \(O(n^2)\) 的区间动态规划把「\([i, j]\) 是不是回文」全预处理出来,每次判断降到 \(O(1)\)。

在网格或棋盘上回溯:撤销比选择更容易写错。 LC 79. 单词搜索从每个格子出发四向搜索,走过的格子先标记、递归返回后必须清掉标记, 否则同一条路径上的格子会被后续分支误判成占用。 \(m \times n\) 个起点、每步最多三个方向(来的方向不再走), 串长为 \(L\) 时上界 \(O(mn \cdot 3^L)\)。 LC 51. N 皇后按行放子,列与两条对角线各用一个集合记占用, 对角线的键分别取 r - c 与 r + c;同样是「进入时加入、返回时移除」。 \(n \le 9\) 时可行解不到 \(400\) 个,搜索空间被这三个集合剪得很小。

7 本章速查

要点 结论
DFS 三件套 vis 标记 + 后继枚举 + 出口
vis 不撤销 遍历:连通块、可达性
vis 撤销 枚举:回溯、所有路径
回溯三件套 选择 → 递归 → 撤销
存答案 必须 path[:] 复制,否则全是同一个引用
全排列/组合 优先 itertools,快 10–30×
手写回溯的价值 只有在需要中途剪枝时才不可替代
入栈时机 入栈即标记 vis,不是出栈才标记
默认递归上限 1000
setrecursionlimit 只改软计数器,不给栈空间
主线程物理栈 Windows 1 MB ≈ 2500 层;Linux 8 MB ≈ 2 万层
threading.stack_size(1<<26) 64 MB ≈ 7 万层;必须在建 Thread 之前调用
崩栈的表现 无 traceback 的静默崩溃,最难调
深度 \(\le 900\) 放心递归
深度 \(\ge 10^5\) 必须改迭代
深度未知 按最坏(链/蛇形)算,即 \(O(n)\)
纯前序 DFS 改写 显式栈,注意访问顺序会反
后序 DFS 改写 两趟法:reversed(DFS 序) 就是后序
进入+退出都要 事件栈 (node, stage) 或迭代器栈
输出路径 pre[] 前驱数组 + 倒推 + reverse
网格题 二维压一维 + 四周哨兵,省边界判断
看到什么 → 想到 DFS
「有多少个连通块 / 岛屿 / 水坑」
「能否从 A 到 B」(不问步数)
「输出所有方案 / 全排列 / 子集」
「树上每个子树的 XXX」→ 两趟法后序
「\(n \le 20\),枚举所有情况」
「棋盘上放 K 个互不攻击的棋子」→ 回溯 + 剪枝,或状压 DP
看到什么 → 不要用 DFS
「最少步数 / 最短路径」→ BFS(BFS 广度优先搜索)
「所有方案数」且规模大 → DP(DP 入门)
深度可能上 \(10^5\) 又必须后序 → 两趟法,不是递归