回溯法笔记

使用场景

  1. 组合问题
  2. 切割问题:字符串有几种切割方式保证子串为回文子串
  3. 子集问题:列出一个数组的所有的子集
  4. 排列问题:与组合问题对应( 组合无顺序,排列有顺序 )
  5. 棋盘问题:迷宫

如何理解回溯法

抽象为一个 n 叉树,树的宽度为集合的大小,树的深度为递归的深度

  • 递归一次其实就是一个 for 循环
  • 当如果想要嵌套 n 层 for 循环解决问题时时,我们没法通过 for 循环实现,回溯算法可以帮我实现控制 for 循环的嵌套层数
  • 任何可以用回溯法解决的问题,都能抽象成一个树形结构

回溯三部曲

  • 递归函数参数以及返回值
  • 确定终止条件
  • 单层递归的逻辑
例题:组合

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

你可以按 任何顺序 返回答案。

  • 回溯法的搜索过程就是一个树型结构的遍历过程,图中,可以看出for循环用来横向遍历,递归的过程是纵向遍历。
var combine = function (n, k) {
    // 存放组合
    let path = []
    // 最后返回的数组
    let result = []
    // path 组合结果之一,我需要用它的长度来判断是否跳出递归
    function backtracking(n, k, startindex) {
        // 递归终止条件,集合长度为k,
        if (path.length === k) {
            // 找到这个子集了 递归终止 防止传递引用
            result.push([...path])
            return
        }
        for (let i = startindex; i <= n; i++) {
            // 这层循环我进入的是啥
            path.push(i)
            // 想做的事情在下一层梦境空间里做了,不把麻烦带出来
            backtracking(n, k, i + 1)
            // 回溯 出来时还是啥,不把麻烦带回上一层,因为我在单层循环,所以到下一轮我不能变
            path.pop()
        }
    }
    backtracking(n, k, 1)
    return result
};

回溯的剪枝

以上一道组合题为例

图中打岔的分支在那一层就已经可以出局了,没必要再往下层递归了,因为不满足条件,这样能极大减少复杂度,比如上面代码单层逻辑中,可以将 n 改成 n-(k-path.length)+1

在这里插入图片描述

 for (let i = startindex; i <= n-k+path.length+1; i++) {
         path.push(i)
         backtracking(n, k, i + 1)
         // 回溯 出来时还是啥,不把麻烦带回上一层,因为我在单层循环,所以到下一轮我不能变
         path.pop()
 }

1、组合问题

例题:组合总数 III

找出所有相加之和为 nk 个数的组合,且满足下列条件:

  • 只使用数字1到9
  • 每个数字 最多使用一次

返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。

示例 1:

输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。

var combinationSum3 = function (k, n) {
    let result = []
    let path = []
    function backtracking(sum, startindex) {
        // 退出递归的条件
        if (sum > n || startindex + k - path.length > 10) {
            return
        }
        // 判断叶子节点是否满足条件
        if (sum === n && path.length === k) {
            result.push([...path])
            return
        }
        // 单层逻辑
        for (let i = startindex; i <= 9; i++) {
            // 到这里了说明 sum<n,还要继续加
            path.push(i)
            backtracking(sum + i,i + 1)
            path.pop()
        }
    }
    backtracking(0, 1)
    return result
};
例题:电话号码的字母组合

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

img

示例 1:

输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2:

输入:digits = ""
输出:[]

需要先对每个数字与字母进行映射

0123456789
‘’‘’‘abc’‘def’‘ghi’‘jkl’‘mno’‘pqrs’‘tuv’‘wxyz’

深度由输入数字个数决定

var letterCombinations = function (digits) {
    let result = []
    let path = ''
    // 需要用一个表映射数字与能选择字母的关系
    let map = ['', '', 'abc', 'def', 'ghi', 'jkl', 'mno', 'pqrs', 'tuv', 'wxyz']
    function backtracking(startindex) {
        // 退出递归的条件
        if (startindex === digits.length) {
           // 针对出题人恶意出空字符串恶心你的情况
            if (path !== '') {
                result.push(path)
            }
            return
        }
        // 读取当前数字对应的所有字母情况,一个个进入再递归到下一层,再回溯
        for (let i = 0; i < map[Number(digits[startindex])].length; i++) {
            path += map[Number(digits[startindex])][i]
            // 这个递归其实就已经包含了遍历所有的号码中的数字了
            backtracking(startindex + 1)
            path = path.substring(0, path.length - 1)
        }

    }
    backtracking(0)
    return result
};

2、分割问题

.

分割问题有点类似于组合问题,只不过,组合是选不同位置的元素,分割问题是选位置不同的缝插入

分割问题的树形结构,以下面的例题为例:

例题:分割回文串

给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。

回文串 是正着读和反着读都一样的字符串。

示例 1:

输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]

示例 2:

输入:s = "a"
输出:[["a"]]

提示:

  • 1 <= s.length <= 16
  • s 仅由小写英文字母组成
  1. 每次从头开始,切割线用传入的 startindex 作为判断,当 startindex 到最后了,说明已经将当前的情况遍历完了,如果当前的分割满足题目条件,那么加入最终结果
  2. 每次比较当前字符串是不是回文的,如果是,就把当前的字符串加入 path,然后把把当前字符串切割掉,用右边剩下的字符串传入下层递归;如果不是,当前末尾指针后移,继续判断是不是回文直到结束
  3. path 代表的就是 startindex 左边的那些已经分割完成的字符串的回文子串的分割方法(以数组保存,分隔符就是分割线)
var partition = function (s) {
    // 存储最终结果
    let result = []
    // 存储某一种可行的分割方案
    let path = []


    // startindex 代表分割线
    function backtracking(startindex) {
        // 终止条件
        if (startindex === s.length) {
            // 存放结果
            result.push([...path])
        }

        // 单层逻辑
        for (let i = startindex; i < s.length; i++) {
            // startindex 为选取字符串的起始位置,i为选取字符串的最后位置
            // 判断现在选取的字符串是不是回文字符串,如果是,那么将这个字符串加入 path,第一次必然是回文
            if (isPalindrom(s.substring(startindex, i + 1))) {
                path.push(s.substring(startindex, i + 1))
            } else {
                // 如果不是,结束本次循环,继续往后找,直到找到回文字符串,如果后面都没有了,那么这种情况就不用进结果了
                continue
            }
            // 没有跳出去,到这一步,证明是回文,那么继续在这里做一个分割,然后分割线后面的字符串进入下一层递归,path 已经把分割线前面的分割方法加进去了
            backtracking(i + 1)
            // 递归执行完毕,path 回溯到前面的状态,保持刚进入这一层循环的状态,继续执行下面的循环
            path.pop()
        }
    }
    backtracking(0)
    return result
};

function isPalindrom(s) {
    return s.split('').reverse().join('') === s
}

3、子集问题

子集问题与前面两种问题的一大区别就是,path(当前满足条件的状况) 进入 result(最终结果) 的时机。

前面的问题是在叶子节点里找满足条件的 path 进入 result,而子集问题在非叶子节点也要找

例题:子集

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

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

示例 1:

输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2:

输入:nums = [0]
输出:[[],[0]]
var subsets = function(nums) {
  // 排序
  nums.sort((a,b)=>a-b)
  let result=[[]]
  let path=[]
  function backtracking(startindex){
      if(startindex===nums.length){
          return
      }
      for(let i=startindex;i<nums.length;i++){
          path.push(nums[i])
          result.push([...path])
          backtracking(i+1)
          path.pop()
      }
  }
  backtracking(0)
  return result
};
例题:子集 II

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。

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

示例 1:

输入:nums = [1,2,2]
输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]

示例 2:

输入:nums = [0]
输出:[[],[0]]
  • 跟子集那道题比起来 多了判重的步骤。
var subsetsWithDup = function (nums) {
   // 不多废话,先排序,为后面判重做准备
   nums.sort((a, b) => a - b)
   
   let result = [[]]
   let path = []
   function backtracking(startindex) {
       if (startindex === nums.length) {
           return
       }
       for (let i = startindex; i < nums.length; i++) {
           // 判重
           if (i > startindex && nums[i] === nums[i-1]) {
               // 跳过这一步
               continue
           }
           // 没跳过,说明不重复,接下来和子集那道题一样的操作
           path.push(nums[i])
           result.push([...path])
           backtracking(i + 1)
           path.pop()
       }
   }
   backtracking(0)
   return result
};
例题:递增子序列

给你一个整数数组 nums ,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。

数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。

示例 1:

输入:nums = [4,6,7,7]
输出:[[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]

示例 2:

输入:nums = [4,4,3,2,1]
输出:[[4,4]]

这道题与前面两道子集题都不一样,前面两个由于只要拿子集,所以可以一开始进行排序,并通过排序排除重复的情况;

但这道题不一样,这道题数组本身是乱序的,但是又在本身数组里需要找到递增的子集,所以一开始不能排序,不然会影响最后的结果,比如

[6,4] 返回的是 [[6],[4]],但是[4,6]返回的是[[4],[6],[4,6]]

var findSubsequences = function (nums) {
    // 这里不能排序了,不然会影响结果
    let result = []
    let path = []

    function backtracking(startindex) {
        // 递归终止条件
        if (startindex === nums.length) {
            return
        }

        for (let i = startindex; i < nums.length; i++) {
            // 要满足增序条件方可继续,不然这个可以直接略过了
            if (i > 0 && nums[i] < nums[startindex - 1]) {
                continue
            }
            // 判重,如果要选的元素里面之前出现过了,比如 --- 1234|(4)5(4) ---,4之前出现过了
            // 这种情况也要略过 
            if ((nums.slice(startindex, i).indexOf(nums[i])) !== -1) {
                continue
            }
            path.push(nums[i])
            // 至少需要两个元素才能进入结果
            if (path.length >= 2) {
                result.push([...path])
            }
            backtracking(i+1)
            path.pop()
        }
    }
    backtracking(0)
    return result
};

4、排列问题

  1. 在排列问题中,由于同一个组合有多种排列,去重操作就不能像组合那样轻松的用一个 startindex 来控制了,需要引入一个 used 数组记录已经使用过的元素
  2. 排列问题一般也是在叶子节点收割结果
例题:全排列

给定一个 不含重复数字 的数组 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]]

首先,以[1,2,3] 为例,将该问题抽象为如下树形结构:

  • 树的深度、宽度由需要排列的集合的大小决定
  • 终止条件 path.length===nums.length
  • 之前单层逻辑让 istartindex 开始是为了避免重复或者作为分割线,但是排列问题中不用 startindex 控制重复了,在这个没有重复元素的集合中,我只要控制自己不重复出现就行了,那么引入了 used[] 就是为了避免自己重复

本题代码:

var permute = function(nums) {
   // 存放结果集
   let result = []
   // 存放一次的排列情况
   let path = []
   // 记录哪些已被使用
   let used=new Array(nums.length).fill(0)

   function backtracking(used){
       // 递归终止条件
       if(path.length===nums.length){
           result.push([...path])
           return
       }

       // 单次搜索的逻辑
       for(let i=0;i<nums.length;i++){
           if(used[i]===1){ 
               // 这个元素已经使用过了,跳过本次循环
               continue
           }
           path.push(nums[i])
           used[i]=1
           // 到下一层继续推进
           backtracking(used)
           // 回溯
           path.pop()
           used[i]=0
       }
   }
   backtracking(used)
   return result
};
例题:全排列 II

给定一个可包含重复数字的序列 nums按任意顺序 返回所有不重复的全排列。

示例 1:

输入:nums = [1,1,2]
输出:
[[1,1,2],
 [1,2,1],
 [2,1,1]]

示例 2:

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

  • 与上道题不一样的在于 树层去重
// 给定一个组合,对他进行全排列
[6,1,2,4,9,2⃣️,5]
// 以 2 为开头的排列情况
[2,...],[2,...],.... 与 [2⃣️,...],[2⃣️,...],....  其实并无二致

代码如下:

var permuteUnique = function (nums) {
    // 多了一层去重,因此我要先排序
    nums.sort((a, b) => a - b)
    let result = []
    let path = []
    let used = new Array(nums.length).fill(0)
    function backtracking() {
        if (path.length === nums.length) {
            result.push([...path])
            return
        }

        for (let i = 0; i < nums.length; i++) {
            // 不仅要判断自己没有出现过,还要保证当前这个位置之前也没有出现过和我一样的值
            if (used[i] === 1) {
                continue
            }
            // 在我前面的这些未被使用元素里出现过和我相同的元素
            // 解法 1:在这之前出现过的元素中,和我一样但没有被 used 的如果存在则跳过
            //  if (nums.slice(0, i).filter((item, index) => used[index] === 0).indexOf(nums[i]) !== -1) {
            //     continue
            //  }
            // 解法 2: 前面的排序就是为了现在能够对树层去重进行简化,我前面的元素要是和我一样那么肯定就在我前面一位
            if(i>0&&nums[i]===nums[i-1]&&used[i-1]===0){
                continue
            }
            path.push(nums[i])
            used[i] = 1
            backtracking()
            path.pop()
            used[i] = 0
        }
    }
    backtracking()
    return result

};

5、棋盘问题

之前的组合问题、分割问题、子集、排列问题处理的都是一个个集合,一个个集合按照题目条件输出若干子集,但棋盘问题 处理的是一个二维数组/矩阵

例题:n 皇后问题

按照国际象棋的规则,皇后可以攻击与之处在 同一行同一列同一斜线 上的棋子。

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q''.' 分别代表了皇后和空位。

示例 1:

输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。

示例 2:

输入:n = 1
输出:[["Q"]]

假设棋盘的 n=3n=3 的情况的树形结构如下:

第一层递归在第一行,第二层递归在第二行,第三层递归在第三行,树的深度与宽度都由棋盘的 n 确定

row 控制当前的递归层数,有点类似于前面的 startindex

var solveNQueens = function (n) {
    // 构建棋盘,棋盘是一个数字,每一行的情况都是用字符串表示,一种棋盘代表一种方式
    let chessboard = new Array(n).fill('.')
    chessboard = chessboard.map(item => item = new Array(n).fill('.').join(''))
    // 收集棋盘结果
    let result = []
    // row 代表当前行
    function backtracking(chessboard, n, row) {
        // 检测是否满足收集条件,能到这一步说明满足条件
        if (row === n) {
            result.push([...chessboard])
        }
        for (let i = 0; i < n; i++) {
            // 当前行 在这个位置放皇后 如果合法,那么就进入下一层递归 ; 如果不合法直接略过这一步
            if (_isValid(row, i, chessboard, n)) {
                // 这里的 row 是当前行索引,i 是当前列索引
                // 字符串由于是简单数据类型,所以没法直接在指定位置修改,因此需要先转为数组
                // 先转为数组再给指定位置赋值,不然最后赋的值会取代当前的chessboard[row]
                chessboard[row] = chessboard[row].split('')
                chessboard[row][i] = 'Q'
                chessboard[row] = chessboard[row].join('')
                backtracking(chessboard, n, row + 1)
                // 回溯
                chessboard[row] = chessboard[row].split('')
                chessboard[row][i] = '.'
                chessboard[row] = chessboard[row].join('')
            }

        }
    }
    backtracking(chessboard, n, 0)
    return result
};

// 判断当前行在这个位置放皇后是否合法
function _isValid(row, i, chessboard, n) {
    // 同一行必然不可能出现皇后了,因为我在不断回溯,因此只要判断当前列或者斜对面的情况
    // 1. 前面几行的同列有没有出现过皇后,后面几行还没遍历,所以不可能出现皇后,因为我只更改过当前行上面的
    if (chessboard.filter(item => item[i] === 'Q').length > 0) {
        return false
    }
    // 2. 当前朝上的斜对面分为左上方与右上方
    for (let j = 1; j <= row && j <= i; j++) {
        if (chessboard[row - j][i - j] === 'Q') {
            return false
        }
    }

    for (let j = 1; j <= row && j < n - i; j++) {
        if (chessboard[row - j][i + j] === 'Q') {
            return false
        }
    }
    return true
}
例题:单词搜索

给定一个二维字符网格 board和一个字符串单词 word

如果 word存在于网格中,返回 true,否则返回 false

单词必须按照字母排序,通过相邻的单元格内的字母构成

二维数组 board=[ [‘A’,‘B’,‘C’,‘E’] , [‘S’,‘F’,‘C’,‘S’] , [‘A’,‘D’,‘E’,‘E’] ]

目标:word=‘ABCCED’

  • 递归的找当前字母的上下左右
var exist = function (board, word) {
    // m 为行数
    const m = board.length
    // n 为列数
    const n = board[0].length
    let result = []
    let path = []
    // 这种情况容易忽略
    if(word.length===1){
       // 如果长度为1,只要表格中有我这个字母就返回 true 
       return board.some(item=>{
           if(item.indexOf(word)!==-1){
               return true
           }
       })
    }
    // a,b 为传入的位置坐标 index 代表匹配字符的索引
    function backtracking(i, j, index) {
        // m,n 为长度与宽度,实际上索引都取不到
        if (i >= m || j >= n || i < 0 || j < 0) {
            // 啥也没找到, 退出递归
            return
        }
        // 已经全部匹配到了,将该组合放入结果
        if (index >= word.length) {
            result.push([...path])
            return
        }
        if (board[i][j] === word[index]) {
            path.push(word[index])
            // 防止下面又找回来了
            let temp = board[i][j]
            board[i][j] = null
            // 上下左右求索
            backtracking(i - 1, j, index + 1)
            backtracking(i, j - 1, index + 1)
            backtracking(i + 1, j, index + 1)
            backtracking(i, j + 1, index + 1)
            // 变回来
            board[i][j] = temp
            path.pop()
        }
        return
    }
    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            backtracking(i, j, 0)
        }
    }

    return result.length>0
};

6、使用心得

单个结果的保存形式

一般来说,结果集中的每一种结果都是用一个一位数组去存储,如果发现当前的一种结果需要用二维数组去存储,可以考虑将二维数组变成一个存储字符串的一位数组,就比如 n 皇后问题

时间复杂度

回溯法本质上也是暴力解题,是用在 for 循环无法控制层数时派上用场,遇到了时间限制的问题就要用其他方法了,比如三数之和的双指针

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值