文章目录
- 0、结论
- 1、题目
- 1.1 题目描述
- 1.2 思路分析
- 1.2.1 暴力递归解法1
- 1.2.2 解法1修改成动态规划
- 1.2.3 暴力递归解法2
- 1.2.4 解法2修改成动态规划
- 1.2.5 对数器
- 1.3 小结
- 2、总结
0、结论
1)C/C++,1秒处理的指令条数为 1 0 8 10^8 108
2)Java等语言,2~4秒处理的指令条数为 1 0 8 10^8 108
说明:基于上述结论,假设题目给定的数组长度为 1 0 6 10^6 106,如果写出来的算法时间复杂度是 O ( N 2 ) O(N^2) O(N2),那么在规定的时间内一定是不能通过的,至少需要一个 O ( N l o g N ) O(NlogN) O(NlogN) 的算法,甚至可以优化到 O ( N ) O(N) O(N)。 若给定的数组长度为 1 0 3 10^3 103,则可知道 O ( N 2 ) O(N^2) O(N2)复杂度的解法足够可以拿下该题,不用再花时间进行优化了。
所以可以通过数据量反推出需要一个什么样的解法。
1、题目
1.1 题目描述
给定数组 int[] d
,其中 d[i]
表示 i
号怪兽的能力;
给定数组 int[] p
,其中 p[i]
表示贿赂 i
号怪兽需要的钱。
开始时你的能力是 0,你的目标是从 0 号怪兽开始,通过所有的怪兽。
如果你当前的能力,小于 i
号怪兽的能力,你必须付出 p[i]
的钱,贿赂这个怪兽,然后怪兽就会加入你,他的能力直接累加到你的能力上;如果你当前的能力,大于等于 i
号怪兽的能力,你可以选择直接通过,你的能力并不会下降,你也可以选择贿赂这个怪兽,然后怪兽就会加入你,他的能力直接累加到你的能力上。
返回通过所有的怪兽,需要花的最小钱数。
1.2 思路分析
根据不同数据规模,有不同的解法。
本题是个简单的动态规划问题。
1.2.1 暴力递归解法1
第一种尝试方法:
dp[i][j]
(即表中格子表示的意义)表示从第 0 号通关到第
i
i
i 号怪兽,能力值
≥
j
\ge j
≥j时,最少使用的钱数;如果能力值无法达到
j
j
j,则为-1。
d p [ i ] [ j ] = { d p [ i + 1 ] [ j + d [ i ] ] + p [ i ] j < d [ i ] ,只能贿赂 m i n { d p [ i + 1 ] [ j + d [ i ] ] + p [ i ] 贿赂 i 号怪兽 d p [ i + 1 ] [ j ] 不贿赂 i 号怪兽 j ≥ d [ i ] ,可以选择 dp[i][j] = \begin{cases} dp[i+1][j + d[i]] + p[i] &\text{ $j < d[i]$,只能贿赂 }\\ min \begin{cases} dp[i+1][j + d[i]] + p[i] &\text{ 贿赂$i$号怪兽 }\\ dp[i+1][j] &\text{ 不贿赂$i$号怪兽 } \end{cases}&\text{ $j \ge d[i]$,可以选择}\\ \end{cases} dp[i][j]=⎩ ⎨ ⎧dp[i+1][j+d[i]]+p[i]min{dp[i+1][j+d[i]]+p[i]dp[i+1][j] 贿赂i号怪兽 不贿赂i号怪兽 j<d[i],只能贿赂 j≥d[i],可以选择
public class MoneyProblem {
// int[] d d[i]:i号怪兽的武力
// int[] p p[i]:i号怪兽要求的钱
// ability 当前你所具有的能力
// index 来到了第index个怪兽的面前
// 目前,你的能力是ability,你来到了index号怪兽的面前,如果要通过后续所有的怪兽,请返回需要花的最少钱数
public static long process1(int[] d, int[] p, int ability, int index) {
if (index == d.length) {
return 0;
}
if (ability < d[index]) { //只能贿赂
return p[index] + process1(d, p, ability + d[index], index + 1);//当前花费的 + 后续至少要花费的
} else { // ability >= d[index] 可以贿赂,也可以不贿赂
return Math.min(p[index] + process1(d, p, ability + d[index], index + 1) /*贿赂*/,
0 + process1(d, p, ability, index + 1) /*不贿赂*/);
}
}
public static long func1(int[] d, int[] p) {
return process1(d, p, 0, 0);
}
}
【弊端】:递归函数 long process1(int[] d, int[] p, int ability, int index)
,两个可变参数(能力ability
和 怪兽的个数index
),改成动态规划要借助这两个参数。但是当怪兽的能力值特别大时,如1亿、4亿等,且最大能力是所有能力值的累加和,即使改成动态规划,也是无法在
1
0
8
10^8
108 条指令内执行完毕的。
1.2.2 解法1修改成动态规划
public static class MoneyProblem {
public static long minMoney1(int[] d, int[] p) {
int sum = 0;
for (int num : d) {
sum += num;
}
long[][] dp = new long[d.length + 1][sum + 1];
for (int i = 0; i <= sum; i++) {
dp[0][i] = 0;
}
for (int cur = d.length - 1; cur >= 0; cur--) {
for (int hp = 0; hp <= sum; hp++) {
// 如果这种情况发生,那么这个hp必然是递归过程中不会出现的状态
// 既然动态规划是尝试过程的优化,尝试过程碰不到的状态,不必计算
if (hp + d[cur] > sum) {
continue;
}
if (hp < d[cur]) {
dp[cur][hp] = p[cur] + dp[cur + 1][hp + d[cur]];
} else {
dp[cur][hp] = Math.min(p[cur] + dp[cur + 1][hp + d[cur]], dp[cur + 1][hp]);
}
}
}
return dp[0][0];
}
}
1.2.3 暴力递归解法2
第二种尝试方法:
定义 dp[i][j]
(即表中格子表示的意义)表示从第 0 号通关到第
i
i
i 号怪兽,严格花费
j
j
j 元能达到的最大能力;如果没法正好使用
j
j
j 元 或者 使用了
j
j
j 元,但是无法到达
i
i
i 号怪兽,均为-1。如果能将整张表顺利填写完毕,那么第
N
−
1
N-1
N−1 行从左往右遍历哪个值不是-1,它对应的钱数就是答案。
分析转移条件
两种选择:
① 方案1:不贿赂 i i i 号怪兽。
此时的 dp[i][j]
就是说从 0 通关到
i
−
1
i-1
i−1 号怪兽,严格花了
j
j
j 元,即 dp[i-1][j]
。
但是如果 dp[i-1][j] = -1
,表示从 0 通关到
i
−
1
i-1
i−1,只花
j
j
j 元的情况下,没有可以通关的方案。此时不能选择第 1 种方案,因为在不贿赂
i
i
i 号的情况下,还想维持
j
j
j 元的前提是严格花费
j
j
j 元,能通过前面的 0 ~
i
−
1
i-1
i−1 号怪兽,而现在花费了
j
j
j 元,前面的
i
−
1
i-1
i−1 个怪兽都通过不了,所以无法做这种选择。
所以方案1——“不贿赂
i
i
i 号怪兽” 成立的第一个条件是 dp[i-1][j] ≠ -1
。
其次,第二个条件,当通过 0 到
i
−
1
i-1
i−1 号怪兽获得的能力大于等于第
i
i
i 号怪兽的能力,即 dp[i-1][j] ≥ d[i]
时,才不用贿赂。
当两个条件都成立时,dp[i][j] = dp[i-1][j]
。
②方案2:贿赂 i i i 号怪兽
假设 i i i 号怪兽的能力是 x x x,贿赂它需要的金额是 y y y 元。
选择贿赂 i i i 号怪兽,且整体要凑出 j j j 元,那么严格使用 j − y j-y j−y 元时,能通过 0 号到 i − 1 i-1 i−1 号怪兽,否则,是到不了 i i i 号怪兽这里来做选择的。而已经决定贿赂了,所以能力值不重要了。
所以方案2——“选择贿赂
i
i
i 号怪兽”成立的条件是 dp[i-1][j-y] ≠ -1
。
整理两种选择方案可得:
d p [ i ] [ j ] = m a x { d p [ i − 1 ] [ j ] d p [ i − 1 ] [ j ] ≠ − 1 且 d p [ i − 1 ] [ j ] ≥ d [ i ] d p [ i − 1 ] [ j − p [ i ] ] + d [ i ] d p [ i − 1 ] [ j − p [ i ] ] ≠ − 1 dp[i][j] = max\begin{cases} dp[i-1][j] &\text{ $dp[i-1][j] \ne -1$ 且 $dp[i-1][j] \ge d[i]$ }\\ dp[i-1][j - p[i]] + d[i] &\text{ $dp[i-1][j-p[i]] \ne -1$}\\ \end{cases} dp[i][j]=max{dp[i−1][j]dp[i−1][j−p[i]]+d[i] dp[i−1][j]=−1 且 dp[i−1][j]≥d[i] dp[i−1][j−p[i]]=−1
public class MoneyProblem {
//从第0号到第index号怪兽,花费的钱必须严格等于money
//如果通过不了,返回-1
//如果可以通过,返回能通过情况下的最大能力值
public static long process2(int[] d, int[] p, int index, int money) {
if (index == -1) { //一个怪兽也没遇到,只能花费0元
return money == 0 ? 0 : -1; //如果money不等于0,意思就是必须要在没遇到怪兽的时候花费money>0,没有这种方案
}
//index >= 0
//1)不贿赂当前index号怪兽
long preMaxAbility = process2(d, p, index - 1, money); //不贿赂index号怪兽时,之前的那些怪兽是否能通过,如果能通过获得的能力值
long p1 = -1;
if (preMaxAbility != -1 && preMaxAbility >= d[index]) { //之前的怪兽能通过 且 获得的能力大于当前怪兽的能力时才不需要贿赂
p1 = preMaxAbility; //如果if中的条件不成立,所以就没有不贿赂当前index号怪兽这种方案存在,p1维持为-1
}
//2) 贿赂当前index号怪兽
//则之前通过0到index-1号要正好花掉money-p[index]元,才能在贿赂index号的时候,一共凑够money元
long preMaxAbility2 = process2(d, p, index - 1, money - p[index]);
long p2 = -1;
if (preMaxAbility2 != -1) {
p2 = d[index] + preMaxAbility2; //贿赂后获得当前怪兽的能力
}
return Math.max{p1, p2}; //返回最大能力值
}
public static int func2(int[] d, int[] p) {
int allMoney = 0;
//所有怪兽要花的钱累加,就是要花费的钱的极限
for (int i = 0; i < p.length; i++) {
allMoney += p[i];
}
int n = d.length;
// 从0元钱开始尝试
for (money = 0; money < allMoney; money++) {
if (process2(d, p, n-1, money) != -1) { //如果返回的最大能力值不等于-1,就找到了通过所有怪兽,花费最少的钱
return money;
}
}
return allMoney; //如果在上面的尝试中都没有找到方案,那就只能花费所有的钱去贿赂所有的怪兽这种方案
}
}
【弊端】:递归函数long process2(int[] d, int[] p, int index, int money)
,两个可变参数(怪兽的个数index
和 贿赂怪兽的钱money
),改成动态规划要借助这两个参数,但是当贿赂怪兽的钱的值范围特别大的时候,即使改成动态规划,在
1
0
8
10^8
108 内也无法填完整张表。
1.2.4 解法2修改成动态规划
public class MoneyProblem {
public static long minMoney2(int[] d, int[] p) {
int sum = 0;
for (int num : p) {
sum += num;
}
// dp[i][j]含义:
// 能经过0~i的怪兽,且花钱为j(花钱的严格等于j)时的武力值最大是多少?
// 如果dp[i][j]==-1,表示经过0~i的怪兽,花钱为j是无法通过的,或者之前的钱怎么组合也得不到正好为j的钱数
int[][] dp = new int[d.length][sum + 1];
for (int i = 0; i < dp.length; i++) {
for (int j = 0; j <= sum; j++) {
dp[i][j] = -1;
}
}
// 经过0~i的怪兽,花钱数一定为p[0],达到武力值d[0]的地步。其他第0行的状态一律是无效的
dp[0][p[0]] = d[0];
for (int i = 1; i < d.length; i++) {
for (int j = 0; j <= sum; j++) {
// 可能性一,为当前怪兽花钱
// 存在条件:
// j - p[i]要不越界,并且在钱数为j - p[i]时,要能通过0~i-1的怪兽,并且钱数组合是有效的。
if (j >= p[i] && dp[i - 1][j - p[i]] != -1) {
dp[i][j] = dp[i - 1][j - p[i]] + d[i];
}
// 可能性二,不为当前怪兽花钱
// 存在条件:
// 0~i-1怪兽在花钱为j的情况下,能保证通过当前i位置的怪兽
if (dp[i - 1][j] >= d[i]) {
// 两种可能性中,选武力值最大的
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j]);
}
}
}
int ans = 0;
// dp表最后一行上,dp[N-1][j]代表:
// 能经过0~N-1的怪兽,且花钱为j(花钱的严格等于j)时的武力值最大是多少?
// 那么最后一行上,最左侧的不为-1的列数(j),就是答案
for (int j = 0; j <= sum; j++) {
if (dp[d.length - 1][j] != -1) {
ans = j;
break;
}
}
return ans;
}
}
1.2.5 对数器
public class MoneyProblem {
public static int[][] generateTwoRandomArray(int len, int value) {
int size = (int) (Math.random() * len) + 1;
int[][] arrs = new int[2][size];
for (int i = 0; i < size; i++) {
arrs[0][i] = (int) (Math.random() * value) + 1;
arrs[1][i] = (int) (Math.random() * value) + 1;
}
return arrs;
}
public static void main(String[] args) {
int len = 10;
int value = 20;
int testTimes = 10000;
for (int i = 0; i < testTimes; i++) {
int[][] arrs = generateTwoRandomArray(len, value);
int[] d = arrs[0];
int[] p = arrs[1];
long ans1 = func1(d, p); //解法1的暴力递归
long ans2 = minMoney1(d, p); //解法1的动态规划
long ans3 = func2(d, p); //解法2的暴力递归
long ans4 = minMoney2(d,p);//解法2的动态规划
if (ans1 != ans2 || ans2 != ans3 || ans1 != ans4) {
System.out.println("oops!");
}
}
}
}
1.3 小结
如果题目中怪兽能力值的数据范围很大,就选择第二种解法;
如果贿赂怪兽的钱的数据范围很大时,就选择第一种解法。
通常,这两个值的数据范围不会同时很大。
方法的选择就是根据 1 0 8 10^8 108 这个标杆进行的,估计表规模,确定哪种解法能拿下。
2、总结
再举个🌰:某个数组的长度是 N N N,范围是 0 ~ 1 0 12 10^{12} 1012,数组中的每个值 V V V,数据范围是 1 ~ 10000。
那么算法要么是在 N N N 上做二分;要么算法就和 N N N 无关,只和 V V V有关;如果既和 N N N有关,又和 V V V有关,那么只有二分能解决。
要想具备这种能力:
- 时间复杂度的分析的基本功扎实 【基本功】
- 知道“常数O(1)指令操作控制在108内”这个结论 【结论】
- 善于观察,能够从给的输入数据状况来分析从什么方向入手 【刷题练习】