程序员必看:如何用动态规划优雅地解决经典算法题!

105 阅读7分钟

引言

在算法的世界里,每一题都是一次思维的冒险。今天,我们将探讨一下aiLeetCode上的两颗璀璨明珠:70. 爬楼梯322. 零钱兑换。通过这两道题目,分享我的解题心得与体会,一同领略算法的魅力。

1. 爬楼梯问题的深度剖析

image.png

问题描述

爬楼梯问题看似简单,实则蕴含着深刻的数学逻辑与算法思维,是经典的斐波那契数列变种题目。题目要求计算到达楼顶的不同方法数量,给定每次可以爬1或2个台阶,求解共有多少种不同的方式能爬到楼顶。是培养我们解决复杂问题能力的良好练习场。

解题思路

对于初学者而言,面对爬楼梯问题时,最自然的思路往往是自顶向下的递归方法。这种方法巧妙地模拟了从第n阶楼梯逐步回退到第n-1和n-2阶的过程,得出递推公式:

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

其中,f(1)=1 和 f(2)=2 是递归的基准条件,即到达第一层有1种方式,到达第二层有2种方式。这种直观的思维路径使得递归成为理解问题结构的理想起点。以此我们就可以成功建立这道题的代码解决方案了。

第一个方案:基础递归

var climbStairs = function (n) {
    if (n === 1) {
        return 1;
    }
    if (n === 2) {
        return 2;
    }
    return climbStairs(n - 1) + climbStairs(n - 2);
};

image.png

然而这种直接的方法虽然易于理解,但在处理较大输入时却暴露出效率低下的弊端——重复计算导致性能急剧下降,甚至可能引发栈溢出。因此,我们需要探索更为高效的解决方案。

第二个方案:记忆化搜索

由此,我们可以引入记忆化搜索的思想,即利用缓存存储已经计算过的结果。当再次遇到相同子问题时,可以直接返回缓存中的结果,从而显著提高效率。这一改进不仅优化了时间复杂度,还使得程序能够在合理时间内完成任务。

const f = [];  // 某层结果和数组的下标是一一对应的
const climbStairs = function (n) {
    if (n === 1) {
        return 1;
    }
    if (n === 2) {
        return 2;
    }
    if (f[n] === undefined) {
        f[n] = climbStairs(n - 1) + climbStairs(n - 2);
    }
    return f[n];
};

image.png

相较于最初的递归方案,记忆化版本通过缓存中间计算结果,成功避免了大量重复运算,从而显著提升了程序的执行效率。这一改进不仅解决了性能瓶颈,还为后续更复杂的优化铺平了道路。

然而,在追求卓越的路上,我们不能止步于此。对于那些渴望在顶尖科技公司中脱颖而出的开发者来说,掌握多种解题策略是必不可少的。因此,除了上述的记忆化搜索之外,学习和理解动态规划这一强大的算法设计技巧显得尤为重要。动态规划不仅是解决复杂问题的有效工具,更是面试官们青睐的核心技能之一。

第三个方案:动态规划

动态规划,这是一种自底向上的迭代过程,它通过构建一张表来记录每一步的状态转移,从而避免了递归带来的额外开销。对于本题而言,状态转移方程同样基于上述递推关系,但实现方式更为简洁高效。

const climbStairs = function (n) {
    const f = []   // f[i] 某一层的结果
    f[1] = 1;
    f[2] = 2;
    //迭代
    for (let i = 3; i <= n; i++) {
        f[i] = f[i - 1] + f[i - 2];
    }
    return f[n];
};

image.png

运用动态规划的思想,我们不仅可以成功解决问题,还能享受其带来的优雅与高效。

动态规划 vs. 递归:选择的艺术

虽然递归提供了直观的问题分解思路,但在实际应用中,动态规划往往展现出更高的效率和更好的可读性。尤其对于那些状态转移方程较为复杂的问题,动态规划为我们提供了一种系统化的方法来逐步构建解法。这不仅是算法设计能力的体现,更是解决问题思维的一种升华。

  • 递归先行,思考为上:对于最难的算法问题,尤其是那些状态转移方程难以确定的情况,先尝试递归来理清思路是明智的选择。递归帮助我们初步理解问题的本质,而动态规划则在此基础上进一步优化,实现从简单到复杂的跨越。

2. 零钱兑换问题的精妙解答

image.png

问题描述

零钱兑换是一个典型的完全背包问题,旨在找到组成指定金额所需的最少硬币数量。不同于爬楼梯问题,这里的关键在于理解每个硬币都可以无限次使用,并且需要考虑所有可能组合以达到最小值。

动态规划 - 最优子结构

针对此类最值问题,动态规划无疑是首选策略。其核心在于确定状态转移方程,即如何从前一步骤的结果推导出下一步骤的最佳选择。具体来说,我们可以定义dp数组,其中dp[i]表示凑足总额i所需最少硬币数目。那么,对于任意金额j,状态转移方程可以写作:

dp[j]=min⁡(dp[j],dp[j−coins[k]]+1)

这里,k遍历所有可用的硬币面额,确保每一种可能性都被考虑到。同时,初始化dp[0]=0(即无需任何硬币即可构成金额0),其余位置设为无穷大,以便后续比较更新。以此我们就可以顺利解决这个问题了

代码演示

const coinChange = function (coins, amount) {
    const f = [];    //每一个面额的最优值
    f[0] = 0;    //初始值
    //迭代
    for (let i = 1; i <= amount; i++) {
        f[i] = Infinity;  //无限大
        for (let j = 0; j < coins.length; j++) {
            if (i - coins[j] >= 0) {
                f[i] = Math.min(f[i], f[i - coins[j]] + 1)
            }
        }
    }
    if (f[amount] == Infinity) {
        return -1
    }
    return f[amount]
};

image.png

具体工作流程如下:首先初始化一个数组f,其中f[i]表示组成金额i所需的最少硬币数,f[0]设为0(因为组成金额0不需要任何硬币),其余位置初始化为Infinity,表示这些金额尚未找到解决方案。然后,对于从1到目标金额amount的每个值i,遍历所有可用的硬币面额,如果当前金额i减去某个硬币面额后的值非负(即i - coins[j] >= 0),则检查该剩余金额i - coins[j]是否已经被解决过(即f[i - coins[j]])。如果是,则更新f[i]f[i - coins[j]] + 1与当前f[i]值中的较小者,确保选择最小的硬币数。这样逐步构建出每一个金额的最优解。最后,若f[amount]仍为Infinity,说明无法用给定的硬币组合成目标金额,返回-1;否则返回f[amount],它代表组成目标金额所需的最少硬币数。

结语

无论是爬楼梯还是零钱兑换,这两道经典题目都为我们提供了一个绝佳的机会,去深入探索不同层次的算法设计。它们不仅检验了我们对基础概念的理解,还促使我们在解决问题的过程中不断引入更高级的技术和优化策略。从最直观的递归到记忆化搜索,再到动态规划的应用,每一步都是对思维深度和广度的挑战。希望本文对你有所启发,也期待你在未来的编程之旅中不断进步,成为更好的自己!

15.jpg