基础算法之回溯算法(下篇)

引言

上篇我们知道了什么是回溯算法,和回溯算法能解决我们什么样的问题。本章我们来讲怎么写回溯算法。

论述

本篇我们就来围绕上篇提到的leetcod第77 组合问题https://leetcode.cn/problems/combinations/description/

来进行讲解回溯算法的一般解法。题目: 

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。 你可以按 任何顺序 返回答案。

回溯算法的逻辑

为了能写出这道题的算法,​​​​首先我们得去弄清楚回溯算法运行的逻辑是这么样的,只有这样,我们才有可能把这种逻辑用代码的方式实现出来。逻辑是怎么样的呢?我用树状图的方式为大家呈现,机器实际上的运行过程。为了更容易理解,我们先来举一个具体的例子。假设n=5,k=3。我们3个元素一组合收集。

182873a9b65c4e988d7cc8f108bf50cc.jpeg

一种组合在图中怎么看?其实就是从最根节点到当前程序位置经过的元素路径,比如现在程序在3,说明递归函数已经调用了第三层(第一次外部调用,后面两次自身调用),这个组合就是1 2 3,那刚好满足我们个数,是我们要的组合。如果程序到了2,无论是从1->2还是3->2,当前组合都是1 2。如果第一层的循环遍历到了4,那么深入后只有5它可以遍历,遍历完成就返回上层函数,到第一层循环遍历5。形象的看树形图,这棵树有着宽度和深度,每一层的宽度由横向遍历的情况决定,树的深度由递归调用的次数决定。

我们的算法就是在模拟这样一个过程,大家在这里停一下,理解后再继续读。

回溯算法解题步骤

我这里给出,由自己亲身实践总结出的 解决回溯算法的通用流程。

确定好是回溯问题后,先写出这个回溯算法的“封装说明”,当然是自己发明的,我建议大家这么做。用这种整体的视角来把握递归算法看有什么体验,大家试试就知道。

封装说明的格式是: 输入XXX——做了什么——>输出XXX

写完封装说明。我们得理一下具体回溯的大致过程,主要是树的深度与树的宽度。清楚后,我们就可以直接套模板了,我推荐Carl创造的模板。

理由是模板的算法设计逻辑很清晰,方便我们理解、调试、编码。模板如下:

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

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

把模板上面的步骤逻辑用代码都实现后,算法也就编码完成了。

我想强调一下,你书写其中的一个步骤的时候,并不是意味着你不用管其他的任何逻辑,而是说你主要专注的工作。有些时候你仍需要了解一些实现原理才能完成这一步骤。

实例讲解

现在解决一个具体的实例,体悟我上面的算法实现过程。

进行这个题目之前,我先要进行一些说明。后面整个代码我都是基于python写的。因为c很底层,很多内容都需要自己实现,导致代码实现起来看起来很繁多,怕给大家造成心理压力,而且不能突出真正的关键的逻辑。所以我是基于python的,不过大家不要担心,我会对每一步执行进行备注。

如果初次遇到这个问题,我们得先审题。很容易觉察到这是要暴力枚举,而且循环的层数由k来决定,而k又不确定。这是回溯算法的使用场景,那我们确定,这题主要的算法逻辑,就是回溯算法。我们开始联想到回溯算法问题的解题流程。

class Solution:
    def combine(self, n: int, k: int): # 会传进两个整数,分别被n、k接受
        lst = [] # 存储当前目标组合的元素
        result = [] # 用来储存所有组合情况

        backstarcking:传入result,lst——将所有的组合存入result列表——>传出void

        return result

没错,就是写递归函数的封装说明。大致对于此题我初步想的是:传入result,lst——将以lst里目前元素为基础的 所有的组合 存入result列表——>传出void。至于为什么要先定义这两个变量 请结合需要自行理解。

第二步我们思考这个回溯算法的逻辑,控制了这棵树的深度和宽度,我们大概也就把握了这颗树、这个回溯算法。数的深度怎么控制呢?也就是怎么控制递归的次数?结合题目,我们得有个变量来记录当前组合的个数,当个数为k时就不继续深入,也就控制了递归的次数。宽度呢?因为组合是无关组合内元素的排列顺序(所以宽度不能从头开始遍历)并且不能出现重复元素(所以新一层跳过该元素),所以宽度的遍历的范围是 上层节点值的下一个数,到n。所以前面我们设计的递归函数的接口要修改,改成:backstarcking:传入result,lst,m(横向要添加的始位置), floor(当前列表里的元素个数)——将以lst目前元素为基础的组合存入result列表——>传出void。

最后我们来到真正代码的编码:

backtracking(return, lst, m, floor):
    if floor == k: # 如果floor层数为k时:
        result.append(lst.copy()) # 把lst复制到result,存下来
        return

    for 选择:本层集合中元素(树中节点孩子的数量就是集合的大小):
        处理节点
        backtracking(路径,选择列表)
        回溯,撤销处理结果
def backstarcking(result, lst, m, floor):
      if floor == k: # 如果floor层数为k时:
          result.append(lst.copy())# 把lst复制到result,存下来
          return
      for i in range(m, n + 1): # 目前lst尝试添加另一种情况
                            # (这类似于java的迭代器,range会生成一个1-n的序列,for in是依次取出每一个元素赋给i变量)
          lst.append(i) # 把i添加到lst
          backstarcking(result, lst, i + 1, floor + 1) # 进入深一层次的递归
          lst.pop() # 把i弹出

我们关注到 backstarcking(result, lst, i + 1, floor + 1) 这一行代码,这一行代码执行结束究竟发生了什么?欸,我们的封装说明就起到作用了,把这行代码执行结束以lst目前元素为基础的组合存入result列表。如果lst里只有1,那么结束后,result里就出现了以1为基础的所有目标组合。然后当然是弹出1,换成以2为基础。是不是非常清晰?爽不爽?

把代码整合起来就是

class Solution:
    def combine(self, n: int, k: int) -> List[List[int]]:
        def backstarcking(result, lst, m, floor):
            if floor == k: # 如果floor层数为k时:
                result.append(lst.copy())# 把lst复制到result,存下来
                return
            for i in range(m, n + 1): # 目前lst尝试添加另一种情况
                lst.append(i) # 把i添加到lst
                backstarcking(result, lst, i + 1, floor + 1) # 进入深一层次的递归
                lst.pop() # 把i弹出

        lst = [] # 存储当前目标组合的元素
        result = []

        # backstarcking:传入result,lst,m(横向要添加的始位置), floor(当前列表里的元素个数)——将以lst目前元素为基础的组合存入result列表——>传出void
        backstarcking(result, lst, 1, 0)

        return result

 这里留意下开始执行 backstarcking(result, lst, 1, 0) 时为什么m、floor初始值为1和0。还是通过封装说明来把握,m是要添加的位置,我们从1开始。对于floor,实际上当时lst里就是没元素,那就初始为0。最后就想讲一下,关于回溯算法的优化,回溯算法是高时间复杂度算法,但尽管如此也能在此基础上优化,有些时候这种优化效果还很客观。这个优化过程我们叫它减枝。

def backstarcking(result, lst, m, floor):
      if floor == k: # 如果floor层数为k时:
          result.append(lst.copy())# 把lst复制到result,存下来
          return
      for i in range(m, n + 1): # 目前lst尝试添加另一种情况
          # 减枝逻辑
          if n - m + 1 < k - floor: # 如果开始到结束的个数小于需要,就不做了。
              break
          
          lst.append(i) # 把i添加到lst
          backstarcking(result, lst, i + 1, floor + 1) # 进入深一层次的递归
          lst.pop() # 把i弹出

注意我上面添加的代码。那就是减枝操作,是在干什么?其实说的是 能提供的所有元素的个数,即使这样还不能满足个数需要的话,那就没必要做了,这棵树后面的所有遍历都被砍掉。这只是根据题意的一种减枝,也就是说我们这棵树根据具体情况没必要那么大,可以找可能去砍枝,去优化算法。

对于有些细节,还没有特别详细讲,考虑到篇幅,而且我相信你们能够凭自己理解。理解不了给我评论。

以上就是leetcode 77组合的全部代码。大家听懂了吗?

总结

总结一下解决回溯问题的流程。

待大家把其他简单代码逻辑实现,来实现递归模块的时候。

第一步:先写出封装说明。格式是: 输入XXX——做了什么——>输出XXX。

第二步:确定树的深度和宽度实现的逻辑。

第三步:结合具体情况用代码实现模板里的逻辑。

~~~~~~

以上就是我本篇想讲的所有内容了,如果这篇文章对你有价值的话,还请点个赞,你的支持对我非常重要!

我是阿航,一位胆大包天、梦想成为大牛的学生~ 

我们下篇文章再聊

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值