LeetCode专题——详解搜索算法中的搜索策略和剪枝

本文始发于我的公众号:TechFlow,原创不易,求个关注web


今天是LeetCode专题第20篇文章,今天讨论的是数字组合问题。算法

描述

给定一个int类型的候选集,和一个int类型的target,要求返回全部的数字组合,使得组合内全部数字的和恰好等于target。数组

注意:数据结构

  1. 全部的元素都是正数
  2. 全部元素没有重复
  3. 答案不能有重复
  4. 每个元素可使用若干次

样例 1:app

Input: candidates = [2,3,6,7], target = 7,
A solution set is:
[
  [7],
  [2,2,3]
]

样例 2:编辑器

Input: candidates = [2,3,5], target = 8,
A solution set is:
[
   [2,2,2,2],
  [2,3,3],
  [3,5]
]

题解

咱们拿到这道题仍是按照老规矩来思考暴力的解法,可是仔细一想会发现好像没有头绪,没有头绪的缘由也很简单,由于题目当中的一个条件:一个元素能够随意使用若干次优化

咱们根本不知道一个元素可使用多少次,这让咱们的暴力枚举有一种无从下手的感受。若是去掉这个条件就方便多了,由于每一个元素只剩下了两个状态,要么拿要么不拿,咱们能够用一个二进制的数来表示。这就引出了一个经常使用的表示状态的方法——二进制表示法编码

二进制表示法

举个例子,假如当下咱们有3个数字,这3个数字都有两个状态选或者不选,咱们想要枚举这3个数字的全部状态,应该怎么办?spa

咱们固然能够用递归来实现,在每层递归当中作决策当前元素选或者不选,分别递归。可是能够不用这么麻烦,咱们能够用二进制简化这个过程。这个原理很是简单,咱们都知道在计算机二进制当中每个二进制位只有两个状态0或者1,那么咱们就用1表示拿,0表示不拿,那么这三个数拿或者不拿的状态其实就对应一个二进制的数字了。3位二进制,对应的数字是0到7,也就是说咱们只须要遍历0到7,就能够得到这3位全部拿和不拿的状态了。翻译

好比说咱们当下遍历到的数字是5,5的二进制表示是101,咱们再把1和0对应拿和不拿两种状态。那么5就能够对应上第一和第三个拿,第二个不拿的状态了。咱们能够用位运算很方便地进行计算。好比咱们要判断第i位是否拿了,咱们能够用(1 << i),<<的意思是左移,左移一位至关于乘2,左移n位就至关于乘上了2的n次方。1对应右边起第0位,也就是最低位的二进制位,咱们对它作左移i的操做就至关于乘上了,那么就获得了第i位了。咱们拿到了以后,只须要将它和状态state作一个二进制中的与运算,就能够获得state中第i位到底是0仍是1了。

由于在二进制当中,and运算会将两个数的每一位作与运算,运算的结果也是一个二进制数。因为咱们用来进行与运算的数是(1 << i),它只有第i位为1,因此其余位进行与运算的结果必然是0,那么它和state进行与运算以后,若是结果大于0,则说明state的第i位也是1,不然则是0。这样咱们就获取了state当中第i位的状态。

因为位运算是指令集的运算,在没有指令集优化的一些语言当中,它的计算要比加减乘除更快。除了快之外它最大的好处是节省空间和计算方便,这两个优势实际上是一体的,咱们一个一个来讲。

首先来讲节省空间,有了二进制表示以后,咱们能够用一个32位的int来表明32个物体的0和1的全部状态。若是咱们用数组来存储的话,显然咱们须要一个长度为32的数组,须要的空间要大得多。这一点在单个状态下并不明显,一旦数据量很大会很是显著。尤为是在密集的IO当中,数据越轻量则传输效率越高

第二个优势是计算方便,计算方便的缘由也很简单,假如咱们要遍历全部的状态,若是用数组或者其余数据结构的话免不了使用递归来遍历,这样会很是麻烦。而使用二进制以后就方便了,因为咱们用二进制表示了全部元素0和1的状态,咱们只须要在一个整数范围作循环就能够了。就像刚才例子当中,咱们要枚举3个元素的状态,咱们只须要从0遍历到7便可。若是在多点元素也没问题,若是是N个元素,咱们只须要从0遍历到(1 << N) - 1。

可是还有一个问题没解决,你可能会说若是咱们用int来表示状态的话,最多只能表示32个物品的状态,若是更多怎么办?一个方法是使用int64,即范围更大的int,若是范围更大的int仍是解决不了问题也不要紧,还有一些基于一样原理实现的第三方包能够支持。可是老实说咱们基本上不会碰到超过64个物品让咱们枚举全部状态的状况,由于这个数字已经很是大了,几乎能够说是天荒地老也算不完。

回到问题

我相信关于二进制表示法的使用和原理,你们应该都了解了,可是本题当中元素是能够屡次出现的,二进制表示法看起来并不顶用,咱们怎么解决这个问题呢?难道这么大的篇幅就白写了?

固然不会白写,针对这种状况也有办法。其实很简单,由于题目当中规定全部的元素都是正数,那么对于每个元素而言,咱们最多取的数量是有限的。举个例子,好比样例当中[2, 3, 6, 7] target是7,对于元素2而言,target是7,即便能够屡次使用,也最多能用上3个2。那么咱们能够拓充候选集,将1个2拓充成3个,同理,咱们能够继续拓充3,最后候选集变成这样:[2, 2, 2, 3, 3, 6, 7],这样咱们就可使用二进制表示法了。

可是显然这个方法不靠谱,由于若是数据当中出现一个1,而且target稍微大一些,那确定直接gg,显然会复杂度爆炸。因此这个方法只是理论上可行,可是实际上并不具备可操做性,我之因此拿出来介绍,纯粹是为了引出二进制表示法。

搜索解决一切

当一个问题明显有不少种状况须要遍历,可是咱们又很难直接遍历的时候,每每都是搜索问题,咱们能够思考一下可否用搜索问题的方法来解决。

这题其实已经很是明显了,搜索的条件已经有了,搜索的空间也明白了,剩下的就是制定搜索策略

我我的认为搜索策略其实就是搜索的顺序和范围,合适的搜索顺序以及范围能够大大下降编码和计算的复杂度,再穿插合适的剪枝,就能够很是漂亮地完成一道搜索问题。

咱们带着思考来看这道题,若是咱们用回溯法来写这道题的话,代码其实并不复杂。很容易就能够写出来:

def dfs(x, sequence, candidates, target):
if x == target:
ans.append(sequence)
return

for i in candidates:
if x + i > target:
continue
sequence.append(i)
dfs(x+i, sequence, candidates, target)
sequence.pop()

你看只有几行,咱们每次遍历一个数加在当前的总和x上而后往下递归,而且咱们还加上了对当前和判断的剪枝。若是当前和已经超过了target,那么显然已经不可能构成正解了,咱们直接跳过。

可是咱们也都发现了,在上面这段代码里,咱们搜索的区间就是全部的候选值,咱们没有对这些候选值进行任何的限制。这其实隐藏了一个很大的问题,还记得题目的要求当中有一条吗,答案不能有重复。也就是说相同元素的不一样顺序会被认为是同一个解,咱们须要去重。举个例子,[3, 2, 2]和[2, 2, 3]会被认为是重复的,可是在上面的搜索策略当中,咱们没有对这个状况作任何的控制,这就致使了咱们在找到全部答案以后还须要进行去重的工做。先找到包含重复的答案,再进行去重,这显然会消耗大量计算资源,因此这个搜索策略虽然简单,但远远不是最好的。

咱们先来分析一下问题,究竟何时会出现重复呢?

我想你们列举一下应该都能发现,就是当咱们顺序错乱的时候。好比说咱们有两个数3和4,咱们先选择3再选择4和先选择4再选择3是同样的。若是咱们不对3和4的选择作任何限制,那么就会出现重复。换句话说若是咱们对3和4的选择肯定顺序就能够避免重复,若是从一开始就不会出现重复,那么咱们也就没有必要去重了,这就能够节省下大量的时间。

因此咱们要作的就是肯定搜索的时候元素选择的顺序,在搜索的时候进行必定的限制,从而避免重复。落实在代码当中就体如今咱们枚举候选集的时候,咱们以前没有作任何限制,咱们如今须要人为加上限制,咱们只能选择以前选过的元素后面的,只能日后拿不能往前拿。因此咱们须要在dfs当中传入一个下标,标记以前拿过的最大的下标,咱们只能拿这个下标以后的,这样搜索就有了顺序,就避免了元素重复和复杂度太高的问题

这一点肯定了以后,剩下的代码就很简单了。

class Solution:
def dfs(self, ret, pos, sequence, now, target, candidates):
if now == target:
# 加入答案,须要.copy()一下
ret.append(sequence.copy())
return

for i in range(pos, len(candidates)):
# 若是过大则不递归
if now + candidates[i] > target:
continue
# 存入sequence,往下递归
sequence.append(candidates[i])
self.dfs(ret, i, sequence, now+candidates[i], target, candidates)
sequence.pop()

def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
ret = []
self.dfs(ret, 0, [], 0, target, candidates)
return ret

从代码上来看,咱们并无作太大的改动,全部的细节几乎都体如今搜索和遍历时的边界以及控制条件上。和整个算法以及代码逻辑比起来,这些是最可有可无的,可是对于解决问题来讲,这些才是实实在在的。

题目变形

今天的题目有一个变种,它就是LeetCode的第40题,大部分题意都同样,只有两个条件发生了变化。第一是40题当中去掉了候选集当中的元素没有重复的限制,第二点是再也不容许元素重复使用。其余的内容都和这题保持一致。

咱们想一下就会发现,若是咱们去掉重复使用的条件,好像没什么变化,咱们是否是只要将递归遍历的条件稍稍改动就行了呢?以前咱们是从pos位置开始化后遍历,如今因为不能重复,因此以前取过的pos不能再取,咱们是否是只要将for循环改为从pos+1开始就好了?

若是候选集的元素中没有重复,这固然是可行的。可是很遗憾,这个条件也被去掉了。因此候选集当中自己就可能出现重复,若是还按照刚才的思路会出现重复的答案。

缘由也很简单,举个例子,好比说候选集是[1, 2, 3, 2, 2],target是5,若是还用刚才的方法搜索的话,咱们的答案当中会出现两个[2, 3]。虽然咱们也是每一个元素都只用了一次,可是仍然违背了答案不能重复的限制。

你可能会有不少想法,好比能够手动去重,好比咱们能够在元素数量上作手脚,将重复的元素去重。很遗憾的是,二者都不是最优解。第一种固然是可行的,找到全部可行解再去重,是一个很朴素的思路。经过优化,能够解决复杂度问题。第二种想法并不可行,由于若是咱们把重复的元素去掉,可能会致使某些解丢失。好比[1, 2, 2],也是和等于5,可是若是咱们把重复的2去掉了,那么就没法获得这个解了。

要解决问题,咱们仍是要回到搜索策略上来。手动筛选、加工数据只是逼不得已的时候用的奇淫技巧,搜索策略才是解题的核心

咱们整理一下思路,能够概括出当前须要咱们解决的问题有两个,第一个是咱们要找到全部解,意味着咱们不能删减元素,第二个是咱们想要搜索的结果没有重复。这看起来是矛盾的,咱们既想要不出现重复,又想重复的元素能够出现,这可能吗?

若是你仔细思考分析了,你会发现是可能的。不过从搜索策略的角度上来讲,比较难想到。首先咱们要保证元素的汇集性,也就是说相同的元素应该汇集在一块儿。要作到这点很简单,咱们只须要排序就好了。这么作的缘由也不难想到,就是为了不重复。若是数据是分散的,咱们是很难去重的,还用刚才的例子,当咱们从2开始递归的时候,咱们能够找到解[2, 3],当咱们从3开始递归的时候,咱们仍然能够找到解[3, 2],这二者是同样的。虽然咱们限制了遍历的顺序严格地从前到后,可是因为元素分散会使得咱们的限制失去做用。为了限制依旧有效,咱们须要排序,让相同的元素汇集,这样咱们每次搜索的内容实际上是由大于等于当前元素的数字组成的答案,这就保证了不在重复。

可是这并无解决全部的问题,咱们再来看一个例子,候选集是[2, 2, 2, 3, 4],target是7,显然[2, 2, 3]是答案,可是咱们怎么保证[2, 2, 3]只出现一次呢?由于咱们有3个2,可是要选出两个2来,咱们须要一个机制,使得只会找到一个答案。这点经过策略已经无能为力了,只能依靠剪枝。咱们固然能够引入额外的数据结构解决问题,但会比较麻烦,而咱们其实有更简单的作法。

这个作法是一个很是精妙的剪枝,咱们在递归当中加入一个判断:当i > pos+1 and candidates[i] == candidates[i-1]的时候,则跳过。其中pos是上次选择的位置,在递归的起始时,带入的是-1,我想这个条件应该你们都能看明白,可是它为何有效可能会一头雾水,翻译成大白话,这个条件实际上是在限制一点:在有多个相同元素出现的时候,必须选择下标小的,也就是前面的。

咱们分析一下可能触发continue的条件,只有两种状况,第一种:

其中pos是上次选择的数字,咱们假设它是1,咱们当前的位置在pos+3。从上图能够看出来,pos+1到pos+3全都相等。若是咱们想要选择pos+3而跳过pos+1和pos+2则会进入continue会跳过。缘由也很简单,由于前面递归的过程中已经选过pos和pos+1的组合了,咱们若是选了pos和pos+3的组合必定会构成重复。也就是说咱们保证了在连续出现的元素当中,若是要枚举的话,必需要从第一个开始。

另外一种状况也相似:

也就是说从pos到pos+3都是2,都相等,这个时候咱们跳过pos+1和pos+2直接选择pos+3也会进入continue,缘由也很简单,咱们如今枚举的是获取两个2的状况,在以前的递归当中已经没举过pos和pos+1了,咱们如今想要跳过pos+1和pos+2直接获取pos+3,对应的状况是同样的,因此须要跳过。

咱们将排序和上述的剪枝方法一块儿使用就解出了本题,仔细观察一下会发现这两个方法根本是相辅相成,天做之合,单独使用哪个也无论用,可是一块儿做用就能够很是简单地解出题目。理解了这两点以后,代码就变得很简单了:

class Solution:

def dfs(self, ret, sequence, now, pos, target, candidates):
if now == target:
ret.append(sequence.copy())
return

for i in range(pos+1, len(candidates)):
cur = now + candidates[i]
# 剪枝
# 若是多个相同的元素,必须保证先去最前面的
if cur > target or (i > pos+1 and candidates[i] == candidates[i-1]):
continue
sequence.append(candidates[i])
self.dfs(ret, sequence, cur, i, target, candidates)
sequence.pop()

def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
# 排序,保证相同的元素放在一块儿
candidates = sorted(candidates)
ret = []
self.dfs(ret, [], 0, -1, target, candidates)
return ret

不知道你们有没有从这个变种当中感觉到搜索策略以及剪枝的威力和巧妙,我我的还蛮喜欢今天的题目的,若是可以把今天的两道题目吃透,我想你们对于深度优先搜索和回溯算法的理解必定能够更上一个台阶,这也是我将这两个问题合在一块儿介绍的缘由。在明天的LeetCode专题当中咱们会来看LeetCode41题,查找第一个没有出现的天然数。

今天的文章就到这里,若是以为有所收获,请顺手点个关注吧,大家的举手之劳对我来讲很重要。

本文使用 mdnice 排版

相关文章
相关标签/搜索