每周Leetcode:53、78、77、46、200

662 阅读8分钟

2021 05 13

本周整理的 5 道 Leetcode 分别为 53-最大子序和78-子集77-组合46-全排列200-岛屿数量

53 最大子序和

题目描述

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

  • 示例 1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
  • 示例 2:
输入:nums = [1]
输出:1
  • 示例 3:
输入:nums = [0]
输出:0
  • 示例 4:
输入:nums = [-1]
输出:-1
  • 示例 5:
输入:nums = [-100000]
输出:-100000
  • 提示:
1 <= nums.length <= 3 * 104
-105 <= nums[i] <= 105

来源:力扣(LeetCode)
链接:leetcode-cn.com/problems/ma…
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

解法一 暴力法

暴力法就是依次求每一个连续子数组的和,然后取最大值。用两个指针 i,j 来表示连续子数组的首尾,每次将 j 向后移动一位,求和,如果 j 移到了最后,将 i 向后移动一位,表示继续计算以下一个字符为首的所有连续子数组的和。

public int maxSubArray(int[] nums){
    int result = Integer.MIN_VALUE;
    int sum = 0;
    for(int i = 0; i < nums.length; ++i){
        // 每次子数组的首元素改变后要重新开始求和
        sum = 0;
        for(int j = i; j < nums.length;++j){
            sum += nums[j];
            result = Math.max(result,sum);
        }
    }
    return result;
}

解法二 动态规划

假设用 p[i] 表示以 nums[i] 结尾的最大连续子序列的和,那么对于 p[i+1]来说,要么取 nums[i+1] 本身,要么和 p[i] 相加,即:p[i+1] = max(nums[i+1],nums[i+1] + p[i])

需要注意的是p[i] 只能求出每次以 nums[i] 结尾的最大连续子序和,一共有 nums.length 个这样的和,最终应该返回这些和的最大值,我们也可以在计算的过程种,动态更新最终的结结果。

// 动态规划
public int maxSubArray(int[] nums) {
    int result = nums[0];
    int sum = nums[0];
    for(int i = 1; i < nums.length; ++i){
        sum = Math.max(sum + nums[i],nums[i]);
        result = Math.max(result,sum);
    }
    return result;
}

解法三 分治法

对于给定的数组,如果将数组从中间位置 mid 分为左右两部分,那么最大子序和可能有三种情况,最后只需要返回这三种情况的 较大值 即可。

  • 情况 1 :最大子序和在左半边

比如 [1,4,-1,2,-2]的最大子序和在左边,为 [1,4]

  • 情况 2 :最大子序和在右半边

比如 [2,-2,-1,4,1] 的最大子序和在右边,为 [4,1]

  • 情况 3 :最大子序和跨越了左右两边

比如 [-2,2,4,1,-1] 的最大子序和横跨左右两边,为 [2,4,1]

定义一个递归函数 int maxSubArray(int[] nums,int left,int right),计算数组中 [left,right] 范围内的最大子序和。递归的终止条件为 left == right

对于前两种情况,可以直接递归调用得到左右两侧的最大子序和。对于第 3 种情况,并不符合递归的规则,我们需要额外进行计算,下面重点看一下第 3 种情况。

第 3 种情况下,最大连续子序和以下两部分的和:

  • 左测以 mid 结尾的最大连续子序和
  • 右侧以 mid + 1 开头的最大连续子序和

例如 [-2,2,4,1,-3,4],左侧以 mid 结尾的最大连续子序和为 6;右侧以 mid+1 开头的最大连续子序和为 2。所以跨越左右两边的最大连续子序和为 8。

image.png

需要注意的是 ,第三种情况并不是左右两侧的最大连续子序和相加。这是因为一侧的最大连续子序和不一定和另一侧的是相连的,比如上图种,右侧的最大连续子序其实是 [4] ,但是它不和左侧的 [2,4] 相邻。

代码实现如下:

// 主方法
public int maxSubArray(int[] nums){
    return maxSubArray(nums,0,nums.length - 1);
}

public int maxSubArray(int[] nums,int left,int right){
    // 递归终止条件
    if(left == right){
        return nums[left];
    }
    int mid = left + (right - left)/2;
    int leftMax = maxSubArray(nums,left,mid);
    int rightMax = maxSubArray(nums,mid + 1,right);
    int midMax = midMax(nums,left,mid,right);
    return max(leftMax,rightMax,midMax);
}

// 求解第 3 种情况
private int midMax(int[] nums,int left,int mid,int right){
    int leftMax = nums[mid];
    int rightMax = nums[mid + 1];
    int sum = nums[mid];
    int i = 0;
    // 从 mid - 1 开始向前遍历
    for(i = mid - 1; i >= left; --i){
        sum += nums[i];
        leftMax = leftMax > sum ? leftMax : sum;
    }
    sum = nums[mid+1];
    // 从 mid + 2 开始向后遍历
    for(i = mid + 2; i <= right; ++i){
        sum += nums[i];
        rightMax = rightMax > sum ? rightMax : sum;
    }
    return leftMax + rightMax;
}

private int max(int a,int b,int c){
    return a > b ? (a > c ? a : c) : (b > c ? b : c);
}

78 子集

题目描述

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

  • 示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
  • 示例 2:
输入:nums = [0]
输出:[[],[0]]
  • 提示:
1 <= nums.length <= 10
-10 <= nums[i] <= 10
nums 中的所有元素 互不相同

来源:力扣(LeetCode)
链接:leetcode-cn.com/problems/su…
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

解法一 DFS

DFS (深度优先搜索)和 回溯法 都利用了递归思想,我们首先以 nums=[1,2,3] 为例,画出递归树。

image.png

由于不计算重复的集合(比如 [1,2][2,1] 是重复的),所以我们每次可以从当前元素的下一个元素开始遍历,这也是递归树由于剪枝而不完整的原因。

DFS 从递归树的根节点出发,沿着一条路径走到底,再向上返回。所以 DFS 遍历出来的子集的顺序是 [],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]

DFS 递归函数如下,其中:

  • results 表示最终的结果集
  • start 表示当前路径从数组的哪个位置开始遍历
  • subset 表示当前路径表示的子集
public void dfs(int[] nums,List<List<Integer>> results,int start,List<Integer> subset){
    // 复制一份 subset,防止引用传递
    results.add(new ArrayList<>(subset));
    // 递归终止的条件,剪枝条件
    if(start == nums.length){
        return;
    }
    for(int i = start; i < nums.length; ++i){
        // 沿着当前路径一直前进,遇到一个节点就加入到子集中
        subset.add(nums[i]);
        dfs(nums,results,i+1,subset);
        // 返回上一层后,需要将当前层加入的子集元素移除
        subset.remove(subset.size() - 1);
    }
}

调用递归函数,并返回结果:

public List<List<Integer>> subsets(int[] nums){
    List<List<Integer>> results = new ArrayList<>();
    dfs(nums,results,0,new ArrayList<Integer>());
    return results;
}

解法二 回溯法

回溯法按照子集的长度进行遍历,除了空子集外,子集的长度可以是 1 ~ nums.length。如果遍历递归的过程中,发现子集的长度等于当前的遍历的长度,则向上一层回溯。

回溯法遍历产生子集的顺序为 [],[1],[2],[3],[1,2],[1,3],[2,3],[1,2,3]

回溯递归函数的设计如下,其中:

  • len 表示当前子集的长度
  • start 表示从哪个位置的元素开始遍历,下一次回溯 start + 1 保证了不出现重复集合
// 回溯递归函数
public void backtracking(int[] nums,List<List<Integer>> results,int len,int start,List<Integer> subset){
    // 递归终止条件
    if(len == subset.size()){
        results.add(new ArrayList<>(subset));
        return ;
    }
    for(int i = start; i < nums.length; ++i){
        subset.add(nums[i]); 
        backtracking(nums,results,len,i+1,subset);
        subset.remove(subset.size() - 1);
    }
}

按照可能的长度进行递归回溯调用:

public List<List<Integer>> subsets(int[] nums){
    List<List<Integer>> results = new ArrayList<>();
    results.add(new ArrayList<>());
    for(int i = 1; i <= nums.length; ++i){
        backtracking(nums,results,i,0,new ArrayList<Integer>());
    }
    return results;
}

解法三 扩展法

扩展法的思想是遍历数组,将 当前元素 分别加入到之前的每个子集中,形成新的子集,然后将之前的子集和新的子集合并为最终的子集集合。

比如 nums=[1,2,3],一开始的子集为 []

  • 遍历到 1,加入已有子集,得到 [1],和之前的子集合并,子集为 [],[1]
  • 遍历到 2,加入已有子集,得到 [2],[1,2],和之前的子集合并,子集为 [],[1],[2],[1,2]
  • 遍历到 3,加入已有子集,得到 [3],[1,3],[2,3],[1,2,3],和之前的合并,子集为 [],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]

扩展法实现如下:

public List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> results = new ArrayList<>();
    results.add(new ArrayList<>());

    for(int i = 0; i < nums.length; ++i){
        // 拷贝已有的子集集合
        List<List<Integer>> copied = new ArrayList<>();
        for(List<Integer> list : results){
            ArrayList<Integer> temp = new ArrayList<>(list);
            copied.add(temp);
        }
        // 分别加入当前元素,并加入 results 中
        for(List<Integer> list : copied){
            list.add(nums[i]);
            results.add(list);
        }
    }
    return results;
}

77 组合

题目描述

给定两个整数 nk,返回 1 ... n 中所有可能的 k 个数的组合。

  • 示例:
输入: n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

来源:力扣(LeetCode)
链接:leetcode-cn.com/problems/co…
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

解法一 回溯法

回溯法的思想和实现逻辑都是类似的,有了上面 78 题的经验,同样可以画出这道题的递归树:

image.png

其中,绿色节点部分是 n=4,k=2 的递归树。递归函数 combine 的定义如下,递归的终止条件是 组合的大小和 k 相等

// 回溯递归函数
void combine(List<List<Integer>> results,int n,int k,int start,List<Integer> comb){
   if(comb.size() == k){
       // results.add(comb) 会导致引用传递,不可取
       results.add(new ArrayList<>(comb));
       return;
   }
   for(int i = start; i <= n;i++){
       comb.add(i);
       combine(results,n,k,i+1,comb);
       // 回溯到上一层,需要将组合的最后一个元素移除
       comb.remove(comb.size() - 1);
   }
}

// 主函数
public List<List<Integer>> combine(int n, int k) {
    List<List<Integer>> results = new ArrayList();
    combine(results,n,k,1,new ArrayList<>());
    return results;
}

遇到递归的问题,除了递归树外,我一般也会画下面这种的简单的框图来加深对递归过程的理解,黑色框表示递归函数的调用,语句顺序从上到下执行。

image.png

46 全排列

题目描述

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

  • 示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
  • 示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]
  • 示例 3:
输入:nums = [1]
输出:[[1]]

来源:力扣(LeetCode)
链接:leetcode-cn.com/problems/pe…
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

解法一 回溯法

这道题同样可以使用回溯法来解决,对于 nums=[1,2,3] ,画出它对应的递归树:

image.png

递归树每条路径构成了一种排列,每个元素都会出现在排列中,但是出现的顺序不同。所以当发现一条路径中,出现了重复的元素,就进行剪枝。

这道题的递归函数相比上面几题参数更加简单,递归的终止条件就是 当前排列的大小和数组长度相等 即所有元素都在排列中

// 回溯递归函数
public void permute(List<List<Integer>> results,int[] nums, List<Integer> ans){
    if(ans.size() == nums.length){
        results.add(new ArrayList<>(ans));
        return;
    }
    for(int i = 0; i < nums.length; i++){
        // 剪枝,如果排列中已经出现了该元素,舍去这条路径
        if(!ans.contains(nums[i])){
            ans.add(nums[i]);
            permute(results,nums,ans);
            ans.remove(ans.size() - 1);
        }          
    }
}

调用递归函数求解:

public List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> results = new ArrayList<>();
    permute(results,nums,new ArrayList<>());
    return results;
}

200 岛屿数量

题目描述

给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由 水平方向和/或竖直方向上 相邻的陆地连接形成。此外,你可以假设该网格的四条边均被水包围。

  • 示例 1:
输入:grid = [
  ["1","1","1","1","0"],
  ["1","1","0","1","0"],
  ["1","1","0","0","0"],
  ["0","0","0","0","0"]
]
输出:1
  • 示例 2:
输入:grid = [
  ["1","1","0","0","0"],
  ["1","1","0","0","0"],
  ["0","0","1","0","0"],
  ["0","0","0","1","1"]
]
输出:3
  • 提示:
m == grid.length
n == grid[i].length
1 <= m, n <= 300
grid[i][j] 的值为 '0' 或 '1'

来源:力扣(LeetCode)
链接:leetcode-cn.com/problems/nu…
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

解法一 BFS

一个岛屿的周围要么是边界,要么是 '0'。遍历二维数组,发现是陆地时,就变成 '0',如果周围还有陆地,也变成 '0',这样当发现周围都没有陆地时,一个完整的岛屿就找到了,并且由于都变成了水域,也不会和之后的岛屿发生冲突。直到遍历结束。

将陆地周围的陆地变成岛屿,直到周围没有陆地的过程,可以使用 BFS 广度优先搜索,一般 BFS 都会和 队列 一起使用。

遍历二维数组的过程中,如果遇到 '1',则进行如下操作:

  • 岛屿数量加 1
  • 将当前元素加入队列中(元素的行号和列号)
  • 将当前元素置为 '0',即变成水域
  • 遍历队列,直到队列为空
    • 取出队首元素,如果四周(上下左右)有陆地,则将周围的元素加入队列并改为 '0'

实现代码如下:

// 方法 1 BFS 思想
public int numIslands(char[][] grid) {
    Queue<Pos> queue = new LinkedList<>();
    int result = 0;
    for(int i = 0; i < grid.length; i++){
        for(int j = 0; j < grid[0].length; j++){
            if('1' == grid[i][j]){
                result++;
                queue.offer(new Pos(i,j));
                grid[i][j] = '0';
                while(!queue.isEmpty()){
                    Pos pos = queue.poll();
                    if(pos.x - 1 >= 0 && grid[pos.x - 1][pos.y] == '1'){
                        queue.add(new Pos(pos.x - 1, pos.y));
                        grid[pos.x - 1][pos.y] = '0';
                    }
                    if(pos.x + 1 < grid.length && grid[pos.x + 1][pos.y] == '1'){
                        queue.add(new Pos(pos.x + 1, pos.y));
                        grid[pos.x + 1][pos.y] = '0';
                    }
                    if(pos.y - 1 >= 0 && grid[pos.x][pos.y - 1] == '1'){
                        queue.add(new Pos(pos.x, pos.y - 1));
                        grid[pos.x][pos.y - 1] = '0';
                    }
                    if(pos.y + 1 < grid[0].length && grid[pos.x][pos.y + 1] == '1'){
                        queue.add(new Pos(pos.x, pos.y + 1));
                            grid[pos.x][pos.y + 1] = '0';
                        }
                    }
                }
            }
        }
        return result;
    }

总结一下,这道题的思路就遇到一个陆地时,一定是某个岛屿的一部分(所以岛屿数量加 1),向四周扩展,直到周围全是水域,说明这些扩展的元素构成了原来的一个岛屿。

解法二 DFS

在扩展的过程中,也可以使用 DFS 思想,递归调用。递归函数 dfs 的作用是将当前元素置为 '0',并对其四周的方向分别进行递归扩展,递归的 终止条件 是四周是边界或者四周本来就是水域。

// dfs 递归函数
void dfs(char[][] grid, int row, int col){
    if(row < 0 || row > grid.length - 1 || col < 0 || col > grid[0].length - 1 || grid[row][col] == '0'){
        return;
    }
    grid[row][col] = '0';
    dfs(grid,row - 1,col);
    dfs(grid,row + 1,col);
    dfs(grid,row,col - 1);
    dfs(grid,row,col + 1);
}

遍历二维数组,遇到陆地就调用 dfs 就可以解决了。

// 方法 2 DFS 思想
public int numIslands(char[][] grid) {
    int result = 0;
    for(int i = 0; i < grid.length; i++){
        for(int j = 0; j < grid[0].length; j++){
            if('1' ==grid[i][j]){
                result++;
                dfs(grid,i,j);
            }
        }
    }
    return result;
}

解法三 并查集

这道题也可以使用 并查集 UnionFind 来解决,这种方法相对复杂,但可以很好的理解并查集思想。

我们可以将岛屿的二维数组映射为一个一维数组 rootroot 的长度为 row*col(其中,row,col 分别是二维数组的行列),分别对应二维数组的每个元素。

root[i] 表示第 i 个元素的祖先的索引,如果 i==root[i],则表示第 i 个元素的祖先就是 root[i]。例如 root = [1,2,2],第 0 个元素祖的位置为 root[0] = 1 ,而第 1 个元素祖先的位置为 root[1] = 2,而 2 == root[2],说明数组中的三个元素都有同一个祖先 2

再回到岛屿的问题上,如果部分陆地元素上都有同一个祖先,那么就可以认为它们构成一个岛屿。

我们将 root 定义在并查集类中,进行相关操作。UnionFind.class 的定义可以认为是一个模板,其中有一个 find(x)union(x,y) 方法,分别对应查询和合并操作。

// 并查集模板类
class UnionFind{
    public UnionFind(char[][] grid){}

    public int find(int x){}

    public void union(int x, int y){}
}

在岛屿问题中,并查集中需要定义两个成员:

  • root[] : 数组,映射二维数组,并记录元素的祖先
  • count : 整数,默认为二维数组的元素个数,表示 合并的次数

在构造函数中,要初始化 root,count,默认 root[i] = i,即自己是自己的祖先。

// UnionFind.class 的构造函数
public UnionFind(char[][] grid){
    int eles = grid.length * grid[0].length;
    count = eles;
    root = new int[eles];
    for(int i = 0; i < root.length; i++){
        root[i] = i;
    }
}

find(x) 的作用是递归找到第 x 个元素的祖先。

/** 递归找到 x 的祖先 */
public int find(int x){
    if(x == root[x]){
        return x;
    }
    root[x] = find(root[x]);
    return root[x];
}

union(x,y) 的作用是将 x,y 同化合并,让它们的祖先一样。

public void union(int x, int y){
    int rootX = find(x);
    int rootY = find(y);
    if(rootX != rootY){
        root[rootX] = rootY;
        count--;
    }
}

同时,UnionFind.class 还应该提供一个 getCount() 函数获取 count。(或者你可以让 count 的权限为 public)。

看完了并查集的代码,下面就是要利用并查集来解决岛屿数量的问题。

首先定义一个变量 waters 表示 0 的数量,遍历二维数组:

  • 如果是水域,waters++
  • 如果是陆地,当前元素分别和上下左右的四个元素进行 union 操作
  • 最后岛屿的数量是 unionFind.count - waters

为什么岛屿的数量是 count - waters
我们假设二维数组的元素个数为 N,水域元素有 waters 个。而一个有 n 个元素的岛屿需要合并 n - 1 次。也就是说,最终岛屿的数量 = N - waters - 合并次数,即 count - waters

// 方法 3 并查集
public int numIslands(char[][] grid) {
    int waters = 0;
    UnionFind uf = new UnionFind(grid);
    for(int i = 0; i < grid.length; i++){
        for(int j = 0; j < grid[0].length; j++){
            if(grid[i][j] == '0'){
                waters++;
            }else {
                operateDir(uf,grid,i,j,-1,0);
                operateDir(uf,grid,i,j,1,0);
                operateDir(uf,grid,i,j,0,-1);
                operateDir(uf,grid,i,j,0,1);
            }
        }
    }
    return uf.getCount() - waters;
}

// 对某个方向进行同化合并操作
private void operateDir(UnionFind uf,char[][] grid,int i,int j,int xDir,int yDir){
    int x = i + xDir;
    int y = j + yDir;
    if(x >= 0 && x < grid.length && y >= 0 && y < grid[0].length && grid[x][y] == '1'){
        uf.union(x*grid[0].length + y,i*grid[0].length+j);
    }
}