观李永乐老师《双蛋问题》解题后感
题目开始前,随便说几句。
- 随便说几句,就是随随便便说的,看不懂没关系。随便说,可能会表达得不好,当作阅读前的热身 。
李永乐老师双蛋问题,大概就是讲,给你两个钛合金鸡蛋,在100层楼中去测试鸡蛋的耐摔度,就是摔碎鸡蛋的临界点。问你最少要试扔多少次(不是多少个,是扔多少次),这两个鸡蛋是你的科研经费能买到的唯一物质。
之后, 李永乐老师,把问题难度提高,给你K个鸡蛋,在N层测耐摔度,最少要扔多少次。
在看本题之前,最好看看李永乐老师的解题思想。
本题用到的算法是动态规划 + 二分查找。
其实这道题目,只懂得计算机的动态规划和二分查找是不够的解答的。
因为,看完视频还不满足,所以就专门跑去 leetcode找一下同款题目来做。
力扣原题如下:
- 鸡蛋掉落
你将获得 K 个鸡蛋,并可以使用一栋从 1 到 N 共有 N 层楼的建筑。
每个蛋的功能都是一样的,如果一个蛋碎了,你就不能再把它掉下去。
你知道存在楼层 F ,满足 0 <= F <= N 任何从高于 F 的楼层落下的鸡蛋都会碎,从 F 楼层或比它低的楼层落下的鸡蛋都不会破。
每次移动,你可以取一个鸡蛋(如果你有完整的鸡蛋)并把它从任一楼层 X 扔下(满足 1 <= X <= N)。
你的目标是确切地知道 F 的值是多少。
无论 F 的初始值如何,你确定 F 的值的最小移动次数是多少?
示例 1:
输入:K = 1, N = 2
输出:2解释: 鸡蛋从 1 楼掉落。如果它碎了,我们肯定知道 F = 0 。 否则,鸡蛋从 2
楼掉落。如果它碎了,我们肯定知道 F = 1 。 如果它没碎,那么我们肯定知道 F = 2 。 因此,在最坏的情况下我们需要移动 2
次以确定 F 是多少。
- 示例 2:
输入:K = 2, N = 6
输出:3
- 示例 3:
输入:K = 3, N = 14 输出:4
- 提示:
1 <= K <= 100
1 <= N <= 10000
力扣原题
链接如下:https://leetcode-cn.com/problems/super-egg-drop/
随后经过细品,才知道这道题目单单靠算法思维:动态规划 + 二分查找仍然无法解答(仅仅靠动态规划,可以解答,但是算法时间复杂度为O(KNN) ,太高了).
所以为了优化动态规划的选择策略,你采用二分查找,从选项中快速的找到最优解。
但是你采用二分查找得先证明离散数组具备单调性,要么单调性递增(/上坡形),要么单调递减(\下坡形),要么分成2段后具备单调性(V字形)
。
你要证明单调性就得运用数学证明,证明鸡蛋个数一定的情况下,楼层数越高,最少需要扔蛋次数就越大。
本题框架:
利用递归代代替迭代法实效动态规划解空间dp的生成。
我们大部分都是利用for循环(forfor,或者forforfor)和动态转移方程,自底向上的运算,生产解空间。
本题利用递归法中回溯的过程,实现自底向上,生产解空间,本质上并没什么高明之处。对于习惯看for循环的人来说,可读性还变差了。(其实按照这个思维,动态规划有点像,带memo的递归回溯算法)
其次就是关于复杂度的运算,对很多人习惯通过for循环的层数来估算时间复杂度(正确的做法应该是看状态的笛卡尔积大小。比如本题是N,K两个状态,那么他们两个维度的笛卡尔积大小为NK),也变得不那么明朗。(这套框架,时间复杂度依然是O(NK),不是这套算法。因为我们的策略采用了二分查找,所以复杂度是lgn,本题的复杂度是O(NKLgn)).
而本题状态转移方程中涉及对多个选项择优(从竞争者中择优,竞争者由候选者dp(n,k)通过业务模型生成)的策略算法在递归函数中实现。
注意对递归方式起步进行边界判断。
本题解法比较精彩的操作:
- 利用哈希表来代替二维数组,提高了dp表下标可读性。
- 利用带memo备忘录的递归回溯算法实现动态规划。
- 利用二分查找策略在众多选项中找到最优解。
- 利用函数单调性,证明可以采用二分查找策略,并设计出了二分查找分界点的判定函数(用来判定怎么缩小搜索范围)。
- 二分查找几个要素处理的很漂亮:
- 搜索范围的定义
- 分界点的定义
- 缩小搜索范围用到的判定函数(判定函数的参数就是分界点了)。
- 终止搜索的区间定义。
- 因为我们二分查找的变量是区间,所以终止条件也是区间的坐标值。
- 本题完美考虑到了离散型区间的元素个数的奇偶性问题。
- 递归的边界判断条件 if (!memo.containsKey(N * 100 + K))写在前头也很漂亮
- 充分的考虑到了动态规划的 Base Case,即鸡蛋数为0和楼层数为1的情况。
class Solution {
public int superEggDrop(int K, int N) {
return dp(K, N);
}
/* 用哈希表模拟二维数组,减少下标堆运算的干扰。提高代码的可读性。哈希表也具备下标访问的能力。而哈希函数可以解决存储的问题。所以多维数组,如果知道下标的上下限可以采用哈希表加坐标编码的方式提高可读性。
如本题,
鸡蛋最多100个,
楼层最多10000个。
我们采用编码为
NK,比如8889层楼,89个鸡蛋。那么key为888989
(或者同理,898889,也可以)
我们可以用字符串运算,“8889” + “89”
其实没必要,因为最大数是10000100.这个值在int范围内,所以我们采用int即可,比如,N = 8889,K = 89
我们只需要通过,位运算的思想,N*100 + K,即可得到888989,其中100就是偏移。
*/
Map<Integer, Integer> memo = new HashMap();
public int dp(int K, int N) {
// 递归函数的边界判断
if (!memo.containsKey(N * 100 + K)) {
// 截至目前解空间的最优解
int ans;
// 初始化 base case
if (N == 0)
ans = 0;
else if (K == 1)
ans = N;
else {
// 二分查找begin
/**
在选项中i是变量,选项值是结果,而且选项i的最小值位于区间中部,满足二分查找的应用条件。所以我们可以采用二分查找使其复杂度从O(n)变差O(lgn)
二分查找,
就是定义搜索域的访问
然后定义一个分界点,对分界点做布尔函数运算,得出boolean结果,利用boolean结果缩小搜索范围。
直到搜索范围缩小为约定大小。
本题,范围 1 ~ N,表示楼层范围。
lo为搜索范围起点,hi为搜索范围的终点。
初始化为[1,N]
定义一个分界点,分界点就是:
搜索范围是离散型区间,所以我们可以取区间中间值,如果是偶数个,中间值有两个,取第1个。如果中间值只有一个,那就直接取。所以我们的分界点方程为: middle = (ol + hi)/2
分界点的布尔函数为:算出选项值,由于选项是离散的。
如果当前选项比(middle + 1)的选项大,且比(middle-1)选项小,那么就说明当前选项处于递减趋势,不是最小值(类似U形抛物线的左半段)。所以最小值在middle的右侧,因此调整左侧起点坐标lo为middle,缩小搜索范围,继续迭代搜索。
如果当前选项比(middle + 1)的选项小,且比(middle-1)选项大,那么就说明当前选项处于递增趋势,不是最小值。所以最小值在middle的左侧,因此调整右侧终点坐标hi的为middle,缩小搜索范围,继续迭代搜索。
约定搜索终止的搜索区间,二分查找的变量是区间,所以我们要约定终止区间(不能笼统的说是终止条件)。
约定终止区间为 如果当前区间大小是奇数,则ol == hi ,如果当前区间大小是偶数,则 ol+1 == hi(因为我们定义了偶数个的起点ol是两个中间值的第一个,所以终止条件才会是ol + 1 == hi, 这就叫百因必有果,很佛系)。
*/
int lo = 1, hi = N;
while (lo + 1 < hi) {
int x = (lo + hi) / 2;
/*
这里采用分段函数的思想来判断趋势。
不过怎么都是在判断趋势,无差。
因为我们从状态转移方程中生成选项的算法中找到分段函数的规律。
动态转移方程中可以知道,
一个鸡蛋从第i层落下,会有碎和不碎两种情况。 i越高,t1的楼层数越高,t2的楼层数越少。
碎的情况t1,从公式来看,K,N不变的情况下,x-1是递增函数,所以从i=1开始时,t1肯定比较小。而大概率不会被作为选项的最终值,因为选项的最终值会从t1和t2选择最大的值。
而不碎的情况则相反,t2是递减函数
从i=1开始时,t2的值比较大,所以大概率会被选中。我们我们可以从t1和t2的大小来判断,当前的趋势,将趋势作为分界点的布尔函数。
思路是对的,不过还有个问题,我们的思路是基于函数递增递减趋势来实现的,我们知道递增递减趋势需要考虑单调性,而不具备单调性,那么二分查找的分界点的布尔函数将会不稳定,从而出现误判情况,最后搜索范围的选择会出现错误。
所以我们需要证明一个命题:
鸡蛋个数一定的情况下,楼层数越高,确定F的最少次数就会越高。
我们如果能从动态规划方程中证明这一点,那么整个思路就通顺的。
*/
int t1 = dp(K-1, x-1);
int t2 = dp(K, N-x);
if (t1 < t2)
lo = x;
else if (t1 > t2)
hi = x;
else
lo = hi = x;
}
// 二分查找end
ans = 1 + Math.min(Math.max(dp(K-1, lo-1), dp(K, N-lo)),
Math.max(dp(K-1, hi-1), dp(K, N-hi)));
}
memo.put(N * 100 + K, ans);
}
return memo.get(N * 100 + K);
}
}
总结
-
总结都写在前头了,所以没什么总结的。
-
说白点,这道题就是一个2维状态的动态规划题,只是择优策略比较复杂,用到了二分查找。
-
二分查找本来就是需要考虑单调性问题,才能用的数学算法。
-
学会这题等于掌握了动态规划和二分查找。
-
以及怎么使用哈希表代替多维数组的方法。
-
说说动态规划蛋疼的缺点,就是一旦变动的状态有多个的话,并且还要求所有状态都会被用到一次,就会出现时间复杂度幂函数级别的增长了。比如本题,有2个状态,N,K,所以时间复杂度是O(NK)起步,如果有3个状态就是o(n^3)次方了。所以有时候为了减少时间复杂度,得考虑怎么减少dp解空间的计算量。最理想的情况就是假如有3个状态,N1,N2,N3 找到一个算法时间复杂度为:O(N1 * logN2 * logN3)。这种题目通常都要求用到数学方法。