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

动态规划入门:初学者必读教程

概述

动态规划(Dynamic Programming)是一种高效的解决问题的方法,通过将问题分解为更小的子问题并存储解来避免重复计算,从而提高效率。本文将详细介绍动态规划的核心思想、常见问题类型及其解题模板,帮助读者理解并掌握动态规划入门知识。动态规划广泛应用于最优化问题、子集问题和路径问题等场景,通过递归和迭代两种方式实现。具体内容包括基础概念、解题模板、案例分析以及进阶技巧。

动态规划入门:初学者必读教程
动态规划基础概念

动态规划简介

动态规划(Dynamic Programming,简称DP)是一种在计算机科学和数学中使用的,用于解决具有重叠子问题和最优子结构性质的问题的方法。动态规划通过将问题分解为更小的子问题,并存储这些子问题的解来避免重复计算,从而提高效率。动态规划的核心思想是利用已经解决的子问题来帮助解决更复杂的问题,从而达到整体最优解。动态规划通常用于解决最优化问题,如最长公共子序列、背包问题、最小路径和等。递归和迭代是动态规划常见的实现方式。递归方法通常用于问题定义,而迭代方法则用来实现具体的算法。

动态规划的核心思想

动态规划的核心思想在于通过“分而治之”的策略来解决问题。具体来说,动态规划把原问题分解成若干个子问题,通过求解这些子问题并保存其结果以避免重复计算,最终求解原问题。动态规划的两个核心概念是“最优子结构”和“重叠子问题”。

  • 最优子结构:原问题的最优解包含其子问题的最优解。这意味着通过求解子问题的最优解,可以构建出原问题的最优解。
  • 重叠子问题:问题可以分解成多个子问题,而这些子问题又包含了相同的子问题。通过存储每个子问题的解,可以避免重复计算,提高效率。

动态规划与递归的关系

动态规划与递归有着密切的关系。递归是一种解决问题的方法,通过将问题分解为更小的子问题来解决。然而,递归在解决重叠子问题时效率较低,因为它会重复计算相同的子问题。动态规划通过存储子问题的解来避免这种重复计算,从而提高了效率。

递归和动态规划的另一个区别在于递归通常用于问题定义,动态规划则用于实现具体的算法。递归通常涉及函数调用自身来解决子问题,而动态规划则通过存储子问题的解来构建最优解。

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

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

这个递归函数虽然简单,但在计算较大的斐波那契数时会非常低效,因为它会重复计算相同的子问题。通过引入动态规划,可以避免这种重复计算:

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]
动态规划常见问题类型

最优化问题

最优化问题是动态规划最常见的应用场景之一。这类问题通常涉及在多个可行解中寻找最优解。例如,背包问题、最长公共子序列、最短路径问题等。解决这类问题时,关键在于找到最优子结构和重叠子问题,并使用动态规划方法计算最优解。

背包问题是动态规划中的经典问题之一,它描述了如何在给定容量的背包中选择物品使得总价值最大。背包问题有两种类型:0/1背包问题和完全背包问题。0/1背包问题每个物品只能选择一次,而完全背包问题每个物品可以被选择多次。

下面是一个0/1背包问题的示例:

  • 问题描述:给定一个背包容量为 C,一组物品,每个物品有一个重量 weight[i] 和一个价值 value[i]。要求选择一些物品放入背包中,使得总重量不超过背包容量,同时总价值最大。
  • 解决方法:使用动态规划来解决0/1背包问题。定义一个二维数组 dp[i][j] 表示前 i 个物品中选择总重量不超过 j 的最大价值。状态转移方程如下:

    dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])

    其中,dp[i-1][j] 表示不选择第 i 个物品的最大价值,dp[i-1][j-weight[i]] + value[i] 表示选择第 i 个物品的最大价值。

下面是一个完整的0/1背包问题的实现示例:

def knapsack_01(C, weight, value):
    n = len(weight)
    dp = [[0] * (C + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(C + 1):
            if j >= weight[i-1]:
                dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i-1]] + value[i-1])
            else:
                dp[i][j] = dp[i-1][j]
    return dp[n][C]

子集问题

子集问题是动态规划中的另一类典型问题。这类问题通常涉及从一个给定集合中选择一个或多个元素,以满足某些条件。例如,子集和问题、子集划分问题等。解决子集问题时,通常需要找到一个有效的状态表示方法,使得状态转移方程能够高效地计算出最优解。

子集和问题是动态规划中的经典问题之一,它描述了如何从给定的整数集合中选择一个子集,使得子集中元素的和等于一个给定的目标值。解决这类问题时,可以使用动态规划来构建一个二维数组 dp[i][j],表示前 i 个元素中是否存在和为 j 的子集。

下面是一个子集和问题的示例:

  • 问题描述:给定一个整数数组 nums 和一个目标值 target,要求从数组中选择一个子集,使得子集中元素的和等于 target
  • 解决方法:使用动态规划来解决子集和问题。定义一个二维数组 dp[i][j] 表示前 i 个元素中是否存在一个子集,使得子集中元素的和等于 j。状态转移方程如下:

    dp[i][j] = dp[i-1][j] or dp[i-1][j-nums[i-1]]

    其中,dp[i-1][j] 表示不选择第 i 个元素的情况,dp[i-1][j-nums[i-1]] 表示选择第 i 个元素的情况。

下面是一个完整的子集和问题的实现示例:

def subset_sum(nums, target):
    n = len(nums)
    dp = [[False] * (target + 1) for _ in range(n + 1)]
    dp[0][0] = True
    for i in range(1, n + 1):
        dp[i][0] = True
    for i in range(1, n + 1):
        for j in range(1, target + 1):
            if j >= nums[i-1]:
                dp[i][j] = dp[i-1][j] or dp[i-1][j-nums[i-1]]
            else:
                dp[i][j] = dp[i-1][j]
    return dp[n][target]

路径问题

路径问题是动态规划中的另一类典型问题。这类问题通常涉及从一个起点到一个终点的最短路径或路径总数。例如,最短路径问题、网格路径问题等。解决路径问题时,通常需要找到一个有效的状态表示方法,使得状态转移方程能够高效地计算出路径的总数或最短路径。

网格路径问题是动态规划中的经典问题之一,它描述了如何从一个起点(通常是左上角)到达一个终点(通常是右下角),并且只能向右或向下移动。解决这类问题时,可以使用动态规划来构建一个二维数组 dp[i][j],表示到达位置 (i, j) 的路径总数。

下面是一个网格路径问题的示例:

  • 问题描述:给定一个二维网格,每一步只能向右或向下移动,求从左上角到右下角的路径总数。
  • 解决方法:使用动态规划来解决网格路径问题。定义一个二维数组 dp[i][j] 表示到达位置 (i, j) 的路径总数。状态转移方程如下:

    dp[i][j] = dp[i-1][j] + dp[i][j-1]

    其中,dp[i-1][j] 表示从上方位置到达 (i, j) 的路径总数,dp[i][j-1] 表示从左方位置到达 (i, j) 的路径总数。

下面是一个完整的网格路径问题的实现示例:

def unique_paths(m, n):
    dp = [[0] * n for _ in range(m)]
    for i in range(m):
        dp[i][0] = 1
    for j in range(n):
        dp[0][j] = 1
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = dp[i-1][j] + dp[i][j-1]
    return dp[-1][-1]
动态规划基本步骤详解

确定状态

确定状态是动态规划的关键步骤之一。状态通常表示为一个数组或一个二维数组,其中每个元素表示某个子问题的解。确定状态时,需要明确状态的含义,并定义一个合适的数组来存储这些状态。

例如,一个典型的动态规划问题通常是求解一个最优解,例如最大值、最小值或路径个数。定义状态时,通常会使用一个数组来存储各个子问题的解。例如,对于一个长度为 n 的数组,定义一个数组 dp,其中 dp[i] 表示前 i 个元素的最优解。

下面是一个例子,假设我们要计算一个数组的最长递增子序列(LIS),定义状态如下:

dp = [0] * n
for i in range(n):
    dp[i] = 1

这里的 dp[i] 表示以第 i 个元素结尾的最长递增子序列的长度。

定义状态转移方程

状态转移方程是动态规划的核心,它描述了如何通过子问题的解来构建原问题的解。定义状态转移方程时,需要考虑如何通过已知的子问题解来计算当前状态的解。通常,状态转移方程涉及一个或多个子问题,并在已知子问题解的基础上进行计算。

例如,在上面的最长递增子序列问题中,定义状态转移方程如下:

for i in range(1, n):
    for j in range(i):
        if nums[i] > nums[j]:
            dp[i] = max(dp[i], dp[j] + 1)

这里的 dp[i] 表示以第 i 个元素结尾的最长递增子序列的长度。对于每个 i,检查所有 j < i 的元素,如果 nums[i] > nums[j],则更新 dp[i]dp[j] + 1

确定初始条件和边界条件

确定初始条件和边界条件是动态规划的重要步骤之一。初始条件通常表示最简单的情况,边界条件则表示最极端的情况。正确地确定初始条件和边界条件可以确保动态规划算法的正确性和完整性。

在动态规划中,通常有以下几种常见的初始条件和边界条件:

  1. 初始条件:通常表示最简单的情况。例如,对于一个数组的最长递增子序列问题,初始条件可以定义为 dp[0] = 1,表示只有一个元素的子序列长度为 1。
  2. 边界条件:通常表示最极端的情况。例如,对于一个网格路径问题,边界条件可以定义为 dp[i][0] = 1dp[0][j] = 1,表示从起点到边界位置的路径总数为 1。

下面是一个完整的最长递增子序列问题的示例:

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

在这个示例中,dp[0] = 1 是初始条件,表示只有一个元素的子序列长度为 1。dp[i] 表示以第 i 个元素结尾的最长递增子序列的长度。

动态规划解题模板

如何选择数据结构

选择合适的数据结构是动态规划的关键步骤之一。通常,动态规划使用数组或二维数组来存储子问题的解。选择合适的数据结构可以提高算法的效率和可读性。

  • 一维数组:适用于解决线性问题,例如最长递增子序列、背包问题等。
  • 二维数组:适用于解决二维问题,例如网格路径问题、编辑距离问题等。
  • 多维数组:适用于解决更复杂的问题,例如三维背包问题、多路径问题等。

例如,在网格路径问题中,通常使用一个二维数组 dp 来存储每个位置的路径总数。定义状态转移方程如下:

dp = [[0] * n for _ in range(m)]
for i in range(m):
    dp[i][0] = 1
for j in range(n):
    dp[0][j] = 1
for i in range(1, m):
    for j in range(1, n):
        dp[i][j] = dp[i-1][j] + dp[i][j-1]

这里的 dp[i][j] 表示从起点到位置 (i, j) 的路径总数。

如何编写代码实现

编写代码实现是动态规划的重要步骤之一。在实现动态规划算法时,通常需要遵循以下步骤:

  1. 定义状态:根据问题描述定义一个合适的数组来存储子问题的解。
  2. 初始化状态:根据初始条件和边界条件初始化状态数组。
  3. 定义状态转移方程:根据问题描述定义状态转移方程。
  4. 实现状态转移:通过循环遍历子问题,计算状态转移方程。
  5. 返回结果:返回最终结果,通常是状态数组中的某个值。

下面是一个完整的网格路径问题的实现示例:

def unique_paths(m, n):
    dp = [[0] * n for _ in range(m)]
    dp[0][0] = 1
    for i in range(m):
        dp[i][0] = 1
    for j in range(n):
        dp[0][j] = 1
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = dp[i-1][j] + dp[i][j-1]
    return dp[-1][-1]

在这个示例中,dp[i][j] 表示从起点到位置 (i, j) 的路径总数。初始化状态数组 dp,并根据状态转移方程计算每个位置的路径总数,最终返回 dp[-1][-1] 作为结果。

动态规划案例分析

经典案例讲解

背包问题

背包问题是动态规划的经典问题之一,通常分为0/1背包问题和完全背包问题。0/1背包问题每个物品只能选择一次,而完全背包问题每个物品可以被选择多次。下面是一个0/1背包问题的示例:

  • 问题描述:给定一个容量为 C 的背包,一组物品,每个物品有一个重量 weight[i] 和一个价值 value[i]。要求选择一些物品放入背包中,使得总重量不超过背包容量,同时总价值最大。
  • 解决方法:使用动态规划来解决0/1背包问题。定义一个二维数组 dp[i][j] 表示前 i 个物品中选择总重量不超过 j 的最大价值。状态转移方程如下:

    dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])

    其中,dp[i-1][j] 表示不选择第 i 个物品的最大价值,dp[i-1][j-weight[i]] + value[i] 表示选择第 i 个物品的最大价值。

下面是一个完整的0/1背包问题的实现示例:

def knapsack_01(C, weight, value):
    n = len(weight)
    dp = [[0] * (C + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(C + 1):
            if j >= weight[i-1]:
                dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i-1]] + value[i-1])
            else:
                dp[i][j] = dp[i-1][j]
    return dp[n][C]

最长递增子序列问题

最长递增子序列(LIS)问题是动态规划的经典问题之一,它描述了如何从一个数组中找到一个最长的递增子序列。下面是一个最长递增子序列问题的示例:

  • 问题描述:给定一个整数数组 nums,要求找到一个最长的递增子序列。
  • 解决方法:使用动态规划来解决最长递增子序列问题。定义一个数组 dp,其中 dp[i] 表示以第 i 个元素结尾的最长递增子序列的长度。状态转移方程如下:

    dp[i] = max(dp[j] + 1) for all j < i and nums[j] < nums[i]

    其中,dp[j] + 1 表示选择第 j 个元素作为前缀的最长递增子序列长度。

下面是一个完整的最长递增子序列问题的实现示例:

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

最长公共子序列问题

最长公共子序列(LCS)问题是动态规划的经典问题之一,它描述了如何找到两个字符串的最长公共子序列。下面是一个最长公共子序列问题的示例:

  • 问题描述:给定两个字符串 text1text2,要求找到它们的最长公共子序列。
  • 解决方法:使用动态规划来解决最长公共子序列问题。定义一个二维数组 dp,其中 dp[i][j] 表示字符串 text1 的前 i 个字符和字符串 text2 的前 j 个字符的最长公共子序列长度。状态转移方程如下:

    dp[i][j] = dp[i-1][j-1] + 1 if text1[i-1] == text2[j-1] else max(dp[i-1][j], dp[i][j-1])

    其中,dp[i-1][j-1] + 1 表示当前字符相等时的最长公共子序列长度,max(dp[i-1][j], dp[i][j-1]) 表示当前字符不相等时的最长公共子序列长度。

下面是一个完整的最长公共子序列问题的实现示例:

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]

实际应用案例分析

最长公共子序列问题

最长公共子序列(LCS)问题是动态规划的经典问题之一,它描述了如何找到两个字符串的最长公共子序列。下面是一个最长公共子序列问题的示例:

  • 问题描述:给定两个字符串 text1text2,要求找到它们的最长公共子序列。
  • 解决方法:使用动态规划来解决最长公共子序列问题。定义一个二维数组 dp,其中 dp[i][j] 表示字符串 text1 的前 i 个字符和字符串 text2 的前 j 个字符的最长公共子序列长度。状态转移方程如下:

    dp[i][j] = dp[i-1][j-1] + 1 if text1[i-1] == text2[j-1] else max(dp[i-1][j], dp[i][j-1])

    其中,dp[i-1][j-1] + 1 表示当前字符相等时的最长公共子序列长度,max(dp[i-1][j], dp[i][j-1]) 表示当前字符不相等时的最长公共子序列长度。

下面是一个完整的最长公共子序列问题的实现示例:

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]
动态规划进阶技巧

优化空间复杂度

在某些情况下,动态规划的空间复杂度可以通过优化来降低。例如,对于一些线性递推关系的动态规划问题,可以通过滚动数组的方式将二维数组优化为一维数组,从而降低空间复杂度。

滚动数组是一种常见的优化方法,它利用了动态规划中状态转移方程的特性,将二维数组中的状态转移到一个一维数组中。滚动数组可以有效地减少空间复杂度,但需要确保状态转移方程和初始条件的正确性。

例如,在最长递增子序列问题中,可以使用滚动数组来优化空间复杂度:

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

在这个示例中,dp[i] 表示以第 i 个元素结尾的最长递增子序列的长度。通过循环遍历每个位置和每个前缀,计算状态转移方程,并返回 max(dp) 作为结果。这里已经使用了一维数组 dp 来优化了空间复杂度。

时间复杂度优化

在某些情况下,动态规划的时间复杂度可以通过优化来降低。例如,对于一些具有重复子问题的情况,可以通过缓存(或记忆化)的方式来减少重复计算。记忆化搜索是一种常见的优化方法,它将已经计算过的子问题结果存储起来,避免重复计算。

记忆化搜索的具体实现方式是,定义一个字典或哈希表来存储已经计算过的子问题结果。在递归调用中,首先检查是否已经计算过该子问题的结果,如果已经计算过,则直接返回存储的结果,否则继续递归计算并存储结果。

例如,在计算斐波那契数列时,可以通过记忆化搜索来优化时间复杂度:

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

在这个示例中,memo 字典存储已经计算过的斐波那契数列结果。在递归调用中,首先检查是否已经计算过该斐波那契数,如果已经计算过,则直接返回存储的结果,否则继续递归计算并存储结果。

点击查看更多内容
TA 点赞

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

评论

作者其他优质文章

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

100积分直接送

付费专栏免费学

大额优惠券免费领

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

举报

0/150
提交
取消