leetcode之回溯、dfs

276 阅读2分钟

回溯法是一种暴力搜索方法,往前一步探索,如果发现该选择不符合,那么就退回一步重新选择。

46.全排列:used,背诵思路!

题目46. 全排列

题解

  1. 排列问题是有序的,也就是说 [1, 2] 和 [2, 1] 是两个集合,数字 1 使用了两次,因此需要一个 used 数组来标记已经选择的元素。

image.png

  1. 递归终止条件:当收集元素的数组 path 的长度等于题目中给出的 nums 数组长度时,说明找到了一个全排列,递归到了叶子节点,此时递归终止。

  2. 单层搜索逻辑:used 数组就是记录此时 path 里都有哪些元素使用了,一个排列里一个元素只能使用一次

「注意」 由于 path 中存的是地址引用,如果直接传递 path ,后续操作 path 时前面存放的 path 内容也会被改变,不符合我们的预期。因此,可以用 path.slice()进行浅拷贝。

const permute = function(nums) {
  const res = []; 
  const path = [];  // 存储一个排列
  
  const dfs = function (arr, used) {
    if (path.length == arr.length) {  // 找到一个全排列,递归应该终止
      res.push(path.slice());
      return;  // 注意
    }
    for (let i = 0; i < arr.length; i++) {
      if(used[i]) continue; // 每个元素只能在path里填充一次
      path.push(arr[i]);
      used[i] = true;
      dfs(arr, used); // 递归直到找到一个全排列,此时 if 会 return
      
      path.pop(); // 回溯,继续下一个排列
      used[i] = false;
    }
  }

  dfs(nums, []);
  return res;
}

时间复杂度:O(n * n!)。

空间复杂度:O(n)。

22. 括号生成:回溯

题目 22.括号生成

这题的思路就是要一直选括号,要么左括号,要么右括号。有 2 个约束:

  1. 对于左括号,只要 '(' 有剩余,就可以选
  2. 对于右括号,当剩下的右括号比左括号多时,才可以选右括号

题解

const generateParenthesis = function(n) {
  const res = [];
  
  const backTrack = function(left, right, str) { // 左右括号剩余的数量,str 是当前正在构建的字符串
    if (str.length == 2 * n) {  // 递归终止条件
      res.push(str);
      return;
    }
    if (left)   // 左括号有剩余
      backTrack(left - 1, right, str + '(');
    if (left < right)   // 右括号剩余数量比左括号多
      backTrack(left, right - 1, str + ')');
  }

  backTrack(n, n, '');
  return res;
};

复杂度超出分析范畴。

200.岛屿数量:网格dfs (仅字节,前端难度较高)

题目200.岛屿问题

「DFS 的三要素基本结构」:

  1. 访问相邻节点:上下左右四个。

  2. 判断 base case:指的是超出网格范围的格子。「先污染,后治理」,不论当前在哪个格子,先往四个方向走一步再说,如果发现超出了网格范围再赶紧返回。

得出网格 dfs 的遍历框架代码:

const inArea = function(grid, x, y) { // 判断坐标 (x, y) 是否在网格中
    return x >= 0 && x < grid.length && y >= 0 && y < grid[0].length;
}

const dfs = function (grid, x, y) { // dfs 遍历网格
    if (!inArea(grid, x, y)) {
        return;
    }
    dfs(grid, x - 1, y);
    dfs(grid, x + 1, y);
    dfs(grid, x, y - 1);
    dfs(grid, x, y + 1);
}
  1. 避免重复遍历(二叉树dfs不需要该要素):网格结构的 dfs 和二叉树的 dfs 最大的不同之处在于,遍历中可能遇到遍历过的节点。因此需要 「标记已经遍历过的格子」,例如对于岛屿问题,我们在值为 1 的陆地格子上做 dfs 遍历,每走过一个陆地格子,就把格子的值改为 2,当遇到 2 的时候,就知道这是遍历过的格子了。

此时的遍历框架加入了 避免重复遍历 的语句,如下:

const dfs = function (grid, x, y) {
    if (!inArea(grid, x, y)) {
        return;
    }
    if (grid[x][y] != 1) { // 不是岛屿 或 之前已经遍历过该岛屿
        return;
    }
    grid[x][y] = 2;  // 标记
    dfs(grid, x - 1, y);
    dfs(grid, x + 1, y);
    dfs(grid, x, y - 1);
    dfs(grid, x, y + 1);
}

如上,我们就得到了一个岛屿问题、乃至各种网格问题的通用 dfs 遍历方法。

对于求岛屿数量的问题,我们先扫描整个二维网格,如果一个位置为 1 ,则以其为起始节点开始进行 dfs 搜索,在搜索过程中,每个搜索到的 1 都会被重新标记为 0。「最终岛屿的数量就是我们进行深度优先搜索的次数」,另外,注意字符串!

// dfs 深搜,将遍历过的地方赋值为'0'
const dfs = function(grid, x, y) {
  if (x < 0 || x >= grid.length || y < 0 || y >= grid[0].length || grid[x][y] == '0') {
    return;  // 越界 或 遇到海洋 就停止搜索
  }
  grid[x][y] = '0';  // 标记 已经遍历过的地方
  dfs(grid, x + 1, y);
  dfs(grid, x - 1, y);
  dfs(grid, x, y + 1);
  dfs(grid, x, y - 1);
}

const numIslands = function(grid) {
  let res = 0;  // 岛屿数量
  
  for (let i = 0; i < grid.length; i++) {  // 遍历二维网格
    for (let j = 0; j < grid[0].length; j++) {
      if (grid[i][j] == '1') {
        res++;
        dfs(grid, i, j);
      }
    }
  }
  
  return res;
}

时间复杂度:O(mn),其中m是行数,n是列数,我们访问每个网格最多一次。

空间复杂度:O(mn),递归的深度最大可能是整个网格的大小。

695. 岛屿的最大面积:网格dfs - 仅字节

题目695. 岛屿的最大面积

思路与 200.岛屿问题 相同。

const dfs = function(grid, x, y) {
  if (x < 0 || x >= grid.length || y < 0 || y >= grid[0].length || grid[x][y] == 0) {
    return 0;  // 注意! 返回0
  }
  grid[x][y] = 0;  
  return 1              // 注意,return +
  + dfs(grid, x + 1, y)  
  + dfs(grid, x - 1, y)
  + dfs(grid, x, y + 1)
  + dfs(grid, x, y - 1);
}

const maxAreaOfIsland = function(grid) {
  let res = 0;  // 岛屿的最大面积
  
  for (let i = 0; i < grid.length; i++) {  
    for (let j = 0; j < grid[0].length; j++) {
      if (grid[i][j] == 1) {
        let tmp = dfs(grid, i, j);  // 注意
        res = Math.max(tmp, res);  // 注意
      }
    }
  }
  
  return res;
}

时间复杂度:O(mn)。

空间复杂度:O(mn)。

39. 组合总和:回溯,考察很少

题目39. 组合总和

因为本题没有组合数量要求,所以递归没有层数的限制,只要选取的元素总和超过 target就返回!和46题思路类似。

image.png

const combinationSum = function(nums, target) {
  const res = [];  
  const path = [];  // 存放符合条件的结果
  
  const backtracking = function(start, sum) { // sum 统计 path 里的元素和
    if (sum > target) return;  // 递归终止1
    if (sum == target) {  // 递归终止2
      res.push(path.slice());
      return;
    }
    // 子集问题是无序的,所以for循环从start开始
    for (let i = start; i < nums.length; i++) {
      let val = nums[i];
      if (val > target - sum) continue;
      path.push(val);
      sum += val;
      backtracking(i, sum);
      path.pop();
      sum -= val;
    }
  }
  backtracking(0, 0);
  return res;
};

时间复杂度:取决于搜索树所有叶子节点的深度之和。

空间复杂度:O(target)。取决于递归的栈深度,最差情况需要递归 target 层。