算法思维-暴力搜索算法
🚮

算法思维-暴力搜索算法

AI custom autofill
回溯解决子集、排列、组合的各类问题,球盒模型。以及岛屿类问题。
Tags
CS
经典算法
回溯算法
DFS
Published
March 22, 2024
算法的本质是穷举,所以暴力搜索是很常用的算法。而 回溯算法、DFS、BFS是最常见的暴力穷举算法,这些算法是又二叉树算法衍生出来。这一部分将这几种关系分别阐述。
 

一 回溯算法 解决子集、排列、组合问题

 
 
无论是排列、组合还是子集问题,简单说无非就是让你从序列 nums 中以给定规则取若干元素,主要有以下几种变体:
组合例题:
形式一、元素无重不可复选,即 nums 中的元素都是唯一的,每个元素最多只能被使用一次,这也是最基本的形式
以组合为例,如果输入 nums = [2,3,6,7],和为 7 的组合应该只有 [7]
形式二、元素可重不可复选,即 nums 中的元素可以存在重复,每个元素最多只能被使用一次
以组合为例,如果输入 nums = [2,5,2,1,2],和为 7 的组合应该有两种 [2,2,2,1] 和 [5,2]
形式三、元素无重可复选,即 nums 中的元素都是唯一的,每个元素可以被使用若干次
以组合为例,如果输入 nums = [2,3,6,7],和为 7 的组合应该有两种 [2,2,3] 和 [7]
 
无论形式怎么变化,其本质就是穷举所有解,而这些解呈现树形结构,所以合理使用回溯算法框架,稍改代码框架即可把这些问题一网打尽
主要关键点如下:
notion image
notion image
首先,组合问题和子集问题其实是等价的,这个后面会讲;至于之前说的三种变化形式,无非是在这两棵树上剪掉或者增加一些树枝罢了
 

1.1 子集(元素无重复不可复选)

例题78
题目给你输入一个无重复元素的数组 nums,其中每个元素最多使用一次,请你返回 nums 的所有子集。
这道题的解,即为该子集树的全部节点,即为所有的子集集合。
notion image
notion image
List<List<Integer>> res = new ArrayList<>(); // 记录回溯算法的递归路径 LinkedList<Integer> track = new LinkedList<>(); public List<List<Integer>> subsets(int[] nums) { backtrack(nums, 0); return res; } private void backtrack(int[] nums, int start) { // 前序位置,每个节点的值都是一个子集 res.add(new LinkedList<>(track)); // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 做选择 track.addLast(nums[i]); // 通过 start 参数控制树枝的遍历,避免产生重复的子集 // // 注意,该start值为i+1,元素不可重复使用,使用后,下面只能从该元素后开始遍历添加 backtrack(nums, i + 1); // 撤销选择 track.removeLast(); } }
🌒
注意,输出到res时候,需要new一个list并赋值track。避免后续track变化而造成输出变化

1.2 组合(元素无重复不可复选)

第 77 题「组合open in new window」:
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
 
如果能够成功的生成所有无重子集,那么稍微改改代码就能生成所有无重组合。比如说,在 nums = [1,2,3] 中拿 2 个元素形成所有的组合,就是所有大小为 2 的子集。
所以组合和子集是一样的:大小为 k 的组合就是大小为 k 的子集
private List<List<Integer>> res = new ArrayList<>(); private LinkedList<Integer> track = new LinkedList<>(); public List<List<Integer>> combine(int n, int k) { backtrack(n, k, 1); return res; } private void backtrack(int n, int k, int start) { if (track.size() == k) { res.add(new ArrayList<>(track)); return; } for (int i = start; i <= n; i++) { // 做选择 track.addLast(i); // 注意,该start值为i+1,元素不可重复使用,使用后,下面只能从该元素后开始遍历添加 backtrack(n, k, i + 1); // 撤销选择 track.removeLast(); } }

1.3 排列(元素无重复不可复选)

力扣第 46 题「全排列open in new window」就是标准的排列问题:
给定一个不含重复数字的数组 nums,返回其所有可能的全排列
用 used 数组标记已经在路径上的元素避免重复选择,然后收集所有叶子节点上的值,就是所有全排列的结果:
List<List<Integer>> res = new ArrayList<>(); LinkedList<Integer> trace = new LinkedList<>(); boolean[] used; public List<List<Integer>> permute(int[] nums) { used = new boolean[nums.length]; backtrack(nums, trace, used); return res; } // 路径:记录在 track 中 // 选择列表:nums 中不存在于 track 的那些元素(used[i] 为 false) // 结束条件:nums 中的元素全都在 track 中出现 public void backtrack(int[] nums, LinkedList<Integer> trace, boolean[] used) { if (trace.size() == nums.length) { res.add(new ArrayList<>(trace)); return; } for (int i = 0; i < nums.length; i++) { // 排除不合法的选择 if (used[i]) { // nums[i] 已经在 track 中,跳过 continue; } // 选择 used[i] = true; trace.add(nums[i]); // 下一层决策 backtrack(nums, trace, used); // 取消选择 used[i] = false; trace.removeLast(); } }
 

1.4 子集、组合(元素可重复,不可复选)

刚才讲的标准子集问题输入的 nums 是没有重复元素的,但如果存在重复元素,怎么处理呢?
力扣第 90 题「子集 IIopen in new window」就是这样一个问题:
给你一个整数数组 nums,其中可能包含重复元素,请你返回该数组所有可能的子集。
 
以 nums = [1,2,2] 为例,为了区别两个 2 是不同元素,后面我们写作 nums = [1,2,2']
按照之前的思路画出子集的树形结构,显然,两条值相同的相邻树枝会产生重复:
notion image
[ [], [1],[2],[2'], [1,2],[1,2'],[2,2'], [1,2,2'] ]
针对重复的节点需要进行剪枝,如果一个节点有多条值相同的树枝相邻,则只遍历第一条,剩下的都剪掉,不要去遍历:
notion image
体现在代码上,需要先进行排序,让相同的元素靠在一起,如果发现 nums[i] == nums[i-1],则跳过。
其中跳过条件:i > start 是确保在同一层上进行跳过。
List<List<Integer>> res = new ArrayList<>(); LinkedList<Integer> track = new LinkedList<>(); public List<List<Integer>> subsetsWithDup(int[] nums) { Arrays.sort(nums); backtrack(nums, 0); return res; } private void backtrack(int[] nums, int start) { // 前序,每个节点都加入res res.add(new ArrayList<>(track)); for (int i = start; i < nums.length; i++) { if (i > start && nums[i] == nums[i - 1]) { // 同一level,相同的元素进行剪枝 continue; } // 选择 track.addLast(nums[i]); backtrack(nums, i + 1); // 撤销选择 track.removeLast(); } }
 
组合问题和子集问题是等价的,所以我们直接看一道组合的题目吧,这是力扣第 40 题「组合总和 IIopen in new window」:
给你输入 candidates 和一个目标和 target,从 candidates 中找出中所有和为 target 的组合。
candidates 可能存在重复元素,且其中的每个数字最多只能使用一次。
class Solution { List<List<Integer>> res = new LinkedList<>(); // 记录回溯的路径 LinkedList<Integer> track = new LinkedList<>(); // 记录 track 中的元素之和 int trackSum = 0; public List<List<Integer>> combinationSum2(int[] candidates, int target) { if (candidates.length == 0) { return res; } // 先排序,让相同的元素靠在一起 Arrays.sort(candidates); backtrack(candidates, 0, target); return res; } // 回溯算法主函数 void backtrack(int[] nums, int start, int target) { // base case,达到目标和,找到符合条件的组合 if (trackSum == target) { res.add(new LinkedList<>(track)); return; } // base case,超过目标和,直接结束 if (trackSum > target) { return; } // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 剪枝逻辑,值相同的树枝,只遍历第一条 if (i > start && nums[i] == nums[i - 1]) { continue; } // 做选择 track.add(nums[i]); trackSum += nums[i]; // 递归遍历下一层回溯树 backtrack(nums, i + 1, target); // 撤销选择 track.removeLast(); trackSum -= nums[i]; } } }
 

1.5 排列(元素可重复,不可复选)

力扣第 47 题「全排列 IIopen in new window」:
给你输入一个可包含重复数字的序列 nums,请你写一个算法,返回所有可能的全排列,函数签名如下:
class Solution { private List<List<Integer>> res = new LinkedList<>(); private LinkedList<Integer> track = new LinkedList<>(); public List<List<Integer>> permuteUnique(int[] nums) { Arrays.sort(nums); boolean[] used = new boolean[nums.length]; backtrack(nums, used); return res; } private void backtrack(int[] nums, boolean[] used) { if (track.size() == nums.length) { res.add(new LinkedList<>(track)); return; } for (int i = 0; i < nums.length; i++) { if (used[i]) { continue; } // 新添加的剪枝逻辑,固定相同的元素在排列中的相对位置 // 要点:保证相同元素在排列中的相对位置保持不变。 // 比如说 nums = [1,2,2']保持排列中 2 一直在 2' 前面。[ [1,2,2'],[2,1,2'],[2,2',1] ] // 进一步,如果 nums = [1,2,2',2''],我只要保证重复元素 2 的相对位置固定,比如说 2 -> 2' -> 2'',也可以得到无重复的全排列结果。 // 当出现重复元素时,比如输入 nums = [1,2,2',2''],2' 只有在 2 已经被使用的情况下才会被选择, // 同理,2'' 只有在 2' 已经被使用的情况下才会被选择,这就保证了相同元素在排列中的相对位置保证固定。 if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) { continue; } // 选择 used[i] = true; track.addLast(nums[i]); backtrack(nums, used); // 取消选择 used[i] = false; track.removeLast(); } } }
重点是剪枝判断,!used[i - 1] 和 used[i - 1] 均可通过所有用例,但是前者效率更高。
如果用绿色树枝代表 backtrack 函数遍历过的路径,红色树枝代表剪枝逻辑的触发,那么 !used[i - 1] 这种剪枝逻辑得到的回溯树长这样
notion image

1.6 子集/组合(元素无重,可复选)

力扣第 39 题「组合总和open in new window」:
给你一个无重复元素的整数数组 candidates 和一个目标和 target,找出 candidates 中可以使数字和为目标数 target 的所有组合。candidates 中的每个数字可以无限制重复被选取
 
标准的子集/组合问题通过在回溯时入参start 为i+1 来保证数据不重复使用,所以在可以重复使用的题目中,入参传入i 即可实现重复使用。同时为避免树无法停止,在base case中加入>target的判断,就可以完成该题目。
class Solution { List<List<Integer>> res = new ArrayList<>(); LinkedList<Integer> track = new LinkedList<>(); int trackSum = 0; public List<List<Integer>> combinationSum(int[] candidates, int target) { Arrays.sort(candidates); backtrack(candidates, target, 0); return res; } private void backtrack(int[] candidates, int target, int start) { if (trackSum == target) { res.add(new ArrayList<>(track)); return; } for (int i = start; i < candidates.length; i++) { if (candidates[i] + trackSum > target) { continue; } // 选择 trackSum = trackSum + candidates[i]; track.addLast(candidates[i]); backtrack(candidates, target, i); // 取消选择 trackSum = trackSum - candidates[i]; track.removeLast(); } } }

1.7 排列(元素无重,可复选)

标准的全排列算法利用 used 数组进行剪枝,避免重复使用同一个元素。如果允许重复使用元素的话,直接放飞自我,去除所有 used 数组的剪枝逻辑就行了
 

1.8 总结

来回顾一下排列/组合/子集问题的三种形式在代码上的区别。
由于子集问题和组合问题本质上是一样的,无非就是 base case 有一些区别,所以把这两个问题放在一起看。
形式一、元素无重不可复选,即 nums 中的元素都是唯一的,每个元素最多只能被使用一次backtrack 核心代码如下:
/* 组合/子集问题回溯算法框架 */ void backtrack(int[] nums, int start) { // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 做选择 track.addLast(nums[i]); // 注意参数 backtrack(nums, i + 1); // 撤销选择 track.removeLast(); } } /* 排列问题回溯算法框架 */ void backtrack(int[] nums) { for (int i = 0; i < nums.length; i++) { // 剪枝逻辑 if (used[i]) { continue; } // 做选择 used[i] = true; track.addLast(nums[i]); backtrack(nums); // 撤销选择 track.removeLast(); used[i] = false; } }
形式二、元素可重不可复选,即 nums 中的元素可以存在重复,每个元素最多只能被使用一次,其关键在于排序和剪枝backtrack 核心代码如下:
Arrays.sort(nums); /* 组合/子集问题回溯算法框架 */ void backtrack(int[] nums, int start) { // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 剪枝逻辑,跳过值相同的相邻树枝 if (i > start && nums[i] == nums[i - 1]) { continue; } // 做选择 track.addLast(nums[i]); // 注意参数 backtrack(nums, i + 1); // 撤销选择 track.removeLast(); } } Arrays.sort(nums); /* 排列问题回溯算法框架 */ void backtrack(int[] nums) { for (int i = 0; i < nums.length; i++) { // 剪枝逻辑 if (used[i]) { continue; } // 剪枝逻辑,固定相同的元素在排列中的相对位置 if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) { continue; } // 做选择 used[i] = true; track.addLast(nums[i]); backtrack(nums); // 撤销选择 track.removeLast(); used[i] = false; } }
形式三、元素无重可复选,即 nums 中的元素都是唯一的,每个元素可以被使用若干次,只要删掉去重逻辑即可,backtrack 核心代码如下:
/* 组合/子集问题回溯算法框架 */ void backtrack(int[] nums, int start) { // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 做选择 track.addLast(nums[i]); // 注意参数 backtrack(nums, i); // 撤销选择 track.removeLast(); } } /* 排列问题回溯算法框架 */ void backtrack(int[] nums) { for (int i = 0; i < nums.length; i++) { // 做选择 track.addLast(nums[i]); backtrack(nums); // 撤销选择 track.removeLast(); } }

二 回溯算法-球盒模型

首先回顾下排列和组合的基本公式:
1、P(n, k)(也有很多书写成 A(n, k))表示从 n 个不同元素中拿出 k 个元素的排列(Permutation/Arrangement)总数;C(n, k) 表示从 n 个不同元素中拿出 k 个元素的组合(Combination)总数。
2、「排列」和「组合」的主要区别在于是否考虑顺序的差异。
3、排列、组合总数的计算公式:
notion image
在分球时候,有两种视角。
以盒的视角,每个盒子必然要选择一个球。第一个盒子可以选择 n 个球中的任意一个,然后你需要让剩下 k - 1 个盒子在 n - 1 个球中选择。
以球的视角,因为并不是每个球都会被装进盒子,所以球的视角分两种情况:
1、第一个球可以不装进任何一个盒子,这样的话你就需要将剩下 n - 1 个球放入 k 个盒子。
2、第一个球可以装进 k 个盒子中的任意一个,这样的话你就需要将剩下 n - 1 个球放入 k - 1 个盒子
 
题目:
给你输入一个数组 nums 和一个正整数 k,请你判断 nums 是否能够被平分为元素和相同的 k 个子集。
函数签名如下:
boolean canPartitionKSubsets(int[] nums, int k);
 
类比球盒模型,有两种方式:
视角一,如果我们切换到这 n 个数字的视角,每个数字都要选择进入到 k 个桶中的某一个
 
视角二,如果我们切换到这 k 个桶的视角,对于每个桶,都要遍历 nums 中的 n 个数字,然后选择是否将当前遍历到的数字装进自己这个桶里
 

2.1 球视角

如果以第一种视角,每个数字要进入k桶,遍历框架如下:
// k 个桶(集合),记录每个桶装的数字之和 int[] bucket = new int[k]; // 穷举 nums 中的每个数字 void backtrack(int[] nums, int index) { // base case if (index == nums.length) { return; } // 穷举每个桶 for (int i = 0; i < bucket.length; i++) { // 选择装进第 i 个桶 bucket[i] += nums[index]; // 递归穷举下一个数字的选择 backtrack(nums, index + 1); // 撤销选择 bucket[i] -= nums[index]; } }
该方式通过代码如下:
// 主函数 boolean canPartitionKSubsets(int[] nums, int k) { // 排除一些基本情况 if (k > nums.length) return false; int sum = 0; for (int v : nums) sum += v; if (sum % k != 0) return false; // k 个桶(集合),记录每个桶装的数字之和 int[] bucket = new int[k]; // 理论上每个桶(集合)中数字的和 int target = sum / k; // 穷举,看看 nums 是否能划分成 k 个和为 target 的子集 return backtrack(nums, 0, bucket, target); } // 递归穷举 nums 中的每个数字 boolean backtrack( int[] nums, int index, int[] bucket, int target) { if (index == nums.length) { // 检查所有桶的数字之和是否都是 target for (int i = 0; i < bucket.length; i++) { if (bucket[i] != target) { return false; } } // nums 成功平分成 k 个子集 return true; } // 穷举 nums[index] 可能装入的桶 for (int i = 0; i < bucket.length; i++) { // 剪枝,桶装装满了 if (bucket[i] + nums[index] > target) { continue; } // 将 nums[index] 装入 bucket[i] bucket[i] += nums[index]; // 递归穷举下一个数字的选择 if (backtrack(nums, index + 1, bucket, target)) { return true; } // 撤销选择 bucket[i] -= nums[index]; } // nums[index] 装入哪个桶都不行 return false; }
这里通过将所有数字放入桶中,同时以base cas、边界条件和剪枝。当放入数字时都check是否为最终结果,通过暴力来寻求答案。但是这种方式解题会超时,下面来看第二种视角。
 

2.2 桶视角

该视角框架如下:
// 装满所有桶为止 while (k > 0) { // 记录当前桶中的数字之和 int bucket = 0; for (int i = 0; i < nums.length; i++) { // 决定是否将 nums[i] 放入当前桶中 if (canAdd(bucket, num[i])) { bucket += nums[i]; } if (bucket == target) { // 装满了一个桶,装下一个桶 k--; break; } } }
将该框架修改为递归方式实现:
boolean backtrack(int k, int bucket, int[] nums, int start, boolean[] used, int target);
现在 k 号桶正在思考是否应该把 nums[start] 这个元素装进来;目前 k 号桶里面已经装的数字之和为 bucketused 标志某一个元素是否已经被装到桶中;target 是每个桶需要达成的目标和。
代码实现如下:
boolean canPartitionKSubsets(int[] nums, int k) { // 排除一些基本情况 if (k > nums.length) return false; int sum = 0; for (int v : nums) sum += v; if (sum % k != 0) return false; boolean[] used = new boolean[nums.length]; int target = sum / k; // k 号桶初始什么都没装,从 nums[0] 开始做选择 return backtrack(k, 0, nums, 0, used, target); } boolean backtrack(int k, int bucket, int[] nums, int start, boolean[] used, int target) { // base case if (k == 0) { // 所有桶都被装满了,而且 nums 一定全部用完了 // 因为 target == sum / k return true; } if (bucket == target) { // 装满了当前桶,递归穷举下一个桶的选择 // 让下一个桶从 nums[0] 开始选数字 return backtrack(k - 1, 0 ,nums, 0, used, target); } // 从 start 开始向后探查有效的 nums[i] 装入当前桶 for (int i = start; i < nums.length; i++) { // 剪枝 if (used[i]) { // nums[i] 已经被装入别的桶中 continue; } if (nums[i] + bucket > target) { // 当前桶装不下 nums[i] continue; } // 做选择,将 nums[i] 装入当前桶中 used[i] = true; bucket += nums[i]; // 递归穷举下一个数字是否装入当前桶 if (backtrack(k, bucket, nums, i + 1, used, target)) { return true; } // 撤销选择 used[i] = false; bucket -= nums[i]; } // 穷举了所有数字,都无法装满当前桶 return false; }

2.3 总结

通俗来说,我们应该尽量「少量多次」,就是说宁可多做几次选择(乘法关系),也不要给太大的选择空间(指数关系);做 n 次「k 选一」仅重复一次(O(k^n)),比 n 次「二选一」重复 k 次(O(k*2^n))效率低很多。

三 DFS岛屿问题

类似与二叉树的遍历,岛屿问题就是二维矩阵的遍历。可以根据二叉树的DFS框架,改写出适用于二维矩阵的遍历。框架如下:
因为二维矩阵本质上是一幅「图」,所以遍历的过程中需要一个 visited 布尔数组防止走回头路
// 二叉树遍历框架 void traverse(TreeNode root) { traverse(root.left); traverse(root.right); } // 二维矩阵遍历框架 void dfs(int[][] grid, int i, int j, boolean[][] visited) { int m = grid.length, n = grid[0].length; 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); // 右 }
 
基本问题:
给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
class Solution { // 主函数,计算岛屿数量 int numIslands(char[][] grid) { int res = 0; int m = grid.length, n = grid[0].length; // 遍历 grid for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (grid[i][j] == '1') { // 每发现一个岛屿,岛屿数量加一 res++; // 然后使用 DFS 将岛屿淹了 dfs(grid, i, j); } } } return res; } // 从 (i, j) 开始,将与之相邻的陆地都变成海水 void dfs(char[][] grid, int i, int j) { int m = grid.length, n = grid[0].length; if (i < 0 || j < 0 || i >= m || j >= n) { // 超出索引边界 return; } if (grid[i][j] == '0') { // 已经是海水了 return; } // 将 (i, j) 变成海水 grid[i][j] = '0'; // 淹没上下左右的陆地 dfs(grid, i + 1, j); dfs(grid, i, j + 1); dfs(grid, i - 1, j); dfs(grid, i, j - 1); } }
为什么每次遇到岛屿,都要用 DFS 算法把岛屿「淹了」呢?主要是为了省事,避免维护 visited 数组
 
题目变形:寻找封闭岛屿的数量。
二维矩阵 grid 由 0 (土地)和 1 (水)组成。岛是由最大的4个方向连通的 0 组成的群,封闭岛是一个 完全 由1包围(左、上、右、下)的岛。
请返回 封闭岛屿 的数目。
该题目与原型相比,需要去掉四个边缘相连的岛屿,所以只需要在遍历前,将岛屿的数量去除掉,即先遍历四条边,淹没岛屿但是不在res记录。如下:
public int closedIsland(int[][] grid) { int m = grid.length; int n = grid[0].length; int res = 0; // 遍历边缘地区,淹没边缘地区 for (int i = 0; i < m; i++) { dfs(grid, i, 0); dfs(grid, i, n - 1); } for (int j = 0; j < n; j++) { dfs(grid, 0, j); dfs(grid, m - 1, j); } // 寻找封闭岛屿 for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (grid[i][j] == 0) { res++; dfs(grid, i, j); } } } return res; }
 
变形2:
notion image
当岛屿 B 中所有陆地在岛屿 A 中也是陆地的时候,岛屿 B 是岛屿 A 的子岛。
反过来说,如果岛屿 B 中存在一片陆地,在岛屿 A 的对应位置是海水,那么岛屿 B 就不是岛屿 A 的子岛
class Solution { int countSubIslands(int[][] grid1, int[][] grid2) { int m = grid1.length, n = grid1[0].length; for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (grid1[i][j] == 0 && grid2[i][j] == 1) { // 这个岛屿肯定不是子岛,淹掉 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); } } } return res; } // 从 (i, j) 开始,将与之相邻的陆地都变成海水 void dfs(int[][] grid, int i, int j) { int m = grid.length, n = grid[0].length; 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); } }