有些问题要求在相对于输入规模呈指数增长的域中
穷举检查所有的元素?时间复杂度:指数级
回溯法:对穷举法的改进,带剪枝的搜索
基本思路:将解表示成元组的形式,依次构建其分量,当构建完某个分量后发现之后的分量无论怎么构建都不可能得到一个解,就提早回头 (剪枝),谨防一条道走到黑
在 n × n 的棋盘上放置 n 个皇后,使得任意两个皇后都不能互相攻击:不在同一行、同一列和同一条斜角线上
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | | | | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | | | | 👑 | | | | | | | 1 | | | 👑 | | | | | |
2 | | | | | | 👑 | | | | | 2 | | | | | 👑 | | | |
3 | | | | | | | | 👑 | | | 3 | | 👑 | | | | | | |
4 | | 👑 | | | | | | | | | 4 | | | | | | | | 👑 |
5 | | | | | | | 👑 | | | | 5 | 👑 | | | | | | | |
6 | 👑 | | | | | | | | | | 6 | | | | | | | 👑 | |
7 | | | 👑 | | | | | | | | 7 | | | | 👑 | | | | |
8 | | | | | 👑 | | | | | | 8 | | | | | | 👑 | | |
将皇后编号为$1, \ldots, n$,不失一般性,约定皇后$i$放在第$i$行
解的表示:$n$元组$(x_1, \ldots, x_n)$,其中$x_i$为皇后$i$的列号
显式约束条件:$x_i \in \{1, \ldots, n\}$
解空间:所有可能的$n$元组,共有$n^n$个
隐式约束条件:没有两个$x_i$相同,没有两个皇后在同一条斜角线上即对任意$i \ne j$有$|x_i - x_j| \ne |i - j|$
由隐式约束条件知解只可能是$\{1, \ldots, n\}$的置换,最多有$n!$个
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | | | | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | | | | 👑 | | | | | | | 1 | | | 👑 | | | | | |
2 | | | | | | 👑 | | | | | 2 | | | | | 👑 | | | |
3 | | | | | | | | 👑 | | | 3 | | 👑 | | | | | | |
4 | | 👑 | | | | | | | | | 4 | | | | | | | | 👑 |
5 | | | | | | | 👑 | | | | 5 | 👑 | | | | | | | |
6 | 👑 | | | | | | | | | | 6 | | | | | | | 👑 | |
7 | | | 👑 | | | | | | | | 7 | | | | 👑 | | | | |
8 | | | | | 👑 | | | | | | 8 | | | | | | 👑 | | |
两个解分别是$(4, 6, 8, 2, 7, 1, 3, 5)$、$(3, 5, 2, 8, 1, 7, 4, 6)$
注意右棋盘是左棋盘逆时针旋转 90 度得到
用树结构组织解空间,形成状态空间树
要求:每个解由树中某个结点表示
遍历状态空间树即可检查所有解,不遗漏不重复
实际实现时,可将遍历整棵树改成生长出整棵树
限界函数:对应隐式约束,用来杀死不满足的结点,即剪枝
状态空间树生长时的三类结点
状态空间树生长的两种策略:
解的表示:$4$元组$(x_1, \ldots, x_4)$,其中$x_i$为皇后$i$的列号
限界函数:设$(x_1, \ldots, x_{i-1})$是根结点到当前 E-结点的路径,那么$x_i$满足限界函数当且仅当其使得$(x_1, \ldots, x_{i-1}, x_i)$表示没有两个皇后可相互攻击的棋盘状态
初始状态:根结点 1,空棋盘
结点生成:依次摆放各个皇后
1 | 2 | 3 | 4 | 1 | 2 | 3 | 4 | 1 | 2 | 3 | 4 | |||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 1 | 👑 | 1 | 👑 | ||||||||||||
2 | 2 | 2 | 👑 | |||||||||||||
3 | 3 | 3 | ||||||||||||||
4 | 4 | 4 |
结点 3 的两个皇后在同一个斜角线
杀死结点 3,返回结点 2 继续扩展
1 | 2 | 3 | 4 | 1 | 2 | 3 | 4 | 1 | 2 | 3 | 4 | |||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 👑 | 1 | 👑 | 1 | 👑 | |||||||||||
2 | 👑 | 2 | 👑 | 2 | 👑 | |||||||||||
3 | 3 | 👑 | 3 | 👑 | ||||||||||||
4 | 4 | 4 |
1 | 2 | 3 | 4 | 1 | 2 | 3 | 4 | 1 | 2 | 3 | 4 | |||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 👑 | 1 | 👑 | 1 | 👑 | |||||||||||
2 | 👑 | 2 | 👑 | 2 | 👑 | |||||||||||
3 | 3 | 👑 | 3 | 👑 | ||||||||||||
4 | 4 | 4 | 👑 |
1 | 2 | 3 | 4 | 1 | 2 | 3 | 4 | 1 | 2 | 3 | 4 | |||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 👑 | 1 | 👑 | 1 | 👑 | |||||||||||
2 | 👑 | 2 | 2 | 👑 | ||||||||||||
3 | 👑 | 3 | 3 | |||||||||||||
4 | 4 | 4 |
1 | 2 | 3 | 4 | 1 | 2 | 3 | 4 | 1 | 2 | 3 | 4 | |||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 👑 | 1 | 👑 | 1 | 👑 | |||||||||||
2 | 👑 | 2 | 👑 | 2 | 👑 | |||||||||||
3 | 3 | 3 | 👑 | |||||||||||||
4 | 4 | 4 |
1 | 2 | 3 | 4 | |
---|---|---|---|---|
1 | 👑 | |||
2 | 👑 | |||
3 | 👑 | |||
4 | 👑 |
结点 31 是答案结点,解$(2, 4, 1, 3)$
另一个解$(3,1,4,2)$可从结点 31 继续回溯得到,也可翻转得到
初始状态是第一个活结点,也是 E-结点
如果能从 E-结点移动到一个新结点,那么新结点将变成活结点和新的 E-结点,旧的 E-结点仍是一个活结点,但不是 E-结点了
如果不能移动到一个新结点,当前的 E-结点就“死”了,返回到上一个活结点 (回溯),该活结点重新变成 E-结点
当找到了答案结点或者遍历了所有活结点后,搜索过程结束
def backtrack(n): k = 1 while k > 0: if x_{k-1}的子结点中还有没检验过的x_k满足限界函数: if x_1, ..., x_k是一条抵达答案结点的路径 print(x_1, ..., x_k) k = k + 1 # 考虑下一个结点 else: k = k - 1 # 回溯到先前的结点
递归版,初始调用backtrack_ref(1)
,自带回退更自然
def backtrack_ref(k): for x_k in 还有没检验过的x_{k-1}的子结点 and 满足限界函数: if x_1, ..., x_k是一条抵达答案结点的路径 print(x_1, ..., x_k) backtrack_ref(k+1)
def not_attack(queen, row, col): # 不会被攻击吗? for i in range(row): # 遍历已在棋盘上的皇后 # 在同一列 或 在同一个斜角线 if queen[i] == col or abs(row - i) == abs(queen[i] - col): return False return True def nqueen(queen): count = row = 0 queen[row] = 0 # 先将皇后1放在第1列 while row >= 0: # 全部结点都检测完后会回溯到-1行 if queen[row] < n: # 当前行未检测到最后一列 if not_attack(queen, row, queen[row]): # 当前位置不会被攻击 if row == n-1: # 已到最后一行 count += 1 # 解计数+1 print(queen) # 输出 row -= 1 # 返回上一行 queen[row] += 1 # 上一行的皇后右移一格 else: row += 1 # 考虑下一行的皇后右移一格 queen[row] = 0 # 将下一行的皇后放在第1列 else: # 当前位置不合法,探测当前行的下一个位置 queen[row] += 1 else: # 当前行已检测到最后一列 row -= 1 # 返回上一行 queen[row] += 1 # 上一行的皇后右移一格 return count def nqueen_rec(queen, row): for col in range(n): # 遍历n个可放位置 if not_attack(queen, row, col): # 若(row, col)位置不会被攻击 queen[row] = col if row == n - 1: # 若是最后一个皇后 输出 print(queen) else: nqueen_rec(queen, row + 1) n = 4 queen = [None for i in range(n)] print(nqueen(queen)) queen = [None for i in range(n)] nqueen_rec(queen, 0) -------------------------------- [1, 3, 0, 2] [2, 0, 3, 1] 2 [1, 3, 0, 2] [2, 0, 3, 1]
输入:无向图,初始点
输出:从给定初始点出发,恰好经过每个顶点一次的回路
例 1:从点 a 出发的 2 条哈密顿回路
输入:无向图,初始点
输出:从给定初始点出发,恰好经过每个顶点一次的回路
例 2:从点 c 出发的 6 条哈密顿回路
def hamilton(vertex): if vertex == start and len(cycle) == len(g) + 1: # 找到回路 print(cycle) return for v in g[vertex]: if not visited[v]: visited[v] = True cycle.append(v) hamilton(v) visited[v] = False cycle.pop() g = { # a-----b 'a': ['b', 'c', 'd'], # |\ / 'b': ['a', 'c'], # | \ / 'c': ['a', 'b', 'd', 'e'], # | c 'd': ['a', 'c', 'e'], # | / \ 'e': ['c', 'd'], # |/ \ } # d-----e visited = {v: False for v in g} start = 'a' cycle = [start] hamilton(start) #------------------------------- # ['a', 'b', 'c', 'e', 'd', 'a'] # ['a', 'd', 'e', 'c', 'b', 'a'] g = { # a-----b 'a': ['b', 'c', 'd'], # |\ / \ 'b': ['a', 'c', 'f'], # | \ / \ 'c': ['a', 'b', 'd', 'e'], # | c f 'd': ['a', 'c', 'e'], # | / \ / 'e': ['c', 'd', 'f'], # |/ \ / 'f': ['b', 'e'], # d-----e } visited = {v: False for v in g} start = 'c' cycle = [start] hamilton(start) #------------------------------------ # ['c', 'a', 'b', 'f', 'e', 'd', 'c'] # ['c', 'a', 'd', 'e', 'f', 'b', 'c'] # ['c', 'b', 'f', 'e', 'd', 'a', 'c'] # ['c', 'd', 'a', 'b', 'f', 'e', 'c'] # ['c', 'd', 'e', 'f', 'b', 'a', 'c'] # ['c', 'e', 'f', 'b', 'a', 'd', 'c'] g = { # 全连通图可以输出全排列 0: [1, 2, 3, 4], 1: [0, 2, 3, 4], 2: [0, 1, 3, 4], 3: [0, 1, 2, 4], 4: [0, 1, 2, 3], } visited = {v: False for v in g} start = 0 cycle = [start] hamilton(start) #------------------------------- # [0, 1, 2, 3, 4, 0] # [0, 1, 2, 4, 3, 0] # [0, 1, 3, 2, 4, 0] # [0, 1, 3, 4, 2, 0] # [0, 1, 4, 2, 3, 0] # [0, 1, 4, 3, 2, 0] # [0, 2, 1, 3, 4, 0] # [0, 2, 1, 4, 3, 0] # [0, 2, 3, 1, 4, 0] # [0, 2, 3, 4, 1, 0] # [0, 2, 4, 1, 3, 0] # [0, 2, 4, 3, 1, 0] # [0, 3, 1, 2, 4, 0] # [0, 3, 1, 4, 2, 0] # [0, 3, 2, 1, 4, 0] # [0, 3, 2, 4, 1, 0] # [0, 3, 4, 1, 2, 0] # [0, 3, 4, 2, 1, 0] # [0, 4, 1, 2, 3, 0] # [0, 4, 1, 3, 2, 0] # [0, 4, 2, 1, 3, 0] # [0, 4, 2, 3, 1, 0] # [0, 4, 3, 1, 2, 0] # [0, 4, 3, 2, 1, 0]
输入:$n$个正数的集合$W = \{ w_1, w_2, \ldots, w_n \}$和正数$M$
输出:$W$中的和数等于$M$的所有子集
例 1:
例 2:
对每个$w_k$做出选择后,以下两种情形可以剪枝:
设$w_1, w_2, \ldots, w_n$已按升序排列,则有
$$ \begin{align*} \quad \sum_{i=1}^k w_i x_i + \sum_{i=k+1}^n w_i \ge M, \quad \sum_{i=1}^k w_i x_i + w_{k+1} \le M \end{align*} $$
上述两个不等式均满足才继续考虑$w_{k+1}$,否则回溯
$n=6$,$(w_1, w_2, w_3, w_4, w_5, w_6) = (5, 10, 12, 13, 15, 18)$,$M = 30$
(待选数下标, 已选数之和, 剩余数之和)
def subset_sum(i, s, left, x): # i s left x = 待选数下标 已选数之和 剩余数之和 当前元组 # print(i, s, left) if i > n: return else: if s + w[i] == M: # 若加上w[i]是一个解 输出 回溯 x.append(1) print(x) x.pop() return if s + left >= M and s + w[i] + w[i + 1] <= M: # 否则 若选择w[i]可满足限界条件 递归 x.append(1) subset_sum(i + 1, s + w[i], left - w[i], x) x.pop() if s - w[i] + left >= M and s + w[i + 1] <= M: # 否则 若不选择w[i]可满足限界条件 递归 x.append(0) subset_sum(i + 1, s, left - w[i], x) x.pop() w, M = [0, 5, 10, 12, 13, 15, 18], 30 n = len(w) x = [] subset_sum(1, 0, sum(w), x) # --------------------------- # 1 0 73 # 2 5 68 # 3 15 58 # 4 15 46 # 5 15 33 # [1, 1, 0, 0, 1] # 3 5 58 # 4 17 46 # [1, 0, 1, 1] # 4 5 46 # 5 5 33 # 2 0 68 # 3 10 58 # 4 10 46 # 5 10 33 # 3 0 58 # 4 12 46 # 5 12 33 # 6 12 18 # [0, 0, 1, 0, 0, 1] # 4 0 46 # 5 13 33 # 5 0 33
$n=6$,$(w_1, w_2, w_3, w_4, w_5, w_6) = (5, 10, 12, 13, 15, 18)$,$M = 30$
(待选数最小下标, 已选数之和, 剩余数之和)
def subset_sum(i, s, left, x): # i s left x = 待选数最小下标 已选数之和 剩余数之和 当前元组 print(i, s, left) if i > n: return else: for j in range(i, n): # 依次尝试选择 w_i, w_i+1, ..., w_n if s + w[j] == M: # 若加上w[j]是一个解 输出 x.append(j) print(x) x.pop() elif j < n-1: # 否则 若选择w[j]可满足限界条件 递归 if s + left >= M and s + w[j] + w[j + 1] <= M: x.append(j) left = 0 for k in range(j+1, n): left += w[k] subset_sum(j + 1, s + w[j], left, x) x.pop() w, M = [0, 5, 10, 12, 13, 15, 18], 30 n = len(w) x = [] subset_sum(1, 0, sum(w), x) # --------------------------- # 1 0 73 # 2 5 68 # 3 15 58 # [1, 2, 5] # 4 17 46 # [1, 3, 4] # 3 10 58 # 4 12 46 # [3, 6] # 5 13 33
回溯法:带剪枝的搜索,最坏情况下时间复杂度和穷举法相同
但是对某些困难的组合难题,可以求解其较大实例
改进:
分派问题:给$n$个人分派$n$件工作,把工作$j$分派给第$i$个人的成本为$\text{cost}(i,j)$,设计一个回溯算法,在给每个人分派一件不同工作的情况下使得总成本最小
设集合$W=(5,7,10,12,15,18,20)$、$M=35$,找出$W$中使得和数等于$M$的全部子集并画出所生成的部分状态空间树