【转】递归与回溯

 

回溯(backtracking)是一种系统地搜索问题解答的方法。为了实现回溯,首先需要为问题定义一个解空间(solution space),这个空间必须至少包含问题的一个解(可能是最优的)。
     下一步是组织解空间以便它能被容易地搜索。典型的组织方法是图(迷宫问题)或树(N皇后问题)。
      一旦定义了解空间的组织方法,这个空间即可按深度优先的方法从开始节点进行搜索。

回溯方法的步骤如下:
     1) 定义一个解空间,它包含问题的解。
     2) 用适于搜索的方式组织该空间。
     3) 用深度优先法搜索该空间,利用限界函数避免移动到不可能产生解的子空间。
回溯算法的一个有趣的特性是在搜索执行的同时产生解空间。在搜索期间的任何时刻,仅保留从开始节点到当前节点的路径。因此,回溯算法的空间需求为O(从开始节点起最长路径的长度)。这个特性非常重要,因为解空间的大小通常是最长路径长度的指数或阶乘。所以如果要存储全部解空间的话,再多的空间也不够用。

 

递归与回溯

[算法分析]
    为了描述问题的某一状态,必须用到它的上一状态,而描述上一状态,又必须用到它的上一状态……这
种用自已来定义自己的方法,称为递归定义。例如:定义函数f(n)为:
        /n*f(n-1) (n>0)
f(n)= |
        \ 1(n=0)
    则当0时,须用f(n-1)来定义f(n),用f(n-1-1)来定义f(n-1)……当n=0时,f(n)=1。
    由上例我们可看出,递归定义有两个要素:
    (1)递归边界条件。也就是所描述问题的最简单情况,它本身不再使用递归的定义。
         如上例,当n=0时,f(n)=1,不使用f(n-1)来定义。
    (2)递归定义:使问题向边界条件转化的规则。递归定义必须能使问题越来越简单。
         如上例:f(n)由f(n-1)定义,越来越靠近f(0),也即边界条件。最简单的情况是f(0)=1。

    递归算法的效率往往很低, 费时和费内存空间. 但是递归也有其长处, 它能使一个蕴含递归关系且结构
复杂的程序简介精炼, 增加可读性. 特别是在难于找到从边界到解的全过程的情况下, 如果把问题推进一步
没其结果仍维持原问题的关系, 则采用递归算法编程比较合适.

    递归按其调用方式分为: 1. 直接递归, 递归过程p直接自己调用自己; 2. 间接递归, 即p包含另一过程
d, 而d又调用p.

    递归算法适用的一般场合为:
    1. 数据的定义形式按递归定义.
       如裴波那契数列的定义: f(n)=f(n-1)+f(n-2); f(0)=1; f(1)=2.
       对应的递归程序为:
         function fib(n : integer) : integer;
         begin
            if n = 0 then fib := 1     { 递归边界 }
                     else if n = 1 then fib := 2
                                   else fib := fib(n-2) + fib(n-1)   { 递归 }
         end;
       这类递归问题可转化为递推算法, 递归边界作为递推的边界条件.
    2. 数据之间的关系(即数据结构)按递归定义. 如树的遍历, 图的搜索等.
    3. 问题解法按递归算法实现. 例如回溯法等.

    从问题的某一种可能出发, 搜索从这种情况出发所能达到的所有可能, 当这一条路走到" 尽头 "
的时候, 再倒回出发点, 从另一个可能出发, 继续搜索. 这种不断" 回溯 "寻找解的方法, 称作
" 回溯法 ".

[参考程序]
    下面给出用回溯法求所有路径的算法框架. 注释已经写得非常清楚, 请读者仔细理解.
const maxdepth = ????;
type statetype = ??????; { 状态类型定义 }
     operatertype = ??????; { 算符类型定义 }
     node = record { 结点类型 }
               state : statetype; { 状态域 }
       operater :operatertype { 算符域 }
    end;
{ 注: 结点的数据类型可以根据试题需要简化 }
var
   stack : array [1..maxdepth] of node; { 存当前路径 }
   total : integer; { 路径数 }
procedure make(l : integer);
var i : integer;
begin
   if stack[l-1]是目标结点 then
   begin
      total := total+1; { 路径数+1 }
      打印当前路径[1..l-1];
      exit
   end;
   for i := 1 to 解答树次数 do
   begin
      生成 stack[l].operater;
      stack[l].operater 作用于 stack[l-1].state, 产生新状态 stack[l].state;
      if stack[l].state 满足约束条件 then make(k+1);
      { 若不满足约束条件, 则通过for循环换一个算符扩展 }
      { 递归返回该处时, 系统自动恢复调用前的栈指针和算符, 再通过for循环换一个算符扩展 }
      { 注: 若在扩展stack[l].state时曾使用过全局变量, 则应插入若干语句, 恢复全局变量在
                        stack[l-1].state时的值. }
   end;
   { 再无算符可用, 回溯 }
end;
begin
   total := 0; { 路径数初始化为0 }
   初始化处理;
   make(l);
   打印路径数total
end.

[例子]    求n个数的全排列。
    〔分析〕求n个数的全排列,可以看成把n个不同的球放入n个不同的盒子中,每个盒子中只能有一
个球。解法与八皇后问题相似。
    〔参考过程〕
       procedure try(i:integer);
       var j:integer;
        begin
             for j:=1 to n do
                  if a[j]=0 then
                 begin
                      x[i]:=j;
                      a[j]:=1;
                      if i<n then try(i+1)
                         else print;
                      a[j]=0;
                 end;
        end;

[习题] 用递归完成:
1、如下图,打印0-n的所有路径(0=〈n〈=9):
   1╠> 3╠> 5╠> 7╠> 9
    ^    ^    ^    ^     ^
    |    |    |    |     |
   0╠> 2╠> 4╠> 6╠> 8
(说明:图中须加上0->3,2->5,4->7,6->9的连线)
2、快速排序。
3、打印杨辉三角。
最后, 给出一道经典的使用回溯算法解决的问题, 留给读者思考.
题目描述:
    对于任意一个m*n的矩阵, 求l形骨牌覆盖后所剩方格数最少的一个方案.

递归算法

程序调用自身的编程技巧称为递归(recursion)。

一个比较经典的描述是老和尚讲故事,他说从前有座山,山上有座庙,庙里有个老和尚在讲故事,他说从前有座山,山上有座庙,庙里有个老和尚在讲故事,他说从前有座山,……。这样没完没了地反复讲故事,直到最后老和尚烦了停下来为止。

反复讲故事可以看成是反复调用自身,但如果不能停下来那就没有意义了,所以最终还要能停下来。递归的关键在于找出递归方程式和递归终止条件。即老和尚反复讲故事这样的递归方程式要有,最后老和尚烦了停下来这样的递归的终止条件也要有。

阶乘的算法可以定义成函数

 n*f(n-1)  (n>0)

f(n)=

 f(n)=1   (n=0)

当n>0时,用f(n-1)来定义f(n),用f(n-1-1)来定义f(n-1)……,这是对递归形式的描述。

当n=0时,f(n)=1,这是递归结束的条件。

递归算法一般用于解决三类问题:

⑴. 数据的定义形式是按递归定义的。

比如阶乘的定义。 

例1 又如裴波那契数列的定义:f(n)=f(n-1)+f(n-2); f(0)=1; f(1)=2

对应的递归程序为:

var n:integer;

function f(n:integer):longint;

begin

 case n of

 0:f:=1; {递归结束条件}

 1:f:=2;

 else

 f:=f(n-1)+f(n-2)  {递归调用}

 end

end;

begin

 readln(n);

 writeln(f(n))

end.

这类递归问题往往又可转化成递推算法,递归边界作为递推的边界条件。

⑵. 问题解法按递归算法实现。例如回溯等。

⑶. 数据的结构形式是按递归定义的。如树的遍历, 图的搜索等。

递归解决实际问题的例子很多,如经典的梵塔问题。

例2 梵塔问题:有n个半径各不相同的圆盘,按半径从大到小,自下而上依次套在a柱上,另外还有b、c两根空柱。要求将a柱上的n个圆盘全部搬到c柱上去,每次只能搬动一个盘子,且必须始终保持每根柱子上是小盘在上,大盘在下。

在移动盘子的过程当中发现要搬动n个盘子,必须先将n-1个盘子从a柱搬到b柱去,再将a柱上的最后一个盘子搬到c柱,最后从b柱上将n-1个盘子搬到c柱去。搬动n个盘子和搬动n-1个盘子时的方法是一样的,当盘子搬到只剩一个时,递归结束。

程序如下:

var a,b,c,number:integer;

procedure move(n,a,b,c:integer);

begin

 if n=1 then writeln(a,'->',c)

 else

 begin

 move(n-1,a,c,b);

 writeln(a,'->',c);

 move(n-1,b,a,c)

 end;

end;

begin

 write('the number of dish:');

 readln(number);

 move(number,1,2,3);

 readln

end.

自然数的拆分,数字的拆分等都可以用到递归算法。

例3 要求找出具有下列性质的数的个数(包含输入的自然数n):

先输入一个自然数n(n<=500),然后对此自然数按照如下方法进行处理:

①. 不作任何处理;

②. 在它的左边加上一个自然数,但该自然数不能超过原数的一半;

③. 加上数后,继续按此规则进行处理,直到不能再加自然数为止.

样例: 输入: 6

满足条件的数为 6

 16

 26

 126

 36

 136

输出: 6

这道题只需求出满足条件的数的个数,在n值不大的情况下用递归求解比较方便,因为它本身题目的条件就是递归定义的。

递归的样例程序如下:

var n,i:integer;

 s:real;

procedure qiu(x:integer);

var k:integer;

begin

 if x<>0 then

 begin

 s:=s+1;

 for k:=1 to x div 2 do qiu(k)

 end

end;

begin

 readln(n);

 s:=0;

 qiu(n);

 writeln(s:2:0)

end.

递归算法解题通常显得很简洁,但递归算法解题的运行效率较低。在递归调用的过程当中系统为每一层的返回点、局部量等开辟了栈来存储。递归次数过多容易造成栈溢出等。

 

回溯算法的一些知识   

1.  回溯算法的主要特征是什么?

答:把回溯算法看成一个不断地进行各种尝试直至问题解决的过程,这个过程似乎具有迭代性。

2.  用自己的语言来描述,如何用右手规则穿越迷宫。用左手法则是否也能达到同样的效果?

答:可以。

3.  用递归回溯法则解决迷宫问题的核心是什么?

答:出口就在其中的一条线上。如果选对了方向,那么将向解决方案更靠近一步。因此,沿着那条路走,问题就变得简单起来,这就是递归式解法的关键。

4.  在SolveMaze的递归实现中,有哪些简单情景?

答:这个参数是起始位置,它返回一些指示值,说明是否成功。到找到返回TURE,否则返回FALSE。

5.  为什么说穿越迷宫时做标记是重要的?如果未作标记,那么SolveMaze函数将会发生什么情况?

答:因为做标记是递归实现的核心,如果没有标记,那么它只能在一个地方向四周判断,然后不会进入下一步。

6.  在SolveMaze的实现中,为什么要在for循环后调用UnmarkSquare函数?这对于该算法是否必要?

答:必须有的。它让递归进入下一步。

7.  SolveMaze函数所返回的布尔结果有何作用?

答:找到返回TURE,否则返回FALSE。这样就判断出循环跳出的条件。

8.  用自己的语言,解释回溯过程在SolveMaze的递归实现中是如何发挥作用的。

答:它可以在错误的时候返回到没有错误的地方,这样一步一步的简化了问题,直至走出迷宫。

9.  在简单的拿子游戏中,开始时的硬币是13个,人类玩家先走第一步,有利还是不利?为什么?

答:有利,因为这样就可以对这个条件判断。然后找到最佳方法。先找到最佳方法,对以后的步骤更有利。

10.              编写一个简单的C语言程序,局势对当前玩家有利时nCoins的值shi TURE,反之为FALSE(提示:使用%运算符)。

11.              什么是最大最小算法?它的名字有什么意义?

答:在任何局势下,最佳走法,简单说,就是让你的对手处在最坏的局势中,最坏的局势就是使得对手只能走出最弱的好棋。这种思想——寻找使对手只能走出最差的局势——被称为“最小最大策略”。他的目标就是将对手的最大机会最小化。

12.              分析一个游戏时,ply这个词是什么意思?

答:单个游戏者的单步动作。

13.              假设您处在下图所示的局势中,分析下两步棋以显示出从您的角度的平分结果:

答:第3种+4+3-2+5的得分是10。

14.              为什么最小最大算法的抽象开发是很有意义的?

答:最小最大算法是一个非常通用的法则,它可以用在各种游戏中而不一览于游戏的形式。

15.              使用哪两种数据结构使得最小最大算法的实现独立于某一特殊游戏的特性?

答:它要有2个互相递归的函数,所以要求:1必须可以限制递归搜索的深度。2。必须可以为为每步棋和每个局势评定一个分数。

16.              FindBestMove函数中有3个参数,每个参数的作用是什么?

 

答:stateT state, int depth, int *pRating.

17.              解释EvaluateStaticPosition函数在最小最大算法的实现过程中的作用。

答:这个函数在游戏结束或已经达到最大递归深度时,简单情景就会出现,这些简单情景允许递归终止。这个函数就是用来执行评估的。

 

 

 

 

回溯算法  

对于给定的问题,如果有简明的数学模型揭示问题的本质,我们尽量用解析法求解;如果无法建立数学模型,或者即使有一定的数学模型,但采用数学方法解决有一定的困难,我们只好用模拟或搜索来求解.搜索的本质是枚举,回溯是最常用的搜索方法之一,它采用深度优先的策略来枚举所有可能的解,结合题设条件的限定,从而得到问题的解.其递归形式的框架如下:

procedure  tryall;

var

  i:integer;

Begin

  If  target()  then  output;

  For  i:=1  to  p  do

  If defined(i) then begin down(i); tryall; up(i); end;

End;

其中:

变量p表示每一阶段决策的可选方案数;

target()函数返回布尔值,用来判断是否完成一次搜索找到一组问题的解;

output过程用于记录或输出所得的解;

defined(i)函数返回布尔值,用来判断该阶段是否可以采用方案i;

down(i)过程用来记录目前阶段的决策,进入下一阶段;

up(i)过程是回溯,返回上一阶段,寻求新的方案.

非递归形式的框架如下:

l:=1;  fillchar(a,sizeof(a),0);

while l>0 do

begin

  inc(a[l]); flag:=false;

  while not flag and (a[l]<=p) do

  if not defined(a[l]) then inc(a[l]) else flag:=true;

  if flag then

  begin down; if target() then output; end

  else  up;

其中数组a用于记录各个阶段所采用的方案.

以上两个框架都判断所有可能的方案组合,均可用于求解问题的所有解或者最优解;如果问题只需求任意一个可行解,对上述框架进行简单的修改即可实现.

一般来说,搜索的时间复杂度是指数级的,但是经过适当的剪枝,尽可能减少方案组合的总量,可以提高算法的时间效率,在规定的时间内出解.例如:

 

石子合并

你有一堆石头质量分别为W1,W2,W3...WN.(W<=100000)现在需要你将石头合并为两堆,使两堆质量的差的绝对值为最小。

输入:

第一行为整数N(1<=N<=20),表示有N堆石子。接下去N行,为每堆石子的质量。

输出:

一行。只需输出合并后两堆的质量差的绝对值的最小值

样例输入:

5

5

8

13

27

14

样例输出:

3

分析:

该题无法建立合适的数学模型,简单的贪心法很容易找出反例;该题目不同于相邻堆的石子进行合并的石子归并问题,石子的合并顺序对于最终结果没有影响,无法归纳相邻阶段之间的转化关系,动态规划方法也难以解决,故考虑采用回溯法求解,确定一堆石子的归属为一个阶段,全部石子的归属确定后即可获得一个两堆质量之差的绝对值,搜索全部可能的归属可能并比较记录最小的绝对值,即是问题的解。

剪枝:

如前所述,回溯的本质是枚举,就该题目而言,理论上所有可能的分组方法是2N种(即每堆石子都有在第一堆或第二堆两种可能)。对于N最大可取到20来说,2N个方案组合的判定时间复杂度难以接受。考虑到分成两个堆,知道一个堆中的石子构成即可推算出另一个堆中的石子构成情况,也就是说在 2N 种方案组合中每种方案都有与之等价的方案。因此,可以假定给定的某堆石子(考虑编程实现方便,一般选择第一堆)分在第一堆,由此可将原来的2N种方案组合减少为  2N-1  种;如果第一堆中石子质量之和已超过总质量的一半,继续向该堆中加入石子,两堆质量差的绝对值必然增大,没有继续搜索的必要,因此剪枝。

源代码如下:

program tj1017;

var

  n,i,l:integer;{l:阶段数}

  w:array[1..20]of longint;{所给石子的质量}

  sum,r,cur:longint;

{sum:总质量;r:差的绝对值;cur:当前第一堆质量和}

  d:array[1..21]of integer;{石子的归属,0:第一堆;1:第二堆}

  flag:boolean;

begin

  assign(input,’a.in’); reset(input);

  assign(output,’a.out’); rewrite(output);

  readln(n); sum:=0;

  for i:=1 to n do begin readln(w[i]); sum:=sum+w[i]; end;

  for i:=1 to n-1 do{按降序排序}

  begin

cur:=i;

for l:=I+1 to n do if w[l]>w[cur] then cur:=l;

if cur<>I then begin r:=w[cur]; w[cur]:=w[I]; w[I]:=r; end;

end;

r:=sum; cur:=0; fillchar(d,sizeof(d),0); l:=1;

repeat

  if (cur<sum div 2) and (l<n+1) then flag:=d[l]<2

  else flag:=false;

  if flag then

  begin{down}

    inc(cur,w[l]*(1-d[l])); inc(d[l]); inc(l);

    if r>abs(sum-2*cur) then r:=abs(sum-2*cur);

  end

  else

  begin{up}

    d[l]:=0; dec(l); if d[l]=1 then dec(cur,w[l]);

  end;

  until l=1;

  writeln(r);

  close(input); close(output);

end.

该题目剪枝中易犯的错误:认为给定数据中质量相等的石子可以相互抵消,从而减少阶段数。

错误原因:质量相等的石子未必分在不同的堆中,故不能提前抵消。反例:四堆石子(10,4,4,1)合并,两个4不能抵消。

近年来,题目的描述有越来越长的趋势,除了考查选手能否熟练运用适当算法解决问题之外,同时又考查选手分析归纳和抽象问题的能力。例如:

话说星矢、紫龙、冰河、阿瞬为了救活雅典娜,必须勇闯黄金十二宫。

首先他们来到了白羊宫,身为白羊座黄金圣斗士的穆先生,早就知道假教皇的阴谋,于是他决定帮助星矢他们。穆先生发现,他们四人经过数次大战,身上的圣衣已经有多处破损,如果继续去挑战黄金圣斗士,一定会输的。便要帮星矢等人修补他们的圣衣。现在已知四人圣衣有几处破损(s1,s2,s3,s4),而且每处破损程度不同,破损程度越厉害,穆先生就需要更多时间来修好它。破损程度用穆先生需修好它用的时间表示(A1...As1,B1...Bs2,C1...Cs3,D1...Ds4)。不过穆先生能力很强,可以同时修补2处破损。但是这2处破损,只能是同一件圣衣上的。就是说他只能同时修补一件圣衣,修好了,才能修补下一件。

Input

本题包含多组数据,每组数据5行. 第1行,为s1,s2,s3,s4(1<=s1,s2,s3,s4<=20) 第2行,为A1...As1 共s1个数,表示第一件圣衣上每个破损处的程度,也就是穆先生修好它所用的时间第3行,为B1...Bs2 共s2个数,表示第二件圣衣上每个破损处的程度,也就是穆先生修好它所用的时间第4行,为C1...Cs3 共s3个数,表示第三件圣衣上每个破损处的程度,也就是穆先生修好它所用的时间第5行,为D1...Ds4 共s4个数,表示第四件圣衣上每个破损处的程度,也就是穆先生修好它所用的时间 (1<=A1...As1,B1...Bs2,C1...Cs3,D1...Ds4<=60)

Output

对于每组数据输出一行,为穆先生修好四件圣衣所要的最短时间。(对Sample output的说明:5+4+6+(2+3)=20)

Sample Input

1 2 1 3

5

4 3

6

2 4 3

Sample Output

20由题意可知,缝补一件圣衣所需的最小时间是在把总时间分成尽可能平均的两份时取得的,即两份的差的绝对值最小是取得的。因此,该题目多次使用上述石子归并问题的算法即可解决。

 

递归过程BACKTRACK1(DATALIST)

  在前面的回溯算法BACKTRACK中,设置了两个回溯点,一个是当遇到非法状态时回溯,一个是当试探了一个状态的所有子状态后,仍然找不到解时回溯。对于某些问题,可能会遇到这样的问题:一个是问题的某一个(或者某些)分支具有无穷个状态,算法可能会落入某一个"深渊",永远也回溯不回来,这样,就不能找到问题的解。另一个问题是,在某一个分支上具有环路,搜索会在这个环路中一直进行下去,同样也回溯不出来,从而找不到问题的解。如下图所示。

递归与回溯


  为了解决这两个问题,下面将给出回溯算法BACKTRACK1,该算法比前面的回溯算法增加了两个回溯点:一个是用一个常量BOUND来限制算法所能够搜索的最大深度,当前状态的深度达到了限制深度时,算法将进行回溯,从而可以避免落入"深渊";另一个是将过程的参数用从初始状态到当前状态的表来替代原来的当前状态,当新的状态产生时,查看是否已经在该表中出现过了,如果出现过,则表明有环路存在,算法将进行回溯,从而解决了环路问题。

  从四皇后的例子看出搜索深度有限,仅有4层,而且不可能出现重复状态的问题,因此BACKTRACK过程完全适用。对于八数码问题则不然,必须设置深度范围限制及防止出现重复状态引起的死循环这两个回溯点,改进后的算法如下:
递归过程BACKTRACK1(DATALIST)
⑴DATA:=FIRST(DATALIST);设置DATA为当前状态
⑵IF MEMBER(DATA,TAIL(DATALIST)),RETURN FAIL;TAIL是取尾操作,表示取表DATALIST中除了第一个元素以外的所有元素。如果DATA在TAIL(DATALIST)中存在,则表示有环路出现,过程返回FAIL,必须回溯。
⑶IF TERM(DATA),RETURN NIL;TERM取真即找到目标,则过程返回空表NIL。
⑷IF DEADEND(DATA),RETURN FAIL;DEADEND取真,即该状态不合法,则过程返回FAIL,必须回溯。
⑸IF LENGTH(DATALIST)>BOUND,RETURN FAIL;LENGTH计算DATALIST的长度,即搜索的深度,当搜索深度大于给定值BOUND时,则过程返回FAIL,必须回溯。
⑹RULES:=APPRULES(DATA);APPRULES计算DATA的可应用规则集,依某种原则(任意排列或按启发式准则排列)排列后赋给RULES。
⑺LOOP:IF NULL(RULES),RETURN FAIL;NULL取真,即规则用完未找到目标,过程返回FAIL,必须回溯。
⑻R:=FIRST(RULES);取头条可应用规则。
⑼RULES:=TAIL(RULES);删去头条规则,减少可应用规则表的长度。
⑽RDATA:=GEN(R,DATA);调用规则R作用于当前状态,生成新状态。
⑾RDATALIST:=CONS(RDATA,DATALIST);将新状态加入到表DATALIST中。
⑿PATH:=BACKTRACK1(RDATALIST);递归调用本过程。
⒀IF PATH=FAIL,GO LO0P;当PATH=FAIL时,递归调用失败,则转移调用另一规则进行测试。
⒁RETURN CONS(R,PATH);过程返回解路径规则表(或局部解路径子表)。

  在过程BACKTRACK1中,形参DATALIST是从初始状态到当前状态的逆序表,即初始状态排在表的最后,而当前状态排在表的最前面。返回值同前面的算法一样,是以规则序列表示的路径表(当求解成功时),或者是FAIL(当求解失败时)。

  这个算法与前者的差别是递归过程自变量是状态的链表,取返回到初始状态路径上所有状态组成的一张表,而DATA则是当前的一个状态。此外在第2、5步增设了两个回溯点以检验是否重访已出现过的状态和限定搜索深度范围,这分别由谓词MEMBER和>,函数LENGTH,常量BOUND实现。

  这里介绍的回溯搜索策略,在有些书中称为"深度优先"搜索,而在本书中"深度优先"搜索有另外的含义,具体请见"深度优先"搜索一节。

  推广的回溯算法可应用于一般问题的求解,但这两个算法只描述了回溯一层的情况,即第n层递归调用失败,则控制退回到(n-1)层继续搜索。实际上往往造成深层搜索失败在于浅层原因引起,因此也可以利用启发信息,分析失败的原因,再回溯到合适的层次上,这就是多层回溯策略的思想,目前已有一些系统使用了这种策略。

 

 

回溯法也称试探法,它的基本思想是:从问题的某一种状态(初始状态)出发,搜索从这种状态出发所能达到的所有“状态”,当一条路走到“尽头”的时候(不能再前进),再后退一步或若干步,从另一种可能“状态”出发,继续搜索,直到所有的“路径”(状态)都试探过。这种不断“前进”、不断“回溯”寻找解的方法,就称作“回溯法”。

 

    回溯的设计

       1.用栈保存好前进中的某些状态.
 

      2.制定好约束条件


  【例1】从1到X这X个数字中选出N个,排成一列,相邻两数不能相同,求所有可能的排法。每个数可以选用零次、一次或多次。例如,当N=3、X=3时,排法有12种:121、123、131、132、212、213、231、232、312、313、321、323。
  【分析】以N=3,X=3为例,这个问题的每个解可分为三个部分:第一位,第二位,第三位。先写第一位,第一位可选1、2或3,根据从小到大的顺序,我们选1;那么,为了保证相邻两数不同,第二位就只能选2或3了,我们选2;最后,第三位可以选1或3,我们选1;这样就得到了第一个解"121"。然后,将第三位变为3,就得到了第二个解"123"。此时,第三位已经不能再取其他值了,于是返回第二位,看第二位还能变为什么值。第二位可以变为3,于是可以在"13"的基础上再给第三位赋予不同的值1和2,得到第三个解"131"和"132"。此时第二位也已经不能再取其他值了,于是返回第一位,将它变为下一个可取的值2,然后按顺序变换第二位和第三位,得到"212"、"213"、"231""232"。这样,直到第一位已经取过了所有可能的值,并且将每种情况下的第二位和第三位都按上述思路取过一遍,此时就已经得到了该问题的全部解。
  由以上过程可以看出,回溯法的思路是:问题的每个解都包含N部分,先给出第一部分,再给出第二部分,……直到给出第N部分,这样就得到了一个解。若尝试到某一步时发现已经无法继续,就返回到前一步,修改已经求出的上一部分,然后再继续向后求解。这样,直到回溯到第一步,并且已经将第一步的所有可能情况都尝试过之后,即可得出问题的全部解。

 

 

什么是回溯算法如何使用回溯算法  

 

个人喜欢彩票,更喜欢在自己原来的彩票程序基础上进行二次性开发彩票程序,让自己不断的进步,下面就是这几天编写彩票程序应用到得回溯算法,从不同角度里思考而编写不同的回溯算法: (个性网名)

#include<stdio.h>

//组选六的回溯法
void com_back(int Five_Number[])
{
 int a[3]={0};

 int i=0, j;

 do
 {
  if(i==0)
  {
           a[i+1] = a[i] + 1;

     a[i+2] = a[i] + 2;

     i = 2;
  }

  if(i==2)
  {
   for(j=0; j<3; j++)
   {
    printf("%d ", Five_Number[a[j]]);
   }
   printf("
 情侣网名\n");

   a[i]++;

   if(a[i]<6)
   {
    continue;
   }
  }

  i=i-2; //即i=0

  a[i]++;

  if(a[i]>3)
  {
          break;
  }
       

 }while(1);
}
int main(void)
{
 int Five_Number[6]={0,2,5,7,8,9};

 int i;

 for(i=0; i<6; i++)
 {
  printf("%d  ", Five_Number[i]);
 }
 printf("\n");

 com_back(Five_Number);
}

 

#include<stdio.h>
#include<windows.h>

void comb_back()
{
 int  a[3]; //存储3个数

 int i, j, k;

 i = 0, k = 0;

 a[i] = 0; //0 为 起点值

 do
 {
  if(a[i]<=3+i) //  比如 a[0]最大值为 3
  {
   if(i == 2)
   {
   // Sleep(500);

    printf("第d组 : ", ++k);

    for(j=0; j<3; j++)
    {
     printf("%d", a[j]); // 输出这个组合的数字
    }
    
    printf("\n");

    a[i]++;

    continue;
   }

   i++;

   a[i] = a[i-1] + 1;
  }
  else  // 回溯
  {
   if(i == 0)  // 已经找到了 全部解
   {
    return ;
   }

   a[--i]++; // 前一位的数字 增加 1, 继续进行向前 试探
  }
 }while(1);
 
}

int main(void)
{
 printf("下面进行回溯算法的结果:\n");

    comb_back();

 printf("qq情侣网名 \n");
 return 0;
}

#include<stdio.h>
#include<windows.h>

//组选三的回溯法
void comb_back(int Six_Number[])
{
 int a[3]={0}; //存储Six_Number数组元素的下标值
 
 int i, j, k;
 
 i = 0, k = 0;
 
// int Sum;
 
 do
 {
  if(i==0)
  {
   if(a[i]>4)
    break;//退出
   if(a[i+1]>a[i])
   { //a[1]>a[0]时则a[1]=a[2],即后两位数相同
   
    a[i+2]=a[i+1];

    i=2;
   }
   else{//a[0]=a[1]时,即前两位数相同
    a[i+1]=a[i];

    a[i+2]=a[i+1]+1;

    i=2;
   }
  }
  
  if(i==2)
  {
   
   printf("第d组 : ", ++k);

   for(j=0; j<3; j++)
   {
    printf("%d", Six_Number[a[j]]); // 输出这个组合的数字
   }
   printf("\n");
   
   //如果前两位数相同时
   if(a[i-2]==a[i-1])
   {
    a[i]++;

    if(a[i]<6) //即a[2]<6
    {//a[2]已循环到Six_Number数组最后一位之前
     continue; //结束本次循环 跳到while条件判断出
    }
   }
   
   //否则回溯
   i=0;

   a[i+1]++;

   if(a[i+1]>5)
   {
    a[i+1]=a[i];

    a[i]++;
   }
  }
  
 }while(1);
 
}

int main(void)
{
    printf("下面进行回溯算法的结果:\n");

     int Six_Number[6] = {0, 3, 5,6, 8, 9};

  for(int i=0; i<6; i++)
  {
   printf("%d ",Six_Number[i]);
  }

  printf("\n");

     comb_back(Six_Number);

     printf("\n");

     return 0;
}

 

用回溯法求解0—1背包问题,并输出问题的最优解。0—1背包问题描述如下:

给定n种物品和一背包。物品i的重量是Wi,其价值为Vi,背包的容量是c,问应如何选择装入背包中的物品,使得装入背包中物品的总价值最大。

 

【实验条件】

Microsoft Visual C++ 6.0

 

【需求分析】

对于给定n种物品和一背包。在容量最大值固定的情况下,要求装入的物品价值最大化。

 

【设计原理】

一、回溯法:

回溯法是一个既带有系统性又带有跳跃性的的搜索算法。它在包含问题的所有解的解空间树中,按照深度优先的策略,从根结点出发搜索解空间树。算法搜索至解空间树的任一结点时,总是先判断该结点是否肯定不包含问题的解。如果肯定不包含,则跳过对以该结点为根的子树的系统搜索,逐层向其祖先结点回溯。否则,进入该子树,继续按深度优先的策略进行搜索。回溯法在用来求问题的所有解时,要回溯到根,且根结点的所有子树都已被搜索遍才结束。而回溯法在用来求问题的任一解时,只要搜索到问题的一个解就可以结束。这种以深度优先的方式系统地搜索问题的解的算法称为回溯法,它适用于解一些组合数较大的问题。

二、算法框架:

1、问题的解空间:应用回溯法解问题时,首先应明确定义问题的解空间。问题的解空间应到少包含问题的一个(最优)解。

2、回溯法的基本思想:确定了解空间的组织结构后,回溯法就从开始结点(根结点)出发,以深度优先的方式搜索整个解空间。这个开始结点就成为一个活结点,同时也成为当前的扩展结点。在当前的扩展结点处,搜索向纵深方向移至一个新结点。这个新结点就成为一个新的活结点,并成为当前扩展结点。如果在当前的扩展结点处不能再向纵深方向移动,则当前扩展结点就成为死结点。换句话说,这个结点不再是一个活结点。此时,应往回移动(回溯)至最近的一个活结点处,并使这个活结点成为当前的扩展结点。回溯法即以这种工作方式递归地在解空间中搜索,直至找到所要求的解或解空间中已没有活结点时为止。

运用回溯法解题通常包含以下三个步骤:

(1)针对所给问题,定义问题的解空间;

(2)确定易于搜索的解空间结构;

(3)以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索;

3、递归回溯:由于回溯法是对解空间的深度优先搜索,因此在一般情况下可用递归函数来实现回溯法。

【概要设计】

0—1背包问题是一个子集选取问题,适合于用子集树表示0—1背包问题的解空间。在搜索解空间树是,只要其左儿子节点是一个可行结点,搜索就进入左子树,在右子树中有可能包含最优解是才进入右子树搜索。否则将右子树剪去。

int c;//背包容量

  int n; //物品数

  int *w;//物品重量数组

  int *p;//物品价值数组

  int cw;//当前重量

  int cp;//当前价值

  int bestp;//当前最优值

  int *bestx;//当前最优解

  int *x;//当前解

 

int Knap::Bound(int i)//计算上界

void Knap::Backtrack(int i)//回溯

 

int Knapsack(int p[],int w[],int c,int n) //为Knap::Backtrack初始化

 

【详细设计】

#include<iostream>

using namespace std;

 

 

 

class Knap

{

friend int Knapsack(int p[],int w[],int c,int n );

 

public:

       void print()

       {

        for(int m=1;m<=n;m++)

   {

    cout<<bestx[m]<<" ";

   }

   cout<<endl;

       };

 

private:

  int Bound(int i);

  void Backtrack(int i);

 

  int c;//背包容量

  int n; //物品数

  int *w;//物品重量数组

  int *p;//物品价值数组

  int cw;//当前重量

  int cp;//当前价值

  int bestp;//当前最优值

  int *bestx;//当前最优解

  int *x;//当前解

 

};

 

 

int Knap::Bound(int i)

{

 //计算上界

       int cleft=c-cw;//剩余容量

       int b=cp;

       //以物品单位重量价值递减序装入物品

       while(i<=n&&w[i]<=cleft)

       {

         cleft-=w[i];

         b+=p[i];

         i++;

       }

       //装满背包

       if(i<=n)

              b+=p[i]/w[i]*cleft;

       return b;

}

 

 

void Knap::Backtrack(int i)

{

  if(i>n)

  {

    if(bestp<cp)

       {

           for(int j=1;j<=n;j++)

                   bestx[j]=x[j];

           bestp=cp;

       }

       return;

  }

  if(cw+w[i]<=c) //搜索左子树

  {            

      x[i]=1;

         cw+=w[i];

         cp+=p[i];

         Backtrack(i+1);

         cw-=w[i];

         cp-=p[i];

  }

         if(Bound(i+1)>bestp)//搜索右子树

         {

             x[i]=0;

                Backtrack(i+1);

         }

 

}

 

 

class Object

{

 friend int Knapsack(int p[],int w[],int c,int n);

public:

       int operator<=(Object a)const

       {

        return (d>=a.d);

       }

private:

       int ID;

       float d;

};

 

 

int Knapsack(int p[],int w[],int c,int n)

{

 //为Knap::Backtrack初始化

       int W=0;

       int P=0;

       int i=1;

       Object *Q=new Object[n];

       for(i=1;i<=n;i++)

       {

        Q[i-1].ID=i;

        Q[i-1].d=1.0*p[i]/w[i];

        P+=p[i];

        W+=w[i];

       }

       if(W<=c)

              return P;//装入所有物品

       //依物品单位重量排序

       float f;

       for( i=0;i<n;i++)

        for(int j=i;j<n;j++)

        {

         if(Q[i].d<Q[j].d)

         {

           f=Q[i].d;

              Q[i].d=Q[j].d;

              Q[j].d=f;

         }

 

 

        }

      

       Knap  K;

       K.p = new int[n+1];

    K.w = new int[n+1];

       K.x = new int[n+1];

       K.bestx = new int[n+1];

       K.x[0]=0;

       K.bestx[0]=0;

       for( i=1;i<=n;i++)

       {

        K.p[i]=p[Q[i-1].ID];

        K.w[i]=w[Q[i-1].ID];

       }

       K.cp=0;

       K.cw=0;

       K.c=c;

       K.n=n;

       K.bestp=0;

       //回溯搜索

       K.Backtrack(1);

    K.print();

    delete [] Q;

       delete [] K.w;

       delete [] K.p;

       return K.bestp;

}

 

void main()

{

       int *p;

       int *w;

    int c=0;

       int n=0;

       int i=0;

 

       cout<<"请输入背包个数:"<<endl;

    cin>>n;

       p=new int[n+1];

       w=new int[n+1];

       p[0]=0;

       w[0]=0;

 

       cout<<"请输入个背包的价值:"<<endl;

       for(i=1;i<=n;i++)

              cin>>p[i];

 

       cout<<"请输入个背包的重量:"<<endl;

       for(i=1;i<=n;i++)

              cin>>w[i];

 

       cout<<"请输入背包容量:"<<endl;

       cin>>c;

 

       cout<<Knapsack(p,w,c,n)<<endl;

 

      

}

 

 

 

 

回顾、回忆

  ----------------------------------------

编辑本段试探法

  回溯法也称试探法,它的基本思想是:从问题的某一种状态(初始状态)出发,搜索从这种状态出发所能达到的所有“状态”,当一条路走到“尽头”的时候(不能再前进),再后退一步或若干步,从另一种可能“状态”出发,继续搜索,直到所有的“路径”(状态)都试探过。这种不断“前进”、不断“回溯”寻找解的方法,就称作“回溯法”。

编辑本段步骤

  用回溯算法解决问题的一般步骤为:

  一、定义一个解空间,它包含问题的解。

  二、利用适于搜索的方法组织解空间。

  三、利用深度优先法搜索解空间。

  四、利用限界函数避免移动到不可能产生解的子空间。

  递归与回溯

 

问题的解空间通常是在搜索问题的解的过程中动态产生的,这是回溯算法的一个重要特性。

  回溯法是一个既带有系统性又带有跳跃性的的搜索算法。它在包含问题的所有解的解空间树中,按照深度优先的策略,从根结点出发搜索解空间树。算法搜索至解空间树的任一结点时,总是先判断该结点是否肯定不包含问题的解。如果肯定不包含,则跳过对以该结点为根的子树的系统搜索,逐层向其祖先结点回溯。否则,进入该子树,继续按深度优先的策略进行搜索。回溯法在用来求问题的所有解时,要回溯到根,且根结点的所有子树都已被搜索遍才结束。而回溯法在用来求问题的任一解时,只要搜索到问题的一个解就可以结束。这种以深度优先的方式系统地搜索问题的解的算法称为回溯法,它适用于解一些组合数较大的问题.

编辑本段递归回溯

  递归回溯:由于回溯法是对解空间的深度优先搜索,因此在一般情况下可用递归函数来实现回溯法如下:

  procedure try(i:integer);

  var

  begin

  if i>n then 输出结果

  else for j:=下界 to 上界 do

  begin

  x:=h[j];

  if 可行{满足限界函数和约束条件} then begin 置值;try(i+1); end;

  end;

  end;

  说明:

  i是递归深度;

  n是深度控制,即解空间树的的高度;

  可行性判断有两方面的内容:不满约束条件则剪去相应子树;若限界函数越界,也剪去相应子树;两者均满足则进入下一层;

  搜索:全面访问所有可能的情况,分为两种:不考虑给定问题的特有性质,按事先顶好的顺序,依次运用规则,即盲目搜索的方法;另一种则考虑问题给定的特有性质,选用合适的规则,提高搜索的效率,即启发式的搜索。

  回溯即是较简单、较常用的搜索策略。

  基本思路:若已有满足约束条件的部分解,不妨设为(x1,x2,x3,……xi),I<n,则添加x(i+1)属于s(i+2),检查(x1,x2,……,xi,x(i+1))是否满足条件,满足了就继续添加x(i+2)、s(i+2),若所有的x(i+1)属于s(i+1)都不能得到部分解,就去掉xi,回溯到(xi,x2,……x(i-1)),添加那些未考察过的x1属于s1,看其是否满足约束条件,为此反复进行,直至得到解或证明无解。

  2 回溯

  [recall;look back upon;trace] 上溯,向上推导

  这种鱼有回溯的习惯

  ————————————————————

编辑本段回溯的设计

  1.用栈保存好前进中的某些状态.

  2.制定好约束条件

  【例1】从1到X这X个数字中选出N个,排成一列,相邻两数不能相同,求所有可能的排法。每个数可以选用零次、一次或多次。例如,当N=3、X=3时,排法有12种:121、123、131、132、212、213、231、232、312、313、321、323。

  【分析】以N=3,X=3为例,这个问题的每个解可分为三个部分:第一位,第二位,第三位。先写第一位,第一位可选1、2或3,根据从小到大的顺序,我们选1;那么,为了保证相邻两数不同,第二位就只能选2或3了,我们选2;最后,第三位可以选1或3,我们选1;这样就得到了第一个解"121"。然后,将第三位变为3,就得到了第二个解"123"。此时,第三位已经不能再取其他值了,于是返回第二位,看第二位还能变为什么值。第二位可以变为3,于是可以在"13"的基础上再给第三位赋予不同的值1和2,得到第三个解"131"和"132"。此时第二位也已经不能再取其他值了,于是返回第一位,将它变为下一个可取的值2,然后按顺序变换第二位和第三位,得到"212"、"213"、"231""232"。这样,直到第一位已经取过了所有可能的值,并且将每种情况下的第二位和第三位都按上述思路取过一遍,此时就已经得到了该问题的全部解。

  由以上过程可以看出,回溯法的思路是:问题的每个解都包含N部分,先给出第一部分,再给出第二部分,……直到给出第N部分,这样就得到了一个解。若尝试到某一步时发现已经无法继续,就返回到前一步,修改已经求出的上一部分,然后再继续向后求解。这样,直到回溯到第一步,并且已经将第一步的所有可能情况都尝试过之后,即可得出问题的全部解。

编辑本段程序

  program p11_14;

  const n=3;x=3;

  var a:array [1..n] of 0..x;

  p,c,I:integer;

  begin

  writeln;

  p:=1; {从第一位开始}

  c:=1; {从1开始选数字}

  repeat

  repeat

  if (p=1) or (c<>a[p-1]) then {第一位可填任意数}

  begin

  a[p]:=c; {将数字C填到第P位上}

  if p=n then {若已填到最后一位,则表明已求出了一个解}

  begin

  for I:=1 to n do write(a); {显示这个解}

  writeln;

  end;

  P:=P+1; {继续下一位}

  C:=1; {下一位从1开始}

  End

  Else

  C:=c+1; {下一位仍然从1开始选数字}

  Until (p>n) or (c>X); {直到已填完最末位,或本位再无数字可选}

  Repeat

  P:=p-1; {向前回溯}

  Until (p=0) or (a[p]<x) ; {回溯到尚有选择余地的一位,或到首位}

  If p>0 then {若非首位,则将该位变为下一个可取的数字}

  C:=a[P]+1;

  Until p=0; {将第一位回溯完毕后,程序结束}

  End.

  由键盘上输入任意n个符号,输出它的全排列。(一个符号只能出现一次)

  program hh;

  const n=3;

  var i,k:integer;

  x:array[1..n] of integer;

  st:string[n];

  t:string[n];

  procedure input;

  var i:integer;

  begin

  write('Enter string=');readln(st);t:=st;

  end;

  function place(k:integer):boolean;

  var i:integer;

  begin

  place:=true;

  for i:=1 to k-1 do

  if x=x[k] then begin place:=false; break end;

  end;

  procedure print;

  var i:integer;

  begin

  for i:=1 to n do write(t[x]);writeln;

  end;

  begin

  input;

  k:=1;x[k]:=0;

  while k>0 do

  begin

  x[k]:=x[k]+1;

  while (x[k]<=n) and (not place(k)) do x[k]:=x[k]+1;

  if x[k]>n then k:=k-1

  else if k=n then print

  else begin k:=k+1;x[k]:=0

  end

  end ;

  readln

  end.

  <i id="bke_bwmm753w">

 

 

 

回溯法-算法框架及基础  

回溯法其实也是一种搜索算法,它可以方便的搜索解空间。
回溯法解题通常可以从以下三步入手:
1、针对问题,定义解空间
2、确定易于搜索的解空间结构
3、以深度优先的方式搜索解空间,并在搜索的过程中进行剪枝
回溯法通常在解空间树上进行搜索,而解空间树通常有子集树和排列树。
针对这两个问题,算法的框架基本如下:
用回溯法搜索子集合树的一般框架:
Cpp代码 复制代码
  1. void backtrack(int t){   
  2.   if(t > n) output(x);   
  3.   else{   
  4.     for(int i = f(n,t); i <= g(n,t);i++){   
  5.           x[t] = h(i);   
  6.           if(constraint(t) && bound(t)) backtrack(t+1);   
  7.      }   
  8.   }   
  9. 用回溯法搜索排列树的算法框架:
Cpp代码 复制代码
  1. void backtrack(int t){   
  2.   if(t > n) output(x);   
  3.   else{   
  4.     for(int i = f(n,t); i <= g(n,t);i++){   
  5.           swap(x[t],x[i]);   
  6.           if(constraint(t) && bound(t)) backtrack(t+1);   
  7.           swap(x[t],x[i]);    
  8.     }   
  9.   }   
  10. }  

其中f(n,t),g(n,t)表示当前扩展结点处未搜索过的子树的起始标号和终止标号,
h(i)表示当前扩展节点处,x[t]第i个可选值。constraint(t)和bound(t)是当前
扩展结点处的约束函数和限界函数。constraint(t)返回true时,在当前扩展结点
x[1:t]取值满足约束条件,否则不满足约束条件,可减去相应的子树。bound(t)返
回的值为true时,在当前扩展结点x[1:x]处取值未使目标函数越界,还需要由backtrack(t+1)
对其相应的子树进一步搜索。
用回溯法其实质上是提供了搜索解空间的方法,当我们能够搜遍解空间时,
显然我们就能够找到最优的或者满足条件的解。这便是可行性的问题, 而效率可以
通过剪枝函数来降低。但事实上一旦解空间的结构确定了,很大程度上时间复杂度
也就确定了,所以选择易于搜索的解空间很重要。
下面我们看看两个最简单的回溯问题,他们也代表了两种搜索类型的问题:子集合问题和
排列问题。
第一个问题:
求集合s的所有子集(不包括空集),我们可以按照第一个框架来写代码:
Cpp代码 复制代码
  1. #include   
  2. using namespace std;   
  3.   
  4. int s[3] = {1,3,6};   
  5. int x[3];   
  6. int  N = 3;   
  7. void print(){   
  8.    for(int j = 0; j < N; j++)   
  9.     if(x[j] == 1)   
  10.        cout << s[j] << " ";   
  11.    cout << endl;   
  12. }   
  13.   
  14. void subset(int i){   
  15.     if(i >= N){   
  16.         print();   
  17.         return;   
  18.     }   
  19.   
  20.     x[i] = 1;//搜索右子树   
  21.     subset(i+1);   
  22.     x[i] = 0;//搜索左子树   
  23.     subset(i+1);   
  24. }   
  25.   
  26. int main(){   
  27.   subset(0);   
  28.   return 0;   
  29. }  


下面我们看第二个问题:排列的问题,求一个集合元素的全排列。
我们可以按照第二个框架写出代码:
Cpp代码 复制代码
  1. #include   
  2. using namespace std;   
  3.   
  4. int a[4] = {1,2,3,4};   
  5. const int N = 4;   
  6.   
  7. void print(){   
  8.     for(int i = 0; i < N; i++)   
  9.            cout << a[i] << " ";   
  10.     cout << endl;   
  11. }   
  12.   
  13. void swap(int *a,int i,int j){   
  14.   int temp;   
  15.   temp = a[i];   
  16.   a[i] = a[j];   
  17.   a[j] = temp;   
  18. }   
  19.   
  20. void backtrack(int i){   
  21.     if(i >= N){   
  22.         print();   
  23.     }   
  24.     for(int j = i; j < N; j++){   
  25.         swap(a,i,j);   
  26.         backtrack(i+1);   
  27.         swap(a,i,j);   
  28.     }   
  29. }   
  30.   
  31. int main(){   
  32.   backtrack(0);   
  33.   return 0;   
  34. }  

这两个问题很有代表性,事实上有许多问题都是从这两个问题演变而来的。第一个问题,它穷举了所有问题的子集,这是所有第一种类型的基础,第二个问题,它给出了穷举所有排列的方法,这是所有的第二种类型的问题的基础。理解这两个问题,是回溯算法的基础.
下面看看一个较简单的问题:
整数集合s和一个整数sum,求集合s的所有子集su,使得su的元素之和为sum。
这个问题很显然是个子集合问题,我们很容易就可以把第一段代码修改成这个问题的代码:
Cpp代码 复制代码
  1. int sum = 10;   
  2. int r = 0;   
  3. int s[5] = {1,3,6,4,2};   
  4. int x[5];   
  5. int  N = 5;   
  6.   
  7. void print(){   
  8.    for(int j = 0; j < N; j++)   
  9.     if(x[j] == 1)   
  10.        cout << s[j] << " ";   
  11.    cout << endl;   
  12. }   
  13. void sumSet(int i){   
  14.     if(i >= N){   
  15.         if(sum == r) print();   
  16.         return;   
  17.     }   
  18.     if(r < sum){//搜索右子树   
  19.       r += s[i];   
  20.       x[i] = 1;   
  21.       sumSet(i+1);   
  22.       r -= s[i];    
  23.     }   
  24.     x[i] = 0;//搜索左子树   
  25.     sumSet(i+1);   
  26. }   
  27.   
  28. int main(){   
  29.   sumSet(0);   
  30.   return 0;   
  31. }  

                                                        八皇后问题

八皇后问题是一个古老而著名的问题,是回溯算法的典型例题。该问题是十九世纪著名的数学家高斯1850年提出:在8X8格的国际象棋上摆放八个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上.
问题分析:
第一步 定义问题的解空间
这个问题解空间就是8个皇后在棋盘中的位置.
第二步 定义解空间的结构
可以使用8*8的数组,但由于任意两个皇后都不能在同行,我们可以用数组下标表示
行,数组的值来表示皇后放的列,故可以简化为一个以维数组x[9]。
第三步 以深度优先的方式搜索解空间,并在搜索过程使用剪枝函数来剪枝
根据条件:x[i] == x[k]判断处于同一列
abs(k-i) == abs(x[k]-x[i]判断是否处于同一斜线
我们很容易写出剪枝函数:
Cpp代码 复制代码
  1. bool canPlace(int k){   
  2.     for(int i = 1; i < k; i++){   
  •         //判断处于同一列或同一斜线   
  •        if(x[i] == x[k] || abs(k-i) == abs(x[k]-x[i]))              return false;   
  •     }   
  •     return true;   
  • }  


然后我们按照回溯框架一,很容易写出8皇后的回溯代码:

Cpp代码 复制代码
  1. void queen(int i){   
  2.     if(i > 8){   
  3.         print();   
  4.         return;   
  5.     }   
  6.     for(int j = 1; j <= 8; j++){   
  7.       x[i] = j;//记录所放的列   
  8.       if(canPlace(i)) queen(i+1);   
  9.     }   
  10. }  


整个代码:

Cpp代码 复制代码
  1. #include<iostream>   
  2. #include<cmath>   
  3. using namespace std;   
  4.   
  5. int x[9];   
  6. void print(){   
  7.     for(int i = 1; i <= 8; i++)   
  8.            cout << x[i] << " ";   
  9.     cout << endl;   
  10. }   
  11.   
  12. bool canPlace(int k){   
  13.     for(int i = 1; i < k; i++){   
  14.             //判断处于同一列或同一斜线   
  15.        if(x[i] == x[k] || abs(k-i) == abs(x[k]-x[i]))    
  16.            return false;   
  17.     }   
  18.     return true;   
  19. }   
  20.   
  21. void queen(int i){   
  22.     if(i > 8){   
  23.         print();   
  24.         return;   
  25.     }   
  26.     for(int j = 1; j <= 8; j++){   
  27.       x[i] = j;   
  28.       if(canPlace(i)) queen(i+1);   
  29.     }   
  30. }   
  31.   
  32. int main(){   
  33.   queen(1);   
  34.   return 0;   
  35. }  

                                      0-1背包问题

0-1背包问题:给定n种物品和一背包.物品i的重量是wi, 其价值为ui,背包的容量为C.
问如何选择装入背包的物品,使得装入背包中物品的总价值最大?
分析:
0-1背包是子集合选取问题,一般情况下0-1背包是个NP问题.
第一步 确定解空间:装入哪几种物品
第二步 确定易于搜索的解空间结构:
可以用数组p,w分别表示各个物品价值和重量。
用数组x记录,是否选种物品
第三步 以深度优先的方式搜索解空间,并在搜索的过程中剪枝
我们同样可以使用子集合问题的框架来写我们的代码,和前面子集和数问题相差无几。
Cpp代码 复制代码
  1. #include<iostream>   
  2. #include<algorithm>   
  • using namespace std;   
  •   
  • class Knapsack{   
  • public:   
  •     Knapsack(double *pp,double *ww,int nn,double cc){   
  •        p = pp;   
  •        w = ww;   
  •        n = nn;   
  •        c = cc;   
  •        cw = 0;   
  •        cp = 0;   
  •        bestp = 0;   
  •        x = new int[n];   
  •        cx = new int[n];   
  •     }   
  •   
  •     void knapsack(){   
  •        backtrack(0);   
  •      }   
  •   
  •     void backtrack(int i){//回溯法   
  •         if(i > n){   
  •             if(cp > bestp){   
  •                bestp = cp;   
  •                for(int i = 0; i < n; i++)   
  •              x[i] = cx[i];   
  •             }   
  •             return;   
  •         }   
  •   
  •         if(cw + w[i] <= c){//搜索右子树   
  •           cw += w[i];   
  •           cp += p[i];   
  •           cx[i] = 1;   
  •           backtrack(i+1);   
  •           cw -= w[i];   
  •           cp -= p[i];   
  •         }   
  •         cx[i] = 0;   
  •         backtrack(i+1);//搜索左子树   
  •     }   
  •   
  •     void printResult(){   
  •        cout << "可以装入的最大价值为:" << bestp << endl;   
  •        cout << "装入的物品依次为:";   
  •        for(int i = 0; i < n; i++){   
  •          if(x[i] == 1)   
  •              cout << i+1 << " ";   
  •        }   
  •        cout << endl;   
  •     }   
  •   
  • private:   
  •    double *p,*w;   
  •    int n;   
  •    double c;   
  •    double bestp,cp,cw;//最大价值,当前价值,当前重量   
  •    int *x,*cx;   
  • };   
  •   
  • int main(){   
  •   double p[4] = {9,10,7,4},w[4] = {3,5,2,1};   
  •     Knapsack ks = Knapsack(p,w,4,7);   
  •     ks.knapsack();   
  •   ks.printResult();   
  •   return 0;   
  • }  

注:

本文章来自:http://fuliang.javaeye.com/blog/164686

 

 

 

回溯算法也叫试探法,它是一种系统地搜索问题的解的方法。回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。

纠错  编辑摘要

回溯算法 - 概念

 

回溯算法也叫试探法,它是一种系统地搜索问题的解的方法。回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。用回溯算法解决问题的一般步骤为:

1、定义一个解空间,它包含问题的解。 

2、利用适于搜索的方法组织解空间。

3、利用深度优先法搜索解空间。

4、利用限界函数避免移动到不可能产生解的子空间。

问题的解空间通常是在搜索问题的解的过程中动态产生的,这是回溯算法的一个重要特性。

   

回溯算法 - 实例分析

 

例:骑士游历(1997年全国青少年信息学(计算机)奥林匹克分区联赛高中组复赛试题第三题)

设有一个n×m的棋盘(2(2,3)->(4,4)

若不存在路径,则输出"no"

任务2:当n,m 给出之后,同时给出马起始的位置和终点的位置,试找出从起点到终点的所有路径的数目(若不存在从起点到终点的路径,则输出0)。

    例如:

     (n=10,m=10),(1,5)(起点),(3,5)(终点),

    输出:

     2(即由(1,5)到(3,5)共有2条路径,如图4)

分析:为了解决这个问题,我们将棋盘的横坐标规定为x,纵坐标规定为y,对于一个m×n的棋盘,x的值从1到m,y的值从1到n。棋盘上的每一个点,可以表示为:(x坐标值,y坐标值),即用它所在的列号和行号来表示,比如(3,5)表示第3列和第5行相交的点。

要寻找从起点到终点的路径,我们可以使用回溯算法的思想。首先将起点作为当前位置。按照象棋马的移动规则,搜索有没有可以移动的相邻位置。如果有可以移动的相邻位置,则移动到其中的一个相邻位置,并将这个相邻位置作为新的当前位置,按同样的方法继续搜索通往终点的路径。如果搜索不成功,则换另外一个相邻位置,并以它作为新的当前位置继续搜索通往终点的路径。

为简单起见,先看4×4的棋盘,如图3。首先将起点(1,1)作为当前位置,按照象棋马的移动规则,可以移动到(2,3)和(3,2)。假如移动到(2,3),以(2,3)作为新的当前位置,又可以移动到 (4,4)、(4,2)和(3,1)。继续移动,假如移动到(4,4),将(4,4)作为新的当前位置,这时候已经没有可以移动的相邻位置了。 (4,4)已经是终点,对于任务一,我们已经找到了一条从起点到终点的路径,完成了任务,可以结束搜索过程。但对于任务二,我们还不能结束搜索过程。从当前位置(4,4)回溯到(2,3),(2,3)再次成为当前位置。从(2,3)开始,换另外一个相邻位置移动,移动到(4,2),……然后是(3,1)。(2,3)的所有相邻位置都已经搜索过。从(2,3)回溯到(1,1),(1,1)再次成为当前位置。从(1,1)开始,还可以移动到(3,2),从(3,2)继续移动,可以移动到(4,4),这时候,所有可能的路径都已经试探完毕,搜索过程结束。


如果用树形结构来组织问题的解空间(如图5),那么寻找从起点到终点的路径的过程,实际上就是从根结点开始,使用深度优先方法对这棵树的一次搜索过程。

对于任务一,要寻找一条从起点到终点的路径,为了确保路径上的点不被丢失,我们可以在程序中设置一个栈,用它来保存搜索过程中象棋马移动到的每一个点。

                                    算法程序如下:
                               

回溯算法回溯算法

在求精以上算法程序的过程中还存在这样一个问题:怎样从当前位置herep移动到它的相邻位置?题目规定,象棋马有四种移动方法,如图2。从左到右,我们分别给四种移动方法编号为0、1、2、3;对每种移动方法,可以用横坐标和纵坐标从起点到终点的偏移值来表示,并将这些表示移动方法的偏移值保存在一个数组pyz中,如下表:

                                

回溯算法回溯算法

 

从当前位置搜索它的相邻位置的时候,为了便于程序的实现,我们可以按照移动编号的固定顺序来进行,比如,首先尝试第0种移动方法,其次尝试第1种移动方法,再次尝试第2种移动方法,最后尝试第3种移动方法。

 
对于任务二,要找出从起点到终点的所有路径的数目。我们可以设置一个统计变量ljs。在搜索解空间的过程中,每当搜索到一条从起点到终点的路径,就让统计变量ljs的值增1。这样,当对解空间的搜索过程结束之后,统计变量ljs的值就是问题的答案。

 

回溯算法 - 应用举例

 

1.跳棋问题
33个方格顶点摆放着32枚棋子,仅中央的顶点空着未摆放棋子。下棋的规则是任一棋子可以沿水平或成垂直方向跳过与其相邻的棋子,进入空着的顶点并吃掉被跳过的棋子。试设计一个算法找出一种下棋方法,使得最终棋盘上只剩下一个棋子在棋盘中央。

算法实现提示
利用回溯算法,每次找到一个可以走的棋子走动,并吃掉。若走到无子可走还是剩余多颗,则回溯,走下一颗可以走动的棋子。当吃掉31颗时说明只剩一颗,程序结束。

2.中国象棋马行线问题

中国象棋半张棋盘如图1(a)所示。马自左下角往右上角跳。今规定只许往右跳,不许往左跳。比如
图4(a)中所示为一种跳行路线,并将所经路线打印出来。打印格式为:
0,0->2,1->3,3->1,4->3,5->2,7->4,8…

回溯算法回溯算法

算法分析:

如图1(b),马最多有四个方向,若原来的横坐标为j、纵坐标为i,则四个方向的移动可表示为:

1: (i,j)→(i+2,j+1); (i<3,j<8)            2: (i,j)→(i+1,j+2); (i<4,j<7)

3: (i,j)→(i-1,j+2); (i>0,j<7)             4: (i,j)→(i-2,j+1); (i>1,j<8)

搜索策略:
S1:A[1]:=(0,0);
S2:从A[1]出发,按移动规则依次选定某个方向,如果达到的是(4,8)则转向S3,否则继续搜索下
一个到达的顶点;
S3:打印路径。

算法设计:

procedure try(i:integer); {搜索}
var j:integer;
begin
for j:=1 to 4 do {试遍4个方向}
if 新坐标满足条件 then
begin
记录新坐标;
if 到达目的地 then print {统计方案,输出结果}
else try(i+1); {试探下一步}
退回到上一个坐标,即回溯;
end;
end;

参考程序:

program exam2;
const x:array[1..4,1..2] of integer=((2,1),(1,2),(-1,2),(-2,1)); {四种移动规则}
var t:integer; {路径总数}
a:array[1..9,1..2] of integer; {路径}
procedure print(ii:integer); {打印}
var i:integer;
begin
inc(t); {路径总数}
for i:=1 to ii-1 do
write(a[i,1],’,’,a[i,2],’-->’);
writeln(’4,8’,t:5);
readln;
end;
procedure try(i:integer); {搜索}
var j:integer;
begin
for j:=1 to 4 do
if (a[i-1,1]+x[j,1]>=0) and (a[i-1,1]+x[j,1]=0) and (a[i-1,2]+x[j,2]<=8) then
begin
a[i,1]:=a[i-1,1]+x[j,1];
a[i,2]:=a[i-1,2]+x[j,2];
if (a[i,1]=4) and (a[i,2]=8) then print(i)
else try(i+1); {搜索下一步}
a[i,1]:=0;a[i,2]:=0
end;
end;

BEGIN         {主程序}
try(2);
END.

 

 

 

 

回溯算法

 

有许多问题,当需要找出它的解集或者要求回答什么解是满足某些约束条件的最佳解时,往往要使用回溯法。

回溯法的基本做法是搜索,或是一种组织得井井有条的,能避免不必要搜索的穷举式搜索法。这种方法适用于解一些组合数相当大的问题。

回溯法在问题的解空间树中,按深度优先策略,从根结点出发搜索解空间树。算法搜索至解空间树的任意一点时,先判断该结点是否包含问题的解。如果肯定不包含,则跳过对该结点为根的子树的搜索,逐层向其祖先结点回溯;否则,进入该子树,继续按深度优先策略搜索。

问题的解空间

•问题的解向量:回溯法希望一个问题的解能够表示成一个n元式(x1,x2,…,xn)的形式。

•显约束:对分量xi的取值限定。

•隐约束:为满足问题的解而对不同分量之间施加的约束。

•解空间:对于问题的一个实例,解向量满足显式约束条件的所有多元组,构成了该实例的一个解空间。

基本思想

(1)针对所给问题,定义问题的解空间;

(2)确定易于搜索的解空间结构;

(3)以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。

 

n皇后问题

回溯算法

对于n皇后问题而言,我们很难找出很合适的方法来快速的得到解,因此,我们只能采取最基本的枚举法来求解。

但我们知道,在n×n的棋盘上放置n个棋子的所有放置方案有种,而这个数字是非常庞大的,直接枚举肯定会超时。

回溯算法

既然回溯算法是由一个节点开始逐步扩展的,因此我们采用把皇后一个一个的放到棋盘上的方式来分析问题。

首先要把第一个皇后放到棋盘上由于第一个皇后有n列可以放,因此可扩展出n种情况。先选其中一列放下这个皇后;

然后开始放第二个皇后。同样第二个皇后也有n列可以放,因此也能扩展出n种情况,但第二个皇后可能会和第一个皇后发生攻击,而一旦发生攻击,就没有必要往下扩展第三个皇后,而如果没有发生攻击,则继续放第三个皇后;

依此类推,直到n个皇后全都放下后,即得到一组可行解。

扩展全部完成后即可得到结果。

回溯算法

n皇后问题的解空间就应该是1~n全排列的一部分。

解空间的长度是n。

解空间的组织形式是一棵n叉树,一个可行的解就是从根节点到叶子节点的一条路径。

控制策略则是当前皇后与前面所有的皇后都不同列和不同对角线。

1.分支限界法与回溯法的区别

分支定界 (branch and bound) 算法是一种在问题的解空间树上搜索问题的解的方法。但与回溯算法不同,主要包括:

(1)求解目标:回溯法的求解目标是找出解空间树中满足约束条件的所有解,而分支限界法的求解目标则是找出满足约束条件的一个解,或是在满足约束条件的解中找出在某种意义下的最优解。

(2)搜索方式的不同:回溯法以深度优先的方式搜索解空间树,而分支限界法则以广度优先或以最小耗费优先的方式搜索解空间树。

2.分支限界法的搜索策略

利用分支定界算法对问题的解空间树进行搜索,它的搜索策略是: 
(1)产生当前扩展结点的所有孩子结点; 
(2)在产生的孩子结点中,抛弃那些不可能产生可行解(或最优解)的结点; 
(3)将其余的孩子结点加入活结点表; 
(4)从活结点表中选择下一个活结点作为新的扩展结点。 
如此循环,直到找到问题的可行解(最优解)或活结点表为空。 
从活结点表中选择下一个活结点作为新的扩展结点,根据选择方式的不同,分支定界算法通常可以分为两种形式:
(1)FIFO(First In First Out) 分支定界算法:按照先进先出原则选择下一个活结点作为扩展结点,即从活结点表中取出结点的顺序与加入结点的顺序相同。 
(2)最小耗费或最大收益分支定界算法:在这种情况下,每个结点都有一个耗费或收益。如果要查找一个具有最小耗费的解,那么要选择的下一个扩展结点就是活结点表中具有最小耗费的活结点;如果要查找一个具有最大收益的解,那么要选择的下一个扩展结点就是活结点表中具有最大收益的活结点。

3.分支限界法的基本思想

分支限界法常以广度优先或以最小耗费(最大效益)优先的方式搜索问题的解空间树。

在分支限界法中,每一个活结点只有一次机会成为扩展结点。活结点一旦成为扩展结点,就一次性产生其所有儿子结点。在这些儿子结点中,导致不可行解或导致非最优解的儿子结点被舍弃,其余儿子结点被加入活结点表中。

此后,从活结点表中取下一结点成为当前扩展结点,并重复上述结点扩展过程。这个过程一直持续到找到所需的解或活结点表为空时为止。

0-1背包问题:

分支限界算法

旅行售货员问题:

分支限界算法

 

回溯的基本思想是:
    从一个给定的起始位置,我们希望到达一个目的位置。
我们重复地进行选择(可能是猜测)下一个位置应当是什么。如果一个给定的选择是有效的,即新的位置可能位于通向目的位置的途径中,则前进到这个新的位置,然后继续。 如果一个给定的选择通向了死胡同,则回到前面的位置,进行其他的选择。
回溯就是通过一系列位置选择到达目的位置,并且在不能到达目的位置时反向退回的策略。

通俗的讲法:从一条路往前走,能进则进,不能进则退回来,换一条路再试。

算法书上可能这样说:回溯法是在包含问题的所有解的解空间树(或森林)中,按照深度优先的策略,从根结点出发搜索解空间树。算法搜索至解空间树的任一结点时,总是先判断该结点是否满足问题的约束条件。如果满足进入该子树,继续按照深度优先的策略进行搜索。否则,不去搜索以该结点为根的子树,而是逐层向其祖先结点回溯。

 
    回溯法在用来求解问题的所有解时,要回溯到根,且根结点的所有可行的子树都已被搜索遍才结束。而回溯法在用来求解问题的任一解时,只要搜索到问题的一个解就可以结束。适用于解决一些最优化问题。

二. 算法设计过程

(1) 确定问题的解空间   
应用回溯法解决问题时,首先应明确定义问题的解空间。问题的解空间应至少包含问题的一个最优解。

(2) 确定结点的扩展规则
约束条件。

(3) 搜索解空间
   回溯算法从开始结点(根结点)出发,以深度优先的方式搜索整个解空间。这个开始结点就成为一个活结点,同时也成为当前的扩展结点。在当前的扩展结点处,搜索向纵深方向移至一个新结点。这个新结点就成为一个新的活结点,并成为当前扩展结点。如果在当前的扩展结点处不能再向纵深方向移动,则当前扩展结点就成为死结点。此时,应该往回移动(回溯)至最近的一个活结点处,并使这个活结点成为当前的扩展结点。回溯法即以这种工作方式递归地在解空间中
搜索,直至找到所要求的解或解空间中已没有活结点时为止。

三. 算法框架
(1) 问题框架

设问题的解是一个n维向量(a1,a2,...,an),约束条件是ai(i=1,2,...,n)之间满足某种条件,记为f(ai)。

(2) 非递归回溯框架
int a[n], i;
i=1;
while(i>0(有路可走) and [未达到目标]){ //还未回溯到头
  if(i>n){ //搜索到叶结点
    搜索到一个解,输出;
  }else{
   a[i]第一个可能的值;
   while(a[i]不满足约束条件且在搜索空间内)
    a[i]下一可能的值;
    if(a[i]在搜索空间内){
     标识占用的资源;
      i = i+1; //扩展下一个结点
   }else{
     清理所占的状态空间;
     i = i-1; //回溯
   }
 }
}

(3)递归算法框架
int a[n];
try(int i){
   if(i>n){
      输出结果;
  }else{
     for(j=下界; j<=上界; j++){//枚举i所有可能的路径
       if(f(j)){ //满足限界函数和约束条件
          a[i] = j;
          ... //其他操作
         try(i+1);
          a[i] = 0; //回溯前的清理工作(如a[i]置空)
       }
     }
   }
}

四、例
1. 问题描述:输出自然数1到n的所有不重复的排列,即n的全排列。

2. 问题分析:
(1) 解空间: n的全排列是一组n元一维向量(x1, x2, x3, ... , xn),搜索空间是:1<=xi<=n i=1,2,3,...,n

(2) 约束条件: xi互不相同。技巧:采用"数组记录状态信息", 设置n个元素的一维数组d,其中的n个元素用来记录数据
1~n的使用情况,已使用置1,未使用置0

3. Java代码:

public class NAllArrangement { private int count = 0; // 解数量 private int n; // 输入数据n private int[] a; // 解向量 private int[] d; // 解状态 public static void main(String[] args) { //测试例子 NAllArrangement na = new NAllArrangement(5, 100); na.tryArrangement(1); } public NAllArrangement(int _n, int maxNSize) { n = _n; a = new int[maxNSize]; d = new int[maxNSize]; } public void tryArrangement(int k) { for (int j = 1; j <= n; j++) { // 搜索解空间 if (d[j] == 0) { a[k] = j; d[j] = 1; } else { // 表明j已用过 continue; } if (k < n) { // 没搜索到底 tryArrangement(k + 1); } else { count++; output(); // 输出解向量 } d[a[k]] = 0; // 回溯 } } private void output() { System.out.println("count = " + count); for (int j = 1; j <= n; j++) { System.out.print(a[j] + " "); } System.out.println(""); } }

运行结果:
C:\java>java NAllArrangement
count = 1
1 2 3 4 5
count = 2
1 2 3 5 4
count = 3
1 2 4 3 5
count = 4
1 2 4 5 3
count = 5
1 2 5 3 4
count = 6
1 2 5 4 3
count = 7
1 3 2 4 5
count = 8
1 3 2 5 4
count = 9
1 3 4 2 5
。。。。。。。。

 

posted on 2013-02-19 21:14  joygpwan  阅读(2869)  评论(0编辑  收藏  举报

导航