1. 问题描述
这是很多年前看到的一道与递归有关的笔试题目:
一个班有50个人,每个人都有自己的固定座位。有一天1号突然有点发疯,他随机选择了一个座位。从2号开始,如果自己的座位是空的,则他坐自己的座位,如果他的座位被占用,他也随机选择一个座位。求最后一个人坐到自己座位的概率。
2. 问题分析
这是一个概率问题,由于1号选择的等概率性,如果我们把1号选择座位n后,50号坐到自己座位的概率计为S(n),则50号坐到自己座位的概率R(50)为:
由以上公式可以看出,当一号选择座位号x(1<x<50)后,最后一位坐到自己位置的概率递归的变为,当总人数为50-x+1时,同等问题的解。
因此,该问题的解决方案可以用递归实现。
3. 递归实现方法
递归实现代码如下:
package main
type Version1 int
func (v Version1) R(n int) float64 {
sum := float64(0)
for x := int(1); x <= n; x++ {
s := v.s(n, x)
sum += s
}
return sum / float64(n)
}
func (v Version1) s(n, x int) float64 {
switch {
case x == 1:
return 100
case x == n:
return 0
default:
return v.R(n - x + 1)
}
}
func main() {
N := 50
println(N, int(Version1(0).R(N)))
}
解题方法没毛病,代码实现很简洁,可惜就是没法在可忍受的时间内计算出结果。
4. 递归实现的性能问题诊断
对v1增加统计参数,代码实现如下:
package main
import (
"fmt"
"time"
)
type Version2 struct {
maxDepth int
fnCallCnt int
}
func (v *Version2) R(n, depth int) (percent float64) {
if depth == 1 {
v.fnCallCnt = 0
v.maxDepth = 1
}
if depth > v.maxDepth {
v.maxDepth = depth
}
v.fnCallCnt++
sum := float64(0)
for x := int(1); x <= n; x++ {
s := v.s(n, x, depth)
sum += s
}
return sum / float64(n)
}
func (v *Version2) s(n, x, depth int) (percent float64) {
switch {
case x == 1:
return 100
case x == n:
return 0
default:
return v.R(n-x+1, depth+1)
}
}
func main() {
M := 50
for N := 1; N <= M; N++ {
v := &Version2{}
start := time.Now()
rate := v.R(N, 1)
dur := time.Now().Sub(start) / time.Millisecond * time.Millisecond
fmt.Printf("v2 N=%-2d R=%-5.1f T=%-10s RD=%-2d Fc=%d\n", N, rate, dur, v.maxDepth, v.fnCallCnt)
}
// output:
// v2 N=1 R=100.0 T=0s RD=1 Fc=1
// v2 N=2 R=50.0 T=0s RD=1 Fc=1
// v2 N=3 R=50.0 T=0s RD=2 Fc=2
// v2 N=4 R=50.0 T=0s RD=3 Fc=4
// v2 N=5 R=50.0 T=0s RD=4 Fc=8
// v2 N=6 R=50.0 T=0s RD=5 Fc=16
// v2 N=7 R=50.0 T=0s RD=6 Fc=32
// v2 N=8 R=50.0 T=0s RD=7 Fc=64
// v2 N=9 R=50.0 T=0s RD=8 Fc=128
// v2 N=10 R=50.0 T=0s RD=9 Fc=256
// v2 N=11 R=50.0 T=0s RD=10 Fc=512
// v2 N=12 R=50.0 T=0s RD=11 Fc=1024
// v2 N=13 R=50.0 T=0s RD=12 Fc=2048
// v2 N=14 R=50.0 T=0s RD=13 Fc=4096
// v2 N=15 R=50.0 T=0s RD=14 Fc=8192
// v2 N=16 R=50.0 T=0s RD=15 Fc=16384
// v2 N=17 R=50.0 T=0s RD=16 Fc=32768
// v2 N=18 R=50.0 T=1ms RD=17 Fc=65536
// v2 N=19 R=50.0 T=2ms RD=18 Fc=131072
// v2 N=20 R=50.0 T=5ms RD=19 Fc=262144
// v2 N=21 R=50.0 T=10ms RD=20 Fc=524288
// v2 N=22 R=50.0 T=22ms RD=21 Fc=1048576
// v2 N=23 R=50.0 T=43ms RD=22 Fc=2097152
// v2 N=24 R=50.0 T=84ms RD=23 Fc=4194304
// v2 N=25 R=50.0 T=169ms RD=24 Fc=8388608
// v2 N=26 R=50.0 T=353ms RD=25 Fc=16777216
// v2 N=27 R=50.0 T=678ms RD=26 Fc=33554432
// v2 N=28 R=50.0 T=1.377s RD=27 Fc=67108864
// v2 N=29 R=50.0 T=2.705s RD=28 Fc=134217728
// v2 N=30 R=50.0 T=5.437s RD=29 Fc=268435456
// v2 N=31 R=50.0 T=10.856s RD=30 Fc=536870912
// v2 N=32 R=50.0 T=21.801s RD=31 Fc=1073741824
// v2 N=33 R=50.0 T=44.53s RD=32 Fc=2147483648
// v2 N=34 R=50.0 T=1m33.856s RD=33 Fc=4294967296
// ...
}
由以上统计数据不难推算,计算出R(50)大约需要90*2^16=5898240秒=1638.4小时=68.27天。
需要调用R函数的次数为:2^48=281,4749,7671,0656=281.47万亿次。
递归函数R(n)的计算次数为O(2^(N-2)),呈指数级别增长。
题目解到这里,我想大家应该明白,出题人的良苦用心其实是想给应聘者一个这样的教训:“珍爱生命,远离递归”。
5. 珍爱生命,远离递归
这个题目比计算Fibonacci数列更典型的原因是以一棵广度为N-2的树递归生长,随着递归深度的增加,计算复杂度以几何级数迅速增长。
有经验的技术经理一定会在编码规范里增加这样一条:“禁止使用任何形式的递归”,原因就在这里。
任何形式的递归都有办法通过一种被称为stack的数据结构使用for循环等价替换。
5.1 通过查表法裁剪递归分支
以下使用查表法裁剪主要递归分支。将已经计算出结果的R(n),通过数组记录下来,以便后续计算快速查表消除递归。
package main
import (
"fmt"
"time"
)
type Version3 struct {
maxDepth int
fnCallCnt int
known []float64
}
func (v *Version3) R(n, depth int) (percent float64) {
if depth == 1 {
v.fnCallCnt = 0
v.maxDepth = 1
v.known = v.known[:0]
}
if len(v.known) >= n { //result is known, use the result
return v.known[n-1]
}
//println(n, depth, len(v.known))
if depth > v.maxDepth {
v.maxDepth = depth
}
v.fnCallCnt++
sum := float64(0)
for x := int(1); x <= n; x++ {
s := v.s(n, x, depth)
sum += s
}
r := sum / float64(n)
v.known = append(v.known, r)
return r
}
func (v *Version3) s(n, x, depth int) (percent float64) {
switch {
case x == 1:
return 100
case x == n:
return 0
default:
return v.R(n-x+1, depth+1)
}
}
func main() {
M := 50
for N := M; N <= M; N++ {
v := &Version3{}
start := time.Now()
rate := v.R(N, 1)
dur := time.Now().Sub(start) / time.Millisecond * time.Millisecond
fmt.Printf("v3 N=%-2d R=%-5.1f T=%-10s RD=%-2d Fc=%d\n", N, rate, dur, v.maxDepth, v.fnCallCnt)
}
// output:
// v3 N=50 R=50.0 T=0s RD=49 Fc=49
// v3 N=50000 R=50.0 T=10.254s RD=49999 Fc=49999
// v3 N=100000 R=50.0 T=39.489s RD=99999 Fc=99999
}
从已得到的测试数据看到,递归函数调用次数已缩减到O(N),并且N=100,000也能在可以忍受的时间内给出计算结果。
但是本质上,这个函数还是递归的。
5.2 用stack消除递归调用
理论上,所有的递归都可以通过数据结构stack实现用堆上空间避免递归深度的无限增加导致栈溢出。
package main
import (
"fmt"
"time"
)
type Version4 struct {
maxDepth int
fnCallCnt int
known []float64
}
func (v *Version4) R(n, depth int) (percent float64) {
if depth > 3 { // assert recursive depth
panic(depth)
}
if depth == 1 {
v.fnCallCnt = 0
v.maxDepth = 1
v.known = v.known[:0]
}
if depth > v.maxDepth {
v.maxDepth = depth
}
if n <= len(v.known) { //result is known, use the result directlly
return v.known[n-1]
}
v.fnCallCnt++
//println(n, depth, len(v.known))
sum := float64(0)
//for x := int(1); x <= n; x++ {
for x := n; x >= 1; x-- { // reverse order to let R(1~n) evaluate order
s := v.s(n, x, depth)
sum += s
}
r := sum / float64(n)
v.known = append(v.known, r)
return r
}
func (v *Version4) s(n, x, depth int) (percent float64) {
switch {
case x == 1:
return 100
case x == n:
return 0
default:
return v.R(n-x+1, depth+1)
}
}
func main() {
M := 100000
for N := M; N <= M; N++ {
v := &Version4{}
start := time.Now()
rate := v.R(N, 1)
dur := time.Now().Sub(start) / time.Millisecond * time.Millisecond
fmt.Printf("v4 N=%-2d R=%-5.1f T=%-10s RD=%-2d Fc=%d\n", N, rate, dur, v.maxDepth, v.fnCallCnt)
}
// output:
// v4 N=50 R=50.0 T=0s RD=3 Fc=50
// v4 N=50000 R=50.0 T=10.136s RD=3 Fc=50000
// v4 N=100000 R=50.0 T=40.075s RD=3 Fc=100000
}
本例由于只需要将R(N)的调用顺序反转为R(1~N)即可实现递归深度不增长,所以不需要用到栈数据结构。
可以看到,通过修改R(x)的调用顺序,已经可以实现递归深度最多不超过3。递归深度不随N增大而增大,可视为假递归,该递归是无害的。
6. 结论
本文通过分析笔试题目要求,通过递归算法实现题目要求。
最后发现通过递归算法实现的代码,需要70天才能实现递归深度仅为50的R(50)计算。
通过查表法和逆序递归法(类似stack原理),成功将递归深度为O(N)的算法优化为不超过O(3),成为“无害递归”,基本成功消除递归。
优化后,计算R(100,000)仅需要40s,且N的上限,不受栈空间大小限制。
该题目设计者用心良苦,成功给应聘者一个这样的教训:“珍爱生命,远离递归”。
有经验的技术经理一定会在编码规范里增加这样一条:“禁止使用任何形式的递归”,原因就在这里。
任何形式的递归都有办法通过一种被称为stack的数据结构使用for循环等价替换。