【JavaScript】几分钟轻松理解「快速幂」算法

1,130 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情

输出内容才能更好的理解输入的知识,leetcode题目链接 70. 爬楼梯
动态规划是算法中特别经典和有趣的一种问题处理方法,也是一道必须越过的坎,它场景多变无法死记硬背,又经常在面试中出现。虽然我还没遇到过🤣

前言🎀

其高大上的名字容易让人望而却步,但其实只要理清了脉络,很多问题便迎刃而解了。

刚开始学习时解决问题不一定要最优解,从暴力到算法也是一种很重要的思考过程
动态规划能想到暴力解法其实问题已经解决一半了

在开始之前不妨先思考一个场景:
如果某个数字初始为0,每次运算只能 +1 或 +2,有多少种方式能累加到10
1 + 1 = 2
1 + 1 + 1 = 2 + 1 = 3
1 + 1 + 1 + 1 = 3 + 1 = 4
. . . . . .
1 + 1 + 1 ... + 1 = 9 + 1 = 10
// 同理
2 + 2 + 2 ... + 2 = 8 + 2 = 10
我们举出每一种能累加到10的情况,倒推整个流程可得结论:如果数字可以累加到n,那么当前只能是 n - 1 或 n - 2 (10 相对于8/9),借用一张

image.png 即数字到n的方式为 数字 到 n - 1 的方式 + 到n- 2 的方式
公式:f(n) = f(n-1) + f(n-2)
再加上边界条件,轻易实现了一个递归的解法

function recur(n) {
    if (n === 1) return 1 
    if (n === 2) return 2 
    return recur(n - 1) + recur(n - 2)
}

时间复杂度为O(xn)O(x^n),x为运算支持的数字的数量,每次递归相当于n个节点扩散出x个子节点。
似乎找到了求累加值的方法,我们再看一下leetcode的题目

题目描述

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1:
输入: n = 2
输出: 2
解释: 有两种方法可以爬到楼顶。 1 + 1 / 2

示例 2:
输入: n = 3
输出: 3
解释: 有三种方法可以爬到楼顶。 1 + 1 + 1 / 1 + 2 / 2 + 1

提示:
· 1 <= n <= 45

解题思路

用前言的代码进行提交,会直接获得 执行结果: 超出时间限制
看一下失败测试用例 n=45n = 45,指数级时间复杂度的函数超时是显而易见的

优化算法

需要优化的地方很明显只有递归的重复计算,每次递归都可能会对 已经计算过的值重新计算
而我们则需要优化这一计算过程,优化的方式就是使用备忘录

其实实现动态规划都有一套固定的流程:暴力法 -> 记忆化递归 -> 动态规划
如果在一开始直接上动态规划的最终解法,只会让人觉得觉得算法遥不可及,所以不要急一步一步来

备忘录优化

备忘录于递归的核心作用是,把递归树在计算过程中产生的冗余节点进行剪枝,极大的减少递归的子节点即重复计算

这样操作有什么好处?
通过备忘录优化的递归树节点不断变少,函数所需要执行的计算次数也变少,最后与求值n成正比,简而言之去掉了重复计算时间复杂度变低了 O(xn)O(x^n) -> O(n)O(n)

let map = new Map() 
map.set(1, 1) 
map.set(2, 2)
function recur(n) {
    if (n === 1) return 1
    if (n === 2) return 2
    let num1 = map.get(n - 1) || recur(n - 1)
    let num2 = map.get(n - 2) || recur(n - 2)
    let sum = num1 + num2
    map.set(n, sum)
    return sum
};

其实到这里的解法已经与动态规划十分相像了,在时间复杂度方面已经符合题解了,但是还有个很明显的缺陷,那就是计算过程中我们保存了全部的状态浪费了大量内存空间,而且递归十分容易导致栈溢出。接下来就可以介绍并使用 动态规划

动态规划

动态规划(dynamic programming)与分治算法类似,核心思想是 大事化小 小事化了 复杂的问题分段简化
与分治法不同的是,适用dp的问题,其分解的子问题 往往是存在关联而不是互相独立的
而问题之间的关联便是我们解题的要点
详细可参考百度百科 - 动态规划 - 基本思想

动态规划中包含三个重要的概念: 最优子结构 边界 状态转移公式

状态转移公式

前言中得出的公式F(n) = F(n - 1) + F(n - 2) 就是阶段与阶段之间的 状态转移方程,它决定了问题的每一个阶段和下一个阶段的关系
可以理解为 把F(n)当作一个状态n,这个状态n是由 状态n - 1状态n - 2 相加而来的,而同理可求 状态n - 1、 n - 2 ...
它能拆解问题为小问题 再将小问题拆解成更小的问题

其实不难看出 状态转移公式与暴力解法十分类似
而后续的各种操作和优化也都是建立在公式之上的,可见状态转移公式的重要性,所以才有前面那句
动态规划能想到暴力解法其实问题已经解决一半了
因为这时我们也得出了状态转移公式

边界

同时我们还需要考虑边界问题,否则会拆解出无穷的子问题,一个问题没有边界永远无法得到有限的结果
F(1)、F(2)是问题的边界

最优子结构

简单理解为:问题的最优解包含子问题的最优解。
反过来说就是可以通过子问题的最优解,推导出问题的最优解。 在动态规划上的表现为后面阶段的状态可以通过前面状态推导出来
F(10) = F(8) + F(9)是爬楼梯问题 n = 10 时的最优子结构

题解

现在回看 备忘录优化,它是自顶向下计算的,相当于一颗不断延伸的二叉树,一直到触底后 即F(1)、F(2),逐层返回答案

结合动态规划概念

根据后面阶段的状态可以通过前面状态推导出来
不难发现我们只要求得F(1)、F(2),那么F(3)的值就出来了
F(4)的值只依赖于F(2)、F(3),那么F(1)的值即可舍弃...

F(1)、F(2)是该问题的边界条件,不需要求值

∴我们只需要保留之前的两个状态,即可推导出新的状态

从最底部开始,每次计算只需要F(n - 1)F(n - 2)两个参数,所以这是一个自底向上的过程,不需要保留全部子状态,可得题解

/** 
* @param {number} n 
* @return {number} 
*/
var climbStairs = function(n) {
    let num1 = 0, num2 = 0, res = 1;
    for (let i = 1; i <= n; ++i) {
        num1 = num2;
        num2 = res;
        res = num1 + num2;
    }
    return res;
};

只需要常数个变量作为辅助空间 所以
空间复杂度为O(1)O(1) 时间复杂度O(n)O(n)

提交代码,通过 完结撒花😊 动态规划.png 牢记解题演变流程和规划概念,根据题意灵活建立问题模型,希望你也能轻松入门动态规划
流程:暴力 -> 递归优化 -> 动态规划
概念:状态转移公式、边界、最优子结构

结语🎉

也可以在leetcode看我的题解 ,不要光看不做题哦,后续会持续更新算法相关的知识
写作不易,如果觉得有收获欢迎大家点个赞谢谢🌹

才疏学浅,如果文章有什么问题欢迎大家指教

参考:漫画:什么是动态规划? - 掘金