实验 3《动态规划算法实验》
一、实验目的
- 掌握动态规划方法贪心算法思想
- 掌握最优子结构原理
- 了解动态规划一般问题
二、实验内容
1. 编写一个简单的程序, 解决 0-1 背包问题。设 N=5, C=10, w={2, 2 , 6 , 5 ,4},
v={6, 3, 5, 4, 6}
2. 合唱队形安排问题
【问题描述】N 位同学站成一排,音乐老师要请其中的(N-K)位同学出列,使得剩下的 K位同学排成合唱队形。合唱队形是指这样的一种队形:设 K 位同学从左到右依次编号为 1, 2… , K , 他 们 的 身 高 分 别 为 T1 , T2 , … , TK ,则 他 们 的 身 高 满 足T1<...<Ti>Ti+1>…>TK(1<=i<=K)。已知所有 N 位同学的身高,计算最少需要几位同学出列,可以使得剩下的同学排成合唱队形。
三、算法思想分析:
动态规划:
对于每一步决策,列出各种可能的局部解,再依据某种判定条件,舍弃那些肯定不能得到最优解的局部解,在每一步都经过筛选,以每一步都是最优解来保证全局是最优解。
与分治的区别在于,子问题往往不是相互独立的;
与贪心的区别在于,贪心选择不可撤回,而在动态规划中,还要考察每个最优决策序列中是否包含一个最优子序列。
动态规划原理:
- 最优子结构性质
- 重叠子问题——保存子问题的解
- 无后效性——下一时刻的状态只与当前状态有关,而和当前状态之前的状态无关,当前的状态是对以往决策的总结。
动态规划关键:
记住已经求过的解,即存储子问题的每一个解,以备它重复出现
含重叠子问题的求解方式:
- 自顶向下的备忘录方法;
- 自底向上的DP:先解决小问题,再由小问题解决大问题(即由之前存在过的状态推导出后面的状态)
找出状态转移方程(一个递推表达式),动态规划中本阶段的状态往往是上一阶段状态和决策的结果;
降规模——找边界条件;
扩规模——找状态转移方程(由之前存在过的状态推导出后面的状态)
自底而上是从具体问题分析到抽象问题,也就是从小问题开始求解,一直推广到大问题的求解;自顶而下则相反,是从抽象的问题入手逐渐解决具体的问题,也就是把大问题化小,最后解决小问题
动态规划五部曲:(代码随想录B站视频)
dp数组及下标含义;递推公式;dp数组如何初始化;遍历顺序;打印dp数组(用于查错)
- 0-1背包问题:降规模,找边界条件——只有一个物品时直接判断能否放入包中;扩规模,找状态转移方程——如图
- LIS(最长递增子序列):降规模,找边界条件——只有一个数字时LIS就是它(故dp数组初始化为1);扩规模,找状态转移方程——每一个数字的LIS长度可以通过比较排在它前面的数字的LIS+1与当前它的LIS的值,遍历完前面所有数字后即可找出它的LIS长度。
dp[i] = max{dp[j] + 1, dp[i]},0 <=j < i && height[j] < height[i]
合唱队形安排其实就是找出一个数,它的左边的最长递增子序列与右边的最长递减子序列长度之和最大。
LDS:dp[i] = max{dp[j] + 1, dp[i]},i < j < n && height[j] < height[i]
四、实验过程分析
-
遇到的问题及解决
- 0-1背包中dp[i][j]的i表示第0-i个物品,j表示背包容量,数组值表示当容量为j,物品为0-i时最优解的价值总量;从递推公式中可以看出dp应该如何初始化——i最小值为0,而递推公式中有i-1,所以i只能从1开始,即要先将i为0时的dp数组初始化,j=0时值就为0;从递推公式中可以看出要想求出dp[i][j]则要先知道dp[i-1][j]和dp[i-1][j-wi]的值,即该元素上面的元素以及左上的元素,故遍历顺序随意,因为无论是先遍历背包容量还是先遍历物品,都可以在求某个元素的值前先得知其上面和左上的元素值。
- 刚开始初始化的时候没有将第一行完全初始化,导致结果有问题,后来就是通过设置断点,打印dp数组发现的问题所在。纠正后dp数组倒是没有问题了,但是打印的放入背包的物品不对,大概率问题出在traceBack函数中了,仔细检查并画出图后发现由于我的逻辑和老师PPT中代码逻辑不一样,我这应该从后面往前找,当该元素值与上面那个元素值相同时,表示这个物品没有被放入背包中。
- 合唱队形安排中犯了个小错误,最后寻找最优解时忘记更新max了,导致结果不对,但是断电调试发现dp数组没有问题,所以将问题聚焦于寻找最优解的代码块中,重读代码发现问题出在忘记更新max了。
-
实验体会
刚开始真的不知道该从何下手,在0-1背包问题中卡了很久,看了PPT,去网上搜了别人的博客,最后又看了B站上代码随想录的视频,终于搞懂了。动规五部曲——dp数组含义,递推公式,dp数组的初始化,求解时的遍历顺序以及用来debug的观察dp数组值是否符合预期。真的总结的很好,所以在做第二题合唱队形安排的时候就很顺,理清思路后几乎没犯什么错就写出来了。这种感觉就很爽。
总之,以后写算法题时还是要先理清思路,不要急着做题,量不是做题关键,写一道题就要彻底弄懂一道题,这样才是有效学习,而且之后还要时常温习,巩固知识,使暂时记忆转为长久记忆。
-
改进空间
还是那个问题,算法实验我主要将注意力集中在各种算法的思想及其实现上,对于程序的健壮性并没有特别考虑,0-1背包就不存在用户输入问题了,因为题目中指定了背包容量以及物品的重量和价值,但是合唱队形安排问题中我还是没有对用户输入进行合法性检查,只做了一些规范性要求,其他全靠用户自己输入是否正确。因为检查起来就让代码逻辑变散了,感觉也不是很有必要,但是在正式项目开发中,对程序的健壮性是有严格要求的,所以这里仍然是应该改进的地方。
五、算法源代码及用户屏幕
1、0-1背包问题:
源代码:
public class DP_01 {
private static int n = 5;//共有5个物品
private static int capacity = 10;//背包容量为10
private static int[]weight = {2, 2, 6, 5, 4};//物品重量
private static int[]value = {6, 3, 5, 4, 6};//物品价值
private static boolean[]isPut = new boolean[n];//存储物品是否放入
private static int[][] bestChoice = new int[n][capacity+1];//bestChoice[i]表示背包容量为capacity,可选物品为0到i时0-1背包问题的最优解——最大价值
public static void main(String[] args) {
System.out.println("背包容量为" + capacity);
System.out.println("共有" + n + "个物品。");
for(int i = 0; i < n; i++){
System.out.println("第" + (i+1) + "个物品的重量为" + weight[i] + ",价值为" + value[i]);
}
//dp数组的初始化,分析递推公式可知要初始化第一行第一列
for(int i = 0; i < n; i++){
bestChoice[i][0] = 0;//第一列由于背包容量为0,故值为0
}
for(int i = 1; i < capacity+1; i++){
if(weight[0] <= i){//第一行
bestChoice[0][i] = value[0];
}
}
dp_01();
System.out.println("该0-1背包问题的最优解的总价值为" + bestChoice[n-1][capacity]);
traceBack(capacity);
for(int i = 0; i < n; i++){
if(isPut[i]){//物品放入背包中
System.out.println("第" + (i + 1) + "个物品被放入包中,它的重量为" + weight[i] + ",价值为" + value[i] + "。");
}
}
}
private static void dp_01(){
//通过递推公式求dp数组各值
for(int i = 1; i < n; i++){//第一行第一列都初始化了,ij直接从1开始,不用从0开始
for(int j = 1; j < capacity + 1; j++){
if(j >= weight[i]){//这个物品可以放入背包
//判定放入包中是否是最优解
bestChoice[i][j] = Math.max(bestChoice[i - 1][j], bestChoice[i - 1][j - weight[i]] + value[i]);
}else {//背包容量不足以将这个物品放入
bestChoice[i][j] = bestChoice[i-1][j];
}
}
}
}
private static void traceBack(int capacity){
for(int i = n - 1; i > 0; i--){
if(bestChoice[i][capacity] == bestChoice[i-1][capacity]){//证明没有被放入包中
isPut[i] = false;
}else {
isPut[i] = true;
capacity -= weight[i];
}
}
if(capacity > weight[0]){//将其他的判断完后,如果容量还够放第一个,就将第一个放进去
isPut[0] = true;
}
}
}
用户屏幕:
正确性检验:
2、合唱队形问题:
源代码:
import java.util.Scanner;
public class ChoralFormation {
private static int n;//共有n位同学
private static int[]height;//每位同学的身高
private static int[]left;//左边的最长递增子序列长度
private static int[]right;//右边的最长递减子序列长度
public static void main(String[] args) {
Scanner input = new Scanner(System.in);
System.out.print("请输入同学人数:");
n = input.nextInt();
System.out.println("请输入这" + n + "位同学的身高(以cm位单位,要求为整数):");
height = new int[n];
left = new int[n];
right = new int[n];
for(int i = 0; i < n; i++){
height[i] = input.nextInt();//获取身高
//初始化dp数组
left[i] = 1;
right[i] = 1;
}
dp_LIS();
dp_LDS();
int max = left[0] + right[0] - 1;
int maxIndex = 0;
for(int i = 1; i < n; i++){//找出最优解
if(left[i] + right[i] - 1 > max){
maxIndex = i;
max = left[i] + right[i] - 1;
}
}
System.out.println("当选择第" + (maxIndex+1) + "位同学(身高" + height[maxIndex] + ")作为队伍里的最高者时为最优解,此时需要" + (n - max) + "位同学出列。");
}
private static void dp_LIS(){
for(int i = 1; i < n; i++){//第一个人左边的最长子序列就是1,不用再求了
for(int j = 0; j < i; j++){
if(height[j] < height[i]){
left[i] = Math.max(left[j] + 1, left[i]);
}
}
}
}
private static void dp_LDS(){
for(int i = n-2; i >= 0; i--){//最后一个人右边的最长递减子序列就是1,也不用再求了
for(int j = i + 1; j < n; j++){
if(height[j] < height[i]){
right[i] = Math.max(right[j] + 1, right[i]);
}
}
}
}
}
用户屏幕: