2.51. 怎样理解递归
初学计算编程的人很容易被递归算法搞晕,不知道怎么用递归来思考问题。本文来帮助读者来增强这种思考能力。
我们之前就介绍过,现代人一般理解的计算机,都是来自冯诺依曼计算机的概念,我们的计算都是一套“因果链”。看看下面这个程序:
# code-1
1: a = 3;
2: a = a + 4;
3: a = a*2;
盯着a这个变量,第一步它等于3,这是它第二步等于7的因,a在第二步等于7是a在第一步等于3的果。这个果是第三步a等于14的因,而第三步a等于14是第二步a=7的果。
冯诺依曼计算机就是一个这样的因果链。只是常常它的因果链步止一个a,还有更多的其他状态而已。
这种基于因果链思考问题的方法,是人脑最直接的推理模式,也是我们觉得“很好想”的模型。但它效率不高,所以人还有一种思维,叫“如此类推”。怎么“如此类推”呢,比如这样:
# code-2
1: a = 3;
for i in range(1,10):
2: a = a + 4;
3: a = a*2;
它是前面思维的顺延。我们还是顺着1-2-3-2-3-2-3-...这条因果链来思考这个执行过程的,只是我们实验了其中几个重复的循环了,我们的脑子认为:后面也差不多,然后我们就开始跳过中间的过程,然后我们就指望计算机无条件一直这样执行下去,帮助我们“如此类推”了而已。
这种方法有点像方程,通过变量来思考行为,前面这个例子中的i就是过程的变量,只是过程的每一步,都和i没有什么直接关联而已。我们换一个例子:
# code-3
def vec_mul(vec, num):
new_vec = []
for i in vec:
new_vec.append(i*num);
return new_vec
vec_mul输进来一个向量,我们也不知道里面有多少个数字,所以每个数字就变成了一个变量,我们的循环拿到每个变量i,然后把它乘上num作为结果。这里的i是第几次循环,我们是不关心的,我们只用方程的思维:对于某次循环中的i,它要乘上num,然后放到new_vec的什么位置上才是正确的……这样就可以了。
这就好像我们计算鸡兔同笼的时候,我们不管鸡和兔的数量,我们只管4*x+2*y=腿的总数。这儿地方通了,剩下我们只考虑列出的方程怎么解,怎么一步步消除变量,再也不关心鸡有几条腿,几个头,兔有几条腿,几个头。
你解方程的时候还去考虑方程两边都乘上一个常数物理上表示什么,那就自己把自己搞乱了。
但还有些“如此类推”的东西是没法用变量表示的,比如说前面这个code-3,如果向量中还可以包括向量,而我们的要求是向量中的每个成员都要乘num。这时你拿到i的时候你就要区分这是不是个向量了,但如果它是向量,你就还要判断它里面的内容是不是向量了……这又是如此类推,所以,你其实只能用递归来“如此类推”。
# code-4
def vec_mul(vec, num):
new_vec = []
for i in vec:
if instanceof(i, list):
new_vec.append(vec_mul(i, num))
else:
new_vec.append(i*num);
return new_vec
这个逻辑是不是看起来很自然?其实纯粹从数学上想这个问题,这其实是很自然的。但如果我们还用前面的因果链来思考这个问题,我们就会觉得很不自然。为什么呢?
我们拿一个例子来想一下就知道了。假定我们的输入是vec_mul([[[1, 2], 3], 4], 5)。我们顺着因果链,第一次循环我们看到的结果是::
i=[[1, 2], 3], new_vec=[vec_mul([[1, 2], 3], 5)]
好了,顺着因果链,我们就应该看被递归的vec_mul里面怎么执行了::
上一层i=[[1, 2], 3], new_vec=[vec_mul([[1, 2], 3], 5)] (第一层)
本层:i=[1, 2], new_vec=[vec_mul([1, 2], 5)]
下层:i=1, new_vec=[5]
下层:i=2, new_vec=[5, 10]
本层:i=3, new_vec=[[5, 10], 15]
上一层i=4, new_vec=[[[5, 10], 16], 20]
这里整个问题的关键就在于,一般的循环我们眼睛里看到的是少数的几个变量,但递归制造的循环是每循环一次产生的变量就会增加一批,而它们的名字是一样的。上面这个图,到了第三行,三个i是同时存在的。三个new_vec也是同时存在的,而不是同一个状态。
所以你的脑子会晕,因为你脑子习惯是用变量来思考“当前状态”的,但这个“当前状态”是随着循环次数的增加会越来越大,你脑子处理不了。
那怎么办呢?这你需要类似方程的思路,在每层上,你只考虑那一层的变量,完全不要想上一层的问题就好了。
在code-4中,你思考new_vec.append(vec_mul(i, num)),不要想vec_mul里面怎么样,因为这里没有全部变量,大家都是自己的局部变量。你这里就是本地有一个new_vec,里面有一个循环便利输入的vec中的每个i,如果i是个列表,我希望得到vec_mul(i, num)的一个返回值。这个返回值是个和i一样形式的list,其中每个成员都乘以num,这个逻辑没有错,那这个定义就没有错。我们不考虑中间产生了多少层函数内的堆栈变量。
也就是说,我们从来不去思考前面那个展开的图。
所有的循环,本质上都是特殊的(没有额外局部变量的递归),所以,让我们用前面这样思考转化掉code-4的循环。这样我们可以深入理解一下前面这种思考方法:
# code-5
def vec_mul(vec, num): # 输入是不变的
# 现在我们需要考虑如何“降级”(reduce)整个计算,而计算的级被vec的长度
# 决定着,所以,最容易考虑的降级方法是先算其中一个,然后算剩下的。
if len(vec) == 0: # 没有内容,无法降级
return vec
elif len(vec) == 1: # 只有一个,不需要降级了
if instanceof(vec[0], list):
return [vec_mul(vec[0], num)]
else:
return [vec[0]*num]
else: # 至少有两个,降一级
n = vec[0] # 第一个数字
rest = vec[1:] # 剩下的数字
if instanceof(n, list): # 计算第一个数字
new_vec = [vec_mul(n, num)]
else:
new_vec = [n*num]
new_vec.extend(vec_mul(rest, num)) # 递归剩下的部分
return new_vec; # 最后的结果
你看,这里完全没有循环,但实际上就是在做了前面的循环。我们在这一层中设计的根本不考虑跨层的关系,我们调用递归的函数和调用其他函数一样的,就使用它的接口,而不是它的每层局部变量的值。
2.51.1. 局部变量和全局变量
上面这种思维方式,有一点是重要的,就是递归的过程基本上都是值传递,我们尽量不用引用传递,这样两层函数之间就没有什么关系了。比如你做new_vec.extend(vec_mul(rest, num)),new_vec是一个集合,rest是另一个集合,vec_mul计算的结果又是一个集合,它们互相之间没有什么关系。所以我们不需要跨调用来想问题,无论内层函数搞什么幺蛾子,反正出来的时候给的一定是它返回的值。
所以你尽量别在递归里面分配堆内存(比如new,malloc等),也不要直接访问全局变量。这样上面这个考虑问题的方法就总是成立的。就好比这个code-5的程序,我们计算new_vec的时候确实是可以在数组原地计算的,但我们每次都分配了一个新的数组,老的数组反正在堆栈中,离开以后就没有了。这样我们只要盯着输入输出那个位置,就总不会错了。
这是大部分函数式编程的基本思路,比如用Ocaml写前面这个程序,它就是这样的::
(* code-6 *)
let rec vec_mul vec, num = match vec with
[] -> [] (* 空的时候返回一个空列表 *)
| n::rest -> n*num @ vec_mul(rest) (* 不空的是否分成两段分别处理 *)
(这里我为了容易说,没有做列表成员也是列表的情形,只是比喻一下。)
用这种思路考虑那种需要积累结果的算法怎么做呢?比如下面这儿循环算法:
# code-7
a = []
for i in range(6):
a.append(i*(i+1))
a会变得越来越长,递归怎样才能让a变得越来越长呢?按值传递的理论,这就应该把这个越来越长的内容也用值传递:
# code-8
# 我们要得到[0*1, 1*2, 2*3, ..., (n-1)*n],所以第一步应该计算0*1,并记住还有n-1
# 步没有算完,所以递归函数在两层之间要同时传递列表和n的值。
def get_long_vec(n, l):
m = len(l) # m决定算到第几步
l1 = l[:] # 拷贝一个新的列表
l1.append(m*(m+1)) # 0项是0*1,1项是1*2……
if n == 1: # 降到1的时候就算了6次
return l1 # 递归到底了
else:
return get_long_vec(n-1, l1) # 没到底,向下递归
l = get_long_vec(6, [])
print(l)
像Ocaml这种只能写值传递的方案,你就必须这样写::
(* code-9 *)
let rec get_long_vec n l = let m = List.length l in
match n with
1 -> None, l@(m*(m+1))
| n -> get_long_vec(n-1, l@(m*(m+1)))
等你熟悉了前面这个思维模型,这些互相影响的关系你能想得很清楚了,我们就可以把全局变量当作一种“打印”了。比如上面这个过程,如果我们认为l1.append()是一种print(),那么上面的函数就是对遍历过程的一种“顺序打印”:
# code-10
def get_long_vec(n, l):
m = len(l)
l.append(m*(m+1))
if n > 1:
return get_long_vec(n-1, l)
else:
return l
get_long_vec(6, [])
这种“所有层的函数都看到同一个全局变量,大家都往里加东西”的情况,就可以使用全局的变量了。这种情况下,原来那些通过递归调用的循环过程,就真的仅仅就是个循环了。
2.51.2. 尾递归
前面我们讨论code-4的时候,已经看到递归是怎么没递归一次就产生一组新的变量的了。这导致递归算法很不实用,因为空间复杂度和循环次数相关,这儿算法需要多少空间根本就不可控。所以,在实用的程序中很少使用一般的递归算法,更多时候,我们会限定使用尾递归。尾递归的概念很好理解:调用递归函数的时候直接返回,就是尾递归。因为进入这个调用前,调用函数就可以释放自己的堆栈,这样就不会产生内存的积累了。
比如这个递归:
# code-11
def sumadd(n, m):
x = m+n
if n==0: return x
else: return sumadd(n-1, x)
sumadd(10, 0)
最后一行我们调用sumadd()递归的时候,我们肯定当前的sumadd中的x不会有人使用了,所以这时我们降低我们的堆栈是肯定不会有问题的。这就好像调用外面一层sumadd的人,直接在调用里面已成sumadd一样,这样无论递归多少次,都不会增高堆栈。
但如果你这样写:
# code-12
def sumadd(n):
if n==0: return 0
else: return n+sumadd(n-1)
这就不是尾递归了,因为你调完递归的sumadd(n-1, x),还要回来本函数加上n才能返回,这就不是尾递归了。同理,前面的code-4和code-5那些,都不是尾递归,因为它们在调用完递归函数,还要做一个简单的计算才能退出本函数。
一个好消息是:任何递归都可以变成尾递归。比如前面这个code-12,你要让sumadd的结果是n加上函数的值,我们就可以把这个计算也放到函数里面去做。这样就变成code-11了:我们把那个需要回到上一级函数计算的值,放到子函数的输入中,
然后我们还可以在封装一下,变成这样:
# code-13
def sumadd_tailrec(n, m):
x = m+n
if n==0: return x
else: return sumadd(n-1, x)
def sumadd(n):
subadd_tailrec(n, 0)
这里sumadd(n)不是递归函数,为了能尾递归,我们需要引入一个m来在函数之间做传递。但这儿m又不是用户需要的,所以我们先写一个尾递归的版本。然后我们再用一个没有这个m的非递归函数来调用它,外面使用的人就看不见这个m了。
2.51.3. 总结
最后我们写个程序来综合一下前面的知识吧,比如我们现在要做一个列出某个列表的排列组合的函数,比如我们输入[1,2,3],我们希望得到::
[[1,2,3], [1,3,2],
[2,1,3), [2,3,1],
[3,1,2], [3,2,1]]
。这个是个典型用循环不好处理的场景,因为它不是线性变化的。但我们构造前面这张表的时候,明显已经看到规律了:
任取一个值出来做第一个值,然后对剩下的列表做一个排列组合,就能得到这个列表。
这就是而递归过程,所以我们可以这样做:
# code-14
def P(v):
if len(v) == 0: return [] # 空的时候什么都返回不了
elif len(v) == 1: return [v] # 只有一个的时候就是自己了
else:
l = [] # 有两个以上,可以递归
for i in v: # 每个成员都取出来一次
v1=v[:] # 复制一份去掉了i的剩余列表
v1.remove(i)
p = P(v1) # 对剩下的列表做排列组合
for j in p: # 每个排列组合的结果都拼到i中
v2=[i] # 这里又需要一份新的
v2.extend(j) # 扩展新的
l.append(v2) # 加到列表中
return l
我们也看到了,但这不是尾递归。这儿尾递归不好做,因为它的计算也是在扩展l,这里要构造尾递归需要一个二维的数据结构才能记住那么多的数据。这个已经有点超出我们要讨论的范围了,写这儿已经相当于写通用的用循环替代递归的算法了。我们找其他机会再讨论这种问题吧。