1. 程式人生 > >#10019. 「一本通 1.3 例 2」生日蛋糕

#10019. 「一本通 1.3 例 2」生日蛋糕

 

冥想盆

 剪枝

 深搜的優化技巧

小小的總結

           理解本題的思路

 感性證明一下最優性剪枝

         【程式碼實現1:最慢最好理解(自己打的):100多ms】

         【程式碼實現2:次慢:30多ms】

         【程式碼實現3:最快:10多ms】

最後放上幾個大佬的部落格


【題目描述】

Mr.W 要製作一個體積為 Nπ 的 M 層生日蛋糕,每層都是一個圓柱體。 設從下往上數第 i 蛋糕是半徑為 Ri​,高度為 Hi​ 的圓柱。當 i<M時,要求 Ri​>Ri+1​且 Hi​>Hi+1​。由於要在蛋糕上抹奶油,為儘可能節約經費,我們希望蛋糕外表面(最下一層的下底面除外)的面積 Q最小。 令 Q =Sπ ,請程式設計對給出的 N 和 M ,找出蛋糕的製作方案(適當的 Ri​ 和 Hi​ 的值),使 S 最小。

(除 Q 外,以上所有資料皆為正整數)

【輸入格式】

 

第一行為 N ,表示待制作的蛋糕的體積為 Nπ;

第二行為 M ,表示蛋糕的層數為 M 。

【輸出格式】

輸出僅一行,一個整數 S(若無解則 S=0 )。

【樣例輸入】

100
2

【樣例輸出】

68

【附:圓柱相關公式:】

體積 V=\pi R^{2}H

側面積 S{}'=2\pi Rh

底面積 S=\pi R^{2}

【資料範圍與提示】

對於全部資料,1≤N≤10^4,1≤M≤20。

首先分為四大部分:

1.感性理解深搜和剪枝

2.理解本題思路

3.著重理解程式碼的優化性

4.最後附上優秀的大佬部落格


首先剪枝:

1.什麼是“剪枝”

顧名思義,剪枝就是剪掉一下耗時的可以不要的東西,

舉個形象的例子吧:一棵樹,按照常理每一年都要剪掉一些枝條,為什麼呢?因為要給主體部分留足夠的營養,把這些浪費營養的枝條都剪掉,這就有了剪枝。同樣的,在c++當中,剪枝就是為了把那些浪費時間,可以跳過列舉的給去掉,從而做到最快最簡最優。

2.剪枝的三大原則

(1).正確性

這是最重要的,為什麼呢?我們剪枝是為了程式碼的優化性,如果剪枝出現了錯誤,把該留下的全部刪掉了,那麼這個剪枝再怎麼高階都沒有用了。(正確性為剪枝的前提)

(2).準確性

可能會有疑問,準確和正確有怎麼區別?正確是指剪掉的枝是該剪的,準確性就是為了優化程式碼而儘可能的剪去不能同行正解的枝條,剪枝有了較高的準確性之後才能更快更準更優。(準確性為剪枝的核心)

(3).高效性

高效指的就是:改善判斷的準確性外,經常還需要提高判斷操作本身的時間效率。但是你想一下,如果我們改善了剪枝判斷的準確性,就不得不提高判斷操作的複雜度,這也就同時降低了剪枝判斷的時間效率。所以高效性就是我們剪枝最困難的一步,就是要解決這個矛盾,從而是程式碼變得高階。(高效性為剪枝的保障)

 其次深搜的優化技巧

 1.優化搜尋順序

我們在搜尋當中,難免會出現一些樹的各個層次、各個分支之間的順序不是固定的,而且也會有不同的形態,這個坑坑窪窪就會影響我們的搜尋,就想一條路,如果全都是坑,那麼必然會影響我們奔跑的速度,所以我們就要優化搜尋順序,使這棵樹的一些大層次可以是相似的,這樣就可以大大的優化我們的搜尋速度。(優化搜尋順序是技巧的前提,因為我們只有把路走直了,才可以進行更多的優化)

2.排除等效多餘

這個就很好理解了,就是說如果我們在搜尋的過程中,發現這棵樹有好幾個分枝都是等效的(相同的),那麼這個時候我們就只需要對其中的一條分枝執行搜尋。(排除等效多餘是技巧的偷懶,這一步可以大大減少時間)

3.可行性剪枝

其實就是一個靈活性的問題,為什麼這麼說呢?因為如果我們在搜尋的過程中,發現當前的這個分枝根本到不了我們遞迴的正規,也就是說走到了死衚衕,這個時候就要立即折返繞路,而不是走到路的盡頭發現是死衚衕才返回。

某些題目條件的範圍限制是一個區間,這時可行性剪枝也被稱為“上下界剪枝”(可行性剪枝是技巧的核心,為什麼呢?因為如果每一次都算到盡頭那一定超時,所以這個就是核心)

4.最優性剪枝

這個就更加好理解了,就是說如果我們後面搜尋到的比我們之前記錄過的最小值還要大的話,就停止搜尋,執行回溯。(最優性剪枝是技巧的保障,因為往往搜尋到後面就會越來越麻煩,所以這個最優性可以讓我們最快的確定最優解)

5.記憶化

記錄每個狀態的搜尋結果,在重複遍歷搜尋的時候直接檢索返回值。就好比我們在對圖進行深搜的時候,標記一個節點是否已經被訪問過。同時因為儲存了狀態,所以我們呼叫最優性剪枝的時候就會大大節省時間,這就是記憶化可以讓一個原本沒有記憶化是100多ms的程式碼變成一個10幾ms的程式碼。(記憶化是技巧的高階,用好陣列,靈活定義陣列的初始狀態)

最後來一個小小的總結吧

1.幾乎所有的搜尋都會用到可行性剪枝

2.在尋找極值的時候,優先考慮最優性剪枝和記憶化,因為沒有最優化剪枝,十有八九都是超時的

3.部分搜尋要回溯,其實就是我們自己呼叫一個值的時候要標記使用,然後用完之後要歸零

4.有個小小的東西,dfs搜尋的函數千萬不要擔心定義的數太多,往往在一開始的時候定義多一點也沒關係,因為等你找到了正解之後可以進行技巧的修改,使得程式碼更加的簡單快速,做到三大原則 

 接下來,理解本題的思路

不得不承認,我覺得最麻煩的就是這個半徑和高的最小值,這個就要理解好題目。題目中說\boldsymbol{\mathbf{}\mathbf{R_{i}>R_{i+1}}}半徑如此,高也是如此\mathbf{H_{i}>H_{i+1}},,這意味著什麼?意味著底下每一層的半徑和高度至少比上一層多1,也就是說,最底下那一層的半徑和高最小為m。

有了這一步,我們的半徑和高的規律已經心中有數了,也知道應該怎樣了,那麼我們在主函式中的初始化應該是怎樣的呢?

1.for(int i=m;i*i*m<=n;i++)
//這個i表示的是半徑的範圍

2.for(int j=m;i*i*j<=n;j++)
//這個j表示的是高的範圍

3.if(i*i+2*i*j<minn)
//這一步表示的是隻有我們在列舉到這個表面積小於我們之前記錄過的才可以繼續

4.dfs(1,i*i*j,i*i+2*i*j,i,j)
//進入遞迴函式,
/*
1.從前1層開始
2.體積為i*i*j
3.表面積為2*i*j
4.i表示半徑
5.j表示高
*/ 

 萬事開頭難,慢慢理解題目就好了

我都以這個最慢的程式碼為基準,因為我最熟悉這個,然後其他的幾個程式碼,把最慢的理解清楚之後,理解起來很簡單,而且我都註釋的很清楚了,所以不用慌。

接下來進入遞迴當中,遞迴當中最重要的莫過於剪枝了(我會放出三個程式碼,分別是最慢最好理解,次慢次理解,最快難理解),這三個程式碼剪枝的方法不一樣,但始終是三個方面

1.前d層的體積加上後面體積的最小值還大於n(題目給出的體積的限制)就剪枝

2.前d層的體積加上後面體積的最大值還小於n,就剪枝

3.前d層的表面積加上後面的表面積大於我們記錄過的最優解,剪枝(沒有這一步,再多的剪枝都會超時,這個是最重要的剪枝也是最難的剪枝,而且最坑的是,就算你用陣列來記錄也沒用,這是一條公式,一條極難想到的公式)

接下來我們一個一個理解啊

1.體積剪枝(最大值不足以滿足題目要求)
if(v+(r-1)*(r-1)*(h-1)*(m-d)<n)return;
/*
	1.如果當前體積加上之後每層的最大值,還比題目要求的體積小,直接結束該趟遞迴
	2.我在主函式當中講了我們的搜尋是自下而上,所以下面一層的高和半徑都比上面一層要大
	3.為什麼說是最大值呢?因為我們選取的是當前上一層的半徑,而且越往上越小
	而我們乘以的同樣也是那麼多層,所以說這個是最大值
*/ 

2.體積剪枝(最小值超過題目要求)
if(v+m-d>n)return;  
/*
	1.如果當前體積加上之後每層的最小值,還比題目要求的體積大,直接結束該趟遞迴
	2.為什麼說是最小值呢?因為我們的上一層的高和半徑是比下面的那一層要小的,也就是最頂上的
	可能半徑就為1,是最小的,既然是最小的說明下面的就比他要大,但是我們就直接
	最小半徑的平方1*1*(m-d)也就是層數,那麼可能會疑惑高去哪裡了?
	高也是假設了最小的1,所以整個半徑平方乘以高乘以層數=1*1*1*(m-d)=m-d
*/

3.最優性剪枝(剪掉超過最優解的)
if(2*(n-v)/r+s>=minn)return;
不要著急這個我要慢慢給你們證明

我們來感性證明一下最優性剪枝啊

 首先我們分成幾部分

第一部分:n-v

第二部分:minn

第三部分:除以r乘以2

第四部分:全部一起

                                                                                                                                                第一部分:v

v=(h[1]*r[1]*r[1]+h[2]*r[2]*r[2]+......+h[d]*r[d]*h[d])

無論如何這個都是可以理解的,v代表前d層的體積,然後我們就是把這個蛋糕一層一層的體積相加就等於總體積

那麼n-v=n-(h[1]*r[1]*r[1]+h[2]*r[2]*r[2]+......+h[d]*r[d]*r[d])

這個就是剩下部分的體積,可以理解的吧

第二部分:minn

過了很久我終於又開始完善這個部落格了。

這個minn可能是比較難理解的,那麼我們一步步來

v=(h[1]*r[1]*r[1]+h[2]*r[2]*r[2]+......+h[d]*r[d]*h[d])這個的值是小於 

h[1]*r[1]*r+h[2]*r[2]*r+......+h[d]*r[d]*r 為什麼呢?因為這個r是我們記錄的最開始的這個最底層的這個的半徑,這個半徑是所有半徑當中最大的,所以每一次都這麼計算的值是最大的,那麼我們不難想到,既然原本是要小於的,只要這個值大於的話,是不是就可以剪枝?那麼我們把這個式子所有的 r 提出來合併,就變成了 (h[1]*r[1]+h[2]*r[2]+......+h[d]*r[d])*r 

第三部分:除以r乘以2

這樣看可能沒有感覺,我們只看中間的那一串,h[1]*r[1]一直加到h[d]*r[d],然而我們的這個側面積的公式就是2hd,所以我們就要進行一個小修改,變成\frac{(h[1]*r[1]+h[2]*r[2]+......+h[d]*r[d])*r }{r}*2= 2*(h[1]*r[1]+h[2]*r[2]+......+h[d]*r[d]),這個就是我們之前辛辛苦苦記錄過的前d層的表面積,也就是我們所更新過的minn值,就是到當前為止記錄過的最小的表面積的值

第四部分:全部一起

好,我在上面說了如果我們找到的這個v=(h[1]*r[1]*r[1]+h[2]*r[2]*r[2]+......+h[d]*r[d]*h[d])大於h[1]*r[1]*r+h[2]*r[2]*r+......+h[d]*r[d]*r就要剪枝的對吧,那麼我們也說了上面的小修改,我們先列出一個原不等式啊:

h[1]*r[1]*r[1]+h[2]*r[2]*r[2]+......+h[d]*r[d]*r[d]>h[1]*r[1]*r+h[2]*r[2]*r+......+h[d]*r[d]*r這個是我們的剪枝條件,但是因為這個的值就是我們之間已經找到了的v的值,所以我們直接用v來代替,然後剩下了就是 n-v=n-(h[1]*r[1]*r[1]+h[2]*r[2]*r[2]+......+h[d]*r[d]*r[d])是吧,這個時候我們就要把這個和之前我們修改過的放在一個,兩邊同時除以這個r乘以2就變成了2*\frac{n-v}{r}+s>minn,這個是最終形式,s其實就是我們前d層的體積所對應的表面積,那麼這個2*\frac{n-v}{r}是什麼意思呢?就是說這個n-v=h*r*r這個是任何一個體積的共同形式,那麼我們除以了r再乘以2之後就變成了什麼呢,變成了2*h*r就是我們熟悉的側面積,這個代表的是我們剩餘體積的側面積,加上我們之前的這個s就是全部的表面積,這個的計算結果是不能大於我們之前記錄過的這個minn的值,所以的話,2*\frac{n-v}{r}+s>minn這個就成為了我們最重要的剪枝了

所有的剪枝我都講清楚了對吧,接下來就看程式碼實現吧 

【程式碼實現1:最慢最好理解(自己打的):100多ms】

 

/*
題目解釋:
題目的意思:有一個體積為Nπ的M層蛋糕,底下每一層的高度和半徑至少比上一層的大1,
也就是說,最底下一次的半徑和高度至少為M了。(關鍵關鍵) 
求解符合題意,又要求其表面積要最小的蛋糕,求出其表面積最小值為多少。
依次遞迴用DPS去尋找符合體積大小的資料,求解找到的最小值便是答案 
*/
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;      
int n,m,minn=2e+9; //min表為面積最小的值,初始化為一個極大值
void dfs(int d,int v,int s,int r,int h)
//前d層蛋糕體積為v 表面積為s 第d層半徑r 高度h 
{
    if(d==m)//前d層就是m層,表示我們已經搜尋完了    
     {
        if(v==n)minn=s;//如果體積也符合題目要求的話,更新表面積的最小值 
        return;//返回值 
     }
    if(v+(r-1)*(r-1)*(h-1)*(m-d)<n)return;
	/*
	1.如果當前體積加上之後每層的最大值,還比題目要求的體積小,直接結束該趟遞迴
	2.我在主函式當中講了我們的搜尋是自下而上,所以下面一層比上面一層要小
	3.為什麼說是最大值呢?因為我們選取的是當前上一層的半徑,而且越往上越小
	而我們乘以的同樣也是那麼多層,所以說這個是最大值
	*/ 
    if(v+m-d>n)return;           
	/*
	1.如果當前體積加上之後每層的最小值,還比題目要求的體積大,直接結束該趟遞迴
	2.為什麼說是最小值呢?因為我們的上一層是比下面的那一層要小的,也就是最頂上的
	可能半徑就為1,是最小的,既然是最小的說明下面的就比他要大,但是我們就直接
	最小半徑的平方1*1*(m-d)也就是層數,那麼可能會疑惑高去哪裡了?
	高也是假設了最小的1,所以整個半徑平方乘以高乘以層數=1*1*1*(m-d)=m-d
	*/
    if(2*(n-v)/r+s>=minn)return;  
	/*
	如果求解過程半途找到比當前最小值,也就是minn的值還大的資料,結束該趟遞迴
	這一步是最關鍵的一步但是我不知道現在在這裡怎麼表示出來 
	*/ 
    for(int i=r-1;i>=m-d;i--)
	/*
	i(半徑)[再上一層的半徑]的最小值要保證大於當前這一層半徑的最小值
	題目解釋當中說的至少要大1,也就是說上一層的半徑最大是當前這一層的半徑-1
	也就是r-1
	最小的話就是也要大於等於剩下的層數,不然後面的層就沒有整數半徑
	比如說:
	總共有5層,當前是第3層(順數),第3層的半徑是5,
	那麼第2層的半徑最大就是4,最小的話就是(5-3)=2
	因為只有大於等於2的時候,這一層的上一層才有半徑,
	如果第2層的半徑是1的話,那麼至少要大於等於1,也就是第1層的半徑小於等於0
	這個顯然是不可能的 
	*/
    {
        for(int j=h-1;j>=m-d;j--)
		/*
		j(高度)[再上一層的高度]的最小值要保證大於當前這一層高度的最小值
		跟半徑是同樣的道理,這裡就不再解釋了 
		*/ 
        {
            if((i*i*j+v<=n)&&(s+2*i*j<minn))
            /*如果我們後面找到的這個半徑和高度組合的體積小於等於n*/
            /*並且它的面積比我們之前記錄過的要小的話*/
            	dfs(d+1,v+i*i*j,s+2*i*j,i,j);/*遞迴搜尋子狀態,也就是處理下一個*/ 
        }
    }
}
int main()
{
     scanf("%d%d",&n,&m);
     for(int i=m;i*i*m<=n;i++)
	 /*
	 1.i表示半徑,半徑的平方(也就是底面積)*層數(也就是體積)
	 小於要求的體積的話,可以繼續
	 2.i從m開始是因為,底下每一層的高度和半徑至少比上一層的大1,
	 也就是說,最底下一次的半徑和高度至少為M了
	 */
     {
        for(int j=m;i*i*j<=n;j++)
        /*
		1.j表示高度,高度乘以底面積小於要求體積,可以繼續
		2.i從m開始是因為,底下每一層的高度和半徑至少比上一層的大1,
	    也就是說,最底下一次的半徑和高度至少為M了 
		*/ 
        {
            if(i*i+2*i*j<minn)/*小於我們一直更新的最小值,才傳入*/ 
            dfs(1,i*i*j,i*i+2*i*j,i,j);
			/*
			從第m層開始,我們之前的列舉是自下而上,所以在搜尋中也是自下而上
			注意:(分開五個來分析) 
			(1)從前1層開始 
			(2)體積是半徑*半徑*高 
			(3)表面積不單單是側面積,最底下那一層的表面積 
			(4)i表示半徑
			(5)j表示高度 
			*/ 
        }
    }
    printf("%d\n",minn);/*輸出最小值*/
    return 0;
}
/*
體積V=πR*R*H
側面積A’=2*π*R*H
底面積A=π*R*R
*/

這個程式碼為什麼會最慢了,因為我們沒有用陣列,也就是說,沒有我們之前的這個記憶化剪枝技巧

【程式碼實現2:次慢:30多ms】

#include<cstdio>
#include<cstring>
#include<algorithm> 
#define oo 1000000000
using namespace std;
int ans;
int minn[20];//儲存體積 
int n,m;
void dfs(int k,int r,int h,int s,int v)
//k:當前層數 r:當前層的半徑 h:高度 s:表面積 v:剩下的體積 
{
    if (s+2*v/r>ans) return;
	//我們知道剩餘的體積,能不能根據體積,估算一個剩餘的側面積,
	//如果( 當前的表面積+餘下的側面積的最小值)
	//比最優值還大,那麼當前層的搜尋就沒有意義。
    if (v-minn[m-k]<0) return;
	//如果剩餘的體積減去後面要用的體積小於0的話,說明不夠體積做一個蛋糕 
    if (k==m)//邊界情況 
    {
        if(v==0) if(s<ans) ans=s;
        return;
    }
    for(int tr=r-1;tr>=m-k;tr--)
    for(int th=h-1;th>=m-k;th--)//不斷縮小半徑和高度 進行列舉(跟那個的意思是一樣的) 
    {
        int ts,tv;
        ts=s+2*tr*th;//後面的表面積 
        tv=v-tr*tr*th;//剩下的體積 
        dfs(k+1,tr,th,ts,tv);//搜尋下一個 
    }
}
int main()
{
    scanf("%d%d",&n,&m);
    ans=oo;
    int j=1;//最小的高為只能為1 
    for(int i=1;i<=m;i++)//預處理一個數組將最小的存起來 等會剪枝 
    {
        minn[i]+=i*i*j;//記錄體積 
        j++;//高會增加,題目中說了 
    }
    for(int r=m;r*r*m<=n;r++)//因為半徑是越來越小的 所以r的大致範圍可以確定 
    for(int h=n/(r*r);h>=m;h--)//高度的大致範圍也可以確定 
    {
        int s,v;//表面積和剩下的體積 
        s=r*r+2*r*h;//第一層的側面積+總頂面積(可以通過平移使所有頂面積拼成第一層的頂面積) 
        v=n-r*r*h;
        dfs(1,r,h,s,v);
    } 
    if(ans==oo) ans=0;//不可能這麼大,所以就是沒有體積炸掉了 
    printf("%d\n",ans);
    return 0;
}

這個用了記憶化,但是仍然慢的主要原因應該是這個程式碼的很多表達方式不是最簡單,讓電腦執行次數最少最方便的

【程式碼實現3:最快:10多ms】

#include<cmath>
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
int n,m,minv[21],mins[21];  
                            //V=n*pi  m 層數 自頂向下1.2.3...m  
                            //minv[i]表示i層到0層加起來的最小總體積 minvs 最小表面積
const int inf=1000000000;   // inf 足夠大就可以 int(32)  -2^31~2^31-1=2147483647  
int best=inf;               //best 最小表面積
void dfs(int depth,int sumv,int sums,int r,int h)//深度優先搜尋 自底m向上搜尋 
//depth表示剩餘層數r h表示當前層得半徑和高度 sumv已經用的總體積 sums已經生成的總表面積
{
    if(depth==0)
    {
        if(sumv==n&&sums<best)//搜尋完成 更新最小表面積best
        {
            best=sums;
        }
        return;
    }
    // 三個剪枝條件:
    //1、已經搜尋過的體積加上還未搜尋過的最小體積不能比總體積n 大
    //2、已經搜尋過的表面積加上還未搜尋過的最小表面積不能比之前的最小總表面積best大 
    //3、n-sumv既所剩體積記作dv 還需要的表面積為s
    //s=2*ri*hi+2*r(i-1)*h(i-1)+... >=2*ri*hi*ri/r+2*r(i-1)*h(i-1)*r(i-1)/r+...
    //                                =2*dv/r(i從depth-1取,r為當前半徑 ri/r<1)
    // 所以得到還需要的最小表面積s=2*(n-sumv)/r,
	//如果最小的s和已經搜尋過的表面積sums依然比best大 就不用繼續搜尋了
    if(sumv+minv[depth-1]>n||sums+mins[depth-1]>best)
	//剪枝如上所述
        return;
    for( int i=r-1;i>=depth;i--)
	//遞減順序列舉depth層半徑的每一個可能值,這裡第depth層的半徑最小值為depth
    {
        if(depth==m)sums=i*i;      
		//俯視蛋糕底面積作為外表面積的初始值(總的上表面積,以後只需計算側面積)
        int maxh=min((n-sumv-minv[depth-1])/(i*i),h-1);  
		//maxh最大高度,即depth層蛋糕高度的上限,
		//(n-sumv-minv[dep-1])表示第depth層最大的體積
        for(int j=maxh;j>=depth;j--)  //同理,第depth層的最小高度值為depth
        {
            dfs(depth-1,sumv+i*i*j,sums+2*i*j,i,j);//遞迴搜尋子狀態
        }
    }
}
int main()
{
    scanf("%d%d",&n,&m); 
    int rmax=sqrt(n); //rmax初始半徑 底層半徑 最大值為sqrt(n)
    int hmax=n;                    //hmax初始高度 高度最大為 n
    minv[0]=mins[0]=0;
    for(int i=1;i<=m;i++)//初始化minv和mins陣列
    {                              
        minv[i]=minv[i-1]+i*i*i;   //從頂層(即第1層)到第i層的最小體積
								   //minv[i]成立時第j層的半徑和高度都為j
        mins[i]=mins[i-1]+2*i*i;
    }
    dfs(m,0,0,rmax,hmax);
    //dfs(m,0,0,n+1,n+1);
    if(best==inf)best=0;  //無解
    if(best==0)printf("0\n"); 
    else printf("%d\n",best); 
    return 0;
}

最快的,用了記憶化,而且所有的表達方式也是為了後面更方便而定義的

最後放上幾個大佬的部落格,感謝這些大佬

部落格1  部落格2 部落格3 部落格4 部落格5 部落格6