回溯法(back tracking)(探索与回溯法)是一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。
白话:回溯法可以理解为通过选择不同的岔路口寻找目的地,一个岔路口一个岔路口的去尝试找到目的地。如果走错了路,继续返回来找到岔路口的另一条路,直到找到目的地。
回溯算法框架。解决一个回溯问题,实际上就是一个**决策树的遍历过程**。你只需要思考 3 个问题:
1、路径:也就是已经做出的选择。
2、选择列表:也就是你当前可以做的选择。
3、结束条件:也就是到达决策树底层,无法再做选择的条件。
如果你不理解这三个词语的解释,没关系,我们后面会用「全排列」和「N 皇后问题」这两个经典的回溯算法问题来帮你理解这些词语是什么意思,现在你先留着印象。
代码方面,回溯算法的框架:
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」,特别简单。
什么叫做选择和撤销选择呢,这个框架的底层原理是什么呢?下面我们就通过「全排列」这个问题来解开之前的疑惑,详细探究一下其中的奥妙!
46. 全排列---中等
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例:
输入: [1,2,3]
输出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
我们在高中的时候就做过排列组合的数学题,我们也知道 n 个不重复的数,全排列共有 n! 个。
PS:为了简单清晰起见,我们这次讨论的全排列问题不包含重复的数字。
那么我们当时是怎么穷举全排列的呢?比方说给三个数 [1,2,3],你肯定不会无规律地乱穷举,一般是这样:
先固定第一位为 1,然后第二位可以是 2,那么第三位只能是 3;然后可以把第二位变成 3,第三位就只能是 2 了;然后就只能变化第一位,变成 2,然后再穷举后两位……
其实这就是回溯算法,我们高中无师自通就会用,或者有的同学直接画出如下这棵回溯树:
只要从根遍历这棵树,记录路径上的数字,其实就是所有的全排列。我们不妨把这棵树称为回溯算法的「决策树」。
为啥说这是决策树呢,因为你在每个节点上其实都在做决策。比如说你站在下图的红色节点上:
你现在就在做决策,可以选择 1 那条树枝,也可以选择 3 那条树枝。为啥只能在 1 和 3 之中选择呢?因为 2 这个树枝在你身后,这个选择你之前做过了,而全排列是不允许重复使用数字的。
现在可以解答开头的几个名词:[2] 就是「路径」,记录你已经做过的选择;[1,3] 就是「选择列表」,表示你当前可以做出的选择;「结束条件」就是遍历到树的底层,在这里就是选择列表为空的时候。
如果明白了这几个名词,可以把**「路径」和「选择」列表作为决策树上每个节点的属性** ,比如下图列出了几个节点的属性:
我们定义的 backtrack 函数其实就像一个指针,在这棵树上游走,同时要正确维护每个节点的属性,每当走到树的底层,其「路径」就是一个全排列。
再进一步,如何遍历一棵树?这个应该不难吧。回忆一下之前「学习数据结构的框架思维」写过,各种搜索问题其实都是树的遍历问题,而多叉树的遍历框架就是这样:
# 自己理解: 本质还是 树的搜索过程,只是每个树的节点带了属性(【路径】、【选择列表】), 然后遍历到每个节点时候对于属性进行判断,如果节点满足【结束条件】,则返回,然后进一步修改,节点的属性,迭代遍历;
void traverse(TreeNode root) {
for (TreeNode child : root.childern)
// 前序遍历需要的操作
traverse(child);
// 后序遍历需要的操作
}
而所谓的前序遍历和后序遍历,他们只是两个很有用的时间点,我给你画张图你就明白了:
现在,你是否理解了回溯算法的这段核心框架?
for 选择 in 选择列表:
# 做选择
将该选择从选择列表移除
路径.add(选择)
backtrack(路径, 选择列表)
# 撤销选择
路径.remove(选择)
将该选择再加入选择列表
我们只要在递归之前做出选择,在递归之后撤销刚才的选择,就能正确得到每个节点的选择列表和路径。
下面,直接看全排列代码:
# python 代码
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
# // 路径:记录在 track 中
# // 选择列表:nums 中不存在于 track 的那些元素
# // 结束条件:nums 中的元素全都在 track 中出现
def trackBack(nums,track): # (路径, 选择列表)
# 触发结束条件
if len(track) == len(nums):
res.append(track[:]) # 需要传递下track的拷贝,否则对track的修改会影响到结果
return None
# // 遍历选择列表,做选择的过程
for i in nums:
# // 排除不合法的选择
if i in track:
continue
# // 做选择
track.append(i)
# // 进入下一层决策树
trackBack(nums,track)
# // 取消选择,'回溯'过程
track.pop()
res = [] # 最终 遍历得到 多个路径 的结果存储
track = [] # 记录当前 路径
trackBack(nums,track)
return res
我们这里稍微做了些变通,没有显式记录「选择列表」,而是通过 nums 和 track 推导出当前的选择列表:
至此,我们就通过全排列问题详解了回溯算法的底层原理。当然,这个算法解决全排列不是很高效,应为对链表使用 contains 方法需要 O(N) 的时间复杂度。有更好的方法通过交换元素达到目的,但是难理解一些,这里就不写了,有兴趣可以自行搜索一下。
但是必须说明的是,不管怎么优化,都符合回溯框架,而且时间复杂度都不可能低于 O(N!),因为穷举整棵决策树是无法避免的。这也是回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。
51. N皇后--困难
n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
上图为 8 皇后问题的一种解法。
给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。
每一种解法包含一个明确的 n 皇后问题的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。
示例:
输入: 4
输出: [
[".Q..", // 解法 1
"...Q",
"Q...",
"..Q."],
["..Q.", // 解法 2
"Q...",
"...Q",
".Q.."]
]
解释: 4 皇后问题存在两个不同的解法。
这个问题很经典了,简单解释一下:给你一个 N×N 的棋盘,让你放置 N 个皇后,使得它们不能互相攻击。
PS:皇后可以攻击同一行、同一列、左上左下右上右下四个方向的任意单位。
这个问题本质上跟全排列问题差不多,决策树的每一层表示棋盘上的每一行;每个节点可以做出的选择是,在该行的任意一列放置一个皇后。
时间复杂度分析:
1. N**(N)----对应于搜索树分析
空间复杂度分析:
1. 初始化过程使用N**2
2. 存储中间结果: N**2
3. 最终结果: res*N**2
# python
class Solution:
# 时间复杂度分析:
# 空间复杂度分析: N**2
def solveNQueens(self, n: int) -> List[List[str]]:
# 思路: 经典回溯问题: 难点在于 坐标 p[i][j] 能否放皇后问题的判断;
# 首先明白: 同行只有一个皇后,同列也是只有一个皇后
# 回溯问题大框架:
# 1. 思考三个问题: 1) 路径: 已经做出的选择 2) 选择列表: 当前可以做的选择(排除不能做的选择) 3) 结束条件: 到达决策树底层, 无法再做选择的条件;
# 2. 代码框架
# result = []
# def trackBack(路径, 选择列表):
# if 满足结束条件: # 递归终止条件
# result.append(路径)
# return None # 找到一个解空间,继续;
#
# for 选择 in 选择列表:
# 做选择
# trackBack(当前路径, 当前选择列表)
# 撤销选择,回溯操作
# trackBack(路径, 选择列表) # 调用 回溯算法
# return result
# 回溯算法: 因为每行中仅有一个皇后,所以路径只需要记录当前行数就可以;
def trackBack(board, row):
# 回溯步骤1:
# if 满足条件:
# res.append(路径) # 路径本题指: 整个棋盘元素
# return
# // 触发结束条件
if row == len(board):
tmp_list = [] # 将 二维棋盘转换为1维列表,进一步加入最终结果
for res_row in board:
tmp_line_res = ''.join(res_row)
tmp_list.append(tmp_line_res)
res.append(tmp_list)
return None
# 回溯步骤2:
# for 选择 in 选择列表:
# 排除不合法选择
# 做选择
# trackBack(路径, 选择列表) 路径: row 选择列表: board
# 撤销选择,回溯
# // 对于每一行遍历,进一步做选择,选择哪一列
for col in range(len(board[0])):
# // 排除不合法路径: 各个回溯算法主要不同之处
if not isVaild(board, row, col): # 判断当前未知 row, col 在(当前已知信息遍历路径+现有棋盘结果)能否放置皇后; 如果不能则排除; 如果可以则继续
continue
# // 做选择, 棋盘布局发生变化, 对应 路径、选择列表发生变化
board[row][col] = 'Q'
# // 当前行决策完成,进入下一行进行决策
trackBack(board, row+1)
# 当前位置执行、递归结束, 撤销选择, 回溯
board[row][col] = '.' # 至此,回溯算法结束;
def isVaild(board, row, col):
# 条件1: 列不能有皇后
# 条件2: 右上方对角线不能有皇后
# 条件3:左上方对角线不能有皇后
# 3条件都满足才可以设置皇后;
# 检查列 是否有皇后冲突, 当前列,从第一行到当前行,不能设置 皇后
for i in range(row):
if board[i][col] == 'Q':
return False
# 检查右上方对角线 是否有皇后冲突, 对角线条件[...]
r_row, r_col = row, col
while r_row > 0 and r_col < n-1: #终止条件: r_row = 0, r_col = n -1
r_row -= 1
r_col += 1
if board[r_row][r_col] == 'Q':
return False
# 检查左上方对角线 是否有皇后冲突, 对角线条件
l_row, l_col = row, col
while l_row > 0 and l_col > 0:
l_row -= 1
l_col -= 1
if board[l_row][l_col] == 'Q':
return False
return True
# 初始化二维棋盘: 初始化. , 后面有皇后: Q
board = [['.']*n for _ in range(n)]
# 最终结果: [[棋盘1布局], [棋盘2布局], [棋盘3布局], ...] , 元素为: 正确放置皇后的棋盘;
res = []
trackBack(board, 0) # board: 选择列表 0: 当前路径(row)
return res
思考: 如何进一步优化? 如何进行剪枝?
左上方、右上方的判断过程: 不能进一步进行优化;
判断
函数 backtrack 依然像个在决策树上游走的指针,通过 row 和 col 就可以表示函数遍历到的位置,通过 isValid 函数可以将不符合条件的情况剪枝: 最差时间复杂度O(N^N)
如果直接给你这么一大段解法代码,可能是懵逼的。但是现在明白了回溯算法的框架套路,还有啥难理解的呢?无非是1. 改改做选择的方式,排除不合法选择的方式而已,只要框架存于心,你面对的只剩下小问题了。
当 N = 8 时,就是八皇后问题,数学大佬高斯穷尽一生都没有数清楚八皇后问题到底有几种可能的放置方法,但是我们的算法只需要一秒就可以算出来所有可能的结果。
不过真的不怪高斯。这个问题的复杂度确实非常高,看看我们的决策树,虽然有 isValid 函数剪枝,但是最坏时间复杂度仍然是 O(N^(N+1)),而且无法优化。如果 N = 10 的时候,计算就已经很耗时了。
有的时候,我们并不想得到所有合法的答案,只想要一个答案,怎么办呢?比如解数独的算法,找所有解法复杂度太高,只要找到一种解法就可以。
其实特别简单,只要稍微修改一下回溯算法的代码即可:
// 函数找到一个答案后就返回 true
bool backtrack(vector& board, int row) {
// 触发结束条件
if (row == board.size()) {
res.push_back(board);
return true; # 如果出现正确答案则终止
}
...
for (int col = 0; col < n; col++) {
...
board[row][col] = 'Q';
if (backtrack(board, row + 1)) # 进行一次判断,如果选择正确则发生终止;
return true;
board[row][col] = '.';
}
return false;
}
这样修改后,只要找到一个答案,for 循环的后续递归穷举都会被阻断。也许你可以在 N 皇后问题的代码框架上,稍加修改,写一个解数独的算法?
回溯算法就是个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做一些操作,算法框架如下:
def backtrack(...):
for 选择 in 选择列表:
做选择
backtrack(...)
撤销选择
写 backtrack 函数时,需要维护走过的「路径」和当前可以做的「选择列表」,当触发「结束条件」时,将「路径」记入结果集。
其实想想看,回溯算法和动态规划是不是有点像呢?我们在动态规划系列文章中多次强调,动态规划的三个需要明确的点就是「状态」「选择」和「base case」,是不是就对应着走过的「路径」,当前的「选择列表」和「结束条件」?
某种程度上说,动态规划的暴力求解阶段就是回溯算法。只是有的问题具有重叠子问题性质,可以用 dp table 或者备忘录优化,将递归树大幅剪枝,这就变成了动态规划。而今天的两个问题,都没有重叠子问题,也就是回溯算法问题了,复杂度非常高是不可避免的。
over
求子集(subset),求排列(permutation),求组合(combination)。这几个问题都可以用回溯算法解决。
此时的时间复杂度呢? O(N) ? 不也是 2 N 2^N 2N ?
78. 子集
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例:
输入: nums = [1,2,3]
输出:
[
[3],
[1],
[2],
[1,2,3],
[1,3],
[2,3],
[1,2],
[]
]
python代码实现;
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
# 不像之前有回溯终止条件, 或者说: 答案格式的满足条件,目前没有了。 其实和之前的代码基本一致;
# 改动1: 之前使用已经访问路径标注, 不可访问的路径; 目前行不通,track里面已经包含的不走约束性不够, 还要 排除之前已出现结果。 故每次start递增;
# 改动2: 之前终止条件是: nums.size() == track.size() 现在没有这个需求, 路径上面所有节点都应该添加。
def trackBack(nums, start, track):
# nums 可以选择的节点;
# start: 开始搜索结果的起始范围;
# track:目前递归情形下已经访问的路径
# track中的值, 不停的添加
res.append(track[:])
# 这里start是形参,所以可以变化不影响;
for i in range(start, len(nums)):
# 做选择
track.append(nums[i])
# 回溯
trackBack(nums, i+1, track)
# 撤销选择
track.pop()
res = []
track = []
trackBack(nums, 0, track)
return res
77. 组合
给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。
示例:
输入: n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
python实现:
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
# 思路: 仍然是回溯算法, 排列为子集加上限定条件k
def trackBack(n, start, track):
if k == len(track):
res.append(track[:])
return None
for i in range(start, n):
track.append(i)
trackBack(n, i+1, track)
track.pop()
res = []
track = []
trackBack(n+1, 1, track)
return res
46. 全排列---中等
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例:
输入: [1,2,3]
输出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
python实现
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
# 思路: 使用回溯, trackBack(), 递归判断每个叶子节点;
def trackBack(nums, track): # (nums: 选择列表, track:路径)
# 触发结束条件
if len(track) == len(nums):
res.append(track[:]) # 传递track浅拷贝, 列表浅拷贝,内部元素不可变,则 track更新,res中append的元素不发生变化
return None
# 遍历选择列表,做选择的过程, num:选择
for num in nums:
# 排除不合法选择:已经在路径中元素
if num in track:
continue
# 做选择
track.append(num)
# 进入下一层决策树
trackBack(nums, track)
# 取消选择,真正回溯过程
track.pop()
res = [] # 最终遍历多个路径结果
track = [] # 当前路径: 已经走过的路径
trackBack(nums, track)
return res
输入是一个9x9的棋盘,空白格子用点号字符 . 表示,算法需要在原地修改棋盘,将空白格子填上数字,得到一个可行解。
至于数独的要求,大家想必都很熟悉了,每行,每列以及每一个 3×3 的小方格都不能有相同的数字出现。那么,现在我们直接套回溯框架即可求解。
我们求解数独的思路很简单粗暴,就是对每一个格子所有可能的数字进行穷举。对于每个位置,应该如何穷举,有几个选择呢?很简单啊,从 1 到 9 就是选择,全部试一遍不就行了:
// 对 board[i][j] 进行穷举尝试
void backtrack(char[][] board, int i, int j) {
int m = 9, n = 9;
for (char ch = '1'; ch <= '9'; ch++) {
// 做选择
board[i][j] = ch;
// 继续穷举下一个
backtrack(board, i, j + 1);
// 撤销选择
board[i][j] = '.';
}
}
并不是 1 到 9 都可以取到的,有的数字不是不满足数独的合法条件吗?而且现在只是给 j 加一,那如果 j 加到最后一列了,怎么办?
很简单,当 j 到达超过每一行的最后一个索引时,转为增加 i 开始穷举下一行,并且在穷举之前添加一个判断,跳过不满足条件的数字:
void backtrack(char[][] board, int i, int j) {
int m = 9, n = 9;
if (j == n) {
// 穷举到最后一列的话就换到下一行重新开始。
backtrack(board, i + 1, 0);
return;
}
// 如果该位置是预设的数字,不用我们操心
if (board[i][j] != '.') {
backtrack(board, i, j + 1);
return;
}
for (char ch = '1'; ch <= '9'; ch++) {
// 如果遇到不合法的数字,就跳过
if (!isValid(board, i, j, ch))
continue;
board[i][j] = ch;
backtrack(board, i, j + 1);
board[i][j] = '.';
}
}
// 判断 board[i][j] 是否可以填入 n
boolean isValid(char[][] board, int r, int c, char n) {
for (int i = 0; i < 9; i++) {
// 判断行是否存在重复
if (board[r][i] == n) return false;
// 判断列是否存在重复
if (board[i][c] == n) return false;
// 判断 3 x 3 方框是否存在重复
if (board[(r/3)*3 + i/3][(c/3)*3 + i%3] == n)
return false;
}
return true;
}
现在基本上差不多了,还剩最后一个问题:这个算法没有 base case,永远不会停止递归。这个好办,什么时候结束递归?显然 r == m 的时候就说明穷举完了最后一行,完成了所有的穷举,就是 base case。
前文也提到过,为了减少复杂度,我们可以让 backtrack 函数返回值为 boolean,如果找到一个可行解就返回 true,这样就可以阻止后续的递归。只找一个可行解,也是题目的本意。
最终代码修改如下:
boolean backtrack(char[][] board, int i, int j) {
int m = 9, n = 9;
if (j == n) {
// 穷举到最后一列的话就换到下一行重新开始。
return backtrack(board, i + 1, 0);
}
if (i == m) {
// 找到一个可行解,触发 base case
return true;
}
if (board[i][j] != '.') {
// 如果有预设数字,不用我们穷举
return backtrack(board, i, j + 1);
}
for (char ch = '1'; ch <= '9'; ch++) {
// 如果遇到不合法的数字,就跳过
if (!isValid(board, i, j, ch))
continue;
board[i][j] = ch;
// 如果找到一个可行解,立即结束
if (backtrack(board, i, j + 1)) {
return true;
}
board[i][j] = '.';
}
// 穷举完 1~9,依然没有找到可行解,此路不通
return false;
}
boolean isValid(char[][] board, int r, int c, char n) {
// 见上文
}
现在可以回答一下之前的问题,为什么有时候算法执行的次数多,有时候少?为什么对于计算机而言,确定的数字越少,反而算出答案的速度越快?
已经实现了一遍算法,掌握了其原理,回溯就是从 1 开始对每个格子穷举,最后只要试出一个可行解,就会立即停止后续的递归穷举。所以暴力试出答案的次数和随机生成的棋盘关系很大,这个是说不准的。
那么你可能问,既然运行次数说不准,那么这个算法的时间复杂度是多少呢?
对于这种时间复杂度的计算,我们只能给出一个最坏情况,也就是 O ( 9 M ) O(9^M) O(9M),其中 M 是棋盘中空着的格子数量。你想嘛,对每个空格子穷举 9 个数,结果就是指数级的。
这个复杂度非常高,但稍作思考就能发现,实际上我们并没有真的对每个空格都穷举 9 次,有的数字会跳过,有的数字根本就没有穷举,因为当我们找到一个可行解的时候就立即结束了,后续的递归都没有展开
这个 O(9^M) 的复杂度实际上是完全穷举,或者说是找到所有可行解的时间复杂度。
如果给定的数字越少,相当于给出的约束条件越少,对于计算机这种穷举策略来说,是更容易进行下去,而不容易走回头路进行回溯的,所以说如果仅仅找出一个可行解,这种情况下穷举的速度反而比较快。
37. 解数独
编写一个程序,通过已填充的空格来解决数独问题。
一个数独的解法需遵循如下规则:
数字 1-9 在每一行只能出现一次。
数字 1-9 在每一列只能出现一次。
数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。
空白格用 '.' 表示。
输入是一个9*9的棋盘
python照着大佬的思路实现。
class Solution:
def solveSudoku(self, board: List[List[str]]) -> None:
"""
Do not return anything, modify board in-place instead.
"""
chars = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
rows, cols = 9, 9
# 当前选择字符的可行性判断
def isValid(board, row, col, char):
for i in range(9):
# 判断行是否出现重复
if board[row][i] == char:
return False
# 判断列是否出现重复
if board[i][col] == char:
return False
# 判断 3*3表格是否存在重复
# 仅仅是画横线的3*3, 并没有要求 任意3*3
if board[(row//3)*3+i//3][(col//3)*3+i%3] == char:
return False
return True
def trackBack(board, row, col):
# print(row, col, board)
'''
board: list 现有棋盘描述
row: int 当前搜索位置
col: int 当前搜索位置
'''
if col == cols:
# 穷举到最后一列换到下一行重新开始
return trackBack(board, row+1, 0)
if row == rows:
# 找到一个可行解, 出发base case, 返回True
return True
# 这部分和之前 for col in len(grid[0])本质一样。 只是这部分现在直接跳转了
if board[row][col] != '.':
# 如果有预设数字, 不用穷举,跳过
return trackBack(board, row, col+1)
for char in chars:
# 遇到非法字符跳过
if not isValid(board, row, col, char):
continue
# 做选择
board[row][col] = char
# print(board)
# 如果找到一个可行解, 立即结束
if trackBack(board, row, col+1):
return True
# 撤销选择
board[row][col] = '.'
return False
trackBack(board, 0, 0)
# return board
括号问题可以简单分成两类,一类是前文写过的 括号的合法性判断 ,一类是合法括号的生成。对于括号合法性的判断,主要是借助「栈」这种数据结构,而对于括号的生成,一般都要利用回溯递归的思想。
括号生成算法是 LeetCode 第 22 题,要求如下:
请你写一个算法,输入是一个正整数 n,输出是 n 对儿括号的所有合法组合,函数签名如下:
明白了合法括号的性质,如何把这道题和回溯算法扯上关系呢?
算法输入一个整数 n,让你计算 n 对儿括号能组成几种合法的括号组合,可以改写成如下问题:
现在有 2n 个位置,每个位置可以放置字符 左括号: ( 或者 右括号: ),组成的所有括号组合中,有多少个是合法的?
这个命题和题目的意思完全是一样的对吧,那么我们先想想如何得到全部 2^(2n) 种组合,然后再根据我们刚才总结出的合法括号组合的性质筛选出合法的组合,不就完事儿了?
如何得到所有的组合呢?这就是标准的暴力穷举回溯框架啊,我们前文 回溯算法套路框架详解 都总结过了:
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
那么对于我们的需求,如何打印所有括号组合呢?套一下框架就出来了,伪码如下:
void backtrack(int n, int i, string& track) {
// i 代表当前的位置,共 2n 个位置
// 穷举到最后一个位置了,得到一个长度为 2n 组合
if (i == 2 * n) {
print(track);
return;
}
// 对于每个位置可以是左括号或者右括号两种选择
for choice in ['(', ')'] {
track.push(choice); // 做选择
// 穷举下一个位置
backtrack(n, i + 1, track);
track.pop(choice); // 撤销选择
}
}
那么,现在能够打印所有括号组合了,如何从它们中筛选出合法的括号组合呢?很简单,加几个条件进行「剪枝」就行了。
对于 2n 个位置,必然有 n 个左括号,n 个右括号,所以我们不是简单的记录穷举位置 i,而是用 left 记录还可以使用多少个左括号,用 right 记录还可以使用多少个右括号,这样就可以通过刚才总结的合法括号规律进行筛选了:
vector<string> generateParenthesis(int n) {
if (n == 0) return {};
// 记录所有合法的括号组合
vector<string> res;
// 回溯过程中的路径
string track;
// 可用的左括号和右括号数量初始化为 n
backtrack(n, n, track, res);
return res;
}
// 可用的左括号数量为 left 个,可用的右括号数量为 rgiht 个
void backtrack(int left, int right,
string& track, vector<string>& res) {
// 若左括号剩下的多,说明不合法
if (right < left) return;
// 数量小于 0 肯定是不合法的
if (left < 0 || right < 0) return;
// 当所有括号都恰好用完时,得到一个合法的括号组合
if (left == 0 && right == 0) {
res.push_back(track);
return;
}
// 尝试放一个左括号
track.push_back('('); // 选择
backtrack(left - 1, right, track, res);
track.pop_back(); // 撤消选择
// 尝试放一个右括号
track.push_back(')'); // 选择
backtrack(left, right - 1, track, res);
track.pop_back(); ;// 撤消选择
}
这样,我们的算法就完成了,算法的复杂度是多少呢?这个比较难分析,对于递归相关的算法,时间复杂度这样计算(递归次数)*(递归函数本身的时间复杂度)。
backtrack 就是我们的递归函数,其中**没有任何 for 循环代码,所以递归函数本身的时间复杂度是 O(1),**但关键是这个函数的递归次数是多少?换句话说,给定一个 n,backtrack 函数递归被调用了多少次?
我们前面怎么分析动态规划算法的递归次数的?主要是看「状态」的个数对吧。其实回溯算法和动态规划的本质都是穷举,只不过动态规划存在「重叠子问题」可以优化,而回溯算法不存在而已。
所以说这里也可以用「状态」这个概念,对于 backtrack 函数,状态有三个,分别是 left, right, track,这三个变量的所有组合个数就是 backtrack 函数的状态个数(调用次数)。
left 和 right 的组合好办,他俩取值就是 0~n 嘛,组合起来也就 N 2 N^2 N2 种而已;这个 track 的长度虽然取在 0~2n,但对于每一个长度,它还有指数级的括号组合,这个是不好算的。
说了这么多,就是想让大家知道这个算法的复杂度是指数级,而且不好算,这里就不具体展开了,是 4 n n \frac{4^{n}}{\sqrt{n}} n4n,有兴趣的读者可以搜索一下「卡特兰数」相关的知识了解一下这个复杂度是怎么算的。 (复杂度计算还不会)
题目描述:
22. 括号生成
数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
示例:
输入:n = 3
输出:[
"((()))",
"(()())",
"(())()",
"()(())",
"()()()"
]
python照着大佬代码实现:
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
# 转换为: 总共有2n位置, 然后使用左括号、右括号进行填充的问题;
# 采样暴力枚举的方式,类似于 全排列数目的计算一致。 然后使用 括号合法性的性质进行剪枝,加速计算即可;
# 1、一个「合法」括号组合的左括号数量一定等于右括号数量,这个很好理解。
# 2、对于一个「合法」的括号字符串组合 p,必然对于任何 0 <= i < len(p) 都有:子串 p[0..i] 中左括号的数量都大于或等于右括号的数量。
res = [] # 最终返回结果
track = [] # 括号组合路径结果
# 回溯过程
def trackBack(left, right):
'''
left: 当前情况下: 左括号剩余数量;
right: 当前情况下: 右括号剩余数量;
'''
# 剪枝进行加速: 对于非法操作进行终止,然后程序继续执行;
# 必要条件1: 剩余左括号数量 > 右括号数量
if left > right:
return
# 必要条件: 剩余数量都必须大于0
if left < 0 or right < 0:
return
# 满足最终生成括号的合法条件
if left == right == 0:
res.append(''.join(track))
return
# 对于当前位置的选择进行枚举判断
# 尝试放一个左括号
# 进行选择
track.append('(')
# 进入下一层决策
trackBack(left-1, right)
# 左括号取出, 进行回溯
track.pop()
# 尝试放一个右括号
# 进行选择
track.append(')')
# 进入一下一层决策
trackBack(left, right-1)
# 右括号取出, 进行回溯
track.pop()
trackBack(n, n)
return res
class Solution:
def findSubsequences(self, nums: List[int]) -> List[List[int]]:
if not nums:
return []
# n log n
def trackBack(nums, start, track):
# nums: 可以选择的节点
# start: 开始搜索结果的起始范围;
# track: 已经访问的路径
if len(track) >= 2 and track not in res:
res.append(track[:])
for i in range(start, len(nums)):
if len(track) > 0 and nums[i] < track[-1]:
continue
# 做选择
track.append(nums[i])
# 进入下一个值进行递归
trackBack(nums, i+1, track)
# 回溯
track.pop()
res = []
track = []
trackBack(nums, 0, track)
return res
思路差不多如下图: 最终返回结果;
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> List[str]:
# 思路: 先使用类似于139思路判断是不是可切分,如果可节分进一步进行回溯查找;
# 回溯思路: 结尾到 进行回溯(标准回溯模板);
def dfs(s, end, wordSet, res, path, dp):
# 如果不用拆分,整个单词就在 wordSet中,直接加入 res, 但是没有return, 仍然要递归;
if s[:end+1] in wordSet:
path.append(s[:end+1])
res.append(' '.join(path[::-1]))
path.pop()
for i in range(end):
if dp[i]:
suffix = s[i+1:end+1]
if suffix in wordSet:
path.append(suffix)
dfs(s, i, wordSet, res, path, dp)
path.pop()
wordSet = {word for word in wordDict}
sLen = len(s)
dp = [False for _ in range(sLen)]
for i in range(sLen):
# 至关重要: 初始化操作;
if s[:i+1] in wordSet:
dp[i] = True
continue
for j in range(i):
if dp[j] and s[j+1:i+1] in wordSet:
dp[i] = True
break
res = []
# 如果有解, 才有必要回溯;
if dp[-1]:
path = []
dfs(s, sLen-1, wordSet, res, path, dp)
return res
174. 地下城游戏-hard
一些恶魔抓住了公主(P)并将她关在了地下城的右下角。地下城是由 M x N 个房间组成的二维网格。我们英勇的骑士(K)最初被安置在左上角的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。
骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。
有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为负整数,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为正整数,则表示骑士将增加健康点数)。
为了尽快到达公主,骑士决定每次只向右或向下移动一步。
编写一个函数来计算确保骑士能够拯救到公主所需的最低初始健康点数。
例如,考虑到如下布局的地下城,如果骑士遵循最佳路径 右 -> 右 -> 下 -> 下,
则骑士的初始健康点数至少为 7。
-2 (K) -3 3
-5 -10 1
10 30 -5 (P)
leetcode大佬—讲解1
leetcode大佬—讲解2
解题思路
划分网格,网格中的值即为最终要优化的目标:骑士的健康点数;
最后一行只能往左回溯,最后一列只能往上回溯,需要特殊处理;
逆序计算到达每个房间时骑士所需的最少健康点数,最后起点即为答案;
class Solution:
def calculateMinimumHP(self, dungeon: List[List[int]]) -> int:
# 思路: 题目关键字: 最低初始健康点数---> 动态规划; 坐标型; 最低健康点数其实就是: 路径和最小,也就是代价最小; 但是必须保证每次都活下来;
# 动态规划转移方程的推倒过程: ????
# dp[i][j]: 代表从 位置右下角到位置(i, j)时候i,j 在保证 到右下角 最小的情况下的最大值;
rows = len(dungeon)
cols = len(dungeon[0])
dp = [[0] * cols for _ in range(rows)]
dp[-1][-1] = max(1, 1 - dungeon[-1][-1])
# 最后一列
for i in range(rows-2, -1, -1):
dp[i][-1] = max(1, dp[i+1][-1] - dungeon[i][-1])
# 最右一列数据
for j in range(cols-2, -1, -1):
dp[-1][j] = max(1, dp[-1][j+1] - dungeon[-1][j])
for i in range(rows-2, -1, -1):
for j in range(cols-2, -1, -1):
dp[i][j] = max(1, min(dp[i+1][j], dp[i][j+1]) - dungeon[i][j])
return dp[0][0]
class Solution:
def partition(self, s: str) -> List[List[str]]:
# 判断字符串是不是回文串;
# 双指针思想遍历; 最差时间复杂度: O(N)
def check_is_palindrome(left, right):
while left < right:
if s[left] != s[right]:
return False
left = left + 1
right = right - 1
return True
# 回溯操作: 递归判断符合要求的路径/选择
def dfs(start, path):
if start == length:
res.append(path[:])
for i in range(start, length):
if not check_is_palindrome(start, i):
continue
# 做选择
path.append(s[start:i + 1])
# 切完第一个字符, 进一步递归;
dfs(i + 1, path)
# 插销选择,回溯;
path.pop()
res = []
length = len(s)
if length == 0:
return res
path = []
dfs(0, path)
return res
class Solution:
def partition(self, s: str) -> List[List[str]]:
# 判断字符串是不是回文串;
# 时间复杂度:O(N^2)
def longestPalindrome(s):
# 动态规划: dp[i][j] : 从位置i 到 j是否是回文串
size = len(s)
if size < 2:
return s
# 初始化二维数组: dp[i][j]:字符串i-j 是否是回文串
dp = [[False]*size for _ in range(size)]
# 初始条件设置
for i in range(size):
dp[i][i] = True
maxLen, start = 1, 0
# 遍历判断、填表: 状态、选择
# 考虑遍历顺序: 从上到下, 从左往右
for j in range(1, size):
for i in range(j):
if s[i] == s[j]:
# 这部分自己肯定想不到的
# 例子: cbba 初始化时候: 左下方初始化值设置为: False,会判断错误
if j - i < 3:
dp[i][j] = True
else:
dp[i][j] = dp[i+1][j-1]
else:
dp[i][j] = False
return dp
# 回溯操作: 递归判断符合要求的路径/选择
def dfs(start, path):
if start == length:
res.append(path[:])
for i in range(start, length):
print(start, i, dp[start][i])
if not dp[start][i]:
continue
# 做选择
path.append(s[start:i + 1])
# 切完第一个字符, 进一步递归;
dfs(i + 1, path)
# 插销选择,回溯;
path.pop()
res = []
length = len(s)
if length == 0:
return res
path = []
dp = longestPalindrome(s)
dfs(0, path)
return res