深入理解回溯算法

本文详细介绍了回溯算法的概念,包括其原理(基于深度优先搜索),应用场景(组合、排列、子集问题等),效率分析,以及如何在实际问题中实现,特别强调了其在穷举问题中的应用和效率优化策略。
摘要由CSDN通过智能技术生成

深入理解回溯算法

回溯算法是一种经典的问题解决方法,通常用于解决搜索和优化问题。它的核心思想是尝试在解空间中搜索所有可能的解,并在找到合适的解或者确定不存在解时回溯到上一步继续搜索。在本文中,我们将探讨回溯算法的原理、应用场景以及实现方式。

原理解析

回溯算法基于深度优先搜索(DFS)的思想,它通过递归地尝试每一种可能的情况来解决问题。在搜索过程中,当遇到无法满足条件的情况时,就会回溯到上一步选择另一种可能性。这种反复搜索的过程直到找到解或者确定不存在解为止。

应用场景

回溯算法在许多问题中都有广泛的应用,例如:

  1. 组合问题:给定一组数,从中找出所有可能的组合。
  2. 排列问题:给定一组数,找出所有可能的排列方式。
  3. 子集问题:给定一组数,找出所有可能的子集。
  4. 图的遍历:如深度优先搜索(DFS)就是一种回溯算法。
  5. 棋盘问题:如八皇后问题、数独等。

回溯法的效率

回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,所以回溯的效率并不高,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。

既然效率不高,为什么要选择回溯算法呢?其实在需要用到回溯算法的地方,也只能用回溯算法。

回溯算法的实现

回溯算法的原理非常简单,它基于深度优先搜索(DFS)的思想,就是问题可以想象成树形结构。

回溯算法是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度

所以基本流程是这样:

  1. 结束条件:根据题目要求,及时退出递归并回收结果。
  2. 选择路径:根据问题定义,做出一些选择,通常是在搜索树中向下一个节点移动。
  3. 递归探索:递归地尝试所有可能的选择,每次选择后进入下一层递归。
  4. 检查条件:在每一步检查当前选择是否符合条件,如果符合继续探索,否则回溯到上一步。
  5. 撤销选择:在回溯时撤销当前选择,继续尝试其他可能性。

接下来看看回溯算法的基本模板:

首先是结束条件,这里的条件就取决于不同的问题:

if (终止条件) {
    存放结果;
    return;
}

然后是选择路径,将问题抽象化成一棵树,一般是横向遍历搜索,纵向递归搜索:

如图:

回溯算法理论基础

遍历的代码就是

for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
    处理节点;
    backtracking(路径,选择列表); // 递归
    回溯,撤销处理结果
}

所以整体的代码框架就是:

void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

回溯算法难以理解,所以还是要从题目出发,这里从组合,子集,排列问题各选一题来理解回溯思想。

77. 组合

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

你可以按 任何顺序 返回答案。

示例 1:

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

示例 2:

输入:n = 1, k = 1
输出:[[1]]

提示:

  • 1 <= n <= 20
  • 1 <= k <= n

对于这道题目,如果是两个数,暴力的解法就是嵌套两层for循环找出所有的代码情况,但是随着数字变多,这种方法就变得不切实际了,所以说用到回溯算法的题目,一般就只能用回溯算法了。

在这里插入图片描述

将所有组合都树形化,这样能直观的看到每种组合的情况,然后我们的代码就要在这棵树上收取结果,跟着回溯的模板,我们要思考递归的结束条件判断,这里比较明显,就是判断路径上的数长度是否超过了k。然后是搜索阶段,这题没有限制,所以只需要从剩下的数里面循环寻找就可以了,然后进入下一层循环。最后是撤销选择,这个时候我们只需要将路径上的数字删掉就可以了,然后新的数就会顶替原来的位置。

下面就是具体的代码实现:

int anstop;		//定义答案数组,这里要求返回二维数组
int** ans;
int pathtop;	//这里定义路径数组,其实就是记录树上每条路径的组合,用来处理叶子节点
int* path;

void backTracking(int n, int k, int startIndex) {	//回溯递归函数,startIndex指的是当前遍历到了哪一个数
    if(pathtop == k) {	//当我的记录数组的长度达到了k,就说明我已经找到了长度为k的组合,也就是我们的答案之一,这里就需要返回这个数组了
        int* temp = (int*)malloc(sizeof(int) * k);	
        int i;
        for(i = 0; i < k; i++) {	//将path数组里面记录的值存入临时数组
            temp[i] = path[i];
        }
        ans[anstop++] = temp;	//将临时数组存入答案数组,并将答案数组的指针加一
        return;
    }
    
    int j;
    for(j = startIndex; j <= n; j++) {	//这部分是对节点的具体操作,从当前值继续向后遍历
        path[pathtop++] = j;	//先将当前的值记录
        backTracking(n, k, j + 1);	//然后调用递归函数,将下一个值带进去
        pathtop--;	//这部分则是具体的回溯操作,因为每当函数返回了值,我们还需要将上一个节点的值弹出,为新的值空出位置
    }
}

int** combine(int n, int k, int* returnSize, int** returnColumnSizes){
    ans = (int**)malloc(sizeof(int*) * 100000);
    path = (int*)malloc(sizeof(int) * k);
    anstop = pathtop = 0;
    backTracking(n, k, 1);
    *returnSize = anstop;
    *returnColumnSizes = (int*)malloc(sizeof(int) * (*returnSize));
    int i;
    for(i = 0; i < anstop; i++) {
        (*returnColumnSizes)[i] = k;
    }
    return ans;
}

78. 子集

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的

子集

(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

示例 1:

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

示例 2:

输入:nums = [0]
输出:[[],[0]]

提示:

  • 1 <= nums.length <= 10

  • -10 <= nums[i] <= 10

  • nums 中的所有元素 互不相同

    这是是子集问题,但是和组合问题的思想是一样的。

    按照之前的回溯思想,收集答案的时候都是从叶子节点上来收集的,但是对于这道题目,当我们把树状图画出来的时候,我们可以发现数上每一个节点都是答案的一种可能,所以在之前的回溯方法中,我们收集答案的时候都会添加一些判断,以此来判断是否是有效答案,但是这一题的关键就是每次进入一层递归时,都要直接将path数组中记录的答案加入答案数组中,不用添加别的判断,然后在超出数组范围时退出递归;

    78.子集

  int** ans;
   int anstop;
   int* path;
   int pathtop;
   int* length;
  
   void backtracking(int* nums, int numsSize, int startindex) {
      int* temp = (int*)malloc(sizeof(int) * pathtop);	//这里是将记录的叶子节点加入答案数组
      int i;
      for(i = 0; i < pathtop; i++) {
          temp[i] = path[i];
      }
      length[anstop] = pathtop;
      ans[anstop++] = temp;
      if(startindex >= numsSize) {
          return;
      }
      int j;
      for(j = startindex; j < numsSize; j++) {
          path[pathtop++] = nums[j];	//将当前的数字记录在path数组中
          backtracking(nums, numsSize, j + 1);	//进行下一次递归
          pathtop--;
      }
  
   }
  
  int** subsets(int* nums, int numsSize, int* returnSize, int** returnColumnSizes) {
      path = (int*)malloc(sizeof(int) * numsSize);
      ans = (int**)malloc(sizeof(int*) * 100000);
      length = (int*)malloc(sizeof(int) * 1500);
      anstop = pathtop = 0;
      backtracking(nums, numsSize, 0);
      *returnSize = anstop;
      *returnColumnSizes = (int*)malloc(sizeof(int) * anstop);
      int i;
      for(i = 0; i < anstop; i++) {
          (*returnColumnSizes)[i] = length[i];
      }
      return ans;
  
  }
turnColumnSizes = (int*)malloc(sizeof(int) * anstop);
      int i;
      for(i = 0; i < anstop; i++) {
          (*returnColumnSizes)[i] = length[i];
      }
      return ans;
  
  }
  

46. 全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

示例 1:

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

示例 2:

输入:nums = [0,1]
输出:[[0,1],[1,0]]

示例 3:

输入:nums = [1]
输出:[[1]]

提示:

  • 1 <= nums.length <= 6
  • -10 <= nums[i] <= 10
  • nums 中的所有整数 互不相同

排列问题就有点不同了,主要在于排列问题讲究顺序,我们需要思考在将所有组合情况找出来的同时,还要区别不同的顺序。依然是先将问题树形化:

以[1,2,3]为例,抽象成树形结构如下:

46.全排列

在排列问题当中,我们需要用一个数组来记录哪些数字我们已经使用过了,如图中的橘黄色部分。

按照模板来思考我们需要做什么:

1.递归结束条件:可以看出叶子节点,就是收割结果的地方。那么什么时候,算是到达叶子节点呢?当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。

2.单层搜索:因为排列问题讲究顺序,所以不能像前面的问题一样从剩下的数字里面寻找合适的,我们要从头开始遍历数组,以防错过任何一种顺序,但是为了避免重复,我们还要利用used数组进行已使用过数字的判断。

3.递归搜索:这里要关键是将uesd数组传下去。

4.回溯操作:将使用过的数字状态改回来。

下面是具体代码实现:

int** ans;       // 存储最终结果的二维数组
int anstop;      // 记录结果的数量
int* path;       // 存储当前路径的一维数组
int pathtop;     // 记录当前路径的长度

// 回溯函数,用于生成排列
void backtracking(int* nums, int numsSize, int* used) {
    // 当路径长度等于数组长度时,表示找到一个排列
    if(pathtop == numsSize) {
        // 创建一个新的一维数组,存储当前排列
        int* temp = (int*)malloc(sizeof(int) * numsSize);
        for(int i = 0; i < numsSize; i++) {
            temp[i] = path[i];
        }
        // 将当前排列存入最终结果数组中
        ans[anstop++] = temp;
        return;
    }

    int j;
    // 遍历选择列表
    for(j = 0; j < numsSize; j++) {
        // 如果数字已经被使用,则跳过
        if(used[j] == 1) {
            continue;
        }
        // 标记数字为已使用
        used[j] = 1;
        // 将当前数字加入路径中
        path[pathtop++] = nums[j];
        // 递归搜索下一层
        backtracking(nums, numsSize, used);
        // 撤销当前选择
        used[j] = 0;
        pathtop--;
    }
}

// 主函数,生成排列
int** permute(int* nums, int numsSize, int* returnSize, int** returnColumnSizes) {
    // 分配内存空间
    ans = (int**)malloc(sizeof(int) * 10000);
    path = (int*)malloc(sizeof(int) * numsSize);
    anstop = pathtop = 0;

    // 初始化用于标记数字是否被使用的数组
    int* used = (int*)malloc(sizeof(int) * numsSize);
    memset(used, numsSize, 0);
    
    // 调用回溯函数生成排列
    backtracking(nums, numsSize, used);

    // 设置返回值
    *returnSize = anstop;
    *returnColumnSizes = (int*)malloc(sizeof(int) * anstop);
    for(int i = 0; i < anstop; i++) {
        (*returnColumnSizes)[i] = numsSize;
    }
    return ans;
}

    int* used = (int*)malloc(sizeof(int) * numsSize);
    memset(used, numsSize, 0);
    
    // 调用回溯函数生成排列
    backtracking(nums, numsSize, used);

    // 设置返回值
    *returnSize = anstop;
    *returnColumnSizes = (int*)malloc(sizeof(int) * anstop);
    for(int i = 0; i < anstop; i++) {
        (*returnColumnSizes)[i] = numsSize;
    }
    return ans;
}
  • 47
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值