回溯法的解空间表示方法

回溯法解题时通常包含3个步骤:

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

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

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

对于问题的解空间结构通常以树或图的形式表示,常用的两类典型的解空间树是子集树和排列树。当所给的问题是从n个元素的集合S中找到S满足某种性质的子集时,相应的解空间树称为子集树。例如,n个物品的0-1背包问题所对应的解空间树是一棵子集树,这类子集树通常有2**n个叶结点,遍历子集树的算法需要O(2**n)计算时间。当所给问题是确定n个元素满足某种性质的排列时,相应的解空间树称为排列树。排列树通常有n!个叶结点。因此,排列树需要O(n!)计算时间。当问题的解空间确定后,便可用不同的剪枝函数和最优解表示方法来获得最终结果。下面,介绍用子集树或排列树构造解空间的常见问题。

1. 子集树

上图是一棵n=3的子集树。它是一棵完全二叉树(有时可能是n叉树,如图着色问题,每一个结点可能有n种选择),从根到每一个叶结点的路径表示一个可行解。从根结点出发,以深度优先的方式搜索整棵树。用回溯法搜索子集树的一般算法可描述为:

void backtrack(int t)
{
    if(t > n) output(x);
    else
        for(int i = 0; i <= 1; i++)
        {
            x[t] = i;
            if(constraint(t) && bound(t)) backtrack(t+1);
        }
}

其中,t表示递归深度,表示树的第t层。当t > n时,算法已搜索到叶结点,由output( x ) 输出可行解。否则,记录当前选择的x的值(即0或1),并递归遍历所有子树(这里只有左右子树)。(constraint(t) && bound(t)) 表示剪枝函数,只有满足剪枝函数的子树才继续递归。一棵子集树一共有2**n个可搜索的解,对于所有可用子集树表示解空间的问题都是在这

2**n个解中找到满足条件的解,不同的是剪枝函数不同,最终解的表示形式不同。

1.1 求解组合

给定正整数n和k,求所有k个数的组合,例如,n=4,k=2,解为:

[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

此时,集合S={1,2,3,4},从第一个元素开始搜索,对于每一个元素可以选择或者不选择,当当前选择到的元素个数等于k时记录这个结果。因此,我们定义result来保存记录的结果,

answer记录了当前选择元素,即当前的解。

class Solution {
public:
    void help(int n , int k , int start, vector<int> &answer, vector<vector<int>> & result) {
        if(k == answer.size()) {
            result.push_back(answer);  //记录结果
            return ;
        }
        if(start > n) return; //搜索至叶结点
        answer.push_back(start);              // 选择当前元素的情况
        help(n, k, start + 1, answer, result);//
        answer.pop_back();
        help(n, k, start + 1, answer, result);  // 不选择的情况
    }
    vector<vector<int>> combine(int n, int k) {
        vector<vector<int>> result;
        vector<int> answer;
        help(n, k, 1, answer, result);
        return result;
    }
};

1.2 求解组合和

上面一个问题其实就是在所有S的子集中寻找大小为k的子集,所以最终的解是大小等于k的子集的集合。

现在,我们给定一个整数的集合C,和一个整数T,找到集合C中元素和为T的所有集合的集合。其中,对应每有个C中的元素可以重复多次。

那么,这个问题与上面问题的区别是:1.找到一个解的条件不同(和为T);2.元素可重复。

class Solution {
private:
    void help(vector<int>& candidates, int start, int target, int sum, vector<int>& answer, vector<vector<int>>& result) {
        if(sum == target) {
            result.push_back(answer);
            return;
        }
        if(start == candidates.size() || sum + candidates[start] > target) return; // 添加一个剪枝函数,当加入当前元素使和大于T时那么这是一棵不符合条件的子树
        answer.push_back(candidates[start]);
        help(candidates, start, target, sum + candidates[start], answer, result);//依然从start开始,因为元素可重复
        answer.pop_back();
        help(candidates, start + 1, target, sum, answer, result);
    }
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        vector<int> answer;
        vector<vector<int>> result;
        sort(candidates.begin(), candidates.end());
        help(candidates, 0, target, 0, answer, result);
        return result;
    }
};

1.3 求解组合和II

上面一题再变一下,一个元素只能用一次。上面一题为了解决元素可重复的问题,我们遍历的时候,当加入当前元素时,递归遍历继续从当前元素开始。而现在要让元素只被选择一次,那么我们改变搜索策略,当选择当前元素时,我们从下一个元素递归遍历,当没有选择当前元素时,我们需要从下一个与当前不重复的元素开始。

void help(vector<int> & candidates, int start, int sum, int target, vector<int>& answer, vector<vector<int>>& result) {
        if(sum == target) {
            result.push_back(answer);
            return ;
        }
        if(start == candidates.size() || sum + candidates[start] > target) return;
        answer.push_back(candidates[start]);
        help(candidates, start + 1, sum + candidates[start], target, answer, result);
        answer.pop_back();
        while(start + 1 < candidates.size() && candidates[start + 1] == candidates[start]) start++;  //去掉重复的元素
        help(candidates, start + 1, sum, target, answer, result);
    }

2. 排列树

上图是一棵表示旅行售货员问题的排列树,从根到叶结点表示一条可选路径。与子集树不同的是,每一个当前结点的搜索策略是选择剩下的元素中的一个,而子集树是选择或不选择当前元素。用回溯法搜索排列树的一般算法为:

void backtrack(int t)
{
    if(t > n) output(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]);
        }
}

2.1 求排列

For example,
[1,2,3] have the following permutations:
[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], and [3,2,1].

这是一个最简单的排列树搜索问题,因为问题的解就是叶结点个数,即n!个。因此,它没有剪枝函数,只要搜索到叶结点就保存一个解。

class Solution {
private:
    void help(vector<int>& nums, vector<int>& pos, int now, vector<vector<int>>& result) {
        int n = nums.size();
        if(now == n) { //
            vector<int> answer(n, 0);
            for(int i = 0; i < n; i++) {
                answer[i] = nums[pos[i]];  //pos保存元素的下标,这里构造需要的解
            }
            result.push_back(answer);
            return;
        }
        for(int i = now; i < n; i++) {
            swap(pos[now], pos[i]);
            help(nums, pos, now + 1, result);
            swap(pos[now], pos[i]);
        }
    }
public:
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int>> result;
        int n = nums.size();
        if(n == 0) return result;
        vector<int> pos(n, 0);
        for(int i = 0; i < n; i++) {
            pos[i] = i;
        }
        help(nums, pos, 0, result);
        return result;
    }
};

2.2 n-Queens 问题

n-Queens问题是把n个皇后放在nxn的棋盘上,让她们不能互相攻击。按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或者同一斜线上的棋子。一个4皇后问题的解为:

[
 [".Q..",  // Solution 1
  "...Q",
  "Q...",
  "..Q."],

 ["..Q.",  // Solution 2
  "Q...",
  "...Q",
  ".Q.."]
]

我们可以把问题转换成n个元素的集合的排列问题,而这个排列需要满足上面这个规则。我们按行优先的顺序,从上往下给每一行填一个皇后。这就相当于,解是一个n元向量,向量的元素下标对应于一行行坐标,而元素对应于列坐标。这里为了方便得到最终形式的解,我们把每一个解表示成nxn的char型矩阵。并设置了3个mask用于剪枝函数,即colFlag、

diagFlag1、diagFlag2分别表示列和两对角线的mask,在剪枝函数中只需判断当前位置处这3个mask对应的位是否被置位。

class Solution {
private:
    int colFlag;
    int diagFlag1;
    int diagFlag2;
    bool isValid(int rowIdx, int colIdx, int n) {
        if((1 << colIdx) & colFlag) return false;
        if((1 << (colIdx + rowIdx)) & diagFlag1) return false;
        if((1 << (n + rowIdx - colIdx - 1)) & diagFlag2) return false;
        return true;
    }
    void setFlag(int rowIdx, int colIdx, int n) { //设置mask flag
        colFlag |= (1 << colIdx);
        diagFlag1 |= (1 << (colIdx + rowIdx));
        diagFlag2 |= (1 << (n + rowIdx - colIdx -1));
    }
    void unsetFlag(int rowIdx, int colIdx, int n) { //取消mask flag
        colFlag &= ~(1 << colIdx);
        diagFlag1 &= ~(1 << (colIdx + rowIdx));
        diagFlag2 &= ~(1 << (n + rowIdx - colIdx -1));
    }
    void help(int n, vector<string>& answer, vector<vector<string>>& result) {
        int rowIdx = answer.size();
        if(rowIdx == n) { //搜索完成,记录结果
            result.push_back(answer);
            return;
        }
        answer.push_back(string(n,‘.‘));
        for(int i = 0; i < n; ++i) {
            if(isValid(rowIdx, i, n)) { //如果当前位置可用
                setFlag(rowIdx, i, n); //设置flag表示选择了
                answer.back()[i] = ‘Q‘;
                help(n, answer, result);
                answer.back()[i] = ‘.‘;
                unsetFlag(rowIdx, i, n);//取消flag,回溯
            }
        }
        answer.pop_back();
    }
public:
    vector<vector<string> > solveNQueens(int n) {
        colFlag = diagFlag1 = diagFlag2 = 0;
        vector<string> answer;
        vector<vector<string>> result;
        help(n, answer, result);
        return result;
    }
};

总结:

子集树和排列树的不同是每一步的选择策略不同。子集树每一步对应的是对应的元素的选择或不选择,排列树每一步对应的是剩下的元素选择其中一个。一个的可搜索的解为2**n,一个为n!,因此,一个高效的回溯法算法必须依赖于剪枝函数来避免无效搜索。

参考资料

1.《算法设计与分析(第二版)》 清华大学出版社

2. leetcode.com

作者:waring  出处:http://www.cnblogs.com/waring  欢迎转载或分享,但请务必声明文章出处。

时间: 2024-11-11 16:29:11

回溯法的解空间表示方法的相关文章

回溯法-01背包问题之一:递归模式

一.回溯法 回溯法是一个既带有系统性又带有跳跃性的搜索算法.它在包含问题的所有解的解空间树中按照深度优先的策略,从根节点出发搜索解空间树.算法搜索至解空间树的任一节点时,总是先判断该节点是否肯定不包含问题的解.如果肯定不包含,则跳过对以该节点为根的子树的系统搜索,逐层向其原先节点回溯.否则,进入该子树,继续按深度优先的策略进行搜索. 运用回溯法解题通常包含以下三个步骤: · 针对所给问题,定义问题的解空间: · 确定易于搜索的解空间结构: · 以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函

分治法、动态规划、回溯法、分支界限法、贪心算法

转:http://blog.csdn.net/lcj_cjfykx/article/details/41691787 分治算法一.基本概念 在计算机科学中,分治法是一种很重要的算法.字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并.这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)…… 任何一个可以用计算机求解的问题所需的计算时

0/1背包问题(回溯法)

回溯法是一个既带有系统性又带有跳跃性的搜索算法.它在包含问题的所有解的解空间树中,按深度优先策略,从根结点出发搜索解空间树.算法搜索至解空间树的任意一结点时,先判断该结点是否包含问题的解.如果肯定不包含,则跳过对该结点为根的子树搜索,逐层向其祖先结点回溯:否则 ,进入该子树,继续按深度优先策略搜索. 问题的解空间 用回溯法解问题时,应明确定义问题的解空间.问题的解空间至少包含问题的一个(最优)解.对于 n=3 时的 0/1 背包问题,可用一棵完全二叉树表示解空间,如图所示: 求解步骤 1)针对所

回溯法--无优化 最优装载问题

//#include "stdafx.h" // 回溯法,解空间分为排列数和子集树,前者是不同节点顺序的排列,后者是一个(0,1,...)的向量子集// 最大装载问题,是一个NP问题,目前只计算第一艘船,属于子集树// 有几个货物,子集树就有几层,当前题目为5层// 我感觉递归还是太过于精巧和经凑,很难挖空心思自己写出来,多熟悉别人现有的程序是一个好办法. #include<iostream>using namespace std; template<class T&

五大常用算法之四:回溯法

(转自:http://www.cnblogs.com/steven_oyj/archive/2010/05/22/1741376.html) 1.概念 回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径. 回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标.但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”. 许

回溯法 -数据结构与算法

1.回溯法算法思想: 定义: 回溯法(探索与回溯法)是一种选优搜索法,按选优条件向前搜索,以达到目标.但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”. 1.回溯法适用:有许多问题,当需要找出它的解集(全部解)或者要求回答什么解是满足某些约束条件的最优解时,往往要使用回溯法. 2.有组织的穷举式搜索:回溯法的基本做法是搜索或者有的组织穷尽搜索.它能避免搜索所有的可能性.即避免不必要的搜索.这种方

算法入门经典-第七章 例题7-4-1 拓展 n皇后问题 回溯法

实际上回溯法有暴力破解的意思在里面,解决一个问题,一路走到底,路无法通,返回寻找另   一条路. 回溯法可以解决很多的问题,如:N皇后问题和迷宫问题. 一.概念 回溯算法实际类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现不满足条件的时候,就回溯返回,尝试别的路径. 百度解释:回溯法(探索与回溯法)是一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标.但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯

基于回溯法寻找哈密顿回路

回溯法是一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标.但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”. 在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树.当探索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯(其实回溯法就是对隐式图的深度优先搜索算法). 若用

算法思想之回溯法

一.概念 回溯:当把问题分成若干步骤并递归求解时,如果当期步骤没有合法选择,则函数将返回上一级递归调用,这种现象称为回溯. 回溯算法应用范围:只要把待求解问题分成不太多的步骤,每个步骤又只有不太多的选择,即可以考虑用回溯法. 回溯算法实际上是一个递归枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径. 回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标.但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不