LeetCode17-电话号码的数字组合(递归的两种语义 & 与动态规划的联系)


观前提示:题目本身并不难(难度Medium)本文旨在通过对递归讨论,明晰递归中的【层级】概念,和动态规划那些说不清的关系,而不在题目本身。

题目背景

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

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

编程前

这是一道很有意思的题目,如果没见过这道题很可能上来就想遍历(不是说遍历不可以,文章后面的【深入与进阶】的动态规划部分探讨了遍历的可能性),但这道题“根正苗红”的解法是递归。本文采用了两种递归方式,并在【深入与进阶】的动态规划部分中讨论了类似dp的方法,用以帮助同学串联递归和dp。

编程中

编程伊始:边界检查

什么时候也不要忘了边界检查,这是一个很好的习惯。

class Solution:
    mapping = {  # 为了简单,这里就写两个数字
		"2": list("abc"),
		"3": list("def")
	}
    def letterCombinations(self, digits: str) -> List[str]:
    	if len(digits) == 0:
    	    return []
    	if len(digits) == 1:
    		return Solution.mapping.get(digitis[0])

编程核心:递归调用

接下来的方案是比较好想的,自己在第一次看到这个题目就想到了,就是用递归函数【递归调用可以解决某个小部分的问题】这个语义,这里可能不太好明白,先放代码,后续进行说明

class Solution:
    mapping = {  # 为了简单,这里就写两个数字
		"2": list("abc"),
		"3": list("def")
	}
    def letterCombinations(self, digits: str) -> List[str]:
    	if len(digits) == 0:
    	    return []
    	if len(digits) == 1:
    		return Solution.mapping.get(digitis[0])
    	res = []
    	for prev in self.letterCombinations(digits[:-1]):
    		for current in self.letterCombinations(digits[-1]):
    			res.append(prev+current)
    	return res

以上代码就是自己在第一次看到这道题写出的代码,已经可以完成任务了,但我们继续深入探讨。

编程完善:递归讨论

讨论:首先解决上一小节遗留的【递归调用可以解决某个小部分的问题】这个语义,在边界讨论之后,我们意识到这是一个滚雪球的题目,就是前面的【雪球】会越滚越大,不断地吸收下一个数字带来的【雪花】,形成更大的【雪球】。那么为了找到当前的【雪球】,就需要找到之前的【雪球和雪花】。听着有点熟悉吗?对,这不就是斐波那契数列吗。为了找到当前的数字,需要找到之前的数字和之前的之前的数字。有一点不一样的是,斐波那契的当前的雪花也是一个滚起来的具有相似结构的【雪球】。正是由于这种相似性(子结构),斐波那契“根正苗红”的解法是动态规划。

化归:在做斐波那契问题的时候,你可以很轻易的递归调用 fib(n-1) 和 fib(n-2)【解决某个小部分的问题】,怎么这道题就不行了呢,原因可能在于,在拿到了 n-1和 n-2 对应的结果之后,这道题还做了其他的操作,不像斐波那契那样只是简单的相加,那如果这样,是不是结构上和本题就很像了。

def fib(n):
	if n <= 2 : 
		return 1 
	prev = fib(n-2)
	current = fib(n-1)
	res = prev+current
	return res

总结:递归调用的一个很典型的应用,或者说思考方式就是【递归调用可以解决某个小部分的问题】,利用这种思考方式的递归题目非常多,像之前我们讨论的LeetCode10-正则匹配问题,或者刚才提到的斐波那契问题。它很典型的一个信号就是,题目存在滚雪球的结构,当然,这种滚雪球的结构是否需要用动态规划(是否存在相似子结构)就需要读者自己进行大量摸索了。

编程之禅:Debug

到这里,我们再回过头看最开始的代码,有两个地方可以再次精简,第一个是,经过刚才的讨论,我们发现没有利用起【雪花】这个语义,即,第二层嵌套可以用 mapping.get(digits[-1]) 代替 第二层嵌套。还可以利用python的列表推导式优化双层嵌套,优化完的代码如下:

class Solution:
    mapping = {  # 为了简单,这里就写两个数字
		"2": list("abc"),
		"3": list("def")
	}
    def letterCombinations(self, digits: str) -> List[str]:
    	if len(digits) == 0:
    	    return []
    	if len(digits) == 1:
    		return Solution.mapping.get(digitis[0])
    	prev = self.letterCombinations(digits[:-1])
    	current = mapping.get(digits[-1])   # 【雪花】而非【雪球】
    	return [i+j for i in prev for j in current]  # 列表推导式替换双层嵌套的循环

到现在,我们似乎得到了一份不错的代码,但是我们的主角,在最开始讨论的,递归中的【层级】概念还有和动态规划的联系呢,别着急,我们放到下一小节在讨论。

深入讨论

层级

引子:其实递归最经典的语义并不是【递归调用可以解决某个小部分的问题】,而是【层级】的概念,这个层级可以理解为一种遍历,但是由于递归结构的特殊性,通过【层级】来表达遍历。别着急,我知道你可能现在还听不懂我在说什么,我们还是先来看代码,之后再解释。

def letterCombinations(num_str):  # 此处省略关于mapping的定义
    res = []
    def helper(current_res):
        if len(current_res) == len(num_str):  # 跳出条件
            res.append(current_res)
            return 
        for n in num_str:   # 这里是存在问题的,每次都会重新开始
            for possible in mapping.get(n):
                helper(current_res+possible)
    helper("")
    return res

以上的代码是一种较为标准的递归结构,有眼尖的同学能够看出,以上的代码是不对的,在两层循环中的递归调用是有问题的,其中的外层循环会一次次的从字符串的开头开始,这就是前文提到的 由于递归的特殊性导致的。

递归特殊性的类比:当我们在进行递归调用的时候,函数体内的结构会重新再执行一遍,这个过程就有点像那种有轮回设定的动漫,比如《从零开始的异世界生活》,主角每次的轮回,所有的场景都会重新来过,但是主角的记忆是积累的(这种类型的动漫基于此,完成戏剧冲突和艺术张力)

层级的概念与应用:基于此我们引出层级的概念,每次递归调用就像往下走了一层,所有周围的环境都会重置,但是跟随着主角的记忆是会累积的,也就是【随着递归调用的参数】是会累积的,明确了这一点,我们就可以将最外层的那层循环,“放到主角的记忆中”,从而让循环得以继续,代码如下:

def dfs(num_str):
    res = []
    def helper(current_res, rest):
        if len(rest) == 0:
            res.append(current_res)
            return 
        for possible in mapping.get(rest[0]):
            helper(current_res+possible, rest[1:])
    helper("", num_str)
    return res

层级的讨论:我们用第二个参数的位置,表示【层级】也就是外层的循环这个语义,每次进入下一层的调用,就舍弃字符串的第一个字符,就好像在遍历一样。此时,代码就是对的了。

其他讨论:细心的同学又会发现,这次的代码中给res增加答案的地方结构发生了变化,其实 len(current)==len(num_str)len(rest) == 0 这两个的含义是一样的,都表示了往下走了 len(num_str)这么多层。关于递归的出口和递归的剪枝以及递归和回溯(他们本质是一样的),我争取放到下一篇文章再来讲述。

动态规划

动态规划是一个比较大的话题,这里不详细展开,只是接着这道题说一下递归和动态规划的关系。实话讲,这道题并不是一道典型的动态规划的题目,因为题目中没有所谓的【相似子结构】,关于动态规划和相似子结构的话题,我也争取放到下一篇文章为大家讲述。现在我们还是聚焦这道题目本身。

引子:在题目最开始我们提到,这道题用大家一开始想到的遍历也是可以做的,跟数据经常打交道的同学可能很快就反应过来了,通过遍历取每一次的笛卡尔积就可以了,sql表示就是 select * from a, b

注:这里只是演示,现实中不建议使用 * 取所有列

对于 不熟悉笛卡尔积和sql的同学,这里也给出简单的例子说明:

a = [1,2]; b=[3,4]
a和b的笛卡尔积就是 a和b所有可能的组合 13, 14, 23, 24

如果要求两个可迭代对象的笛卡尔积后结果的元素个数,相信各位同学可以秒出(这不就成排列组合了吗,中学时候都做吐了),比如上面这道例题就是 C 2 1 ∗ C 2 1 = 2 ∗ 2 = 4 C_2^1*C_2^1=2*2=4 C21C21=22=4但是题目要求的是求出所有可能的组合,我们又该怎么办呢

笛卡尔积函数:首先肯定是先写一个求笛卡尔积的函数,想偷懒的同学这时就想使用之前写的return [i+j for i in l1 for j in l2],这是不行的,因为如果l1是空,第一层循环就不进去了,加一个判断条件即可

    def cartesian(l1, l2):
        if not l1 or not l2:
            return l1 or l2
        return [i+j for i in l1 for j in l2]

当然你如果想偷懒,写成一行也可以,但是为了更明确,这里写了三行。

动态规划
这里动态规划每一步的结果是一个比较长的列表,我们没有必要存每一步的结果,因为当前结果对动态规划很早之前的结果没有依赖,于是代码就很简单了。

def like_dp(num_str):
    def cartesian(l1, l2):
        if not l1 or not l2:
            return l1 or l2
        return [i+j for i in l1 for j in l2]
    res = []
    for n in num_str:
        res = cartesian(res, mapping.get(n))
    return res

总结

  • 递归中比较典型的语义
    本文提到的递归语义有两种,一种是【递归调用可以解决某个小部分的问题】,另一种是【层级】,其中【层级】的语义是递归最为经典的,掌握好这两个递归的切入点,可以对解决此类问题提供有效的思路入口。
  • 递归与动态规划
    本文提出【雪球】和【雪花】的概念,由于斐波那契在求解【雪花】的时候又重新走了一遍【雪球】的路,导致某些子结构被重新计算了,所以斐波那契更推荐用动态规划解决,而这道题的【雪花】只是一个很简单的列表,复杂度不高(就是一个单纯的滚雪球,越滚越大的问题)所以递归和动态规划就没有什么太本质的时间差别了。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值