赞
踩
有一栋层数为1~100层的高楼,其中存在一个第k层,从这一层将鸡蛋扔下,恰好能够将鸡蛋摔碎(即低于这个楼层,鸡蛋一定摔不碎;高于这个楼层,鸡蛋则一定会摔碎),这个第k层称为临界楼层。
现给了你2个完全一样的鸡蛋,你可以把它们从任意一层楼扔下去。显而易见的,如果你把一个鸡蛋摔碎了,那它就没有了;如果鸡蛋没碎,那还可以捡起来继续扔。在最坏情况下,需要扔多少次鸡蛋才能确定这个临界楼层?
首先明确一件事情:因为题目保证了临界楼层的存在性,因此,临界楼层最大不超过第100层。也就是说,鸡蛋从第100层扔下是肯定会碎的,相当于是题目中隐含的条件。信息完全未知的楼层只有99层。
很显然,这是一个有序的查找问题。
对于查找问题,最简单的就是遍历:从第1层开始扔下鸡蛋,如果没碎,那么依次测试2、3、4……,直到鸡蛋在第k层被摔碎。这固然是一个可行的方法,但是效率很低。在最坏情况下,临界楼层是100层,需要在1~99楼总共扔99次鸡蛋才能得到这一结果。当然,如果你只有1个鸡蛋,那么也就不得不采用这种朴素的办法。
对于这种有序的查找,最有效率的方法自然是二分查找。可惜的是,我们只有2个鸡蛋。假设临界楼层是100层,此时总共需要摔碎8个鸡蛋才能得到结果。虽然二分查找太过于浪费鸡蛋,但是我们可以借鉴其中的思想,采取折衷的策略,在2个鸡蛋的前提下,提高一点效率。
分段查找似乎是一个不错的选择。一个简单的思路是,每10层分一段,总共有10段。第一个鸡蛋依次从10,20,30,……,90层扔下,这样可以确定临界楼层在哪个区间,每个区间长度都是10;再用第二个鸡蛋遍历这个区间,就可以找到临界楼层。最坏情况下,临界楼层在99层或者100层,需要丢18次鸡蛋才能找到结果。
这个做法相比前面的大有长进,不过仍然有提升的空间。这个潜在的提升空间在于,如果第一个鸡蛋更早的碎了,那么有机会用更少的次数找到解;如果第一个鸡蛋碎的比较晚,那么在第一个鸡蛋上已经耗费了相当多的步数。如果能够平衡一下较好的情况和较差的情况,那么将会得到一个最坏情况下表现更好的算法。
基于上述想法,提出一种思路:对于分段查找的区间,并不采用均匀的划分,而是从下往上每个区间的长度递减1。这样,当临界楼层所在的高度较高时,虽然定位这个区间需要花费更多的次数,但遍历这个区间需要的次数会少一些。1
具体算法如下:
做到这里,我们的经典问题已经解决了。假如想要将题目推广到任意的楼高,也只需要修改上述方程的右边,即可得到结果。
但是,如果变成3个鸡蛋呢?
问题变得麻烦得多了:从第
x
x
x层扔下第一个鸡蛋,假如碎了,那我用剩下的两个鸡蛋去探索
[
1
,
x
]
[1,x]
[1,x]这个区间,到底需要多少步?假如没碎,下一个区间又应该设置为多长?
不过,想到这里,我们已经能够看出明显的动态规划特征:问题的解由若干个小规模的子问题的解组成。即:
在这个思路下,来思考相应的状态转移方程,以及如何用程序实现。当拥有m个鸡蛋、楼层高度为n时:
状态转移方程:
w
o
r
s
t
(
i
,
j
)
=
{
j
−
1
,
i
=
1
0
,
j
=
1
min
{
max
{
w
o
r
s
t
(
i
−
1
,
x
)
,
w
o
r
s
t
(
i
,
j
−
x
)
}
+
1
}
,其他,其中
x
=
1
,
2
,
.
.
.
,
j
−
1
worst(i,j)=\left\{
程序实现:
#include <iostream> #include <algorithm> #define num_of_eggs 3 // 鸡蛋的个数 #define height 100 // 楼层的高度 using namespace std; int main() { int worst[num_of_eggs + 1][height + 1]; // 初始化 for (int i = 0; i <= num_of_eggs; i++) { for (int j = 0; j <= height; j++) { worst[i][j] = 0; } } // 给 worst表的第一行赋初值 for (int j = 1; j <= height; j++) { worst[1][j] = j - 1; } // 计算 for (int i = 2; i <= num_of_eggs; i++) { for (int j = 2; j <= height; j++) { worst[i][j] = worst[i][j - 1] + 1; for (int k = 1; k < j; k++) { int temp = max(worst[i - 1][k], worst[i][j - k]) + 1; if (temp <= worst[i][j]) { worst[i][j] = temp; } } } } cout << worst[num_of_eggs][height] << endl; return 0; }
这样可以轻松的算出,如果有3个鸡蛋,100层楼,最坏情况下的最小步数为9。m个鸡蛋,n层楼的任意情况,也可以轻松算出啦~
然而,这个方法的时空复杂度都不太理想,还有很明显的可以压缩的空间。该算法的时间复杂度为
O
(
m
n
2
)
O(mn^2)
O(mn2),空间复杂度为
O
(
m
n
)
O(mn)
O(mn)。为什么说还有明显的压缩空间呢?我们把上述的worst表打印一段出来看看:
楼层高度 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
1个鸡蛋 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
2个鸡蛋 | 0 | 1 | 2 | 2 | 3 | 3 | 3 | 4 | 4 | 4 |
3个鸡蛋 | 0 | 1 | 2 | 2 | 3 | 3 | 3 | 3 | 4 | 4 |
在拥有两个以上的鸡蛋时,每增加一层楼,最小步数可能+1,也可能不变,所以最后会形成一个0,1,2,2,…,2,3,3,…,3,4,4,…的序列,为了找到一个这么简单且有规律的序列,居然需要使用三次方时间复杂度的算法?我们可以用一些动态规划的小伎俩,比如说二分查找、参考前一次的决策等等来压缩一些时间,但这还不够。实际上,相比起研究探索给定的楼层需要多少步数,研究给定的步数能够探索多少楼层 ,是更有价值的!
自此,我们研究的问题已经改变了:我们拥有m个鸡蛋,至多允许扔k次,那么楼高至多为多少时,能够确保找到临界楼层?显然,随着步数的增长,可以探索的楼高是指数上升的,因此一定可以更快地找到原问题的解。
这个问题也可以用动态规划逐层分解:
状态转移方程:
f
(
m
,
k
)
=
{
k
+
1
,
m
=
1
1
,
k
=
0
f
(
m
−
1
,
k
−
1
)
+
f
(
m
,
k
−
1
)
,其他
f(m,k)=\left\{
程序实现:
#include <iostream> #include <algorithm> #define num_of_eggs 3 // 鸡蛋的个数 #define steps 9 // 至多可以测试的次数 using namespace std; int main() { int f[num_of_eggs + 1][steps + 1]; // 初始化 for (int i = 0; i <= num_of_eggs; i++) { for (int j = 0; j <= steps; j++) { f[i][j] = 0; } } // 赋初值 for (int j = 0; j <= steps; j++) { f[1][j] = j + 1; } for (int i = 1; i <= num_of_eggs; i++) { f[i][0] = 1; } // 计算 for (int i = 2; i <= num_of_eggs; i++) { for (int j = 1; j <= steps; j++) { f[i][j] = f[i - 1][j - 1] + f[i][j - 1]; } } cout << f[num_of_eggs][steps] << endl; return 0; }
可以算出3个鸡蛋、9次测试至多可以探索130层楼。任意m个鸡蛋、k次测试的结果也可以轻松算出~ 而且在方法二中需要3*130的表格才能完成的任务,在方法三中仅仅需要3*9的表格就可以完成!
可以看出,在这个程序的f表中,楼层的增长速度将是指数的。对于相同的询问:m个鸡蛋,n层楼,需要多少步数,仅需要
O
(
m
log
n
)
O(m\log n)
O(mlogn)的时空复杂度,相比起方法二,大大提升了性能。
对于给定步数,至多能够探索多少楼层的问题,是否只能通过递推计算呢?有没有直接计算的方法?
回忆一下我们扔鸡蛋的策略:从某一层楼扔下鸡蛋,如果鸡蛋碎了,那么向下寻找临界楼层;如果鸡蛋没碎,那么向上寻找临界楼层。经历了一系列的尝试之后,会得到一系列的测试结果(例如:碎,没碎,没碎,没碎,碎,……)。每一次的测试结果决定了下一次尝试的方向(向上或向下),如果测试的结果足够,则可以确定临界楼层。
据此,不妨进行一个大胆的抽象:对于每一次测试,如果结果是鸡蛋碎了,那么记1,如果鸡蛋没碎,那么记0。由于我们只有m个鸡蛋,至多能够测试k次,因此最终得到一个长度为k的二进制串,且其中至多有m个1。这些合法二进制串与临界楼层的可能情况一一对应,因此只要算出符合上述要求的合法二进制串有多少个,就能知道m个鸡蛋、k步至多能够探索多少楼层。
结合例子说一下:假设我们拥有2个鸡蛋,至多可以扔3次。根据前面的讨论,容易知道至多可以探索7层楼。下面对所有合法二进制串逐一讨论:
根据组合数的知识,我们不需要枚举就可以算出合法二进制串的数量:
C
3
0
+
C
3
1
+
C
3
2
=
1
+
3
+
3
=
7
C_3^0+C_3^1+C_3^2=1+3+3=7
C30+C31+C32=1+3+3=7。
如果你还不太服气,可以多算几个结果:(其实我也不是很服气,但是看着真挺有那么回事的)
2个鸡蛋,测试14次(熟悉的原始问题):
C
14
0
+
C
14
1
+
C
14
2
=
1
+
14
+
91
=
106
C_{14}^0+C_{14}^1+C_{14}^2=1+14+91=106
C140+C141+C142=1+14+91=106
3个鸡蛋,测试9次(原始问题的推广,100层楼用3个鸡蛋最坏情况需要9次):
C
9
0
+
C
9
1
+
C
9
2
+
C
9
3
=
1
+
9
+
36
+
84
=
130
C_9^0+C_9^1+C_9^2+C_9^3=1+9+36+84=130
C90+C91+C92+C93=1+9+36+84=130
这些数字都可以在方法二和方法三的程序中得到验证。
结论: m个鸡蛋,k次测试,至多可以探索
C
k
0
+
C
k
1
+
C
k
2
+
.
.
.
+
C
k
m
C_{k}^0+C_{k}^1+C_{k}^2+...+C_{k}^{m}
Ck0+Ck1+Ck2+...+Ckm层楼。
PS:根据这个公式,可以验证方法三中的递推公式噢~
f
(
m
−
1
,
k
−
1
)
=
C
k
−
1
0
+
C
k
−
1
1
+
C
k
−
1
2
+
.
.
.
+
C
k
−
1
m
−
1
f
(
m
,
k
−
1
)
=
C
k
−
1
0
+
C
k
−
1
1
+
C
k
−
1
2
+
.
.
.
+
C
k
−
1
m
−
1
+
C
k
−
1
m
f
(
m
−
1
,
k
−
1
)
+
f
(
m
,
k
−
1
)
=
C
k
−
1
0
+
(
C
k
−
1
0
+
C
k
−
1
1
)
+
(
C
k
−
1
1
+
C
k
−
1
2
)
+
.
.
.
+
(
C
k
−
1
m
−
1
+
C
k
−
1
m
)
=
C
k
0
+
C
k
1
+
C
k
2
+
.
.
.
+
C
k
m
=
f
(
m
,
k
)
这个办法在小规模的用例上体现出巨大的优势,可以不用打表、直接计算。不过在大规模的用例上,因为编程计算组合数在实质上也是依靠递推,所以相比起方法三,时间和空间复杂性上带来的提高应该 不大 。
洋洋洒洒写了一大堆,做个总结吧:
方法一:数学推导
- 优点:可解释性强,不需要编程。
- 缺点:局限性大,应该 只能解决2个鸡蛋的问题。
方法二:动态规划(以鸡蛋数量和楼高作为维度)
- 优点:可解释性还不错,已经可以解决任意楼高、任意鸡蛋数量的问题。
- 缺点:时间复杂性和空间复杂性不理想。
方法三:改进动态规划(以鸡蛋数量和步数作为维度)
- 优点:可以解决任意楼高、任意鸡蛋数量的问题,且时空复杂性十分理想。
- 缺点:可解释性比较差。
方法四:二进制串+组合数法
- 优点:可以解决任意楼高、任意鸡蛋数量的问题,且可以直接列式计算,不用打表递推。
- 缺点:几乎没有可解释性。
总之总之,欢迎讨论,欢迎指出错误,欢迎指出文中没讲明白的部分,欢迎提出更高妙的办法!!!
赞
踩
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。