• C
  • C语言动态规划

  • @ 2025-5-30 8:05:34

📚 什么是动态规划?

动态规划(Dynamic Programming,简称DP) 是一种在数学、计算机科学和经济学中广泛使用的算法设计技术。它主要用于解决具有重叠子问题和最优子结构的问题。

通俗地讲:

动态规划 = 分治 + 记忆化


🔍 动态规划的核心思想

  1. 将原问题分解为更小的子问题
  2. 每个子问题只求解一次,并保存结果
  3. 通过组合子问题的解来得到原问题的解

✅ 动态规划适用条件

  • 最优子结构:原问题的最优解包含子问题的最优解。
  • 重叠子问题:在递归求解过程中,子问题会被多次重复调用。

🧠 动态规划的步骤

  1. 定义状态(State)
  2. 确定状态转移方程
  3. 初始化边界值
  4. 按顺序计算所有状态
  5. 返回最终结果

📈 经典例题讲解

例题一:斐波那契数列(记忆化搜索 vs DP)

题目描述:

计算第 n 个斐波那契数,其中 F(0) = 0, F(1) = 1, F(n) = F(n - 1) + F(n - 2)

普通递归(不推荐):

int fib(int n) {
    if (n <= 1)
        return n;
    return fib(n - 1) + fib(n - 2);
}

⚠️ 缺点:大量重复计算,时间复杂度 O(2ⁿ)


✅ 使用动态规划优化(自底向上)

#include <stdio.h>

int fib_dp(int n) {
    if (n == 0) return 0;

    int dp[n + 1];  // 创建状态数组
    dp[0] = 0;      // 初始状态
    dp[1] = 1;

    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];  // 状态转移方程
    }

    return dp[n];
}

int main() {
    int n = 10;
    printf("Fibonacci(%d) = %d\n", n, fib_dp(n));
    return 0;
}

🟢 输出:

Fibonacci(10) = 55

📊 时间复杂度:O(n)
💾 空间复杂度:O(n)


例题二:背包问题(Knapsack Problem)

题目描述:

给定一个容量为 W 的背包和 N 个物品,每个物品有重量 w[i] 和价值 v[i]。选择物品装入背包,使得总价值最大。


🧩 动态规划分析:

  • 定义状态:dp[i][w] 表示前 i 个物品,在总重量不超过 w 时的最大价值。
  • 状态转移:
    dp[i][w] = max(dp[i-1][w], dp[i-1][w - w[i]] + v[i])
    

✅ 示例代码(二维DP):

#include <stdio.h>
#include <string.h>

#define MAX(a,b) ((a) > (b) ? (a) : (b))

int knapsack(int W, int wt[], int val[], int n) {
    int dp[n+1][W+1];

    // 初始化
    memset(dp, 0, sizeof(dp));

    // 填表
    for (int i = 1; i <= n; i++) {
        for (int w = 0; w <= W; w++) {
            if (wt[i-1] <= w)
                dp[i][w] = MAX(val[i-1] + dp[i-1][w - wt[i-1]], dp[i-1][w]);
            else
                dp[i][w] = dp[i-1][w];
        }
    }

    return dp[n][W];
}

int main() {
    int val[] = {60, 100, 120};
    int wt[] = {10, 20, 30};
    int W = 50;
    int n = sizeof(val)/sizeof(val[0]);

    printf("最大价值为:%d\n", knapsack(W, wt, val, n));
    return 0;
}

🟢 输出:

最大价值为:220

📊 时间复杂度:O(n * W)
💾 空间复杂度:O(n * W)


优化空间复杂度(滚动数组)

可以将二维数组压缩为一维数组:

int knapsack_optimized(int W, int wt[], int val[], int n) {
    int dp[W+1];
    memset(dp, 0, sizeof(dp));

    for (int i = 0; i < n; i++) {
        for (int w = W; w >= wt[i]; w--) {
            dp[w] = MAX(dp[w], dp[w - wt[i]] + val[i]);
        }
    }

    return dp[W];
}

🧩 更多经典问题(附简要说明)

问题名称 描述 关键点
最长公共子序列(LCS) 找两个字符串中最长的公共子序列 二维DP,比较字符
最长递增子序列(LIS) 找出最长递增的子序列 状态转移依赖前面元素
数字三角形 从顶部到底部路径最大和 自底向上DP
矩阵链乘法 如何加括号使矩阵相乘次数最少 区间DP

📊 总结图解

         ┌───────────────┐
         │   动态规划    │
         └────────┬──────┘
                  │
     ┌────────────┴────────────┐
     │                        │
┌────┴────┐          ┌────────┴────────┐
│ 最优子结构 │          │ 重叠子问题       │
└───────────┘          └─────────────────┘
     │                        │
状态定义              状态转移方程
     │                        │
初始化边界            填表或递推计算
     │                        │
输出最终结果          空间优化(滚动数组等)

🛠 小技巧与建议

  • 从简单问题入手,逐步理解状态定义和转移
  • 多画表格帮助理解DP过程
  • 注意边界条件(如初始值)
  • 学会空间优化(如滚动数组)
  • 多练习 LeetCode 上的经典DP题目

📚 推荐学习资源


📄 附录:完整C语言模板(背包问题)

#include <stdio.h>
#include <string.h>

#define MAX(a,b) ((a) > (b) ? (a) : (b))

int knapsack(int W, int wt[], int val[], int n) {
    int dp[W+1];
    memset(dp, 0, sizeof(dp));

    for (int i = 0; i < n; i++) {
        for (int w = W; w >= wt[i]; w--) {
            dp[w] = MAX(dp[w], dp[w - wt[i]] + val[i]);
        }
    }

    return dp[W];
}

int main() {
    int val[] = {60, 100, 120};
    int wt[] = {10, 20, 30};
    int W = 50;
    int n = sizeof(val)/sizeof(val[0]);

    printf("最大价值为:%d\n", knapsack(W, wt, val, n));
    return 0;
}

📝 结语

动态规划是算法中的核心技巧之一,虽然初学时有一定难度,但一旦掌握,你将能轻松应对许多复杂的算法问题。

Practice makes perfect! 多做题,多总结,你会发现DP其实很有趣!


📅 更新日期:2025年5月30日
📎 适用于:C语言初学者 & 算法爱好者

1 条评论

  • @ 2025-5-30 8:05:48

    下面我将为你提供一个详细、通俗易懂的C语言动态规划完整教程。这个教程包含了动态规划的基本概念、解题步骤、经典问题分析以及对应的C语言代码实现。

    C语言动态规划完整教程

    一、动态规划基本概念

    1. 什么是动态规划?

    动态规划(Dynamic Programming,简称DP)是一种通过将复杂问题分解为重叠子问题,并存储子问题的解来避免重复计算,从而提高算法效率的方法。

    2. 动态规划的核心思想

    动态规划的核心思想是避免重复计算利用子问题的最优解来构造原问题的最优解

    3. 动态规划与分治法的区别

    • 分治法:将问题分解为相互独立的子问题,然后递归地解决这些子问题,最后合并子问题的解。
    • 动态规划:处理的子问题是重叠的,通过存储子问题的解来避免重复计算。

    4. 动态规划的适用条件

    动态规划适用于满足以下两个条件的问题:

    • 最优子结构:问题的最优解包含子问题的最优解。
    • 子问题重叠:在求解过程中,许多子问题会被重复计算。

    二、动态规划解题步骤

    1. 定义状态

    状态是原问题和子问题的抽象表示,通常用一个数组或多维数组来表示。例如:

    • dp[i] 表示问题规模为 i 时的解。
    • dp[i][j] 表示在某种条件下,问题规模为 ij 时的解。

    2. 确定状态转移方程

    状态转移方程是动态规划的关键,它描述了状态之间的递推关系。例如:

    • dp[i] = dp[i-1] + dp[i-2](斐波那契数列)
    • dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + value[i][j](背包问题)

    3. 初始化边界条件

    边界条件是状态转移方程无法处理的特殊情况,需要单独初始化。例如:

    • dp[0] = 1
    • dp[i][0] = 0dp[0][j] = 0

    4. 确定计算顺序

    根据状态转移方程的依赖关系,确定计算状态的顺序,确保在计算某个状态时,它所依赖的状态已经计算完成。

    三、经典动态规划问题及C语言实现

    1. 斐波那契数列

    问题描述:计算斐波那契数列的第n项,其中 F(0) = 0, F(1) = 1, 且 F(n) = F(n-1) + F(n-2)

    C语言实现

    #include <stdio.h>
    
    // 方法一:递归(不使用动态规划,存在大量重复计算)
    int fib_recursive(int n) {
        if (n == 0) return 0;
        if (n == 1) return 1;
        return fib_recursive(n-1) + fib_recursive(n-2);
    }
    
    // 方法二:动态规划(自底向上)
    int fib_dp(int n) {
        if (n == 0) return 0;
        int dp[n+1];  // 创建一个数组存储子问题的解
        dp[0] = 0;    // 初始化边界条件
        dp[1] = 1;
        for (int i = 2; i <= n; i++) {
            dp[i] = dp[i-1] + dp[i-2];  // 状态转移方程
        }
        return dp[n];
    }
    
    // 方法三:优化空间复杂度的动态规划
    int fib_dp_optimized(int n) {
        if (n == 0) return 0;
        int a = 0, b = 1, c;
        for (int i = 2; i <= n; i++) {
            c = a + b;
            a = b;
            b = c;
        }
        return b;
    }
    
    int main() {
        int n = 10;
        printf("递归方法: F(%d) = %d\n", n, fib_recursive(n));
        printf("动态规划方法: F(%d) = %d\n", n, fib_dp(n));
        printf("优化空间的动态规划方法: F(%d) = %d\n", n, fib_dp_optimized(n));
        return 0;
    }
    

    代码解释

    • fib_recursive:使用递归方法计算斐波那契数列,但存在大量重复计算,时间复杂度为O(2ⁿ)。
    • fib_dp:使用动态规划自底向上计算,存储中间结果,时间复杂度为O(n),空间复杂度为O(n)。
    • fib_dp_optimized:优化空间复杂度的动态规划,由于每个状态只依赖前两个状态,只需保存两个变量,空间复杂度降为O(1)。

    2. 爬楼梯问题

    问题描述:假设你正在爬楼梯,需要n步才能到达顶部。每次你可以爬1步或2步。有多少种不同的方法可以爬到顶部?

    C语言实现

    #include <stdio.h>
    
    // 爬楼梯问题(本质上是斐波那契数列)
    int climbStairs(int n) {
        if (n <= 2) return n;
        int dp[n+1];
        dp[1] = 1;  // 1步有1种方法
        dp[2] = 2;  // 2步有2种方法(1+1或2)
        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i-1] + dp[i-2];  // 状态转移方程:第i步的方法数等于第i-1步和第i-2步的方法数之和
        }
        return dp[n];
    }
    
    // 优化空间复杂度
    int climbStairsOptimized(int n) {
        if (n <= 2) return n;
        int a = 1, b = 2, c;
        for (int i = 3; i <= n; i++) {
            c = a + b;
            a = b;
            b = c;
        }
        return b;
    }
    
    int main() {
        int n = 5;
        printf("爬 %d 阶楼梯的方法数: %d\n", n, climbStairs(n));
        printf("优化空间后的方法数: %d\n", n, climbStairsOptimized(n));
        return 0;
    }
    

    代码解释

    • 状态定义:dp[i] 表示爬到第 i 阶楼梯的方法数。
    • 状态转移方程:dp[i] = dp[i-1] + dp[i-2],因为最后一步可以是从第 i-1 阶爬1步上来,也可以是从第 i-2 阶爬2步上来。
    • 边界条件:dp[1] = 1dp[2] = 2

    3. 最大子数组和

    问题描述:给定一个整数数组 nums,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

    C语言实现

    #include <stdio.h>
    #include <stdlib.h>
    
    // 最大子数组和
    int maxSubArray(int* nums, int numsSize) {
        if (numsSize == 0) return 0;
        int dp[numsSize];  // dp[i] 表示以第i个元素结尾的最大子数组和
        dp[0] = nums[0];    // 初始化
        int max_sum = dp[0];
        
        for (int i = 1; i < numsSize; i++) {
            // 状态转移方程:要么将当前元素加入前面的子数组,要么以当前元素开始一个新的子数组
            dp[i] = (dp[i-1] + nums[i] > nums[i]) ? dp[i-1] + nums[i] : nums[i];
            // 更新全局最大和
            if (dp[i] > max_sum) {
                max_sum = dp[i];
            }
        }
        
        return max_sum;
    }
    
    // 优化空间复杂度
    int maxSubArrayOptimized(int* nums, int numsSize) {
        if (numsSize == 0) return 0;
        int current_sum = nums[0];
        int max_sum = nums[0];
        
        for (int i = 1; i < numsSize; i++) {
            // 直接在原变量上更新,不需要数组
            current_sum = (current_sum + nums[i] > nums[i]) ? current_sum + nums[i] : nums[i];
            if (current_sum > max_sum) {
                max_sum = current_sum;
            }
        }
        
        return max_sum;
    }
    
    int main() {
        int nums[] = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
        int numsSize = sizeof(nums) / sizeof(nums[0]);
        
        printf("最大子数组和: %d\n", maxSubArray(nums, numsSize));
        printf("优化空间后的最大子数组和: %d\n", maxSubArrayOptimized(nums, numsSize));
        
        return 0;
    }
    

    代码解释

    • 状态定义:dp[i] 表示以第 i 个元素结尾的最大子数组和。
    • 状态转移方程:dp[i] = max(dp[i-1] + nums[i], nums[i]),即要么将当前元素加入前面的子数组,要么以当前元素开始一个新的子数组。
    • 全局最大和:遍历 dp 数组找到最大值。

    4. 背包问题(0-1背包)

    问题描述:有 n 个物品和一个容量为 W 的背包。每个物品有重量 w[i] 和价值 v[i],要求选择一些物品放入背包,使得背包内物品的总价值最大,且总重量不超过背包容量。每个物品只能选择一次。

    C语言实现

    #include <stdio.h>
    #include <stdlib.h>
    
    // 0-1背包问题
    int knapsack(int W, int* wt, int* val, int n) {
        // 创建二维数组 dp[i][w] 表示前i个物品放入容量为w的背包中的最大价值
        int dp[n+1][W+1];
        
        // 初始化:当物品数为0或背包容量为0时,价值为0
        for (int i = 0; i <= n; i++) {
            dp[i][0] = 0;
        }
        for (int w = 0; w <= W; w++) {
            dp[0][w] = 0;
        }
        
        // 填充dp数组
        for (int i = 1; i <= n; i++) {
            for (int w = 1; w <= W; w++) {
                if (wt[i-1] > w) {
                    // 当前物品重量大于背包容量,不能放入
                    dp[i][w] = dp[i-1][w];
                } else {
                    // 可以选择放入或不放入,取最大值
                    dp[i][w] = (dp[i-1][w] > dp[i-1][w-wt[i-1]] + val[i-1]) ? 
                               dp[i-1][w] : dp[i-1][w-wt[i-1]] + val[i-1];
                }
            }
        }
        
        return dp[n][W];
    }
    
    int main() {
        int val[] = {60, 100, 120};  // 物品价值
        int wt[] = {10, 20, 30};     // 物品重量
        int W = 50;                  // 背包容量
        int n = sizeof(val) / sizeof(val[0]);
        
        printf("背包能装入的最大价值: %d\n", knapsack(W, wt, val, n));
        
        return 0;
    }
    

    代码解释

    • 状态定义:dp[i][w] 表示前 i 个物品放入容量为 w 的背包中的最大价值。
    • 状态转移方程:
      • 当第 i 个物品的重量大于当前背包容量 w 时,不能放入,dp[i][w] = dp[i-1][w]
      • 否则,可以选择放入或不放入,取最大值:dp[i][w] = max(dp[i-1][w], dp[i-1][w-wt[i-1]] + val[i-1])
    • 最终结果:dp[n][W] 即为所求。

    四、动态规划常见优化技巧

    1. 空间优化

    当状态转移方程只依赖于前面的几个状态时,可以通过滚动数组或直接用变量保存中间结果,将空间复杂度从O(n)或O(n²)降低到O(1)或O(n)。

    例如,斐波那契数列和爬楼梯问题的优化版本都使用了这种技巧。

    2. 状态压缩

    在某些二维动态规划问题中,如果状态转移只依赖于上一行或上一列的状态,可以将二维数组压缩为一维数组,从而降低空间复杂度。

    例如,0-1背包问题可以通过逆序更新一维数组来实现空间优化:

    // 0-1背包问题的空间优化版本
    int knapsackOptimized(int W, int* wt, int* val, int n) {
        int dp[W+1];
        // 初始化
        for (int w = 0; w <= W; w++) {
            dp[w] = 0;
        }
        
        // 逆序更新dp数组
        for (int i = 0; i < n; i++) {
            for (int w = W; w >= wt[i]; w--) {
                dp[w] = (dp[w] > dp[w-wt[i]] + val[i]) ? dp[w] : dp[w-wt[i]] + val[i];
            }
        }
        
        return dp[W];
    }
    

    五、动态规划问题解题思路总结

    1. 分析问题:确定问题是否具有最优子结构和子问题重叠的特性。
    2. 定义状态:找出问题的状态表示,通常是一个数组或多维数组。
    3. 推导状态转移方程:根据问题的最优子结构,找出状态之间的递推关系。
    4. 初始化边界条件:处理状态转移方程无法覆盖的特殊情况。
    5. 确定计算顺序:根据状态转移方程的依赖关系,确定计算顺序。
    6. 优化:检查是否可以优化空间复杂度或时间复杂度。

    通过不断练习和分析经典问题,你将逐渐掌握动态规划的解题技巧,能够更快速地识别和解决适合用动态规划方法解决的问题。

    • 1