傳送門:HDU 6787 Chess
Problem Description
你現在有一個棋盤,上面有 n 個格子,格子從左往右,1,…,n 進行標號。你可以在棋盤上放置恰好 m 個傳送器,並且對於每個傳送器設定傳送位置。傳送位置需滿足:對於在 i 號格子上的傳送器,傳送目標位置 j 滿足 j<i。 1 號格子不能放置傳送器。
現在有一名玩家,拿著一枚 1,…,11 的骰子(骰子每次等概率地投出 1 到 11 中的一個數字),從位置 1開始,用骰子決定前進步數,即如果當前在位置 y 且投出數字 x,那棋子將會跳到位置 y+x。如果棋子跳到棋盤外的位置(x+y>n),玩家失敗。如果位置
y+x上恰好有個傳送器,那玩家的棋子立刻會被傳送到傳送器目標位置。如果傳送器目標位置有另一個傳送器,玩家的棋子會沿著另一個傳送器繼續傳送。這個過程持續若干次,直到目標位置沒有傳送器為止。如果玩家棋子可以到達點 n 且不被傳送到別的地方(意味著位置 n 沒有傳送器),則玩家獲勝。
問現在有多少種不同的放置傳送器的方法,使得玩家有可能獲勝。如果擺放傳送器的格子不同或者某個格子上的傳送器傳送的位置不同則視為不同的方案。
每個格子最多放置一個傳送器。
Input
第一行一個正整數 test (1≤test≤20) 表示資料組數。對於每組資料,第一行兩個整數 n,m (1≤n≤1000,0≤m≤1000) 表示格點數和傳送器數。
Output
對於每組資料,一行一個整數表示答案模 109+7。如果無解(沒有任何獲勝的方案),輸出 −1。
提醒一下:骰子 的讀音:tóuzi
1. 文獻綜述
主流方法有兩種我將其分別命名為添塊法與擴充套件法。(當然本質都是dp,名字只是個記號不必過分糾結)
添塊法:
【starve_to_death】 博文一,文章是言簡意賅的,但程式碼與表述有所出入,看時要注意。(後面也會提到這篇文章)
【stduy_ing】博文二,程式碼是清楚簡潔的。
擴充套件法:
【二分抄程式碼】博文三 ,表述基本上也是清楚的。
【矮矮的夏祭】博文四,一開始我沒看他的公式,但後來新增了一些解釋後就能看懂了,公式的表述上我認為可以再簡化一點。
以上博文都是可以一讀的,同時也對他們表示感謝,因為我一開始是不會這道題的,看了他們題解之後才懂的。
2. 前言
因為這題不是我獨立做出來的,所以我不能保證這道題應該按照本文所說的去思考。我只是把我認為的可能的分析和解決問題的思路表述出來。主要是我先講了一個塞塊法,而這種方法是無法求解出問題的,怕把人帶跑偏了,所以還請帶上批判性的眼光去看待本文。有哪些地方說的不恰當的話,歡迎批評指正。
2. 特殊含義名詞宣告
- 塊:一個位置是一個塊
- 白塊: 沒有放傳送器的位置
- 排列/序列可行: 能獲勝
3. 整體分析
傳送器只能讓人往回走,且傳送完一定落在一個白塊上。刨去傳送的過程,其實就是從一個白塊到另一個白塊。我們每次最多往前走11格,如果兩個白塊之前間隔大於11,那我們就無法再往前了,也就無法獲勝了。所以我們現在有:
獲勝\(\Rightarrow\)任意兩個白塊之間距離\(\leq\) 11
- 那傳送器傳送到哪呢?
事實上傳送到哪都行,因為無論怎麼傳送我們都能停在一個白塊上,我們也只能通過這些白塊向前走。
所以對於傳送器傳到哪我們是沒有限制的(合法就行)。
而如果任意兩個白塊之前距離\(\leq\) 11,那麼我們就一定能由一個白塊直接跳到另一個白塊的位置,也就能一直往前走,只要最後一個位置是白塊,我們一定能獲勝。
那麼我們現在有:
獲勝\(\Leftrightarrow\)任意兩個白塊之前距離\(\leq\) 11 且最後一個位置是白塊
白塊之間的是傳送器,所以 “任意兩個白塊之前距離\(\leq\) 11”等價於:連續的傳送器個數\(\leq\) 10
分析到這裡我們就知道這題想讓我們幹什麼了,就是讓我們排排m個傳送器,保證上述白塊的位置的要求下,每種排列的傳送器的目標位置可以任意取,累乘一下得到這種排列對應的方案數,最後再把所有排列累加一下得到答案。
上述是我們容易想到的方法,好想但是貌似不太好做啊,如果就幾個位置,在紙上畫畫還行,位置一多就不知道要怎麼處理了。
4. 先來幾個簡單的例子感受一下
n總塊數,m傳送器個數,r[n][m]為對應的方案數
- n=1 m=0
就一個塊,就一種方案,r[1][0]=1 - n=2 m=0
只能兩個白塊,還是隻有一種方案,r[2][0]=1 - n=3
- m=0
r[3][0]=1 - m=1
第2個位置傳送器,第3個位置白塊 r[3][1]=1
- m=0
- n=4
- m=0
r[4][0]=1 - m=1
第2個位置傳送器 : 1
第3個位置傳送器 : 2
r[4][1]=1+2=3 - m=2
第2,3 位置傳送器 : 1*2
r[4][2]=2
- m=0
我們試著去找一下每步之間的聯絡
5. 塞塊法
直接分析n個位置m個傳送器不好分析,我們可以嘗試從n和m都比較小的情況一點點地擴大,遞推。
由一個可行的的排列到一個元素更多的可行的排列,我們可以往裡面塞塊,只要保證塞完還能獲勝就行。
現考慮往裡塞一個白塊,也就是隻增加n,而不增加m。因為是白塊所以也不用擔心太多連在一起,所以可以隨便塞,我們考慮塞完了之後對傳送器的影響:由於多了一個位置,傳送器最右可以到的地方,便向右擴充套件了一塊。比如n=3 m=1,傳送器只能在位置2。而n=4 m=1,傳送器既可以在位置2,也可以在位置3.對於這個新位置:n-1,既可以放傳送器也可以不放。
- 如果不放,那就相當於只是在(n-1,m)的後面加了一個白塊其他全都不變,方案數:r[n-1][m]
- 如果放,那我們得從左邊挑一個傳送器到:n-1,這個時候要注意,放完之後可能就會有多於10個傳送器連在一起。即使你知道放那肯定不會讓連續的傳送器多於10個,那n-2位置是傳送器還是白塊也是不知道的,一個可行的排列要求最後一個塊是白塊,也就是說我們不能確定前n-2個塊是不是一個可行的排列。於是我們便無法和之前我們分析過的可行的排列建立聯絡。
現在阻礙我們的是:我們不知道把傳送器放到一個位置會不會使連續的傳送器多於10個。這個時候,我們就得考慮多記錄一些資訊了比如:某個位置附近有多少連續的傳送器。
我們還沒考慮塞傳送器的情況,便發覺這種方法行不通了,最起碼維護的資訊是不足。當然你也可以直接考慮塞傳送器的情況,同樣也會發現,為了保證我塞完還可行,我需要多維護一些資訊。
現在我們知道了我們需要維護傳送器的位置資訊,這個位置是所有位置嗎?好像是的。那不成了維護傳送器的排列情況了嗎?彷彿又回到了我們一開頭想的那個可想而不可及的方法。可能的排列的個數雖然數量級比較大但我們可以算,但具體怎麼排,就不容易知道了。
這時我們可以想:我們一定得由一個可行的序列直接蹦到一個更大的可行的序列嗎?
6. 添塊法
想擴充套件序列除了塞,我們還可以直接在末尾添啊。
在擴充套件過程中我們可以允許這個序列暫時是不可行的,但得是可行序列的一部分。用c表示傳送門,b表示白塊。
bcc 雖然不可行,但卻是bccb的一部分,我們可以由bcc擴充套件到bccb。
6.1 添傳送器
現在我們嘗試往末尾填一個傳送器
基於前述分析得知我們需要知道:包括這個位置往左連續的傳送器有幾個。因為獲勝的條件是:連續的傳送器個數\(\leq\) 10,所以只有個數在1-10 這10種情況。(當然維護“不包含這個位置往左連續的傳送器有幾個”也可以,部落格一的程式碼就是當不包含做的)
為了區分這十種情況我們再加入一個維度記錄到前位置有連續的幾個傳送器。
我們用f[i][j][k] 表示
表示當前考慮到第i個格子,已經用了 j個傳送器,包括 i 號位置有連續k個傳送器。
對於i,j,k 沒添進來之前的狀態必然是i-1,j-1,k-1
所以轉移函式:f[i][j][k] = f[i-1][j-1][k-1]*(i-1)
乘上i-1是因為在i位置的傳送器有i-1種目標位置。
例子:
f[4][1][1]=f[3][0][0]*3=3
(很多題解的程式碼裡寫的是:f[i][j][k] = f[i][j][k]+ f[i-1][j-1][k-1]*(i-1)
我認為後面的f[i][j][k]是沒必要加上的,加上可能是為了形式上更加符合dp的一般表示方法吧。之所以加上沒錯是因為f[i][j][k]初始值是0)
6.2 添白塊
我們來再考慮新塊是白塊的情況。
白塊對應k=0.但這時剛才那個轉移函式就沒法用了,因為k-1=-1
這時我們得分類討論了,考慮第i-1個塊有幾種情況,
它可能是白塊,也可能是傳送器,如果是傳送器可能是連續的第1-10個
所以
f[i][j][0]=f[i-1][j][0]+f[i-1][j][1]+...+f[i-1][j][10]
至此我們便可以由初始狀態一點點地遞推到答案狀態:f[n][m][0]。
程式碼:
注意這塊,我加了個特判,其實不加直接輸出func()結果也行。
我加上只是為了提醒大家:不能認為m必須<=n-2。
傳送器不能在頭尾,所以我們認為m應該<=n-2。但當n=1時,頭尾變成了一個,這時我們便不能要求m<=-1了,因為m等於0也存在一種可行方案。
if(m<=n-2 || m==0)
cout<<func()<<endl;
else
cout<<-1<<endl;
完整程式碼
**展開檢視程式碼**
#include<iostream>
#include<cstring>
using namespace std;
int test=1;
int n,m;
const int n_max=1000;
int f[n_max+1][n_max-1][10+1]; // i 位置之前(包括i)總共有j個傳送器 且i是連續的第k個
const int mod=1e9+7;
typedef long long ll;
int func()
{
memset(f,0,sizeof(f));
for(int i=1;i<=n;i++)
{
f[i][0][0]=1; //沒有傳送器的方案數都是1種
for(int j=1;j<=m;j++)
{
for(int k=0;k<=10; k++)
{
f[i][j][0]=(f[i][j][0]+f[i-1][j][k])%mod;
//cout<<"i: "<<i<<" j: "<<j<<" k: "<<k<<" f0: "<<f[i][j][0]<<endl;
if (k!=0)
{
if(f[i-1][j-1][k-1]==0) //可以起到一定的加速作用不至於TLE
continue;
f[i][j][k]=(ll)f[i-1][j-1][k-1]*(i-1)%mod;
//cout<<"i: "<<i<<" j: "<<j<<" k: "<<k<<" f: "<<f[i][j][k]<<endl;
}
//你也可以算f[i+1][j][0]
//f[i+1][j][0]=(f[i+1][j][0]+f[i][j][k])%mod;
//cout<<"i+1: "<<i+1<<" j: "<<j<<" f: "<<f[i+1][j][0]<<endl;
}
}
}
if (f[n][m][0]!=0)
return f[n][m][0];
else
return -1;
}
int main()
{ cin>>test;
for(int k=0;k<test; k++)
{
cin>>n>>m;
if(m<=n-2 || m==0)
cout<<func()<<endl;
else
cout<<-1<<endl;
}
}
7. 擴充套件法
我們考慮往一個可行的序列後加一個白塊使其擴充套件為一個更大的可行序列,與上述兩種思路不同的是:我們不再限制一次加一個塊,也不再限制新加的塊緊貼末尾,白塊可以加到距離末尾1-11的任意地方,中間的填上傳送器。
現在我們變換一下座標系,我們固定新加的白塊的位置,設為i位置,左邊距離i 1-11的位置便是所有可能的擴充套件前序列的尾部的位置。也就是說我們只能通過這些位置所代表的序列擴充套件到i位置。
注意:我們不關心是怎麼跳到這個新塊上的,題目沒問你怎麼跳的方案數,我們關心的是白塊的排列,當然白塊的排列與傳送器的排列是等價的我們確定了其中一個另一個也就被確定了。
(這裡變換座標系只是方便表示與編碼,從i遞推i+1和從i-1遞推i本質上都是一樣的。)
令 r[i][j] 為n=i m=j 對應的方案數
轉移函式:r[i][j]=r[i-1][j]+r[i-2][j-1]*(i-2)+r[i-3][j-2]*(i-2)*(i-3)+....+r[i-11][j-10]*(i-2)*(i-3)...*(i-10)
其中:
- r[i-3][j-2]*(i-2)*(i-3)表示白塊加在了 n=i-3 m=j-2 所代表的可行序列後 的第3個位置,中間填上了2個傳送器,位置分別為i-1和i-2,傳送目標位置分別有:i-2,i-3種。剩餘的同理。
- r[i-1][j] 表示白塊加在了 n=i-1 m=j 所代表的可行序列後,中間沒有傳送器。
至此我們便可由初始狀態遞推到答案狀態:f[n][m]
程式碼:
注意:其中mod一定要定義成常量:const int !!!
不能是普通的int,否則你再怎麼優化都會TLE(血的教訓)
加與不加const差距多達1000多ms!
其中的temp便是公式中乘的那些係數,由於可以由上一個temp多乘一個數得到,所以寫成了這種更新的形式而不是一個for迴圈。
**展開檢視程式碼**
#include<iostream>
#include<cstring>
using namespace std;
int test=1;
int n,m;
const int n_max=1000;
typedef long long ll;
int f[n_max+1][n_max-1];
const int mod=1e9+7;
int func()
{
memset(f,0,sizeof(f));
for(int i=1;i<=n;i++)
{
f[i][0]=1; //沒有傳送器的方案數都是1種
for(int j=1;j<=m;j++)
{
int temp=1;
for(int k=1;k<=11; k++)
{
if(i-k<=0 || j-(k-1) <0)
break;
if(k==1)
temp = 1 ;
else
temp=(ll)temp*(i-k)%mod;
f[i][j]=(f[i][j]+(ll)f[i-k][j-(k-1)]*temp)%mod;
}
}
}
if (f[n][m]!=0)
return f[n][m];
else
return -1;
}
int main()
{ cin>>test;
for(int k=0;k<test; k++)
{
cin>>n>>m;
if(m<=n-2 || m==0)
cout<<func()<<endl;
else
cout<<-1<<endl;
}
}
8. 不同方法對比
- 擴充套件法與添塊法的不同。
添塊法中我們維護了連續的傳送器的位置資訊,儲存了可行序列的一部分對應的的方案數。
用c表示傳送門,b表示白塊。
例如:bcc
但我們是不會詢問到這些部分序列的方案數的,我們只可能詢問bcb或者bccb。所以理論上我們不用儲存這些部分序列對應的的方案數。
其實這兩種方法本質上也是相同的,擴充套件法轉移函式後面加的那一串也正是這些方案數。只不過一個是現算一個是把他們存起來了。 - 我們再來重新審視一下塞塊法為何不行?
我們發現擴充套件法與添塊法都是在一端操作,不考慮上一個狀態的詳細的內部情況。而塞塊法則恰恰需要知道上一個狀態的詳細的內部情況,而只有忽略這些才能簡化計算。當然理論上依舊是可行的只不過時間複雜度或編碼困難度可能是我們不能接受的。
9. 關鍵破題點總結
- 不討論傳送器的目標位置,它可以傳送到任意合法位置。
- 不能有多於10個傳送器連續排列
- 在一端進行擴充套件,尋找轉移函式
10. 做題記錄與心路歷程
這題想了一個多小時也就那麼一點點思路,還是在知道往dp方向去想的前提下。但還是理不清楚不知如何處理,上述關鍵破題點都是我一開始沒想到的,無奈之下去搜了波題解。我覺得難爆了的題,程式碼竟然那麼簡單,僅用一個轉移函式便可解決。研究了幾天,也算是懂個差不多吧,畢竟dp博大精深,還需日積月累,反覆琢磨,方可駕輕就熟。
程式碼中那個特判我真是想破頭都沒想到,問題竟出在那!
其實我剛開始寫這篇部落格時還是有些地方還是沒搞清楚,就是一邊寫一邊理思路,寫了刪,刪了又寫,反覆修改,耗時一個晚上加一天終得此文。
在我完成前本文前的最後一步:確定最終程式碼時,本打算跑一跑沒啥問題一粘我就去睡覺了,然而擴充套件法的程式碼竟然TLE了。然後調了一個半小時!從半夜0.32到1.57。任我想破腳趾頭都沒想到竟然是因為沒把mod設定成常整型!簡直重新整理我認知邊界,有時間的話我得好好研究一下這個問題。
哦對了,文獻綜述部分除了博文2,其他博文下面留言的人都是我(至少目前是的),或挑錯或問問題,他們都進行了耐心的回答,所以說博主人都是很好的,所以有啥不對您就指出來,有啥不懂的您就問。