7.1.普通一维DP问题

普通一维DP问题

在C++中,一维动态规划(1D DP)是处理线性序列问题的核心方法。这类问题的状态通常只依赖前一两个状态,可以用一维数组(或变量)存储中间结果。以下是详细解析:


一、一维DP的核心解题步骤

  1. 明确问题是否满足DP条件

    • 存在重叠子问题(避免重复计算)
    • 具有最优子结构(当前最优解依赖子问题最优解)
  2. 定义状态

    • dp[i]表示处理到第i个元素时的最优解(或目标值)
    • 例如:dp[i]可以表示前i个房屋能偷到的最大金额(打家劫舍问题)
  3. 确定初始条件

    • 处理dp[0]dp[1]等初始值,通常对应问题的最小规模情况
  4. 推导状态转移方程

    • 找到dp[i]dp[i-1]dp[i-2]等的关系式
    • 例如:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
  5. 选择遍历顺序

    • 线性遍历(正向/逆向)或根据依赖关系确定顺序
  6. 空间优化(可选)

    • 若状态只依赖有限的前置状态,可用滚动变量代替数组

二、经典一维DP问题与代码实现

1. 斐波那契数列(基础模板)

问题:计算第n个斐波那契数(F(0)=0, F(1)=1
状态定义dp[i]表示第i个斐波那契数
状态转移dp[i] = dp[i-1] + dp[i-2]

int fib(int n) {
    if (n <= 1) return n;
    vector<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_optimized(int n) {
    if (n <= 1) return n;
    int prev2 = 0, prev1 = 1, curr;
    for (int i = 2; i <= n; ++i) {
        curr = prev1 + prev2;
        prev2 = prev1;
        prev1 = curr;
    }
    return curr;
}

2. 爬楼梯问题(LeetCode 70)

问题:每次爬1或2阶台阶,到达第n阶有多少种方法?
状态定义dp[i]表示到达第i阶的方法数
状态转移dp[i] = dp[i-1] + dp[i-2]
(最后一步可能是1阶或2阶)

int climbStairs(int n) {
    if (n <= 2) return n;
    vector<int> dp(n+1);
    dp[0] = 1; // 初始状态(无台阶时视为1种方式)
    dp[1] = 1;
    for (int i = 2; i <= n; ++i) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[n];
}

// 空间优化版
int climbStairs_optimized(int n) {
    if (n <= 2) return n;
    int a = 1, b = 1, c;
    for (int i = 2; i <= n; ++i) {
        c = a + b;
        a = b;
        b = c;
    }
    return c;
}

3. 打家劫舍(LeetCode 198)

问题:不能偷相邻房屋,求最大可偷金额
状态定义dp[i]表示前i个房屋能偷到的最大值
状态转移
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])
(第i个房屋偷或不偷)

int rob(vector<int>& nums) {
    int n = nums.size();
    if (n == 0) return 0;
    vector<int> dp(n+1);
    dp[0] = 0;
    dp[1] = nums[0];
    for (int i = 2; i <= n; ++i) {
        dp[i] = max(dp[i-1], dp[i-2] + nums[i-1]);
    }
    return dp[n];
}

// 空间优化版(只用两个变量)
int rob_optimized(vector<int>& nums) {
    int prev = 0, curr = 0;
    for (int num : nums) {
        int temp = max(curr, prev + num);
        prev = curr;
        curr = temp;
    }
    return curr;
}

4. 最大子数组和(LeetCode 53)

问题:找到具有最大和的连续子数组
状态定义dp[i]表示以第i个元素结尾的最大子数组和
状态转移
dp[i] = max(nums[i], dp[i-1] + nums[i])

int maxSubArray(vector<int>& nums) {
    int n = nums.size();
    vector<int> dp(n);
    dp[0] = nums[0];
    int max_sum = dp[0];
    for (int i = 1; i < n; ++i) {
        dp[i] = max(nums[i], dp[i-1] + nums[i]);
        max_sum = max(max_sum, dp[i]);
    }
    return max_sum;
}

// 空间优化版(只保留前一个状态)
int maxSubArray_optimized(vector<int>& nums) {
    int prev = nums[0], max_sum = prev;
    for (int i = 1; i < nums.size(); ++i) {
        prev = max(nums[i], prev + nums[i]);
        max_sum = max(max_sum, prev);
    }
    return max_sum;
}

三、一维DP的关键优化技巧

1. 空间压缩(滚动数组)

当状态仅依赖前一个或前几个状态时,可用变量代替数组:

// 优化前
vector<int> dp(n);
dp[i] = f(dp[i-1], dp[i-2]);

// 优化后(斐波那契例子)
int a = 0, b = 1, c;
for (int i = 2; i <= n; ++i) {
    c = a + b;
    a = b;
    b = c;
}
2. 状态合并

若问题允许,可将多个状态合并为单个变量:

// 股票买卖问题中的状态合并
int hold = -prices[0], not_hold = 0;
for (int price : prices) {
    int prev_hold = hold;
    hold = max(hold, not_hold - price);
    not_hold = max(not_hold, prev_hold + price);
}

四、一维DP的常见陷阱与注意事项

  1. 索引偏移

    • 数组下标从0开始,但dp[i]可能对应nums[i-1]
    • 示例:dp[1]对应第一个房屋nums[0]
  2. 边界条件处理

    • 空输入(如n=0时的rob函数)
    • 初始值设定(如dp[0]是否需要特殊处理)
  3. 负数处理

    • 最大子数组和问题中,数组可能全为负数
  4. 状态转移方程的正确性

    • 必须覆盖所有可能的情况(如偷/不偷、跳1步/2步)

五、典型一维DP问题分类

问题类型 特点 经典例题
线性递推 状态仅依赖前1-2个状态 斐波那契、爬楼梯
选择型 每一步有选择/不选两种决策 打家劫舍、最大子数组和
路径计数 统计达到目标的路径数 不同路径(带障碍物版)
序列匹配 处理字符串/序列的匹配问题 最长递增子序列(LIS)

六、调试与验证技巧

  1. 打印DP表
    在循环中输出dp[i]的值,观察是否符合预期:

    for (int i = 0; i <= n; ++i) {
        cout << "dp[" << i << "] = " << dp[i] << endl;
    }
    
  2. 小规模测试
    手动计算n=2n=3的情况,验证代码输出是否正确。

  3. 对比暴力解
    对较小输入,用递归暴力解法与DP结果对比。


七、综合练习建议

  1. 从简单问题入手(如斐波那契数列),熟悉状态定义
  2. 逐步挑战更复杂的选择型问题(如打家劫舍 II 的环形变种)
  3. 尝试将二维DP问题降维为一维(如0-1背包的空间优化)

你可能感兴趣的:(c++数据结构与算法,c++,算法)