🌀

图的 DFS:一条路走到黑,撞死胡同就回头

树的 DFS 往下走不会回头,图不一样——图里有环(地铁环线),不记「去过哪」就会 1→2→4→3→1→2… 转圈转到天荒地老。图 DFS 的铁律:进每个节点前先看 vis 数组,去过就跳过,没去过就标记再进。

💡 打个比方

DFS = 走迷宫:从入口出发,选一条路一直走到头(走到死胡同、或者所有岔路都试过了),就回头到上一个岔口,换一条没试过的路继续走。树(比如组织架构图)没有环——往下走永远不会走回上面,不会迷路;但图有环(像地铁环线 1→2→4→3→1 绕一圈就回来了),不记「去过哪」,DFS 就会无限循环。所以图 DFS 多一条铁律:准备一个 vis 数组(bool,vis[i] = true 表示 i 去过)——进节点前查一下,去过就跳过,没去过就标上再进。这样每个节点恰好进一次,走完整张迷宫,所有能走到的节点都 visit 了。函数自己调自己 = 递归,「走到头回头」正是递归返回时干的。

术语:DFS(depth-first search,深度优先搜索:「走到头、回头」)、vis/visited 数组(bool,标记去过哪)、递归(函数调自己,返回 = 回头)、环(走一圈能回到起点的路线)、连通块/孤岛(互相能到达的一群节点)、回溯(递归返回,回到上一个岔口)。

🖼️ 大图看明白

DFS:一条路走到黑,回头换下一条(访问顺序 1→2→4→3→5→6) 1 2 4 3 5 6 1 2 3 4 5 6 访问顺序 1 → 2 → 4 → 3 → 5 → 6 回头路(不再走): 1—3、3—4(虚线) vis 数组(都 ✓): 1✓ 2✓ 3✓ 4✓ 5✓ 6✓ 每个节点恰好进一次 走过的路(DFS 树边) 去过(不再走)

🧩 子任务 1:树 DFS 回顾 vs 图的麻烦——环!

🎯 小目标:想明白「为什么树不用 vis、图必须用」——差别就在有没有环。
// 树(无环):往下走不会走回上面
//        1
//       / \
//      2   3
//     /\
//    4  5
//   DFS(1) → DFS(2) → DFS(4) → 回头 → DFS(5) → 回头 → DFS(3) → 完
//   每个节点只有一个「来路」,递归返回时自然回头,不会撞见老朋友
//
// 图(有环):地铁环线,会走回起点
//     1 ── 2
//     │    │
//     3 ── 4
//   没有 vis 时:DFS(1) → 2 → 4 → 3 → 又见 1 → 又见 2 → 又见 4 → …
//   无限循环!(递归版:栈越叠越厚 → 栈溢出 / 超时)
//
// 解法:vis 数组(bool,初始全 false)
//   进节点前:if (vis[i]) 跳过;否则 vis[i] = true 再进
//   效果:每个节点恰好进一次,环被 vis「剪断」
✅ 小检查:① 树上 DFS 为什么可以不查 vis?② 四节点环 1-2-4-3-1 里,不查 vis 的 DFS 从 1 出发会怎样?③ vis 用什么类型存?初始值是多少?
看看答案 ① 树往下走只会遇到没去过的孩子(唯一的「回头边」是回父节点,而递归是从父节点调下来的,父节点不会再调回来)——没有第二条路能绕回同一个节点。 ② 1→2→4→3→1→2→4→3…无限循环(递归版栈越叠越厚,最终栈溢出或超时 TLE)。 ③ bool 数组(或 int 存访问标记),初始全 0(false)。
⚠️ 易错:把树 DFS 的「不用 vis」直接搬到图上——图里每条边都可能是回头路。另外 vis 是全局的:一次 DFS 的整个过程中共用同一份,别每次递归都新开一份。

🧩 子任务 2:手推 DFS 遍历顺序 + vis 标记过程(6 节点小图)

🎯 小目标:给定 6 节点图(边 1-2、1-3、2-4、3-4、4-5、5-6),邻居按编号从小到大试,手推完整访问顺序,并说清「谁跳过谁」。
// 图:边 1-2、1-3、2-4、3-4、4-5、5-6
// adj[1]={2,3} adj[2]={1,4} adj[3]={1,4} adj[4]={2,3,5} adj[5]={4,6} adj[6]={5}
//
// 从 1 出发 DFS(邻居从小到大试):
// 第 1 步:进 1,vis[1]=1,顺序:1
// 第 2 步:试 1 的邻居 2(未去)→ 进 2,vis[2]=1,顺序:1 2
// 第 3 步:试 2 的邻居 1(已去,跳过)→ 4(未去)→ 进 4,vis[4]=1,顺序:1 2 4
// 第 4 步:试 4 的邻居 2(已去)→ 3(未去)→ 进 3,vis[3]=1,顺序:1 2 4 3
// 第 5 步:试 3 的邻居 1(已去)→ 4(已去)→ 没路了,回头到 4
// 第 6 步:4 的下一个邻居 5(未去)→ 进 5,vis[5]=1,顺序:1 2 4 3 5
// 第 7 步:试 5 的邻居 4(已去)→ 6(未去)→ 进 6,vis[6]=1,顺序:1 2 4 3 5 6
// 第 8 步:6 的邻居 5(已去)→ 回头 → 一路回头 → 完!
//
// 最终访问顺序:1 2 4 3 5 6(6 个节点各进恰好 1 次)
// 关键:1 的邻居 3 为什么没从 1 进?——2→4→3 这条路上 3 已经被访问,
//       回到 1 再试 3 时 vis[3]=1 → 跳过(这条 1—3 边就是「回头路」)
✅ 小检查:① 为什么 3 是从 4 那边被访问的,而不是从 1?② 结束时每个节点各被访问了几次?谁保证了这一点?③ 若把 adj[1] 改成 {3,2}(先试 3),访问顺序变成什么?
看看答案 ① DFS 永远先试第一个未访问的邻居:1 先试 2,顺着 2→4 往下走时 3 就被 4「顺路」访问了;等回到 1 再试 3,它已经去过 → 跳过。 ② 每个节点恰好 1 次——vis 数组保证:进过一次就标 true,以后再也进不来。 ③ 1 → 3 → 4 → 2 → 5 → 6(1 先走 3,3 的邻居 1 已去,再走 4;4 的邻居 2 未去 → 进 2,2 的邻居都去了 → 回头 → 4 继续 5 → 6)。
⚠️ 易错:访问顺序不唯一——取决于邻居的尝试顺序(数组顺序、编号大小)。题目不规定时,「顺序」类问题一般另有条件;但「哪些节点被访问」是唯一的(同一个连通块内全都访问)。

🧩 子任务 3:连通块计数——几个「孤岛」?

🎯 小目标:用「外层 for + 每次从新起点 DFS」数出一张图有几个连通块(孤岛);记住无环图(森林)的公式:连通块 = n − 边数。
// 图:n = 6,边只有 1-2、3-4(5、6 是孤点)
//
// 思路:外层 for 从 1 数到 n,每次发现「没访问过的节点」
//       = 一个新孤岛的入口 → cnt++,然后 DFS 把整个岛标记掉
//
// i = 1:vis[1] 未访问 → cnt = 1 → DFS(1):访问 {1, 2}
// i = 2:已访问,跳过(同属 1 号岛)
// i = 3:未访问 → cnt = 2 → DFS(3):访问 {3, 4}
// i = 4:已访问,跳过
// i = 5:未访问 → cnt = 3 → DFS(5):访问 {5}
// i = 6:未访问 → cnt = 4 → DFS(6):访问 {6}
// 答案:4 个连通块 = {1,2}、{3,4}、{5}、{6}
//
// 森林(无环图)公式:连通块 = n − 边数 k
//   本例:6 − 2 = 4 ✓(每个连通块是一棵树:t 个节点的树恰有 t−1 条边,
//   加起来:边数 = Σ(t_i − 1) = n − 连通块数)
✅ 小检查:① 外层 for 循环的作用是什么?② 无环图 n=6、k=2 条边,连通块有几个?③ n 个节点的无向图连通至少需要几条边?少一条会怎样?
看看答案 ① 「巡逻」:逐个节点检查,每遇到一个没访问过的节点,就是发现一个新孤岛——cnt++ 并 DFS 把整座岛标记掉(岛内其他节点就都被跳过)。 ② 4 个(n − k = 6 − 2)。 ③ n − 1 条(一棵树的边数);少一条就一定断开,至少 2 个连通块。
⚠️ 易错:DFS 之前必须 vis[u] = true(或 dfs 第一行就标)——不标就会在环里打转。外层 for 一个都别漏(1 到 n),孤点也会各数成一座「岛」。

🧩 子任务 4:两点通路判断——从 s 出发 DFS 能不能走到 t?

🎯 小目标:会判断「s 到 t 有没有通路」:从 s 做一次 DFS,看 vis[t] 是不是 true;分清无向(能往返)和有向(顺着箭头)。
// 无向图(子任务 2 的 6 节点图):s = 1, t = 6
//   DFS(1) 访问 {1,2,4,3,5,6} → vis[6] = true → 1 能走到 6 ✓
//   无向图:能走到 ⇔ 能走回(同在一个连通块里)
//
// 有向图:n = 6,边 1→2、2→3、3→1、5→6
//   DFS(1):访问 {1, 2, 3}(3→1 回头路,vis 挡住)→ vis[6] = false
//   → 1 到不了 6(6 只能从 5 进,1 到不了 5)
//   DFS(6):访问 {6}(6 没有出边)→ vis[1] = false → 6 也到不了 1
//   有向图:s 能到 t ≠ t 能到 s(方向!)
✅ 小检查:① 有向环 1→2、2→3、3→1:从 2 出发能到 1 吗?② 有向图里「s 能到 t」要求什么?③ 无向图怎么判断「整张图连通」?
看看答案 ① 能:2→3→1 顺着箭头走得到。 ② 存在一条顺着箭头方向的路径(每步都沿有向边)。 ③ 任选一个节点做 DFS,vis 全为 true(访问数 = n)→ 连通;否则不连通(也可以数连通块 = 1 否)。
⚠️ 易错:有向图判断「s→t」时 DFS 从 s 出发(不是 t);想问「谁能到 t」要建反向图(所有边掉头)再 DFS。无向图这两个问题是一回事。

💻 完整代码:连通块计数 + 两点通路判断(邻接表 + 递归 DFS)

// 题目:读入 n 个节点 m 条无向边的图,然后:
//   ① 输出连通块(孤岛)个数;
//   ② 读入 s t,判断 s 能不能走到 t,输出 YES / NO。
// 输入:第 1 行 n m;接下来 m 行每行 u v(无向边);最后 1 行 s t。
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;

const int N = 100005;
vector<int> adj[N];   // 邻接表
bool vis[N];           // 去过没?
int n, m;

void dfs(int u) {           // 从 u 出发,访问所有能走到的节点
    vis[u] = true;          // 先标记再扩展(环里才不会打转)
    for (int i = 0; i < (int)adj[u].size(); i++) {
        int v = adj[u][i];
        if (!vis[v]) dfs(v);   // 只递归「没去过」的邻居
    }
}

int main() {
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    // ① 连通块计数:每遇到一个没访问过的节点 = 一座新孤岛
    int cnt = 0;
    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            cnt++;
            dfs(i);
        }
    }
    cout << cnt << endl;
    // ② 通路判断:从 s 做一次 DFS,看 vis[t]
    memset(vis, 0, sizeof vis);   // 重置 vis
    int s, t;
    cin >> s >> t;
    dfs(s);
    cout << (vis[t] ? "YES" : "NO") << endl;
    return 0;
}

// 样例:n=6, m=6;边:1 2 / 1 3 / 2 4 / 3 4 / 4 5 / 5 6;查询:1 6
// 输出:1(全图一座岛)/ YES(1 能到 6,路径如 1→2→4→5→6)

✋ 你来试试

第 1 题:n=5,边 1-2、2-3、3-1(三角形)、4-5:连通块有几个?DFS(1) 会访问哪些节点? 2 个连通块({1,2,3} 和 {4,5});DFS(1) 访问 {1,2,3}——3→1 是回头路,被 vis 挡住,不会绕圈。
第 2 题:有向图边 1→2、2→3、3→2:1 能到 3 吗?3 能到 1 吗? 1 到 3:能(1→2→3 顺箭头);3 到 1:不能(3 只能去 2,2 只能去 3,1 永远进不来)。有向图的「能到」是单向的。
第 3 题:n=10 的图没有环,边数 k=3:连通块一定有几个?(提示:森林公式) 7 个(连通块 = n − k = 10 − 3 = 7)。无环图每个连通块是树(t 个节点的树 = t−1 条边),所以连通块数被 n 和 k 唯一确定。
第 4 题(加餐):节点特别多、递归层数太深会栈溢出,DFS 还能怎么写?(提示:想想 DP 里「滚动」的思想——把状态存起来) 改成显式栈的迭代版:起点入栈 → 每次弹出一个节点、标记它、把它的未访问邻居全部压栈。栈就是「记回头路」的笔记本——效果和递归一模一样,但不会栈溢出(后面 BFS 的队列同理)。
🎯 一句话记住:图 DFS = 走迷宫——一条路走到黑、回头换下一条;进节点前必查 vis(去过跳过、没去过标记再进),每个节点恰好进一次;外层 for + 从每个未访问点 DFS = 数连通块;从 s 做一次 DFS 看 vis[t] = 判断 s 能否走到 t(有向图只顺着箭头)。