关闭

全面解析回溯法:算法框架与问题求解

标签: 算法
705人阅读 评论(0) 收藏 举报
分类:

什么是回溯法?

回溯法的通用框架

利用回溯法解决问题

总结与探讨

附:《算法设计手册》第7章其余面试题解答

 

  摘了一段来自百度百科对回溯法思想的描述:

在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。当探索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯。(其实回溯法就是对隐式图的深度优先搜索算法)。 若用回溯法求问题的所有解时,要回溯到根,且根结点的所有可行的子树都要已被搜索遍才结束。 而若使用回溯法求任一个解时,只要搜索到问题的一个解就可以结束。

   可以把回溯法看成是递归调用的一种特殊形式。其实对于一个并非编程新手的人来说,从来没使用过回溯法来解决问题的情况是很少见的,不过往往是“对症下药”,针对特定的问题进行解答。这些天看了《算法设计手册》回溯法相关内容,觉得对回溯法抽象的很好。如果说算法是解决问题步骤的抽象,那么这个回溯法的框架就是对大量回溯法算法的抽象。本文将对这个回溯法框架进行分析,并且用它解决一系列的回溯法问题。文中的回溯法采用递归形式。

  在进一步的抽象之前,先来回顾一下DFS算法。对于一个无向图如下图左,它的从点1开始的DFS过程可能是下图右的情况,其中实线表示搜索时的路径,虚线表示返回时的路径:

 

  可以看出,在回溯法执行时,应当:保存当前步骤,如果是一个解就输出;维护状态,使搜索路径(含子路径)尽量不重复。必要时,应该对不可能为解的部分进行剪枝(pruning)。

  下面介绍回溯法的一般实现框架:

复制代码
bool finished = FALSE; /* 是否获得全部解? */
backtrack(int a[], int k, data input)
{
    int c[MAXCANDIDATES]; /*这次搜索的候选 */
    int ncandidates; /* 候选数目 */
    int i; /* counter */
    if (is_a_solution(a,k,input))
    process_solution(a,k,input);
    else {
        k = k+1;
        construct_candidates(a,k,input,c,&ncandidates);
        for (i=0; i<ncandidates; i++) {
            a[k] = c[i];
            make_move(a,k,input);
            backtrack(a,k,input);
            unmake_move(a,k,input);
            if (finished) return; /* 如果符合终止条件就提前退出 */
        }
    }
}
复制代码

  对于其中的函数和变量,解释如下:

  a[]表示当前获得的部分解;

  k表示搜索深度;

  input表示用于传递的更多的参数;

  is_a_solution(a,k,input)判断当前的部分解向量a[1...k]是否是一个符合条件的解

  construct_candidates(a,k,input,c,ncandidates)根据目前状态,构造这一步可能的选择,存入c[]数组,其长度存入ncandidates

  process_solution(a,k,input)对于符合条件的解进行处理,通常是输出、计数等

  make_move(a,k,input)unmake_move(a,k,input)前者将采取的选择更新到原始数据结构上,后者把这一行为撤销。

 

  其实回溯法框架就是这么简单,通过这个框架,足以解决很多回溯法问题了。不信?下面展示一下:

  (由于后文所有代码均为在C中编写,因此bool类型用int类型代替,其中0为FALSE,非0为TRUE。)

 

问题1:求一个集合的所有子集

解答:

  将3个主要的函数实现,这个问题就解决了。由于每次for循环中a[k]=c[i],这是唯一的改动,并且在下次循环时会被覆盖,不需要专门编写make_move()和make_unmove()。

复制代码
int is_a_solution(int a[],int k, data input)
{
    return k==input;
}

void construct_candidates(int a[],int k, data input, int c[],int *ncandidates) 
{
    c[0] = 1;
    c[1] = 0;
    *ncandidates = 2;
}

void process_solution(int a[],int k,data input)
{
    int i;
    printf("{");
    for(i=1;i<=k;i++)
        if(a[i])
            printf(" %d",i);
    printf(" }\n");
}
复制代码

  候选构造函数construct_candidates()相对简单,因为对每个集合中的元素和一个特定子集,只有出现和不出现这两种可能。

  调用这个函数只需:

generate_subsets(int n)
{
  int a[NMAX];
  backtrack(a,0,n);
}

扩展:

  Skiena在《算法设计手册》第14章组合算法部分介绍了生成子集的三种方式:按排序生成、二进制位变换、格雷码。上面这个算法是二进制变换的一种,格雷码生成可以参考后面习题解答的7-18;而按排序生成则比较复杂,它按特定顺序生成,如{1,2,3}生成顺序为{} , {1}, {1, 2}, {1, 2, 3}, {1, 3}, {2}, {2, 3},并且建议除非有这种要求,否则不要使用这个方式。 

 

 

问题2:输出不重复数字的全排列

解答:

  与上1题不同的是,由于不能重复出现,每次选择的元素都将影响之后的候选元素集合。构造候选时应从之前获得的部分解中获取信息,哪些元素是可以后续使用的,哪些是不可以的:

复制代码
void construct_candidates(int a[],int k, data input, int c[],int *ncandidates) 
{
    int i;
    int in_perm[NMAX+1];
    for(i=1;i<=NMAX;i++)
        in_perm[i] = 0;
    for(i=1;i<k;i++)
        in_perm[a[i]] = 1;
    *ncandidates = 0;
    for(i=1;i<=input;i++)
        if(!in_perm[i]) {
            c[*ncandidates] = i;
            *ncandidates += 1;
        }
}
复制代码

  不过这里可以看出一个问题,如果每次都是需要选择分支时构造候选元素,势必会造成浪费。这里仅仅是一个展示,如果提高效率,可以把解空间和原空间优化到一起,这样不必每次都生成解空间。下面的代码是对这个问题更好的也是更常见的解法,我相信不少人都写过,并对上一种看似复杂的解法表示不屑一顾:

复制代码
void permutaion(int *array,int k,int length)
{
    int i;
    if (length==k) {
        for(i=0;i<length;i++)
            printf("%d ",array[i]);
        printf("\n");
        return;
    }
                
    for(i=k;i<length;i++) {
        swap(&array[i],&array[k]);
        permutaion(array,k+1,length);
        swap(&array[i],&array[k]);
    }
}
复制代码

  但仔细观察这个解法,可以发现其实它暗含了is_a_solution()、construct candidates()、process_solution()、make_move()和unmake_move()这些步骤,它其实是一般的回溯法框架的简化和优化。

 

问题3:求解数独——剪枝的示范

解答:

  由于填入的数字涉及到横纵两个坐标,单纯的解向量a[]不能满足保存解的要求了。仅a[k]表示填入的值,定义一个结构以保存数独的当前状态和第k步时填入点的坐标:

复制代码
#define DIMENSION 9
#define NCELLS DIMENSION*DIMENSION
typedef struct {
    int x,y;
} point;

typedef struct {
    int m[DIMENSION+1][DIMENSION+1];
    int freecount;
    point move[NCELLS+1];
} boardtype;

typedef boardtype* data;
复制代码

  同时把获取下一步候选的construct_candidates()分解为两步:获取下一步填入点的坐标next_square()、获取该点可以填入的数值possible_values()。对于这两步需要进行一些探讨:

  next_square()可以采取任意取一个没有填入的点的随机策略(arbitrary);而更有效的策略是最大约束(Most Constrained),即取的点行、列以及所在3*3方阵点数最多的点,这样它的约束最多,填入的数字的可能性最少。

  possible_values()也可以采用两种策略:局部计数(local count),即只要满足行、列、3*3方阵内部都不冲突,就作为可能填入的数值;预测(look ahead),即对填入的数,预先找下一步时是否所有空都可填入至少一个数字来确认这个数是否可以被填入。《算法设计手册》作者认为,我们如果采用最大约束和局部计数策略,回溯过程就已经暗含了预测(失败时会回退),我曾经试过,专门写一个look ahead函数是得不偿失的,它并不比直接回溯开销小,甚至更大。

  因此,为了提高效率,next_square()采取最大约束策略,possible_values()采取暗含的预测策略。为了计算出最大约束的点,我还写了一个evaluate()函数用来计算某个未填点的得分,得分越大说明约束越强,约束最强的点将成为候选点。这个evaluate()不是很严格,因为它重复计算了一些点,不过影响不大。这两个策略的采取可以看作是剪枝的过程。剪枝是回溯法的重要加速途径,好的剪枝策略能够提高回溯法的运行速度,这是回溯法与暴力算法的一大区别。

 construct_candidates()、next_square()、possible_values()

  由于要对定义的数据结构进行修改,make_move()和unmake_move()也需要进行实现了。

 make_move()和unmake_move()

  is_a_solution()是对freecount是否为0的判断,process_solution()可以用作输出填好的数独,这两个函数的解法略过。而backtrack()函数和基本框架相比,看上去没多大的区别。

 backtrack of sudoku

  经测试,《算法设计手册》上的Hard级别的数独,我的这个程序可以获得和原书同样的解。

附注:

  这里是以数独为例展示回溯法。而如果需要专门进行数独求解,可以试试DancingLinks,有一篇文章对其进行介绍,感兴趣的读者可以自行查阅。另外有关DancingLinks的性能,可以参阅:算法实践——舞蹈链(Dancing Links)算法求解数独

 

问题4:给定一个字符串,生成组成这个字符串的字母的全排列(《算法设计手册》面试题7-14)

解答:

  如果字符串内字母不重复,显然和问题2一样。

  如果字符串中有重复的字母,就比较麻烦了。不过套用回溯法框架仍然可以解决,为了简化候选元素的生成,将所有候选元素排列成数组,形成“元素-值”对,其中值代表这个元素还能出现几次,把ASCII码的A~Z、a~z映射为数组下标0~51。实现如下:

 问题4解法

显然,construct_candidates()已经化入了backtrace()内部,而且这也是一个对如何将候选也作为参数传递给下一层递归的很好的展示

 

问题5:求一个n元集合的k元子集(n>=k>0)。(《算法设计手册》面试题7-15)

解答:

  如果想采用问题1的解法,需要稍作修改,使得遍历至叶结点(也即所有元素都进行标记是否在集合中)时,判断是不是一个解,即元素数目是否为k。满足才能输出。

 问题5解法完整示例

 

问题6:电话号码对应字符串

  电话键盘上有9个数字,其中2~9分别代表了几个字母,如2:ABC,3:DEF......等等。给定一个数字序列,输出它所对应的所有字母序列。(《算法设计手册》面试题7-17,以及《编程之美》3.2“电话号码对应英语单词”)

解答:

  这个问题在回溯法里已经很简单了,因为每一步的选择都不影响下一步的选择。稍微要注意的一点是如何把数字与多个字母的对应关系告诉程序。这个存储结构和相应的construct_candidates()可能是这样的:

复制代码
static char ch[10][4] =
{
    "",
    "",
    "ABC",
    "DEF",
    "GHI",
    "JKL",
    "MNO",
    "PQRS",
    "TUV",
    "WXYZ",
};

static int total[10] ={0,0,3,3,3,3,3,4,3,4}; 

void construct_candidates(int a[],int k,data input, int *c,int *ncandidates)
{
    *c = input[k-1] - '0';
    *ncandidates = total[*c];
    return;
}
复制代码

  而backtrack()中填充解空间a[]则是这样的:

a[k] = ch[c][i];

  你会发现,backtrack()和《编程之美》3.2节解法二的RecurisiveSearch()实质是一样的:都是回溯法嘛。当然,能简化还是应该尽量简化的。

复制代码
//c[i][j] 数字i对应的第j个字母
//total[i] 数字i一共对应几个字母
//number[] 待转换的数字序列
//answer[] 解空间
//index 当前处理的数字的位置
//n 电话号码总长度

void recursive(int * number, int * answer, int index, int n)
{
    if(index == n)
    {
        for(int i=0;i<n;i++)
            printf("%c", c[number[i]][answer[i]]);
        printf("\n");
        return;
    }
    for(answer[index]=0;answer[index]<total[number[index]];answer[index]++)
    {
        recursive(number, answer, index+1, n);
    }
}
复制代码

 

问题7:一摞烙饼的排序(《编程之美》1.3)

  假设有一堆烙饼,大小不一,需要把它们摆成从下到上由大到小的顺序。每次翻转只能一次翻转最上面的几个烙饼,把它们上下颠倒。反复多次可以使烙饼有序。那么,最少应该翻转几次呢?

解答:

  根据《编程之美》的分析可知,对于n个烙饼,如果每次都把最大的先翻到最上面,然后再把它翻到最下面,这样就只用处理最上面的(n-1)个。而翻完n-1个时,最小的必然已经在上面,因此,翻转的上界是2(n-1)。

  为了在搜索解的时候剪枝,如果当前翻转次数多于上界2(n-1),则必然不是最少的,应该直接返回。

  同时,当烙饼内部几个部分分别有序时(比如3、4、5已经连在一起、9、10已经连在一起),不应该拆散它们,而是应该视为一个整体进行翻转。这样,每次把最大的和次大的翻在一起,肯定要优于上界。把这个不怎么紧密的下界记为LowerBound,值为顺序相邻两个烙饼大小不相邻顺序的对数(pairs,不是log)。

  这样,有了粗略的上界和下界,就可以进行剪枝了。为了更有效的剪枝,可以把当前翻转步数大于已记录解的翻转步数的所有解也给剪掉。

  套用回溯法的框架,以下是求解代码。虽然和《编程之美》上的C++的面向对象版本看上去不太一样,但实质是一样的:

 烙饼排序

  其实理解这个算法的关键是如何把“翻转烙饼”的过程抽象成数据结构的改变,回溯法倒不是那么重要。

 

问题8:8皇后问题

  国际象棋棋盘上有8*8个格子。现在有8枚皇后棋子,一个格子只能放一个棋子,求解所有放法,使得这些棋子不同行、不同列、且不在对角线上((4,5)和(5,6)就是在对角线上的情况,不合法)。

解答:

  上面练习了那么多回溯法的问题,我相信能看到这里的人水平已经足以解决这个问题了。按行放置可以保证棋子不同行,对于每种放置可能,检查是否与上面各行的棋子是否同列、同对角线。都不满足的才能选作此次的决策即可。

 8皇后问题

 

总结与探讨

  通过以上的实例,可以发现回溯法框架确实能够解决许多形态各异的问题,这也得归功于这个框架足够抽象而不限于具体问题的求解,其通用性毋庸置疑。

  然而如果一个问题看到之后就有了思路,并能直接写出类似于问题2的精简版的情况又如何呢?这种情况下当然就没必要再去套用回溯法框架了,因为你已经把这个框架的步骤内化到自己的思考中并能在这个问题上运用自如了,这一点是值得高兴的。这时回溯法框架对于你来说只是用于检查代码正确性的一种额外验证方式罢了,没必要退而求其次。

  当你思路比较混乱,不知如何下手时我才建议搬出回溯法框架进行分析和套用。不过从问题7烙饼排序中可以看到,有时思路的不清晰往往是对实际问题的抽象不够,而不是编写回溯法解决本身的问题。

  编写回溯法时应该注意尽可能剪枝,同时维护好构造候选时所用的数据结构。 

 

附:《算法设计手册》第7章其余面试题解答

7-16.

  请用给定字符串中的字母重新组合成在字典中的单词。比如Steven Skiena可以重组为Vainest Knees。

解答:

  虽然通过回溯法可以把所有情况列出并与字典对照,但这未免太没有效率了。

  更快的方法是把给定字符串和所有字典单词排序成字母序,比如apple变成aelpp,再对排序后的字符串在排序后的字典进行搜索。这是个变位词的变形,变位词的处理可以参考:http://www.cnblogs.com/wuyuegb2312/p/3139926.html#title21

 

7-18.

  一间能容纳n个人的空房,房外有n个人。你站在门口,可以选择让门外的一个人进屋,也可以选择让屋内的人出来一个。请输出所有的2n种屋中人的出现情况的可能,并且这些情况是相邻的(上一种情况通过一次操作能变成下一种情况)

解答:

  一开始不是很理解,参考答案上也提到是用格雷码来解决。不过如果知道格雷码的生成方式,就好解决了:

  1. 1位格雷码有两个码字
  2. (n+1)位格雷码中的前2n个码字等于n位格雷码的码字,按顺序书写,加前缀0
  3. (n+1)位格雷码中的前2n个码字等于n位格雷码的码字,按逆序书写,加前缀1

 

  不过为了输出美观,由于C语言不提供printf直接输出2进制数,需要把10进制数转化成2进制数输出,而且首端的0要补上,这需要花点心思。下面是一个生成4位格雷码的程序,并不是回溯法。(为了省事直接在回溯法框架上改的)

 格雷码生成

 

7-19.

  使用能生成随机数{0,1,2,3,4}的函数rng04来生成rng07。每次运行rng07平均要调用几次rng04?

解答:

  随机数生成函数以前已经分析过了:http://www.cnblogs.com/wuyuegb2312/p/3141292.html。对于调用次数的期望,

  如果将rng07写作rng03+4*rng01,那么rng04调用的次数为它在rng03和rng01之和,都是 $1\cdot \frac{4}{5} +2\cdot \frac{4}{5}\cdot \frac{1}{5} +3\cdot \frac{4}{5}\cdot (\frac{1}{5})^{2}+ ... = 1.25$ 

0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:51084次
    • 积分:1090
    • 等级:
    • 排名:千里之外
    • 原创:59篇
    • 转载:33篇
    • 译文:0篇
    • 评论:8条
    最新评论