第5章〓回溯法 【案例引入】 本章学习目标: (1) 掌握解空间的概念、解空间的类型和回溯法的求解过程。 (2) 掌握子集树回溯算法框架以及构造表达式、图着色、子集和、0/1背包和完全背包问题等经典回溯算法设计方法。 (3) 掌握排列树回溯算法框架以及n皇后、任务分配和旅行商问题等经典回溯算法设计方法。 (4) 灵活运用回溯法算法策略解决复杂问题。 5.1回溯法概述 5.1.1问题的解空间 扫一扫 视频讲解 回溯法(backtracking)类似于穷举法,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时就“回溯”(即回退),尝试其他路径,所以回溯法有“通用的解题法”之称。一个复杂问题的解决方案是由若干个小的决策步骤组成的决策序列,其解可以表示成解向量x={x0,x1,…,xn-1},其中分量xi对应第i步的选择,通常可以有两个或者多个取值,表示为xi∈Si,Si为xi的取值候选集。x中各个分量xi的所有取值的组合构成问题的解向量空间,简称为解空间(solution space),由于解空间一般是一棵树结构,所以也称为解空间树。解空间树中的每个结点确定了所求问题的一个状态(state),因此解空间树也称为状态空间。求解问题的每一步决策对应于解空间树的一个分支结点,而一系列决策求解过程对应着解空间树的生长过程,在许多情况下问题的最终解都会呈现在这棵解空间树的叶子结点上,对应解的结点称为解结点,从根结点到解结点的路径称为解路径,解路径上所有xi分量的取值确定了问题的一个解。 例如,对于第2章中表2.1所示的0/1背包问题,状态为(cw,cv,i),表示考虑物品i时当前选择物品的总重量和总价值分别是cw和cv,对应的解空间树如图5.1所示,根结点是(0,0),i为结点的层次(这里层次从0开始),为了简便,结点中没有标注i值。解结点是叶子结点(6,8),表示最大价值是8,从根结点到解结点对应的解向量x={0,1,1,1},表示一个装入方案是选择物品1、2和3。 图5.10/1背包问题的解空间树 归纳起来,解空间树的一般结构如图5.2所示,根结点(为第0层)的每个分支对应分量x0的一个取值(或者说x0的一个决策),若x0的候选集为S0={v0,1,…,v0,a},即根结点的子树个数为|S0|,例如x0=v0,0时对应第1层的结点A0,x0=v0,1时对应第1层的结点A1,…。对于第1层的每个结点Ai,Ai的每个分支对应分量x1的一个取值,若x1的取值候选集为S1={v1,0,…,v1,b},Ai的分支数为|S1|,例如对于结点A0,当x1=v1,0时对应第2层的结点B0,…。以此类推,最底层是叶子结点层,叶子结点的层次为n,解空间树的高度为n+1。从中看出第i层的结点对应xi的各种选择,从根结点到每个叶子结点有一条路径,路径上的每个分支对应一个分量的取值,这是理解解空间树的关键。 图5.2解空间树的一般结构 从形式化角度看,解空间树是S0×S1×…×Sn-1的笛卡儿积,例如当|S0|=|S1|=…|Sn-1|=2时解空间是一棵高度为n+1的满二叉树。需要注意的是,问题的解空间是虚拟的,并不需要在算法运行中真正地构造出整棵树结构,然后在该解空间中搜索问题的解。实际上,有些问题的解空间因为过于复杂或结点过多难以画出来。 5.1.2什么是回溯法 用回溯法求解的问题通常分为两种类型,一种类型是给定一个约束函数,需要求所有满足约束条件的解,称为求所有解类型,例如求幂集问题中的每一个子集都是一个解; 另外一种类型是除了约束条件以外还包含目标函数,最后是求使目标函数值最大或最小的最优解,称为求最优解类型(或优化问题),例如0/1背包问题属于求最优解类型。这两类问题本质上是相同的,因为只有求出所有解再按目标函数进行比较才能求出最优解。 从前面的讨论看出问题的解包含在对应的解空间树中,剩下的事情就是在解空间树中搜索满足约束条件的解。所谓回溯法就是在解空间树中采用深度优先搜索方法从根结点出发搜索解,与树的先根遍历类似,当搜索到某个叶子结点时对应一个可能解,如果同时又满足约束条件,则该可能解是一个可行解。所以一个可行解就是从根结点到对应叶子结点的路径上所有分支的取值,例如一个可行解为(a0,a1,…,an-1),其图示如图5.3所示,在解空间中搜索到可行解的部分称为搜索空间。简单地说,回溯法采用深度优先搜索方法寻找根结点到每个叶子结点的路径,判断对应的叶子结点是否满足约束条件,如果满足该路径就构成一个解(可行解)。 回溯法在搜索解时首先让根结点成为活结点,所谓活结点是指自身已生成但其孩子结点还没有全部生成的结点,同时也成为当前的扩展结点,所谓扩展结点(E结点)是指正在产生孩子结点的结点。在当前的扩展结点处沿着纵深方向移至一个新结点,这个新结点又成为新的活结点,并成为当前的扩展结点。如果在当前的扩展结点处不能再向纵深方向移动,则当前的扩展结点就成为死结点,所谓死结点是指其所有孩子结点均已产生的结点,此时应往回移动(回溯)至最近的一个活结点处,并使这个活结点成为当前的扩展结点。 如图5.4所示,从结点A扩展出子结点B(用实线箭头表示),从结点B继续扩展,当结点B的所有子结点扩展完毕,结点B变为死结点,从结点B回退到结点A(即回溯,用虚线箭头表示),通过回溯使结点A恢复为扩展结点B之前的状态,再扩展出子结点C,此时开始做结点C的扩展,结点C就是扩展结点,由于结点A可能还有尚未扩展的其他子结点,结点A仍是活结点。 图5.3求解的搜索空间 图5.4解空间树的搜索过程 简单地说,回溯法就是采用深度优先搜索方法搜索解空间树,并随时判定当前结点是否满足解题要求,满足要求时继续向下搜索,不满足要求时则回溯到树的上一层继续搜索另一棵子树,直到找到问题的解为止。采用回溯法的算法称为回溯算法。设计回溯算法的关键点有以下3个: (1) 根据问题的特性确定结点是如何扩展的,不同的问题扩展方式是不同的。 (2) 在解空间中按什么方式搜索解,实际上树的遍历主要有先根遍历和层次遍历,前者就是深度优先搜索(DFS),后者就是广度优先搜索(BFS)。回溯法就是采用深度优先搜索解,第6章介绍的分支限界法则是采用广度优先搜索解。 (3) 解空间通常十分庞大,如果要高效地找到问题的解,通常采用一些剪支的方法实现。 所谓剪支就是在解空间中搜索时提早终止某些分支的无效搜索,减少搜索的结点个数但不影响最终结果,从而提高了算法的时间性能。常用的剪支策略如下。 (1) 可行性剪支: 在扩展结点处剪去不满足约束条件的分支。例如,在0/1背包问题中,如果选择物品i会导致总重量超过背包容量,则终止选择物品i的分支的继续搜索。 (2) 最优性剪支: 用限界函数剪去得不到最优解的分支。例如,在0/1背包问题中,如果沿着某个分支走下去无论如何都不可能得到比当前解bestv更大的价值,则终止该分支的继续搜索。 (3) 改变搜索的顺序: 在搜索中改变搜索的顺序,比如原先是递减顺序,可以改为递增顺序,或者原先是无序,可以改为有序,这样可能会减少搜索的总结点。 严格来说改变搜索的顺序并不是一种剪支策略,而是一种对搜索方式的优化。前两种剪支策略采用的约束函数和限界函数统称为剪支函数。归纳起来,回溯法可以简单地理解为深度优先搜索加上剪支。因此用回溯法求解的一般步骤如下: (1) 针对给定的问题确定其解空间,其中一定包含所求问题的解。 (2) 确定结点的扩展规则。 (3) 采用深度优先搜索方法搜索解空间树,并在搜索过程中尽可能采用剪支函数避免无效搜索。 5.1.3回溯算法分析 通常以回溯法的解空间树中的结点个数作为算法的时间分析依据。假设解空间树共有n+1层(根结点为第0层,叶子结点为第n层),第1层有m0个结点,每个结点有m1个子结点,则第2层有m0m1个结点,同理,第3层有m0m1m2个结点,以此类推,第n层有m0m1…mn-1个结点,则采用回溯法求所有解的算法的执行时间为T(n)=m0+m0m1+m0m1m2+…+m0m1m2…mn-1。这是一种最坏情况下的时间分析方法,在实际中可以通过剪支提高性能。为了使估算更精确,可以选取若干条不同的随机路径,分别对各随机路径估算结点总数,然后取这些结点总数的平均值。在通常情况下回溯法的效率高于穷举法。 5.2基于子集树的回溯算法框架 5.2.1解空间树的类型 解空间树通常有两种类型。当所给的问题是从n个元素的集合S中找出满足某种条件的子集时,相应的解空间树称为子集树(subset tree),如图5.1所示的解空间树就是一棵子集树。当所给的问题是确定n个元素满足某种性质的排列时,相应的解空间树称为排列树(permutation tree),5.10节中介绍的求全排列的解空间树就是排列树。 5.2.2求幂集 求幂集问题的解空间树是最经典的子集树,由此导出子集树的回溯算法框架。为了通用,将求幂集问题改为求含不同整数的集合的所有子集。 问题描述: 有一个含n个不同整数的数组a,设计一个算法求其所有子集(幂集)。例如,a={1,2,3},所有子集是{{1,2,3},{1,2},{1,3},{1},{2,3},{2},{3},{}}(输出顺序任意)。 扫一扫 视频讲解 解法1: 设a={a0,…,ai,…,an-1},解向量为x={x0,…,xi,…,xn-1},其中xi=0表示不选择a[i],xi=1表示选择a[i],这里x的固定长度为n。当已经生成解向量的{x0,…,xi-1}部分后,再由{ai,…,an-1}产生解向量的{xi,…,xn-1}部分。这样用(i,x)表示状态,解空间树的根结点对应状态(i=0,x的元素均为0),目标状态是(i=n,x为一个解)。从状态(i,x)可以扩展出两个状态(即二选一): (1) 选择a[i]元素下一个状态为(i+1,x[i]=1)。 (2) 不选择a[i]元素下一个状态为(i+1,x[i]=0)。 在一条搜索路径上i总是递增的,所以不会出现状态重复的情况。对应的回溯算法如下: vector x;//解向量 void disp(vector &a) {   //输出一个解 printf(" {"); for (int i=0;i &a,int i) {   //回溯算法 if (i>=a.size())   //到达一个叶子结点 disp(a);   //输出对应的解 else { x[i]=1; dfs(a,i+1);   //选择a[i] x[i]=0; dfs(a,i+1);   //不选择a[i] } } void pset1(vector &a) {   //求幂集算法1 int n=a.size(); x=vector(n); dfs(a,0); } 求解a={1,2,3}的解空间树如图5.5所示,图中方框旁边的“(数字)”表示递归调用次序,从中看出结点层次i(从0开始)与当前考虑的元素ai(选择或者不选择ai)的下标是一致的,同时每个叶子结点对应一个解,而且每个解对应的解向量的长度相同。 图5.5求解a={1,2,3}的解空间树 pset1算法分析: 在对应的解空间树中,每个层次为i(i从0开始)的分支结点对应元素a[i]的选择和不选择两种情况,所以解空间树是一棵高度为n+1的满二叉树,叶子结点共有2n个,每个叶子结点对应一个解,输出一个解的时间为O(n),所以算法的最坏时间复杂度为O(n×2n)。 扫一扫 视频讲解 解法2: 设a={a0,…,ai,…,an-1},解向量x={x0,…,xi,…,xm-1}改为直接存放a的一个子集(m为x的长度,0≤m≤n),例如,n=3,a={1,2,3},x={1}或者x={1,3}等都是该问题的解。从递归角度出发,设f(i)表示求以ai开头的子集,首先空集{}肯定是一个子集。 (1) 求以a0开头的子集,f(0)={a0}∪{a0合并f(1)的每个元素}∪{a0合并f(2)的每个元素}={{1},{1,2},{1,2,3},{1,3}}。 (2) 求以a1开头的子集,f(1)={a1}∪{a1合并f(2)的每个元素}={{2},{2,3}}。 (3) 求以a2开头的子集,f(2)={a2}={{3}}。 也就是说求f(i)的递推公式如下: f(i)={ai}∪i &a,int i,int j) {   //回溯算法 disp(i);   //输出一个解x[0..i-1] for(int j1=j;j1 &a) {   //求幂集算法2 dfs(a,0,0); } 解空间中第i层的结点(i,j,x)就是为xi(i≤j)选择{aj,…,an-1}中的一个元素,即n-j选一,如图5.8所示。 图5.8xi可以选择a[j..n-1]中的任意元素 解向量x用vector向量表示,由于参数i与解空间中对应结点的层次相同,所以可以省略参数i,对应的回溯算法如下: vector x;//解向量 void disp(vector &x) {   //输出一个解(子集) printf(" {"); for (int k=0;k &a,int j) {   //回溯算法 disp(x);   //输出对应的解 for(int j1=j;j1 &a) {   //求幂集算法2 dfs(a,0); } pset2算法分析: 在对应的解空间树中恰好有2n个结点,每个结点都是解结点,输出一个解的时间为O(n),所以算法的最坏时间复杂度为O(n×2n)。 说明: pset1和pset2两个算法的差异如下。 (1) pset1采用固定的二选一,结点层次i与当前考虑元素ai的下标一致,算法简单,方便理解,而pset2采用多选一,算法设计相对复杂。 (2) pset1解空间树中的结点个数几乎是pset2解空间树中结点个数的两倍,一般来说解空间树中的结点个数越少则算法的时空性能越好,所以pset2的性能好于pset1。 (3) pset1求出的全部子集是无序的,而pset2求出的全部子集是有序的,即除了第一个空集以外,首先输出以a[0]为首元素的所有子集,接着是以a[1]为首元素的所有子集,以此类推,如果求解问题要求有序性,应该采用以pset2为基础的回溯算法。 扫一扫 视频讲解 【例5.1】子集Ⅱ(LintCode18★★)。给定一个可能具有重复数字的数组nums,设计一个算法返回其所有可能的子集,子集中的每个元素都是非降序的,两个子集间的顺序是无关紧要的,解集中不能包含重复子集。例如,nums={1,2,2},答案是{{2},{1},{1,2,2},{2,2},{1,2},{}}。要求设计如下成员函数: 扫一扫 源程序 vector > subsetsWithDup(vector &nums) { } 解法1: 利用5.2.2节中pset1算法的思路求解,先对nums递增排序,由于nums中存在重复元素,结果子集也一定重复,这里采用set容器实现去重,最后将set中的子集复制到vector>容器ans中,返回ans即可。 扫一扫 源程序 解法2: 利用5.2.2节中pset2算法的思路求解,先对nums递增排序,在考虑求以nums[i]开头的子集时,用j遍历nums[i..n-1],xi取a[j]值,如果跳过nums[j]=nums[j-1](j>i)的nums[j]便可以实现去重。 5.2.3子集树回溯算法框架 设问题的解是一个n维向量{x0,x1,…,xn-1},约束函数表示每个分量xi应满足的约束条件,记为constraint(xi); 限界函数记为bound(xi),一般地,解空间为子集树的递归回溯框架如下: int x[n]; //解向量,全局变量 void dfs(int i) {   //子集树的递归框架 if(i>=n)   //搜索到叶子结点 产生一个可能解; else { for (j=下界;j<=上界;j++) {   //用j枚举x[i]所有可能的选择 x[i]=j;   //产生一个可能的解分量 …   //其他操作 if (constraint(i) && bound(i)) dfs(i+1);   //满足约束条件和限界函数,继续下一层 } } } 采用上述算法框架需要注意以下几点: (1) i从0开始调用上述回溯算法框架,此时根结点为第0层,叶子结点为第n层。当然i也可以从1开始,这样根结点为第1层,叶子结点为第n+1层,需要将上述代码中的“if(i>=n)”改为“if (i>n)”。 (2) 在上述框架中通过for循环用j枚举x[i]所有可能的路径,如果扩展路径只有两条,可以改为两次递归调用(例如求解0/1背包问题、子集和问题等都是如此)。 (3) 这里回溯框架只有i一个参数,在实际应用中可以根据具体情况设置多个参数。 5.3图的路径搜索 问题描述: 给定一个含n个顶点的带权无向图以及图中两个顶点s和t,设计一个算法求s到t的所有路径及其路径长度。 解假设带权无向图采用邻接矩阵存放。例如如图5.9(a)所示的带权无向图的邻接矩阵如下: A=05∞1∞ 50∞∞∞ ∞∞023 1∞208 ∞∞380 图5.9一个带权无向图及其解空间树 现在求从顶点s到顶点t的所有路径(默认为简单路径),这是一个求所有解的问题,约束条件就是s→t的路径,由于路径是一个顶点序列,所以对应的解向量x={x0,x1,…,xk-1}表示一条路径。下面以s=0,t=4为例进行讨论,x0=0(没有其他选择),需要求出满足约束条件的其他xi(i≥1),这里的路径有多条,每条路径对应一个解向量,对应的解空间树如图5.9(b)所示,图中的i表示结点的层次,由于x0是固定的,只需要从x1开始求,根结点的层次i=1,从中看出S1={1,3},S2={2,4},S3={4},其中包含该问题的两个解x={0,3,2,4}和x={0,3,4}。 说明: 在本问题中结点的扩展就是从对应的顶点找所有相邻顶点,即多选一,对应的解空间树属于子集树,由于找到的路径长度不一定相同,所以解向量并不是固定长度的。 为了避免路径中出现重复的顶点,采用visited数组判重,在邻接搜索中跳过当前路径中已经出现的顶点。这里还需要求路径长度,为了简单,将路径长度存放在x的末尾。对应的回溯算法如下: const int INF=0x3f3f3f3f; int n=5; vector> A={{0,5,INF,1,INF},{5,0,INF,INF,INF},  //邻接矩阵 {INF,INF,0,2,3},{1,INF,2,0,8},{INF,INF,3,8,0}}; vector> ans;   //存放答案 vector x;   //解向量 vector visited; void dfs(int i,int curlen,int t) {   //回溯算法 if (i==t) {   //找到终点 ans.push_back(x);   //将x添加到ans中 ans.back().push_back(curlen);   //在路径的末尾添加路径长度 } else { for (int j=0;j(n,0); visited[s]=1; dfs(s,0,t);   //i从0开始 printf("从%d到%d的所有路径:\n");   //输出结果 for(int i=0;i> getPath(int n,vector> &g,int s,int t) { } 扫一扫 源程序 解法1: 给定的无向图采用邻接矩阵A存放,采用本节前面讨论的路径搜索方法求解,在搜索顶点i的相邻点j时,j以从0到n-1的顺序试探,所以当s到t有多条路径时会按照字典序找到这些路径。 图5.11邻接表G 扫一扫 源程序 解法2: 给定的无向图采用邻接表G存放,G的类型为vector>,G[i]表示顶点i的所有出边邻接点,例如图5.10所示的无向图对应的邻接表G如图5.11所示。 由于需要按字典序输出s到t的所有路径,所以要保证G[i]中的元素按顶点编号递增排列,采用的方法是在创建G[i]后调用sort()实现排序,其他与解法1相同。 5.4构造表达式 扫一扫 视频讲解 问题描述: 设计一个算法在1、2、……、9(顺序不能变)数字之间插入+或-或什么都不插入,使得计算结果总是100,并求所有的可能性。例如1+2+34-5+67-8+9=100。 图5.12表达式示意图 解用数组a存放1~9的整数,设计解向量x,x[i]表示在a[i]前面插入的运算符,其示意图如图5.12所示。x[i]只能取值'+'、'-'或者空格(三选一)。设计回溯算法dfs(sum,prev,i),其中sum表示考虑x[i]取值时前面表达式的计算结果(初始值为a[0]),prev表示考虑x[i]取值时前面的运算数(初始值为a[0]),i从1开始(a[0]前面没有运算符): (1) 若x[i]取值'+',sum+=a[i],prev=a[i],继续搜索下一层,返回时恢复sum和prev。 (2) 若x[i]取值'-',sum-=a[i],prev=-a[i],继续搜索下一层,返回时恢复sum和prev。 (3) 若x[i]取值' ',则需要合并整数,例如prev=2,a[2]=3,若x[2]取值' ',合并的整数是23。如prev=-2,a[2]=3,若x[2]取值' ',合并的整数是-23,所以需要先执行sum-=prev减去前面的元素值,再执行tmp=prev*10±a[i](±为原prev的符号)得到合并的整数,最后执行sum+=tmp得到合并结果(tmp为新的前面的运算数),继续搜索下一层,返回时恢复sum和prev。 当i=9时到达一个叶子结点,若sum=100对应一个解,构造对应的表达式并添加到ans中,最后输出ans。对应的算法如下: #define N 9 int a[N]; vector ans;//存放答案 char x[N];   //解向量 void dfs(int sum,int prev,int i) {   //回溯算法 if (i==N) {   //到达一个叶子结点 if (sum==100) {   //找到一个解 string s=to_string(a[0]); for (int j=1;j0) tmp=prev*10+a[i];   //如prev=2,a[i]=3,结果为23 else tmp=prev*10-a[i];   //如prev=-2,a[i]=3,结果为-23 sum+=tmp;   //计算合并结果 dfs(sum,tmp,i+1); sum-=tmp;   //回溯sum sum+=prev; } } void express() { for (int i=0;i& nums, int s) { } 解用ans表示满足要求的解个数(初始为0),设置解向量x={x0,x1,…,xn-1},xi表示nums[i](0≤i≤n-1)前面添加的符号,xi只能在'+'和'-'符号中二选一,所以该问题的解空间为子集树。用expv表示当前运算结果(初始为0)。对于解空间中第i层的结点A,若xi选择'+',则expv+=nums[i],若xi选择'-',则expv-=nums[i],在回退到A时要恢复expv。当到达一个叶子结点时,如果expv=s,说明找到一个解,置ans++。 由于该问题只需要求最后的解个数,所以不必真正设计解向量x,仅设计expv即可。对应的程序如下: class Solution { int ans;//存放解个数 public: int findTargetSumWays(vector& nums,int s) { ans=0; dfs(nums,s,0,0); return ans; } void dfs(vector& nums,int s,int i,int expv) {//回溯算法 if (i==nums.size()) {   //到达一个叶子结点 if(expv==s) ans++;  //找到一个解 } else { expv+=nums[i];  //nums[i]前选择'+' dfs(nums,s,i+1,expv); expv-=nums[i];   //回溯:恢复expv expv-=nums[i];   //nums[i]前选择'-' dfs(nums,s,i+1,expv); expv+=nums[i];   //回溯:恢复expv } } }; 上述程序提交时通过,执行用时为163ms,内存消耗为5.59MB。 5.5图的m着色问题 扫一扫 视频讲解 问题描述: 给定一个连通图G和m种不同的颜色,用这些颜色为图G的各顶点着色,每个顶点着一种颜色。如果有一种着色法可以使G中每条边的两个顶点着不同颜色,则称这个图是m可着色的。图的m着色问题是给定图G和m种颜色,找出所有不同的着色方案数。例如,如图5.13所示的无向连通图,m=3时不同的着色方案数为12。 图5.13一个无向连通图 解对于连通图G,采用邻接矩阵A存放,这里的A是一个0/1矩阵。图中的顶点编号为0~n-1,共m种颜色,颜色编号为0~m-1。设计解向量x={x0,x1,…,xn-1},其中x[i]表示顶点i的着色,图中每个顶点可能的着色为0~m-1(初始时x[i]置为-1表示未着色),所以有0≤x[i]≤m-1,相当于m选一,对应的解空间是一棵m叉树,高度为n+1。i从0开始,当i=n时对应一个叶子结点,表示找到一种着色方案,将着色方案数ans增1,最后输出ans即可。对应的回溯算法如下: int n=4; int A[MAXN][MAXN]={{0,1,1,1},{1,0,0,0},{1,0,0,1},{1,0,1,0}}; int ans=0;//全局变量,累计解个数 int x[MAXN];   //全局变量,x[i]表示顶点i的着色 bool judge(int i,int j) {   //判断顶点i是否可以着色j for(int k=0;k=n)   //到达一个叶子结点 ans++;   //着色方案数增1 else { for (int j=0;j a;   //存放所有整数 int cnt=0;   //累计解个数 int tot=0;   //累计搜索的结点个数 vector x;   //解向量 void disp() {   //输出一个解 printf(" 第%d个解,",++cnt); printf("选取的数为: "); for (int i=0;i=n) {   //到达一个叶子结点 if (cs==t) disp();   //找到一个满足条件的解,输出 } else {   //没有到达叶子结点 x[i]=1;   //选取整数a[i] dfs(cs+a[i],i+1); x[i]=0;   //不选取整数a[i] dfs(cs,i+1); } } void subs1(vector&A,int T) {   //求解子集和问题 n=A.size(); a=A; t=T; x=vector(n); printf("求解结果\n"); dfs(0,0);   //i从0开始 printf("tot=%d\n",tot); } 对于子集和问题A={3,1,5,2},T=8,调用subs1(A,T)时的输出结果如下: 求解结果 第1个解,选取的数为: 3 5 第2个解,选取的数为: 1 5 2 tot=31 subs1算法分析: 对于含n个元素的数组a和正整数t,上述算法的解空间是一棵高度为n+1的满二叉树,共有2n+1-1个结点,递归调用2n+1-1次,每找到一个满足条件的解就调用disp()输出,而执行disp()的时间为O(n),所以subs()算法的最坏时间复杂度为O(n×2n)。 2) 左剪支 由于a中的所有元素都是正整数,每次选择一个元素时cs都会变大,当cs>t时沿着该路径继续找下去一定不可能得到解。利用这个特点减少搜索的结点个数。当搜索到第i(0≤it,表示选择a[i]后子集和超过t,不必继续沿着该路径求解,终止该路径的搜索,也就是左剪支。 ② 若cs+a[i]≤t,沿着该路径继续下去可能会找到解,不能终止。 简单地说,仅扩展满足cs+a[i]≤t的左孩子结点。 例如a={3,1,5,2},t=8,其搜索空间如图5.16所示,图中共29个结点,除去两个被剪支的结点(用虚框结点表示),剩下27个结点,也就是说递归调用27次,性能得到了提高。对应的回溯算法如下: void dfs(int cs,int i) { //回溯算法 tot++;   //累计调用次数 if (i>=n) {   //到达一个叶子结点 if (cs==t) disp();   //找到一个满足条件的解,输出 } else {   //没有到达叶子结点 if (cs+a[i]<=t) {   //左孩子结点剪支 x[i]=1;   //选取整数a[i] dfs(cs+a[i],i+1); } x[i]=0;   //不选取整数a[i] dfs(cs,i+1); } } void subs2(vector&A,int T) {   //求解子集和问题 n=A.size(); a=A; t=T; x=vector(n); printf("求解结果\n"); dfs(0,0);   //i从0开始 printf("tot=%d\n",tot); } 图5.16求a={3,1,5,2},t=8时子集和的搜索空间 当A={3,1,5,2},T=8时调用subs2(A,T)算法的求解结果如下: 求解结果 第1个解,选取的数为: 3 5 第2个解,选取的数为: 1 5 2 tot=27 3) 右剪支 左剪支仅考虑是否扩展左孩子结点,可以进一步考虑是否扩展右孩子结点。当搜索到第i(0≤i=n) {   //到达一个叶子结点 if (cs==t) disp();   //找到一个满足条件的解,输出 } else {   //没有到达叶子结点 rs-=a[i];   //求剩余整数的和 if (cs+a[i]<=t) {   //左孩子结点剪支 x[i]=1;   //选取整数a[i] dfs(cs+a[i],rs,i+1); } if (cs+rs>=t) {   //右孩子结点剪支 x[i]=0;   //不选取整数a[i] dfs(cs,rs,i+1); } rs+=a[i];   //恢复剩余整数的和(回溯) } } void subs3(vector&A,int T) {   //求解子集和问题 n=A.size(); a=A; t=T; x=vector(n); int rs=0;   //表示所有整数的和 for (int j=0;jbestw的右孩子结点。 说明: 由于深度优先搜索是纵向搜索的,可以较快地找到一个解,以此作为bestw,再对某个搜索结点(cw,rw)做cw+rw>bestw的右剪支,通常比广度优先搜索的性能更好。 当第i层的这个结点扩展完成后需要恢复rs,即置rs+=a[i](回溯)。如果搜索到某个叶子结点(即i≥n),得到一个可行解,其选择的集装箱重量和为cw(由于左剪支的原因,cw一定小于或等于t),若cw>bestw,说明找到一个满足条件的更优解,置bestw=cw,bestx=x。全部搜索完毕,bestx就是最优解向量。对应的递归算法如下: vector w; int t; int n; vector x; //解向量 vector bestx;   //存放最优解向量 int bestw=0;   //存放最优解的总重量,初始化为0 int tot=0;   //累计搜索的结点个数 void dfs(int cw,int rw,int i) {   //回溯算法 tot++; if (i>=n) {   //到达一个叶子结点 if (cw>bestw) {   //找到一个满足条件的更优解 bestw=cw; //保存更优解 bestx=x; } } else {   //尚未找完所有集装箱 rw-=w[i];   //求剩余集装箱的重量和 if (cw+w[i]<=t) {   //左孩子结点剪支:选择满足条件的集装箱 x[i]=1;   //选取集装箱i cw+=w[i];   //累计当前所选集装箱的重量和 dfs(cw,rw,i+1); cw-=w[i];   //恢复当前所选集装箱的重量和(回溯) } if (cw+rw>bestw) {   //右孩子结点剪支 x[i]=0;   //不选择集装箱i dfs(cw,rw,i+1); } rw+=w[i];   //恢复剩余集装箱的重量和(回溯) } } void loading(vector&W,int T) {//求解简单装载问题 w=W; t=T; n=w.size(); int rw=0; for (int i=0;i(n); dfs(0,rw,0);   //i从0开始 printf("求解结果\n"); for (int i=0;ino=no; this->w=w; this->v=v; } bool operator<(const Goods&s) const {   //用于按v/w递减排序 return (double)v/w>(double)s.v/s.w; } }; 例如,表2.1中的4个物品采用g容器存放: vector g={Goods(0,5,4),Goods(1,3,4),Goods(2,2,3),Goods(3,1,1)}; 设当前解向量x={x0,x1,…,xn-1},xi=1表示选择物品i,xi=1表示不选择物品i,最优解向量用bestx表示,最大价值用bestv表示(初始为0),为了简洁,将n、W、bestx和bestv均设计为全局变量。 1) 左剪支 由于所有物品的重量为正数,采用左剪支与子集和问题类似。当搜索到第i(0≤ibestv的右孩子结点。 说明: 上述bound(cw,cv,i+1)算法中包含物品k的一部分价值,这是不是与0/1背包问题矛盾呢?答案是不矛盾,这里的上界值表示沿着该路径走下去可能装入背包的最大价值,是一种启发搜索信息,并不是真的取物品k的一部分。 例如,对于根结点,cw=0,cv=0,若不选择物品0(对应根结点的右孩子结点),剩余背包容量rw=W=6,b=cv=0,考虑物品1,g[1].wrw,只能部分装入,b=b+rw×(g[3].v/g[3].w)=6.6。 右剪支是求出第i层的结点的b,b=bound(cw,cv,i),若b≤bestv则停止右分支的搜索,也就是仅扩展满足b>bestv的右孩子结点。 对于表2.1所示的实例,n=4,按v/w递减排序后如表5.1所示,初始时bestv=0,求解过程如图5.20所示,图中两个数字的结点为(cw,cv),只有右结点标记为(cw,v,ub),虚框结点表示被剪支的结点,带阴影的结点是最优解结点,其求解结果与递归法和穷举法完全相同,图中结点的数字为(cw,cv),求解步骤如下: ① i=0,根结点为(0,0),cw=0,cv=0,cw+w[0]≤W成立,扩展左孩子结点,cw=cw+w[0]=2,cv=cv+v[0]=3,对应结点(2,3)。 ② i=1,当前结点为(2,3),cw+w[1](5)≤W成立,扩展左孩子结点,cw=cw+w[1]=5,cv=cv+v[1]=7,对应结点(5,7)。 ③ i=2,当前结点为(5,7),cw+w[2](6)≤W成立,扩展左孩子结点,cw=cw+w[2]=6,cv=cv+v[1]=7,对应结点(6,8)。 图5.200/1背包问题实例的搜索空间 ④ i=3,当前结点为(6,8),cw+w[2](6)≤W不成立,不扩展左孩子结点。 ⑤ i=3,当前结点为(6,8),不选择物品3时计算出b=cv+0=8,而b>bestv(0)成立,扩展右孩子结点。 ⑥ i=4,当前结点为(6,8),由于i≥n成立,它是一个叶子结点,对应一个解bestv=8。 ⑦ 回溯到i=2层次,当前结点为(5,7),不选择物品2时计算出b=7.8,b>bestv不成立,不扩展右孩子结点。 ⑧ 回溯到i=1层次,当前结点为(2,3),不选择物品1时计算出b=6.4,b>bestv不成立,不扩展右孩子结点。 ⑨ 回溯到i=0层次,当前结点为(0,0),不选择物品0时计算出b=6.6,b>bestv不成立,不扩展右孩子结点。 解空间搜索完,最优解为bestv=8,装入方案是选择编号为2、1、3的3个物品。从中看出如果不剪支搜索的结点个数为31,剪支后搜索的结点个数为5。 对应的递归算法如下: vector g; //存放全部物品 int W;   //背包的容量 int n;   //物品的个数 vector x;   //解向量 vector bestx;   //存放最优解向量 int bestv=0;   //存放最大价值,初始为0 int tot=0;   //累计搜索的结点个数 double bound(int cw,int cv,int i) {…}   //同前 void dfs(int cw,int cv,int i) {   //回溯算法 tot++;   //累计调用次数 if (i>=n) {   //到达一个叶子结点 if (cw<=W && cv>bestv) {   //找到一个满足条件的更优解,保存它 bestv=cv; bestx=x; } } else {   //没有到达叶子结点 if(cw+g[i].w<=W) {   //左剪支 x[i]=1;   //选取物品i dfs(cw+g[i].w,cv+g[i].v,i+1); } double b=bound(cw,cv,i+1);   //计算限界函数值 if(b>bestv) {   //右剪支 x[i]=0;   //不选取物品i dfs(cw,cv,i+1); } } } void knap(vector g1,int W1) {   //求0/1背包问题 g=g1; W=W1; n=g.size(); sort(g.begin(),g.end());   //按v/w递减排序 x=vector(n,0); dfs(0,0,0);   //i从0开始 printf("最佳装填方案\n"); for (int i=0;i=n) { if(cw<=W && cv>bestv)   //找到一个更优解 bestv=cv; } else { dfs(cw,cv,i+1);   //不选择物品i if(cw+w[i]<=W)   //剪支 dfs(cw+w[i],cv+v[i],i);   //选择物品i,然后继续选择物品i if(cw+w[i]<=W)   //剪支 dfs(cw+w[i],cv+v[i],i+1);   //选择物品i,然后选下一件 } } void completeknap1() {   //求完全背包问题 dfs(0,0,0); printf("最大价值=%d\n",bestv); } 解法2: 利用5.2.2节中求幂集的标准子集树算法2的思路,设解向量x={x0,x1,…,xm-1},解空间树中的第i层结点是为xi在物品j~n-1中选择适合的物品j1(即置xi=j1),由于后面仍然可以选择物品j1,所以置下一步(求xi+1)选择的物品仍然是j1~n-1。这里只需要求最大价值,不必实际设计解向量x。对应的回溯算法如下: int bestv=0; //存放最大价值,初始为0 void dfs(int cw,int cv,int j) {   //回溯算法 if(cw<=W && cv>bestv) {   //找到一个更优解 bestv=cv; } for (int j1=j;j1 x;//解向量 vector used;   //used[i]表示a[i]是否使用过 int cnt=0;   //累计排列个数 void disp() {   //输出一个解 printf(" %2d {",++cnt); for (int i=0;i &a,int i) {   //回溯算法 int n=a.size(); if (i>=n) { disp(); } else { for(int j=0;j &a) {   //求全排列算法1 int n=a.size(); x=vector(n); used=vector(n,0); dfs(a,0); } perm1算法分析: 从表面上看调用的dfs算法中每个结点是n选一,实际上通过剪支操作使得解空间树中的根结点层(i=0)有一个结点,i=1层有n个结点,i=2层有n(n-1)个结点,i=3层有n(n-1)(n-2)个结点,以此类推,最后叶子结点层有n!个结点,对应n!个排列。设总结点个数为C(n),则: C(n)=1+n+n(n-1)+n(n-1)(n-2)+…+n! ≈n+n(n-1)+n(n-1)(n-2)+…+n! =n!1+11!+12!+…+1(n-1)!=n!e-1n!-1(n+1)!-… =n!e-1=O(n!) 叶子结点个数为n!,也就是C(n)和叶子结点个数同级,而每个叶子结点对应一个解,输出一个解的时间是O(n),所以算法的时间复杂度为O(n×n!)。 解法2: 改进解法1,首先将a存放到x中,通过交换方式避免使用used数组,简单地说,设解向量为x={x0,…,xi,…,xn-1},当已经生成解向量的{x0,…,xi-1}部分后,现在产生解向量的{xi,…,xn-1}部分,将xi与其中的每一个元素交换,目的是让xi取所有可能的元素值(由于x0,…,xi-1元素已经使用过,所以xi不能取这些元素值),采用交换的方式也保证了xi的所有元素不重复。对应的回溯算法如下: vector x;//解向量 int cnt=0;   //累计排列个数 void disp() {   //输出一个解 printf(" %2d {",++cnt); for (int i=0;i=n) { disp(); } else { for(int j=i;j &a) {   //求全排列算法2 int n=a.size(); x=vector(n); for(int i=0;i=n)   //搜索到叶子结点 产生一个可能的解; else { for (j=i;j<=n;j++) {   //用j枚举i所有可能的路径 …   //x[i]选择x[j]的操作 swap(x[i],x[j]); if (constraint(i) && bound(i)) dfs(i+1);   //满足约束条件和限界函数,进入下一层 swap(x[i],x[j]);   //回溯: 恢复状态 …   //回退到第i层结点的操作 } } } 同样的几点注意见解空间为子集树的回溯算法框架的说明。 5.11n皇后问题 扫一扫 视频讲解 问题描述: n皇后问题的描述参见第3章中的3.8节。这里采用回溯法求解。 解同样用整数数组q存放n皇后问题的求解结果,即q[i](0≤i≤n-1)的值表示第i个皇后所在的列号,即该皇后放在(i,q[i])的位置上。 这里q相当于解向量,显然q[0..n-1]的取值恰好是0~n-1的某个排列,所以该问题转换为求全排列问题,对于每个排列判断是否为一个解,在采用回溯法时对应的解空间树是一棵排列树,根结点的层次为0,用于确定q[0](即皇后0的列号),层次为i的结点用于确定q[i](即皇后i的列号),叶子结点的层次为n,每个叶子结点对应一个解。 对应的基于排列树的回溯算法如下: int q[MAXN];//存放n皇后问题的解(解向量) int cnt=0;   //累计解个数 void disp(int n) {   //输出一个解 printf(" 第%d个解:",++cnt); for (int i=0;i=n) disp(n);   //所有皇后放置结束,输出一个解 else { for (int j=i;j> c; vector x;//解向量 vector bestx;   //最优解向量 int bestc;   //最小成本 vector used; int bound(int cost,int i) {   //求下界算法 int minsum=0; for (int i1=i;i1=n) {   //到达一个叶子结点 if (cost> &C) {   //求解任务分配问题 c=C; n=c.size(); x=vector(n); for(int i=0;i(n,false); bestc=INF; dfs(0,0);   //从人员0开始 printf("最优分配方案\n"); for (int k=0;k x;//解向量(路径) vector bestx;  //保存最短路径 int d;  //x路径的长度 int bestd=INF;  //保存最短路径长度 void dfs(vector> &A,int s,int i) {  //回溯算法 int n=A.size(); if(i>=n) {  //到达一个叶子结点 if(d+A[x[n-1]][s]> &A,int s) {  //求解TSP(起始点为s) int n=A.size(); x.push_back(s);//添加起始点s for(int i=0;i%d",bestx[j]); } printf("\n 路径长度: %d\n",bestd); } 针对第3章中的图3.14,设置起始点s=0,调用上述算法的求解结果如下: 最短路径: 0->2->3->1->0 路径长度: 23 扫一扫 视频讲解 思考题: 为了提高回溯算法的时间性能,还可以采用哪些剪支方法? TSP算法分析: 在算法中求x1~xn-1共n-1个元素的全排列,解空间中的结点个数为O((n-1)!),叶子结点的个数同级,到达叶子结点时进行路径长度的比较并执行bestx=x实现路径复制的时间为O(n),所以算法的时间复杂度为O(n×(n-1)!),即O(n!)。 扫一扫 源程序 【例5.8】旅行计划(LintCode1891★★)。问题描述参见第3章中的例3.9,这里采用回溯法求解。 解采用与前面用回溯法求解旅行商问题完全相同的思路,设置起始点s=0。 5.14练习题 扫一扫 自测题 1. 简述回溯算法中主要的剪支策略。 2. 简述回溯法中常见的两种类型的解空间树。 3. 鸡兔同笼问题是一个笼子里面有鸡和兔子若干只,数一数,共有a个头、b条腿,求鸡和兔子各有多少只?假设a=3,b=8,画出对应的解空间树。 4. 考虑子集和问题,n=3,a={1,3,2},t=3,回答以下问题: (1) 不考虑剪支,画出求解的搜索空间和解。 (2) 考虑左剪支(选择元素),画出求解的搜索空间和解。 (3) 考虑左剪支(选择元素)和右剪支(不选择元素),画出求解的搜索空间和解。 5. 考虑n皇后问题,其解空间树为由1、2、……、n构成的n!种排列组成,现用回溯法求解,要求: (1) 通过解搜索空间说明n=3时是无解的。 (2) 给出剪支操作。 (3) 最坏情况下在解空间树上会生成多少个结点?分析算法的时间复杂度。 6. 二叉树的所有路径(LintCode480★)。给定一棵二叉树,设计一个算法求出从根结点到叶子结点的所有路径。例如,对于如图5.27所示的二叉树,答案是{"1->2->5","1->3"}。要求设计如下成员函数: vector binaryTreePaths(TreeNode *root) { } 图5.27一棵二叉树 7. 二叉树的最大路径和Ⅱ(LintCode475★★)。给定一棵二叉树,设计一个算法找到二叉树的最大路径和,路径必须从根结点出发,路径可在任意结点结束,但至少包含一个结点(也就是根结点)。要求设计如下成员函数: int maxPathSum2(TreeNode *root) {} 8. 求组合(LintCode152★★)。给定两个整数 n 和 k,设计一个算法求从1~n中选出k个数的所有可能的组合,对返回组合的顺序没有要求,但一个组合内的所有数字需要是升序排列的。例如,n=4,k=2,答案是{{1,2},{1,3},{1,4},{2,3},{2,4},{3,4}}。要求设计如下成员函数: vector> combine(int n,int k) { } 9. 最小路径和Ⅱ(LintCode1582★★)。给出一个m×n的矩阵,每个点有一个正整数的权值,设计一个算法从(m-1,0)位置走到(0,n-1)位置(可以走上、下、左、右4个方向),找到一条路径使得该路径所经过的权值和最小,返回最小权值和。要求设计如下成员函数: int minPathSumII(vector> &matrix) {} 10. 递增子序列(LeetCode491★★)。给定一个含n(1≤n≤15)个整数的数组 nums(100≤nums[i]≤100),找出并返回所有该数组中不同的递增子序列,递增子序列中至少有两个元素,可以按任意顺序返回答案。数组中可能含有重复元素,如果出现两个整数相等,也可以视作递增序列的一种特殊情况。例如,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}}。要求设计如下成员函数: vector> findSubsequences(vector& nums) {} 11. 给表达式添加运算符(LeetCode282★★★)。给定一个长度为n(1≤n≤10)的仅包含数字0~9的字符串num和一个目标值整数target(-231≤target≤231-1),设计一个算法在num的数字之间添加二元运算符(不是一元)+、-或*,返回所有能够得到 target 的表达式。注意所返回表达式中的运算数不应该包含前导零。例如,num="105",target=5,答案是{"1*0+5","10-5"}。要求设计如下成员函数: vector addOperators(string num,int target) { } 12. 求解马走棋问题。在m行n列的棋盘上有一个中国象棋中的马,马走“日”字且只能向右走。设计一个算法,求马从棋盘的左上角(1,1)走到右下角(m,n)的可行路径的条数。例如,m=4,n=4的答案是2。 13. 设计一个回溯算法求迷宫中从入口s到出口t的最短路径及其长度。 14. 设计一个求解n皇后问题的所有解的迭代回溯算法。 15. 题目描述见第3章中3.11节的第12题,这里要求采用回溯法求解。 5.15在线编程实验题 1. LintCode1353——根结点到叶子结点求和★★ 2. LintCode802——数独★★★ 3. LintCode135——数字组合★★ 4. LintCode1915——举重★★★ 5. LintCode680——分割字符串★★ 6. LintCode136——分割回文串★★ 7. LintCode816——旅行商问题★★★ 8. LeetCode784——字母大小写全排列★★ 9. LeetCode1079——活字印刷★★ 10. LeetCode93——复原IP地址★★ 11. LeetCode22——括号的生成★★ 12. LeetCode89——格雷编码★★ 13. LeetCode301——删除无效的括号★★★ 14. POJ3050——跳房子 15. POJ1724——道路 16. POJ1699——最佳序列 17. POJ1564——求和 18. POJ2245——组合 19. POJ1321——棋盘问题 20. POJ2488——骑士之旅