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