由《鹰蛋》一题引发的思考

What number should I guess next ?

这篇文章的灵感来源于最近技术部的团建与著名的DP优化《鹰蛋》。记得在一个月前,查到鹰蛋的题解前,我在与同学讨论时,一直试图寻找一种固定的模式来决策。但是,却总存在更理想的方案。在此,我想以团建的游戏作为背景,系统(sui bian)地描述和分析在这些决策模式。

又或者说,我想试着介绍的是: THE FUN OF DECISION 思维游戏与决策的魅力

本文前半部分的受众可以是没接触过OI但是学过高中数学的、正在或想要接触计算机编程或是单纯觉得有趣的小伙伴。我希望通过这篇文章,能激起你们对决策思路、动态规划、甚至是分析数据找规律的兴趣。我希望,大家在看这篇文章的时候,不要急着往下翻,可以适当地停下来,思考一下,想想自己能想出什么样的决策,或是找个同学过过招。只有跟着一起思考,才能体现出思维游戏的有趣之处。

后半部分主要是在理解了朱晨光大佬的这篇论文后用自己的话进行了表述,受众主要是有一定dp基础的,但是没能看懂论文或是被论文严谨的公式证明吓住的小伙伴。

一. 万恶起源——深草革命的开端

温馨提示:背景纯属虚构,如有雷同,我爬就是了。

季树部奇奇怪怪的团建开始了。部长jxh为了巩固独裁统治,加强思想集中,下令全体部员为ta唱一首歌。

原本,腼腆的小萌新们并不敢反对,但是,不知怎么混进来的,突然出现的一个陌生的ID打破了这片表面的祥和。

ta在群中揭露了部长把发红包的钱拿去买奶茶的照片,引发了部员的不满。随着舆情的发酵,部员们决定发动革命。由于部的吉祥物是深草,史称深草革命。

眼看情势不妙,狡猾的部长决定用一个游戏来与部员决战。

ta说:”我会在纸上写一个 \(1\)\(1\times 10^{18}\) 间的数,然后你们12个人来通过猜数字来确定它。放心,每次猜数,我都会告诉你们这个数是否比你们猜的数小。但是,如果某个人猜的数大于或等于我写的数,这个人便出局了,不能再猜下一个数字。如果12个人全部出局,你们必须马上提交答案。当你们觉得已经确定我手中的数字时,也可以在全部出局前提交答案,但是,作为答案的这个数字必须在你们猜过的数字中

”如果你们答案错了,你们就乖乖唱歌。如果你们答案对了,我们就角色互换,你们来写数我来猜。为了保证公平性,在我猜了12个大于等于答案的数后,我会停止猜数,提交答案。当然,我也和你们一样,可以在出局前提交答案。然后,我们比拼确定对方答案时猜数的次数,谁少谁赢。跟我一样少也算你们赢。若你们赢了,我就给你们唱歌。“

憨厚的部员们接受了这个提议。但是部长很狡猾,ta并没有把答案写在纸上,而是根据部员们猜的数字决定答案。也就是说,ta随时在心里计算着,想尽办法地把部员们猜数的次数提到最高,甚至使他们猜不出来。ta在纸上写字只是个假动作,事实上,答案并不是事先确定的,而是在部员们猜的过程中不断调整的

好在部里的大佬比较多,他们决定,用科学的猜数(决策)方式,击溃部长的幻想。

由于部长实在太强了,就算部员们不先确定数字,也像部长那样调整,部长还是能用最优决策使自己的猜数次数最少

所以,要赢部长,取得革命胜利,部员们必须想出最优的决策,来应对部长绝对聪明的调整。并同样以绝对聪明的调整,将部长的次数卡到尽可能高。

头脑风暴就这样打响了。

但是,由于各位大佬各抒己见,部员内部分裂成了几派。革命军陷入混乱之中,部史学家称之为黎明前最后的混沌。

下面,为了讨论的方便,我可能会将部员的人数比如12记为K,部长给出的范围的右边界比如 \(1\times 10^{18}\) 记为N,我们将部员猜数的次数用含有N和K的式子表示。

下面转化为鹰蛋问题做一个简单的描述:
现在你买了一栋有1e18层的大楼,你想要知道蛋从几楼起摔下来会摔坏(也有可能从头扔到尾都没摔坏)。你手头只有12个蛋,摔坏的蛋就不能用了。问,在最坏情况下,最少需要扔几次才能确定答案?
在下文中我们讨论的内容无非这几点:怎么扔效率如何哪种情形最坏
如果你被上面的“最坏”和“最少”绕晕了,那请回过头去看故事背景,相信你能理解这个最坏与最少的含义。

P.S. 上面的这句”也有可能“是我加上去的,你也可以理解为:就算你试了N-1层楼蛋都没坏,你仍然需要把蛋从第N层楼上扔下去。这与前面部长的”你们的答案必须在猜过的数里面“这条规则都是同一个目的:避免歧义

二. 起于混沌——保守派的决策

保守派认为,想要战胜部长,第一步是要保证,一定能确定答案。因此,他们主张选用一种最为稳妥的方式。

毫无疑问,一个一个往上猜绝对能确定最终结果,但这样的操作容易被狡猾的部长卡到 \(10^{18}\) 次。

他们所想到的第一种方式,是每隔11个数猜一次

他们的想法是这样的:从12开始,到 24、36、48、最后到 999,999,999,999,999,996 ,这样一来,就算部长在某个时候说出“答案不比这小”的话,他们也可以用剩下的11个人扫遍余下的11个数字来确定结果

最终,他们猜数的最坏次数(也就是狡猾的部长会卡的次数),便是 \(10^{18}\div 12+11=83,333,333,333,333,344\) 。这个数字有17位,显然大得离谱。

但是,保守派得部员们并没有灰心。他们观察到,自己其实完全没有余下11个人来扫遍11个数的必要。只要留下一个人,从缩小了的范围的底端往上扫,同样可以确定部长的数字

怎样最大限度得利用余下的11个部员来缩小范围呢?

他们想到了无敌的二分法

每次选择范围的中点,再根据部长给出的大小提示,在缩小了的范围中取下一个中点。这样,每次猜数,都可以使范围缩小为原来的一半

经过估算,二分法在任何情况下,只需要猜测 \(\log_2 N\approx 60\) 。这简直就是史诗级的飞跃。

然而,想要完成这样完美的策略,需要有60名部员,仅靠12名部员无法完成

保守派的成员对此心知肚明。于是,他们决定由11名部员进行二分来缩小范围,剩下的一名部员从最终范围的底端往上扫

显然,部长不会容忍范围一次次地缩小,ta会尽可能得让试图二分的队员出局,进而将范围维持得尽可能大,因此,部员们最多只能进行11次二分

(在上面有涉及到一点部长视角的决策思路,关于这一点,之后会有更详细的论述)

经过估算,最终他们需要猜测的最坏次数是 \(10^{18}\div 2^{11}+11=488,281,250,000,011\) 。这个数字,只有15位,与第一种方法相比,效率接近其200倍

正当保守派的成员为了这个结果欣喜不已,疲惫不堪时,有一个声音从旁边传出:“为什么不多留下一个人,进一步缩小范围呢?”

(草这段写得我尴尬症犯了)

long long guess1(long long N,long long K){
	long long l=1,r=N,mid,c=K,cnt=0;
	while(l<=r&&c>1){
		mid=(l+r)>>1; 
		cnt++;
		if(evil_check(l,r,mid,c)){
			c--; r=mid-1;
		}else l=mid+1;
	}
	while(l<=r){
		cnt++;
		if(evil_check(l,r,l,c)) break;
		l++;
	}
	return cnt;
}

上面的代码只供参考,方便大家理解这种策略的决策过程。我用evil_check来表示部长对大小关系的判断。下文中出现的类似代码,都只是作为一种框架,方便大家理解决策的过程和大致的实现

简单的说,我写的这些代码只是用来看着玩的不是拿来跑的

evil_check也就是怎样让次数卡到最多的策略会在后面的番外篇讨论。不过自己想想也好,其实不难。

看不懂也没关系,跳过就是了,我自认为文字的表述大概可能还是清楚的。。

三. 黑夜里的烛光——改良派的崛起

发出声音的,是改良派的部员。

改良派的部员分析了保守派的两种主张,提出了把两种方式结合起来的思路。

先由10名部员进行二分来缩小范围,然后选择合适的间隔来猜数,最后扫剩下的区间X就行了。

经过粗略的计算,在二分10次后,余下的待定区间大小为 \(SIZE=10^{18}\div2^{10}=976,562,500,000,000\)

此时,如果直接把SIZE分为两份,就是保守派的第二种方案。那么,如果分为3份呢

不难发现分为三份后,每段的大小只有325,520,833,333,333,虽然为此,他们比分成两份多猜了一个数,但是余下的每段大小却大幅减小

分为两份大概还需猜测: \(1+SIZE\div2=488,281,250,000,001\)
分为三份大概还需猜测: \(2+SIZE\div3=325,520,833,333,335\)

P.S. 为什么我总是在“还需猜测”前面加上“大概”或“估算”,是应为实际操作中可能会出现向下取整、l=mid-1之类的种种变动,这一点在下面对三分乃至k分的介绍中体现尤为显著。为了方便讨论,而且大多情况下这一点小小的变动对于1e18的大数字来说也算不上什么,我选择了估算忽略的方式。顺带一提,如无特别说明,默认向下取整。

显然,分成三份要显著优于分成两份。

当然,改良派的部员并没有止步于此,他们继续探究:在分成几份时,能取得最优秀的结果

将分成的份数记为X,我们希望结果最优,就是希望函数 \(f(X)=SIZE\div X+(X-1)\) 取到最小。

季树部的大佬怎么可能不会对勾函数呢?于是他们很快找到了答案。

\(X=31,250,000\) ,取到最优解 62,499,999。

下面我来整理一下我们达成的成就:

  1. 利用间隔11的算法: 83,333,333,333,333,344
  2. 二分11次后完全遍历:488,281,250,000,011
  3. 二分10次后分三份:325,520,833,333,345
  4. 二分10次后分 \(\sqrt {SIZE}\) 份:62,500,009

全体起立!!!从现在开始这里叫卢。。好吧串场了

long long guess2(long long N,long long K){
	long long l=1,r=N,mid,c=K,cnt=0;
	while(l<=r&&c>2){
		mid=(l+r)>>1; 
		cnt++;
		if(evil_check(l,r,mid,c)){
			c--; r=mid-1;
		}else l=mid+1;
	}
	long long block=sqrt(r-l+1);
	while(l+block<=R){
		cnt++; 
		if(evil_check(l,r,l+block,c)){
			r=l+block-1;
			break;
		}
		l+=block+1;
	}
	while(l<=r){
		cnt++;
		if(evil_check(l,r,l,c)) break;
		l++;
	}
	return cnt;
}

幕间 愚者的尝试

(这里懒得编背景了)

在与同学讨论时,我曾经想到某种优化区间扫描的策略:倍增扫描策略。

也就是,扫描时,每次间隔由1、2、4、8逐渐递增

当时,我的想法是这样的:既然一个一个扫太费时,那我就多留下一个人,倍增地扫来缩小范围

于是,我打了一个先二分K-2次后倍增的策略和先倍增后二分K-2的策略,发现在某些N较大,K较小的数字下能够取得优于直接二分K-1次后遍历全区间的结果

但是,在随后的思考中,我发现了这种做法在最坏情况下,还是只能将区间缩小为原本的二分之一,而且为此要猜数的次数还高于直接取中点的二分法

那么,为什么纵使这样一个看起来毫无好处的策略,却真的可以使次数减少呢?

在几个星期前,我对这个问题的思考还只停留在二分答案的最劣情况与倍增的最劣情况答案出现的位置不同所以中和了这种幼稚且错误的想法上。

现在,我想我能给出较为科学的解释。

以先二分K-2次后倍增的策略为例:

因为每次倍增过程我们试图排除的区间大小是1、2、4、8最后是 \(2^n\)

这些区间大小加起来 总大小有 \(\sum2^i=2^{n+1}-1<SIZE\)

最坏情况下,余下来的区间大小是 \(2^n\)

\(2^n+n<SIZE\div2+1\) 时,我的策略便会优于直接二分11次后遍历的策略。

一样的道理,n足够大时,这种差距会体现得尤其明显。

下面是我当时打的暴力,仅供参考:

(写得太丑了于是删掉了)

四. 破空一闪——革命派的创举

(越来越中二了我的天)

前排声明,这个策略是同学想出来的。我好菜啊orz

革命派的小伙伴们想得更为深入:说到底,我们为什么要用二分呢?

首先,让我来介绍一下,什么是三分法。

与二分法不同,我们每次将区间分为三份,并对两个三等分点由小到大依次做出猜测。
如果小的三等分点比答案大,证明答案在第一份里;
如果大的三等分点比答案大,证明答案在中间那一份里;
如果大的三等分点比答案小,证明答案在第三份里。
之后,对缩小了的区间执行相同的操作,直到人耗尽或是区间缩小到可判断。
如果我们是从大到小来猜测的话,同样将区间缩小为三分之一,却需要消耗两个猜测大数的机会。

在人数足够多的情况下,我们可以对二分与三分做如下的比较:

从长度为N的范围中确定一个数,使用二分法,在任何情况下,都需要 \(\log_2 N\)

使用三分法,最坏情况下,需要 \(2\times \log_3N\)

\(2\times \log_3N/\log_2N=2\times\log_32=\log_34>1\)

所以,在人数充足的最坏情况下,三分法并不比二分法优秀

但是,革命派的部员们并没有因此局限了思路。他们在改良派对最后区间处理的改进中意识到,增大分的份数的实质,就是在每次划分区间时,通过额外的猜数,在仍然只消耗一个人的情形下,达到进一步缩小范围的目的。或者说,这是一种舍小局办大事的决策手段。

于是,抱着这样的思想,革命派人首先尝试了三分11次后扫描余下区间的策略

经过估算,最坏情形下需要猜数的次数是: \(11\times2+10^{18}\div3^{11}=5,645,029,269,498\)

相比起二分11次后扫描余下区间的 488,281,250,000,011,效率高了接近100倍。

我们不难发现,三分11次后余下的区间仍高达13位数,有巨大的优化空间。

革命派部员联想到了二分法达到最优的条件: \(K\geq\log_2N\)

那么为什么我们不试着找到一个合适的底数k,使我们的K满足上面的式子呢?

于是,k分法应运而生了。

对于这个底数k,应有 \(K\geq\log_kN\) ,换句话说,应有 \(k^K\geq N\)

我们不妨取 \(k=\sqrt[K-1]{N}=\sqrt[11]{10^{18}}\approx43\)

确定了这个k之后,经过估算,需要猜数的个数变成了: \(11\times42+10^{18}\div43^{11}\approx463\)

求出了这条式子后的革命派部员们回过头去看了看改良派对最后区间的优化处理,顿时百感交集。

其实,改革派的优化是将改良派的优化变得更普遍化,只是这一步之遥,有时也是那么遥不可及,而只有走出关键的一步的人,才会被载入史册

到此,让我们再来整理一遍部员们达到的成就,为自己看到这里的耐心和黎明的临近鼓掌吧

  1. 保守派: 二分11次后完全遍历:488,281,250,000,011

  2. 改良派: 二分10次后分 \(\sqrt {SIZE}\) 份:62,500,009

  3. 改革派: 43分11次后完全遍历:463

没错,你没有看错,这只是临近黎明仍没有到来

long long guess3(long long N,long long K){
	long long l=1,r=N,c=K,cnt=0,block,num,mid;
    while(l<=r){
	    num=floor(pow(r-l+1,1.0/(c-1)));
        block=(r-l+1)/num+1; mid=l;
        while(mid+block<=r){
        	mid+=block; cnt++;
            if(evil_check(l,r,mid,c)){
            	c--; r=mid-1; break;
            }else l=mid+1;
        }
        if(mid==l) break;
    }
    while(l<=r){
		cnt++;
		if(evil_check(l,r,l,c)) break;
		l++;
	}
    return cnt;
}

再次提醒,代码仅仅作为一种决策模式的参考。(当然你也可以自己写个evil_check跑跑看,不过我比较菜,可能会出锅)

事实上,我们不难发现,在上面的代码中,向下还是向上取整、加block后要不要加减一等玄学问题,都有可能使猜数的次数出现微妙的变化。甚至只是在某次k分时作了调整,也可能会对结果产生玄学的影响。这一点,在1e18这样的大范围上体现尤为显著。但是,我们的最优解却只有一个,想要从这些许许多多的可能的调整(比如有的加block后减了1,有的却不减)中分析出最优的结果。从解的唯一性这个角度来看,我认为,这个算法过于玄学,虽然求出来的答案或许很接近于最优解,但它应该不是正确的解法

什么,没看懂上面这段话?那就跳过吧,我也不知道怎么解释,只是一种单纯的感觉罢了。

五. 拨开混沌——神秘的支援者

(打到这里我才发现我根本找不到能算1e18的代码,虽然我嫖了个题解的截图,不过看不懂也打不出来,所以具体的最优答案是多少,我也不知道。或许过几天就知道了,所以我只能通过较小数据的分析,让大家体会一下其正确性)

在经过了各自的讨论后,部员们有了一定的底气。

但是,强大的部长是一个不可轻敌的对手。

一定要赢才行,一定要确保最终的胜利才行。每个人都在心中这样默念着。

但是,部长胸有成竹的样子仍让部员们感到不安。

还有吗?难道猜数的次数还能再缩小吗?

就在部员们苦恼不已之际,Q群中那个神秘的ID,给出了ta的提示。

“为什么要局限于某种规律性的分段呢?”

现在,让我们代入部员的身份,一起思考吧。(其实是懒得改人称了)

每次需要决定在范围 \([l,r]\) 内猜某个数x后,状况会出现如下的变化:

  1. 若x大于等于部长的答案,一名部员出局,余下的K-1名部员继续在 \([l,x\big)\) 内猜数
  2. 若x小于部长的答案,则由K名部员继续在 \(\big(x,r]\) 内猜数

由于部长太过强大,他一定会在情况1和情况2中挑出一个猜数次数较多的来继续。

好在虽然我们无法选择情况1和情况2,却有选择x的自由

那么,我们只需要计算出每个x对应的情况1和情况2的次数最大值,然后再从这些x中选出一个最大值最小的来猜数,就能够达到最优的次数。

但是,我们要如何求出这个值呢?

我们将k个人在区间长度为n的范围里猜数的最优次数记为 \(f(k,n)\)

也就是说,我们需要求解的,是 \(f(K,N)\)

假如在第一次猜数中,我们猜测的数字是区间中的第W个。
则对于情况1,我们余下来的区间为 \([l,l+W-1\big)\) 对应的区间长度为 \(W-1\) ,人数为 \(K-1\)
对于情况2,余下的区间为 \(\big(l+W-1,r]\) 对应的区间长度为 \(N-W\) ,人数为 \(K\)

那么我们有:

\(f(K,N)=max\{f(K-1,W-1),f(K,N-W)\}+1\)

换句话讲:我们将在K个人、长度为N的区间情况下求最坏情况的最小猜测次数的问题,转化为了在K-1个人、长度为W-1的区间情况下K个人、长度为N-W的区间情况下求解的两个规模较小的子问题

或许你会问,这有什么意义,问题不还是没有得到解决吗?

这当然是有意义的。因为,随着子问题规模的不断缩小,最终,我们将具有能够将子问题求解的能力,并利用求出的子问题,我们可以将其代入大子问题的式子中,解出大子问题,最终解出我们要求得f(K,N)

像这样,自顶向下将问题不断分解到可以计算答案后,通过迭代回溯,计算大问题的过程,我们称之为递归

那么,什么样的小问题我们可以直接回答呢?

显然的,对于任意的区间长度n,我们有 \(f(1,n)=n\) ,因为在我们只剩1个人的时候,我们必须从底向上一个数一个数的猜,假如我们先猜了第1个数,发现它小于答案,后猜了第三个数,如果第三个数大于等于答案,我们就无法再确定第二个数是否是答案了。正是因为如此,最坏情况下,我们需要猜测n次。

显然的,对于任意人数k,我们有 \(f(k,0)=0\) ,区间长度为0自然没有猜的必要。

于是,我们在利用递归求解出问题的解的同时,记录下为了取到这种解,我们每次取的是区间中的第几个数,这样一来,我们只需要照着事先算好的表回答,就能够实现最优的策略。

long long f(long long K,long long N){
	if(N==0) return 0;
	if(K==1) return N;
	long long ans=INF;
	for(int w=1;w<=N;w++){
		long long temp=max( f(K-1,w-1),f(K,N-w) );
		if(temp+1<ans){
			ans=temp+1;
			chosen[K][N]=w;
		} 
	}
}

就这样,除了求出 \(f(K,N)\) 及取到这个最小值每次所需要选择的数字外,我们还求出了其它各种K和N下需要选择第几个数最优。就算部长临时起意,做出了不符合最坏情况的决策,我们也能通过查表,做出最优的选择。

顺带一提,如果我们不是利用递归来计算,而是先预处理出每个小问题的解并存下来,之后再一步一步扩展问题的规模,从下而上得计算,我们将其称之为动态规划,简称dp。

\[f(K,N)= \begin{cases} max\{f(K-1,W-1),f(K,N-W)\}+1 & K>1,N>0\\ 0 & N=0\\ N & K=1 \end{cases} \]

而上面的这条公式,我们称之为动态规划的状态转移方程

代码就不再给出了,dp的写法随便搜一下就有了,在这里我只给出 N=1000,K=3 时,k分法与正解的对比:
k分法(代码):跑出46 可能存在evil_check判断不够合理的问题
理论计算31分法:30*2+1000/(31^2)=61
dp:19
代码我会放在文章的最末尾。仅供参考。

就这样,部员们利用自己的聪明才智,想出了击溃部长的方法。

正当他们打开计算机,准备把理论付诸实践时,他们突然发现,用刚才的方法,若是直接枚举K、N和W,就需要循环接近 \(12\times10^{18}\times10^{18}\) 次 。想要在短时间内描述出1e18中的每一种情况几乎是不可能的。就算是计算机,也不可能在在决战开始前算出一条合适的路径。

怎么办?只能优化dp了。

就在这时,有人突然发现,群里那个陌生的id,居然是部长的小号!

原来,部长是看到最近部里气氛沉闷,大家在枯燥的学业中迷失了自己,就把自己掏钱给部员们买奶茶的照片用另一个马甲发了出来。

部员们终于理解了部长的良苦用心。他们与部长更改了游戏规则,把每次决策的思考时间改成了1秒,并且禁止了计算机的使用,场面于是变得混乱而快乐起来。

六. DP优化——接近神的领域

警告: 本节或许不会再像上面那样从非常基础的地方开始解释。

然而写完这节我并没有觉得接近了神的领域,反而觉得自己越来越菜了

下面的内容是对这篇论文的内容作较为感性的解读。

在这里,我们不再考虑决策过程中究竟发生了什么,我们只希望求出这个最优的猜数次数

首先,当人数一样多的情况下,区间越长,猜数的次数只可能更多,不可能更少。按照这样感性的分析,我们可以得出这样的结论(下记为结论1): \(f(K,N)>=f(K,N-1)\)

(当然,你也可以像论文中的那样用数学归纳法去准确地证明这个结论)

得出这个结论后,我们回过头看我们枚举W的过程。

枚举后,我们取的是 \(max\{f(K-1,W-1),f(K,N-W)\}\) (下记为式1

根据结论1,我们不难发现,随着w的递增,f(K-1,W-1)逐渐递增,f(K,N-W)逐渐递减。我们将f(K-1,W-1)和f(K,N-W)随着w的变化的曲线大致画出来后,不难发现:
式1取到最小值的位置,就在两条曲线的交汇处

所以,我们就不需要枚举每个w,而是直接利用二分查找找到令 \(f(K-1,w-1)\geq f(K,N-w)\) 的第一个位置mid,然后比较挑选式1在w取mid与mid-1下的最优取值就行了。

此外,不难发现,就单次操作而言,其对缩小区间的最大贡献是把区间一分为2(因为最坏情况下部长肯定不会允许你取到不等分段后的小区间)

再加上我们前面在k分法的描述中也有提到,在所有的分法中,假如人数足够多,二分法在最劣情况下拥有最优的表现。所以,当人数足以进行2分法时,我们直接进行二分法便是最优的。此时,直接记为二分法所需的次数就行了,不需要枚举或是二分w来计算。

到这里,我们已经将原本 \(O(KN^2)\) 的朴素算法优化到了 \(O(KN\log_2N)\)

但是这样的复杂度显然还不能使我们算出1e18这样的数据。我们还需要进一步的优化。

(很显然这种神仙思路就不是我这种凡人想得出来的)

可以发现,当人数一定时,若区间长度多了1,猜数次数要么多一,要么不变。
这里可以感性地理解一下:我们可以假装不知道区间的长度是N+1,仍然按区间长度为N的策略去猜数,然后得到最坏情况下的最优次数后,我们再把这个多冒出来的数再猜一遍。这样一来猜数次数便只多了1。再怎么搞,也不至于多2或多更多。

这是更为科学详细的证明:

对于式子 \(f(K,N)=max\{f(K-1,W-1),f(K,N-W)\}+1\)
我们单独拿出W=1的情况出来,很显然,f(K-1,W-1)=0 我们有 f(K,N)<=f(K,N-1)+1
另一方面,我们又有结论1: f(K,N)>=f(K,N-1)
综合起来,我们有 \(f(K,N-1)\leq f(K,N)\leq f(K,N-1)+1\)

换句话说,f(K,N)要么等于f(K,N-1) 要么等于f(K,N-1)+1

显然,当存在某种决策w,使得max{f(K-1,W-1),f(K,N-W)}+1等于f(K,N-1)时,我们有f(K,N)=f(K,N-1),否则,我们有f(K,N)=f(K,N-1)+1

在计算f(K,N)时我们考虑通过某种O(1)的手段,来确认是否存在相应的决策,进而实现O(1)的状态转移

现在,我们思考一下,当我们试图计算f(K,N)时,在这之前的计算中我们已经或者可以得出了什么?

我想,dp本身就是从已知子问题推导出新问题的解的过程,所以,在我们试图优化dp时,弄清我们在dp过程中能够得出什么值或是规律、可以维护什么值至关重要。我们不应总把目光放在最终的结果或是循环的层数上。当然,我不是说我们总是去盯着打出来的数表上。至少,我们不应总把dp的中间数据当成一个黑盒子,有时,或许只有善于观察分析、或是不倦于打表观察的人,才能发现打开宝盒的钥匙。

啊这让我想起了之前在洛谷随机跳到的这道题。那个打表的题解就是我写的(

啊说到底上面这段话只是我这个凡人的想法,不喜勿喷。

啊不小心跑题了,我们回到刚才的问题吧。

按照for循环的顺序,在我们试图计算 f(K,N) 时,我们已经计算出了 f(K,0) 到 f(K,N-1) 的所有值。

对于的某个数p,假如其被用于 f(K,N) 的计算中,式子应该是这样的:

\(f(K,N)=max\{f(K,p),f(K-1,N-p-1)\}+1\) p的含义为猜数后鹰蛋并没有摔碎的时余下的范围大小

我们知道,随着 p 的增大,f(K,p) 的值会越来越大,而 f(K-1,N-p-1) 的值会越来越小。

当p=N-1时 f(K,N) = max{ f(K,N-1),f(K-1,0) }+1 = f(K,N-1) + 1

换句话说,如果我们试图找到一个p使 max{ f(K,p),f(K-1,N-p-1) } + 1 = f(K,N-1),这个p一定在N-1之前

举例来说,我们知道,在算 f(K,N-1) 时,假如 f(K,N-1) = f(K,N-2) 我们找的对应的 p 绝对不是 N-2 。

再进一步讲,我们希望 f(K,N) = f(K,N-1) ,我们选的这个 p ,其对应的 f(K,p) 就必须小于 f(K,N-1) (因为取了max后面还有个+1呢)

同时,我们不能忘记,max{ } 里头还有个 f(K-1,N-p-1)

我们知道 f(K-1,N-p-1) 随p的递减而递增。所以,我们的p既不能离N-1太近,又不能离它太远,否则f(K-1,N-p-1)变大,这对我们没有好处。

那么问题就简单了。

我们取p 使之是满足 f(K,p) < f(K,N-1) 的最大的数。由于我们知道 f(K,N) 相邻项的最大差距是1 ,所以必然有 f(K,p) = f(K,N-1)-1

假如这个p不能使 max{ f(K,p),f(K-1,N-p-1) } + 1 = f(K,N-1) ,那么更小的p同样不行

这是很显然的,因为 f(K,p) = f(K,N-1)-1,而 max{ f(K,p),f(K-1,N-p-1) } + 1 却大于 f(K,N-1),表明 f(K-1,N-p-1) 比 f(K,p) 要大,就算再往小的p处取,由于 f(K-1,N-p-1) 是随p的递减而递增的,所以不可能取到更优的结果。

所以,我们每次只需要判断 max{ f(K,p),f(K-1,N-p-1) } + 1 是否等于 f(K,N-1) 就行了,假如 f(K,N) 比 f(K,N-1) 还大,那么只需要把p改成N-1就行了。维护和查询都是 O(1) 的。

这样一来,我们就将dp优化到了 O(KN)

虽然这样还是没能算出1e18的数据,不过为了把这玩意理解表述得清楚、白话一点,我的脑细胞已经死光了

到这里,对这种dp已经优化得相当彻底了。无法再继续优化了。

什么?你问1e18到底怎么算的?那是另外一种思路了。

七. 思路翻转——最后的黎明

首先,让我们用下面的k分法代码跑一次 K=12 N=1e18 的数据(当然我们前面也理论分析过了是400多)

我们得到了444的结果。

再来点极端点的,我们跑个 K=3 N=1e18 的数据。

我们得到了1499999999的结果。

我们知道,k分法并不是最优的算法,实际上我们在小数据的分析中已经发现,正解与K分法存在较大的差距。

比如: 同样的 K=3 N=1e8 k分法跑出了14999 而用dp跑出的正解是844

这样的差距,显然会随着N的增大而放大。

我想说明什么呢?既然我们知道,K>2时答案会比 1499999999 小得多,我们不妨大胆猜想:这个答案的大小相当有限。既然f(K,N)的状态多到算不完,那么我们就调整一下思路,不再根据K和N来算猜数的次数F,而根据K和F来推导N的大小

换句话说,我们的dp思路是这样的:

用 n(K,F) 表示通过 进行了F次猜数(其中摔坏了K个蛋),所能确定答案的最大区间长度。

我们知道,K=1时,我们只能一次一次从小到大猜,所以 n(1,F) 总等于 F

对于状态转移方程的推导,论文中描述得非常好懂,我在此处只做简要介绍,顺便解释一下可能有些同学无法理解的为什么是加起来的问题(如果我讲得清楚的话)

我们假设第F次猜的是数字x。若这个数比答案小,则接下来我们需要用余下的K个蛋(没摔坏)去猜x+1开始的往上的区间。于是,我们将问题转化为了用K个蛋猜F-1次的子问题。同理,如果这个蛋摔坏了,我们就将问题转化为了用K个蛋猜F-1次的子问题

我们有 \(n(K,F)=n(K-1,F-1)+1+n(K,F-1)\)

下面解释为什么是两个子问题的解加起来:
若在最坏情形下我们第F次猜的蛋没摔坏,我们知道,那意味这x往下的区间就不需要我们去理会了,而往上的区间长度是有限的 n(K,F-1) ,所以为了达到最大的区间长度,我们需要把x往下的区间长度尽可能设大。但这并不意味着我们就可以为所欲为地把x往下的区间设到无穷大。我们知道,我们所有的讨论的基于这样一个前提:最坏情况下。而用K-1个蛋尝试F-1次最终能确定的区间长度是 n(K-1,F-1) ,如果我们把区间长度设得更长,那么下面区间就需要更多的猜数次数,那么蛋摔坏的情况就会比没摔坏的情况更劣,这就与最坏情形的前提相矛盾。因此,我们有n(K,F)=n(K-1,F-1)+1+n(K,F-1)
摔坏的情况同理,可以分析出相同的结论。

于是乎,我们只需要预先处理出 n(K,F) 之后二分查找 n(K,F)>=N,把最小的F输出来就行了。

然后问题就来了,这个东西怎么算?要算到多大?

答案是,先算了再说
(啊啊啊我居然一本正经得加粗了我在干什么)

论文中对这个复杂度有一个清晰严谨但我太菜了所以看不懂的证明。在此我们采用直接打表硬算的方式确定我们需要的范围。

打表时,我们知道,蛋越多,同样次数下能确定的区间就越大
所以,想知道需要F要开多大,我们其实不需要去试那么大的K
而F开大一点是必要的,但要小心long long可能会溢出的问题,可以在发现N可以取到1e18就停下来。
打表的代码在后面的代码库中。

顺带一提,我有的地方使用了n(F,K) 有的地方用了 n(K,F) 大家不必纠结顺序,只需要知道 K指人数 F指次数 就行了。

经过打表,在我们已经把F定到1e7的情况下,仍然求不出K=2下使 n(F,K)>=1e18 对应的F
而K=3的情况则顺利得求了出来,F=1817121。这个数字的大小尚且还在我们能接受的范围内。
但K=2显然不能了,也就是说,对于K=2和1的情况,我们需要特判

好在,这两种情况都相对简单。

对于K=2的情况:
n(F,2) = n(F-1,2) + n(F-1,1) + 1 = n(F-1,2) + F
换句话说 n(F,2) = 1 + 2 + 3 + ... + F = (1+F)*F/2
F*(F+1)=N*2

到此,问题已经解决(代码在后面的代码库中)。

最后,让我们看看算出来的 K=12 N=1e18 的结果吧: 143

这个数字的大小,是否符合你们的预期呢?

尾声 小小的宣传

建议大家可以去看看那篇论文最后对动态规划的总结。其实,比起学会一道题并把它A掉,总结方法更为重要。

作为一个连noip都没拿过一等的小萌新,我真诚地希望你们能从我这篇博客中收获到什么。

最后宣传一下技术部的博客

里面会陆陆续续的有奇奇怪怪的干货出来,欢迎大家关注。(虽然大概不会是我写的)

番外1 Evil Check——部长的如意算盘

在部员们猜数字 x 的时候,部长是怎么认定,是否该让x小于答案呢?

显然,如果部长也打出了 f(K,N) 的表,那么他只需要根据表选择一个更大的就行了。

这也是最科学、最准确的决策方式。

这里,我们试着给部长针对k分法的决策思路。

(其实我有试着给出更普遍的思路,但是最后失败了)

我们知道,部长的目的是猜数次数最大化或是无法确定区间。

那么当人数只剩一人时,如果部员们犯下了不老老实实一个一个试的错误,就让他们输掉吧。

下面我们试着讨论人数大于1的情形:

假如ta认定x不小于答案,就会有一名部员出局。

在k分法中,区间并不总是能被k整除。

例如,我们在将11分为三份时 每份为11/3=3......2

我们需要判断的两个三等分点为1 5 9 11

划分出的三个区间分别为[1,4] [6,8] [10,11]

不难发现,除了最后一个区间,前面的区间的大小相同。

我们希望k分法的猜数次数尽可能多,自然就希望k分产生的k-1个中间点都被猜一次。

在最后一个k分点判断时,显然的,选择让x大于等于答案更优一些。这样一来,一方面我们取到了倒数第二个的区间,这个区间不会小于最后余下来的那一段区间,另一方面,又使部员们失去了一次扔鹰蛋(猜较大数字)的机会。

typedef long long ll;
bool evil_check(ll l,ll r,ll x,ll c){
    if(c==1) return x!=l;
    return x-l > r-x;
}

番外2 对n(F,K)的感性分析

注意:这一节还没写完,有待完善。

科学严谨的比较分析和复杂度计算请移步论文

在看HIT的题解时,我看到这样一个结论:
\(n(F,K)=\sum^K_{i=1}{F\choose i}\)

\(F\choose i\) 表示 F选i的组合数(应该都看得懂吧)

关于这个结论怎么出来的,题解前面只有一句话:"利用数学归纳法有:"

众所周知数学归纳法是OI中最强大的算法,不信你去看辗转相除法

我想在这里用一种比较感性的方式解读这个公式。
自己画个杨辉三角出来吧,方便理解。
n=0: 1
n=1: 1 1
n=2: 1 2 1
n=3: 1 3 3 1

首先,n(F,K)与C(n,m)也就是n选m的组合数递推公式有相似之处。
公式1:n(F,K) = n(F-1,K) + n(F-1,K-1) + 1 (K>1)
公式2:C(n,m) = C(n-1,m) + C(n-1,m-1)

撇开原有的数不看,当n=1这一层往下统计K>1的点时时多加了1,多出来的这个数造成的变化
n=0: 0
n=1: 0 0
n=2: 0 1 1
n=3: 0 1 2 1

好像有什么不对的(

算了,我爬(

代码库

需要注意一下,ural那道题的k可以到1000,记得特判k比较大的情况真的傻逼。
比如下面这样。

if(k>=log2(n)){ printf("%d\n",log2(n)); continue; }

下面的代码一般默认为K<=63
N的话可以根据复杂度自行选择合适的大小。(毕竟我开了vector)

还有,我的log2(n)是自己写的,因为我觉得直接用函数去算有点太玄学了,还不如自己模拟一遍,反正常数也差不了多少。

下面的代码仅供理解和研究。可能会因为stl的常数问题被卡爆。但是比较清晰和简洁
我把所有的修改过的且ac的代码扔在另一篇文章里,有需要的可以进行参考。

发现代码有误可以在评论区指出,不过我查了挺多遍了,合理没有问题。

顺带一提,我的输入格式不一定按着题目来,你们想提交的话要改一下,都能A的。

不会吧应该不会有人看不懂滚动数组吧不会吧

代码1:k分法 附带适用于k分法的evil_check

#include <cstdio>
#include <cmath>
using namespace std;
typedef long long ll;
bool evil_check(ll l,ll r,ll x,ll c){
    if(c==1) return x!=l;
    return x-l > r-x;
}
ll guess3(ll N,ll K){
	ll l=1,r=N,c=K,cnt=0,block,num,mid;
    while(l<=r){
	    num=floor(pow(r-l+1,1.0/(c-1)));
        block=(r-l+1)/num+1; mid=l;
        while(mid+block<=r){
        	mid+=block; cnt++;
            if(evil_check(l,r,mid,c)){
            	c--; r=mid-1; break;
            }else l=mid+1;
        }
        if(l==mid) break;
    }
    while(l<=r){
		cnt++;
		if(evil_check(l,r,l,c)) break;
		l++;
	}
	//上面这个while可以直接改成 cnt+=(r-l+1);
    return cnt;
}
int main(){
    //注意读入顺序 先人数 后范围
    ll K,N; scanf("%lld%lld",&K,&N);
    printf("%lld\n",guess3(N,K));
    return 0;
}

代码2:无优化的n^3 dp 附带决策过程的打表

#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
typedef long long ll;
#define REG register
#define rep(i,a,b) for(REG int i=a;i<=b;i++)
#define Rep(i,a,b) for(REG int i=a;i>=b;i--)
const int INF=1e9;
int N,K; 
int main(){
    scanf("%d%d",&K,&N);
    vector<int> f[K+1],pre[K+1];
    //resize后vector中元素默认为0
    rep(i,0,K) f[i].resize(N+1),pre[i].resize(N+1);
    rep(i,1,N) f[1][i]=i;
    rep(i,2,K){
        rep(j,1,N){
            f[i][j]=INF;
            rep(w,1,j){
                int now=max(f[i-1][w-1]+1,f[i][j-w]+1);
                if(now<f[i][j]){
                    f[i][j]=now;
                    pre[i][j]=w;
                }
            }
        }
    }
    printf("%d\n",f[K][N]);
	//下面是决策路径的打印
    int x=K,y=N,base=max(K,N)+1,l=1; 
    vector<int> route,L;
    route.push_back(x*(N+1)+y); L.push_back(l);
    while(pre[x][y]){
        REG int w=pre[x][y];
        if(f[x-1][w-1]>f[x][y-w]) x--,y=w-1;
        else y-=w,l+=w;
        L.push_back(l);
        route.push_back(x*base+y);
    }   
    rep(i,0,route.size()-1){
        x=route[i]/base,y=route[i]%base;
        if(y==0) break;
        printf("[%d,%d] with %d people f[%d][%d]=%d\n",L[i],L[i]+y-1,x,x,y,f[x][y]);
    }
    return 0;
}

代码3:二分优化+部分(i,j)直接取二分结论

#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
inline int log2(int x){
	//直接模拟二分过程的最坏情况
    int l=1,r=x,c=0;
    while(l<=r){
        int mid=(l+r)>>1; c++;
        if(r-mid>mid-l) l=mid+1;
        else r=mid-1;
    }
    return c;
}
int main(){
    int k,n; scanf("%d%d",&k,&n);
    vector<int> f,p;
    f.resize(n+1); p.resize(n+1);
    for(int i=1;i<=n;i++) f[i]=i;
    for(int i=2;i<=k;i++){
        f.swap(p); f.resize(n+1); f[1]=1;
        for(int j=2;j<=n;j++){
            //当足以二分时 直接二分
            if(i>=log2(j)){ f[j]=log2(j); continue; }
            //利用二分查找找出交汇点 确定可能的两个位置并比较
            int l=1,r=j,mid,ans=r;
            while(l<=r){
                mid=(l+r)>>1;
                if(p[mid-1]>=f[j-mid]) ans=mid,r=mid-1;
                else l=mid+1;
            }
            f[j]=max(p[ans-1],f[j-ans])+1;
            if(--ans>=1) f[j]=min(f[j],max(p[ans-1],f[j-ans])+1);
        }
    }
    printf("%d\n",f[n]);
    return 0;
}

代码4:O(KN) 线性神仙优化

#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
int main(){
    int k,n; scanf("%d%d",&k,&n);
    vector<int> f,p; f.resize(n+1); 
    for(int i=1;i<=n;i++) f[i]=i;
    for(int i=2;i<=k;i++){
        f.swap(p); f.resize(n+1);
        int pre=0; f[1]=1;
        for(int j=2;j<=n;j++){
        	//f[pre]<f[j-1]且是满足这个条件的最大pre
            if(max(f[pre],p[j-pre-1])+1==f[j-1]) f[j]=f[j-1];
            else f[j]=f[j-1]+1,pre=j-1;
        }
    }
    return printf("%d\n",f[n]),0;
}

代码5 打表以确定需要预处理的 g(K,F) 的范围

#include <cstdio>
typedef long long ll;
const ll F=1e7,K=3,N=1e18;
ll f[F+3][K+1];
int main(){
    for(int i=1;i<=F;i++) f[i][1]=i;
    f[1][1]=f[1][2]=f[1][3]=1;
    for(int i=2;i<=F;i++){
        for(int k=2;k<=K;k++){
            f[i][k]=f[i-1][k]+f[i-1][k-1]+1;
        }
    }
    for(int k=2;k<=K;k++){
        printf("K=%d:",k);
        bool have=0;
        for(int i=1;i<=F;i++){
            if(f[i][k]>=N){ have=1; printf("%d",i); break; }
        }
        if(!have) printf("MAX:%lld",f[F][k]);
        printf("\n");
    }
    return 0;
}

代码6 另一个的dp思路

其实HIT的原题K<=64 不过K>60的情况会因为log2(1e18)=60而被判掉
然而请同学帮忙测的时候还是re了。或许是oj问题。
这里给出一个HIT的大样例供参考:
353529145519311753 5
答案是 8426
我跑的也是 8426 那就当它过了吧(雾

#include <cstdio>
#include <cmath>
using namespace std;
typedef long long ll;
#define REG register
#define rep(i,a,b) for(REG int i=a;i<=b;i++)
#define Rep(i,a,b) for(REG int i=a;i>=b;i--)
inline char getc(){
    static char buf[1<<14],*p1=buf,*p2=buf;
    return p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<14,stdin),p1==p2)?EOF:*p1++;
}
inline ll scan(){
    REG ll x=0; REG char ch=0;
    while(ch<48) ch=getc();
    while(ch>=48) x=x*10+ch-48,ch=getc();
    return x;
}
inline ll log2(ll x){
    ll l=1,r=x,mid,c=0;
    while(l<=r){
        mid=(l+r)>>1; c++;
        if(mid-l>r-mid) r=mid-1;
        else l=mid+1;
    }
    return c;
}
const ll F=2e6,K=60,N=1e18;
ll f[K+1][F],end[K+1];
inline void prework(){
    rep(i,1,F-200) f[1][i]=i;
    rep(i,1,F-200){
        rep(j,2,K){
            if(end[j]) break;
            f[j][i]=f[j][i-1]+f[j-1][i-1]+1;
            if(f[j][i]>=N) end[j]=i;
        }
    } 
}
int main(){
    REG int T=scan();
    prework();
    while(T--){
        REG ll n=scan(),k=scan(),temp;
        if(k==1){ printf("%lld\n",n); continue; }
        if(k==2){
            ll t=floor(sqrt(n<<1));
            if(t*(t+1)<n<<1) t++;
            printf("%lld\n",t); continue;
        }
        temp=log2(n);
        if(k>=temp){ printf("%lld\n",temp); continue; }
        REG int l=1,r=end[k],mid,ans;
        while(l<=r){
            mid=(l+r)>>1;
            if(f[k][mid]>=n) r=mid-1,ans=mid;
            else l=mid+1;
        }
        printf("%d\n",ans);
    }
    return 0;
}

END

posted @ 2020-10-07 13:38  喵乖乖喵  阅读(1121)  评论(0编辑  收藏  举报

膜拜众神

网安院技术部     ZZY大师     Xinyang 大佬     Wjyyy