前端算法思想 -- 递归&回溯

486 阅读18分钟

1.递归&回溯算法

回溯算法:从一个点出发去寻找可能的目标,如果不行可以退一步就到上一个点,选择另一个分支。(甚至可以一直退,选择起点分支),直到选中目标为止。 总而言之,生活中不可能给你重新选择的机会,但是回溯算法可以让你回到起点,重新选择。 回溯特点:总是在递归的基础上建立回溯,可以认为是递归的更复杂一点的调用。

“递归” 的基础上谈 “回溯” : 递归的基本性质就是函数调用,在处理问题的时候,递归往往是把一个大规模的问题不断地变小然后进行推导的过程。 回溯则是利用递归的性质,从问题的起始点出发,不断地进行尝试,回头一步甚至多步再做选择,直到最终抵达终点的过程。

回溯算法是啥?
就是把这里的可能性2次,换成n次,从而把二叉树会变成n叉树,而且在每次递归之后再执行另外的操作,此时就是树的后序遍历执行的时机。
背景:什么问题适合用回溯算法解决?

  • 有很多路
  • 这些路,有思路,也有出路
  • 通常需要递归来模拟所有的路

相关题目


1.0 前置知识-排列和组合两者的区别

组合排列经常一起出现,这里先简单说明下它们的区别。

什么是组合?假设有三个明星abc,有三位粉丝xyz,让每位粉丝在其中选2位认可的明星,有多少选法?很明显粉丝选abba都无所谓,最终的结果里,这两种选择是一样的,所以这就是组合问题。

什么是排列?还是假如有三个明星abc,有三位粉丝xyz,让每位粉丝选出你认为的第1名和第2名,有多少种选法?很明显此时abba就是两种不同的结果,都需要统计,这就是排列问题。
总结:所以排列问题是需要考虑先后顺序的,ab和ba是两个不同的结果,而组合问题是不需要考虑顺序的:ab和ba这两种排列方式是算一种结果,需要剔除一个。
摘自:juejin.cn/post/690050…


1.1 组合问题

1.1.1 电话号码的字母组合

题目: 给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。 给出数字到字母的映射如下(与电话按键相同)。
注意 1 不对应任何字母。
输入:"23" 输出:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]
image.png

链接:leetcode-cn.com/problems/le…

思路
这题核心思想是多叉树的全路径问题,只是每一层的路径我们需要去在电话号码对应的字符串中获取。如果说二叉树的递归模版是判断当前层node是否存在然后递归当前节点的左右子树。那么全路径或者排列问题的模版就是判断当前层是否存在(也可能是level深度),然后通过循环遍历上一节点下面所有的子节点数组,依次和上一节点进行合并组合。
需要注意的是
(1)递归模版:如果说二叉树的递归模版是判断当前层node是否存在然后递归当前节点的左右子树。那么全路径或者排列问题的模版就是判断当前层是否存在(也可能是level深度),然后通过循环遍历上一节点下面所有的子节点数组,依次和上一节点进行合并组合。
(2)递归结束的条件:组合问题还有一个特点,就是多叉树的层数大多数都已经明确了,就是输出元素的位数!所以,递归结束的条件就是当前level:curlevel是否等于题目中告诉我们的多叉树深度。
(3)循环内的任务:我们在递归的过程中需要保留到这一层之前的路径,用到达这节点的路径来和这一个节点下面所有的子节点数组一一组合。
(4)递归参数问题:此外,全排列另一个需要注意的就是递归传递的有哪些参数问题
解答image.png

var letterCombinations = function(digits) {
  var arr = ['', 'abc', 'def', 'ghi', 'jkl', 'mno', 'pqrs', 'tuv', 'wxyz'];
  if (!digits) { return []; }
  var deep = digits.length; // 长度就是深度
  var indexArr = digits.split('').map((item) => {
      return Number(item) - 1;
  })
  var result = [];
  function recursion(curpath, curlevel) {
      if (curlevel === digits.length) { // 如果等于深度,就是相当于到最后一层,那么就是一条完整路径,就可以将那一条路径放入到result中
          result.push(curpath)
          return
      }
      var curarr = arr[indexArr[curlevel]];// 关键在于:每一层需要for循环拼接的节点是什么
      for (let i = 0; i < curarr.length; i++) {
          recursion(curpath+curarr[i], curlevel+1);   
      }
  }
  recursion('', 0);
  return result;
};

1.1.2 组合问题

image.png 题目分析: 这一题于上一题不同的是,这个需要剪枝!
这里两个整数n和k的作用分别是什么?数字范围是1-n,k个数的组合表示选出数字的位数是k位。比如【1~5】中可以成三位数的一共有哪些?拿到题目不能自己脑子先乱,比如135、123然后自己举这种例子就会觉得越来越乱。对于这种数的组合立马想想是不是全排列的变种!是不是需要剪枝!全排列问题转化为多叉树➕剪枝,就相当于【1~5】是每一层的子节点,而k则表示这颗多叉树的深度。
分析过程就是晒出题目干扰信息,转化为数学题目的过程。

var combine = function(n, k) {
    var deep = k;
    var result = [];
    function recursion(curpath, level) {
        if (level === deep) { 
            result.push(curpath);
            return;
        }
        for (let index = 1; index < n + 1; index++) {
            var max = 0
            if (index > Math.max(...curpath)) {
                var arr = [...curpath];
                arr.push(index);
                recursion(arr, level+1)
            }
        }
    }
    recursion([], 0);
    return result;
};

解法2:

/** 
 * 排列关键:
 * 如果是排列的话,那么当前节点下面的直接子节点就是:当前数组全集中刨去 当前节点 & 当前节点之上的路径, 剩下的就是直接子节点的列举
 * 例如:1下面的子节点就是2,3,4,5
 *                                       12345                         12345          12345
 *                                         1                             2              3
 * 当前节点:                     2       3       4     5               1 3 4 5        1 2 4 5
 * 下一层子节点:                3 4 5   2 4 5   2 3 5  2 3 4 
 * 
 * 
 * 
 * 
/** 组合理解的关键:
 * 如果列举所有3个数组合的话那么当前节点下面的直接子节点只能是当前节点的index后面的所有元素,例如3下面的直接子节点只能是45
 *                   12345             12345        12345
 *                     1                      2            3        4        5
 *           2       3    4   5            3  4  5        4 5     5        
 *         3 4 5   4 5    5   无          45  5           5
 * 
 * 
 * 
 * 
 * 两者的区别是,下面的直接子节点是数组中只刨去当前节点,还是刨去当前节点之前的所有子节点
 */
 n = 4, k = 2
var combine = function(n, k) {
    let deep = k
    const arr = Array(n).fill().map((item, index) => index + 1)
    const result = []
    function recursion(curpath, deepth) {
        if (curpath.length === k) {
            result.push([...curpath])
            return
        }
        let preNode = [...curpath][curpath.length - 1]
        let childArr = arr.filter((item) => item > preNode)
        if (childArr.length === 0) { return }
        for (let index = 0; index < childArr.length; index++) {
            recursion([...curpath, childArr[index]], deepth + 1)
        }
    }
    for (let index = 1; index < n + 1; index++) {
      recursion([index], 1)
    }
    return result
}

1.1.3 组合总和

这个全排列不同的是,这个多叉树的深度没有明确告诉我们,那么我们需要遍历到第几层就停止遍历了呢?停止遍历的标准这里不是到了第几层(例如上一题,给出了组合是几位数字,就是遍历到第几层,通过传入参数level来知道当前所处第几层,然后停止遍历)。这里停止遍历的条件是当前节点向上的所有父节点和为target或者大于target。

image.png 注意: 1.结果中【2,2,3】满足,但是如果是全排列的话【3,2,2】就不满足,结果不让重复。这就意味着这是一道组合题,而不是排列题。
2.对于组合题目,数字类型【1,2,3,4】组合:首位是1,那么1可以和后面三位组合12,13,14;对于第二位再组合的时候就不能回头组合了,只能向后找组合23,24.那么对于多叉树而言节点2下面的子节点只能是2后面的所有元素,不能是之前的元素。
3.但是题目中要求可以数字重复,那就是多叉树而言节点2下面的子节点只能是包括2自身,以及2后面的所有元素(2,3,4)

 var combinationSum = function(candidates, target) {
    var path = [];
    var result = [];
    var length = candidates.length
    function recursion(path, index1) {
        var sum = path.reduce((pre, cur, arr, index) => {
            return pre + cur;
        }, 0);
        if (sum > target) { return; }
        if (sum === target) {
            result.push(path);
            return
        }
        for (let index2 = index1; index2 < length; index2++) {
            recursion([...path, candidates[index2]], index2); 
        }
    }
    recursion([], 0, 0)
    return result;
}

代码中记住两点: 1.for循环中循环的元素表示这一个节点的下一层所有的child节点。这里for循环从index = 2开始,那么下一层节点是从index=2到后面最后一个元素。下面这张图应该再每一层中加上自己,例如节点2的child节点的第三层银改为【2,3,4】 下面这张图应该对应的for循环是for(index2 = index1 + 1; index2 < length; index2++)表示不包括自己(包括自己的就是index1 = index2,当前集合的下标为起点)

image.png (借用juejin.cn/post/690050… 大神的图)


1.1 排列问题:全排列

给定一个 没有重复 数字的序列,返回其所有可能的全排列。 示例:
输入: [1, 2, 3]
输出: [ [1,2,3],[1,3,2],[2,1,3], [2,3,1],[3,1,2],[3,2,1] ]

来源:力扣(LeetCode)
链接:leetcode-cn.com/problems/pe…

var permute = function(nums) {
    var result= [];
    function recursion(path, level) {
        var curlevel = level+1;
        if (curlevel > nums.length) { 
            result.push(path)
            return;
        } 
        // 对下一层进行剪枝,nums中剔除不再之前走过的路径,即【1,2,3,4,5】中之前的走过来1,2,
        // 那么下一层子节点就是【3,4,5】
        var nextarr = nums.filter((item) => {
            return !path.includes(item)
        })
        // 然后再继续循环递归往下走
        for(let index = 0; index < nextarr.length; index++) {
            recursion([...path, nextarr[index]], curlevel)
        }
    }
    recursion([], 0)
    return result;
};

全排列可以认为是一个排列组合的模版,递归参数是路径一定有一个路径,路径是数组。递归结束条件要么使用路径要么使用层数。 剪枝操作 剪枝后循环中的数组就是剪枝后的数组,然后加递归

const arr: any = [1, 2, 3]
const deepLength = arr.length - 1
const result: any = []
const recursion = function (path: any, curlevel: any, leftArr: any) {
  if (curlevel > deepLength) {
    result.push(path)
    return
  }

  for (let index = 0; index < leftArr.length; index++) {
    const curpath = [...path, leftArr[index]]
    const leftArr_ = leftArr.filter((item: any) => item !== leftArr[index])
    recursion(curpath, curlevel + 1, [...leftArr_])
  }
}
recursion([], 0, arr)
console.log('result', result)
var permute = function(nums) {
  const result = []
  function recursion(prePath) {
    const curpath = [ ...prePath ]
    const nextArr = nums.filter((item) => !curpath.includes(item))
    if (nextArr.length === 0) {
        result.push(curpath)
        return
    }
    for (let index = 0; index < nextArr.length; index++) {
        recursion([...curpath, nextArr[index]])
    }
  }
  recursion([])
  return result
}

1.2 子集问题:子集

image.png

var subsets = function(nums) {
    var result = [];
    function recursion(patharr, level, index1) {
        if (level > nums.length) { return; }
        result.push([...patharr]);
        for (let index2 = index1; index2 < nums.length; index2++) {
            recursion([...patharr, nums[index2]], level+1, index2+1)
        }
    }
    recursion([], 0, 0)
    return result;
};

分析: 全排列和子集问题关系,全排列是相当于多叉树中多有的叶子结点,通过剪枝得到全排列,而子集问题相当于在全排列的基础上得到这棵树的所有节点,且树中每一层的节点数量也要变化。


1.3 分割问题:复原IP地址

image.png 基本思路(如下图): image.png 复原ip地址问题,可以将切割转化为多叉树的组成的路径是否合法问题。例如: 101023,那么第一层节点就是前三个数字101拆分: 1,10,101.这个和我们之前的节点都是一个单独数字不同。因此在每个层的节点需要我们通过for循环这个遍历101,然后每次循环时候substring(0, index),那么得到下一层级的数组为【1,10,101】,如下所示:

// nextarr就是前三个数字,之所以取三个,因为ip地址每个最多三位,所以取3个,然后拆分
let nextLevel = []
for (let i = 0; i < nextarr.length; i++) {
    var curnode = nextarr.substring(0, i+1)
    nextLevel.push(curnode)
}
var restoreIpAddresses = function(s) {
    var result = [];
    function recursion(path, index) {
        if (path.length > 4 && index < (s.length - 1)) { return; }
        var nextarr = s.substring(index, index+3);
        if (nextarr.length === 0) {
            if (path.length === 4) {
                var it = path.join('.')
                result.push(it);
            }
        }
        for (let i = 0; i < nextarr.length; i++) {
            var curnode = nextarr.substring(0, i+1)
            if (check(curnode)) {
                recursion([...path, curnode], i + index + 1)
            }
        }
    }
    recursion([], 0)
    function check(node) {
        if (node[0] === '0' && node.length > 1) {
            return false;
        } else if (Number(node) > 255) {
            return false;
        } else {
            return true;
        }
    }
    return result;
};

分析:这里条件有很多,所以要进行一一拆分,这里的checkout是用来检查每一个节点是否满足要求:小于255,两位数以上第一位数为0。


中心思想:通过两层for循环来遍历整个二维数组,在遍历的过程中如果遇到1,则加1即可。 在遍历第一个的时候,第一个节点周围所有的1就已经被递归完毕了,所以for循环执行第二次的时候已经找不到和第一个1相连的1的,都被改为了2.
下面说一下递归:以当前节点为中心,前后左右都要执行一次递归,例如,走到左边节点后,以左边节点为中心在执行递归,查看前后左右节点是否为1,如果为1则改为2。
需要注意的是:我只需要在递归函数的开头查看当前传经来的行和列是否满足grid的行列四个要求:行数和列数都要大于0,行数index < 行总数-1, 列index < 列总数-1。如果满足代表可以找到对应矩阵节点,那么继续走上下左右递归调用,哪怕是行数是0-1,也不用管,递归开头会阻止你这个中心节点不存在。


1.4 岛屿网格类问题

DFS深度遍历除了在二叉树中使用之外,还有一种情况也常常需要使用DFS,那就是网格图,网格图的dfs一共要解决三个问题:1. 如何实现四周染色 2.如何判断染色是否超出范围 3.如何避免重复染色

问题1:如何实现四周染色
网格问题一般是在mxn的二维数组(网格)中,在每个小方格的上下左右四个相邻的单元格中进行搜索,其中岛屿问题就是场景的网格问题。 在二叉树的深度遍历中,我们可以明确递归遍历的模版是:

fucntion recursion(node) {
    if (!node) { return; }
    recursion(node.left);
    recursion(node.right);
}

这是因为二叉树每一个节点后面都会有左节点和右节点,如果没有子节点那么会进入到空节点的判断中。 而岛屿问题的深度遍历,每个元素的特点就是有四个相邻节点,那么要继续向周围遍历,就需要使用一下递归遍历模版:

function recursion(row, column) {
    // 上下左右四个方向,也可以将它看为四叉树
    recursion(row - 1, column);
    recursion(row + 1, column);
    recursion(row, column - 1);
    recursion(row, column + 1);
}

问题2:范围判定
当然这里还缺少遍历过程中超出了非线性存储的范围,例如: image.png 所以,对比二叉树中 if (!node) return; 这里对遍历范围的限制为:

if (
    row_index < 0 || row_index > grid.length || 
    col_index < 0 || col_index > grid[0].length
) {
    return;
}
// 因此合并起来的模版就是:
function recursion(row, column) {
    if (row_index<0||row_index>grid.length||col_index<0||col_index>grid[0].length) return;
    // 上下左右四个方向,也可以将它看为四叉树
    recursion(row - 1, column);
    recursion(row + 1, column);
    recursion(row, column - 1);
    recursion(row, column + 1);
}

问题3:如何避免重复染色 因为如果我们遇到四个正方形单元格都是1的话,那么就会导致无限递归的问题,所以为了避免这种情况我们需要对我们遍历过的单元格进行标注,来达到递归终止,不再染色。方法:对于遍历过的部分染色为‘2’。
所以最终的网格染色模版为,如下:

function recursion(row_index, col_index) {
    // 1.超出网格范围限制
    if (row_index<0 || row_index>grid.length || col_index<0 || col_index>grid[0].length ) return;
    // 2.重复染色问题
    if (grid[row_index][col_index] === '2') return;
    // 3.染色
    recursion(row_index - 1, col_index);
    recursion(row_index + 1, col_index);
    recursion(row_index, col_index - 1);
    recursion(row_index, col_index + 1);
}

1.4.1 颜色填充问题

这里的颜色填充问题和下面的岛屿问题,都是属于二维数组的问题,而我们要对二维数组的全局进行染色,那么就意味着我们需要遍历整个二维数组。典型的遍历方法就是两层for循环。但是这里还有个问题:染色问题,每一个元素都要以当前元素作为中心向上下左右四个方向染色。 image.png

var floodFill = function(image, sr, sc, newColor) {
    let color = image[sr][sc];
    let row = sr;
    let column = sc;
    function recursion(row, column) {   
        if (!image[row] || image[row][column] === undefined || image[row][column] !== color ) {
            return; 
        }
        if (image[row][column] === newColor) {
            return;
        }
        // console.log(row, column)
        recursion(row - 1, column);
        recursion(row + 1, column);
        recursion(row, column - 1);
        recursion(row, column + 1);
        image[row][column] = newColor;
    }
    recursion(sr, sc);
    return image
};

分析:染色问题是最基本的二维数组操作问题
关键点1: 如何蔓延? 到达某一个坐标的时候,如何将达到这个坐标周围的节点蔓延。即通过四个递归函数的调用:recorsion(x+1,y)、recorsion(x,y+1)、recorsion(x-1,y)、recorsion(x,y-1)。
关键点2: 两个限制条件终止蔓延递归?
1.物理坐标不满足
2.已经染色过了:image[row][column] === newColor

1.4.2 floodFill填色问题:岛屿数量

image.png 解题思路:
由题目要求可知,被 0 包围的1组成一个岛屿。 但是我们需要做的是便利一个二维数组,肯定是要双重for循环遍历,也就是说一个节点一个节点的便利。我们可以把for循环每到一个二维数组的节点,如果是0就认为是海水,跳过进行下一个节点遍历。如果是1,那么我们就认为我们到达了某个岛屿的边缘。这个时候我们既然到达了岛屿的边缘,我们就需要对所在岛屿进行“染色”(染色方式为将这个岛屿内的1标记为2),标记这个岛屿已经计算在数量内了。这样我们在for循环便利每个节点的时候,只要遇到 “1” 就代表我们发现了 “新岛屿”(因为老岛屿都为2了)。 因此,我们的染色操作就是一个递归,而我们在for循环的时候一定会遇到已经染色的岛屿2的,因为我们染色的操作一定能是优先在for循环遍历到那个节点之前。也就是说,一旦到达岛屿边缘(就会执行递归染色操作),而此时这个岛屿剩下的1还没遍历到,后面遍历到的时候已经是2了。等到再发现1的时候就优势新岛屿了。

var numIslands = function(grid) {
    var result = 0;
    var row = grid.length;
    var column = grid[0].length;
    for (let row_index = 0; row_index < row; row_index++) {
        for (let column_index = 0; column_index < column; column_index++) {
            var curnode = grid[row_index][column_index]; // 双重for循环遍历每个节点
            if (curnode === '1') { // 下面对每个节点判断:记述操作 + 染色递归
                result = result + 1;
                console.log(row_index, column_index)
                recursion(row_index, column_index)
            }
        }
    }
    // 递归染色的注意点:一定要注意递归染色的两个终止条件:
    // 1.边界终止(即物理的index_x和index_y小于0,或者大于m x n维度的length值)
    // 2.已经染色的终止(这里可以替换为grid[row_index][column_index] === '1'未染色的进入递归)
    // 这里的递归为了减少混乱的计算,采用了return,即recursion的使用尽管使用,我会在递归函数中去判断是否符合递归条件,
    // 即row_index < 0 || column_index < 0 || row_index > row-1 || column_index > column-1)
    
    function recursion(row_index, column_index) {
        if (row_index < 0 || column_index < 0 || row_index > row-1 || column_index > column-1) {
            return;
        }
        if (grid[row_index][column_index] === '1') {
            grid[row_index][column_index] = '2';
            recursion(row_index + 1, column_index)
            recursion(row_index, column_index + 1)
            recursion(row_index - 1, column_index)
            recursion(row_index, column_index - 1)
        }
        
    }
    recursion(0,0)
    console.log(grid)
    return result;
};

1.4.3 岛屿最大面积

image.png

var maxAreaOfIsland = function(grid) {
    var result = [0];
    var row = grid.length;
    var column = grid[0].length;
    for (let row_index = 0; row_index < row; row_index++) {
        for (let column_index = 0; column_index < column; column_index++) {
            var curnode = grid[row_index][column_index];
            if (curnode === 1) {
                let sum = recursion(row_index, column_index, 0);
                console.log('sum', sum)
                if (sum) {
                    result.push(sum);
                } else {
                    result.push(0);
                }
                
            }
        }
    }
    function recursion(row_index, column_index, sum) {
        let sum1 = sum;
        if (row_index < 0 || column_index < 0 || row_index > row-1 || column_index > column-1) {
            return sum1;
        }
        if (grid[row_index][column_index] === 1) {
            sum1 = sum1 + 1;
            grid[row_index][column_index] = 2;
            let a = recursion(row_index + 1, column_index, sum1)
            let b = recursion(row_index, column_index + 1, a)
            let c = recursion(row_index - 1, column_index, b)
            let d = recursion(row_index, column_index - 1, c)
            sum1 = d
        }
        return sum1;
    }
    recursion(0,0)
    console.log(grid)
    return Math.max(...result);
};

1.4.4 岛屿周长

给定一个 row x col 的二维网格地图 grid ,其中:grid[i][j] = 1 表示陆地, grid[i][j] = 0 表示水域。

网格中的格子 水平和垂直 方向相连(对角线方向不相连)。整个网格被水完全包围,但其中恰好有一个岛屿(或者说,一个或多个表示陆地的格子相连组成的岛屿)。

岛屿中没有“湖”(“湖” 指水域在岛屿内部且不和岛屿周围的水相连)。格子是边长为 1 的正方形。网格为长方形,且宽度和高度均不超过 100 。计算这个岛屿的周长。

image.png

1.8 棋盘问题:N皇后