一道笔试题展示的良苦用心——珍爱生命远离递归

本文探讨了一道关于递归的笔试题目,分析了递归实现计算特定概率时的性能问题,揭示了递归可能导致的计算复杂度急剧增长。通过查表法和逆序递归,成功将递归深度优化到常数级别,显著提高了算法效率,证明了递归问题可以通过栈数据结构等方法转化为循环,避免了递归带来的负面影响。
摘要由CSDN通过智能技术生成

1. 问题描述

这是很多年前看到的一道与递归有关的笔试题目:

一个班有50个人,每个人都有自己的固定座位。有一天1号突然有点发疯,他随机选择了一个座位。从2号开始,如果自己的座位是空的,则他坐自己的座位,如果他的座位被占用,他也随机选择一个座位。求最后一个人坐到自己座位的概率。

 2. 问题分析

这是一个概率问题,由于1号选择的等概率性,如果我们把1号选择座位n后,50号坐到自己座位的概率计为S(n),则50号坐到自己座位的概率R(50)为:

R(50) = \sum_{n=1}^{50}(\frac{1}{50}*S(n))

S(n) = \left\{\begin{matrix} \\ 100 \rightarrow (n=1) \\ R (50-n+1) \rightarrow 1<n<50 \\ 0 \rightarrow (n=50) \end{matrix}\right.\displaystyle

 由以上公式可以看出,当一号选择座位号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循环等价替换。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值