回溯法
回溯法
回溯法也是搜索算法中的一种控制策略,但与枚举法不同的是,它是从初始状态出发,运用题目给出的条件、规则,按照深度优秀搜索的顺序扩展所有可能情况,从中找出满足题意要求的解答。回溯法是求解特殊型计数题或较复杂的枚举题中使用频率最高的一种算法。
一、回溯法的基本思路
何谓回溯法,我们不妨通过一个具体实例来引出回溯法的基本思想及其在计算机上实现的基本方法。【例题12.2.1】n皇后问题
一个n×n(1≤n≤100)的国际象棋棋盘上放置n个皇后,使其不能相互攻击,即任何两个皇后都不能处在棋盘的同一行、同一列、同一条斜线上,试问共有多少种摆法?
输入:
n
输出:
所有分案。每个分案为n+1行,格式:
方案序号
以下n行。其中第i行(1≤i≤n)行为棋盘i行中皇后的列位置。
在分析算法思路之前,先让我们介绍几个常用的概念:
1、状态(state)
状态是指问题求解过程中每一步的状况。在n皇后问题中,皇后所在的行位置i(1≤i≤n)即为其时皇后问题的状态。显然,对问题状态的描述,应与待解决问题的自然特性相似,而且应尽量做到占用空间少,又易于用算符对状态进行运算。
2、算符(operater)
算符是把问题从一种状态变换到另一种状态的方法代号。算符通常采用合适的数据来表示,设为局部变量。n皇后的一种摆法对应1..n排列方案(a1,…,a n)。排列中的每个元素a i对应i行上皇后的列位置(1≤i≤n)。由此想到,在n皇后问题中,采用当前行的列位置i(1≤i≤n)作为算符是再合适不过了。由于每行仅放一个皇后,因此行攻击的问题自然不存在了,但在试放当前行的一个皇后时,不是所有列位置都适用。例如(l,i)位置放一个皇后,若与前1..l-1行中的j行皇后产生对角线攻击(|j-l|=|a j -i|)或者列攻击(i≠a j),那么算符i显然是不适用的,应当舍去。因此,不产生对角线攻击和列攻击是n皇后问题的约束条件,即排列(排列a1,…,a i,…,a j,…,a n)必须满足条件(|j-i|≠|a j-a i|) and (a i≠a j) (1≤i,j≤n)。
3、解答树(analytic tree)
现在让我们先来观察一个简单的n皇后问题。设n=4,初始状态显然是一个空棋盘。
此时第一个皇后开始从第一行第一列位置试放,试放的顺序是从左至右、自上而下。每个棋盘由4个数据表征相应的状态信息(见下图):
(××××)
其中第i(1≤i≤4)个数据指明当前方案中第i个皇后置放在第i行的列位置。若该数据为0,表明所在行尚未放置皇后。棋盘状态的定义如下
var
stack:array[1‥4]of integer;{stack[i]为i行皇后的列位置}
从初始的空棋盘出发,第1个皇后可以分别试放第1行的4个列位置,扩展出4个子结点。
在上图中,结点右上方给出按回溯法扩展顺序定义的结点序号。现在我们也可以用相同方法找出这些结点的第二行的可能列位置,如此反复进行,一旦出现新结点的四个数据全非空,那就寻到了一种满足题意要求的摆法。当尝试了所有可能方案,即获得了问题的解答,于是得到了下列图形。
该图形象一棵倒悬的树。其初始结点v1叫根结点,而最下端的结点v3、v5、v9、v13、v16、v17称为叶结点,其中2个数据全非零的叶结点,亦即本题的目标结点。由根结点到每一个目标结点之间,揭示了一种成功摆法的形成过程。显然,4皇后问题存在由v9、v13表示的二种方案。上图被称作解答树。树中的每一结点都是当前方案中满足约束条件的元素状态。除了根结点、叶结点以外的结点都称作分枝结点。分枝结点愈接近根结点者,辈分愈高;反之,愈远离根结点者,辈分愈低。上图中结点v7是结点v8的父结点(又称前件),结点v13是结点v12的子结点(又称后件)。某结点所拥有的子结点的个数称作该结点的次数。显而易见,所有叶结点的次数为0。树中各结点次数最大值,被称作为该树的次数。算符的个数即为结解答树的次数。由上图可见,4皇后的解答树是4次树。
一棵树中的某个分枝结点也可视作为“子根”,以该结点为根的树则称作“子树”。由以上讨论可以看出解答树的结构:
1、初始状态构成(主)树的根结点。对应于n皇后来说,初始时的空棋盘即为根结点;
2、除根结点以外,每个结点都具有一个、且只有一个父结点。对应于n皇后问题来说,置放i行皇后的子结点,只有在置放了前i-1行皇后的一个父结点基础上产生;
3、每个非根结点都有一条路径通往根结点,其路径长度(代价)定义为这条路径的边数。对应于n皇后来说,当前行序号即为路径代价。当路径代价为n+1时,说明n个皇后已置放完毕,一种成功的摆法产生。
有了以上的基础知识和对n皇后问题的初步分析,我们已经清楚地看到,求解n皇后问题,无非就是做两件事:
1、从左至右逐条树枝地构造和检查解答树t;
2、检查t的结点是否对应问题的目标状态;
上述两件事同时进行。为了加快检查速度,一般规定:
1、再扩展一个分枝结点前进行检查,只要它不满足约束条件,则不再构造以它为根的子树;
2、已处理过的结点若以后不会再用,则不必保留。即回溯过程中经过的结点不再保留。例如在上图中,当我们求出第一种摆法v1-v2-v3后,由于皇后置放第三行任何列位置都会产生攻击,因此舍弃该摆
法,开始寻求第二种摆法。从上图可看出,第二条路径为v1-v2-v4-v5,v3在第二种摆法中不再用到,不必保留,应当退回到v2状态,在第二行选择尚未使用过的列位置4,扩展出v4。一般来说,当求出一条路径后,必须从叶结点开始,沿所在路径回溯,回溯至第一个还剩有适用算符的分枝点(亦称为尚未使用过的通向右边方向的结点),从那里换上一个新算符,继续求下一条路径。
按上述规定对照上图,我们来具体分析4皇后的置放过程。初始状态(0,0,0,0)作为根结点v1,由此出发,置第1个皇后于第1行第1列位置。从(1,0,0,0)开始,第2个皇后相继选择了第2行的1、2列位置,由于会产生攻击,因此选择该行的列位置3放入,产生状态(1,3,0,0)。但是第3个皇后无论放入第3行哪列位置都难逃攻击,因此只得沿第一条路径回溯至第一个尚未用过的通向右边方向的分枝点v2,以寻求第二种摆法。从(1,0,0,0)状态换上新的列位置4,产生(1,4,0,0)。从(1,4,0,0)选择列位置2(由于列位置1产生攻击),产生(1,4,2,0)。由于第4个皇后无论置放第4行哪列位置都会产生攻击,第二种摆法失败,同样再从v5开始,沿第二条路径回溯。由于v2,v4都没有未使用的满足约束条件的算符(列位置)了,因此第一个分枝点是v1,从v1的(0,0,0,0)换上位置2,产生v6的(2,0,0,0)。这样依次使用满足约束条件的算符扩展下去,又得出第三条路径v1-v6-v7-v8-v9。可见,v9的(2,4,1,3)是一种成功的摆法。按上述规律不断回溯检查,直至得出第六条路径v1-v14-v17。沿路径从v17回溯,由于v14选择尚未用过的列位置3、4都会产生攻击,因此不再剩有适用的列位置了,只得回溯至v1。又因为v1已经选择了列位置4而无法再扩展,至此,求出了4皇后的所有可能摆法。
由上述扩展过程引出回溯法的基本思想:从左至右逐条树枝地构造和检查查找解答树,已处理过的结点若以后不会再使用则不必保留(一般说来,检查长度为n的树枝,只要保留n个结点就够了)。若按这种方式得到一条到达树叶的树枝t,实际上就得到了一条路径。然后沿树枝t回溯到第一个尚未使用过通往右边路径方向上的分枝点,并由此分枝点向右走一步,然后再从左至右地逐个进行构造和检查,直至达到叶子为止,这时又得到一条路径。按这种方法搜索下去,直至求出所有路径。显然用这种方法检查,在树枝左边的一切结点都已检查过,树枝右边的一切结点尚未产生出来。我们把这种不断“回溯”查找解答树中目标结点的方法,称作“回溯法”。
由上述算法思想,我们很容易想到,应选择怎样一种数据结构来存放当前路径上各结点的状态和算符?它应具有“后进先出”的特征,就象食堂里的一叠盘子,每次只许一个一个地往顶上堆,一个一个地从顶上往下取。这就是我们通常所说的栈。栈是一种线性表,所有进栈或出栈的数据都只能在表的同一端进行,就象堆盘子和拿盘子一样,都只能在顶端“堆上”或“取下”。这顶端叫“栈顶”,另一端叫“栈底”。Pascal编译系统内部,保留一部分内存用作栈区,存放过程和函数的值参以及过程和函数内部所说明的局部变量。每当一个过程和函数被启用时,系统就在栈顶分配一组值参和局部变量(进栈)。而当该过程或函数退出时,这些局部变量或值参就被消除(退栈)。我们为回溯法设计的一个递归过程run 就是利用系统的这一特性:
procedure run(当前状态);
var
i:integer;
begin
if 当前状态为边界
then begin
if 当前状态为最佳目标状态then 记下最优结果;
exit;{回溯}
end;{then}
for i←算符最小值to 算符最大值do
begin
算符i作用于当前状态,扩展出一个子状态;
if (子状态满足约束条件) and (子状态满足最优性要求)then run(子状态);
end;{for}
end;{run}
我们在应用回溯法求所有路径的算法框架解题时,应考虑如下几个重要因素:
⑴定义状态:即如何描述问题求解过程中每一步的状况。在n皇后问题中,将行位置l作为状态。如果扩展结点时参与运算的变量有多个,为了精简程序,增加可读性,我们一般将参与子结点扩展运算的变量组合成当前状态列入值参,以便回溯时能恢复递归前的状态,重新计算下一条路径;
⑵边界条件:即在什么情况下程序不再递归下去。在n皇后问题中,将l=n+1(产生一种成功摆法)作为边界条件。如果是求满足某个特定条件的一条最佳路径,则当前状态到达边界时并非一定意味着此时就是最佳目标状态。因此还须增加判别最优目标状态的条件;
⑶搜索范围:在当前状态不满足边界条件的情况下,应如何设计算符值的范围。换句话说,如何设定for 语句中循环变量的初值和终值。在n皇后问题中,l行的列位置i作为搜索范围,即1≤i≤n;⑷约束条件和最优性要求:所谓约束条件是指,当前扩展出一个子结点后应满足什么条件方可继续递归下去;如果是求满足某个特定条件的一条最佳路径,那么在扩展出某个子状态后是否继续递归搜索下去,不仅取决于子状态是否满足约束条件,而且还取决于子状态是否满足最优性要求。在n皇后问题中,将(l,i)置放皇后不产生攻击(att=false)作为约束条件;
⑸参与递归运算的参数:将参与递归运算的参数设为递归子程序的值参或局部变量。若这些参数的存储量大(例如数组)且初始值需由主程序传入,为避免内存溢出,则必须将其设为全局变量,且回溯前需恢复其递归前的值。在n皇后问题中,将皇后的行位置l和列位置i作为参与递归运算的参数;
虽然上述程序流程仅是一种“粗线素描”,但其编排直接面对问题,囊括回溯搜索的所有本质特征,并有意识地在问题与算法之间显现一种“隐约可见”的思维自由度,有利于启迪创造性。按照上述要求,我们设计了n皇后问题的递归子程序:
procedure run(l:integer);{从第l行出发,递归搜索所有摆法}
var
i:integer;
function att:boolean;{检查前l-1行,若产生攻击,置att为true;否则返回flase } begin
att←false;{攻击标志初始化}
for j←1 to l-1 do {搜索前l-1行}
if (abs(l-j)=abs(stack[j]-i))or(i=stack[j])
then begin {若l行的皇后与j行的皇后产生攻击,则返回true} att←true;break;
end;{then}
end;{att}
begin
if l=n+1 then {产生一种成功摆法}
begin
inc(total);writeln('no ',total);{累计方案数并输出该方案}
for j←1 to n do 输出第j行皇后的列位置为stack[j];
writeln;
exit;{回溯,求下一方案} end;{then}
for i←1 to n do
begin
stack[l]←i;{ (l,i)试放一个皇后}
if not att then run(l+1); { 若(l ,i)置放皇后不产生攻击,则搜索l+1行 } end ;{for}
二、回溯法的应用实例
在组成状态的各个元素中,如果有元素需要采用存储量大(例如数组、字符串)的数据类型,则应该避免将这些元素列为参或局部变量。因为系统栈区的容量极其有限,每次递归都要将这些大存储量的压入栈区,很容易产生内存溢出。不妨将它们设为设为全局变量来参与递归运算,但回溯前需要恢复其递归前的值。
【例题12.2.2】构造字串
生成长度为n 的字串,其字符从26个英文字母的前p(p ≤26)个字母中选取,使得没有相邻的子序列相等。例如p=3,n=5时
‘a b C b a ’满足条件
‘a b C b C ’不满足条件
输入:n ,p
输出:所有满足条件的字串
题解
1、检查当前字串是否符合条件
设当前串s=s 1‥s m-1s m 且s 1‥s m-1合理。按照下述方法判断s 是否仍然保持其合理性质:
分别判断s 的后缀中长度为1的两个相邻子串s m-1与s m 是否相等;长度为2的两个相邻子串s m-3s m-2与s m-1s m 是否相等,……‥,长度为??????2m 的两个相邻子串s 1‥??
????2m s 与12+??????m s ‥s m 是否相等。即s m-2l+1‥s m-1与s m-l+1‥s m 是否相等(1≤l ≤??
????2m )。若其中一旦出现了两个相邻子串相等的情况,则说明s 1‥s m 不合理;若经过??
????2m 次判断后未出现不合理情况,则说明s 1‥s m 满足条件。 2、回溯搜素满足条件的所有字串
我们从空串出发,逐个字符地延长字串。若当前字符添入后使得字串保持合理的性质,则添入该字符;否则改变字串。
状态:待扩展的字母序号at 。实际上字串s 亦参与了递归运算,但是由于该变量的存储量太大,因此我们将s 设为全局变量;
边界条件和目标状态:产生了一个满足条件的字串,即at=n+1;
搜索范围:第at 位置可填的字母集{'a'.. chr(ord('a')+p-1)};
约束条件:当前字串没有相邻子串相等的情况
var
n ,p :integer ; {字串长度和可选字母的个数} tl :longint ; { 满足条件的字串数} ed :char ; { 可选字母集中的最大字母}
s :string ; {满足条件的字串} procedure solve(at :integer); {递归扩展第at 个字母} var
ch :char ; i :integer ;
begin
if at=n+1 {若产生了一个满足条件的字串,则输出,满足条件的字串数+1} then begin
writeln(f,s); inc(tl);
exit {回溯}
end;{then}
for ch←'a' to ed do {搜索每一个可填字母} begin
s←s+ch;
i←1; { 检查当前字串是否符合条件} while (i<=at div 2) and
(copy(s,length(s)-i+1,i)<>copy(s,length(s)-2*i+1,i)) do inc(i);
if i>at div 2 then solve(at+1); {若当前字串符合条件,则递归扩展下一个字母} delete(s,length(s),1) {恢复填前的字串} end{for}
end;{solve}
begin
readln(n,p); {输入字串长度和前缀长短}
ed←chr(ord('a')+p-1); {计算可选字母集中的最大字母}
s←''; tl←0; {满足条件的字串初始化为空,字串数为0}
solve(1); {从第1个字母开始递归计算所有满足条件的字串}
writeln('Total:',tl); {输出满足条件的字串数}
end.{main}
在搜索过程中,如果扩展子状态的过程是在不同情况(即每种情况下的约束条件、扩展规则或搜索范围不同)下进行,则对当前状态分情形递归搜索
【例题12.2.3】数字排列
在n*n的棋盘上(1≤n≤10)填入1,2,...n*n共n*n个数,使得任意两个相邻的数之和为素数。例如,当n=2
其相邻数的和为素数的有:1+2,1+4,4+3,2+3
当n=4
在这里我们约定:左上角的格子里必须放数字1
程序要求:
输入:n
输出:若有多种解,则需输出第一行,第一列之和均为最小的排列方案;若无解,则输出"nO!"
题解
1、计算3‥2*n2-1的素数表su
搜索过程中需要反复进行素数判断。为了提高运算效率,不妨将2‥n2中的素数置入一个常量表su。设
var
f:boolean; { 素数标志}
su :set of byte ; { 素数表} 计算过程如下:
su ←[];
for i ←3 to n*n*2-1 do
begin
f ←true ;
for j ←2 to ??i do
if i mod j=0 then begin f ←false ; break ;end ;{then}
if f then su ←su+[i];
end ;{for}
2、回溯搜索填数方案
设
var
max ,sum :integer ; { 第1行、第1列的最小数和、当前数和} p :array[1..maxn*maxn]of boolean ; {p[i]—数i 已填标志}
b ,a :array[1..maxn ,1..maxn]of byte ; {最佳棋盘和当前棋盘}
我们首先将1填入(1,1);然后按照先第1行后第1列的顺序填入2*n-2个数;最后逐行填其他位置。 状态:(i ,j ,sum )。即欲填(i ,j ),目前第1行、第1列的数和为sum 。P 序列也应该是状态,但不能列入值参。原因是P 序列的内存开销太大,列入值参极易导致内存溢出。无奈之下,只能作为全局变量。当数值k’填入棋盘,p[k ’]←true ;但回溯时,必须通过p[k ’]←false 恢复递归前k’的未填状态;
边界:i>n ,即完成数据的填写。若满足条件,则当前方案作为最佳方案记下(max ←sum ;b ←a ); 目标:sum=n*(2*n-1)。若1…2*n-1填入第1行、第1列,则输出最佳棋盘b 并退出;
搜索范围:1≤k ≤???
?????22n 。由于棋盘的任意两个相邻的数之和为素数,因此棋盘的结构为 奇 偶 奇………
偶 奇 偶………
奇 偶 奇………
………………
由此可以看出,行号i 和列号j 同为奇或者同为偶时(not (odd(i) xor odd(j))),(i ,j )填奇数k’=2*k -1;否则(i ,j )填偶数k’=2*k ;
约束条件:
p[k’]=flase ,即目前棋盘未填入数据k’;
若满足约束条件,则k’ 填入(i ,j ),置k’ 填入标志(p[k’]←true )。然后分情形递归;
分情形递归:
①若(i=1)∧(a[i ,j]+a[i ,j-1]∈su)∧(sum+a[i ,j] ②若(j=1)∧(a[i ,j]+a[i-1,j]∈su)∧(sum+a[i ,j] ③否则填其它位置。如果(a[i ,j]+a[i-1,j]∈su)∧(a[i ,j]+a[i ,j-1]∈su),即上下两数之和为素数且左右两数之和为素数,则分情形递归:若当前行填完(j=n ),则递归计算(i+1,2,sum)状态;否则递归计算 (i ,j+1,sum )状态; 我们通过递归程序solve(i ,j ,sum)描述搜索过程: procedure solve(i ,j :byte ;sum :integer); var k ,k ’:integer ; begin if i>n then {若完成数据的填写,则记下当前方案} begin max ←sum ; b ←a ; if max=n*(2*n-1) then {若1…2*n-1填入第1行、第1列,则输出最佳棋盘b 并退出} begin for r ←1 to n do begin for s ←1 to n do write(b[r ,s]:5); writeln ; end ;{for} halt ; end ;{then} exit ; {回溯} end ;{then} for k ←1 to ??? ?????22n do {枚举(i ,j )位置的所有可能填数} begin {否则计算欲填入(i ,j )的数据k ’} if (odd(i) xor odd(j)) then k ’←2*k else k ’←2*k-1; if not p[k ’] then {若k ’未填过,则k ’ 填入(i ,j ),置k ’ 填入标志} begin a[i ,j] ←k ’; p[k ’] ←true ; if i=1 {若当前填第一行且左右两数之和为素数且第1行、第1列的数和仍为最小} then begin if (a[i ,j]+a[i ,j-1] in su)and(sum+a[i ,j] if j=n { 若第一行填完,则填写(2,1);否则填写(i ,j+1)} then solve(2,1,sum+a[i ,j]) else solve(i ,j+1,sum+a[i ,j]); end {then } else if j=1{若填第一列且上下两数之和为素数且第1行、第1列的数和仍为最小} then begin if ((a[i ,j]+a[i-1,j]) in su)and(sum+a[i ,j] then if i=n then solve(2,2,sum+a[i ,j]){若第一列填完,则填写(2,2)} else solve(i+1,j ,sum+a[i ,j]); {否则填写(i+1,j )} end{then} else {否则填其它位置。如果上下两数之和为素数且左右两数之和为素数} if (a[i ,j]+a[i-1,j] in su)and(a[i ,j]+a[i ,j-1] in su) then if j=n then solve(i+1,2,sum){若当前行填完,则填写(i+1,2)} else solve(i,j+1,sum); {否则填写(i,j+1)} p[k’]←false; {恢复递归前k’的未填状态} end;{then} end;{else} end;{solve} 3、主程序 初始时,计算计算3‥2*n2-1的素数表su,并设第1行、第1列的最小数和max为∞。然后按照试题要求将1填入(1,1),通过递归调用solve(1,2,1)计算填数方案。若递归后max仍为∞,则说明无解;否则棋盘b即为最佳方案: fillchar(p,sizeof(p),false); { 初始时未填任何数据} max←30000; { 第1行、第1列的最小数和初始化} p[1] ←true; { 1填入(1,1)} a[1,1] ←1; solve(1,2,1); { 递归计算填数方案} if max=30000 then writeln(’no’) else for i←1 to n do begin {输出最佳方案b} for j←1 to n do write(b[i,j]:5); writeln; end;{for} 三、回溯法的优化 但是,回溯法亦有其致命的弱点——时间效率比较数学解析法低。为了改善其时效,我们要尽可能减少分枝(减少解答树的次数)和增加约束条件(使其在保证出解的前提下尽可能“苛刻”);另外还可以从下述两个方面考虑优化 1、递归前对尚待搜索的信息进行预处理 如果搜索对象是通过某种运算直接得出其结果的,那么搜索前一般需进行预处理—通过相应运算将所有搜索对象的计算结果置入常量表,搜索过程中只要将当前搜索对象的结果值从常量表取出即可。这样可以显著改善搜索效率。否则,在搜索过程中每遇到这些对象都要计算,则会产生大量的重复运算。例如在【例题12.2.3】数字排列中将2‥n2中的素数置入一个常量表su,就是一种改善搜索效率的预处理。 2、记忆化搜索 如果解答树中存在一些性质相同的子树,那么,只要我们知道了其中一棵子树的性质,就可以根据这个信息,导出其它子树的性质。这就是自顶向下记忆化搜索的基本思想。 【例题12.2.1】序关系计数问题 用关系’<’和’=’将3个数a、b和C依次排列有13种不同的关系: a