避免使用暴力递归

385 阅读5分钟

请勿在你的程序中使用 暴力递归

文末增加爬楼梯问题解法(Swift)。

真实经历: 在某项目中做 笔记 应用时, 有个需求需要递归遍历 文件夹 下的所有笔记, 文件夹 内包括 文件夹, 默认可以无限包含文件夹, 即 root文件夹 -> 一级文件夹1 | 一级文件夹2 | ... -> 二级文件夹1 | 二级文件2 | ... -> ... -> ... | n级文件夹n . 当时是直接暴力使用递归函数进行开发的, 还好用户量不大并且实际使用中并没有超过 5层 的嵌套, 所以项目在第一阶段还算运行良好, 当我们意识到这里存在重大漏洞后, 经过协商产品最终同意默认文件夹嵌套不超过5层. 操作系统采用的文件存储结构不是简单的数据库, 这里不作过多讨论.

本文将以 Fibonacci 进行讲解为何不应该直接使用暴力递归.

题目十分简单,计算斐波那数列第n项的值.

备注1: 本文代码使用了 &+ 运算符,不讨论结果,只看重实际运算耗时情况

备注2: 本文使用的真机为 iPhone 6 系统为 iOS 12.4

备注3: 为了使用尾递归优化,工程设置了relase模式

# 暴力递归写法

一般使用简单递归解法,代码如下

func fibonacciUseRecursion(_ count: Int) -> Int {
    if count == 1 || count == 2 {
        return 1
    }
    return fibonacciUseRecursion(count - 1) &+ fibonacciUseRecursion(count - 2)
}

其时间复杂度 O(2^N).

  • count = 30 时其执行时间如下

  • count = 50 时执行时间如下:

    3次之后我的 iPhone 6 已经成为了暖手宝🙃, 平均耗时 86s

  • count > 50 我没勇气试了

对于暴力求解的优化一般是缓存已经求得的结果, 如 [f(3): 2, f(4): 3, f(5): 5, f(6): 8, ...], 下次求解时直接从缓存中查找并返回,本文不作过多讨论,具体可见demo中的 fibonacciUseMemory 等, 其 count = 50 的执行结果如下:

# 使用 For 循环

在使用尾递归前先使用 for 循环求解,代码如下:

func fibonacciUseForLoop(_ count: Int) -> Int {
    guard count > 2 else { return 1 }
    var sum1 = 1
    var sum2 = 1
    for _ in 3...count {
        (sum1, sum2) = (sum2, sum1 &+ sum2)
    }
    return sum2
}

期时间复杂度为 O(N).

count = 30, count = 50, count = 10000 等的执行时间,见使用尾递归对比

# 使用尾递归

为确保编译器对尾递归进行优化请在 scheme 设置中将 Run 的配置从 Debug 改为 Release,见文章 备注3.

尾递归写法如下

func fibonacciUseTailRecursion(_ count: Int) -> Int {
    if count == 1 || count == 2 {
        return 1
    }
    func fibonacciInternal(sum1: Int = 1, sum2: Int = 1, total: Int) -> Int {
        guard total > 1 else { return sum1 &+ sum2 }
        return fibonacciInternal(sum1: sum2, sum2: sum1 &+ sum2, total: total - 1)
    }
    return fibonacciInternal(total: count - 2)
}

其时间复杂度为 O(N).

for 循环对比如下

count for 循环 尾递归
30
50
10000

# 总结

当然这里列举 Fibonacci 的暴力递归本身就是 O(2^N) 的复杂度,你可能会列举一个累加1...n 的普通递归进行反驳,代码如下

func sum(_ n: UInt) -> UInt {
    if n == 1 {
        return 1
    }
    return n + sum(n - 1)
}

其时间复杂度为 O(N).

尾递归写法如下:

func sum(_ n: UInt) -> UInt {
    func sumInternal(_ total: UInt, current: UInt = 0) -> UInt {
        if n == 1 {
            return current + 1
        }
        return sumInternal(total - 1, current: current + total)
    }
    return sumInternal(n)
}

时间复杂度同样为 O(N).

但是别忘了, 普通的递归会一直压栈,如果 n 过大, 例如 n = 10000000 时, 你的程序很可能会因为因为 堆栈爆炸 而崩溃.

因为编译器的优化尾递归并无此问题.

结论: 你应该在应用中尽量避免使用 暴力递归 , 实在需要时请替换为 尾递归 .

demo 传送门

# 爬楼梯问题

经典的爬楼梯问题也是一个斐波那数列问题,原版本为:

每次可以爬楼梯1层或者2层, 求第 n 层时共有几种爬法;

递归解法为:

f(1) = 1
f(2) = 2
f(n) = f(n-1) + f(n-2)

这里给出变种问题:

每次最多爬 m 层, 求爬到第 n 层有几种爬法;

解法如下:

// MARK: 爬楼梯问题
extension ViewController {
    func countFor(totalStep s: Int, maxStep m: Int) -> Int {
        if m == 0 || s == 0 {
            return 0
        }
        if s == 1 || m == 1 {
            return 1
        }
        var resultArray: [Int] = Array(repeating: 0, count: s + 1)
        resultArray[1] = 1
        if s <= m {
            for idx in 2...s {
                // f(idx-1) + f(idx-2) + ... + f(1)
                let pre = (1..<idx).reduce(0, { res, index in
                    return res &+ resultArray[idx - index]
                })
                resultArray[idx] = pre &+ 1
            }
        } else {
            // 计算前 m 项的 结果
            for idx in 2...m {
                // f(idx-1) + f(idx-2) + ... + f(1)
                let suf = (1..<idx).reduce(0, { res, index in
                    return res &+ resultArray[idx - index]
                })
                resultArray[idx] = suf &+ 1
            }
            // 计算剩余的结果
            for idx in m + 1...s {
                resultArray[idx] = Array(1...m).reduce(0, { res, index in
                    return res &+ resultArray[idx - index]
                })
            }
        }
        print("最大步数为 \(m) 时, 走 \(s) 层, 共有 \(resultArray[s]) 种走法")
        return resultArray[s]
    }

    // MARK: 按最大 3 步走时的验证函数
    func countByThree(for s: Int) -> Int {
        var num1 = 1 // maxStep is one for one step
        var num2 = num1 + 1 // maxStep is two for two steps
        var num3 = num2 + num1 + 1 // maxStep is three for three steps
        // 对于 maxStep 为 m 则有公式 f(m) = f(m-1) + f(m-2) + ... + f(1) + 1
        for _ in 4...s {
            (num1, num2, num3) = (num2, num3, num1 + num2 + num3/* 2*num3 - num1 */)
        }
        print("最大步数为 3 时, 走 \(s) 层, 共有 \(num3) 种走法")
        return num3
    }
}

内含注释,如有疑问请留言。