动态规划笔记(一)

news2024/9/25 13:17:29

动态规划(Dynamic Programming,DP)是求解决策过程最优化的过程,通过把原问题分解为相对简单的子问题的方式求解复杂问题,在数学、管理科学、计算机科学、经济学和生物信息学等领域被广泛使用。

它的基本思想非常简单,若要求解一个给定问题,我们需要求解其不同部分(即子问题),再根据子问题的解得出原问题的解。

通常许多子问题非常相似,为了减少计算量,动态规划法试图每个子问题仅解决一次,一旦算出某个给定子问题的解,则将其记忆化存储,以便下次求解同一个子问题解时可以直接查表。因此具有天然剪枝的功能。

DP 题目的特点

首先我们一起来看一下,什么样的题目可能需要使用动态规划。一般而言(并不绝对),如果题目如出现以下特点,你就可以考虑(有一定概率)使用动态规划。

特点一:计数

题目问:有多少种方法?有多少种走法?

关键字:多少!

特点二:最大值/最小值

题目问:某种选择的最大值是什么?完成任务的最小时间是什么?数组的最长子序列是什么?达到目标最少操作多少次等。

关键字:最!

特点三:可能性

题目问:是否有可能出现某种情况?是否有可能在游戏中胜出?是否可以取出 k 个数满足条件?

关键字:是否!

通常而言,看到这三类题目,就可以尝试往 DP 解法上靠。

DP 的 6 步破题法

找到题目的特点,确定可以使用 DP 之后,接下来就可以准备逐步破题了。

下面我们以一道题目为例,详细介绍破解 DP 问题的思考过程与解题步骤。其实这道题不难,我相信你们都见过,不过我还是希望你能跟着我的思维重新再思考一遍。

【题目】给定不同面额的硬币 coins 和一个总金额 amount,需要你编写一个函数计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,则返回 -1。你可以认为每种硬币的数量是无限的。

输入:coins = [1, 2, 5],amount = 11

输出:3

解释:11 元可以拆分为 5 + 5 + 1,这是最少的硬币数目。

【分析】首先我们看到关键字“最少”,因此可以尝试往 DP 上面想。在 DP 问题上,很多人都存在一个思维误区,这里我们称为误区 1:

利用 DP 求解问题时,一开始就去想第一步具体做什么!

你之所以没有思路,往往是因为采用了一种顺应题意的方法去求解问题。比如题目问:

如何求“最少步数”,你就去想“从头开始怎么走”;
如何选择可以“达到最大收益”,你就真的开始去想“怎么选择”。

这恰恰是 DP 题目给你下的一个“套”,这样思考很容易带你陷入暴力求解的方法,找不到优化的思路。因此,千万不要从第一步开始思考。就这道题而言,就是不要去想,我的第一个硬币怎么选!

那么我们应该从哪里着手呢?答案是:最后一步!

1. 最后一步

以这道题为例,最后一步指的是:兑换硬币的时候,假设每一步操作总是选择一个硬币,那么我们看一下最后一步如何达到 amount?

以给定的输入为例:

coins = [1, 2, 5], amount = 11

最后一步可以通过以下 3 个选项得到:

  • 已经用硬币兑换好了 10 元,再添加 1 个 1 元的硬币,凑成 11 元;

  • 已经用硬币兑换好了 9 元,再添加 1 个 2 元的硬币,凑成 11 元;

  • 已经用硬币兑换好了 6 元,再添加 1 个 5 元的硬币,凑成 11 元。

接下来,应该立即将以上 3 个选项中的未知项展开成子问题

注意:如果你找的最后一步,待处理的问题规模仍然没有减小,那么说明你只找到了原始问题的等价问题,并没有找到真正的最后一步。

2. 子问题

拿到 3 个选项之后,你可能会想:[10元,9元,6元] 是如何得到?到此时,一定不要尝试递归地去求解 10 元、9 元、6 元,正确的做法是将它们表达为 3 个子问题:

  • 如何利用最少的硬币组成 10 元?
  • 如何利用最少的硬币组成 9 元?
  • 如何利用最少的硬币组成 6 元?

我们原来的问题是,如何用最少的硬币组成 11 元。

不难发现,如果用 f(x) 表示如何利用最少的硬币组成 x 元,就可以用 f(x) 将原问题与 3 个子问题统一起来,得到如下内容:

原问题表达为 f(11);

3 个子问题分别表达为 f(10)、f(9)、f(6)。

接下来我们再利用 f(x) 表示最后一步的 3 个选项:

  • f(10) + 1 个 1 元得到 f(11);

  • f(9) + 1 个 2 元得到 f(11);

  • f(6) + 1 个 5 元得到 f(11)。

3. 递推关系

递推关系,一般需要通过两次替换得到。

最后一步,可以通过 3 个选项得到。哪一个选项才是最少的步骤呢?这个时候,我们可以采用一个 min 函数来从这 3 个选项中得到最小值。

f(11) = min(f(11-1), f(11-2), f(11-5)) + 1

接下来,第一次替换:只需要将 11 换成一个更普通的值,就可以得到更加通用的递推关系:

f(x) = min(f(x-1), f(x-2), f(x-5)) + 1

当然,这里 [1, 2, 5] 我们依然使用的是输入示例,进行第二次替换

f(x) = min(f(x-y), y in coins) + 1

写成伪代码就是:

f(x) = inf
for y in coins:
    f(x) = min(f(x), f(x-y) + 1)

4. f(x) 的表达

接下来我们要做的就是在写代码的时候,如何表达 f(x)?

这里有一个小窍门。

直接把 f(x) 当成一个哈希函数。那么 f 就是一个 HashMap。

对于大部分 DP 题目而言,如果用 HashMap 替换 f 函数都是可以工作的。如果遇到 f(x, y) 类似的函数,就需要用 Map<Integer/x/, Map<Integer/y/, Integer>> 这种嵌套的方式来表达 f(x, y)。

当然,有时候,用数组作为哈希函数是一种更加简单高效的做法。具体来说:

  • 如果要表达的是一维的信息,就用一维数组 dp[] 表示 f(x);

  • 如果要表达的是二维的信息,就用二维数组 dp[][] 表示 f(x, y)

这就是为什么很多 DP 代码里面可以看到很多dp数组的原因。但是,现在你要知道

用 dp[] 数组并不是求解 DP 问题的核心。

因为,数组只是信息表达的一种方式。而题目总是千万变化的,有时候可能还需要使用其他数据结构来表达 f(x)、f(x, y) 这些信息。比如:

f(x)、f(x, y) 里面的 x, y 都不是整数怎么办?是字符串怎么办?是结构体怎么办?

当然,就这个题而言,可以发现有两个特点:

  • 1)f(x) 中的 x 是一个整数;

  • 2)f(x) 要表达的信息是一维信息。

那么,针对这道题而言言,我们可以使用一维数组,如下所示:

int[] dp = new int[amount + 1];

数组下标 i 表示 x,而数组元素的值 dp[i] 就表示 f(x)。

那么递推关系可以表示如下:

dp[x] = inf;
for y in coins:
  dp[x] = min(dp[x], dp[x-y] + 1);

5. 初始条件与边界

那么,如何得到初始条件与边界呢?这里我分享一个小技巧: 你从问题的起始输入开始调用这个递归函数,如果递归函数出现“不正确/无法计算/越界”的情况,那么这就是你需要处理的初始条件和边界。

比如,如果我们去调用以下两个递归函数。

  • coinChange(0):可以发现给定 0 元的时候,dp[amount-x] 会导致数组越界,因此需要特别处理dp[0]。

  • coinChange(-1) 或者 coinChange(-2) 的调用也是会遇到数组越界,说明这些情况都需要做特别处理。

那么什么情况作为初始条件?什么情况作为边界?答案就是:

  • 如果结果本身的存放不越界,只是计算过程中出现越界,那么应该作为初始条件。比如 dp[0]、dp[1];

  • 如果结果本身的存放是越界的,那么需要作为边界来处理,比如 dp[-1]。

当然,就这道题而言,初始条件是 dp[0] = 0,因为当只有 0 元钱需要兑换的时候,应该是只需 0 个硬币。

6. 计算顺序

说来有趣,计算顺序最简单,我们只需要在初始条件的基础上使用正向推导多走两步可以了。比如:

初始条件:dp[0] = 0

那么接下来的示例中的输入:coins[] = [1, 2, 5]。我们已经知道 dp[0] = 0,再加上可以做的 3 个选项,那么可以得到:

  • dp[1] = dp[0] + 1 元硬币 = 1

  • dp[2] = dp[0] + 2 元硬币 = 1

  • dp[5] = dp[0] + 5 元硬币 = 1

到这里,递推关系好像还没有用到。那什么时候用呢?我们来看下面两种情况:

  • 如下图所示,第一种情况,dp[5] 可以直接通过 dp[0] 得到,值为 1。
    在这里插入图片描述
  • 如下图所示,第二种,dp[5] 可以通过 dp[3] 得到,值为 3。
    在这里插入图片描述

此时时可以发现,判断具体取哪个值时,就需要用到前面的递推关系了。
f(x) = min(f(x-1), f(x-2), f(x-5)) + 1

我们只需要取较小的值就可以了。
【代码】到这里,你应该可以写出 DP 的代码了:

class Solution{

  public int coinChange(int[] coins, int amount) {
    // 没有解的时候,设置一个较大的值
    final int INF = Integer.MAX_VALUE / 4;
    int[] dp = new int[amount + 1];
    // 一开始给所有的数设置为不可解。
    for (int i = 1; i <= amount; i++) {
      dp[i] = INF;
    }
    // DP的初始条件
    dp[0] = 0;
    for (int i = 0; i < amount; i++) {
      for (int y : coins) {
        // 注意边界的处理,不要越界
        if (y <= amount && i + y < amount + 1 && i + y >= 0) {
          // 正向推导时的递推公式!
          dp[i + y] = Math.min(dp[i + y], dp[i] + 1);
        }
      }
    }
    return dp[amount] >= INF ? -1 : dp[amount];
  }

}

复杂度分析:

一共两层循环,外层需要循环 O(Amount) 次,内层需要循环 O(N) 次(如果有 N 种硬币)。那么时间复杂度为 O(Amount * N)。由于申请了数组,那么空间复杂度为 O(Amount)。

这里我利用一个例题,深入地讲解了 DP 的破题法的几个步骤。后面我将利用这个方法带你依次切开每一道难啃的 DP 题。

这里,我再分享一个小技巧,需要注意:

当求最小值的时候,我们往往将不可能的情况设置为 Integer.MAX_VALUE / 4。

因为如果设置为 Integer.MAX_VALUE,那么一旦涉及加法,立马就溢出了,导致程序出错。所以我们尽量设置一个足够大的数,避免进行加法的时候溢出。

这里我已经将 DP 的思路整理成如下图中展示的 6 步。尽管我现在处理 DP 问题已经很熟练了,但有时候,碰到一些特别难处理的 DP 题目,依然会回到这 6 步分析法,一步一步踏踏实实地分析。在这里插入图片描述

DP 的分类

经过前面的讨论,我们学会了 DP 的通用解法,不过 DP 实际上还可以分成很多种类别。比如:

  • 线性 DP
  • 区间 DP
  • 背包 DP
  • 树形 DP
  • 状态压缩 DP

在练习和准备面试的时候,多看看这些题型,对面试会很有帮助。下面我们一个一个介绍。

线性 DP

我们在读书的时候,遇到的很多 DP 题目,比如最长公共子序列、最长递增子序列等,这类题目实际上都是线性 DP。不过今天我们不再介绍这类经典的 DP 题目,而是介绍一些在面试中经常出现的线性 DP 题目。

例 1:打劫

【题目】你是一个专业的小偷,计划去沿街的住户家里偷盗。每间房内都藏有一定的现金,影响你偷盗的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,要求你计算不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

输入:nums = [1,2,3,1]

输出:4

解释:偷窃 nums[0] 号房屋 (金额 = 1),然后偷窃 nums[2]号房屋(金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。

【分析】接下来,我们就照着 DP 的 6 步分析法(千万别顺着题意去想要偷那些房间!!)。我们把思维放慢,一步一步分析。

1. 最后一步

就这道题而言,最后一步就是处理第 N-1 个房间(我们假设一共有 N 个房间,并且从 0 开始)。

那么第 N-1 个房间,有两个选项。

偷:如果要偷第 N-1 个房间,那么收益就是处理前 N-3 个房间之后,再偷第 N-1 房间。

不偷:那么只需要处理到第 N-2 个房间,那么收益就是处理前 N-2 个房间之后的收益。

2. 子问题

最后一步的 2 个选项中都有未知项,我们可以将它们展开为子问题:

处理完 [0, …, N-3] 之后,最大收益是多少?

处理完 [0, …, N-2] 之后,最大收益是多少?

下面我们可以统一问题的表示:

f(x) 表示处理完 [0, …, x] 这些房间之后的最高收益。

3. 递推关系

统一问题的表示之后,首先来表示一下最后一步:

f(N-1) = max(f(N-2), f(N-3) + nums[N-1])

这里需要采用替换法,将 N-1 换为 x。可以得到:

f(x) = max(f(x-1), f(x-2) + nums[x])

4. f(x) 的表达

这里 x 表示的是原数组 [0, …, x] 这个区间范围。由于所有的 x 表示的区间都是从 0 开始的,所以这个区间的起始点信息没有必要保留,因此只需要保存区间端点 x。我们发现:

x 是个整数;

x 的范围刚好是 nums 数组的长度。

尽管 f(x) 可以用哈希来表示,但如果用数组来表达这个函数映射关系,更加直接和高效。因此,我们也用 dp[] 数组来表达 f(x)。并且利用元素 i 表示 x,可以让 i 与 nums 数组的下标对应起来。

5. 初始条件与边界

初始条件:首先我们看“无法计算/越界”的情况:

dp[0] = max(dp[0-1], dp[0-2] + nums[0]); // <-- 越界!
dp[1] = max(dp[1-1], dp[1-2] + nums[1]); // <-- 越界 
dp[2] = max(dp[2-1], dp[2-2] + nums[2]);

我们发现 dp[0], dp[1] 会在计算过程中出现越界,所以需要优先处理这两项。

  • dp[0]:当只有 nums[0] 可以偷的时候,其值肯定为 max(0, nums[0])。

注意陷阱,有的题可能会给你的带负数值的情况,不要直接写成 nums[0]。

  • dp[1]:当有 0 号,1 号房间可以偷的时候,由于不能连续偷盗,那么只需要在 0、nums[0]、nums[1] 里面选最大值就可以了。所以 dp[1] = max(0, nums[0], nums[1])。

边界:要保证不能越过数组的边界!

6. 计算顺序

拿到初始条件与边界之后,只需要再多走两步,就知道代码怎么写了。接下来我们开始求解 dp[2], dp[3]。

dp[2] = max(dp[2-1], dp[2-2] + nums[2]);
dp[3] = max(dp[3-1], dp[3-2] + nums[3]);

【代码】利用前面分析过的初始条件和递推关系,可以写出如下代码:

class Solution
{
  public int rob(int[] nums)
  {
    final int N = nums == null ? 0 : nums.length;
    if (N <= 0) {
      return 0;
    }
    int[] dp = new int[N];
    dp[0] = Math.max(0, nums[0]);
    if (N == 1) {
      return dp[0];
    }
    dp[1] = Math.max(0, Math.max(nums[0], nums[1]));
    for (int i = 2; i < N; i++) {
      dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
    }
    return dp[N - 1];
  }
}

**复杂度分析:**时间复杂度 O(N),空间复杂度 O(N)。

**【小结】**通过 6 步分析法,我们很快就搞定来这道经典的 DP 题目。

这道题还有一个小变形,我想你可以尝试求解下面的练习题 1。

练习题 1:你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方的所有房屋都围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下 ,能够偷窃到的最高金额。

输入:nums = [2,3,2]

输出:3

解释:你不能先偷窃 nums[0] 号房屋(金额 = 2),然后偷窃 nums[2] 号房屋(金额 = 2), 因为他们是相邻的。最大收益是偷取nums[1]=3。

区间 DP

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/833087.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

SpringBoot整合Caffeine

一、Caffeine介绍 1、缓存介绍 缓存(Cache)在代码世界中无处不在。从底层的CPU多级缓存&#xff0c;到客户端的页面缓存&#xff0c;处处都存在着缓存的身影。缓存从本质上来说&#xff0c;是一种空间换时间的手段&#xff0c;通过对数据进行一定的空间安排&#xff0c;使得下…

java:解决报错非法字符: ‘\ufeff‘以及什么是BOM

背景 运行 JAVA 项目后&#xff0c;报错提示&#xff1a;非法字符: \ufeff&#xff0c;如图&#xff1a; 但是我在这个报错的文件中并没有搜到这个字符&#xff0c;那到底是什么原因 什么是BOM BOM&#xff08;Byte Order Mark&#xff09;&#xff0c;隐藏字符&#xff0c…

5、二叉树

二叉树遍历 递归序 public static void f(Node head) {if (head == null) {return;}f(head.left);f(head.right); }前中后遍历_递归 public static void preOrderRecur(Node head) {if (head == null) {return;}System.out.print(head.value + " ");preOrderRecur…

智慧防汛,数字科技的力量

随着夏日的脚步临近&#xff0c;台风季节即将降临。对于那些居住在沿海地区的人们来说&#xff0c;台风是一种常见的自然灾害&#xff0c;其带来的风雨可能对生命和财产造成严重威胁。然而&#xff0c;随着数字科技的飞速发展&#xff0c;可视化技术为防汛抗台工作带来了全新的…

6个非常实用的漫画素材网站,设计师必看

对于漫画设计师来说&#xff0c;漫画素材网站是必不可少的&#xff0c;今天本文将与大家分享6个好用的漫画素材网站&#xff0c;帮助设计师实现优质创作&#xff0c;一起来看看吧&#xff01; 1、即时设计 即时设计是更适合国内设计师使用的图像设计工具&#xff0c;在它的【…

if语句实现成绩等级判断

if语句实现成绩等级判断 案例分析代码实现小结Time 案例分析 使用键盘输入一个成绩&#xff0c;然后通过if判断语句实现成绩等级的判断 代码实现 import java.util.Scanner;public class DetermineDemo {public static void main(String[] args) {Scanner scanner new Scanne…

在线五子棋对战

目录 数据管理模块&#xff08;数据库设计&#xff09; 前端界面模块 业务处理模块 会话管理模块网络通信模块(session,cookie) 在线管理模块 房间管理模块 用户匹配模块 项目扩展 数据管理模块&#xff08;数据库设计&#xff09; 数据库中有可能存在很多张表&#xf…

SystemC的调度器

文章目录 前言调度器初始化evaluatewait updatenotify delta notificationtime notification仿真结束 前言 SystemC是基于C的库&#xff0c;主要用来对 IC 进行功能建模和性能建模。有时也被用来当做 RTL (register transfer level) 级的升级版 HLS(High Level synthesis) 直接…

Hadoop-HDFS的Namenode及Datanode(参考Hadoop官网)

HDFS有什么特点&#xff0c;被设计做什么 Hadoop分布式文件系统(HDFS)被设计成适合运行在通用硬件(commodity hardware)上的分布式文件系统。有一下几个特点&#xff1a; HDFS是一个高度容错性的系统&#xff0c;具有高容错、高可靠性、高扩展性的特点&#xff0c;适合部…

【卡尔曼滤波】用Python实现卡尔曼滤波效果,Python

文章目录 纯python基于pykalman 库 纯python 以下是一个简单的Python示例&#xff0c;演示了如何使用NumPy库实现一个一维卡尔曼滤波器。 import numpy as np import matplotlib.pyplot as pltdef kalman_filter(initial_state, initial_estimate_error, process_variance, me…

机器学习笔记 - 使用 YOLOv5、O​​penCV、Python 和 C++ 检测物体

一、YOLO v5简述 YOLO v5虽然已经不是最先进的对象检测器,但是YOLOv5 使用了一个简单的卷积神经网络 CNN架构(相对YOLO v8来讲,不过v8精度是更高了一些),更易理解。这里主要介绍如何轻松使用 YOLO v5来识别图像中的对象。将使用 OpenCV、Python 和 C++ 来加载和调用我们的…

Cadence 小技巧系列(持续更新)

■ ADE setup simulator/directory/host 更改仿真路径&#xff0c;默认home路径空间太小了&#xff0c;改成当前路径就行。 瞬态tran仿真要用APS跑&#xff08;setup--high...&#xff09; 瞬态tran仿真精度设置&#xff0c;conservation&#xff0c;option--maxstep设为0.1n…

Android 13(T) - Media框架(2)- libmedia

这一节学习有两个目标&#xff1a; 1 熟悉Android Media API的源码路径与调用层次 2 从MediaPlayer的创建与销毁了解与native的串接 1、源码路径 Media相关的API位于&#xff1a;frameworks/base/media/java/android/media&#xff0c;里面提供有MediaPlayer MediaCodecList M…

Java判断Object类型的方法

Java判断Object类型的方法 private boolean check(Object obj) {if(obj.getClass()java.util.Date.class){return true;}else {return false;}}

GPU版PyTorch对应安装教程

一、正确安装符合自己电脑的对应GPU版本的PyTorch之前需要了解三个基本概念 算力、CUDA driver version、CUDA runtime version ①算力&#xff1a;需要先知道你的显卡&#xff0c;之后根据官网表格进行对应&#xff0c;得到算力 ②CUDA driver version&#xff1a;电脑上显卡…

RISC-V基础之函数调用(二)栈与寄存器(包含实例)

堆栈是一种后进先出&#xff08;LIFO&#xff09;的队列&#xff0c;用于存储函数调用时的临时数据和现场数据。堆栈指针sp&#xff08;寄存器2&#xff09;是一个普通的RISC-V寄存器&#xff0c;按照惯例&#xff0c;指向堆栈的顶部。堆栈从高地址向低地址增长&#xff0c;即当…

设置系统编码 Beta

在yolov5环境搭建过程中会遇到如下的编码错误警告&#xff1a; 这时&#xff0c;按住“ctrlc”中止进程&#xff0c;然后设置系统编码&#xff1a; 电脑右键属性打开&#xff1a; 重启之后等安装好了&#xff0c;记得回去把bae键取消。

人工智能发展的五个主要技术方向是什么?

人工智能主要分支介绍 通讯、感知与行动是现代人工智能的三个关键能力&#xff0c;在这里我们将根据这些能力/应用对这三个技术领域进行介绍&#xff1a; 计算机视觉(CV) 自然语言处理(NLP) 在 NLP 领域中&#xff0c;将覆盖文本挖掘/分类、机器翻译和语音识别。 机器人 1、…

linux鲁班猫屏幕和触摸[初用鲁班猫切换屏幕为MIPI-1080P][旋转屏幕为横屏显示][屏幕和触摸方向永久修改]

初用鲁班猫切换屏幕为MIPI-1080P 鲁班猫信息: 板卡从如下地址采购:https://detail.tmall.com/item.htm?_u110jcean66aa&id694560455663&spma1z09.2.0.0.56f52e8dj4eUdI&skuId5156903694777 鲁班猫官方文档和教程:https://doc.embedfire.com/linux/rk356x/quick_s…

[Docker实现测试部署CI/CD----自由风格的CI操作[中间架构](4)]

10、自由风格的CI操作&#xff08;中间架构&#xff09; 中间架构图 创建web项目 创建一个 web 项目&#xff0c;就使用简单的 spring boot 工程&#xff0c;例如工程名为 hellojks。仅需导 入 spring web 依赖即可。 import org.springframework.web.bind.annotation.GetMapp…