二维矩阵DFS模板解决岛屿系列问题,tracerse,backtrack,dfs返回值的讨论

发布于:2025-05-01 ⋅ 阅读:(17) ⋅ 点赞:(0)

目录

前言

二维矩阵的DFS框架代码

岛屿数量

题目思路:

代码实现:

封闭岛屿数量

题目思路

代码实现:

子岛屿数量

题目思路:

代码实现:

岛屿的最大面积

题目思路:

代码实现:

岛屿问题总结

建议backtrack/dfs/traverse 函数尽可能保持void


前言

本文的主要思路来源于bilibil博主labuladong,他的算法网页为:

一文秒杀所有岛屿题目 | labuladong 的算法笔记

越看东哥越觉得牛逼,强烈建议大家去东哥的网站上刷题,狠狠推荐!

二维矩阵的DFS框架代码

岛屿系列算法问题是经典的面试高频题,看起来可能比较无从下手,但是这类问题都可以使用dfs框架来完成,掌握了二维数组的dfs框架,这样的问题就会轻松很多,本文就来把这些问题一网打尽。

岛屿系列题目的核心考点就是用 DFS/BFS 算法遍历二维数组

本文主要来讲解如何用 DFS 算法来秒杀岛屿系列题目:

那么如何在二维矩阵中使用 DFS 搜索呢?如果你把二维矩阵中的每一个位置看做一个节点,这个节点的上下左右四个位置就是相邻节点,那么整个矩阵就可以抽象成一幅网状的「图」结构。

如果学习过二叉树递归遍历中的模板的话,完全可以根据二叉树的遍历框架改写出二维矩阵的 DFS 代码框架:

让我们简单回顾一下二叉树中的递归遍历模板:

// 二叉树遍历框架
void traverse(TreeNode* root) {
    traverse(root->left);
    traverse(root->right);
}

下面就是我们的二维矩阵的dfs框架:

因为二维矩阵本质上是一幅「图」,所以遍历的过程中需要一个 visited 布尔数组防止走回头路,如果你能理解下面这段代码,那么搞定所有岛屿系列题目都很简单。

// 二维矩阵遍历框架
void dfs(vector<vector<int>>& grid, int i, int j, vector<vector<bool>>& visited) {
    int m = grid.size(), n = grid[0].size();
    if (i < 0 || j < 0 || i >= m || j >= n) {
        // 超出索引边界
        return;
    }
    if (visited[i][j]) {
        // 已遍历过 (i, j)
        return;
    }

    // 进入当前节点 (i, j)
    visited[i][j] = true;

    // 进入相邻节点(四叉树)
    // 上
    dfs(grid, i - 1, j, visited);
    // 下
    dfs(grid, i + 1, j, visited);
    // 左
    dfs(grid, i, j - 1, visited);
    // 右
    dfs(grid, i, j + 1, visited);
}

岛屿数量

题目思路:

题目链接:

200. 岛屿数量 - 力扣(LeetCode)

题目会输入一个二维数组 grid,其中只包含 0 或者 10 代表海水,1 代表陆地,且假设该矩阵四周都是被海水包围着的。

我们说连成片的陆地形成岛屿,那么请你写一个算法,计算这个矩阵 grid 中岛屿的个数。

比如说题目给你输入下面这个 grid 有四片岛屿,算法应该返回 4:

思路很简单,关键在于如何寻找并标记「岛屿」,这就要 DFS 算法发挥作用了,我们直接看解法代码:

代码实现:

#include<bits/stdc++.h>
using namespace std;

//前向声明
void dfs(int i, int j, vector<vector<int>>& grid, int n, int m);
// 主函数,计算岛屿数量
int numIslands(vector<vector<int>>& grid) {
    int n = grid.size();
    int m = grid[0].size();
    int Island_num = 0;
    for(int i=0; i<n; i++){
        for(int j=0; j<m; j++){
            if(grid[i][j] == 1){
                // 每发现一个岛屿,岛屿数量加一
                Island_num++;
                // 然后使用 DFS 将岛屿淹了
                dfs(i, j, grid, n, m);
            }
        }
    }
    return Island_num;
}

//dfs的作用是利用深度优先算法将grid[i][j]这块陆地以及周围的陆地全部变成海洋,表示已经访问过
void dfs(int i, int j, vector<vector<int>>& grid, int n, int m){
    //注意这里关于边界条件应该先判断,然后再判断是否为海洋,不然可能数组越界
    if(i<0 || j<0 || i>=n || j>=m){
        return;
    }
    
    if(grid[i][j] == 0){
        return;
    }

    // 将 (i, j) 变成海水
    grid[i][j] = 0;
    // 淹没上下左右的陆地,这里下面四个函数调用顺序随意
    dfs(i, j-1, grid, n, m);//左
    dfs(i, j+1, grid, n, m);//右
    dfs(i-1, j, grid, n, m);//上
    dfs(i+1, j, grid, n, m);//下

}
int main(){
    //grid是一个二维数组,表示的是陆地和海洋,1表示陆地,0表示海洋
    vector<vector<int>> grid = {
        {1, 1, 1, 1, 0},
        {1, 1, 0, 1, 0},
        {1, 1, 0, 0, 0},
        {0, 0, 0, 0, 1}
    };

    cout << "numIslands: " << numIslands(grid);

    return 0;
}

为什么每次遇到岛屿,都要用 DFS 算法把岛屿「淹了」呢?主要是为了省事,避免维护 visited 数组

因为 dfs 函数遍历到值为 0 的位置会直接返回,所以只要把经过的位置都设置为 0,就可以起到不走回头路的作用。

这类可以凭借技巧不使用visited的 DFS 算法还有个别名叫做 FloodFill 算法,现在有没有觉得 FloodFill 这个名字还挺贴切的~

洪水填充有点蔓延的感觉,简直是:

妙蛙种子吃着妙脆角妙进了米奇妙妙屋,妙到家了!!!

封闭岛屿数量

题目思路

题目链接:

1254. 统计封闭岛屿的数目 - 力扣(LeetCode)

题目描述:

二维矩阵 grid 由 0 (土地)和 1 (水)组成。岛是由最大的4个方向连通的 0 组成的群,封闭岛是一个 完全 由1包围(左、上、右、下)的岛。

请返回 封闭岛屿 的数目。

比如题目给你输入如下这个二维矩阵:

算法返回 2,只有图中灰色部分的 0 是四周全都被海水包围着的「封闭岛屿」。

那么如何判断「封闭岛屿」呢?其实很简单,把上一题中那些靠边的岛屿排除掉,剩下的不就是「封闭岛屿」了吗

代码实现:

#include<bits/stdc++.h>
using namespace std;

void dfs(int i, int j, int n, int m, vector<vector<int>>& grid);

int Island_num(vector<vector<int>>& grid){
    int n = grid.size();
    int m = grid[0].size();
    int island_num = 0;

    //将第一列和最后一列的岛屿进行清除
    for(int i=0; i<n; i++){
        dfs(i, 0, n, m, grid);
        dfs(i, m-1, n, m, grid);
    }

    //将第一行和最后一行的岛屿进行清除
    for(int i=0; i<m; i++){
        dfs(0, i, n, m, grid);
        dfs(n-1, i, n, m, grid);
    }
    // 遍历 grid,剩下的岛屿都是封闭岛屿
    for(int i=0; i<n; i++){
        for(int j=0; j<m; j++){
            if(grid[i][j] == 1){
                island_num++;
                dfs(i, j, n, m, grid);
            }
        }
    }
    return island_num;
}


void dfs(int i, int j, int n, int m, vector<vector<int>>& grid){
    //如果边界越界的话,就返回
    if(i<0 || j<0 || i>=n || j>=m){
        return;
    }
    //如果是海水的话,就直接返回
    if(grid[i][j] == 0){
        return;
    }
    //把陆地淹没成海水
    grid[i][j] = 0;

    dfs(i-1, j, n, m, grid);
    dfs(i+1, j, n, m, grid);
    dfs(i, j-1, n, m, grid);
    dfs(i, j+1, n, m, grid);

}
int main(){
    //1表示陆地,0表示海水
    vector<vector<int>> grid = {
        {1, 1, 1, 1, 0},
        {1, 1, 0, 1, 0},
        {1, 0, 1, 0, 0},
        {0, 0, 0, 0, 1}
    };
    cout << "island_num: " << Island_num(grid);


    return 0;
}

子岛屿数量

题目思路:

题目链接:

1905. 统计子岛屿 - 力扣(LeetCode)

题目描述:

给你两个 m x n 的二进制矩阵 grid1 和 grid2 ,它们只包含 0 (表示水域)和 1 (表示陆地)。一个 岛屿 是由 四个方向 (水平或者竖直)上相邻的 1 组成的区域。任何矩阵以外的区域都视为水域。

如果 grid2 的一个岛屿,被 grid1 的一个岛屿 完全 包含,也就是说 grid2 中该岛屿的每一个格子都被 grid1 中同一个岛屿完全包含,那么我们称 grid2 中的这个岛屿为 子岛屿 。

grid1和grid2大小一模一样

请你返回 grid2 中 子岛屿 的 数目 。

这道题的关键在于,如何快速判断子岛屿

什么情况下 grid2 中的一个岛屿 B 是 grid1 中的一个岛屿 A 的子岛?

当岛屿 B 中所有陆地在岛屿 A 中也是陆地的时候,岛屿 B 是岛屿 A 的子岛。

反过来说,如果岛屿 B 中存在一片陆地,在岛屿 A 的对应位置是海水,那么整个岛屿 B 就不是岛屿 A 的子岛

那么,我们只要遍历 grid2 中的所有岛屿,把那些不可能是子岛的岛屿排除掉,剩下的就是子岛。

代码实现:

class Solution {
    public:
        int countSubIslands(vector<vector<int>>& grid1, vector<vector<int>>& grid2) {
            int m = grid2.size(), n = grid2[0].size();
            for (int i = 0; i < m; i++) {
                for (int j = 0; j < n; j++) {
                    if (grid2[i][j] == 1 && grid1[i][j] == 0) {
                        // 这个岛屿肯定不是子岛,淹掉
                        dfs(grid2, i, j);
                    }
                }
            }
            // 现在 grid2 中剩下的岛屿都是子岛,计算岛屿数量
            int res = 0;
            for (int i = 0; i < m; i++) {
                for (int j = 0; j < n; j++) {
                    if (grid2[i][j] == 1) {
                        res++;
                        dfs(grid2, i, j, m, n);
                    }
                }
            }
            return res;
        }
    
    private:   
        // 从 (i, j) 开始,将与之相邻的陆地都变成海水
        void dfs(vector<vector<int>>& grid, int i, int j, int m, int n) {
            if (i < 0 || j < 0 || i >= m || j >= n) {
                return;
            }
            if (grid[i][j] == 0) {
                return;
            }
    
            grid[i][j] = 0;
            dfs(grid, i + 1, j);
            dfs(grid, i, j + 1);
            dfs(grid, i - 1, j);
            dfs(grid, i, j - 1);
        }
    };

这道题的思路和计算「封闭岛屿」数量的思路有些类似,只不过后者排除那些靠边的岛屿,前者排除那些grid2中的岛屿但是不可能是grid1子岛的岛屿。

这里排除grid2中的非grid1的子岛核心代码是:

if (grid2[i][j] == 1 && grid1[i][j] == 0) {
    // 这个岛屿肯定不是子岛,淹掉
    dfs(grid2, i, j);
}

又是一个很巧妙的技巧!

妙蛙种子吃着妙脆角妙进了米奇妙妙屋,妙到家了!!!

岛屿的最大面积

题目思路:

题目链接:

695. 岛屿的最大面积 - 力扣(LeetCode)

题目描述: 

给你一个大小为 m x n 的二进制矩阵 grid 。

岛屿 是由一些相邻的 1 (代表土地) 构成的组合,这里的「相邻」要求两个 1 必须在 水平或者竖直的四个方向上 相邻。你可以假设 grid 的四个边缘都被 0(代表水)包围着。

岛屿的面积是岛上值为 1 的单元格的数目。

计算并返回 grid 中最大的岛屿面积。如果没有岛屿,则返回面积为 0 。

比如题目给你输入如下一个二维矩阵:

其中面积最大的是橘红色的岛屿,算法返回它的面积 6。

这题的大体思路和之前完全一样,只不过 dfs 函数淹没岛屿的同时,还应该想办法记录这个岛屿的面积

代码实现:

#include<bits/stdc++.h>
using namespace std;

//前向声明
void dfs(int& max_island_temp, int i, int j, vector<vector<int>>& grid, int n, int m);

// 主函数,计算面积最大岛屿
int Max_Island(vector<vector<int>>& grid) {
    int n = grid.size();
    int m = grid[0].size();
    int max_island = 0;

    for(int i=0; i<n; i++){
        for(int j=0; j<m; j++){
            if(grid[i][j] == 1){
                // 每发现一个岛屿,就使用 DFS 将岛屿淹了
                int max_island_temp = 0;
                dfs(max_island_temp, i, j, grid, n, m);
                max_island = max(max_island, max_island_temp);
            }
        }
    }
    return max_island;
}

//dfs的作用是利用深度优先算法将grid[i][j]这块陆地以及周围的陆地全部变成海洋,表示已经访问过
void dfs(int& max_island_temp, int i, int j, vector<vector<int>>& grid, int n, int m){
    //注意这里关于边界条件应该先判断,然后再判断是否为海洋,不然可能数组越界
    if(i<0 || j<0 || i>=n || j>=m){
        return;
    }
    
    if(grid[i][j] == 0){
        return;
    }

    // 将 (i, j) 变成海水
    grid[i][j] = 0;
    max_island_temp++;
    // 淹没上下左右的陆地,这里下面四个函数调用顺序随意
    dfs(max_island_temp, i, j-1, grid, n, m);//左
    dfs(max_island_temp, i, j+1, grid, n, m);//右
    dfs(max_island_temp, i-1, j, grid, n, m);//上
    dfs(max_island_temp, i+1, j, grid, n, m);//下

}
int main(){
    //grid是一个二维数组,表示的是陆地和海洋,1表示陆地,0表示海洋
    vector<vector<int>> grid = {
        {1, 0, 1, 1, 0},
        {1, 1, 0, 1, 0},
        {1, 1, 0, 0, 0},
        {0, 0, 0, 0, 1}
    };

    cout << "max_Island: " << Max_Island(grid);

    return 0;
}

关于计算岛屿的面积的时候,识别到grid [ i ] [ j ]为陆地的时候就+1,只需要修改这样一点逻辑,就可以达到计算岛屿面积的需求。

// 将 (i, j) 变成海水
    grid[i][j] = 0;
//记录当前岛屿面积+1
    max_island_temp++;

岛屿问题总结

岛屿问题如何去做呢?

我的回答就是:背模板

labuladong的模板化刷题思路真的是太厉害了,只要将核心的模板记忆下来,我们将会发现核心问题已经迎刃而解了,背诵模板的基础上你可以适当做点题,这里有两个作用:

1.加深对于模板的记忆,不用太过于想模板怎么来的,记住模板就行了。

现在我有点觉得刷算法题和做数学题是很相似的,算法题中的模板就像数学题中的公式,我问你你用数学中的公式的时候,你需要很细致了解公式推导过程吗?你肯定就会说我会做题就行了。

同样类似的,想出来这些模板的天才,他们的想法我们也不需要很细究,我们大致理解,能够感受框架流程,思想上理解这样做是对的,清晰记忆框架,在框架基础上来做题这才是我们所需要的,如果你非要死磕从0->1这个框架为什么这样写的话,emm,那可能有点较真了,虽然有时候我也会较真为什么这样做,不过后面想到最后,还是觉得应该背模板了。

前两天刷到一个视频,说的是计算机学生在面试的时候应该如何去做算法题,其实正解就是一秒出思路,迅速把代码框架敲出来,然后在面试官给定时间内大多数时间都是进行微调,比如哪里变量漏写了一个或者不小心写了个小bug。要想做到这一点的话,本质上要求的不是我们有多好的脑子,对算法有多么深刻的理解,本质上需要的就是背诵。

如果机考的时候现场苦思冥想题目,那只能说明我们刷题不够多,对这些还没有达到背诵的程度。如果真的有那种需要我们苦思冥想的题目,那大概率我们也不会写,当然公司大概率也不会出这些题。

所以我现在的理解就是:拥抱模板~

2.多了解一下模板的灵活变通题型,有些题型可能没有见过的话还是很难想到的,或者说我们很难想到最简便的那种方式。

比如在岛屿数量这个问题中,我们熟悉模板之后,需要再做一些别的题目,这些题目的核心虽然都是用的岛屿的模板,但是里面有一些细节上的操作和一些思路还是不太一样的,有意识地练习和积累这些模板的灵活运用和变通题型,是提高在压力环境下快速解决问题能力的关键。

建议backtrack/dfs/traverse 函数尽可能保持void

这里可能只是简单提一下这个结论,由于我现在刷题量还不是很多,没办法很详细地来进行对比,所以下面我将简单谈论一下。

递归算法主要有两种思维模式,一种是「遍历」的思维,一种是「分解问题」的思维。

遍历 (Traversal): 这种思维模式主要用于探索一个结构 (如树、图、或者通过状态变化形成的搜索空间) 中的所有可能路径或所有元素。递归在这里的作用是深入到结构的下一层或下一个可能的状态。它往往用于查找所有解、执行某种访问操作,或者判断某个属性是否存在于整个结构中。前面提到的 Backtracking 和 DFS (用于图或树遍历) 很多时候就属于这种思维模式的应用。

分解问题 (Problem Decomposition / Divide and Conquer): 这种思维模式是将一个大问题分解成一个或多个规模更小、但类型相同的子问题,然后递归地解决这些子问题,最后将子问题的解组合起来形成原问题的解。这种模式强调的是问题的结构本身可以被递归地拆分。

凭借我现有的经验来说,我觉得递归算法中遍历类题目要比分解类题目多,在这些遍历类问题中我们一般推荐就是尽量将backtrack/dfs/traverse 函数函数的返回值设置成void类型

有些遍历函数加返回值是为了提前终止递归,目标是找到一个解并停止,在某些问题中,你只需要找到第一个满足条件的解就足够了。这里的话其实可以给backtrack/dfs/traverse添加返回值,从而达到快速退出递归函数的效果。

但有我认为是没有必要的,在backtrack/dfs/traverse中搅和进去一个返回值的话,个人认为代码不太好理解,因为这样递归函数中除了考虑basecase以及遍历和递归深入问题之外,还需要额外考虑一下函数返回值的处理问题,增加了我们问题处理的复杂程度。

OK这时候你就会想问了,那么如果目标是找到一个解并停止,那这样应该怎么做呢?

可以添加一个 found 外部变量,通过给backtrack/dfs/traverse传入额外的变量来进行控制。具体的题目对应有数独问题:

day22——回溯算法,全排列,生成二进制串,数独-CSDN博客

通过一个found变量来实现,如果找到一个有效解就快速退出递归。

emm,这里只是举了一个例子,其实我想说的是,为了更便于理解,在实际的递归问题中,当你想要给递归函数backtrack/dfs/traverse加上返回值的话,建议可以先想一下能不能通过其他手段来避免返回值的使用,比如说增加传入一个变量之类的,实在没办法或者用传入变量方式来解决问题很复杂,再来考虑选择增加返回值


网站公告

今日签到

点亮在社区的每一天
去签到