【JavaScript 算法】动态规划:最优子结构与重叠子问题


🔥 个人主页:空白诗

文章目录

    • 一、最优子结构
      • [1.1 最优子结构的例子](#1.1 最优子结构的例子)
      • [1.2 如何识别最优子结构](#1.2 如何识别最优子结构)
    • 二、重叠子问题
      • [2.1 重叠子问题的例子](#2.1 重叠子问题的例子)
      • [2.2 解决重叠子问题的方法](#2.2 解决重叠子问题的方法)
      • [2.3 如何识别重叠子问题](#2.3 如何识别重叠子问题)
    • [三、经典动态规划问题及其 JavaScript 实现](#三、经典动态规划问题及其 JavaScript 实现)
      • [3.1 斐波那契数列](#3.1 斐波那契数列)
      • [3.2 背包问题](#3.2 背包问题)
    • 四、总结

在算法的世界里,动态规划(Dynamic Programming,简称DP)是一种解决复杂问题的有力工具。它通过将问题分解为更小的子问题,并记忆这些子问题的结果,从而避免重复计算,提高效率。动态规划的两个核心概念是最优子结构重叠子问题


一、最优子结构

最优子结构指的是一个问题的最优解可以由其子问题的最优解构造而成。换句话说,如果我们可以通过解决子问题来解决原问题,那么这个问题就具有最优子结构性质。

1.1 最优子结构的例子

例子1:最短路径问题

在图论中,找到从一个顶点到另一个顶点的最短路径是一个常见的问题。如果一个路径的最优解(最短路径)可以由其子路径的最优解构成,那么这个问题就具有最优子结构。

假设我们要从顶点A到顶点D找到最短路径,而经过顶点B和C是最优路径的一部分,那么从A到B的路径和从B到C的路径也必须是最短的。否则,我们可以找到一条更短的路径替代原路径。
A到D的最短路径 A到B的最短路径 B到C的最短路径 C到D的最短路径

例子2:矩阵链乘法

在矩阵链乘法中,我们需要找到一种最有效的方式来计算多个矩阵的乘积。这个问题也具有最优子结构性质,因为计算矩阵链的最优乘积方式可以通过计算它的子链的最优乘积方式来得到。

1.2 如何识别最优子结构

识别一个问题是否具有最优子结构性质,通常需要以下步骤:

  1. 分解问题:将原问题分解为子问题,确保子问题独立且易于解决。
  2. 验证子问题:检查子问题的解是否可以组合成原问题的解。
  3. 组合子问题:确认是否可以通过组合子问题的最优解来获得原问题的最优解。

二、重叠子问题

重叠子问题是指在解决一个问题的过程中,会多次遇到相同的子问题。如果这些子问题重复出现,我们可以使用记忆化技术(Memoization)或表格法(Tabulation)来存储它们的计算结果,从而避免重复计算。

2.1 重叠子问题的例子

例子1:斐波那契数列

斐波那契数列是重叠子问题的经典例子。在计算斐波那契数列的过程中,我们会多次计算相同的子问题。例如,计算F(5)时,我们需要计算F(4)和F(3),而计算F(4)又需要计算F(3)和F(2)。这样会导致大量的重复计算。
F5 F4 F3 F3 F2 F2 F1 F2 F1 F1 F0 F1 F0

例子2:最长公共子序列

在计算两个字符串的最长公共子序列(LCS)时,我们也会遇到重叠子问题。例如,在比较字符串"ABCBDAB"和"BDCABA"时,我们需要比较子序列"BCBDAB"和"DCABA"以及"ABCBDAB"和"DCABA",这些子序列的比较会重复多次。
LCS1 LCS2 LCS3 LCS4 LCS5 LCS6 LCS7 LCS8 LCS9 LCS10 LCS11

在这张图中,我们看到计算最长公共子序列时的一些重叠子问题。每一个节点代表一个子问题,例如"LCS1"表示求解字符串"ABCBDAB"和"BDCABA"的最长公共子序列,而"LCS2"表示求解"BCBDAB"和"DCABA"的最长公共子序列。因为这些子问题在多个计算路径中会重复出现,所以它们就是重叠子问题的例子。

2.2 解决重叠子问题的方法

1. 记忆化技术(Memoization)

记忆化技术是一种自顶向下的解决方法,通过递归计算子问题,并将计算结果存储在一个表中。每当需要计算一个子问题时,先检查表中是否已有结果,如果有则直接使用,否则计算并存储结果。

javascript 复制代码
function fibonacci(n, memo = {}) {
  if (n <= 1) return n;
  if (memo[n]) return memo[n];
  memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo);
  return memo[n];
}

console.log(fibonacci(10)); // 输出55

2. 表格法(Tabulation)

表格法是一种自底向上的解决方法,通过迭代计算所有子问题的解,并将这些解存储在一个表中。这样,每当需要计算一个子问题时,可以直接查表得到结果。

javascript 复制代码
function fibonacci(n) {
  if (n <= 1) return n;
  const table = [0, 1];
  for (let i = 2; i <= n; i++) {
    table[i] = table[i - 1] + table[i - 2];
  }
  return table[n];
}

console.log(fibonacci(10)); // 输出55

2.3 如何识别重叠子问题

识别一个问题是否具有重叠子问题性质,通常需要以下步骤:

  1. 分解问题:将原问题分解为子问题。
  2. 检测重复:检查是否存在重复计算的子问题。
  3. 优化策略:选择合适的优化策略,如记忆化技术或表格法,来存储和复用子问题的计算结果。

通过理解最优子结构和重叠子问题的概念,我们可以更好地应用动态规划来解决实际问题。这两个核心概念帮助我们识别问题的结构特性,并选择合适的优化策略,从而提高算法的效率。


三、经典动态规划问题及其 JavaScript 实现

3.1 斐波那契数列

斐波那契数列是动态规划的经典问题之一。其递推公式为:
F ( n ) = F ( n − 1 ) + F ( n − 2 ) F(n) = F(n-1) + F(n-2) F(n)=F(n−1)+F(n−2)

基准条件为:
F ( 0 ) = 0 , F ( 1 ) = 1 F(0) = 0, F(1) = 1 F(0)=0,F(1)=1

记忆化技术实现斐波那契数列

javascript 复制代码
/**
 * 计算斐波那契数列的第 n 项
 * 使用记忆化技术来避免重复计算
 * @param {number} n - 斐波那契数列的第 n 项
 * @param {object} memo - 用于存储已经计算过的斐波那契数值
 * @returns {number} - 第 n 项的斐波那契数值
 */
function fibonacci(n, memo = {}) {
  // 基准条件:如果 n 小于等于 1,则返回 n
  if (n <= 1) return n;

  // 如果 memo 对象中已经存在第 n 项的结果,则直接返回
  if (memo[n]) return memo[n];

  // 否则,计算第 n 项的结果,并将其存储在 memo 对象中
  memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo);

  // 返回第 n 项的结果
  return memo[n];
}

// 示例:计算斐波那契数列的第 10 项
console.log(fibonacci(10)); // 输出55

在上述代码中,我们使用了一个 memo 对象来存储已经计算过的斐波那契数值,这样在遇到重复子问题时可以直接返回结果,避免了重复计算。


3.2 背包问题

背包问题描述了这样一个场景:给定一组物品,每个物品有一定的重量和价值,在总重量不超过容量的情况下,如何选择物品使得总价值最大。

动态规划实现背包问题

javascript 复制代码
/**
 * 解决背包问题,找到在不超过最大容量的情况下,能够获得的最大价值
 * @param {number[]} weights - 物品的重量数组
 * @param {number[]} values - 物品的价值数组
 * @param {number} capacity - 背包的最大容量
 * @returns {number} - 背包能够容纳的最大价值
 */
function knapsack(weights, values, capacity) {
  const n = weights.length;

  // 创建一个二维数组 dp,用于存储动态规划的结果
  // dp[i][w] 表示前 i 件物品在容量为 w 时能够获得的最大价值
  const dp = Array.from({ length: n + 1 }, () => Array(capacity + 1).fill(0));

  // 遍历每一件物品
  for (let i = 1; i <= n; i++) {
    // 遍历每一种可能的容量
    for (let w = 1; w <= capacity; w++) {
      // 如果当前物品的重量小于等于当前容量
      if (weights[i - 1] <= w) {
        // 选择当前物品,或者不选择当前物品,取最大值
        dp[i][w] = Math.max(
          dp[i - 1][w], // 不选择当前物品
          dp[i - 1][w - weights[i - 1]] + values[i - 1] // 选择当前物品
        );
      } else {
        // 如果当前物品的重量大于当前容量,则不能选择当前物品
        dp[i][w] = dp[i - 1][w];
      }
    }
  }

  // 返回最大价值
  return dp[n][capacity];
}

// 示例:背包问题
const weights = [1, 3, 4, 5]; // 物品的重量数组
const values = [1, 4, 5, 7]; // 物品的价值数组
const capacity = 7; // 背包的最大容量

console.log(knapsack(weights, values, capacity)); // 输出9

在上述代码中,我们使用一个二维数组 dp 来存储动态规划的结果。dp[i][w] 表示前 i 件物品在容量为 w 时能够获得的最大价值。通过遍历每一件物品和每一种可能的容量,我们可以找到在不超过最大容量的情况下,能够获得的最大价值。


四、总结

动态规划通过分解问题、存储子问题结果,解决了许多经典的计算问题。在实际应用中,识别问题是否具有最优子结构和重叠子问题的性质,并正确使用记忆化技术或表格法,可以显著提高算法的效率。

通过以上两个示例,相信大家对动态规划的基本思想和应用有了更深入的理解。在实际开发中,遇到复杂问题时,不妨考虑一下是否可以通过动态规划来解决。

相关推荐
qq_4335545414 分钟前
C++ 面向对象编程:递增重载
开发语言·c++·算法
带多刺的玫瑰35 分钟前
Leecode刷题C语言之切蛋糕的最小总开销①
java·数据结构·算法
巫师不要去魔法部乱说1 小时前
PyCharm专项训练5 最短路径算法
python·算法·pycharm
若川1 小时前
Taro 源码揭秘:10. Taro 到底是怎样转换成小程序文件的?
前端·javascript·react.js
qystca1 小时前
洛谷 P11242 碧树 C语言
数据结构·算法
IT女孩儿1 小时前
JavaScript--WebAPI查缺补漏(二)
开发语言·前端·javascript·html·ecmascript
冠位观测者1 小时前
【Leetcode 热题 100】124. 二叉树中的最大路径和
数据结构·算法·leetcode
悲伤小伞1 小时前
C++_数据结构_详解二叉搜索树
c语言·数据结构·c++·笔记·算法
m0_675988233 小时前
Leetcode3218. 切蛋糕的最小总开销 I
c++·算法·leetcode·职场和发展
佳心饼干-5 小时前
C语言-09内存管理
c语言·算法