代码随想录 Day 28 | 【第七章 回溯算法part 01】理论基础、77. 组合、216.组合总和III、17.电话号码的字母组合

一、理论基础

其实在讲解二叉树的时候,就给大家介绍过回溯,这次正式开启回溯算法,大家可以先看视频,对回溯算法有一个整体的了解。

1. 什么是回溯

有回溯就有递归,回溯函数就是递归函数。

2. 使用原因及解决的问题

(1)使用原因:纯暴力,但有些问题必须采用回溯,因为for循环嵌套搜索失效。

(2)解决问题:

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 棋盘问题:N皇后,解数独等

注意区别:组合是不强调元素顺序的,排列是强调元素顺序

3. 如何理解回溯法

        回溯法可以抽象为一个树形结构(N叉树),因为回溯就是一个递归的过程;回溯抽象为N叉树,横向for循环,纵向递归。

4. 回溯模板

(1)确定递归函数的参数和返回值:一般来说,回溯法没有返回值,参数较多,一开始无法直接写出参数,所以在写的过程中添加参数即可。

(2)确定终止条件:在终止条件中收集结果(if 语句)。

(3)单层递归逻辑:for循环的参数用于处理集合每一个元素,也就是for循环遍历集合的每一个参数,然后在循环中处理节点,然后进入递归过程,最后回溯(撤销处理节点的情况)。

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

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

 

二、77. 组合

        对着 在 回溯算法理论基础 给出的 代码模板,来做本题组合问题,大家就会发现 写回溯算法套路。

        在回溯算法解决实际问题的过程中,大家会有各种疑问,先看视频介绍,基本可以解决大家的疑惑。

        本题关于剪枝操作是大家要理解的重点,因为后面很多回溯算法解决的题目,都是这个剪枝套路。

题目链接/文章讲解:代码随想录

视频讲解:带你学透回溯算法-组合问题(对应力扣题目:77.组合)| 回溯法精讲!_哔哩哔哩_bilibili

剪枝操作:带你学透回溯算法-组合问题的剪枝操作(对应力扣题目:77.组合)| 回溯法精讲!_哔哩哔哩_bilibili

1. 代码实现

(1)定义全局变量一维数组path和二维数组result。

(2)确定递归函数的参数和返回值:传入的参数n表示数组的大小,k表示组合的大小,startindex表示本次搜索的起始位置。 

(3)确定终止条件:抵达叶子节点,也就是path的大小等于k,然后使用result数组收集这个结果,然后返回即可。

(4)单层递归逻辑:每个节点实际上都是一个for循环,for循环中起始位置是startindex去遍历剩余元素。先把第i个元素放进path,然后进入递归,传入n、k、i+1,这样从第i+1个元素开始搜索;最后进行回溯过程,将path清空。

class Solution:
    def combine(self, n: int, k: int) -> List[List[int]]:
        result = []
        self.backTracking(n, k, 1, [], result)
        return result

    def backTracking(self, n, k, startindex, path, result)->List[List[int]]:
        if len(path) == k:
            result.append(path[:])
            return
        for i in range(startindex, n+1):
            path.append(i)
            self.backTracking(n, k, i+1, path, result)
            path.pop()

2. 代码易错点

(1). 代码里为什么要写成result.append(path[:])而不是写result.append(path):

在这段代码中,result.append(path[:])的使用是为了确保添加到result列表中的每个组合都是path列表的一个独立副本,而不是对path的引用。这是因为path在递归过程中会被修改(通过path.append(i)path.pop()),如果我们直接添加path本身到result中,那么当path在后续的递归中被修改时,已经添加到result中的列表也会受到影响,因为它们指向的是同一个对象。

详细解释如下:

  1. 列表是可变的:在Python中,列表是可变的数据类型。这意味着如果你将一个列表赋值给另一个变量,或者将一个列表作为元素添加到另一个列表中,这两个变量实际上引用的是同一个列表对象。因此,通过一个变量对列表所做的修改会影响到另一个变量。

  2. 递归中的状态变化:在回溯算法中,path列表用于存储当前的组合状态。随着递归的进行,path会被不断地修改(添加元素和移除元素)。

  3. 避免共享状态:当你找到一个满足条件的组合(即len(path) == k)时,你需要将这个组合保存下来。如果此时你使用result.append(path),你实际上是在向result中添加了一个对当前path状态的引用。由于path在后续的递归中会被修改,这个引用所指向的列表内容也会改变。

  4. 创建副本:为了避免这个问题,path[:]创建了一个path的浅拷贝。这意味着你得到了一个新的列表,它与path有相同的元素,但它们是两个不同的对象。因此,当path在后续的递归中被修改时,这个新列表的内容不会受到影响。

所以,result.append(path[:])确保了每个添加到result中的组合都是独立的,不会受到后续递归中path修改的影响。这是回溯算法中一个常见的技巧,用于正确处理可变数据结构在递归过程中的状态变化。

(2). 为什么使用path.pop()而不是path = []来重置path列表

在回溯算法中,使用path.pop()而不是path = []来重置path列表的原因在于它们对列表对象本身的影响不同。

  1. path.pop()
    • path.pop()方法会从path列表的末尾移除一个元素,并返回这个被移除的元素。
    • 在回溯算法中,这通常用于撤销上一次的选择,以便尝试其他可能的选择。
    • 当递归调用返回时,我们需要回到上一次的状态,以便继续搜索。使用path.pop()可以保留path列表之前的所有元素,只移除最后添加的那个元素。
  2. path = []
    • path = []会创建一个新的空列表,并将变量path指向这个新列表。
    • 这意味着原来的path列表(以及可能包含的对它的引用)将被丢弃,所有之前添加到path中的元素都将丢失。
    • 在回溯算法中,如果我们使用path = []来重置列表,我们将无法撤销上一次的选择,因为我们已经丢弃了包含之前所有选择的原始列表。

在回溯过程中,我们需要能够回溯到之前的每一步,以便尝试所有可能的选择。为了实现这一点,我们必须保留每一步的状态,并在需要时能够撤销选择。path.pop()允许我们这样做,因为它只移除最后添加的元素,而不影响列表中的其他元素。

因此,在回溯算法中,我们通常使用path.pop()(或类似的撤销操作)来回到上一步的状态,而不是创建一个全新的列表并丢弃之前的状态。这样做可以确保我们能够正确地遍历所有可能的解,并且每个解都是基于之前步骤的有效选择构建的。

3. 组合问题的剪枝操作

所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置

如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了

class Solution:
    def backTracking(self, n, k, startindex, path, result)->List[List[int]]:
        if len(path) == k:
            result.append(path[:])
            return
        for i in range(startindex, n-(k-len(path))+2):
            path.append(i)
            self.backTracking(n, k, i+1, path, result)
            path.pop()

    def combine(self, n: int, k: int) -> List[List[int]]:
        result = []
        path = []
        self.backTracking(n, k, 1, path, result)
        return result

 

三、216.组合总和III

如果把 组合问题理解了,本题就容易一些了。

1. 代码实现

(1)首先定义变量result(存放结果集)和path(收集组合)。

(2)确定递归函数的参数和返回值:参数targetSum相当于题目中相加为n;k是要求的组合数,用于控制树的深度;sum是当前组合数的加和,用于与targetSum进行比较;startindex用于控制当前递归层取数的起始位置,初始化为1开始。

(3)确定终止条件:当path的长度等于要求的k,那么终止递归;然后判断组合的和等于targetSum,那么它是满足目标值的组合,放进结果集。

(4)单层递归逻辑:for循环,从startindex开始(相当于从1开始,因为题目说明了数字从1-9),到9结束:遍历i,就把i加入进sum中,同时集合path收集i,然后继续递归,传入targetsum、k、sum、i+1。然后进行回溯,sum-i,将path弹出。

(5)剪枝操作(本题有组合个数和组合总和均有要求,因此可以从这两个角度剪枝):如果sum>targetsum,可以直接return,没有必要再递归了;此外,从元素角度进行考虑可以剪枝,如果横向元素个数不足k,那么继续向后遍历也没有意义,无法凑成元素个数为k的组合。

class Solution:
    def backTracking(self, targetSum, sum, k, startindex, path, result):
        if targetSum < sum: return
        if len(path) == k:
            if sum == targetSum:
                result.append(path[:])
            return
        for i in range(startindex, 9-(k-len(path))+2):
            sum += i
            path.append(i)
            self.backTracking(targetSum, sum, k, i+1, path, result)
            sum -= i
            path.pop()

    def combinationSum3(self, k: int, n: int) -> List[List[int]]:
        result = []
        path = []
        self.backTracking( n, 0, k, 1, path, result)
        return result

四、17.电话号码的字母组合

本题大家刚开始做会有点难度,先自己思考20min,没思路就直接看题解。

1. 代码实现

(1)定义字符串string,用于收获单个结果;定义string类型的数组result,用于收集结果。 

(2)确定递归的返回值和参数:参数digits是字符串类型,参数index表明当前递归到哪一个数字了。

(3)确定终止条件:index指向空才能结束递归,因为如果指向最后一个数字就终止的话,最后一个数字没有得到下面的递归处理,导致结果集不完整。在叶子节点收获结果:将path放入结果集result,最后return。

(4)单层递归逻辑:由于digit是一个字符串,所以需要将里面的字符单独取出来,将字符数字转变为真正的数字。然后根据这个数字,在map里面找数字对应的字符串。for循环在字符串里获取字符,将字符放进自定义的字符串里,继续递归,传入digits和index+1(在另一个字符串里进行搜索);最后进行回溯string.pop()。

class Solution:
    def __init__(self):
        self.lettermap=[           
            "abc",  # 2
            "def",  # 3
            "ghi",  # 4
            "jkl",  # 5
            "mno",  # 6
            "pqrs", # 7
            "tuv",  # 8
            "wxyz"  # 9
        ]
        self.result = []
        self.s = ''

    def backTracking(self, digits, index):

        if index == len(digits):
            return self.result.append(self.s)
        digit = int(digits[index])
        letter = self.lettermap[digit-2]

        for i in range(len(letter)):
            self.s += letter[i]
            self.backTracking(digits, index+1)
            self.s = self.s[:-1]

    def letterCombinations(self, digits: str) -> List[str]:
        if len(digits) == 0: 
            return self.result
        self.backTracking(digits,0)
        return self.result

2. 代码易错点

        不可以对字符串(str)对象使用 .append() 方法,因为字符串并没有这个方法。.append() 方法是用于列表(list)的,用于在列表末尾添加一个新的元素。字符串可以用join()或直接相加减。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值