为了账号安全,请及时绑定邮箱和手机立即绑定

动态规划学习:初学者的简单教程

概述

动态规划是一种高效的问题解决方法,通过将问题分解成更小的子问题并存储子问题的解来优化计算效率。这种方法特别适用于可以通过重复利用先前计算结果来高效求解的问题。本文详细介绍了动态规划的基本概念、核心要素以及常见的动态规划问题和解决方法,包括从递归方法到最优子结构和状态转移方程的转换。

引入动态规划

动态规划的基本概念

动态规划(Dynamic Programming)是一种通过将复杂问题分解成更小的子问题来求解的方法。这种方法通过存储子问题的解来避免重复计算,从而提高效率。动态规划特别适用于那些可以通过重复使用先前计算结果来高效求解的问题。

动态规划与递归的区别

虽然动态规划和递归都用于解决问题,但两者之间存在显著的区别。递归是一种编程技术,它使用函数调用自身的方法来解决问题,但递归可能会导致大量的重复计算。例如,在计算斐波那契数列时,递归方法会导致大量的重复调用。

动态规划则通过存储子问题的解来避免重复计算,这种方法通常使用数组或哈希表来存储中间结果。因此,动态规划在时间和空间利用率上通常比单纯的递归方法更高效。

下面是一个简单的斐波那契数列计算的递归实现示例:

def fibonacci(n):
    if n <= 1:
        return n
    else:
        return fibonacci(n-1) + fibonacci(n-2)

可以看到,对于较大的 n,递归方法的效率非常低,因为它会重复计算相同的数列项。

相比之下,动态规划方法能够有效地避免这种重复计算。下面是一个使用动态规划求解斐波那契数列的示例:

def fibonacci_dp(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[0], dp[1] = 0, 1
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

在这个示例中,我们使用一个数组 dp 来存储每次计算的结果,避免了重复计算。

动态规划的核心要素

重叠子问题

在动态规划中,重叠子问题是其核心要素之一。它指的是在解决一个问题时,不同的子问题会被多次计算。动态规划通过存储这些重叠子问题的解来避免重复计算,从而提高效率。

例如,计算斐波那契数列中的 fib(n) 会涉及到计算 fib(n-1)fib(n-2)。如果 n 较大,这些子问题会被多次计算。下面我们通过一个简单的例子来展示如何识别和解决重叠子问题。

假设我们有一个函数 fib(n),用于计算斐波那契数列中的第 n 个数:

def fib(n):
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

在这个递归实现中,计算 fib(n) 会涉及多次计算 fib(n-1)fib(n-2)。这会导致大量的重复计算。为了防止这种情况,我们可以使用动态规划方法来存储已计算的结果:

def fib_dp(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[0], dp[1] = 0, 1
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

在这个实现中,我们使用了一个数组 dp 来存储每个子问题的结果,这样在后续计算时就可以直接使用已计算的结果,避免了重复计算。

最优子结构

最优子结构是动态规划的另一个核心概念。它指的是一个问题的最优解可以由其子问题的最优解构建而来。换句话说,全局最优解可以通过局部最优解来构建。动态规划利用这一特性来构建全局最优解。

例如,考虑一个经典的背包问题:给定一个容量为 W 的背包和若干物品,每个物品都有一个重量和一个价值。在不超过背包容量的情况下,选择一组物品,使得总价值最大。这个问题可以被分解成若干子问题,每个子问题涉及背包容量减小和物品减少的情况。

最优子结构的特性允许我们通过动态规划来解决这个问题。具体来说,我们可以定义一个二维数组 dp,其中 dp[i][w] 表示在前 i 个物品中选择一个子集,使得总重量不超过 w 的最大价值。通过优化选择物品的方式,我们可以逐步构建全局最优解。

下面是一个简单的背包问题的动态规划实现:

def knapsack(weights, values, capacity):
    n = len(values)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(1, capacity + 1):
            if weights[i - 1] <= w:
                dp[i][w] = 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]

在这个实现中,dp[i][w] 表示在前 i 个物品中选择子集,使得总重量不超过 w 的最大价值。通过遍历所有可能的物品和容量组合,我们可以构建全局最优解。

动态规划的实现步骤

定义状态

在动态规划中,定义状态是至关重要的一步。状态是指在解决问题时需要跟踪的信息。这些信息通常由一个或多个数组或变量来表示,存储子问题的解。

例如,考虑一个简单的爬楼梯问题:给定一个楼梯有 n 级,每次可以走 1 级或 2 级,求到达第 n 级楼梯的总方法数。我们可以定义状态 dp[i] 表示到达第 i 级楼梯的总方法数。这样,我们可以通过递推关系来求解 dp[n]

下面我们来看一个具体的例子:

def climb_stairs(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[1], dp[2] = 1, 2
    for i in range(3, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

在这个实现中,dp[i] 表示到达第 i 级楼梯的方法数。通过递推关系 dp[i] = dp[i - 1] + dp[i - 2],我们可以逐级计算到达第 n 级楼梯的方法数。

状态转移方程

状态转移方程描述了如何从一个状态转移到另一个状态。它定义了如何使用已知的信息来计算新的信息。对于动态规划问题,状态转移方程通常表示为递推关系,它描述了当前状态与前一个或多个子状态之间的关系。

例如,对于爬楼梯问题,状态转移方程是 dp[i] = dp[i - 1] + dp[i - 2],这意味着到达第 i 级楼梯的方法数等于到达第 i-1 级和第 i-2 级的方法数之和。

下面是一个具体的例子:

def climb_stairs(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[1], dp[2] = 1, 2
    for i in range(3, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

在这个实现中,状态转移方程是 dp[i] = dp[i - 1] + dp[i - 2],通过递推关系,我们可以计算出到达第 n 级楼梯的所有方法数。

初始化和边界情况

在动态规划问题中,初始化和处理边界情况是非常重要的。初始化通常涉及到定义初始状态,而边界情况则涉及到处理特殊情况。

例如,对于爬楼梯问题,初始状态是 dp[1] = 1dp[2] = 2,因为到达第 1 级和第 2 级楼梯的方法数分别是 1 和 2。边界情况是当 n <= 1 时,直接返回 n

下面是一个具体的例子:

def climb_stairs(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[1], dp[2] = 1, 2
    for i in range(3, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

在这个实现中,初始状态是 dp[1] = 1dp[2] = 2,边界情况是当 n <= 1 时,直接返回 n

常见动态规划问题及解决方法

爬楼梯问题

爬楼梯问题是一个典型的动态规划问题,它要求计算到达第 n 级楼梯的总方法数。给定一个楼梯有 n 级,每次可以走 1 级或 2 级,求到达第 n 级楼梯的总方法数。

例如,如果楼梯有 3 级,可以有以下方法到达:

  • 1, 1, 1
  • 1, 2
  • 2, 1

因此,总方法数为 3。

下面我们来看一个具体的实现:

def climb_stairs(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[1], dp[2] = 1, 2
    for i in range(3, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

在这个实现中,我们定义了状态 dp[i] 表示到达第 i 级楼梯的方法数。状态转移方程是 dp[i] = dp[i - 1] + dp[i - 2],这意味着到达第 i 级楼梯的方法数等于到达第 i-1 级和第 i-2 级的方法数之和。

0/1 背包问题

0/1 背包问题是一个经典的动态规划问题。给定一个背包容量为 W,若干物品,每个物品有重量 weight[i] 和价值 value[i]。选择若干物品放入背包,使得总重量不超过背包容量,总价值最大。

下面是一个具体的实现:

def knapsack(weights, values, capacity):
    n = len(values)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(1, capacity + 1):
            if weights[i - 1] <= w:
                dp[i][w] = 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]

在这个实现中,dp[i][w] 表示在前 i 个物品中选择一个子集,使得总重量不超过 w 的最大价值。状态转移方程是 dp[i][w] = max(dp[i - 1][w], dp[i - 1][w - weights[i - 1]] + values[i - 1]),这意味着选择第 i 个物品时,总价值为 dp[i - 1][w - weights[i - 1]] + values[i - 1]。如果不选择第 i 个物品,则总价值为 dp[i - 1][w]

最长递增子序列

最长递增子序列问题要求在一个数组中找到最长的递增子序列。例如,对于数组 [10, 9, 2, 5, 3, 7, 101, 18],最长递增子序列是 [2, 3, 7, 101]

下面是一个具体的实现:

def length_of_lis(nums):
    if not nums:
        return 0
    dp = [1] * len(nums)
    for i in range(len(nums)):
        for j in range(i):
            if nums[i] > nums[j]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)

在这个实现中,dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。状态转移方程是 dp[i] = max(dp[i], dp[j] + 1),这意味着如果 nums[i] > nums[j],则以 nums[i] 结尾的最长递增子序列的长度为 dp[j] + 1

最长公共子序列

最长公共子序列问题要求在一个字符串中找到另一个字符串的最长公共子序列。例如,对于字符串 "ABCBDAB""BDCAB",最长公共子序列是 "BCAB"

下面是一个具体的实现:

def longest_common_subsequence(text1, text2):
    m, n = len(text1), len(text2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if text1[i - 1] == text2[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
    return dp[m][n]

在这个实现中,dp[i][j] 表示字符串 text1 的前 i 个字符和字符串 text2 的前 j 个字符的最长公共子序列长度。状态转移方程是 dp[i][j] = dp[i - 1][j - 1] + 1,如果 text1[i - 1] == text2[j - 1],否则 dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

最大子数组和

最大子数组和问题要求在一个数组中找到和最大的连续子数组。例如,对于数组 [-2, 1, -3, 4, -1, 2, 1, -5, 4],和最大的连续子数组是 [4, -1, 2, 1],和为 6

下面是一个具体的实现:

def max_subarray_sum(nums):
    if not nums:
        return 0
    dp = [nums[0]]
    for i in range(1, len(nums)):
        dp[i] = max(nums[i], dp[i - 1] + nums[i])
    return max(dp)

在这个实现中,dp[i] 表示以 nums[i] 结尾的子数组的最大和。状态转移方程是 dp[i] = max(nums[i], dp[i - 1] + nums[i]),这意味着如果 dp[i - 1] + nums[i] 大于 nums[i],则以 nums[i] 结尾的子数组的最大和为 dp[i - 1] + nums[i],否则为 nums[i]

动态规划的优化技巧

时间和空间复杂度的优化

在处理较大的输入时,优化动态规划的时间和空间复杂度是非常重要的。通常,可以通过减少状态的数量或优化状态转移方式来达到这一点。

例如,对于爬楼梯问题,我们可以将状态数组从 dp[n+1] 优化为 dp[2],因为到达第 i 级楼梯的方法数只依赖于到达第 i-1 级和第 i-2 级的方法数。这样,空间复杂度从 O(n) 优化到 O(1)

下面是一个优化的实现:

def climb_stairs(n):
    if n <= 1:
        return n
    dp = [0, 1, 2]
    for i in range(3, n + 1):
        dp[i % 3] = dp[(i - 1) % 3] + dp[(i - 2) % 3]
    return dp[n % 3]

在这个实现中,我们使用了三个变量 dp[0], dp[1], 和 dp[2] 来存储到达前三个楼梯的方法数。通过取模运算,我们可以在三个变量之间循环更新,从而减少了空间复杂度。

记忆化搜索

记忆化搜索是一种将递归与动态规划相结合的技巧。它通过存储递归函数的中间结果,避免重复计算。这种方法非常适合处理具有重叠子问题的问题。

例如,对于斐波那契数列的计算,我们可以使用记忆化搜索来优化递归实现:

def fibonacci(n, memo={}):
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    memo[n] = fibonacci(n-1) + fibonacci(n-2)
    return memo[n]

在这个实现中,我们使用一个字典 memo 来存储中间结果。每次递归调用时,我们首先检查 n 是否已经在 memo 中。如果是,则直接返回已存储的结果,避免重复计算。

动态规划练习题推荐

经典题目与解析

为了巩固所学知识,推荐一些经典的动态规划问题进行练习。这些题目涵盖了不同类型的问题,可以帮助你更好地理解和应用动态规划。

1. 最长递增子序列 (Longest Increasing Subsequence)

最长递增子序列问题要求在一个数组中找到最长的递增子序列。例如,对于数组 [10, 9, 2, 5, 3, 7, 101, 18],最长递增子序列是 [2, 3, 7, 101]

下面是一个具体的实现:

def length_of_lis(nums):
    if not nums:
        return 0
    dp = [1] * len(nums)
    for i in range(len(nums)):
        for j in range(i):
            if nums[i] > nums[j]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)

在这个实现中,dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。状态转移方程是 dp[i] = max(dp[i], dp[j] + 1),这意味着如果 nums[i] > nums[j],则以 nums[i] 结尾的最长递增子序列的长度为 dp[j] + 1

2. 最长公共子序列 (Longest Common Subsequence)

最长公共子序列问题要求在一个字符串中找到另一个字符串的最长公共子序列。例如,对于字符串 "ABCBDAB""BDCAB",最长公共子序列是 "BCAB"

下面是一个具体的实现:

def longest_common_subsequence(text1, text2):
    m, n = len(text1), len(text2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if text1[i - 1] == text2[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
    return dp[m][n]

在这个实现中,dp[i][j] 表示字符串 text1 的前 i 个字符和字符串 text2 的前 j 个字符的最长公共子序列长度。状态转移方程是 dp[i][j] = dp[i - 1][j - 1] + 1,如果 text1[i - 1] == text2[j - 1],否则 dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

3. 最大子数组和 (Maximum Subarray Sum)

最大子数组和问题要求在一个数组中找到和最大的连续子数组。例如,对于数组 [-2, 1, -3, 4, -1, 2, 1, -5, 4],和最大的连续子数组是 [4, -1, 2, 1],和为 6

下面是一个具体的实现:

def max_subarray_sum(nums):
    if not nums:
        return 0
    dp = [nums[0]]
    for i in range(1, len(nums)):
        dp[i] = max(nums[i], dp[i - 1] + nums[i])
    return max(dp)

在这个实现中,dp[i] 表示以 nums[i] 结尾的子数组的最大和。状态转移方程是 dp[i] = max(nums[i], dp[i - 1] + nums[i]),这意味着如果 dp[i - 1] + nums[i] 大于 nums[i],则以 nums[i] 结尾的子数组的最大和为 dp[i - 1] + nums[i],否则为 nums[i]

如何巩固所学知识

巩固所学知识的最佳方式是多做练习题,反复解决不同类型的问题。以下是一些推荐的练习网站和资源:

  • 慕课网(imooc.com):提供丰富的动态规划课程和题目,适合不同水平的开发者。
  • 力扣(LeetCode):在线编程平台,提供大量的动态规划题目和解决方案。
  • CodeForces:竞赛平台,提供高质量的动态规划题目。
  • HackerRank:在线编程平台,提供大量的动态规划题目和评测系统。

通过这些资源,你可以找到各种难度的动态规划问题,不断挑战自己,提高解决问题的能力。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消