<回溯算法>超级直观,小白也能看懂——回溯算法是如何实现覆盖所有可能组合的

91 阅读5分钟

概念

回溯本质上也属于一种暴力枚举,但它比纯暴力更高效,因为它会剪枝,即提前终止不可能的路径。

专门用于枚举所有可能的组合(或排列、子集)。

局限性:时间复杂度为2n,大规模的问题则需要使用动态规划、贪心算法。

每次循环都是一次选择,主体使用的是递归的方式,自己调用自己,递归以后全部结果覆盖所有可能,维护成最终最合适的结果,它能够覆盖全部组合,但通常会剪枝,这个操作生效在在当前路径继续深入将不再有更优解的情况,避免无效递归。

回溯的三要素

回溯的三个要素可以归纳为循环、递归、剪枝,但这不是一个硬性规定,根据实情来使用

循环:用于遍历当前路径所有选择。

递归:深入探索一个允许的选择。

剪枝:后续将无满足可能,提前终止。

回溯的概念图

以下是回溯的概念图,只包含循环和递归,

是我对回溯的一种原始的演示,剪枝是灵活添加的做法。 image.png

更加细节地注释:

image.png

整体大概的路径轨迹会是这样一个图样:

image.png

总之:这呈现了回溯算法的实现结构,可以直观看出它覆盖了所有可能的组合。

【另外】如果需求是 排列(顺序不同则异)而非组合(不看顺序),则通过添加一些逻辑也可以实现。

通过前面的图例,大家应该可以感受到,回溯算法能够实现枚举覆盖所有可能的组合

回溯算法的结构

那么,回溯算法的代码怎么写?其实很清晰

声明一个backtrack回溯函数,其内部逻辑如下:

1.首先是剪枝逻辑,满足条件则剪枝,即return,不再执行后续路径的生成,

2.路径选择逻辑,通过循环来实现,每次循环都是基于本路径进行新分支(通常称选择),选择方式为:嵌套调用函数自身,传入i+1、当前累积数据。

【解释】:

i+1:下次将从当前索引的后一个开始选择;

当前累计数据:当前路径的值。

function xxXxxx(){
  // ...
  function backtrack(start,currentSum){
    // 剪枝逻辑
    if( 条件符合 ){
      // 进行某些更新
      return; // 剪枝
    }
    // 选择逻辑
    for(let i = start ; i < len ; i ++){
      backtrack(i+1,currenttSum); // 下次选择从当前项的后一项开始
    }
  }
  // ...
}

那么,回溯为什么叫回溯呢?

回溯 = 试错 + 回退 + 剪枝,名字来源于"走不通就退回去,换条路再走"

回溯算法的核心是:

  • 深度优先搜索(DFS) :通常沿着一条路径深入到底,再回退。

回溯函数内部的执行顺序

这里需要提出来:如果for()循环中的代码是同步的,则会等待代码执行完毕再进入下一个循环,所以回溯算法实际上就是一条路先走到底,走完了再看另一条路,这样形成的,当然,符合我们直观理解的,则是把整个图拿出来,看每一级调用,代表n个元素的组合这样,但其实际的执行顺序我们有必要清楚。

也就是,回溯算法首先形成一完整的条路,再形成下一条完整的路,每条路从根到任意节点为一个组合,所有路形成后,覆盖所有可能的组合。

  • 递归 + 状态撤销:通过递归实现前进,通过撤销选择实现回退。

就拿京东秋招的这道题来讲:最低金额使用代金券 刚下班,领了京东秒购的麦某劳优惠券,想去买汉堡,优惠券最低使用金额是X元,以不点重复的汉堡为前提,我想要以最低的价格使用到优惠券,会提供两行数据,第一行是优惠券最低使用金额X,第二行是一个数组,里面的每个值都表示汉堡的某个价格,请返回最低需要花费多少能够使用到优惠券。

这是一个背包问题的变种,可以使用动归+贪心,也可以使用回溯(较基础)。

function minCostToUseCoupon(X,prices){
  prices.sort((a,b)=>{ return a - b; });
  let minCost = Infinity;
  
  // 回溯函数
  function backtrack(start,currentSum){
    // 剪枝逻辑
    if(currentSum >= X){ 
      minCost = Math.min(minCost,currentSum);
      return;
    }
    // 选择逻辑
    for(let i = start ; i < prices.length ; i++){ 
      backtrack(i+1,currentSum + prices[i]);
    }
  }
  backtrack(0,0); // 从0号索引开始,初始和为0,调用回溯函数 
  // 如果没有符合条件的组合则返回-1,有则返回minCost
  return minCost === Infinty ? -1 : minCost;
}

这里最后一行通过数据是否被改变判断是否有符合的组合,也是初学时比较常见的做法。

那么到这里,从概念——>结构——>直观理解——>回溯的调用执行路径(初学者小细节须知:for循环会等待同步代码再进入下一个循环——>大厂笔试真题练习,可谓是良苦用心,直观又带练。

如果觉得我本文讲得还不错,可以给个小小的嘛~~~ 3Q!我是LC_Happy,祝我们都会拥有一个向往的快乐生活!