今天来看看leetcode上面这道动态规划的题目,这道题虽然要做出来并不困难,但是我们可以在做出来的基础上再深究一下相关的优化问题。
题目描述
解题过程
解题前的一点思考
为什么说这道题是动态规划?其实题目给的测试用例,就已经很明显地提示了我们状态转移方程。
按照题目给出的测试用例,稍微推理一下:
每次对用户输入的汤的数量,我们都有四种分配方案:
情况下的结果,并进行运算获得结果;但是如果用例更大一点呢?比如我给你的N是150的话?
如果N=150,那进行第3种分配方式之后,两种汤的剩余量都会变成100,而N=100的情况我们上面已经分析过了。
总结一下:通过对一部分小测试用例进行测试,我们发现这个问题是可以拆分成多个相同小问题的,故满足使用动态规划的大前提----拥有子问题。
并且再进一步分析,不难发现,输入不同的N,可能会产生相同的子问题,进一步说明这个问题是拥有重叠子问题的,所以我们最后要优化整个算法需要使用动态规划或记忆化搜索。
解题过程
明确了这个问题可以使用动态规划来解决,剩下的问题无非就是定义状态,推出状态转移方程。
- 状态定义: 其实这个题目状态定义非常明显,两种汤A、B,使用一个二维数组就可以表示这两种汤的剩余情况。
dp[a][b] = 在剩余 a mLA汤 和 b mLB汤的情况下,需要计算出的概率
ps:根据本人最近在leetcode上的刷题感觉,一般做一道dp题,想清楚如何定义状态是一件至关重要的事情,有时候不同的状态定义方式甚至会导致算法复杂度的急剧变化,可以见leetcode887题,《鸡蛋掉落》,在这道题上最好的状态定义方式甚至可以使时间复杂度从O(n^2)降低到O(n)
- 状态转移方程:这个题目的状态转移方程也非常好写,题目的测试用例就已经提醒了我们,只要分四种情况讨论就好了
dp[a][b] = 0.25 * (dp[a - 100][b] +
dp[a - 75][b - 25] +
dp[a - 50][b - 50] +
dp[a - 25][b - 75]);
空间优化
至此,动态规划最重要的两个步骤都写完了,非常容易。
但是再好好地看一下状态转移方程,你会发现大量的数组空间会被浪费!因为我们每次分配汤,至少是以25mL的分量进行分配的。很明显,每进行一次状态转移,至少就会浪费掉24个空间。
解决这个问题的思路,其实跟哈希表的理念很像,我们可以将不连续的汤的体积,映射成一连串连续的索引上。而这道题的映射方式也非常容易。
上面这个表格也很好理解,你只需要按照每次分25mL汤,至少需要分多少次,这个思路去理解就可以了。
由于这道题的basecase也很好分析,所以这里就不多说了,直接上代码分析。
public double soupServings(int N) {
int n = (int)Math.ceil(N / 25.);
double[][] dp = new double[n + 1][n + 1];
//base case
dp[0][0] = 0.5;
for(int i = 1; i <= n; i ++)
dp[i][0] = 0.0;
for(int j = 1; j <= n; j ++)
dp[0][j] = 1.0;
for(int a = 1; a <= n; a ++){
for(int b = 1; b <= n; b ++){
dp[a][b] = 0.25 * (dp[Math.max(a - 4, 0)][b] + dp[Math.max(a - 3, 0)][b - 1]
+ dp[Math.max(a - 2, 0)][Math.max(b - 2, 0)] + dp[a - 1][Math.max(b - 3, 0)]);
}
}
return dp[n][n];
}
在书写动态规划代码的过程中,有这么一个问题经常会困扰我,就是我要怎么遍历,从哪里开始遍历,才可以确保是按照 子问题->更大的问题,一步步地求解出整个dp数组呢?
在这个题目中,这个问题很简单,但是我想借这个题目把寻找二维dp开始遍历点的方法简单地说一下。
其实很简单,画表就可以了!
dp[a][b] = 0.25 * (dp[a - 100][b] +
dp[a - 75][b - 25] +
dp[a - 50][b - 50] +
dp[a - 25][b - 75]);
观察上面的状态转移方程,你会发现新状态是从前面的行和列转移过来的,所以这里只需要按照一般的行列顺序进行遍历就可以了。
ps:其实这个问题在有一些题目还是蛮难想的,你可以去尝试一下leetcode516题-《最长回文子序列》,也是动态规划的一个经典题目,但是我觉得其中遍历的顺序不画出表格干想,有时候头脑不清晰还是挺混乱的
优化思路
如果你拿上面那段代码直接去leetcode提交,是不能通过的,会提示内存溢出错误。事实上,关于题目描述部分,我还有一部分没截全,是这样的:
N的最大取值甚至可以到达10e,而你再返回去看我们写出来的算法,会发现是一个O(n^2)的算法, 假设题目真的给出了10e的N,10e/25也是千万级的(注意我们在开始遍历前经历了一步映射),事实上对于普通的家用计算机,一个O(n^2)的算法处理百万级别的数据可能已经要不少时间,更何况千万数据。并且即便算法在时间复杂度上过得去,别忘了我们在解决问题的过程中开辟了一个 n x n 的二维数组,这个数组在N为10e时,要存储10^16个32位整数,很明显内存溢出错误就是这里产生的。
其实我在做这道题的时候,被这个地方卡了很久,并且到最后都没有实现出解决办法, 这个过程中产生了不少很有意思的思路,稍后可以写出来记录一下(虽然没有实现出来)。先看看别人给出的思路,其实就是一个概率问题(学好数学是多么重要!)
数学优化思路
答案就隐藏在题目给出的注释2上,返回值误差可以是10^(-6)! 返回去看题目给出的四种分配方案,你会发现,每次分配,A平均会减少 (100 + 75 + 50 + 25) / 4 = 62.5mL,而B是 (0 + 25 + 50 + 75) / 4 = 37.5 mL, 因此当N无穷大时,代表了我们需要分配的次数无穷多,这种情况下,A从统计意义上来说肯定比B更快分配完,这时候题目要求的概率会无限接近于1,而我们所需要的,只是求出N的这个阈值,并在函数一开始判断N的大小并返回就可以了!
加入阈值判断之后的代码如下:
public double soupServings(int N) {
if (N >= 4800)
return 1;
int n = (int)Math.ceil(N / 25.);
double[][] dp = new double[n + 1][n + 1];
//base case
dp[0][0] = 0.5;
for(int i = 1; i <= n; i ++)
dp[i][0] = 0.0;
for(int j = 1; j <= n; j ++)
dp[0][j] = 1.0;
for(int a = 1; a <= n; a ++){
for(int b = 1; b <= n; b ++){
dp[a][b] = 0.25 * (dp[Math.max(a - 4, 0)][b] + dp[Math.max(a - 3, 0)][b - 1] +
dp[Math.max(a - 2, 0)][Math.max(b - 2, 0)] + dp[a - 1][Math.max(b - 3, 0)]);
}
}
return dp[n][n];
}
需要注意的是,这里我直接使用了计算出来的阈值;要验证这个阈值的正确性也并不困难。但是如何计算,这应该是一个统计上的问题了,超出本人能力范围,这里就不说了。
个人对此题的一些思考
下面我想简单地记录一下自己在思考优化的过程中,想出的两个方法:
- 不使用自底向上的dp数组记录,转而使用回溯记忆化搜索: 其实你仔细画出表格之后,稍微分析一下,就会发现,其实我们为了求出目标dp[n][n],并不需要把整个数组 n x n 个位置全部遍历一遍,为什么呢?
如上表,我们要求出(5, 5)这个位置的数值,根据状态转移方程,只需要求 (1, 5), (2, 4), (3, 3), (4, 2)这四个位置的数值,再累加并乘0.25就可以了。而在前面的dp计算过程中,我们却遍历了整张表格,明显是没必要的。所以我们可以使用回溯法,只计算我们需要的位置的dp数值就可以了。
具体代码如下所示,因为要防止子问题的重复求解,所以使用了一个HashMap对已经求解出来的解进行记录。这里因为是二维坐标,所以我选择创建了一个Tuple类作为键,使用自定义类作为Java的HashMap键时,不要忘了实现hashCode和equals方法,这些都是Java相关的基础知识,这里就不展开了。
private class Tuple{
public int a, b;
public Tuple(int a, int b){
this.a = a;
this.b = b;
}
@Override
public int hashCode(){
int B = 31;
return a*B + b;
}
@Override
public boolean equals(Object o){
if(this == o) return true;
if(o == null) return false;
if(getClass() != o.getClass()) return false;
Tuple another = (Tuple)o;
return a == another.a && b == another.b;
}
}
private HashMap<Tuple, Double> dp = new HashMap<>();
public double soupServings(int N){
if (N >= 4800)
return 1;
int n = (int)Math.ceil(N / 25.);
return soupServings2(n, n);
}
private double soupServings2(int a, int b){
if(a == 0 && b == 0) return 0.5;
if(a == 0) return 1;
if(b == 0) return 0;
Tuple tuple = new Tuple(a, b);
if(dp.containsKey(tuple))
return dp.get(tuple);
double p1 = soupServings2(Math.max(a - 4, 0), b);
double p2 = soupServings2(Math.max(a -3, 0), b - 1);
double p3 = soupServings2(Math.max(a - 2, 0), Math.max(b - 2, 0));
double p4 = soupServings2(a - 1, Math.max(b - 3, 0));
double p = 0.25*(p1 + p2 + p3 + p4);
dp.put(tuple, p);
return p;
}
-
能否在未知阈值的基础上,节省dp过程的一部分空间?
ps: 关于这个思路,我想不出来如何组织代码,所以这部分只简单地聊聊思路
其实整体思路跟1很像,但是在这个思路中我想的是能否通过自底向上,只在内存中记录我们当前所需要的那部分数据,从而达到节省空间的目的。
如果你斜着看这个表格,并把每根斜线看成新的行的话,是不是我们只需要预判dp[n][n]的位置,并从一开始选择这样一个新的初始行,通过每隔4行计算一遍新行(注意:每一个新行n上的数据,都是从新行n-4上计算得到的,通过前面那个状态转移方程我们可以知道这个事实),从而最后达到dp[n][n]所在新行并计算出该行上的数据(其实只有dp[n][n]一个数字)就行了?
虽然每一个新行的长度都是不相同的,但是如果我们按照从右上角到左下角的顺序对新行元素进行遍历,你可能会发现新世界。
如上图中,(5, 6)这个位置的值计算,需要红线部分的元素;当计算完(5, 6)之后,其实红线最右上的元素我们以后就不会再用到了,可以直接丢弃掉,这是不是意味着,我们可以只使用一个一维数组来记录我们要求的值,当使用完某个已经不需要再使用的值之后,我们就可以直接把以后可能需要的,但是刚计算出来的值直接覆盖上去?
其实这有点像leetcode上面股票问题的第一题(121题)的空间优化思路:
我们一开始使用一个二维dp数组来记录所有位置的情况,但是写完算法之后再看代码,你会发现后一个状态的更新,只跟前一个状态有关,所以我们只记录最新状态不就行了吗? 只不过在本文讨论的分汤问题中,这个数组每一行元素分布数量是不均匀的。
结语
好了,到此为止,个人想要说的东西就已经全部说完了。第一次写文章,很多地方可能比较啰嗦,望见谅。