【LeetCode】从递归到 Flood Fill:5 道题吃透 DFS 的选择、回溯与标记

文章目录

  • [一、写题之前:先真正理解递归、DFS 与回溯](#一、写题之前:先真正理解递归、DFS 与回溯)
    • [1.1 递归不是"自己调用自己"这么简单](#1.1 递归不是“自己调用自己”这么简单)
    • [1.2 什么是递归出口](#1.2 什么是递归出口)
    • [1.3 DFS:先沿着一条路走到底](#1.3 DFS:先沿着一条路走到底)
    • [1.4 回溯:返回时把现场恢复](#1.4 回溯:返回时把现场恢复)
    • [1.5 为什么保存答案时要复制一份集合](#1.5 为什么保存答案时要复制一份集合)
    • [1.6 什么时候恢复状态,什么时候不恢复](#1.6 什么时候恢复状态,什么时候不恢复)
  • [二、LeetCode 39:组合总和------看懂选择、递归和撤销](#二、LeetCode 39:组合总和——看懂选择、递归和撤销)
    • [2.1 先把题目翻译成大白话](#2.1 先把题目翻译成大白话)
    • [2.2 最直接的想法会遇到什么问题](#2.2 最直接的想法会遇到什么问题)
    • [2.3 递归状态怎样设计](#2.3 递归状态怎样设计)
    • [2.4 用一个小样例手工走一遍](#2.4 用一个小样例手工走一遍)
    • [2.5 对照我的完整代码](#2.5 对照我的完整代码)
    • [2.6 常见错误与复杂度](#2.6 常见错误与复杂度)
  • [三、LeetCode 22:括号生成------不是所有分支都值得进入](#三、LeetCode 22:括号生成——不是所有分支都值得进入)
    • [3.1 什么叫"有效括号"](#3.1 什么叫“有效括号”)
    • [3.2 为什么不先生成所有字符串](#3.2 为什么不先生成所有字符串)
    • [3.3 状态设计](#3.3 状态设计)
    • [3.4 手工推演 `n = 2`](#3.4 手工推演 n = 2)
    • [3.5 对照我的代码](#3.5 对照我的代码)
    • [3.6 常见错误与代码细节](#3.6 常见错误与代码细节)
  • [四、LeetCode 98:验证二叉搜索树------把树转化成递增序列](#四、LeetCode 98:验证二叉搜索树——把树转化成递增序列)
    • [4.1 先理解二叉搜索树的规则](#4.1 先理解二叉搜索树的规则)
    • [4.2 中序遍历为什么能够解决问题](#4.2 中序遍历为什么能够解决问题)
    • [4.3 我们需要记录什么](#4.3 我们需要记录什么)
    • [4.4 手工跟踪一次递归](#4.4 手工跟踪一次递归)
    • [4.5 对照我的代码](#4.5 对照我的代码)
    • [4.6 为什么使用 `long` 保存前驱值](#4.6 为什么使用 long 保存前驱值)
    • [4.7 常见错误与复杂度](#4.7 常见错误与复杂度)
  • [五、LeetCode 200:岛屿数量 ------ 一次 DFS 为什么等于一座岛](#五、LeetCode 200:岛屿数量 —— 一次 DFS 为什么等于一座岛)
    • [5.1 把二维数组想成一张地图](#5.1 把二维数组想成一张地图)
    • [5.2 为什么发现一块新陆地就让答案加一](#5.2 为什么发现一块新陆地就让答案加一)
    • [5.3 方向数组是什么意思](#5.3 方向数组是什么意思)
    • [5.4 手工模拟一座小岛](#5.4 手工模拟一座小岛)
    • [5.5 对照我的代码](#5.5 对照我的代码)
    • [5.6 为什么这里不需要回溯](#5.6 为什么这里不需要回溯)
    • [5.7 常见错误与复杂度](#5.7 常见错误与复杂度)
  • [六、LeetCode 130:被围绕的区域------正着难,就反着找](#六、LeetCode 130:被围绕的区域——正着难,就反着找)
    • [6.1 题目真正要求判断什么](#6.1 题目真正要求判断什么)
    • [6.2 从每个内部区域出发为什么比较麻烦](#6.2 从每个内部区域出发为什么比较麻烦)
    • [6.3 三阶段处理法](#6.3 三阶段处理法)
    • [6.4 用题目样例一步步变化](#6.4 用题目样例一步步变化)
    • [6.5 对照我的代码](#6.5 对照我的代码)
    • [6.6 常见错误与复杂度](#6.6 常见错误与复杂度)
  • 七、把五道题放到一起,我们究竟学会了什么
    • [7.1 每道题的"当前状态"不同](#7.1 每道题的“当前状态”不同)
    • [7.2 判断是否需要回溯的简单方法](#7.2 判断是否需要回溯的简单方法)
    • [7.3 三种减少无效搜索的方法](#7.3 三种减少无效搜索的方法)
    • [7.4 一份适合初学者的思考清单](#7.4 一份适合初学者的思考清单)
  • 八、总结

刷算法题时,很多小伙伴都会卡在"我能看懂答案,但自己想不出来"这一步。

尤其是遇到递归代码时,我们明明认识每一行 Java 语句,却不知道程序为什么会不断进入 dfs(),又为什么在递归结束后删除刚刚加入的元素。组合题是一种写法,二叉树是一种写法,二维网格似乎又变成了另一套知识。

其实,它们背后一直在重复同一件事:从当前状态出发,向下一步探索;遇到终点就返回;如果当前选择还要让给其他分支使用,就在返回时恢复现场。

这篇文章会从零开始,把这个过程拆开讲清楚。我们从我的 Java 算法练习中选出 5 道已经通过 LeetCode 测试的代表题:

题目 核心方法 初学者要解决的问题
39. 组合总和 回溯、剪枝 为什么要传 start,为什么递归后要删除元素
22. 括号生成 回溯、状态约束 怎样在生成过程中保证括号始终可能合法
98. 验证二叉搜索树 DFS、中序遍历 怎样把一棵树转化成容易判断的递增序列
200. 岛屿数量 DFS、Flood Fill 为什么一次 DFS 就能找到一整座岛屿
130. 被围绕的区域 反向思考、Flood Fill 为什么从边界开始比从内部开始更简单

如果你以前对递归、DFS 或回溯比较陌生,也不用着急。我们先不看具体题目,从一个生活场景开始。

一、写题之前:先真正理解递归、DFS 与回溯

1.1 递归不是"自己调用自己"这么简单

我们经常听到一句定义:递归就是方法自己调用自己。这句话没有错,但只记住这句话,还不足以帮助我们读懂算法。

更准确地说,递归有两个阶段:

  1. 向下进入:当前方法暂时没有执行完,先调用下一层方法。
  2. 向上返回:下一层执行结束,程序回到当前层,继续执行调用语句后面的代码。

先看一个最小例子:

java 复制代码
public void printNumber(int n) {
    if (n == 0) {
        return;
    }
    System.out.println("进入:" + n);
    printNumber(n - 1);
    System.out.println("返回:" + n);
}

调用 printNumber(3) 时,预期输出为:

text 复制代码
进入:3
进入:2
进入:1
返回:1
返回:2
返回:3

为什么"返回"部分是倒序的?因为每一层方法都在等待下一层结束:

text 复制代码
printNumber(3)
└─ 等待 printNumber(2)
   └─ 等待 printNumber(1)
      └─ printNumber(0) 触发出口并返回
      └─ printNumber(1) 继续执行后半段
   └─ printNumber(2) 继续执行后半段
└─ printNumber(3) 继续执行后半段

这个"等待中的方法信息"会保存在调用栈中。每一层都有属于自己的参数和局部变量,因此不同递归层中的 n 并不是同一个值。

初学递归时,不要只盯着方法定义看。把每一次调用都看成一次全新的方法执行,并在纸上写出它收到的参数,代码就会清楚很多。

1.2 什么是递归出口

递归必须有一个能够停止继续调用的条件,这就是递归出口,也叫终止条件或基础情况(Base Case)。

上面例子中的出口是:

java 复制代码
if (n == 0) {
    return;
}

没有出口,方法就会一直调用下去,直到调用栈空间耗尽并出现 StackOverflowError

不同题目的出口不一样:

  • 组合总和:当前数字之和已经等于或超过目标值。
  • 括号生成:已经放完 n 对括号。
  • 二叉树:当前节点为 null
  • 网格搜索:坐标越界、遇到水或者遇到已经访问的格子。

所以写递归之前,我们应该先问自己:什么情况下,这条路已经不需要继续走了?

1.3 DFS:先沿着一条路走到底

DFS 的全称是 Depth-First Search,也就是深度优先搜索。

假设我们正在走迷宫,面前有多条岔路。DFS 的做法不是每条路都先走一步,而是选择其中一条,一直走到终点或死路,再退回最近的岔路口尝试其他方向。
#mermaid-svg-PSpeAwJUMm7jtVwl{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PSpeAwJUMm7jtVwl .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PSpeAwJUMm7jtVwl .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PSpeAwJUMm7jtVwl .error-icon{fill:#552222;}#mermaid-svg-PSpeAwJUMm7jtVwl .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PSpeAwJUMm7jtVwl .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PSpeAwJUMm7jtVwl .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PSpeAwJUMm7jtVwl .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PSpeAwJUMm7jtVwl .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PSpeAwJUMm7jtVwl .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PSpeAwJUMm7jtVwl .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PSpeAwJUMm7jtVwl .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PSpeAwJUMm7jtVwl .marker.cross{stroke:#333333;}#mermaid-svg-PSpeAwJUMm7jtVwl svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PSpeAwJUMm7jtVwl p{margin:0;}#mermaid-svg-PSpeAwJUMm7jtVwl .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-PSpeAwJUMm7jtVwl .cluster-label text{fill:#333;}#mermaid-svg-PSpeAwJUMm7jtVwl .cluster-label span{color:#333;}#mermaid-svg-PSpeAwJUMm7jtVwl .cluster-label span p{background-color:transparent;}#mermaid-svg-PSpeAwJUMm7jtVwl .label text,#mermaid-svg-PSpeAwJUMm7jtVwl span{fill:#333;color:#333;}#mermaid-svg-PSpeAwJUMm7jtVwl .node rect,#mermaid-svg-PSpeAwJUMm7jtVwl .node circle,#mermaid-svg-PSpeAwJUMm7jtVwl .node ellipse,#mermaid-svg-PSpeAwJUMm7jtVwl .node polygon,#mermaid-svg-PSpeAwJUMm7jtVwl .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-PSpeAwJUMm7jtVwl .rough-node .label text,#mermaid-svg-PSpeAwJUMm7jtVwl .node .label text,#mermaid-svg-PSpeAwJUMm7jtVwl .image-shape .label,#mermaid-svg-PSpeAwJUMm7jtVwl .icon-shape .label{text-anchor:middle;}#mermaid-svg-PSpeAwJUMm7jtVwl .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-PSpeAwJUMm7jtVwl .rough-node .label,#mermaid-svg-PSpeAwJUMm7jtVwl .node .label,#mermaid-svg-PSpeAwJUMm7jtVwl .image-shape .label,#mermaid-svg-PSpeAwJUMm7jtVwl .icon-shape .label{text-align:center;}#mermaid-svg-PSpeAwJUMm7jtVwl .node.clickable{cursor:pointer;}#mermaid-svg-PSpeAwJUMm7jtVwl .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-PSpeAwJUMm7jtVwl .arrowheadPath{fill:#333333;}#mermaid-svg-PSpeAwJUMm7jtVwl .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-PSpeAwJUMm7jtVwl .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-PSpeAwJUMm7jtVwl .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PSpeAwJUMm7jtVwl .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-PSpeAwJUMm7jtVwl .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PSpeAwJUMm7jtVwl .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-PSpeAwJUMm7jtVwl .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-PSpeAwJUMm7jtVwl .cluster text{fill:#333;}#mermaid-svg-PSpeAwJUMm7jtVwl .cluster span{color:#333;}#mermaid-svg-PSpeAwJUMm7jtVwl div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-PSpeAwJUMm7jtVwl .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-PSpeAwJUMm7jtVwl rect.text{fill:none;stroke-width:0;}#mermaid-svg-PSpeAwJUMm7jtVwl .icon-shape,#mermaid-svg-PSpeAwJUMm7jtVwl .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PSpeAwJUMm7jtVwl .icon-shape p,#mermaid-svg-PSpeAwJUMm7jtVwl .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-PSpeAwJUMm7jtVwl .icon-shape .label rect,#mermaid-svg-PSpeAwJUMm7jtVwl .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PSpeAwJUMm7jtVwl .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-PSpeAwJUMm7jtVwl .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-PSpeAwJUMm7jtVwl :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 起点
选择第一条路
选择第二条路
继续深入
另一种选择
到达终点或死路

DFS 描述的是"先深入,再返回"的搜索顺序。它可以用于树、图、二维网格,也可以用于枚举组合和排列。

1.4 回溯:返回时把现场恢复

回溯通常建立在 DFS 之上。它最典型的三个动作是:

  1. 做出一个选择;
  2. 带着新状态进入下一层;
  3. 下一层返回后,撤销刚才的选择。

#mermaid-svg-au9FXjWgva1H8MxH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-au9FXjWgva1H8MxH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-au9FXjWgva1H8MxH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-au9FXjWgva1H8MxH .error-icon{fill:#552222;}#mermaid-svg-au9FXjWgva1H8MxH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-au9FXjWgva1H8MxH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-au9FXjWgva1H8MxH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-au9FXjWgva1H8MxH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-au9FXjWgva1H8MxH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-au9FXjWgva1H8MxH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-au9FXjWgva1H8MxH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-au9FXjWgva1H8MxH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-au9FXjWgva1H8MxH .marker.cross{stroke:#333333;}#mermaid-svg-au9FXjWgva1H8MxH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-au9FXjWgva1H8MxH p{margin:0;}#mermaid-svg-au9FXjWgva1H8MxH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-au9FXjWgva1H8MxH .cluster-label text{fill:#333;}#mermaid-svg-au9FXjWgva1H8MxH .cluster-label span{color:#333;}#mermaid-svg-au9FXjWgva1H8MxH .cluster-label span p{background-color:transparent;}#mermaid-svg-au9FXjWgva1H8MxH .label text,#mermaid-svg-au9FXjWgva1H8MxH span{fill:#333;color:#333;}#mermaid-svg-au9FXjWgva1H8MxH .node rect,#mermaid-svg-au9FXjWgva1H8MxH .node circle,#mermaid-svg-au9FXjWgva1H8MxH .node ellipse,#mermaid-svg-au9FXjWgva1H8MxH .node polygon,#mermaid-svg-au9FXjWgva1H8MxH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-au9FXjWgva1H8MxH .rough-node .label text,#mermaid-svg-au9FXjWgva1H8MxH .node .label text,#mermaid-svg-au9FXjWgva1H8MxH .image-shape .label,#mermaid-svg-au9FXjWgva1H8MxH .icon-shape .label{text-anchor:middle;}#mermaid-svg-au9FXjWgva1H8MxH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-au9FXjWgva1H8MxH .rough-node .label,#mermaid-svg-au9FXjWgva1H8MxH .node .label,#mermaid-svg-au9FXjWgva1H8MxH .image-shape .label,#mermaid-svg-au9FXjWgva1H8MxH .icon-shape .label{text-align:center;}#mermaid-svg-au9FXjWgva1H8MxH .node.clickable{cursor:pointer;}#mermaid-svg-au9FXjWgva1H8MxH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-au9FXjWgva1H8MxH .arrowheadPath{fill:#333333;}#mermaid-svg-au9FXjWgva1H8MxH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-au9FXjWgva1H8MxH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-au9FXjWgva1H8MxH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-au9FXjWgva1H8MxH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-au9FXjWgva1H8MxH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-au9FXjWgva1H8MxH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-au9FXjWgva1H8MxH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-au9FXjWgva1H8MxH .cluster text{fill:#333;}#mermaid-svg-au9FXjWgva1H8MxH .cluster span{color:#333;}#mermaid-svg-au9FXjWgva1H8MxH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-au9FXjWgva1H8MxH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-au9FXjWgva1H8MxH rect.text{fill:none;stroke-width:0;}#mermaid-svg-au9FXjWgva1H8MxH .icon-shape,#mermaid-svg-au9FXjWgva1H8MxH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-au9FXjWgva1H8MxH .icon-shape p,#mermaid-svg-au9FXjWgva1H8MxH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-au9FXjWgva1H8MxH .icon-shape .label rect,#mermaid-svg-au9FXjWgva1H8MxH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-au9FXjWgva1H8MxH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-au9FXjWgva1H8MxH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-au9FXjWgva1H8MxH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

没有更多选择
进入当前状态
是否到达递归出口
保存答案或直接返回
枚举当前可以做的选择
做出选择
递归进入下一层
撤销选择并恢复现场
返回上一层

常见模板如下:

java 复制代码
void dfs(当前状态) {
    if (满足结束条件) {
        保存答案;
        return;
    }

    for (当前层的每个可选项) {
        做出选择;
        dfs(下一层状态);
        撤销选择;
    }
}

例如,当前路径是 [2, 2],我们添加数字 3 后得到 [2, 2, 3]。这条路搜索结束以后,要删除末尾的 3,恢复成 [2, 2],才能继续尝试添加 67

如果不删除,上一条路径中的数据就会混进下一条路径。

1.5 为什么保存答案时要复制一份集合

回溯代码中经常出现:

java 复制代码
list.add(new ArrayList<>(ret));

初学者很容易写成:

java 复制代码
list.add(ret);

ret 在整个搜索过程中会被不断添加和删除。如果直接保存 ret,结果集合中保存的只是同一个对象的多个引用。后面修改 ret 时,之前保存的结果也会跟着变化。

new ArrayList<>(ret) 的作用是拍一张"当前路径快照"。之后再修改 ret,已经保存的答案不会受到影响。

1.6 什么时候恢复状态,什么时候不恢复

这是回溯题和连通区域题之间最容易混淆的地方。

  • 组合、排列、括号等路径题需要恢复状态:同一个元素或位置还要参与其他分支。
  • 岛屿等连通分量题通常不恢复访问状态:一个格子一旦被归入某座岛屿,就不能再次算进另一座岛屿。

接下来,我们带着这些概念进入第一道题。

二、LeetCode 39:组合总和------看懂选择、递归和撤销

2.1 先把题目翻译成大白话

题目给出一个没有重复数字的数组 candidates,以及一个目标值 target。我们要从数组中选择若干个数字,让它们的和等于目标值。

需要注意两条规则:

  1. 同一个数字可以重复使用。
  2. [2,2,3][3,2,2] 只是顺序不同,只能保留其中一种。

例如:

text 复制代码
candidates = [2, 3, 6, 7]
target = 7

有效结果是:

text 复制代码
[2, 2, 3]
[7]

2.2 最直接的想法会遇到什么问题

我们可以从数组中不断选数:先选 2,再选 2,再选 3,总和正好等于 7

但如果每一层都从数组第一个元素重新开始,那么程序还会生成:

text 复制代码
[2, 3, 2]
[3, 2, 2]

它们与 [2,2,3] 是同一种组合。问题不在于不能搜索到答案,而在于会搜索出大量重复答案。

解决方法是规定搜索方向:一条路径中的数字只能从当前下标向后选择,不能再回头选择前面的数字。

于是我们需要一个参数 start,表示当前层从数组的哪个位置开始枚举。

2.3 递归状态怎样设计

状态 含义 为什么需要
ret 当前已经选择的数字 到达目标值时,用它保存答案
count 当前选择数字的总和 判断是否达到或超过 target
start 当前层允许选择的起始下标 防止产生顺序不同的重复组合
list 所有最终答案 方法最后需要返回它

递归出口有两个:

java 复制代码
if (count > n) return;

说明当前和已经超过目标值。题目中的候选数都是正数,继续添加只会越来越大,因此这条路不可能再得到答案。

java 复制代码
if (count == n) {
    list.add(new ArrayList<>(ret));
    return;
}

说明当前路径正好组成目标值。我们复制并保存当前路径,然后结束这条分支。

2.4 用一个小样例手工走一遍

为了不让搜索树太大,我们使用:

text 复制代码
candidates = [2, 3]
target = 5

第一次调用:

text 复制代码
dfs(candidates, count = 0, start = 0)
ret = []

程序从下标 0 开始,先选择数字 2

text 复制代码
ret = [2]
count = 2
start = 0

为什么下一层 start 仍然是 0?因为题目允许数字 2 重复使用。

下一层再次选择 2

text 复制代码
ret = [2, 2]
count = 4

再选择 2

text 复制代码
ret = [2, 2, 2]
count = 6

此时 count > target,返回上一层。返回后执行:

java 复制代码
ret.remove(ret.size() - 1);

路径从 [2,2,2] 恢复为 [2,2]。接着当前层尝试数字 3

text 复制代码
ret = [2, 2, 3]
count = 7

仍然超过目标,再次返回并删除 3

当程序回到 ret = [2] 的那一层后,再选择 3

text 复制代码
ret = [2, 3]
count = 5

这时得到答案并保存 [2,3]

整个关键过程可以写成:

当前路径 当前和 动作
[] 0 选择 2
[2] 2 再次选择 2
[2,2] 4 选择 2,和超过 5
[2,2,2] 6 返回,删除末尾 2
[2,2] 4 选择 3,和超过 5
[2,2,3] 7 返回并逐层恢复
[2] 2 选择 3
[2,3] 5 保存答案

2.5 对照我的完整代码

java 复制代码
class Solution {
    List<List<Integer>> list;
    List<Integer> ret;
    int n;

    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        list = new ArrayList<>();
        ret = new ArrayList<>();
        n = target;
        dfs(candidates, 0, 0);
        return list;
    }

    public void dfs(int[] candidates, int count, int start) {
        if (count > n) return;
        if (count == n) {
            list.add(new ArrayList<>(ret));
            return;
        }

        for (int i = start; i < candidates.length; i++) {
            ret.add(candidates[i]);
            dfs(candidates, count + candidates[i], i);
            ret.remove(ret.size() - 1);
        }
    }
}

核心三行正好对应回溯的三个动作:

java 复制代码
ret.add(candidates[i]);                       // 做选择
dfs(candidates, count + candidates[i], i);   // 进入下一层
ret.remove(ret.size() - 1);                   // 撤销选择

递归参数传 i 而不是 i + 1,允许当前数字重复选择;循环从 start 开始,保证下标不会倒退,从而避免重复组合。

从截图可以看到,这份实现通过了当时页面中的全部测试用例。运行时间与击败比例会受到平台环境影响,只能作为该次提交的记录。

2.6 常见错误与复杂度

常见错误:

  • 递归时传入 0:会生成顺序不同的重复组合。
  • 递归时传入 i + 1:每个数字只能选择一次,不符合本题规则。
  • 忘记删除 ret 最后一个元素:不同路径会互相污染。
  • 保存答案时直接添加 ret:最后可能得到多个相同引用或空集合。

组合搜索的分支数量会随目标值快速增长,因此时间复杂度通常用"指数级"描述。搜索深度最多约为 target / 最小候选数。当前路径和递归栈占用的空间也与搜索深度有关,不计算返回结果时可理解为 O ( t a r g e t / m i n ) O(target / min) O(target/min)。

三、LeetCode 22:括号生成------不是所有分支都值得进入

3.1 什么叫"有效括号"

题目给出括号对数 n,要求生成所有有效的括号组合。

n = 2 时,结果是:

text 复制代码
(())
()()

下面这些都不合法:

text 复制代码
)(    // 右括号出现在可以与它匹配的左括号之前
(()   // 左右括号数量不相等
())(  // 中间已经出现无法匹配的右括号

有效括号在构造过程中必须始终满足:

  1. 左括号数量不超过 n
  2. 任意前缀中,右括号数量不能超过左括号数量。
  3. 最终左右括号数量都等于 n

3.2 为什么不先生成所有字符串

长度为 2n 的每个位置都有两种选择:左括号或右括号。如果完全不判断合法性,就会生成 2 2 n 2^{2n} 22n 个字符串,再从中筛选答案。

但像 )( 这样的前缀,从第一个字符开始就已经无效,后面无论添加什么都无法补救。

所以我们不应该让它进入递归。回溯中的剪枝,很多时候就是在做选择之前判断这个选择还有没有成功的可能

3.3 状态设计

状态 含义
left 当前字符串中已经使用的左括号数量
right 当前字符串中已经使用的右括号数量
str 当前正在构造的括号字符串
m 需要生成的括号对数
list 所有有效结果

当前层有两种可能的选择:

java 复制代码
if (left < m) {
    // 可以添加左括号
}

左括号还没有用完时,可以继续添加。

java 复制代码
if (right < left) {
    // 可以添加右括号
}

只有右括号比左括号少时,才允许添加右括号。这保证任何时刻都存在尚未匹配的左括号。

3.4 手工推演 n = 2

初始状态:

text 复制代码
str = ""
left = 0
right = 0

第一步不能选择右括号,因为 right < left 不成立,只能添加左括号:

text 复制代码
str = "("
left = 1
right = 0

现在有两条路:

  • 再添加左括号,得到 "(("
  • 添加右括号,得到 "()"

先沿第一条路走:

text 复制代码
"" → "(" → "((" → "(()" → "(())"

得到 (()) 后返回。程序逐层删除刚才添加的字符,回到 "(",再选择右括号:

text 复制代码
"(" → "()" → "()(" → "()()"

最终得到两个答案。
#mermaid-svg-izAKiZMqBsVOHuYd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-izAKiZMqBsVOHuYd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-izAKiZMqBsVOHuYd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-izAKiZMqBsVOHuYd .error-icon{fill:#552222;}#mermaid-svg-izAKiZMqBsVOHuYd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-izAKiZMqBsVOHuYd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-izAKiZMqBsVOHuYd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-izAKiZMqBsVOHuYd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-izAKiZMqBsVOHuYd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-izAKiZMqBsVOHuYd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-izAKiZMqBsVOHuYd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-izAKiZMqBsVOHuYd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-izAKiZMqBsVOHuYd .marker.cross{stroke:#333333;}#mermaid-svg-izAKiZMqBsVOHuYd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-izAKiZMqBsVOHuYd p{margin:0;}#mermaid-svg-izAKiZMqBsVOHuYd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-izAKiZMqBsVOHuYd .cluster-label text{fill:#333;}#mermaid-svg-izAKiZMqBsVOHuYd .cluster-label span{color:#333;}#mermaid-svg-izAKiZMqBsVOHuYd .cluster-label span p{background-color:transparent;}#mermaid-svg-izAKiZMqBsVOHuYd .label text,#mermaid-svg-izAKiZMqBsVOHuYd span{fill:#333;color:#333;}#mermaid-svg-izAKiZMqBsVOHuYd .node rect,#mermaid-svg-izAKiZMqBsVOHuYd .node circle,#mermaid-svg-izAKiZMqBsVOHuYd .node ellipse,#mermaid-svg-izAKiZMqBsVOHuYd .node polygon,#mermaid-svg-izAKiZMqBsVOHuYd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-izAKiZMqBsVOHuYd .rough-node .label text,#mermaid-svg-izAKiZMqBsVOHuYd .node .label text,#mermaid-svg-izAKiZMqBsVOHuYd .image-shape .label,#mermaid-svg-izAKiZMqBsVOHuYd .icon-shape .label{text-anchor:middle;}#mermaid-svg-izAKiZMqBsVOHuYd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-izAKiZMqBsVOHuYd .rough-node .label,#mermaid-svg-izAKiZMqBsVOHuYd .node .label,#mermaid-svg-izAKiZMqBsVOHuYd .image-shape .label,#mermaid-svg-izAKiZMqBsVOHuYd .icon-shape .label{text-align:center;}#mermaid-svg-izAKiZMqBsVOHuYd .node.clickable{cursor:pointer;}#mermaid-svg-izAKiZMqBsVOHuYd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-izAKiZMqBsVOHuYd .arrowheadPath{fill:#333333;}#mermaid-svg-izAKiZMqBsVOHuYd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-izAKiZMqBsVOHuYd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-izAKiZMqBsVOHuYd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-izAKiZMqBsVOHuYd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-izAKiZMqBsVOHuYd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-izAKiZMqBsVOHuYd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-izAKiZMqBsVOHuYd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-izAKiZMqBsVOHuYd .cluster text{fill:#333;}#mermaid-svg-izAKiZMqBsVOHuYd .cluster span{color:#333;}#mermaid-svg-izAKiZMqBsVOHuYd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-izAKiZMqBsVOHuYd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-izAKiZMqBsVOHuYd rect.text{fill:none;stroke-width:0;}#mermaid-svg-izAKiZMqBsVOHuYd .icon-shape,#mermaid-svg-izAKiZMqBsVOHuYd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-izAKiZMqBsVOHuYd .icon-shape p,#mermaid-svg-izAKiZMqBsVOHuYd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-izAKiZMqBsVOHuYd .icon-shape .label rect,#mermaid-svg-izAKiZMqBsVOHuYd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-izAKiZMqBsVOHuYd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-izAKiZMqBsVOHuYd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-izAKiZMqBsVOHuYd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 空字符串
左括号
两个左括号
一对括号
两个左括号加一个右括号
结果一: 两层嵌套
一对括号后再加左括号
结果二: 两对并列

图中没有从空字符串直接添加右括号的分支,因为它在进入递归前就被 right < left 排除了。

3.5 对照我的代码

java 复制代码
class Solution {
    int left;
    int right;
    StringBuilder str;
    List<String> list;
    int m;

    public List<String> generateParenthesis(int n) {
        m = n;
        list = new ArrayList<>();
        str = new StringBuilder();
        dfs();
        return list;
    }

    public void dfs() {
        if (right == m) {
            list.add(str.toString());
        }

        if (left < m) {
            str.append('(');
            left++;
            dfs();
            left--;
            str.deleteCharAt(str.length() - 1);
        }

        if (right < left) {
            str.append(')');
            right++;
            dfs();
            right--;
            str.deleteCharAt(str.length() - 1);
        }
    }
}

这里的 StringBuilder 与上一题的 ArrayList 作用相似:它们都保存当前路径,并在不同递归层之间共享。因此递归返回后,不仅要把 leftright 减回去,还要删除最后添加的字符。

3.6 常见错误与代码细节

  • right <= left 添加右括号:当左右数量相同时仍会添加右括号,立即产生无效前缀。
  • 只恢复计数,不删除字符:计数回去了,但字符串没有回去,状态仍然不一致。
  • 只删除字符,不恢复计数:下一条路径会使用错误的左右括号数量。
  • 到达完整答案后仍然继续扩展:容易让出口语义不清晰。

当前实现中,right == m 时合法路径也必然有 left == m。保存答案后虽然没有显式 return,两个选择条件都不会再次成立,因此结果正确。为了让小白读者更容易识别递归出口,可以写成:

java 复制代码
if (right == m) {
    list.add(str.toString());
    return;
}

答案数量对应卡特兰数。生成每个答案还需要构造长度为 2n 的字符串,因此时间复杂度可写为 O ( C n × n ) O(C_n \times n) O(Cn×n),递归路径的额外空间为 O ( n ) O(n) O(n)。

四、LeetCode 98:验证二叉搜索树------把树转化成递增序列

4.1 先理解二叉搜索树的规则

有效二叉搜索树(Binary Search Tree,BST)需要满足:

  • 当前节点左子树中的所有节点都严格小于当前节点。
  • 当前节点右子树中的所有节点都严格大于当前节点。
  • 左右子树本身也必须是有效二叉搜索树。

注意关键词是"所有节点",而不只是左右孩子。

例如下面这棵树:

text 复制代码
        5
       / \
      1   6
         / \
        3   7

节点 6 大于根节点 5,看起来没有问题;但 3 位于根节点 5 的右子树中,却小于 5,因此整棵树不是二叉搜索树。

只比较父节点和直接孩子,会漏掉这种更深层的错误。

4.2 中序遍历为什么能够解决问题

二叉树的中序遍历顺序是:

text 复制代码
左子树 → 当前节点 → 右子树

对于二叉搜索树:

  • 左子树中的值都更小,所以先访问;
  • 然后访问当前节点;
  • 右子树中的值都更大,所以最后访问。

因此,有效二叉搜索树的中序遍历结果一定是一个严格递增序列

例如:

text 复制代码
        2
       / \
      1   3

中序遍历过程是:

text 复制代码
访问左节点 1 → 访问根节点 2 → 访问右节点 3

得到 [1,2,3],严格递增,所以它是有效二叉搜索树。

4.3 我们需要记录什么

不必真的创建一个数组保存全部遍历结果。只要记录"上一个访问到的节点值",就能在访问当前节点时比较:

text 复制代码
上一个值 < 当前值:继续检查
上一个值 >= 当前值:不是有效二叉搜索树

我的代码中变量名是 count,它实际承担的是"中序遍历前驱值"的角色。

4.4 手工跟踪一次递归

仍然使用 [2,1,3]

步骤 当前调用 动作 前驱值变化
1 节点 2 先递归左子树 暂不变化
2 节点 1 左子树为空,检查节点 1 Long.MIN_VALUE → 1
3 节点 1 右子树为空,返回 保持 1
4 节点 2 检查 1 < 2 1 → 2
5 节点 3 检查 2 < 3 2 → 3
6 根调用结束 所有检查均通过 返回 true

递归调用虽然从根节点 2 开始,但真正第一次比较的是最左侧节点 1。这正是中序遍历的结果。

4.5 对照我的代码

java 复制代码
class Solution {
    long count = Long.MIN_VALUE;

    public boolean isValidBST(TreeNode root) {
        if (root == null) return true;

        boolean left = isValidBST(root.left);
        if (!left) {
            return false;
        }

        boolean cur = false;
        if (count < root.val) {
            count = root.val;
            cur = true;
        }

        boolean right = isValidBST(root.right);
        return left && cur && right;
    }
}

代码执行顺序对应:

java 复制代码
boolean left = isValidBST(root.left);   // 左
// 比较 count 与 root.val              // 根
boolean right = isValidBST(root.right); // 右

如果左子树已经不合法,就立刻返回 false,不必继续检查当前节点和右子树。

4.6 为什么使用 long 保存前驱值

官方允许节点值达到 Integer.MIN_VALUE。如果写成:

java 复制代码
int count = Integer.MIN_VALUE;

那么树中的第一个节点恰好也是 Integer.MIN_VALUE 时,判断 count < root.val 不成立,会误判为无效。

使用 long count = Long.MIN_VALUE,就能保证初始值一定小于任何合法的 int 节点值。

4.7 常见错误与复杂度

  • 只比较当前节点与左右孩子:无法约束更深层的后代节点。
  • 使用 <= 判断递增:本题要求严格小于和严格大于,重复值不能出现在 BST 中。
  • 把前驱值定义为普通 int 最小值:可能与真实节点值冲突。
  • 在同一个 Solution 对象上多次调用却不重置状态:第二次会继承上一次的 count

每个节点只访问一次,时间复杂度为 O ( n ) O(n) O(n)。递归栈深度等于树高 h,空间复杂度为 O ( h ) O(h) O(h);平衡树约为 O ( log ⁡ n ) O(\log n) O(logn),退化成链表时最坏为 O ( n ) O(n) O(n)。

五、LeetCode 200:岛屿数量 ------ 一次 DFS 为什么等于一座岛

5.1 把二维数组想成一张地图

题目给出由字符 '1''0' 组成的二维网格:

  • '1' 表示陆地;
  • '0' 表示水;
  • 只有上下左右相邻的陆地才属于同一座岛;
  • 对角线接触不算连接。

例如:

text 复制代码
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1

这里有三座岛:左上角的一块、中间单独的一格、右下角的一块。

二维数组可以看成一张没有显式画出边的图。每个格子是一个节点,上下左右四个方向就是它可能连接的邻居。

5.2 为什么发现一块新陆地就让答案加一

我们从左到右、从上到下扫描整个网格。

如果遇到一块没有访问过的陆地,说明此前的 DFS 没有覆盖到它,因此它不属于已经发现的任何岛屿。此时:

  1. 岛屿数量加一;
  2. 从这块陆地出发执行 DFS;
  3. 把所有与它连通的陆地标记为已访问。

以后扫描到同一座岛上的其他格子时,因为已经访问过,就不会重复计数。
#mermaid-svg-ocwzrktu6Ojh2odX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ocwzrktu6Ojh2odX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ocwzrktu6Ojh2odX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ocwzrktu6Ojh2odX .error-icon{fill:#552222;}#mermaid-svg-ocwzrktu6Ojh2odX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ocwzrktu6Ojh2odX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ocwzrktu6Ojh2odX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ocwzrktu6Ojh2odX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ocwzrktu6Ojh2odX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ocwzrktu6Ojh2odX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ocwzrktu6Ojh2odX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ocwzrktu6Ojh2odX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ocwzrktu6Ojh2odX .marker.cross{stroke:#333333;}#mermaid-svg-ocwzrktu6Ojh2odX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ocwzrktu6Ojh2odX p{margin:0;}#mermaid-svg-ocwzrktu6Ojh2odX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ocwzrktu6Ojh2odX .cluster-label text{fill:#333;}#mermaid-svg-ocwzrktu6Ojh2odX .cluster-label span{color:#333;}#mermaid-svg-ocwzrktu6Ojh2odX .cluster-label span p{background-color:transparent;}#mermaid-svg-ocwzrktu6Ojh2odX .label text,#mermaid-svg-ocwzrktu6Ojh2odX span{fill:#333;color:#333;}#mermaid-svg-ocwzrktu6Ojh2odX .node rect,#mermaid-svg-ocwzrktu6Ojh2odX .node circle,#mermaid-svg-ocwzrktu6Ojh2odX .node ellipse,#mermaid-svg-ocwzrktu6Ojh2odX .node polygon,#mermaid-svg-ocwzrktu6Ojh2odX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ocwzrktu6Ojh2odX .rough-node .label text,#mermaid-svg-ocwzrktu6Ojh2odX .node .label text,#mermaid-svg-ocwzrktu6Ojh2odX .image-shape .label,#mermaid-svg-ocwzrktu6Ojh2odX .icon-shape .label{text-anchor:middle;}#mermaid-svg-ocwzrktu6Ojh2odX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ocwzrktu6Ojh2odX .rough-node .label,#mermaid-svg-ocwzrktu6Ojh2odX .node .label,#mermaid-svg-ocwzrktu6Ojh2odX .image-shape .label,#mermaid-svg-ocwzrktu6Ojh2odX .icon-shape .label{text-align:center;}#mermaid-svg-ocwzrktu6Ojh2odX .node.clickable{cursor:pointer;}#mermaid-svg-ocwzrktu6Ojh2odX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ocwzrktu6Ojh2odX .arrowheadPath{fill:#333333;}#mermaid-svg-ocwzrktu6Ojh2odX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ocwzrktu6Ojh2odX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ocwzrktu6Ojh2odX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ocwzrktu6Ojh2odX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ocwzrktu6Ojh2odX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ocwzrktu6Ojh2odX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ocwzrktu6Ojh2odX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ocwzrktu6Ojh2odX .cluster text{fill:#333;}#mermaid-svg-ocwzrktu6Ojh2odX .cluster span{color:#333;}#mermaid-svg-ocwzrktu6Ojh2odX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ocwzrktu6Ojh2odX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ocwzrktu6Ojh2odX rect.text{fill:none;stroke-width:0;}#mermaid-svg-ocwzrktu6Ojh2odX .icon-shape,#mermaid-svg-ocwzrktu6Ojh2odX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ocwzrktu6Ojh2odX .icon-shape p,#mermaid-svg-ocwzrktu6Ojh2odX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ocwzrktu6Ojh2odX .icon-shape .label rect,#mermaid-svg-ocwzrktu6Ojh2odX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ocwzrktu6Ojh2odX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ocwzrktu6Ojh2odX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ocwzrktu6Ojh2odX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否

扫描到一个格子
它是未访问的陆地吗
继续扫描下一个格子
岛屿数量加一
从该格子启动 DFS
标记当前格子
递归搜索上下左右的陆地
整座岛都被标记

5.3 方向数组是什么意思

如果不用方向数组,我们可能写出四段几乎相同的代码:

java 复制代码
dfs(i - 1, j); // 上
dfs(i + 1, j); // 下
dfs(i, j - 1); // 左
dfs(i, j + 1); // 右

代码中使用:

java 复制代码
int[] dx = {0, 0, 1, -1};
int[] dy = {1, -1, 0, 0};

将相同下标组合起来:

k dx[k] dy[k] 方向
0 0 1
1 0 -1
2 1 0
3 -1 0

当前位置是 (i,j),下一个位置就是:

java 复制代码
int x = i + dx[k];
int y = j + dy[k];

这样便可以用一个循环处理四个方向。

5.4 手工模拟一座小岛

假设网格为:

text 复制代码
1 1 0
1 0 0
0 0 1

扫描到 (0,0) 时,它是未访问陆地,于是计数从 0 变为 1,并进入 DFS:

text 复制代码
(0,0) → (0,1)
  ↓
(1,0)

DFS 结束后,左上角三块陆地都被标记。外层循环继续扫描时,(0,1)(1,0) 不会再次计数。

扫描到 (2,2) 时,它仍是未访问陆地,于是计数变为 2。最终答案就是 2

5.5 对照我的代码

java 复制代码
class Solution {
    boolean[][] check;
    int n, m;
    int[] dx = {0, 0, 1, -1};
    int[] dy = {1, -1, 0, 0};

    public int numIslands(char[][] grid) {
        n = grid.length;
        m = grid[0].length;
        int count = 0;
        check = new boolean[n][m];

        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == '1' && !check[i][j]) {
                    dfs(grid, i, j);
                    count++;
                }
            }
        }
        return count;
    }

    public void dfs(char[][] grid, int i, int j) {
        check[i][j] = true;

        for (int k = 0; k < 4; k++) {
            int x = i + dx[k];
            int y = j + dy[k];
            if (x >= 0 && x < n && y >= 0 && y < m
                    && !check[x][y] && grid[x][y] != '0') {
                dfs(grid, x, y);
            }
        }
    }
}

进入 dfs() 后第一件事就是:

java 复制代码
check[i][j] = true;

这表示当前格子已经归入正在搜索的岛屿。必须先标记,再搜索邻居,否则相邻格子可能又递归回当前格子,形成重复访问。

5.6 为什么这里不需要回溯

组合总和中,数字 2 搜索完一条路径后,还可能参与另一条路径,所以需要从 ret 中删除。

岛屿数量不同:格子 (0,0) 一旦确认属于第一座岛,就永远不应该再参与第二座岛。因此 check[i][j] 不需要恢复为 false

这不是忘了写回溯,而是题目状态的含义决定了它不能恢复。

5.7 常见错误与复杂度

  • 把对角线也算作相邻方向:会把原本分开的岛连接起来。
  • 先搜索邻居,最后才标记当前格子:可能在两个格子之间反复递归。
  • 忘记判断坐标边界:会出现数组下标越界。
  • 每遇到一个 '1' 就计数,但没有访问标记:一座岛会被重复计算。

官方输入只包含 '0''1',所以代码中的 grid[x][y] != '0'grid[x][y] == '1' 等价;后者更直观。

每个格子最多扫描和访问一次,时间复杂度为 O ( m n ) O(mn) O(mn)。check 数组需要 O ( m n ) O(mn) O(mn) 空间,递归栈最坏也可能达到 O ( m n ) O(mn) O(mn)。

六、LeetCode 130:被围绕的区域------正着难,就反着找

6.1 题目真正要求判断什么

棋盘中只有 'X''O'。题目要求把被 'X' 完全围住的 'O' 改成 'X'

但有一种 'O' 不能修改:它自身位于边界,或者能通过其他 'O' 一直连接到边界。

例如:

text 复制代码
X X X X
X O O X
X X O X
X O X X

中间的三个 'O' 没有连接到边界,所以会被改成 'X'。左下角的 'O' 位于边界,需要保留。

6.2 从每个内部区域出发为什么比较麻烦

一种直观想法是:发现 'O' 后启动 DFS,搜索整块区域,并记录它是否触碰边界。

但这样要额外保存当前区域中的所有坐标。DFS 结束后:

  • 如果触碰边界,就原样保留;
  • 如果没有触碰边界,再把保存的坐标全部改成 'X'

逻辑可以实现,但状态较多。

换个方向会简单很多:所有不能被修改的 'O',一定与边界上的某个 'O' 连通。

于是我们先从边界出发,把安全区域找出来。

6.3 三阶段处理法

整个算法可以分为三步:

  1. 遍历四条边,从边界上的 'O' 启动 DFS。
  2. 将所有与边界连通的 'O' 临时标记成 '.'
  3. 扫描整个棋盘:剩余 'O' 改为 'X',临时 '.' 恢复为 'O'

为什么临时标记使用 '.'?因为官方输入保证原始棋盘只有 'X''O',所以 '.' 不会与原数据混淆。

6.4 用题目样例一步步变化

初始棋盘:

text 复制代码
X X X X
X O O X
X X O X
X O X X

第一步从四条边寻找 'O'。只有左下角 (3,1) 位于边界,因此将它以及与它连通的安全区域标记为 '.'

text 复制代码
X X X X
X O O X
X X O X
X . X X

第二步扫描整个棋盘。此时仍然是 'O' 的格子不与边界连通,可以全部改为 'X'

text 复制代码
X X X X
X X X X
X X X X
X . X X

最后把临时标记 '.' 恢复成 'O'

text 复制代码
X X X X
X X X X
X X X X
X O X X

通过临时状态,我们把"判断一个区域是否被包围"转换成了"保护所有与边界连通的区域"。
#mermaid-svg-Jeu8kXigL1ZSlDQy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Jeu8kXigL1ZSlDQy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Jeu8kXigL1ZSlDQy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Jeu8kXigL1ZSlDQy .error-icon{fill:#552222;}#mermaid-svg-Jeu8kXigL1ZSlDQy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Jeu8kXigL1ZSlDQy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Jeu8kXigL1ZSlDQy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Jeu8kXigL1ZSlDQy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Jeu8kXigL1ZSlDQy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Jeu8kXigL1ZSlDQy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Jeu8kXigL1ZSlDQy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Jeu8kXigL1ZSlDQy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Jeu8kXigL1ZSlDQy .marker.cross{stroke:#333333;}#mermaid-svg-Jeu8kXigL1ZSlDQy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Jeu8kXigL1ZSlDQy p{margin:0;}#mermaid-svg-Jeu8kXigL1ZSlDQy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Jeu8kXigL1ZSlDQy .cluster-label text{fill:#333;}#mermaid-svg-Jeu8kXigL1ZSlDQy .cluster-label span{color:#333;}#mermaid-svg-Jeu8kXigL1ZSlDQy .cluster-label span p{background-color:transparent;}#mermaid-svg-Jeu8kXigL1ZSlDQy .label text,#mermaid-svg-Jeu8kXigL1ZSlDQy span{fill:#333;color:#333;}#mermaid-svg-Jeu8kXigL1ZSlDQy .node rect,#mermaid-svg-Jeu8kXigL1ZSlDQy .node circle,#mermaid-svg-Jeu8kXigL1ZSlDQy .node ellipse,#mermaid-svg-Jeu8kXigL1ZSlDQy .node polygon,#mermaid-svg-Jeu8kXigL1ZSlDQy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Jeu8kXigL1ZSlDQy .rough-node .label text,#mermaid-svg-Jeu8kXigL1ZSlDQy .node .label text,#mermaid-svg-Jeu8kXigL1ZSlDQy .image-shape .label,#mermaid-svg-Jeu8kXigL1ZSlDQy .icon-shape .label{text-anchor:middle;}#mermaid-svg-Jeu8kXigL1ZSlDQy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Jeu8kXigL1ZSlDQy .rough-node .label,#mermaid-svg-Jeu8kXigL1ZSlDQy .node .label,#mermaid-svg-Jeu8kXigL1ZSlDQy .image-shape .label,#mermaid-svg-Jeu8kXigL1ZSlDQy .icon-shape .label{text-align:center;}#mermaid-svg-Jeu8kXigL1ZSlDQy .node.clickable{cursor:pointer;}#mermaid-svg-Jeu8kXigL1ZSlDQy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Jeu8kXigL1ZSlDQy .arrowheadPath{fill:#333333;}#mermaid-svg-Jeu8kXigL1ZSlDQy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Jeu8kXigL1ZSlDQy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Jeu8kXigL1ZSlDQy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jeu8kXigL1ZSlDQy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Jeu8kXigL1ZSlDQy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jeu8kXigL1ZSlDQy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Jeu8kXigL1ZSlDQy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Jeu8kXigL1ZSlDQy .cluster text{fill:#333;}#mermaid-svg-Jeu8kXigL1ZSlDQy .cluster span{color:#333;}#mermaid-svg-Jeu8kXigL1ZSlDQy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Jeu8kXigL1ZSlDQy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Jeu8kXigL1ZSlDQy rect.text{fill:none;stroke-width:0;}#mermaid-svg-Jeu8kXigL1ZSlDQy .icon-shape,#mermaid-svg-Jeu8kXigL1ZSlDQy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Jeu8kXigL1ZSlDQy .icon-shape p,#mermaid-svg-Jeu8kXigL1ZSlDQy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Jeu8kXigL1ZSlDQy .icon-shape .label rect,#mermaid-svg-Jeu8kXigL1ZSlDQy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Jeu8kXigL1ZSlDQy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Jeu8kXigL1ZSlDQy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Jeu8kXigL1ZSlDQy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

遍历四条边
发现边界 O
DFS 标记整块边界连通区域
继续检查边界
扫描整个棋盘
剩余 O 改成 X
临时标记恢复为 O

6.5 对照我的代码

java 复制代码
class Solution {
    int[] dx = {0, 0, 1, -1};
    int[] dy = {1, -1, 0, 0};
    int n, m;

    public void solve(char[][] board) {
        n = board.length;
        m = board[0].length;

        for (int i = 0; i < n; i++) {
            if (board[i][0] == 'O') {
                dfs(board, i, 0);
            }
            if (board[i][m - 1] == 'O') {
                dfs(board, i, m - 1);
            }
        }

        for (int j = 0; j < m; j++) {
            if (board[0][j] == 'O') {
                dfs(board, 0, j);
            }
            if (board[n - 1][j] == 'O') {
                dfs(board, n - 1, j);
            }
        }

        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (board[i][j] == 'O') {
                    board[i][j] = 'X';
                } else if (board[i][j] == '.') {
                    board[i][j] = 'O';
                }
            }
        }
    }

    public void dfs(char[][] board, int i, int j) {
        board[i][j] = '.';

        for (int k = 0; k < 4; k++) {
            int x = i + dx[k];
            int y = j + dy[k];
            if (x >= 0 && x < n && y >= 0 && y < m
                    && board[x][y] == 'O') {
                dfs(board, x, y);
            }
        }
    }
}

第一组循环处理左右边界,第二组循环处理上下边界。四个角会被检查两次,但第一次 DFS 后已经变为 '.',第二次条件不再成立,因此不会重复搜索。

代码直接修改 board,既充当访问标记,又完成最终结果转换,因此不需要单独创建 visited 数组。

6.6 常见错误与复杂度

  • 从内部 'O' 开始修改,没有先判断是否连接边界:会错误修改安全区域。
  • 只检查第一行和最后一行,忘记第一列和最后一列。
  • 临时标记仍使用 'O':DFS 无法区分已访问和未访问格子,可能反复递归。
  • 最后只把 'O' 改为 'X',忘记恢复临时标记。

棋盘中的每个格子只会被常数次检查,时间复杂度为 O ( m n ) O(mn) O(mn)。除了递归栈外没有使用同规模的额外数组;最坏情况下递归栈空间为 O ( m n ) O(mn) O(mn)。

七、把五道题放到一起,我们究竟学会了什么

7.1 每道题的"当前状态"不同

题目 当前状态 递归出口 返回后是否恢复
组合总和 当前路径、累计和、起始下标 和等于或超过目标值 恢复路径
括号生成 当前字符串、左右括号数量 括号使用完成 恢复字符串和计数
验证二叉搜索树 当前节点、中序前驱值 当前节点为空或顺序不合法 不恢复前驱值
岛屿数量 当前坐标、访问矩阵 越界、海水或已访问 不恢复访问标记
被围绕的区域 当前坐标、棋盘临时状态 越界或不是 'O' 最后统一恢复安全区域

所以,不能看到 DFS 就机械套用同一份代码。我们需要先明确:递归参数和成员变量分别代表什么,它们在不同搜索分支之间能否共享。

7.2 判断是否需要回溯的简单方法

可以问自己一句话:

当前分支结束后,这个状态还需要被其他分支重新使用吗?

  • 如果需要,就恢复。例如组合中的路径、括号中的字符串。
  • 如果不应该再使用,就保留标记。例如已经归属某座岛屿的格子。

7.3 三种减少无效搜索的方法

  1. 限制选择范围 :组合总和用 start 保证下标不倒退。
  2. 进入递归前检查合法性 :括号生成只有在 right < left 时才添加右括号。
  3. 改变搜索方向:被围绕的区域先找与边界连通的安全区域。

这些方法表面不同,本质都是尽量不进入已经知道不会成功的分支。

7.4 一份适合初学者的思考清单

以后遇到 DFS 或回溯题,可以按下面顺序思考:

  1. 一次递归调用处理的是什么?一个位置、一层选择,还是一个树节点?
  2. 哪些数据描述了当前状态?
  3. 什么情况下得到答案?
  4. 什么情况下已经不可能得到答案,可以提前返回?
  5. 当前层有哪些可选项?
  6. 选择以后,下一层的参数怎样变化?
  7. 下一层返回后,哪些修改必须撤销?
  8. 怎样避免重复访问或重复答案?

如果这八个问题都能回答,代码通常只是把答案逐句翻译成 Java。

八、总结

这篇文章从最基础的递归调用过程开始,逐步分析了组合总和、括号生成、验证二叉搜索树、岛屿数量和被围绕的区域。

五道题看起来跨越了组合、字符串、二叉树和二维网格,但它们都围绕一条主线展开:

  • DFS 负责沿着一条路径不断深入;
  • 递归出口负责告诉程序什么时候停止;
  • 回溯负责在尝试结束后恢复可复用的状态;
  • 剪枝负责提前排除不可能成功的选择;
  • Flood Fill 则把 DFS 用在二维网格的连通区域中。

如果你现在还不能完全脱离模板写出代码,也没有关系。先尝试拿一个很小的样例,在纸上写出每一层的参数、当前路径和返回后的状态变化。能够手工走通递归过程,就是从"看懂答案"走向"自己写出答案"的关键一步。

下一步可以继续练习全排列、子集、单词搜索、N 皇后和岛屿最大面积,把路径型回溯与网格型 DFS 分别整理成自己的模板。

感谢大家的阅读,我们下期再见!

相关推荐
Water_Sunzhipeng2 小时前
2024牛客暑期多校训练营1
算法
hetao17338372 小时前
2026-08-09~08-14 hetao1733837 的刷题记录
c++·算法
weixin_383196472 小时前
java基础面试题@Autowired和@Resource区别
java·开发语言
技术小黑2 小时前
RNN算法实战系列06 | LSTM 实现糖尿病探索与预测
rnn·算法·lstm
evans在进步2 小时前
LeetCode 33:搜索旋转排序数组——Java 两阶段二分查找详解
java·python·leetcode
疯狂打码的少年2 小时前
【数据结构】二叉树的性质(五大性质+计算)
数据结构·笔记·算法
Web Security Loop2 小时前
MAC卸载JDK环境
java·macos·jdk
wabs6662 小时前
关于图论【A*算法 | 卡码网127.骑士的攻击的思考】
数据结构·算法·图论·卡码网·广搜的改进版
CIO_Alliance3 小时前
AI认知系列(3)| 数据、算法、算力、场景四要素协同
人工智能·算法·ipaas·系统集成·企业cio联盟·企业级ai化转型