题目一、 0-1背包问题
题目:
给定两个长度都为N的数组weights和values, weights[i]和values[i]分别代表 i号物品的重量和价值。 给定一个正数bag,表示一个载重bag的袋子, 你装的物品不能超过这个重量。 返回你能装下最多的价值是多少?
为了大家更好理解,该题的数值都是大于等于0的,当然不是大于等于0的也可以解。
这道题就牵扯出上面文字一个很重要的内容,就是尝试,从尝试入手,可以改出一个动态规划版本,只要搞定尝试,我们的动态规划就是从尝试来的,那么这个尝试,有一个很常见的尝试模型,叫做从左往右依次尝试。
这是一个从左往右的模型,这种模型可以解决很多的题目,背包题目只是其中之一。
那什么是尝试呢?,举个例子 有三个货 0 1 2 号货,有分别的重量和价值,从左往右开始,我要货是一种状态,我不要又是另一种状态,每一个分支都走,毫无疑问,最大价值就在其中,因为我们暴力枚举了,所以我们要做的,就是把自己的暴力尝试想法写出一个递归版本。
分析: 从左往右尝试,我们需要一个index去标识我们考虑到index号货物,在index之前,是我已经做过决定了,index之后我可以自由选择的。我们先想一下baseCase,当index走到数组最后,说明没货了,返回0,当我们选择的重量超过背包容量之后,肯定也要结束,返回0。如果没中这两个baseCase,那么我们就还能继续考虑做选择。最后选择要或者不要该货物中最大的。
//所有的货,重量和价值都在w 和 v 数组中,
//为了方便,其中没有负数
//bag背包容量, 不能超过这个载重
//返回:不超重的情况下,能够得到的最大价值
public static int maxValue(int[] w,int[] v,int bag){
if (w==null || v==null || w.length != v.length || w.length == 0){
return 0;
}
return process(w,v,0,bag);
}
//当前考虑到了index号货物,index ... 所有的货物你可以自由选择
//做的选择不能超过背包容量
//返回最大价值
private static int process(int[] w, int[] v,int index, int bag) {
// if (bag < 0){
// return 0;
// }
if (bag < 0){
return -1;
}
if (index == w.length){
return 0;
}
//有货 index位置的货
//bag有空间, 0
//不要当前的货
int p1 = process(w,v,index+1,bag);
// p2直接这么写有问题
//例如 bag = 6, w[7] v[15]
//在往下走的时候,发现bag被减为-1 了但是返回的是0 就会影响上游做决策
//怎么解决?
//我们可以把bag < 0 时返回 -1
int p2 = 0;
int next = process(w,v,index+1,bag - w[index]);
if (next != -1){
p2 = v[index] + next;
}
return Math.max(p1,p2);
}
根据起初分析,写出的代码是有一些问题的,例如注释中写的例子,如果按之前bag小于0返回0的条件,那么在做完选择后bag变为-1了,但是我返回0,一点也没耽误之前当前价值加上之前价值的决策,对当前决策没有任何影响,其实该决策已经是无效的,那么怎么解决呢,如上述代码,我们把bag小于0改为返回-1,然后我们提前执行一下,如果该返回值为有效的值,才把该价值加上,如果是无效的,就不会把该值给加上。
那么如何改成动态规划版本呢?
我们注意到上述的递归,只有两个可变参数,所以这两个参数代表递归的状态,然后我们再看有没有重复调用,如下图,要了0要了1,没要2和没要0没要1要了2,在下个位置都是3位置剩10重复值。可以改为动态规划。
接下来我们看index取值范围为0 - N,bag取值返回为 负 - bag ,所以我们在设置动态规划表的时候,准备 N + 1 * bag + 1 的表。
这是这张表最初始的形态,行是index,列时bag,怎么看出来的,从暴力递归中看出来的,如果bag < 0 return -1,我们可以把0列之前的位置全部当做-1的海洋,如果index == w.length,return 0,该例子的的length 为 4 ,如果等于4,return 0,所以最后一行全是 0,但是每一行怎么依赖呢,我们看递归函数,他们都依赖于index + 1 位置,我们可以看出他们都是依赖于下一行的,我们就可以根据第四行填出第三行,根据第三行填出第二行,最后把这张表填满,找出我们需要的位置(0,bag)就是答案,代码如下:
public static int dp(int[] w,int[] v,int bag){
if (w==null || v==null || w.length != v.length || w.length == 0){
return 0;
}
int N = w.length;
//index 表示行
int[][] dp = new int[N + 1][bag + 1];
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= bag; rest++) {
int p1 = dp[index + 1][rest];
int p2 = 0;
int next = rest - w[index] < 0 ? -1:dp[index + 1][rest - w[index]];
if (next != -1){
p2 = v[index] +next;
}
dp[index][rest] = Math.max(p1,p2);
}
}
return dp[0][bag];
}
题目二
规定1和A对应、2和B对应、3和C对应...26和Z对应 那么一个数字字符串比如"111”就可以转化为: "AAA"、"KA"和"AK" 给定一个只有数字字符组成的字符串str,返回有多少种转化结果。
我们同样需要有一个index 表示从0到index位置你无需过问,从index 到str.length 你有多少种转化方法。在上一题中可能性的枚举策略是要和不要,而这一题可能性的枚举策略就不是要和不要了。那这一题采用什么策略呢?我们先来尝试,如果index 来到 str.length()返回什么?是返回0吗?0中方法 不对,当我字符串结束的时候,我能不能转化,能 ,转化成什么 空字符串, 返回 1,还有一种说法,0 到 index - 1 位置已经转化完了,无需过问,当index到达终止位置的时候,找到了一种方法,这种方法叫做之前做的决定,返回 1,比如说 1 1 1,0位置我假设让1变成A,1位置假设让1变成A,2位置假设让1变成A,到了3位置,我返回1为什么,到了三位置,我做了一个决定,把111变成AAA,终止位置我只收集一个点数,之前做的所有决定,共同构成这一种转化方法。
分析:如果index到最后了,返回1,说明做了一个决定,如果index没有到最后,说明有字符,如果当前字符是0字符,return 0 。为啥,因为26个字母中没有和0对应的,如果你让index位置单枪匹马的面对0字符,说明你做的决定错了,返回0。如果可以继续进行,说明index位置不是0字符,我是不是用永远可以做一个决定,就是我让index位置一个字符单转。还有一种可能是第index位置和第index+1位置共同构成一个字母,直接去index+2位置,第二种决定不一定都有,最后返回ways。
public static int number(String str){
if (str == null || str.length() == 0){
return 0;
}
return process(str.toCharArray(),0);
}
// str[0...index] 无需过问
// str[i...]去转化,返回有多少种转化方法
private static int process(char[] strs, int index) {
if (index == strs.length){
return 1;
}
// i 没到最后,说明有字符
if (strs[index] == '0'){
return 0;
}
//str[i] != '0'
//可能性一 单转
int ways = process(strs,index + 1);
if (index + 1 < strs.length && (strs[index] - '0') * 10 + strs[index + 1] - '0' < 27){
ways += process(strs,index + 2);
}
return ways;
}
接下来开始改动态规划,我们发现调用的方法除了固定参数strs时,竟然只有一个参数index,这说明什么,说明改动态规划是一个一维数组,非常的好改,
public static int dp(String str){
if (str == null || str.length() == 0){
return 0;
}
char[] chars = str.toCharArray();
int N = chars.length;
int[] dp = new int[N + 1];
dp[N] = 1;
for (int i = N - 1; i >= 0; i--) {
if (chars[i] != '0'){
dp[i] = dp[i+1];
if (i + 1 < str.length() && (chars[i] - '0') * 10 + chars[i + 1] - '0' < 27){
dp[i] += dp[i+2];
}
}
}
return dp[0];
}
题目三
给定一个字符串str,给定一个字符串类型的数组arr,出现的字符都是小写英文 arr每一个字符串,代表一张贴纸,你可以把单个字符剪开使用,目的是拼出str来 返回需要至少多少张贴纸可以完成这个任务。 例子:str= "babac",arr = {"ba","c","abcd"}至少需要两种贴纸“ba”和“abcd”,因为使用这两张贴纸,把每一个字符单独剪开,含有两个a,两个b,一个c,是可以拼出str的。所以返回2分析:
首先我们注意到贴纸这件事跟顺序没有一毛钱关系,我都给你剪开,你想要拼出的字符顺序重要吗 不重要,他问的就是至少几张贴纸,能把需要的字符包含全。既然跟顺序无关,举个栗子,有三张贴纸,“abc”,“bba”,“cck”,假设想要拼出的东西是“bbbbaca”,我们可以先把要拼的东西排个序,当然不排也可以,拍完序之后我们怎么试,我就第一张用abc看后续最少能有几张,我就第一张用bba我看后续能用几张,我就第一张用cck我看后续能用几张,答案必定在其中。
public static int minSticks(String[] sticks,String target){
int ans = process1(sticks,target);
// -1 是什么,怎么都搞不定
return ans == Integer.MAX_VALUE ? -1:ans;
}
//所有贴纸sticks
//要组成目标target
//返回最小张数
private static int process1(String[] sticks, String target) {
if (target.length() == 0){
return 0;
}
int min = Integer.MAX_VALUE;
for (String stick : sticks) {
String rest = minus(target,stick);
if (rest.length() != target.length()){
min = Math.min(min,process1(sticks,rest));
}
}
return min + (min == Integer.MAX_VALUE ? 0 : 1);
}
private static String minus(String target, String stick) {
char[] str1 = target.toCharArray();
char[] str2 = stick.toCharArray();
int[] count = new int[26];
for (char cha : str1) {
count[cha - 'a']++;
}
for (char cha : str2) {
count[cha - 'a']--;
}
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < 26; i++) {
if (count[i] > 0){
for (int j = 0; j < count[i]; j++) {
stringBuilder.append((char)(i+'a'));
}
}
}
return stringBuilder.toString();
}
但是该版本不够高效,下面看一个比较高效的方法:我们可以把贴纸用一个二维数组保存如下图:
例如 “acc”,"bbc","aaa", 在二维数组中,0位置表示 字符串 “acc”的词频, a在0位置出现1次,b在1位置出现0次,c在2位置出现两次,其余都是0次,而 1位置表示 “bbc”,2位置表示“aaa”同理。所以二维数组就可以表示所有贴纸,因为我们只要每种字符出现的次数,帮着减就完了,二维数组代替贴纸数组,词频都给做好,减起来快。同时还需要剪枝,我就在所有贴纸中选必须含有我第一个字符的贴纸去跑分支,剩下分支没试,不管当初消的是什么,但肯定会有消除第一个字符的时刻,那么我把该时刻提前并不会影响最少张数。
public static int minSticks2(String[] sticks,String target){
int N = sticks.length;
//关键优化 用词频表代替贴纸数组
int[][] count = new int[N][26];
for (int i = 0; i < N; i++) {
char[] chars = sticks[i].toCharArray();
for (char cha : chars) {
count[i][cha - 'a'] ++;
}
}
int ans = process2(count,target);
return ans == Integer.MAX_VALUE ? -1:ans;
}
//所有贴纸sticks
//要组成目标target
//返回最小张数
//sticks[i]数组,当初i号贴纸的字符统计 sticks -- >所有贴纸
private static int process2(int[][] sticks, String target) {
if (target.length() == 0){
return 0;
}
//target做词频统计,
//target aabbcc
// 0位置代表a 1位置代表 b 2位置代表c 他们的值代表出现的次数
char[] chars = target.toCharArray();
int[] charsCount = new int[26];
for (char cha : chars) {
charsCount[cha - 'a'] ++;
}
int N = sticks.length;
int min = Integer.MAX_VALUE;
for (int i = 0; i < N; i++) {
//尝试第一张贴纸是谁
int[] stick = sticks[i];
//最关键的优化,(重要的剪枝,这一步也是贪心)
if (stick[chars[0]-'a'] > 0){
StringBuilder builder = new StringBuilder();
for (int j = 0; j < 26; j++) {
if (charsCount[j]>0){
int num = charsCount[j] - stick[j];
for (int k = 0; k < num; k++) {
builder.append((char) (j + 'a') );
}
}
}
String rest = builder.toString();
min = Math.min(min,process2(sticks,rest));
}
}
return min + (min == Integer.MAX_VALUE ? 0:1);
}
该方法最大的优化是增加了剪枝,并且词频表直接相减,会快很多。
接下来我们需要改成动态规划的形式,但是,我们惊奇的发现,该递归形式的可变参数竟然是一个字符串,只通过字符串,我们没办法改成严格表结构的方式,我们先看一下有没有必要改。
通过下图我们发现,其实还是有必要改的,还是有重复的值的,但是它的可变参数是一个字符串,我们没办法摸清它的变化范围,那么我们就没办法用表结构改,那该怎么办呢?还记得之前介绍的记忆化搜索吗?没错,就是记忆化搜索 。我们可以把出现过的放在一个缓存中记下来,下回再遇到直接取。
public static int minSticks3(String[] sticks,String target){
int N = sticks.length;
//关键优化 用词频表代替贴纸数组
int[][] count = new int[N][26];
for (int i = 0; i < N; i++) {
char[] chars = sticks[i].toCharArray();
for (char cha : chars) {
count[i][cha - 'a']++;
}
}
HashMap<String,Integer> dp = new HashMap<>();
dp.put("",0);
int ans = process3(count,target,dp);
return ans == Integer.MAX_VALUE ? -1:ans;
}
//所有贴纸sticks
//要组成目标target
//返回最小张数
//sticks[i]数组,当初i号贴纸的字符统计 sticks -- >所有贴纸
private static int process3(int[][] sticks, String t,HashMap<String,Integer> dp) {
if (dp.containsKey(t)){
return dp.get(t);
}
//target做词频统计,
//target aabbcc
// 0位置代表a 1位置代表 b 2位置代表c 他们的值代表出现的次数
char[] target = t.toCharArray();
int[] tCounts = new int[26];
for (char cha : target) {
tCounts[cha - 'a']++;
}
int N = sticks.length;
int min = Integer.MAX_VALUE;
for (int i = 0; i < N; i++) {
//尝试第一张贴纸是谁
int[] stick = sticks[i];
//最关键的优化,(重要的剪枝,这一步也是贪心)
if (stick[target[0]-'a'] > 0){
StringBuilder builder = new StringBuilder();
for (int j = 0; j < 26; j++) {
if (tCounts[j] > 0){
int num = tCounts[j] - stick[j];
for (int k = 0; k < num; k++) {
builder.append((char)(j + 'a'));
}
}
}
String rest = builder.toString();
min = Math.min(min,process3(sticks,rest,dp));
}
}
int ans = min + (min == Integer.MAX_VALUE ? 0:1);
dp.put(t,ans);
return ans;
}
题目三、最长公共子序列问题
给定两个字符串str1和str2, 返回这两个字符串的最长公共子序列长度 比如 : str1 = “a12b3c456d”,str2 = “1ef23ghi4j56k” 最长公共子序列是“123456”,所以返回长度6
分析:我们假设str1 [0...i]位置 str2[0...j]位置,我就关心str1 从0到i位置和str2从0到j位置最长子序列是什么,主函数怎么调呢?我们肯定关心整体啊,参数直接传str1.length - 1 ,str2.length - 1
那么规定好递归的含义了,接下来要怎么写递归函数呢?如果 i 位置 为 0 时 那str1就剩一个字符的时候,怎么返回呢,那str2 [0...j]这一段最长就只有1啊,因为str1就只有一个字符,如果str2[j]和str[i]字符一样,直接返回1,如果不一样,str2[0...j-1]上继续。同理 j位置也是一样。还有一种可能就是str1和str2都不是0的位置,我们就要分情况讨论了,第一张可能性,完全不考虑 i 位置字符,但有可能考虑j位置字符,第二种可能 有可能考虑i位置,完全不考虑j位置字符,还有第三种,我即考虑i也考虑j,在第三种情况下,只有i和j同时以相同字符结尾,才会有公共子串,如果i和j字符一样,我们得出一个公共子串1 + 在[i-1][j-1]上继续递归。
public static int longestCommonSubsequence1(String s1,String s2){
if (s1 == null || s2 == null || s1.length() == 0 || s2.length() == 0){
return 0;
}
char[] str1 = s1.toCharArray();
char[] str2 = s2.toCharArray();
return process1(str1,str2,s1.length()-1,s2.length() -1);
}
//只关心str1[0....i] 和 str2[0...j] 公共子序列多长
public static int process1(char[] str1,char[] str2,int i,int j){
if (i == 0&&j==0){
return str1[i] == str2[j] ? 1 : 0;
}else if (i == 0){
if (str1[i] == str2[j]){
return 1;
}else {
return process1(str1,str2,i,j - 1);
}
}else if (j == 0){
if (str1[i] == str2[j]){
return 1;
}else {
return process1(str1,str2,i - 1,j);
}
}else {
int p1 = process1(str1,str2,i,j-1);
int p2 = process1(str1,str2,i-1,j);
int p3 = str1[i] == str2[j] ? 1+process1(str1,str2,i-1,j-1):0;
return Math.max(p1,Math.max(p2,p3));
}
}
当然该过程过于暴力,可能会超时,所以下面我们根据该暴力方法改为动态规划版本,我们发现该题目的递归调用可变参数为 i 和 j,发现是两个下标,我们是可以知道i 和 j 的取值范围的,i的范围是啥,0到str1的长度呗,j的范围是啥0到str2的长度呗,我们发现可以改成严格的表的形式,我们可以准备一个二维数组,然后在分析依赖,发现一个普通位置,可能会依赖左上角的值或者左边的值或者上边的值,根据baseCase我们可以把这些值填出来,从而完成整张表的填写,最后返回需要位置的值即可。
public static int longestCommonSubsequence2(String s1,String s2){
if (s1 == null || s2 == null || s1.length() == 0 || s2.length() == 0){
return 0;
}
char[] str1 = s1.toCharArray();
char[] str2 = s2.toCharArray();
int N = str1.length;
int M = str2.length;
int[][] dp = new int[N][M];
dp[0][0] = str1[0] == str2[0] ? 1 : 0;
for (int j = 1; j < M; j++) {
dp[0][j] = str1[0] == str2[j] ? 1 : dp[0][j-1];
}
for (int i = 1; i < N; i++) {
dp[i][0] = str1[i] == str2[0] ? 1 : dp[i-1][0];
}
for (int i = 1; i < N; i++) {
for (int j = 1; j < M; j++) {
int p1 = dp[i-1][j];
int p2 = dp[i][j-1];
int p3 = str1[i] == str2[j] ? 1+dp[i-1][j-1]:0;
dp[i][j] = Math.max(p1,Math.max(p2,p3));
}
}
return dp[N-1][M-1];
}