美团春招算法题——上楼梯

111 阅读4分钟

斐波那契数列又叫黄金分割数列或者兔子数列。(废话不多说) 有许多算法题最后的答案也都是斐波那契数列。比如最经典的上楼梯问题。(美团春招)

楼梯问题

题目:假设这里有n级阶梯,小明一次可以上一个阶梯,也可以一次上两个阶梯。请问小明一共有几种不同的方法到达n级阶梯?

这个问题上至算法大佬,下到代码萌新都能给出答案。但是写法却很能反应个人水平。

标准版

function fibnacci(n) {
    if (n <= 0) { // 判断是否小于0
        console.log('请输入一共大于0的数');
    }
    if (n == 1 || n == 2) { // 斐波那契数列的第一项和第二项都是1
        return 1
    } else {
        return fibnacci(n - 1) + fibnacci(n - 2) 
        // 后面一项为前面两项之和
    }
}

本质就是运用了递归,非常朴素的方法。代码的结构也很清晰。

极简版

let fibnacci = n =>
    n <= 0 ? 0 : n == 1 ? 1 : fibnacci(n - 2) + fibnacci(n - 1)

三目表达式一行搞定 但是这里的代码不仅难看,而且难看。

传统递归方法的弊端

如果你细心的话就会发现,上面三种方法在 n 较小的时候,运行效果十分不错,随着n越来越大,程序运行的速度会越来越慢。

实际上,以上三种方法的运行时间是随指数增长的,当n>30时,你就可以明显感受到延迟。利用传统递归的方法,其时间复杂度为2^N。

// 采用递归的算法可以将代码简单理解为
// f(5) = f(4) + f(3)
//      = f(3) + f(2) + f(2) + f(1)
//      = f(2) + f(1) + f(2) + f(2) + f(1)

相当于你将它们最终都转换为了f(1)和f(2),以上楼梯这个题目来理解的话,相当于你用了穷举法来逐个列出了上楼梯的不同方法,这毫无疑问是十分不理智的。只是我们使用了计算机来代替我们自己进行穷举,但随着n值的不断增加,即使是计算机也会承受不住。

此外,如果你了解一点函数执行栈的知识就会知道,在函数运行时,会被放入执行栈内,如果这个函数内部含有函数那么就会继续入栈。也就是说,在每一次递归之后,都有一个函数被压入执行栈。在执行栈里执行一个,弹出一个,速度自然就慢了很多。此外,当压入的函数太多,还有可能给你报错:堆栈溢出

所以说以上两种种方法只有理论价值,没有工程价值,所以,一个好的程序员需要对这个算法进行优化。

优化

尾调用优化

// 依然是双指针,但函数最后返回一个函数的调用。
function fibnacci(n, a = 1, b = 1) {
    if (n <= 2) {
        return b
    }
    return fibnacci(n - 1, b, a + b) // 返回函数的调用
    // return fibnacci(n-1) + fibnacci(n-2) 这种方法不属于尾递归,因为他返回的是两个函数的值,而不是函数的调用。
}

这里我们每次递归结束后,都会返回一个函数的调用,而这时上一个函数实际上已经结束了,他会很顺理成章的从执行栈中被弹出,此时,执行栈中就只存在这个我们通过return返回的函数。也就是说,执行栈内始终只有一个函数,大大节约了时间成本和空间成本。不会出现堆栈溢出的报错。

for循环优化(双指针)

// 求斐波那契数列主要就是要知道前一项(a)和前两项(b)的值
function fibnacci(n) {
    var a = 1, // 用来存储上上的值,实际上它是一个指针
        b = 1, // 存储上一项的值
        sum = 0; // 当前项的值,由于利用了上一项和上上项,所以n必须大于3。
    if (n == 1 || n == 2) {
        return 1 // 小于三的时候直接返回1
    } else {
        for (var i = 3; i <= n; i++) {
            sum = a + b // 得到当前项
            // 之后两个指针向下滑动
            a = b // 原本存储上上项的a用来存储上一项
            b = sum // 原本用来存储上一项的b用来存储当前项。
        }
        return sum;  // 输出结果项
    }
}

这种方法的精髓在于利用了双指针,将上一项和上上项存储起来。双指针是算法中一种十分重要的思想。

如果你仔细观察的话,会发现这种方法和尾递归的方法有异曲同工之妙。(我连参数都用的同一个a,b)实际上,尾调用只不过把计算过程放入了递归函数的参数中。

缓存优化

// 定义一个斐波那契数组,每一项计算的值都存入这个数组,使用的时候直接从数组中取值。
function fibnacci(n) {
    let arr = [0, 1, 1] // 由于数组下标从0开始,因此第一项的值定义为0,相当于占位符。
    if (n == 1 || n == 2) {
        return 1
    }
    for (let i = 3; i <= n; i++) {
        arr[i] = arr[i - 1] + arr[i - 2] // 将计算结果添加到arr斐波那契数组中
    }
    // console.log(arr)
    return arr[n]
}

如果你在函数内部打印arr数组就会发现,当n越来越大的时候,这个数组也会变得非常庞大,相当于将传统递归方法中数量众多的f(1)和f(2)替换成了数组的项数,是一种牺牲内存空间来换取计算效率的方法。并不推荐。

总结

利用尾调用优化最好

function fibnacci(n, cur = 1, next = 1) {
    if (n <= 2) {
        return next
    }
    return fibnacci(n - 1, next, cur + next)
}