hello hello,好久不见。
今天我们来看一道算法题:LeetCode691 贴纸拼词。这是一道hard难度的题,还是很有难度的。
题意:给你一堆贴纸stickers,和一个英文单词。每一种贴纸都有无限张,并且每一张贴纸能剪切成一个个的字母。
现在问你,如何用最少的贴纸,组成这个英文单词?请返回最少贴纸张数。
分析:动态规划刷的多的同学,应该能够反应过来,这是一道类似于背包问题的dp题。尝试思路是:第一张贴纸,我用、1张、2张……,这样去尝试;第二张贴纸,我用、1张、2张……。一直尝试到最后一张贴纸。 只要英文单词target变为空字符串,那么我们就尝试成功了,这就是其中一种尝试方法。
1、递归版本
代码框架:
public int minStickers(String[] stickers, String target) {
if (stickers == null || stickers.length == 0 || target == null || target.length() == 0) {
return 0;
}
int ans = process(stickers, target);
return ans == Integer.MAX_VALUE ? -1 : ans;
}
public int process(String[] stickers, String target) {
}
那么现在我们只需要填好递归函数,这个代码就算写完了。
根据分析,只要target变为空字符串,那么我们就可以直接返回了。所以递归结束条件:if (target.length() == 0) return 0 ;
接下来就是尝试,用第一张贴纸去试试,然后调用递归函数;用第二张贴纸去试试,然后调用递归函数……
public static int process(String[] stickers, String target) {
if (target.length() == 0) {
return 0;
}
int min = Integer.MAX_VALUE;
for (String first : stickers) {
String rest = minus(target, first);
if (rest.length() != target.length()) {
min = Math.min(min, process(stickers, rest));
}
}
return min == Integer.MAX_VALUE ? min : min + 1;
}
public String minus(String target, String sticker) {
int[] count = new int[26];
for (char ch : target.toCharArray()) {
count[ch - 'a']++;
}
for (char ch : sticker.toCharArray()) {
count[ch - 'a']--;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 26; i++) {
while (count[i]-- > 0) {
sb.append((char) (i + 'a'));
}
}
return sb.toString();
}
这个递归版本,在LeetCode中,能够一部分用例,其他用例没通过的原因就是,这个递归函数调用了很多重复计算的子过程。所以需要在递归版本的基础之上,再次进行优化。
2、剪枝版本
所谓剪枝,可以这么理解,实际在递归调用的时候,递归调用的展开图如下:
也就是说,在某一个递归调用的过程中,额外加的if语句不成立,导致递归函数没必要调用后续的过程,直接返回上一层调用,这样的行为就称为剪枝。
那么这道题,应该如何剪枝?举个例子:
假设 target = “hello”,按照上面的递归版本的代码会这样调用:
for() {
? 删除第一个h,递归调用后续;
? 删除第二个e,递归调用后续;
? 删除第三个l,递归调用后续;
? ……
}
你会发现,在这一个for循环中,居然枚举了删除每一个字母的过程,看上去好像并没有什么问题。但仔细想想,有必要吗?
其实并没有必要,比如说 这一层递归函数消除h,和下一层递归函数消除h,这两个并没有什么本质上的区别,其目的就是为了使target变为空字符串。如下图所示:
代码如下,稍微有些改动,以前的帖纸是以字符串数组来存储的,我们需要稍微调整一下,用二维数组存储,一行表示一种贴纸,一列表示这种贴纸中的字母数量,也就是词频统计嘛。
public int minStickers(String[] stickers, String target) {
if (stickers == null || stickers.length == 0 || target == null || target.length() == 0) {
return 0;
}
int N = stickers.length;
int[][] stickers2 = new int[N][26];
for (int i = 0; i < N; i++) {
for (char ch : stickers[i].toCharArray()) {
stickers2[i][ch - 'a']++;
}
}
int ans = process2(stickers2, target);
return ans == Integer.MAX_VALUE ? -1 : ans;
}
public int process2(int[][] stickers, String target) {
if (target.length() == 0) {
return 0;
}
int min = Integer.MAX_VALUE;
int[] count = new int[26];
for (char ch : target.toCharArray()) {
count[ch - 'a']++;
}
char firstCh = target.charAt(0);
for (int i = 0; i < stickers.length; i++) {
if (stickers[i][firstCh - 'a'] > 0) {
StringBuilder sb = new StringBuilder();
for (int j = 0; j < 26; j++) {
int nums = count[j] - stickers[i][j];
for (int k = 0; k < nums; k++) {
sb.append((char) (j + 'a'));
}
}
String rest = sb.toString();
min = Math.min(min, process2(stickers, rest));
}
}
return min == Integer.MAX_VALUE ? min : min + 1;
}
剪枝版本的代码虽然做到了一定的优化,但是在LeetCode上,还是会超时。这就需要再优化成动态规划版本了。
3、动态规划版本
从递归版本的代码可以看出,递归函数process在调用的过程中,形参只有一个target在变化,所以在我们以前的惯性思维中,只有一个可变参数,那我搞一维数组就能搞定了。
但是,用一维数组在这道题能行的通吗?好像并不可以。为什么?
原因在于如果用一维数组,这个数组我应该开辟多大的空间?这个空间的大小是根据target的变化范围来定的。而target的变化范围并不是很好计算。那么这道题用一维数组,不太可行。
只能用记忆化搜索。
既然可变参数是一个字符串类型,那么用哈希表存储,就能解决这个问题了。改记忆化搜索版本,也比较简单,在递归函数中,随身带着这个哈希表即可,其他的代码不用改,还是用剪枝版本的即可。
public static int minStickers(String[] stickers, String target) {
if (stickers == null || stickers.length == 0 || target == null || target.length() == 0) {
return 0;
}
int N = stickers.length;
int[][] stickers2 = new int[N][26];
for (int i = 0; i < N; i++) {
for (char ch : stickers[i].toCharArray()) {
stickers2[i][ch - 'a']++;
}
}
HashMap<String, Integer> dp = new HashMap<>();
dp.put("", 0);
int ans = process3(stickers2, target, dp);
return ans == Integer.MAX_VALUE? -1 : ans;
}
public static int process3(int[][] stickers, String target, HashMap<String, Integer> dp) {
if (dp.containsKey(target)) {
return dp.get(target);
}
int[] count = new int[26];
for (char ch : target.toCharArray()) {
count[ch - 'a']++;
}
int min = Integer.MAX_VALUE;
for (int i = 0; i < stickers.length; i++) {
if (stickers[i][target.charAt(0) - 'a'] > 0) {
StringBuilder sb = new StringBuilder();
for (int j = 0; j < 26; j++) {
int nums = count[j] - stickers[i][j];
for (int k = 0; k < nums; k++) {
sb.append((char)(j + 'a'));
}
}
String rest = sb.toString();
min = Math.min(min, process3(stickers, rest, dp));
}
}
min = min == Integer.MAX_VALUE? min : min + 1;
dp.put(target, min);
return min;
}
好啦,以上全部就是这道题的思路。重要的是理解了递归调用过程,那么这道题就迎刃而解了。
那我们下期再见吧!!!
|