一道题谈回溯

CSDN话题挑战赛第1期
活动详情地址:https://marketing.csdn.net/p/bb5081d88a77db8d6ef45bb7b6ef3d7f
参赛话题:Leetcode刷题指南
话题描述:代码能力是一个程序员的基本能力,而除了做项目之外,大家接触到的最常规的提升代码能力的方法基本就是刷题了,因此,加油刷题,冲刺大厂!
创作模板:Leetcode刷题指南


这两天LeetCode刷题遇到几道回溯法的题,借LeetCode中的第526题总结一下。

1)原题描述

N个正整数:1,2,3…N可以任意排列,求出输入为N(N不大于15)时,满足要求的排列的个数。其中要求为,对于一个给定的排列A,如果它的任意位置i(1 <= i <= N)都有第i个位置的数可以整除i,或者i可以整除A的第i个位置的数。

注意这里的位置i是从1开始取值的,我们如果用数组表示A,则A的索引是从0开始,这样上述条件可以表示为:A[j] % (j+1) == 0 或者(j + 1) % A[j] == 0;其中j是A的索引,有0 <= j <= N – 1。

例如:

输入:N=2

输出:2

解释:1,2的排列一共两种:[1,2]和[2,1]。可以验证,这两个排列都满足要求。

2)回溯法

这道题官方给出的解法是回溯法。基本思路是这样的:设集合s包含这N个正整数,我们从中选出一个满足要求的数a放到第一个位置,然后从s中除去a;之后,再从第二个位置开始重复这个选数的操作,直至最后一个位置也放好了数,这样我们就得到了一个满足要求的排列A。需要注意的是在为第i个位置选数时,可能得到多个满足要求的数,我们应该对每一个满足要求的选择进行后续的递归处理。如果我们初始化s为s = [True] * N,也就是用s[j] == True 代表正整数j + 1 还没有被选择。则为第i个位置选数的代码可以写成这样:

def helper(i):
……
begin_index = i - 1 #begin_index是索引,从0开始,所以要减1
# 从s中搜索未使用的数
for j in range(0, N):
if s[j] and (i % (j + 1) == 0 or (j + 1) % i == 0):
s[j] = False # 代表j + 1已经被使用
helper(i + 1) #递归地对第i+1位置进行选数操作
s[j] = True # 对第i个位置的选择不唯一,重置s[j],以便尝试新的选择
到这里,主要的部分就完成了,之后还需要完善一下helper,这个待会儿说。我把这个递归的过程画出来,说一下为啥这种方法被称为回溯。

图中a,b,c,d,e代表的是正整数,蓝色箭头线代表递归操作或者说是选数操作,红色是操作的序号。从开始处起,为第一个位置选了a;之后第二个位置选了b,直至为最后一个位置选了e。此时我们得到了一个满足要求的序列。之后向前一个位置返回,查看之前的位置是否有其他选择,这一步对应序号为5的操作,也就是回到了倒数第二个位置。发现并没有其他可以选择的数,则继续向前一个位置返回,这对应序号为6的操作,此时发现了新的选择e,则对其进行递归操作,这一步对应着序号为7的操作。之后的步骤类似,这里图没有画完。其实可以看出,整个过程相当于先序遍历一棵树,只不过这棵树是隐含的,没有被实际创造出来。我们的每一步选数操作,实际上包含试探意味的,也就是,我只是为当前位置选择了一个合适的数,但是在这种选择之下,之后的每个位置是否都还可以选出合适的数以组成一个满足要求的序列,这一点我们是不确定的。所以我们每完成一个选数操作后,对之后的情况进行试探性的递归,这一点,我感觉可以看作是使用回溯的动机。

现在谈一下回溯的作用。我们这样重新解释一下上图:假设不止abcde这5个正整数,经过第4步选择操作,我们为当前的位置选择了一个合理的数e,但是,在之后的选择,我们发现没有任何一个符合要求的数可以供我们选择,所以立即向前一个位置返回,重新进行前一个位置的选择。这种情况下,回溯其实是做到了对上面这个树状图进行了“裁剪”的操作,它避免了我们深入一个已知的错误分支,这一点便是回溯的作用。

当时我提交的代码如下:

def countArrangement(self, N: int) -> int:
#数的集合,上文中的s
num_set = [True] * N
res = 0 # 计数器变量,我们最终要求的
# 这里使用的是索引,和上文中的位置i略有出入,问题不大
def helper(begin_index):
nonlocal res, num_set
#begin_index指向完整序列末尾的下一个位置,代表所有位置都满足要求了,这也对应树状图的终端结点
if begin_index == N:
res += 1 # 递增计数器变量
return
# 选择部分的代码
for i in range(0, N):
if num_set[i] and ((i + 1) % (begin_index + 1) == 0 or (begin_index + 1) % (i + 1) == 0):
num_set[i] = False
helper(begin_index + 1)
num_set[i] = True
helper(0)
return res
值得一提的是,这种方法在运行时间上仅仅超过了25%的用户。下面谈谈原因和改进。

3)动态规划

考虑下面的一种情况:

从开始进行选数,通过一系列操作,到第四步,得到数e,但是之后的选择中没有一个满足要求的数,所以我们发现这种选择不可行,于是通过第5步向前回溯。中间省略一些步骤,来到第n步操作,经过一些选择操作,依旧是在第5个位置选出e之后,发现无法继续之后的选择,于是先前回溯。问题就出在这里,第2步操作的情况,和第n+2步的操作应该是完全一样的,因为待选的数集是一样的:都是除去a和b之外的所有整数。然而我们却对一个相同的问题进行的重复的探索求解,从而造成了不必要的开销。看到这里,很明显,为了解决相同问题的重复求解,我们应该使用动态规划。这里不再细说动态规划的原理,谈一下实现备忘录的技巧。

上面的讨论可以看出,判断两个子问题是不是同一个子问题,可以通过比较两个子问题下的待选数集是否是相同的。如果两个子问题都是从{c,d,e}中进行选数排列,那显然这两个问题是同一个子问题。之后,计划使用一个字典(dict)来作为备忘录,其中待选数的集合作为键值,以存储每个子问题对应的结果。上边实现回溯时我们使用的是list来表示数集。所以现在的问题是list作为一种可变对象,不能作为dict的键值。一种方法是将list转换为tuple;另一种方法是将数集用一个正整数表示。数集实际表示的是对应位置的正整数是否已经使用,所以每个元素仅取值为True或者False。这其实可以使用一个正整数在二进制表示下相应的位为0或者1来表示。比如[True, True, False, True] 我们可以表示为0100b = 4。这里我们用0代表了True,1代表了False,这样做减少了备忘录的空间开销,缺点时,需要通过位操作来访问和修改每一位。代码如下:

def countArrangement(self, N: int) -> int:
# 备忘录
cache = {}
# 作用和回溯法一样,只不过num_set变成了整数类型
def helper(begin_index, num_set):
nonlocal cache
# 查看备忘录,避免子问题重复求解
if num_set in cache:
return cache[num_set]
if begin_index == N:
return 1
res = 0
i = begin_index + 1
for j in range(0, N):
num_bit = (num_set & (1 << j)) >> j
if not num_bit and (i % (j + 1) == 0 or (j + 1) % i == 0):
num_set |= 1 << j
res += helper(i, num_set)
num_set &= ~(1 << j)
cache[num_set] = res
return res
# 从索引0出开始;num_set为0代表所有的数都未被使用
return helper(0, 0)
还有一个不同点,回溯法时helper直接修改它上一级作用域的变量res;但是动态规划时我们需要记录子问题的结果,所以为helper设置了返回值。

CSDN话题挑战赛第1期
活动详情地址
:https://marketing.csdn.net/p/bb5081d88a77db8d6ef45bb7b6ef3d7f

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值