从走迷宫到算法思维:如何用DFS解决LeetCode岛屿问题(C++保姆级拆解)

从走迷宫到算法思维:如何用DFS解决LeetCode岛屿问题(C++保姆级拆解)

很多朋友在初次接触深度优先搜索(DFS)时,往往是从“迷宫寻路”这类经典教学示例开始的。看着代码一步步探索、回溯,最终找到出口,那种感觉就像亲手解开了一个精巧的机械谜题。然而,当我们在LeetCode上遇到诸如“200. 岛屿数量”这类高频考题时,却常常感到无从下手——明明都是DFS,为什么从迷宫到岛屿,中间仿佛隔着一道无形的墙?

这堵墙,其实就是算法思维的迁移能力。迷宫问题教会我们DFS的基本操作:探索、标记、回溯。但岛屿问题,以及更广泛的网格类问题,要求我们将这套操作内化为一种更通用的问题抽象与模式识别能力。今天,我们就来彻底拆解这堵墙,不仅让你看懂代码,更要让你掌握背后那种“一通百通”的思考方式。无论你是正在备战技术面试的求职者,还是希望夯实算法基础的中高级开发者,这篇文章都将带你走完从具体问题抽象到工程化实现的完整链条。

1. 思维跃迁:从迷宫路径到连通块识别

迷宫问题和岛屿问题,看似风马牛不相及,实则共享着同一个核心计算模型:二维网格上的图遍历

在迷宫问题中,我们通常寻找一条从起点S到终点T的路径。DFS的角色是一个执着的探险家,尝试每一条岔路,走不通就退回上一个路口(回溯),直到找到终点或穷尽所有可能。我们关心的是序列可达性

而到了岛屿问题(以LeetCode 200为例),场景变了。我们面对的是一个由‘1’(陆地)和‘0’(水)组成的网格,目标是统计其中“岛屿”的数量。这里的“岛屿”被定义为水平或垂直方向上相邻的陆地单元格组成的连通块。此时,DFS的角色从一个寻找终点的探险家,转变为一个区域标记员。它的任务不再是找一条路,而是从任意一个未被访问的‘1’出发,“淹没”或“标记”所有与之相连的‘1’,从而将一整片岛屿从地图上“识别”出来。

这个思维的转变至关重要。我们来看一个对比:

特征维度经典迷宫问题LeetCode 200 岛屿问题
遍历目标找到一条从S到T的路径找出所有由‘1’组成的连通区域
DFS的启动点唯一的起点S网格中每一个未被访问的‘1’
DFS的使命探索单一路径,找到即止探索整个连通区域,并标记所有访问过的点
回溯的需求需要回溯以尝试其他路径通常无需回溯(标记后不再访问)
结果输出一条路径或“无解”连通区域的数量

提示:在岛屿问题中,DFS一旦从一个‘1’启动,就会像墨水扩散一样染遍整个岛屿。我们不需要回溯去尝试其他“走法”,因为我们的目标就是“占领”整个区域。这其实是一种简化版的DFS,去掉了寻找特定目标时的回溯逻辑,只保留了遍历和标记。

理解了这个根本区别,代码结构的差异就顺理成章了。迷宫DFS的核心函数通常返回bool(是否找到终点),而岛屿DFS的辅助函数通常是void,它的任务就是执行一次“洪水填充”。

2. 核心构建:岛屿数量问题的DFS解法全拆解

让我们直接切入最经典的LeetCode 200题:“岛屿数量”。我将从问题抽象、变量定义、函数设计到最终代码,一步步拆解,并穿插讲解面试中常见的思考点。

2.1 问题抽象与建模

给定一个m x n的二维网格grid,我们需要一个算法来返回岛屿的数量。

第一步:将网格转化为图 这是最关键的一步。我们可以把每个网格单元格看作图中的一个节点。两个节点之间存在边,当且仅当它们都是陆地(‘1’)并且在水平或垂直方向上相邻(四连通)。这样,一个“岛屿”就对应图中的一个连通分量。问题于是转化为:计算该图中连通分量的数量

第二步:设计遍历策略 计算连通分量数量的标准算法之一就是DFS(或BFS)。算法伪代码思路如下:

  1. 初始化一个计数器islands = 0
  2. 遍历网格中的每一个单元格(i, j)
  3. 如果当前单元格是未被访问过的陆地(grid[i][j] == '1'),则:
    • 计数器islands加1(我们发现了一个新的岛屿)。
    • 启动一次DFS,从(i, j)开始,访问并标记所有与之连通的陆地单元格,确保它们不会被重复计数。

2.2 DFS辅助函数的设计

这个DFS函数是算法的引擎。它需要做什么?

  1. 职责:将传入的陆地单元格及其所有连通陆地标记为“已访问”。
  2. 输入:当前单元格的坐标(i, j)和网格的引用。
  3. 终止条件:当前坐标越界,或者当前单元格不是陆地(‘0’),或者已经被访问过。
  4. 递归过程:标记当前单元格,然后向其四个方向(上、下、左、右)递归调用自身。

这里有一个重要的工程决策:如何标记“已访问”? 常见的有三种方法:

  • 修改原数组:将访问过的‘1’改为‘0’或另一个字符(如‘2’)。这种方法空间复杂度为O(1),但修改了输入数据。
  • 使用独立的访问数组:创建一个与grid同维度的bool visited[m][n]数组。空间复杂度O(m*n),但不修改原数据。
  • 使用哈希集合:将访问过的坐标(i, j)存入集合。适用于稀疏网格,但常数开销较大。

在面试中,如果面试官没有特别说明,第一种方法(修改原数组)通常是首选,因为它简洁且节省空间。我们就采用这种方法。

2.3 C++代码实现与逐行解析

下面是最具代表性的C++实现。我会在关键位置添加注释,解释每一部分的意图和可能遇到的“坑”。

class Solution {
public:
    int numIslands(vector<vector<char>>& grid) {
        // 处理边界情况:空网格
        if (grid.empty() || grid[0].empty()) return 0;
        
        int m = grid.size();    // 网格行数
        int n = grid[0].size(); // 网格列数
        int islands = 0;        // 岛屿计数器
        
        // 遍历网格中的每一个单元格
        for (int i = 0; i < m; ++i) {
            for (int j = 0; j < n; ++j) {
                // 发现一块未被探索的“新大陆”
                if (grid[i][j] == '1') {
                    ++islands;          // 发现一个新岛屿
                    dfs(grid, i, j, m, n); // “淹没”这个岛屿
                }
            }
        }
        return islands;
    }
    
private:
    // DFS辅助函数:淹没以(i, j)为起点的整个岛屿
    void dfs(vector<vector<char>>& grid, int i, int j, int m, int n) {
        // 递归终止条件:越界、遇到水、或已访问过(这里已访问的陆地被标记为'0')
        if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] != '1') {
            return;
        }
        
        // 标记当前单元格为已访问(淹没为‘水’)
        grid[i][j] = '0';
        
        // 向四个方向递归探索
        dfs(grid, i - 1, j, m, n); // 上
        dfs(grid, i + 1, j, m, n); // 下
        dfs(grid, i, j - 1, m, n); // 左
        dfs(grid, i, j + 1, m, n); // 右
        
        // 注意:这里没有回溯操作!我们的目的就是遍历并标记,不需要恢复状态。
    }
};

几个关键点的深度解读:

  1. 递归终止条件的顺序if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] != '1')。这里必须先检查下标越界,再访问grid[i][j]。如果顺序颠倒,在越界的情况下访问数组会导致未定义行为(段错误)。这是一个非常常见的错误。
  2. 方向数组的替代:代码中显式写出了四个方向的递归调用。你也可以使用一个方向数组来使代码更紧凑,这在处理八连通等问题时优势明显:
    vector<pair<int, int>> directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    for (auto& dir : directions) {
        dfs(grid, i + dir.first, j + dir.second, m, n);
    }
    
  3. 为什么是void函数:这个DFS函数不需要返回值,因为它唯一的任务就是“标记一片区域”。它不负责判断成功与否,也不收集路径信息。

3. 从解对到解优:剪枝、优化与复杂度分析

写出能通过样例的代码只是第一步。在面试中,面试官更期待你展现出对算法性能的掌控力。这就涉及到复杂度分析和优化技巧。

3.1 时间与空间复杂度

  • 时间复杂度:O(m * n) 最坏情况下,我们需要遍历网格中的每一个单元格。每个单元格最多被访问一次(在DFS中被标记后就不会再进入主循环的if判断),因此总时间复杂度是网格单元格的数量级。
  • 空间复杂度:O(m * n) 这个复杂度主要来自递归调用栈。在最坏情况下,如果整个网格都是陆地,DFS递归的深度可能达到m*n(例如,一条蛇形路径)。这意味着递归调用栈可能非常深。这是DFS解法的一个潜在风险点。

注意:对于极端大小的网格(例如1000x1000的全陆地网格),递归深度可能超过系统栈的默认限制,导致栈溢出。这是面试中一个很好的进阶讨论点。

3.2 优化与变体探讨

1. 迭代DFS(使用栈) 为了避免递归过深的风险,我们可以用显式的栈来模拟递归过程,将空间复杂度从递归的O(m*n)降低到O(min(m, n))(栈中同时存储的节点数最多为网格对角线长度)。这对于解决栈溢出问题非常有效。

void dfs_iterative(vector<vector<char>>& grid, int i, int j, int m, int n) {
    stack<pair<int, int>> stk;
    stk.push({i, j});
    grid[i][j] = '0'; // 入栈即标记
    
    vector<pair<int, int>> dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    
    while (!stk.empty()) {
        auto [x, y] = stk.top();
        stk.pop();
        
        for (auto& dir : dirs) {
            int nx = x + dir.first;
            int ny = y + dir.second;
            if (nx >= 0 && nx < m && ny >= 0 && ny < n && grid[nx][ny] == '1') {
                grid[nx][ny] = '0';
                stk.push({nx, ny});
            }
        }
    }
}

2. 广度优先搜索(BFS)作为替代 BFS使用队列,同样可以避免递归过深的问题,并且代码结构清晰。在求连通块数量这个问题上,BFS和DFS的时间复杂度相同,选择哪一种更多是个人习惯和具体场景(例如,如果需要记录遍历的“层”信息,BFS更合适)。

3. 并查集(Union-Find)解法 这是一个完全不同但非常优美的思路。我们可以将每个陆地单元格初始化为一个独立的集合,然后遍历网格,将相邻的陆地单元格进行合并。最后,集合的数量就是岛屿的数量。并查集解法在应对动态连通性问题(如实时添加/删除陆地)时更有优势。

class UnionFind {
    vector<int> parent, rank;
    int count; // 连通分量计数
public:
    UnionFind(vector<vector<char>>& grid) {
        int m = grid.size(), n = grid[0].size();
        parent.resize(m * n);
        rank.resize(m * n, 0);
        count = 0;
        // 初始化:每个‘1’都是自己的父节点,并计数
        for (int i = 0; i < m; ++i) {
            for (int j = 0; j < n; ++j) {
                if (grid[i][j] == '1') {
                    int id = i * n + j;
                    parent[id] = id;
                    ++count;
                }
            }
        }
    }
    // ... 省略 find 和 union 操作
    int getCount() const { return count; }
};

// 在主函数中遍历网格,合并相邻的‘1’对应的集合

4. 思维扩展:DFS在网格类问题中的模式化应用

掌握了“岛屿数量”的解法,你就获得了一把打开许多LeetCode网格问题的钥匙。我们来看看如何将这套模式应用到其他高频题目上,体会算法思维的迁移。

4.1 岛屿的最大面积(LeetCode 695)

这个问题可以看作是“岛屿数量”的变体。我们不再计数,而是在每次DFS中计算连通块的大小。

解法调整

  • DFS函数需要返回一个整数值,代表当前岛屿的面积。
  • 在DFS内部,将当前单元格面积(1)加上四个方向递归调用的结果。
  • 在主循环中,用maxArea记录每次DFS返回值的最大值。
int dfs_area(vector<vector<int>>& grid, int i, int j) {
    if (i < 0 || i >= grid.size() || j < 0 || j >= grid[0].size() || grid[i][j] != 1) {
        return 0;
    }
    grid[i][j] = 0; // 标记为已访问
    return 1 + dfs_area(grid, i+1, j) + dfs_area(grid, i-1, j)
             + dfs_area(grid, i, j+1) + dfs_area(grid, i, j-1);
}

4.2 被围绕的区域(LeetCode 130)

这个问题要求找到所有被‘X’完全包围的‘O’,并将其替换为‘X’。一个巧妙的思路是反向思考:所有不被包围的‘O’一定与边界相连。

解法模式

  1. 首先,对四条边界上的每一个‘O’单元格启动DFS/BFS,将所有与边界相连的‘O’标记为一个特殊字符(如‘#’)。这一步的目的是“保护”这些不会被改变的‘O’。
  2. 然后,遍历整个网格:
    • 将剩余的‘O’(即被包围的)改为‘X’。
    • 将‘#’恢复为‘O’。

这个“从边界入手”的技巧,在解决“太平洋大西洋水流问题”(LeetCode 417)等题目时也非常有用。

4.3 单词搜索(LeetCode 79)

在一个二维网格中搜索一个单词是否存在,单词必须由相邻单元格的字母按顺序连接而成。这是DFS回溯算法的典型应用。

与岛屿问题的核心区别

  • 需要回溯:因为路径不能重复使用同一个单元格,所以在递归返回时需要恢复单元格的访问状态,以便尝试其他路径。
  • 有明确的终止目标:找到完整的单词序列。
  • 需要处理匹配失败:当前路径不匹配时,需要及时剪枝返回。
bool dfs_search(vector<vector<char>>& board, string& word, int index, int i, int j) {
    if (index == word.size()) return true; // 找到完整单词
    if (i<0 || i>=board.size() || j<0 || j>=board[0].size() || board[i][j] != word[index]) {
        return false; // 越界或不匹配,剪枝
    }
    
    char temp = board[i][j];
    board[i][j] = '#'; // 标记为已访问,防止重复使用
    
    bool found = dfs_search(board, word, index+1, i+1, j)
              || dfs_search(board, word, index+1, i-1, j)
              || dfs_search(board, word, index+1, i, j+1)
              || dfs_search(board, word, index+1, i, j-1);
    
    board[i][j] = temp; // 回溯,恢复状态
    return found;
}

通过对比这些题目,你会发现,DFS在网格问题中的应用就像一个可定制的搜索框架。你需要根据问题的具体需求,调整DFS函数的返回值(void, bool, int)、状态标记与恢复策略(永久标记 vs 临时标记+回溯)、以及启动DFS的时机和条件。理解了这个框架,再遇到新的网格问题,你就能快速定位解题方向,而不是从头开始摸索。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值