算法练习之回溯算法

526 阅读10分钟

1.回溯算法理论基础

什么是回溯法

回溯法也可以叫做回溯搜索法,是一种搜索方式。

回溯法的效率

**回溯法的本质是穷举,穷举所有可能,然后选出我们想要的答案。**如果想要回溯法高效一些,可以加入一些剪枝的操作,但也改变不了回溯法就是穷举的本质。

回溯法解决的问题

回溯法一般可以解决一些几类问题:

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 棋盘问题:N皇后,解数独等等

如何理解回溯法

回溯法解决的问题都可以抽象为树形结构。

因为回溯法解决的都是在集合中递归查找子集,集合的大小构成了树的宽度,递归的深度构成了树的深度。

递归要有终止条件,所以必然是一颗高度有限的树(N叉树)。

回溯法模板

回溯三部曲:

  • 回溯函数参数以及返回值

    • 回溯函数一般没有返回值
    • 参数不容易确定,一般是先写逻辑,需要什么参数就填什么参数。
  • 回溯函数终止条件

    • 一般是满足条件的答案,就把答案存起来,并结束本层递归。
  • 回溯的遍历过程

    伪代码如下:

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
    

    for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。

    backtracking这里自己调用自己,实现递归。

回溯算法模板框架如下:

void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

2.组合问题

77.组合

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

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

示例 1:

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

思路:

一开始想到用for循环来解决,例如示例中k为2,很容易想到用两个for循环。但如果k是5呢,难道要用5个循环吗?这时就得用回溯法来解决,有多少层循环就递归多少次即可。

回溯三部曲:

1.确定函数参数

入参:由于是组合,需要用一个变量startIndex来确定遍历的起始位置。

然后还需要两个全局变量,用来保存结果res和路径path

2.确定终止条件

当path的长度等于k时终止条件

3.确定函数单层逻辑

只要遍历数组,然后保存路径,调用递归,回溯。

代码如下:

var combine = function (n, k) {
    let res = [], path = []
    const backTracking = (startIndex) => {
        if (path.length === k) {
            res.push([...path])
            return
        }
        for (let j = startIndex; j <= n; j++) {
            path.push(j)
            // 递归
            backTracking(j + 1)
            // 回溯
            path.pop(j)
        }
    }
    backTracking(1)
    return res
}

剪枝优化

在遍历的时候,我们是用j <= n来确定范围的,但是这个范围是可以优化的。

举个栗子,当n=4,k=4的话,那么第一层for循环的时候,从第二个元素开始的遍历都没有意义了。因为从第二个元素开始的话,后面是凑不齐4个数的组合。

所以可以对这个范围进行一下优化:

1.已选元素的个数:path.length

2.还需要的元素的个数: k - path.length

3.遍历的结束位置: n - (k-path.length) + 1

代码如下:

var combine = function (n, k) {
    let res = [], path = []
    const backTracking = (startIndex) => {
        if (path.length === k) {
            res.push([...path])
            return
        }
        for (let j = startIndex; j <= n - (k - path.length) + 1; j++) {
            path.push(j)
            // 递归
            backTracking(j + 1)
            // 回溯
            path.pop(j)
        }
    }
    backTracking(1)
    return res
}

40.组合总和 II

给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用一次。

**注意:**解集不能包含重复的组合。

示例 1:

输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]

思路:

这题有两个地方需要注意

  • 每个数字在组合中只能使用一次
  • 解集不能包含重复的组合

第一个地方还比较好解决,只要设置一个遍历的起始位置即可。第二个地方则需要对解集进行去重,怎么去重呢,可以对数组进行排序,在遍历的时候如果当前值和上一个相同,则跳过当前循环。

回溯三部曲:

1.确定回溯函数参数

需要用到两个全局变量,一个是res保存结果,一个是path保存路径。

还需要两个参数,一个是startIndex用来遍历的起始位置,一个是sum用来保存当前的总和。

2.确定终止条件

sum>target则终止

sum===target则保存路径并且终止

3.确定回溯函数单层搜索的逻辑

根据起始位置遍历数组,然后进行递归处理。这里有个需要注意的地方就是当遇到和上一个值相同,则跳过当前循环。

代码如下:

var combinationSum2 = function (candidates, target) {
    let res = [], path = [], len = candidates.length;
    // 对数组进行排序
    candidates.sort((a, b) => a - b)
    backTracking(0, 0)
    return res
    function backTracking(sum, startIndex) {
        // 终止条件
        if (sum > target) return
        if (sum === target) {
            res.push([...path])
            return
        }
        for (let i = startIndex; i < len; i++) {
            const item = candidates[i]
            // 去重
            if (i > startIndex && item === candidates[i - 1]) continue
            sum += item
            path.push(item)
            backTracking(sum, i + 1)
            // 回溯
            sum -= item
            path.pop()
        }
    }
};

3.分割问题

131.分割回文串

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

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

示例 1:

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

思路:

用startIndex作为字符串切割的起始位置,判断切割的字符串是否是回文,是则找下个回文字符串,不是则找下个位置。

回溯三部曲:

1.确定函数的入参

入参是startIndex,也就是遍历和切割的起始位置。

2.确定函数的终止条件

当遍历完整个字符串则结束。

3.确定单层逻辑

从起始位置开始切割字符串,判断字符串是否回文,是则递归处理下一个,不是则回退。

代码如下:

var partition = function (s) {
    let res = [], path = []
    backTracking(0)
    return res
    function backTracking(startIndex) {
        if (startIndex === s.length) {
            res.push([...path])
            return
        }

        for (let i = startIndex; i < s.length; i++) {
            let item = s.slice(startIndex, i + 1)
            if (!isValid(item)) continue
            path.push(item)
            backTracking(i + 1)
            path.pop()
        }
    }
    // 判断是否回文
    function isValid(s) {
        let left = -1, right = s.length
        while (++left < --right) {
            if (s[left] !== s[right]) {
                return false
            }
        }
        return true
    }
};

93.复原IP地址

给定一个只包含数字的字符串,用以表示一个 IP 地址,返回所有可能从 s 获得的 有效 IP 地址 。你可以按任何顺序返回答案。

有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。

例如:"0.1.2.201" 和 "192.168.1.1" 是 有效 IP 地址,但是 "0.011.255.245"、"192.168.1.312" 和 "192.168@1.1" 是 无效 IP 地址。

示例 1:

输入:s = "25525511135"
输出:["255.255.11.135","255.255.111.35"]

思路:

跟上一题类似,其实就是切割字符串,判断字符串是否在0-255之间。

回溯三部曲:

1.确定回溯函数的入参

入参:startIndex ,切割的起始位置。

2.确定回溯函数的终止条件

当path的长度等于4时终止。如果是最后一个位置,则把path存放到res中。

3.确定回溯函数的单层逻辑

从startIndex开始遍历,切割字符串,如果是有效的ip则递归处理下一个,如果不是则遍历下一个位置。

代码如下:

var restoreIpAddresses = function (s) {
    let res = [], path = [], len = s.length
    backTracking(0)
    return res

    function backTracking(startIndex) {
        if (path.length === 4) {
            if (startIndex === len) {
                res.push(path.join('.'))
            }
            return
        }
        for (let i = startIndex; i < len; i++) {
            let ip = s.slice(startIndex, i + 1)
            if (!isValid(ip)) continue
            path.push(ip)
            backTracking(i + 1)
            path.pop()
        }
    }
    // 是否有效ip
    function isValid(ip) {
        if (ip.length > 1 && ip[0] === '0') return false
        if (+ip > 255) return false
        return true
    }
}

4.子集问题

78.子集

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

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

示例 1:

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

思路:

求子集问题,其实就是遍历这棵树的时候,把所有节点都记录下来。

回溯三部曲:

1.递归函数参数

全局变量path为子集收集元素,res存放子集集合。

入参需要startIndex

2.递归函数终止条件

当startIndex大于nums的长度时终止,但由于遍历是从startIndex开始的,所以终止条件不加也是可以的。

3.递归函数单层循环逻辑

求取子集问题,不需要任何剪枝,因为子集就是要遍历整棵树。

代码如下:

var subsets = function (nums) {
    let res = [], path = []
    function backTracking(startIndex) {
        res.push([...path])
        for (let i = startIndex; i < nums.length; i++) {
            path.push(nums[i])
            backTracking(i + 1)
            path.pop(nums[i])
        }
    }
    backTracking(0)
    return res
};

90.子集II

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

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

示例 1:

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

思路:

这题和上一题的区别在于数组中有重复的元素,而解集不能包含重复的子集,也就是要对解集进行去重。去重有一个好的方法,就是先对数组进行排序,在遍历的时候,如果当前的值和上一个值相同,则跳过即可。

回溯三部曲:

1.递归函数参数

全局变量path用来收集路径,res用来收集子集

入参:遍历的起始位置startIndex

2.递归函数终止条件

当startIndex大于数组长度时终止。

3.递归函数单层逻辑

这里由于需要去重,所以要对当前的值和上一个值进行比较,如果相同则跳过。

代码如下:

var subsetsWithDup = function (nums) {
    let res = [], path = []
    // 排序
    nums.sort((a, b) => a - b)
    backTracking(0)
    return res
    function backTracking(startIndex) {
        res.push([...path])
        for (let i = startIndex; i < nums.length; i++) {
            if (i > startIndex && nums[i] === nums[i - 1]) continue;
            path.push(nums[i])
            backTracking(i + 1)
            path.pop()
        }
    }
};

491.递增子序列

给你一个整数数组 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]]

思路:

这题和上一题很相似,都是求子集和去重。但有个区别,上一题可以通过对数组排序来进行去重,这题不能改变数组的顺序,我们可以用一个对象来达到去重的目的。

代码如下:

var findSubsequences = function (nums) {
    let res = [], path = []
    backTracking(0)
    return res
    function backTracking(startIndex) {
        if (path.length >= 2) {
            res.push([...path])
        }
        let uset = {}
        for (let i = startIndex; i < nums.length; i++) {
            // 当值比path最后一个元素小,或者已经存在uset里面则走下一步
            if (path.length && nums[i] < path[path.length - 1] || uset[nums[i]]) continue
            uset[nums[i]] = true
            path.push(nums[i])
            backTracking(i + 1)
            path.pop(nums[i])
        }
    }
};

5.排列问题

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]]

思路:

排列和组合的区别在于,排列是有序的,而组合是无序的。在解决组合问题的时候,我们需要用到startIndex来解决顺序的问题。而对于排列,我们需要判断值是否在数组中来解决重复的问题。

回溯三部曲:

1.回溯函数参数

全局变量res用来收集所有子集,path用来收集路径

2.回溯函数终止条件

当path的个数等于数组的长度时,则把path放到res中,然后终止。

3.回溯函数单层逻辑

遇到path存在的值时则跳过,否则把值放到path里面,递归处理下一个

代码如下:

var permute = function (nums) {
  let res = [], path = []
  backTracking()
  return res
  function backTracking() {
    if (path.length === nums.length) {
      res.push([...path])
      return
    }
    for (let i = 0; i < nums.length; i++) {
      // 如果路径已经包含了当前值,则跳过
      if (path.includes(nums[i])) continue
      path.push(nums[i])
      backTracking()
      path.pop()
    }
  }
};

47.全排列 II

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

示例 1:

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

思路:

这题和上一题的区别在于序列包含重复数字,并且要返回不重复的子集。其实也就是去重。去重一定要对元素进行排序,这样才能通过相邻的节点来判断是否重复使用了。

var permuteUnique = function (nums) {
    let res = [], path = []
    nums.sort((a, b) => a - b)
    backTracking()
    return res
    function backTracking(used = []) {
        if (path.length === nums.length) {
            res.push([...path])
            return
        }
        for (let i = 0; i < nums.length; i++) {
            if (i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) continue
            // 通过索引来判断是否访问过
            if (used[i]) continue
            used[i] = true
            path.push(nums[i])
            backTracking(used)
            used[i] = false
            path.pop()

        }
    }
};

这里面,去重最为关键的代码是:

if (i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) continue

如果改成下面的代码也是对的

if (i > 0 && nums[i] === nums[i - 1] && used[i - 1]) continue

这是为什么呢,这是因为这是针对不同的位置去重,如果要对树层中前一位去重,就用used[i - 1] == false,如果要对树枝前一位去重用used[i - 1] == true

对于排列问题,树层上去重和树枝上去重,都是可以的,但是树层上去重效率更高!

6.棋盘问题

332.重新安排行程

给你一份航线列表 tickets ,其中 tickets[i] = [fromi, toi] 表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。

所有这些机票都属于一个从 JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。

  • 例如,行程 ["JFK", "LGA"]["JFK", "LGB"] 相比就更小,排序更靠前。

假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次 且 只能用一次。

示例 1:

img

输入:tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]
输出:["JFK","MUC","LHR","SFO","SJC"]

思路:

这题有两个难点,一个是要构建一颗可以遍历的树,一个是要按字典排序返回返回最小的行程组合。

回溯三部曲:

1.确定递归函数的参数和返回值

需要两个全局变量,一个是res用来保存结果,一个是map用来保存每个城市的航班信息。

返回值:由于只要返回一个行程,所以需要设置返回值,当找到行程则结束

2.确定终止条件

当res的个数等于tickets的个数加1时,说明找到行程,返回true

当res最后一个城市没有行程时,则返回false

3.确定单层逻辑

遍历res最后一个城市的行程,把该行程从map中删除,然后递归处理下一个城市,最后做回溯。

代码如下:

var findItinerary = function (tickets) {
    let res = ['JFK']
    let map = {}
    // 构造map
    for (const ticket of tickets) {
        const [from, to] = ticket
        map[from] = map[from] || []
        map[from].push(to)
    }
    // 排序
    for (let city in map) {
        map[city].sort()
    }

    backTracking()

    return res

    function backTracking() {
        if (res.length === tickets.length + 1) return true
        let citys = map[res[res.length - 1]]
        if (!citys || !citys.length) return false
        for (let i = 0; i < citys.length; i++) {
            const city = citys[i]
            res.push(city)
            // 移除city
            citys.splice(i, 1)
            if (backTracking()) return true
            citys.splice(i, 0, city)
            res.pop()
        }
    }

};

51.N皇后

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

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

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

示例 1:

img

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

思路:

用一个数组path来存放皇后放置的位置,然后判断这个位置是否能放,能放则继续下一个行,不能放则找下个位置。最后当所有位置都找到的时候,生成要输出的格式。

回溯三部曲:

1.确定函数的参数

入参:当前的行数

2.确定函数的终止条件

当path的个数等于n时,说明找到所有的位置。

3.确定单层逻辑

遍历当前层的位置,然后判断这个位置是否冲突,冲突则找下一个位置,不冲突则找下一层的。

代码如下:

var solveNQueens = function (n) {
  let res = [], path = []
  backTracking(0)
  return res
  function backTracking(row) {
    if (path.length === n) {
      res.push(generate(path))
      return
    }
    for (let i = 0; i < n; i++) {
      // 判断是否符合条件
      if (isConfilt(row, i)) continue
      path.push(i)
      backTracking(row + 1)
      path.pop()
    }
  }

  function generate(path) {
    return path.map(i => {
      let arr = new Array(n).fill('.')
      arr[i] = 'Q'
      return arr.join('')
    })
  }

  function isConfilt(row, col) {
    return path.some((c, r) => {
      // 列是否一样,是否在一个斜线上
      if (c === col || c + r === col + row || r - c === row - col) {
        return true
      }
    })
  }
}

37.解数独

编写一个程序,通过填充空格来解决数独问题。

数独的解法需 遵循如下规则

  1. 数字 1-9 在每一行只能出现一次。
  2. 数字 1-9 在每一列只能出现一次。
  3. 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)

数独部分空格内已填入了数字,空白格用 '.' 表示。

示例:

img

输入:board = [["5","3",".",".","7",".",".",".","."],["6",".",".","1","9","5",".",".","."],[".","9","8",".",".",".",".","6","."],["8",".",".",".","6",".",".",".","3"],["4",".",".","8",".","3",".",".","1"],["7",".",".",".","2",".",".",".","6"],[".","6",".",".",".",".","2","8","."],[".",".",".","4","1","9",".",".","5"],[".",".",".",".","8",".",".","7","9"]]
输出:[["5","3","4","6","7","8","9","1","2"],["6","7","2","1","9","5","3","4","8"],["1","9","8","3","4","2","5","6","7"],["8","5","9","7","6","1","4","2","3"],["4","2","6","8","5","3","7","9","1"],["7","1","3","9","2","4","8","5","6"],["9","6","1","5","3","7","2","8","4"],["2","8","7","4","1","9","6","3","5"],["3","4","5","2","8","6","1","7","9"]]

思路:

从第一个位置开始,用1-9分别填入每个位置,如果不冲突则找下一个位置,冲突则回滚。一直到最后一个位置。

回溯三步走:

1.确定回溯函数的参数和返回值

入参:当前位置的行和列

返回值:遍历到最后一个位置则返回true,如果1-9都遍历完还找不到则返回false。

2.确定函数的终止条件

当遍历到第10行则结束。

3.确定函数的单层逻辑

判断当前位置是否能放置,可以则递归下一个,不可以会放置下个数字。

代码如下:

const solveSudoku = (board) => {
  let len = board.length
  backTracking(0, 0)
  return board
  function backTracking(row, col) {
    if (row >= len) return true
    if (col === len) return backTracking(row + 1, 0)
    if (board[row][col] !== '.') return backTracking(row, col + 1)
    for (let i = 1; i <= len; i++) {
      // 判断条件
      let item = String(i)
      if (isConfilt(row, col, item)) continue
      board[row][col] = item
      if (backTracking(row, col + 1)) return true
      board[row][col] = '.'
    }
    return false
  }

  function isConfilt(row, col, val) {
    // 行不能一样
    if (board[row].includes(val)) return true
    // 列不能一样
    for (let i = 0; i < len; i++) {
      if (board[i][col] === val) return true
    }
    // 3*3不能一样
    let r = Math.floor(row / 3) * 3
    let c = Math.floor(col / 3) * 3
    for (let i = 0; i < 3; i++) {
      for (let j = 0; j < 3; j++) {
        if (board[r + i][c + j] === val) {
          return true
        }
      }
    }
    return false

  }
}

参考链接: 回溯算法