本篇文章主要总结一下笔者学习的两种算法:回溯法和分支限界法。这两种算法其实在OI界中很少用到,因为简单粗暴,本质上就是暴力+剪枝。两者都是暴力搜索的一种方法,只不过分别对应二叉树遍历中的DFS和BFS思想。为什么会联系到二叉树呢?因为一个可以用回溯法或分支限界法解决的问题,实际上就是一个决策树的遍历过程。
那么我们逐一详细介绍一下两种算法。
回溯法
前文提到,解决回溯问题,实际上就是一个决策树 的遍历过程。
Q:什么是决策树?
A:决策树(Decision Tree)是一种常用的数据结构,也是一种常见的机器学习模型。在这个上下文中,我们可以将决策树理解为一个图,其中每个节点代表一个决策,每个边代表基于该决策的结果。根节点(root)代表初始状态,叶节点(leaf)代表决策结果。
在回溯法中,决策树被用来枚举所有可能的解决方案。每个节点代表一个部分解决方案,每个边代表一个选择,从根节点到叶节点的路径代表一个完整的解决方案。回溯法通过深度优先搜索(DFS)策略,进行决策树的遍历,寻找所有可能的解决方案。
例如,假设我们正在解决一个简单的迷宫问题。我们可以将每个位置视为一个决策(向上、向下、向左或向右移动),并将整个迷宫视为一个决策树。然后,我们可以使用回溯法来遍历这个决策树,找到从入口到出口的路径。
那么我们只需要搞懂三个问题,就能把握住问题的核心:
路径:就是已经做出的选择
选择列表:就是你在当前节点可以做出的选择
结束条件:就是你到达决策树底层,无法再继续做选择的条件。(也可以是一些额外的约束条件,用于剪枝减少选择提高算法效率)
代码方面,回溯问题的伪代码框架大致如下:
result = [] def backtrack (路径, 选择列表 ): if 满足结束条件: result.add(路径) return for 选择 in 选择列表: 将该选择从选择列表移除 路径.add(选择) backtrack(路径, 选择列表) 路径.remove(选择) 将该选择再加入选择列表
回溯算法代码的核心,就是一个for循环中的递归。首先检查结束条件,决定是否退出;如果没有退出,就在递归调用之前,「做选择」;在递归调用之后「撤销选择」 。就是这么简单。
接下来用两个问题来理解回溯法:全排列和N皇后问题。
全排列
46. 全排列 - 力扣(LeetCode)
给定一个不含重复数字的数组 nums
,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3] 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
我们抛开算法来看,自己想想怎么对三个数进行全排列:首先固定1不变,排列2,3和3,2;然后固定2为第一个元素,排列1,3和3,1;最后固定3为第一个元素,排列1,2和2,1。
这种穷举的思想,就是一个决策树的过程。
你可以把选择列表 与属性 视为节点的两个属性,每个节点都储存着你做出这个选择时的情况。定义的backtrack
函数更像是一个指针,在决策树上用DFS
的遍历方式游走,同时要不断的正确维护每个节点的属性。当我们走到决策树的底层时,我们走过的路径
就是一个全排列。
为什么要撤销选择?
正如我们前文提到的,要在决策树游走,需要我们正确的维护每一个决策节点的属性。
每当我们做出一个决策,我们就做出了一个选择:也就是改变了路径 。那么,当我们走到这个选择的决策树底层,回到该节点时,需要恢复原路径信息,这样我们才能在不受干扰的条件下做出另一个选择。
那我们可以不撤销选择吗?也可以。
我们之所以要撤销选择,是因为我们更改了节点的信息。但如果我们每次做选择时都生成一个节点的副本,在这上面进行的改变不会影响到原有节点,此时就不需要进行选择撤回操作。但是这么做需要给遍历到的每个节点都创建一个副本,带来的空间开销很大,不利于函数的性能。
那么用回溯法解决问题的思路就很显而易见了:首先我们做出选择,然后进入到下一层决策树继续选择;如果track列表中出现了重复的数字,说明该数字已经被选择过,丢弃该选择;如果最后路径列表的长度等于全排列的长度,说明排列已完成,返回路径结束递归。
代码如下:
package 全排列func permute (nums []int ) [][]int { res := make ([][]int , 0 ) track := make ([]int , 0 ) var backtrack func (nums []int , track []int ) backtrack = func (nums []int , track []int ) { if len (track) == len (nums) { res = append (res, append ([]int {}, track...)) return } for i := 0 ; i < len (nums); i++ { if contains(track, nums[i]) { continue } track = append (track, nums[i]) backtrack(nums, track) track = track[:len (track)-1 ] } } backtrack(nums, track) return res } func contains (slice []int , item int ) bool { for _, a := range slice { if a == item { return true } } return false }
必须说明的是,不管怎么优化,都符合回溯框架 ,而且时间复杂度都不可能低于 O(N!),因为穷举整棵决策树是无法避免的。这也是回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高 。
N皇后
51. N 皇后 - 力扣(LeetCode)
按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题 研究的是如何将 n
个皇后放置在 n×n
的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n
,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q'
和 '.'
分别代表了皇后和空位。
输入:n = 4 输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
这个问题本质上跟全排列问题差不多,决策树的每一层表示棋盘上的每一行;每个节点可以做出的选择是,在该行的任意一列放置一个皇后。
套用框架可得代码如下:
func solveNQueens (n int ) [][]string { var res [][]string var board [][]byte for i := 0 ; i < n; i++ { board = append (board, make ([]byte , n)) for j := 0 ; j < n; j++ { board[i][j] = '.' } } var backtrack func (row int ) backtrack = func (row int ) { if row == n { var tmp []string for i := 0 ; i < n; i++ { tmp = append (tmp, string (board[i])) } res = append (res, tmp) return } for col := 0 ; col < n; col++ { if !isValid(board, row, col) { continue } board[row][col] = 'Q' backtrack(row + 1 ) board[row][col] = '.' } } backtrack(0 ) return res } func isValid (board [][]byte , row, col int ) bool { n := len (board) for i := 0 ; i < n; i++ { if board[i][col] == 'Q' { return false } } for i, j := row-1 , col-1 ; i >= 0 && j >= 0 ; i, j = i-1 , j-1 { if board[i][j] == 'Q' { return false } } for i, j := row-1 , col+1 ; i >= 0 && j < n; i, j = i-1 , j+1 { if board[i][j] == 'Q' { return false } } return true }
函数 backtrack
依然像个在决策树上游走的指针,通过 row
和 col
就可以表示函数遍历到的位置,通过 isValid
函数可以将不符合条件的情况剪枝。
总结
回溯算法就是个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做一些操作。写 backtrack
函数时,需要维护走过的「路径」和当前可以做的「选择列表」,当触发「结束条件」时,将「路径」记入结果集 。
其实想想看,回溯算法和动态规划是不是有点像呢?我们在动态规划 系列文章中多次强调,动态规划的三个需要明确的点就是「状态」「选择」和「base case」,是不是就对应着走过的「路径」,当前的「选择列表」和「结束条件」?
某种程度上说,动态规划的暴力求解阶段就是回溯算法。只是有的问题具有重叠子问题性质,可以用 dp table 或者备忘录优化,将递归树 大幅剪枝,这就变成了动态规划。而今天的两个问题,都没有重叠子问题,也就是回溯算法 问题了,复杂度非常高是不可避免的。
拓展部分
以下内容来源于课件,偏理论,猜测整理自《算法导论》。
主要介绍解空间树的子集树和排列树的概念。
回溯法是一个既带有系统性又带有跳跃性的搜索算法;
它在包含问题的所有解的解空间树 中,按照深度优先的策略,从根结点出发搜索解空间树。—— 系统性
算法搜索至解空间树的任一结点时,判断该结点为根的子树是否包含问题的解,如果肯定不包含,则跳过以该结点为根的子树的搜索,逐层向其祖先结点回溯。否则,进入该子树,继续深度优先的策略进行搜索。——跳跃性
这种以深度优先的方式系统地搜索问题的解的算法称为回溯法,
它适用于解一些组合数较大的问题。许多复杂的、规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。
其实我觉得就是一种更加科学、附带剪枝的暴力搜索法。
要了解子集树和排列树,我们先对问题的解空间做一个定义。
问题的解向量:回溯法希望一个问题的解能够表示成一个n元式
(x1, x2, …, xn) 的形式。
显约束:对分量xi的取值限定。
隐约束:为满足问题的解而对不同分量之间施加的约束。
解空间:对于问题的一个实例,解向量满足显式约束条件的所有多元组, 构成了该实例的一个解空间。
注意:同一个问题可以有多种表示,有些表示方法更简单,所需表示的状态空间更小(存储量少,搜索方法简单)。
关于搜索空间主要有三种表示方法:
表序表示:搜索对象通过线性表数据结构来表示
显示图表示:搜索对象在搜索开始前就通过图(树)的数据结构来表示
隐式图表示:除了初始节点,其他的节点在搜索过程中全部动态生成。因为搜索空间可能很大,难以全部储存。
对解空间的遍历方法可以参照二叉树的遍历方法,即前中后序遍历。一般我们使用中序遍历,因为这样就可以很方便的在搜索完左子树后撤销选择再进入右子树。
有了以上概念,我们就可以根据解空间树对回溯法的操作进行更加详细的定义:
首先针对问题定义问题的解空间
接着确定易于搜索的解空间结构(子集树还是排列树)
以深度优先搜索的方式遍历解空间树,并在搜索过程中用剪枝函数避免无效搜索。
常常用剪枝函数在扩展节点减去不满足约束的子树
减去得不到最优解的子树
两类常见的解空间树定义如下:
子集树:当所给的问题是从几个元素的集合S中找出满足某种性质的子集时,相应的解空间树称为子集树。子集树通常有2n 个叶子结点,其总结点个数为2n+1 -1,遍历子集树时间为Ω(2n )。如0-1背包问题
排列树:当所给问题是确定n个元素满足某种性质的排列时,相应的解空间树称为排列树。排列树通常有n!个叶子结点,因此,遍历排列树需要Ω(n!)的计算时间。如TSP问题 (Traveling Salesman Problem,推销员问题) ,叶结点数为n!,遍历时间为Ω(n!)。
子集树
假设现在有一列数a[0],a[1], …a[n-1],如果一个问题的解的长度不是固定的,并且解和元素顺序无关,即可以从中选择0个或多个,那么解空间的个数将是指数级别的,为2^n,可以用下面的子集树来表示所有的解(假设这里n=4)
void backtrack(int t){ // 表示访问到第t层,t从0开始 if (t == n) // 如上图(PIC、子集树)n=4时就可以输出结果 output(x); else for (int i = 0; i<=l; i++){ //表示选或者不选a[t],l代表选项的范围,可能是0到某个上限 x[t] = i; if (constraint(t) && bound(t)) backtrack(t + 1); } }
排列树
如果解空间是由n个元素的排列形成,即n个元素的每一个排列都是解空间中的一个元素,那么,最后解空间的组织形式是排列树。
void backtrack(int t){ //t代表当前层数,从0开始,n代表总层数 if (t == n) // t==n时表示已经生成了一组全排列,输出结果 output(x); // x是储存当前排列结果的数组 else for (int i = t; i<=n; i++){ swap(x[t],x[i]); if (constraint(t) && bound(t)) { backtrack(t + 1); swap(x[t],x[i]); // 每次递归后恢复原状 } } } //这里的n表示层数!
例题
下面给出用回溯法求解01背包问题的代码,出自我的课程实验报告:
#include <iostream> #include <vector> using namespace std;struct Item { int weight; int value; }; void backtrack (int i,int weight,int value,const vector<Item>& items,int capacity,int & maxValue) { if (weight==capacity||i==items.size ()){ maxValue=max (value,maxValue); return ; } backtrack (i+1 ,weight,value,items,capacity,maxValue); if (weight+items[i].weight<=capacity){ backtrack (i+1 ,weight+items[i].weight,value+items[i].value,items,capacity,maxValue); } } int main () { int capacity,count,maxValue; vector<Item>Items; maxValue=0 ; cin>>capacity>>count; for (int i = 0 ; i < count; ++i) { struct Item Item; cin>>Item.weight>>Item.value; Items.emplace_back (Item); } backtrack (0 ,0 ,0 ,Items,capacity,maxValue); cout<<"The max value is " <<maxValue<<endl; return 0 ; }
可以看出设计理念参照的是解空间树的子集树,因为01背包问题是一个组合问题,与顺序无关,所以不需要撤销选择而直接遍历所有的解就行了。
分支限界法
回溯算法的思想其实和深度优先搜索也就是DFS
有共通之处,一个要走到决策树底层才退出,一个是根据深度遍历,都是“打破南墙才回头”。这无疑是一种经典的暴力搜索的方式,但是这种方式具有盲目性,接下来我们来介绍基于BFS
宽度优先搜索的分支限界法。
分支限界法常以广度优先或以最小耗费(最大效益)优先的方式搜索问题的解空间树。
i) 对已处理的各结点根据限界函数 估算目标函数的可能取值,
ii) 从中选出目标函数取得极大(极小) 值的结点优先进行广度优先搜索,
iii) 不断地调整搜索方向,尽快找到解,裁剪那些不能得到最优解的子树以提高搜索效率。
其中的限界函数是针对具体问题的目标函数,比如对于01背包问题,可以预设限界函数为背包剩余空间的平均价值。
求解步骤
定义解空间/对解编码
确定解空间的树结构
按照BFS的方式对解空间树进行遍历:
每个活节点只有一次方式变为扩展节点
由扩展节点生成一步可达 的新节点,即宽度搜索
在新节点中,删除不可能导出最优解的节点,这点可以利用限界函数辅助判断
将剩余的节点加入活动表(队列)中
从活动表中选择节点再进行扩展(分支)
直到活动表(队列)为空
在选择队列时有两种选择,对应两种分支限界法:
队列式FIFO分支限界法:节点进入与取出顺序与普通队列相同
优先队列(代价最小或者效益最大)分支限界法:每个节点都可以借助限界函数来预估一个价值,以此决定节点处理的优先级,一般要用到堆来实现优先队列。
例题
这里给出对01背包应用分支限界法的解法,包括FIFO式和优先队列式。
package mainimport "fmt" type Node struct { level int weight float64 value float64 bound float64 included bool } func bound (node Node, n int , W float64 , weights []float64 , values []float64 ) float64 { if node.weight >= W { return 0 } bound := node.value j := node.level + 1 totWeight := node.weight for ; j < n; j++ { if totWeight+weights[j] <= W { bound += values[j] totWeight += weights[j] } else { break } } if j < n { bound += (W - totWeight) * (values[j] / weights[j]) } return bound } func knapsack01 (n int , W float64 , weights []float64 , values []float64 ) float64 { var maxValue float64 var queue []Node var initNode Node initNode.level = -1 initNode.weight = 0 initNode.value = 0 queue = append (queue, initNode) for len (queue) > 0 { node := queue[0 ] queue = queue[1 :] if node.level == -1 { node.bound = bound(node, n, W, weights, values) } if node.bound > maxValue { leftNode := Node{ level: node.level + 1 , weight: node.weight + weights[node.level+1 ], value: node.value + values[node.level+1 ]} leftNode.bound = bound(leftNode, n, W, weights, values) if leftNode.weight <= W && leftNode.value > maxValue { maxValue = leftNode.value } if leftNode.bound > maxValue { queue = append (queue, leftNode) } rightNode := Node{ level: node.level + 1 , weight: node.weight, value: node.value} rightNode.bound = bound(rightNode, n, W, weights, values) if rightNode.bound > maxValue { queue = append (queue, rightNode) } } } return maxValue } func main () { var n int fmt.Scan(&n) var W int fmt.Scan(&W) var weights = make ([]float64 , n) for i := 0 ; i < n; i++ { fmt.Scan(&weights[i]) } var values = make ([]float64 , n) for i := 0 ; i < n; i++ { fmt.Scan(&values[i]) } result := knapsack01(n, float64 (W), weights, values) fmt.Println("Maximum value that can be obtained is:" , result) } package mainimport ( "container/heap" "fmt" ) type Node struct { level int weight float64 value float64 bound float64 included bool } type PriorityQueue []Nodefunc (pq PriorityQueue) Len() int { return len (pq) }func (pq PriorityQueue) Less(i, j int ) bool { return pq[i].bound > pq[j].bound } func (pq PriorityQueue) Swap(i, j int ) { pq[i], pq[j] = pq[j], pq[i] } func (pq *PriorityQueue) Push(x interface {}) { item := x.(Node) *pq = append (*pq, item) } func (pq *PriorityQueue) Pop() interface {} { old := *pq n := len (old) item := old[n-1 ] *pq = old[0 : n-1 ] return item } func bound (node Node, n int , W float64 , weights []float64 , values []float64 ) float64 { if node.weight >= W { return 0 } bound := node.value j := node.level + 1 totWeight := node.weight for ; j < n; j++ { if totWeight+weights[j] <= W { bound += values[j] totWeight += weights[j] } else { break } } if j < n { bound += (W - totWeight) * (values[j] / weights[j]) } return bound } func knapsack01 (n int , W float64 , weights []float64 , values []float64 ) float64 { var maxValue float64 var queue = make (PriorityQueue, 0 ) heap.Init(&queue) var initNode Node initNode.level = -1 initNode.weight = 0 initNode.value = 0 queue.Push(initNode) for queue.Len() > 0 { node := heap.Pop(&queue).(Node) if node.level == -1 { node.bound = bound(node, n, W, weights, values) } if node.bound > maxValue { leftNode := Node{ level: node.level + 1 , weight: node.weight + weights[node.level+1 ], value: node.value + values[node.level+1 ]} leftNode.bound = bound(leftNode, n, W, weights, values) if leftNode.weight <= W && leftNode.value > maxValue { maxValue = leftNode.value } if leftNode.bound > maxValue { heap.Push(&queue, leftNode) } rightNode := Node{ level: node.level + 1 , weight: node.weight, value: node.value} rightNode.bound = bound(rightNode, n, W, weights, values) if rightNode.bound > maxValue { heap.Push(&queue, rightNode) } } } return maxValue } func main () { var n int fmt.Scan(&n) var W int fmt.Scan(&W) var weights = make ([]float64 , n) for i := 0 ; i < n; i++ { fmt.Scan(&weights[i]) } var values = make ([]float64 , n) for i := 0 ; i < n; i++ { fmt.Scan(&values[i]) } result := knapsack01(n, float64 (W), weights, values) fmt.Println("Maximum value that can be obtained is:" , result) }
不同于回溯法,我们在分支限界法一般都需要显示的指定树的数据结构,因为涉及到树的层次遍历(宽度优先搜索)。
这里的重点是限界函数的应用,值得仔细揣摩。
参考文章
回溯算法套路详解 - 知乎 (zhihu.com)