精读《算法 - 回溯》

如何尝试走迷宫呢?遇到障碍物就从头 “回溯” 继续探索,这就是回溯算法的形象解释。

更抽象的,可以将回溯算法理解为深度遍历一颗树,每个叶子结点都是一种方案的终态,而对某条路线的判断可能在访问到叶子结点之前就结束。

相比动态规划,回溯可以解决的问题更复杂,尤其是针对具有后效性的问题。

动态规划之所以无法处理有后效性问题,原因是其 dp(i)=F(dp(j)) 其中 0<=j<i 导致的,因为 i 通过 i-1 推导,如果 i-1 的某种选择会对 i 的选择产生影响,那么这个推导就是无效的。

而回溯,由于每条分支判断是相互独立的,互不影响,所以即便前面的选择具有后效性,这个后效性也可以在这条选择线路持续影响下去,而不影响其他分支。

所以回溯是一种适用性更广的算法,但相对的,其代价(时间复杂度)也更高,所以只有当没有更优算法时,才应当考虑回溯算法。

精读

经过上述思考,回溯算法的实现思路就清晰了:递归或迭代。由于两者可以相互转换,而递归理解成本较低,因此我更倾向于递归方式解决问题。

这里必须提到一点,即工作与算法竞赛思维的区别:由于递归调用堆栈深度较大,整体性能不如迭代好,且迭代写法不如递归自然,所以做算法题时,为了提升那么一点儿性能,以及不经意间流露自己的实力,可能大家更倾向用迭代方式解决问题。

但工作中,大部分是性能不敏感场景,可维护性反而是更重要的,所以工程代码建议用更易理解的递归方式解决问题,把堆栈调用交给计算机去做。

其实算法代码追求更简短,能写成一行的绝不换行也是同样的道理,希望大家能在不同环境里自由切换习惯,而不要拘泥于一种风格。

用递归解决回溯的套路不止一种,我介绍一下自己常用的 TS 语言方法:

function func(params: any[], results: any[] = []) {
  // 消耗 params 生成 currentResult
  const { currentResult, restParams } = doSomething(params);
  // 如果 params 还有剩余,则递归消耗,直到 params 耗尽为止
  if (restParams.length > 0) func(restParams, results.concat(currentResult));
}

这里 params 就类似迷宫后面的路线,而 results 记录了已走的最佳路线,当 params 路线消耗完了,就走出了迷宫,否则终止,让其它递归继续走。

所以回溯逻辑其实挺好写的,难在如何判断这道题应该用回溯做,以及如何优化算法复杂度。

先从两道入门题讲起,分别是电话号码的字母组合与复原 IP 地址。

电话号码的字母组合

电话号码的字母组合是一道中等题,题目如下:

给定一个仅包含数字  2-9  的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值