一道筆試題:撈魚問題
題目:20個桶,每個桶中有10條魚,用網從每個桶中抓魚,每次可以抓住的條數隨機,每個桶只能抓一次,問一共抓到180條的排列有多少種 (也可求概率)。
分析一下
這道題其實可以這樣理解,假設我已經抓到了180條魚,而這180條魚來自20個桶中,反過來就是我們將這180條魚放到20個桶中且要保證每個桶的魚在(0-10)之間,有多少種方法。假設我們在前i個桶中抓取了k(0<=k<=10*i)條魚,那麼抓取180條魚的排列種數就等於在剩下的(20-i)個桶中抓取(180-k)條魚的方法加上前i個桶中抓取k條魚的方法。
再想一下
由於總共有200條魚,我們抓走了180條魚,那麼還剩下多少條呢?顯然是20條。再用上面的思維,我們就可以這樣想,將20條魚放回這20個桶中滿足每個桶中的魚(0-10),有多少种放法。這裡當然是一個排列問題而不是組合問題,因為我第一個桶放1條魚、第2個桶不放與第一個桶不放、第二個桶放1條魚,是兩種的不同的放法。
程式設計思想
思想其實跟完全揹包問題類似,我們首先考慮第20個桶放多少魚,那麼我們就可以減去最後一個桶放的魚數,再去考慮前面19個桶怎麼放剩下的魚。這樣形成了一個遞迴,我們就可以很快寫出如下程式碼。
//bucket,表示當前考慮到第幾個桶,當然首先考慮第20個桶 //fishN,表示當前有多少魚 //返回值表示有多少中方法 int Func(int bucketN,int fishN) { if(bucketN<0 || fishN<0)return 0;//錯誤處理 if(bucketN == 1){ if(fishN>=0 && fishN<=10)//第1個桶裡面放0-10的魚,方法只有一種 return 1; else{ return 0; //其他用0種方法 } } int sum = 0; for(int i=0; i<=10; ++i){ sum += Func(bucketN-1,fishN-i);//考慮前面bucket-1的組合,同時減掉當前放的魚 } return sum; }
遞迴優化
上面的程式碼,其實有很多重複計算。下面我們簡單的分析一下:
F(20,20) = F(19,19)+ F(19,18).......; //考慮第20個桶放一條魚和兩條魚的情況
F(19,19) = F(18,17)+.....;//第19個桶放2條魚 ①
F(19,18) = F(18,17)+.....;//第19個桶放1條魚 ②
我們發現①和②都呼叫了F(18,17),但是它們確實各算各的,這樣就存在著很多類似的重複計算。該遞迴樹,基本全是重複的點,這樣時間複雜度被拖得很高。那麼,我們只要設計一個數組來把計算好的值儲存下來,這樣避免了很多重複的計算。程式碼如下:
//bucket,表示當前考慮到第幾個桶,當然首先考慮第20個桶 //fishN,表示當前有多少魚 //返回值表示有多少中方法 int dp[21][200] = {0}; int FuncOptimize(int bucketN,int fishN) { if(bucketN<0 || fishN<0)return 0; if(bucketN == 1){ if(fishN>=0 && fishN<=10) return 1; else{ return 0; } } if(dp[bucketN][fishN] == 0){ //這個子過程沒有被計算我們採取呼叫遞迴計算 for(int i=0; i<=10; ++i){ dp[bucketN][fishN] += FuncOptimize(bucketN-1,fishN-i); } } return dp[bucketN][fishN]; }
下面我們用我們熟悉的斐波拉契序列做一個實驗,我們同樣的優化方式來做,看下程式跑得快了多少?
#include<iostream>
#include <windows.h>
using namespace std;
long long fibonacci[100];
long long Fibonacci(int n)
{
if(n == 0)return 1;
if(n == 1)return 1;
return Fibonacci(n-1) + Fibonacci(n-2);
}
long long FibonacciOptimize(int n)
{
if(n == 0)return 1;
if(n == 1)return 1;
if(fibonacci[n] == 0){
fibonacci[n] = FibonacciOptimize(n-1) + FibonacciOptimize(n-2);
}
return fibonacci[n];
}
int main()
{
DWORD seconds = GetTickCount();
cout<<Fibonacci(40)<<endl;
cout<<"優化之前:"<<(GetTickCount() - seconds)<<"ms"<<endl;
seconds = GetTickCount();
cout<<FibonacciOptimize(40)<<endl;
cout<<"優化之後:"<<(GetTickCount() - seconds)<<"ms"<<endl;
system("pause");
return 0;
}
這個結果很是滿意啊。
動態規劃實現
我們知道這種自頂向下的遞迴,都可以轉換為動態規劃自底向上的思想。就是我們遞迴的值是有下面的值一步步壘起來的,那麼我只需要一開始就把下面的值算好,保持在那裡,然後再算上面的時候,直接拿過來用,這就很快了。據我瞭解,揹包問題的程式設計思路跟這道題是一摸一樣,只不過揹包走到第i種物品的時候,第i種物品的重量已經固定,而我們這道題是第i個桶取值是一個範圍,那我們只要把這裡範圍都掃一遍就好了。
#include<iostream>
#include <windows.h>
using namespace std;
int dp[21][200] = {0};
int FuncDp(int bucketN,int fishN)
{
int i,j,k;
for(i=0; i<=10; ++i)
dp[1][i] = 1;//初始值要弄好,不然後面就廢了
for(i=2; i<=bucketN; ++i){
for(j=0; j<=fishN; ++j){
for(k=0; k<=10&&j-k>=0; ++k){//可以取0-10
dp[i][j] += dp[i-1][j-k];
}
}
}
return dp[bucketN][fishN];
}
int main()
{
cout<<FuncDp(20,180)<<endl;
memset(dp,0,sizeof(dp));
cout<<FuncDp(20,20)<<endl;
system("pause");
return 0;
}
其實,程式碼還可以更簡練,仔細想想,就是初始化狀態的方法;其實初始化合法狀態完全可以這樣想,問題始終都是分解成子問題的,根據遞迴的實現方法,只有分解到0個桶裝0條魚才是合法的,那麼我們就初始化這一個狀態為合法即可,然後從第一個桶開始向上計算,程式碼如下:
#include <iostream>
using namespace std;
int dp[21][200];
int i, j, k;
void main()
{
int bucketN, fishN;
scanf("%d %d", &bucketN, &fishN);
dp[0][0] = 1; /* 初始化合法狀態 */
for(int i = 1; i <= bucketN; ++i) /* 從第一個桶開始 */
{
for(int j = 0; j <= fishN; ++j)
{
for(int k = 0; k <= 10 && j-k >= 0; ++k)
{
dp[i][j] += dp[i-1][j-k];
}
}
}
printf("%d\n",dp[bucketN][fishN]);
}
想不通我的空間優化怎麼不成功,類似揹包問題的優化,555555555555555555。有大神可以幫忙解釋一下啊?
#include<iostream>
#include <windows.h>
using namespace std;
int dp[200] = {0};
int FuncDp(int bucketN,int fishN)
{
int i,j,k;
for(i=0; i<=10; ++i)
dp[i] = 1;
for(i=2; i<=bucketN; ++i){
for(j=fishN; j>=0; --j){//反著遍歷,防止覆蓋
for(k=0; k<=10&&j-k>=0; ++k){//可以取0-10
dp[j] += dp[j-k];
}
}
}
return dp[fishN];
}
int main()
{
cout<<FuncDp(20,180)<<endl;
memset(dp,0,sizeof(dp));
cout<<FuncDp(20,20)<<endl;
system("pause");
return 0;
}