vlambda博客
学习文章列表

数据结构 | 回溯算法

回溯算法

一、什么是回溯法

        在程序设计中,有一类求一组解、或求全部解或求最优解的问题,如最熟悉的n皇后、全排列问题等。不是根据某种确定的计算法则,而是利用试探和回溯的搜索技术求解。回溯法也是设计递归过程的一种重要方法,它的求解过程==实质上是一个先序遍历一棵“状态树”的过程==,只是这棵树不是遍历前预先建立的,而是隐含在遍历过程中,但如果认识到这点,很多问题的递归过程设计也就迎刃而解了,回溯法通常用最简单的递归方法来实现。下图为在解决 n=3全排列问题构造的状态树。

二、回溯法解决的问题

在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。

回溯法,⼀般可以解决如下⼏种问题:

  • 组合问题:N个数⾥⾯按⼀定规则找出k个数的集合

  • 切割问题:⼀个字符串按⼀定规则有⼏种切割⽅式

  • ⼦集问题:⼀个N个数的集合⾥有多少符合条件的⼦集

  • 排列问题:N个数按⼀定规则全排列,有⼏种排列⽅式

  • 棋盘问题:N皇后,解数独等等

    为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,因此回溯适合于一些需要穷举的问题。另外,由于需要穷举,回溯算法的效率不高。

三、回溯算法的基本思想

        对于用回溯法求解的问题,首先要将问题进行适当的转化,得出状态树。这棵树的每条完整路径都代表了一种解的可能。通过深度优先搜索这棵树,枚举每种可能的解的情况;从而得出结果。但是,回溯法中通过构造约束函数,可以大大提升程序效率,因为在深度优先搜索的过程中,不断的将每个解(并不一定是完整的,事实上这也就是构造约束函数的意义所在)与约束函数进行对照从而删除一些不可能的解,这样就不必继续把解的剩余部分列出从而节省部分时间。

回溯法中,首先需要明确下面三个概念:

  1. 约束函数:约束函数是根据题意定出的。通过描述合法解的一般特征用于去除不合法的解,从而避免继续搜索出这个不合法解的剩余部分。因此,约束函数是对于任何状态空间树上的节点都有效、等价的。
  2. 状态树:刚刚已经提到,状态空间树是一个对所有解的图形描述。树上的每个子节点的解都只有一个部分与父节点不同。
  3. 扩展节点、活结点、死结点:所谓扩展节点,就是当前正在搜索它的子节点的节点。活结点就是一个自身已搜索但其子节点还没有被全部搜索的结点;死结点就是所有儿子节点已经全被搜索。

        回溯法从开始节点出发,以深度优先方式搜索解空间树,开始结点成为活结点,也成为当前的扩展结点。在当前扩展结点处,向纵深方向移到一个新结点,这个新结点成为新的活结点,并成为当前扩展结点,如果在当前扩展结点处不能再向纵深方向移动,则当前扩展节点就成为死节点,此时应向回移动(回溯)至最近的活结点,并使这个活结点成为当前扩展节点。继续这个搜索过程,直到找到所要求的解或解空间中已无活节点为止。所以回溯法体现出走不通就退回再走的思路。

四、用回溯法解题的一般步骤

首先,要通过读题完成下面三个步骤:

(1)描述解的形式,定义一个解空间,它包含问题的所有解,这一步主要明确问题的解空间树。

(2)构造状态空间树。

(3)构造约束函数(用于杀死节点)。

然后就开始遍历,回溯法⼀般是在集合中递归搜索,集合的⼤⼩构成了树的宽度,递归的深度构成的树的深度。

数据结构 | 回溯算法

遍历过程伪代码如下:

for (选择:本层集合中元素(树中节点孩⼦的数量就是集合的⼤⼩)) {
 处理节点;
 backtracking(); // 递归
 回溯,撤销处理结果
}

for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,⼀般来说,搜索叶⼦节点就是找的其中⼀个结果了。

回溯算法框架如下:

void backtracking(参数) {
 if (终⽌条件) {
   存放结果;
  return;
 }
 for (选择:本层集合中元素(树中节点孩⼦的数量就是集合的⼤⼩)) 
  {
    处理节点;
    backtracking(); // 递归
    回溯,撤销处理结果
  }
}

五、典型题目应用

1 、全排列问题

描述:

一般把 1 ~n 这 n 个整数按某个顺序摆放的结果称为这 n 个整数的一个排列,而全排列指这 n 个整数能形成的所有排列。例如对 1、2、3 这三个整数来说,(1, 2, 3)、(1, 3, 2)、(2, 1, 3)、(2, 3, 1)、(3, 1, 2)、(3,2, 1)就是 1~3 的全排列。

现在需要实现按字典序从小到大的顺序输出1~n 的全排列,其中(a1, a2,…,an)的字典序小于(b1,b2,…, ba)是指存在一个 i,使得 a1=b1、a2= b2、…、ai-1=bi-1、ai<bi成立。因此上面 n =3的例子就是按字典序从小到大的顺序给出的。

示例 :

输入:
n = 3
输出:
[[1,2,3], 
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]]

思路:

设定一个数组 P,用来存放当前的排列;再设定一个数组used,其中 used[x]当整数 × 已经在数组 P中时为 true。现在按顺序往 P 的第 1 位到第 n 位中填入数字。不妨假设当前已经填好了 P[1]~P[index-1],正准备填 P[index]。显然需要枚举 1~n,如果当前枚举的数字 x还没有在 P[1]~P[index- 1]中(即 used[x] = false),那么就把它填入 P[index],同时将 used[x]置为 true,然后去处理 P 的第 index +1 位(即进行递归);而当递归完成时,再将used[x]还原为 false,以便让 P[index]填下一个数字。

数据结构 | 回溯算法

代码实现:

#include<stdio.h>
//全排列
const int maxn = 11;

//P为当前排列,used记录整数x是否已经在P中
int n, P[maxn], used[maxn] = {false};

//当前处理排列的第index位
void generate(int index){
    if (index == n+1)//递归边界,已经处理完排列的1~n位
    {
        for (int i = 1; i <= n; i++)
        {
            printf("%d", P[i]);
        }
        printf("\n");
        return;
    }
    for (int x = 1; x <= n; x++) //枚举1~n,试图将x填入P[index];
    {
        if (used[x]==false)//如果x不在P[0]~P[index-1]中
        {
            P[index] = x;//令P的第index位为x,即把x加入当前排列
            used[x] = true;//记x已经在P中
            generate(index + 1);//处理排列的第inde+1号位(递归)
            used[x] = false;//已处理完P[index]为x的子问题(回溯,撤销标记)
        }
    }
}

int main(int argc, char const *argv[])
{
    n = 3;
    generate(1);
    return 0;
}

复杂度分析:

  • 时间复杂度

回溯算法的时间复杂度主要由递归树的个数决定,而在全排列中,程序在叶子结点和非叶子结点的行为是不一样的,因此需要分开计算。

非叶子结点:

数据结构 | 回溯算法

将2视为常系数1,每个内部节点循环n次,故非叶子结点的时间复杂度为:O(n x n!)

叶子结点:

就是n!,在叶子结点需要做循环输出,需要O(n),叶子结点的时间复杂度也为O(n x n!)

总的时间复杂度为:O(n x n!)

  • 空间复杂度

    O(n x n!),n为数组长度。

2、n皇后问题

描述:

n 皇后问题是指在一个 n*n 的国际象棋棋盘上放置 n 个皇后,使得这 n 个皇后两两均不在同一行、同一列、同一条对角线上,求合法的方案数。例如下图是n=5 的情况,其中图 a 是一个合法的方案,而图  b 由于有两个皇后在同一条对角线,因此不是合法方案。

数据结构 | 回溯算法

示例(以4皇后为例):

输出:
* Q * *
* * * Q
Q * * *
* * Q *

* * Q *
Q * * *
* * * Q
* Q * *

摆放的方式有 2 种

思路:

n皇后的约束条件:

  1. 不能同⾏
  2. 不能同列
  3. 不能同斜线

下面是n=4时,构造的部分状态树:

数据结构 | 回溯算法

一行一行地摆放,在确定一行中的那个皇后应该摆在哪一列时,需要当前列是否合法,如果合法,则将皇后放置在当前位置,并进行递归,回溯。每行都摆满皇后时,则产生了一种解法,将所有解法收集并返回。

判断合法时,只需要判断列、45度、135度即可,

代码实现:

#include<stdio.h>
//n皇后问题
int n=4;
int count = 0;
bool isvalid(int row,int col,char Q[4][4]){//检查位置是否合法
    //检查列
    for (int i = 0; i < row; i++)
    {
        if(Q[i][col]=='Q')
            return false;
    }
    //检查45度
    for (int i = row - 1, j = col - 1; i >=0 && j >= 0; i--, j--)
    {
        if(Q[i][j]=='Q')
            return false;
    }
    //检查135度
    for(int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++)
    {
        if(Q[i][j]=='Q')
            return false;
    }
    return true;
}
void Queue(int row,char Q[4][4]){
    if (row==4)
    {
        for(int i=0; i<4; i++){
            for(int k=0; k<4; k++)
                printf("%c ", Q[i][k]);//得到一个解,在屏幕上显示
            printf("\n");
        }
        printf("\n");
        count++;
        return ;
    }
    for (int i = 0; i < 4; i++)
    {
        if (isvalid(row,i,Q))
        {
            Q[row][i] = 'Q';//放置皇后
            Queue(row + 1, Q);
            Q[row][i] = '*';
        }
        
    }
    
}
int main()
{
    char Q[4][4];
    for(int i=0; i<4; i++)
        for(int j=0; j<4; j++)
            Q[i][j] = '*';
    Queue(0, Q);
    printf("摆放的方式有 %d 种\n", count);
    return 0;
}

3、组合问题

描述:

给定两个整数 nk,返回 1 ... n 中所有可能的 k 个数的组合。

示例:

输入: 
n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]

思路:

把组合问题的解的过程

数据结构 | 回溯算法

  • 返回值及参数:
定义了一个静态数组:
typedef struct{
    int key[maxn]={0};
    int length=0;
}sq;

sq p;//用来存放符合条件的结果

void combine(index){}

    此外还需要一个int 变量 index,这个参数⽤来记录本层递归的中,从哪⾥开始遍历,也就是控制树的横向遍历,每次从集合中选取元素,可选择的范围随着选择的进⾏⽽收缩,调整可选择的范围,就是要靠index

从上图可以看出,在第一次取数(取1)后,开始进入第二层递归(从{2,3,4}中取2),为了控制从234中取数,就需要index。

  • 终止条件:
if (p.length==k)
    {
        return;
    }
  • 搜索过程:

    回溯法的搜索过程就是⼀个树型结构的遍历过程,可以看出for循环⽤来横向遍历,递归的过程是纵向遍历。

for (int x = index; x <= n; x++)//控制树的横向遍历
    {
        p.key[p.length + 1] = x;
        p.length++;//处理结点

        combine(x + 1);//递归:控制树的纵向遍历

        p.length--;//回溯,撤销处理结点
    }

代码实现:

#include<stdio.h>
//组合问题
const int maxn = 11;
int n, k;

typedef struct{
    int key[maxn]={0};
    int length=0;
}sq;

sq p;

void combine(int index){
    if (p.length==k)//终止条件,输出
    {
        for (int i = 1; i <= k; i++)
        {
            printf("%d", p.key[i]);
        }
        printf("\n");
        return;
    }
    for (int x = index; x <= n; x++)//控制树的横向遍历
    {
        p.key[p.length + 1] = x;
        p.length++;//处理结点

        combine(x + 1);//递归:控制树的纵向遍历

        p.length--;//回溯,撤销处理结点
    }
}

int main(int argc, char const *argv[])
{
    n = 9, k = 7;
    combine(1);
    return 0;
}

优化:

当n = 4,k = 4时,第⼀层for循环的时候,从元素2开始的遍历都没有意义了。在第⼆层for循环,从元素3开始的遍历都没有意义了。如下图:

数据结构 | 回溯算法

图中每⼀个节点(图中为矩形),就代表本层的⼀个for循环,那么每⼀层的for循环从第⼆个数开始遍 历的话,都没有意义,都是⽆效遍历。所以,可以剪枝的地⽅就在递归中每⼀层的for循环所选择的起始位置。如果for循环选择的起始位置之后的元素个数已经不⾜我们需要的元素个数了,那么就没有必要搜索了。

因此在

  for (int x = index; x <= n; x++)

这个for循环中,没必要遍历到n,只需遍历到n - (k - p.length) + 1(已经选择的元素个数:p.length; 2. 还需要的元素个数为: k-p.length;)优化后的for循环为:

 for (int x = index; x <= n - (k - p.length) + 1; x++)

4、组合总和问题

描述:

给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的数字可以无限制重复被选取。1 <= candidates[i] <= 200

示例:

输入:candidates = [2,3,6,7], target = 7,
所求解集为:
[
[7],
[2,2,3]
]

输入:candidates = [2,3,5], target = 8,
所求解集为:
[
[2,2,2,2],
[2,3,3],
[3,5]
]

思路:

回溯搜索的过程抽象成树形结构如下:

注意图中叶⼦节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的 限制,只要选取的元素总和超过target,就返回。

  • 递归函数参数:
typedef struct{
    int key[maxn]={0};
    int length=0;
}sq;
sq p;//存放满足条件的结果

void combinationSum(int index,int *a,int n,int sum){
  • 递归终止条件:
if(sum==target){
        //输出结果
        return;
    }
if (sum>target)
    {
        return;
    }
  • 遍历:

注意,本题元素为可重复选取的。

for (int x = index; x < n; x++)
    {
        sum += a[x];
        
        p.key[p.length + 1] = a[x];
        p.length++;//处理结点
        
        combinationSum(x,a,n,sum);//关键点,不需要+1了,因为可以重复读取

        sum -= a[x];//回溯
        p.length--;//回溯
    }

代码实现:

#include<stdio.h>
//组合求和

const int maxn = 11;
int target;

typedef struct{
    int key[maxn]={0};
    int length=0;
}sq;
sq p;

void combinationSum(int index,int *a,int n,int sum){
    if(sum==target){
        for (int i = 1; i <= p.length; i++)
        {
            printf("%d ",p.key[i]);
        }
        printf("\n");
        return;
    }
    if (sum>target)//超过,return
        return;
    for (int x = index; x < n; x++)
    {
        sum += a[x];
        
        p.key[p.length + 1] = a[x];
        p.length++;//处理结点
        
        combinationSum(x,a,n,sum);//关键点,不需要+1了,因为可以重复读取

        sum -= a[x];//回溯
        p.length--;//回溯
    }
    
}

int main(int argc, char const *argv[])
{
    int candidates[4] = {2367};
    target = 7;
    combinationSum(0, candidates, 4,0);
    return 0;
}

5、求子集问题

描述:

给定⼀组不含重复元素的整数数组 nums,返回该数组所有可能的⼦集(幂集)。说明:解集不能包含重复的⼦集。

示例:

输⼊: nums = [1,2,3]
输出:
[
 [3],
 [1],
 [2],
 [1,2,3],
 [1,3],
 [2,3],
 [1,2],
 []
]

思路:

在前面几道题中,都是要找到树的叶子结点,但在这道题中,是要求把所有结点都要打印出来。

  • 递归函数参数:
const int maxn = 11;
typedef struct{
    int key[maxn]={0};
    int length=0;
}sq;
sq p;//用顺序表来记录集合
int a[3] = {123};//测试集合
int n = 3;
void subsets(int index){
    
}
  • 递归终止条件:

当index超过数组长度时终止。

if (index >=n)//终止条件
        return;
  • 遍历:
for (int i = index; i < n; i++)
    {
        p.key[p.length] = a[i];
        p.length++;//处理结点

        subsets(i+1);//递归

        p.length--;//回溯,撤销处理结点
}

代码实现:

#include<stdio.h>
//求子集问题
const int maxn = 11;
typedef struct{
    int key[maxn]={0};
    int length=0;
}sq;
sq p;

int a[3] = {123};//测试集合
int n = 3;

void subsets(int index)
{
    for (int j = 0; j < p.length; j++)//在终止条件前打印出来,避免遗漏空集情况。
    {
        printf("%d ", p.key[j]);
    }
    printf("\n");

    if (index >=n)//终止条件
        return;
    
    for (int i = index; i < n; i++)
    {
        p.key[p.length] = a[i];
        p.length++;//处理结点

        subsets(i+1);//递归

        p.length--;//回溯,撤销处理结点
    }
}

int main(int argc, char const *argv[])
{
    subsets(0);
    return 0;
}