
文章目录
- [一、写题之前:先真正理解递归、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 递归不是"自己调用自己"这么简单
我们经常听到一句定义:递归就是方法自己调用自己。这句话没有错,但只记住这句话,还不足以帮助我们读懂算法。
更准确地说,递归有两个阶段:
- 向下进入:当前方法暂时没有执行完,先调用下一层方法。
- 向上返回:下一层执行结束,程序回到当前层,继续执行调用语句后面的代码。
先看一个最小例子:
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 之上。它最典型的三个动作是:
- 做出一个选择;
- 带着新状态进入下一层;
- 下一层返回后,撤销刚才的选择。
#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],才能继续尝试添加 6 或 7。
如果不删除,上一条路径中的数据就会混进下一条路径。
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。我们要从数组中选择若干个数字,让它们的和等于目标值。
需要注意两条规则:
- 同一个数字可以重复使用。
[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
)( // 右括号出现在可以与它匹配的左括号之前
(() // 左右括号数量不相等
())( // 中间已经出现无法匹配的右括号
有效括号在构造过程中必须始终满足:
- 左括号数量不超过
n。 - 任意前缀中,右括号数量不能超过左括号数量。
- 最终左右括号数量都等于
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 作用相似:它们都保存当前路径,并在不同递归层之间共享。因此递归返回后,不仅要把 left 或 right 减回去,还要删除最后添加的字符。

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 没有覆盖到它,因此它不属于已经发现的任何岛屿。此时:
- 岛屿数量加一;
- 从这块陆地出发执行 DFS;
- 把所有与它连通的陆地标记为已访问。
以后扫描到同一座岛上的其他格子时,因为已经访问过,就不会重复计数。
#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 三阶段处理法
整个算法可以分为三步:
- 遍历四条边,从边界上的
'O'启动 DFS。 - 将所有与边界连通的
'O'临时标记成'.'。 - 扫描整个棋盘:剩余
'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 三种减少无效搜索的方法
- 限制选择范围 :组合总和用
start保证下标不倒退。 - 进入递归前检查合法性 :括号生成只有在
right < left时才添加右括号。 - 改变搜索方向:被围绕的区域先找与边界连通的安全区域。
这些方法表面不同,本质都是尽量不进入已经知道不会成功的分支。
7.4 一份适合初学者的思考清单
以后遇到 DFS 或回溯题,可以按下面顺序思考:
- 一次递归调用处理的是什么?一个位置、一层选择,还是一个树节点?
- 哪些数据描述了当前状态?
- 什么情况下得到答案?
- 什么情况下已经不可能得到答案,可以提前返回?
- 当前层有哪些可选项?
- 选择以后,下一层的参数怎样变化?
- 下一层返回后,哪些修改必须撤销?
- 怎样避免重复访问或重复答案?
如果这八个问题都能回答,代码通常只是把答案逐句翻译成 Java。
八、总结
这篇文章从最基础的递归调用过程开始,逐步分析了组合总和、括号生成、验证二叉搜索树、岛屿数量和被围绕的区域。
五道题看起来跨越了组合、字符串、二叉树和二维网格,但它们都围绕一条主线展开:
- DFS 负责沿着一条路径不断深入;
- 递归出口负责告诉程序什么时候停止;
- 回溯负责在尝试结束后恢复可复用的状态;
- 剪枝负责提前排除不可能成功的选择;
- Flood Fill 则把 DFS 用在二维网格的连通区域中。
如果你现在还不能完全脱离模板写出代码,也没有关系。先尝试拿一个很小的样例,在纸上写出每一层的参数、当前路径和返回后的状态变化。能够手工走通递归过程,就是从"看懂答案"走向"自己写出答案"的关键一步。
下一步可以继续练习全排列、子集、单词搜索、N 皇后和岛屿最大面积,把路径型回溯与网格型 DFS 分别整理成自己的模板。
感谢大家的阅读,我们下期再见!