前言
本文章將會持續更新,主要是一些個人覺得比較妙的題,主觀性比較強(給自己記錄用的),有講錯請補充。
帶 !號的題是基礎例題,帶 * 號的是推薦首先完成的題(有一定啟發性的)。
動態規劃
線性動態規劃
! Jury Compromise(藍書例題)
看到題目比較容易的想到:
定義:f[i][j][k]
為 \(i\) 表示考慮到第 \(i\) 個候選人,\(j\) 表示當前辯方總分為 \(j\),\(k\) 表示當前控方總分為 \(k\) 時是否可行。
得到方程式:
dp 後列舉絕對值大小判斷是否可行。
時間是足夠的,但我們還可以最佳化空間。
設 f[j][k]
表示在前 \(i\) 個人中選 \(j\) 個人,辯方控方之差為 \(k\),可辯方與控方之和的最大值。
得到方程式:
初始:\(f[0][0]=0\),其它均為正無窮。
這就像 01 揹包了,所以 \(j\) 也要倒序列舉。
但此題還要輸出方案,所以還要定義一個陣列 d[i][j][k]
,表示 f[j][k]
的最大值是從哪一位候選人轉移過來的(注意要輸出方案的題都要把每一位狀態都記錄下來,滾動陣列會覆蓋一些資訊)。
最後遞迴求解。
注意 \(k\) 可能是負的,要有一個偏移量 base
,f[j][k]
變成 f[j][k+base]
。
Coins(藍書例題)
P6064 [USACO05JAN] Naptime G(藍書例題)
先把環變成鏈,由於這麼做第一個小時一定是不計入貢獻的(就算是在睡覺,也是入睡的第一個小時,沒有貢獻),所以再做一次 dp,第二次強制規定熟睡。
The least round way
因為只有 \(2 \times 5\) 會在末尾新增 \(0\),所以先求出每個點的 \(5\) 與 \(2\) 的因數個數,然後 dp 。
設 \(f[0][i][j]\) 為到 \(i,j\) 這個位置 \(2\) 的最小因數和,\(f[1][i][j]\) 為到 \(i,j\) 這個位置 \(5\) 的最小因數和,最後比較兩者的最小值,即為答案。
注意如果有 \(0\),只要走了 \(0\) 就後面都是 \(0\),即答案為 \(1\),所以上面的答案與 \(1\) 比較,取最小值。
此題要輸出方案,所以遞迴求解就行。
注:此題的 dp 只能分開求,不能合起來求,因為這樣求的只是區域性最優,而無法透過這一個推到下一個(可能這個點是因數 \(2\) 最小,下一個點又是因數 \(5\) 最小,可能這個點的因數 \(5\) 的個數來更新下一個點更優)。
至於可以分開求的原因:最後求的都是因數 \(2\)、因數\(5\) 單個的最小值,輸出路徑時是沿著最小的那個輸出的,那另一個因數在此路徑上的值肯定大於等於單個求出的值,但不印象答案的變化。
個人錯點:在判 \(0\) 前就輸出答案,相當於沒判 \(0\)。
code
#include<bits/stdc++.h>
using namespace std;
const int N=1000+10;
int n,flag,op;
int num[2][N][N],f[2][N][N];
bool vis[N][N];
int get(int x,int k)
{
int res=0;
while(x%k==0) res++,x/=k;
return res;
}
void print(int i,int j,int k)
{
if(i==1&&j==1)
{
if(k) printf("R");
else printf("D");
return ;
}
if(i==1) print(i,j-1,1);
else if(j==1) print(i-1,j,0);
else if(f[op][i][j]==f[op][i-1][j]+num[op][i][j]) print(i-1,j,0);
else if(f[op][i][j]==f[op][i][j-1]+num[op][i][j]) print(i,j-1,1);
if(i!=n||j!=n)
if(k) printf("R");
else printf("D");
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
{
int x;
scanf("%d",&x);
if(!x) flag=i,vis[i][j]=1,num[1][i][j]=num[0][i][j]=1;
else num[0][i][j]=get(x,2),num[1][i][j]=get(x,5);
}
for(int i=1;i<=n;++i)
f[0][0][i]=f[0][i][0]=f[1][0][i]=f[1][i][0]=2e9+10;
f[0][1][1]=num[0][1][1],f[1][1][1]=num[1][1][1];
for(int i=1;i<=n;i++)
for(int j=(i==1?2:1);j<=n;j++)
{
f[0][i][j]=min(f[0][i-1][j],f[0][i][j-1])+num[0][i][j];
f[1][i][j]=min(f[1][i-1][j],f[1][i][j-1])+num[1][i][j];
}
int ans=min(f[0][n][n],f[1][n][n]);
if(flag&&ans>1)
{
puts("0");
for(int i=1;i<flag;i++) printf("D");
for(int i=1;i<n;i++) printf("R");
for(int i=flag+1;i<=n;i++) printf("D");
return 0;
}
printf("%d\n",ans);
if(f[0][n][n]>f[1][n][n]) op=1;
else op=0;
print(n,n,0);
return 0;
}
Modular Sequence
構造的每一個數的都是:\(x/y+k\times y,k \in N\),並且除了開頭都是一個個等差數列加起來。
可以算出 \(k\) 的總數 \(s\),再預處理出可以構成 \(1\) 到 \(s\) 的最短序列長度。
式子為:
\(get(j)\) 等於 \((j+1)*j/2\)。
最後列舉開頭等差數列的結尾,這裡總和記為 \(sum\),只要有滿足 \(f[s-sum]<=n-i\) 就找到符合要求的序列,再遞迴輸出方案即可。
時間複雜度:\(O(n \sqrt s)\)。
P7690 [CEOI2002] A decorative fence
我們可以一位一位的填木板,就像“試填法”。但是看 \(C\) 的範圍是大於 int
範圍一個一個求太慢了,所以想到倍增。
用倍增預處理出第一個木板\(1\)到\(N\)的所有情況,因為還要區分這個數的前後兩位是比它高還是低所以還要存這個木板是處於高位還是低位,用\(1,0\)表示。
所以很容易想出狀態 \(f[ i ][ j ][ k ]\) ,表示用 \(i\) 塊木板拼成柵欄其中最左邊的長度從小到大在第 \(j\) 位,並且狀態為 \(k\) (\(k\) 指是處於高位還是低位)。
這裡特別強調 \(j\) 是從小到大的第 \(k\) 位而不是木板真實的長度。因為如果是真實的長度,每一個個狀態是唯一的,就是和暴力列舉是一樣的,不方便倍增計數。
這裡第 \(j\) 大的有點像離散化思想,把真實的值用從大到小的編號相互對映,以達到減少列舉數量的目的。
狀態轉移方程:
第一個實在是當前為低位時,那它的前一個就是高位並比它高。
第二個式子是當前為高位是,那它的前一個就是低位並比它低。
當倍增處理後只需要從小到大依次減去個數,從而求出第 \(C\) 小的排列(有一點像數位dp),當然每減去一次,\(k\) 就要取反一次,因為每一塊木板肯定是高低交錯的。
時間複雜度:預處理 \(O(N^{3})\),求答案時 \(O(N^{2})\) 。
一些細節看程式碼註釋。
code
#include<bits/stdc++.h>
using namespace std;
int T,n;
bool vis[30];
long long m,f[30][30][2];
void get_f()//預處理
{
f[1][1][0]=f[1][1][1]=1;//初始化
for(int i=2;i<=20;i++)
for(int j=1;j<=i;j++)
{
for(int k=j;k<=i-1;k++)
f[i][j][0]+=f[i-1][k][1];
for(int k=1;k<=j-1;k++)
f[i][j][1]+=f[i-1][k][0];
}
}
int main()
{
cin>>T;
get_f();
while(T--)
{
memset(vis,0,sizeof vis);//多組資料要清空
cin>>n>>m;
int las,k;//las指當前數字,k指狀態(高位還是低位)
//一位一位的找,先找第一位
for(int j=1;j<=n;j++)
{
if(f[n][j][1]>=m)//為了使字典序更小必須k是1開頭,就是後面一位是低位
{
las=j;
k=1;
break;
}
else m-=f[n][j][1];
if(f[n][j][0]>=m)
{
las=j;
k=0;
break;
}
else m-=f[n][j][0];
}
vis[las]=1;//vis是來存是否出現過,因不能有重複
cout<<las<<" ";
//找後面幾位
for(int i=2;i<=n;i++)
{
k^=1;//必須是一個高位一個低位
int j=0;//j是列舉前i位,這個數的排位
for(int l=1;l<=n;l++)//l是當前的真實長度
{
if(vis[l])continue;//找過了就不著了
j++;
if(k==0&&l<las||k==1&&l>las)//0,1分兩種考慮
{
if(f[n-i+1][j][k]>=m)
{
las=l;
break;
}
else m-=f[n-i+1][j][k];
}
}
vis[las]=1;
cout<<las<<" ";
}
puts("");
}
return 0;
}
! P2657 [SCOI2009] windy 數
注:古早文章,寫的很爛。此篇文章只是講解了記憶化搜尋的程式碼,和可能有點用的小總結。
記憶化搜尋
#include<bits/stdc++.h>
using namespace std;
int q[20];
int f[20][20][2][2];
int a,b;
int n;
int dfs(int len,int las,bool flag,bool ze)
{
if(!len)return 1;
if(~f[len][las][flag][ze])return f[len][las][flag][ze];//前面搜過了就直接放回值
int sum=0;
for(int i=0;i<=9;i++)
{
if((!flag||i<=q[len])&&(ze||abs(i-las)>=2))
sum+=dfs(len-1,i,flag&&(i==q[len]),ze&&(i==0));
}
f[len][las][flag][ze]=sum;
return sum;
}
int work(int x)
{
memset(q,0,sizeof q);
memset(f,-1,sizeof f);
n=0;
while(x)
{
q[++n]=x%10;
x/=10;
// cout<<q[n];
}
// cout<<endl;
return dfs(n,11,1,1);
}
int main()
{
cin>>a>>b;
cout<<work(b)-work(a-1);
return 0;
}
數位dp,記憶化搜尋常用套路
1 dfs中設定幾個變數
(1)len
位數,現在是第幾位。
(2)las
上一個數是什麼,通常題目的限制條件是與前一位的關係。
(3)flag
指有沒有限制,通常是以從高到低位列舉的如果上一位沒有到最大限制。
這一位可以填\(0-9\)(題目中的限制不包括)。
(4)ze
有沒有前置\(0\),如果有前置\(0\),不能充當首位,但可以去更新位數更大的數。
本題程式碼中幾個難懂的點
1.
if((!flag||i<=q[len])&&(ze||abs(i-las)>=2))
!flag||i<=q[len]
:沒有限制,前一位沒到最高或有限制但這一位沒到限制。
ze||abs(i-las)>=2
:有前導0都可以填或有限制但滿足條件。
2.
sum+=dfs(len-1,i,flag&&(i==q[len]),ze&&(i==0));
flag&&(i==q[len])
:前面有限制(前面的位數都到了最高位)並且這一位也到最高位,後面有限制。
ze&&(i==0)
:前面有前導 \(0\) ,並且這一位也是 \(0\),就附上 \(0\) 。
樹形動態規劃
! P3177 [HAOI2015] 樹上染色
此題把黑點與黑點,白點與白點的邊權和,轉化成每條邊對整體的邊權和的影響。
本質上是一個樹形 dp ,把每一條邊看作一個物品,求每一個物品對整體的貢獻。
設 f[i][j]
為考慮第 $i $ 個結點,其子樹中有 \(j\) 個點染成黑色的邊權和。
設 \(val=(j*(k-j)+(s[v]-j)*(n-k-(s[v]-j)))\)。
式子為:
時間複雜度:\(O(n^2)\)。
資料結構最佳化
! P3957 [NOIP2017 普及組] 跳房子
求金幣數很難,但如果給出金幣數來判斷是否能得到 \(k\) 分要容易的多,由此可以想到二分金幣數。
設金幣數為 \(g\) ,那每次跳的距離的範圍為 \([\max(1,d-g),d+g]\) ,很容易想出式子:
現在時間複雜度為: \(O(\log V \times n^2)\),\(V\) 指金幣的值域,顯然是無法透過此題的。
我們發現式子中列舉 \(j\) 的長度是固定,為:\(g \times 2 +1\),並且是求那一段區間中的最值,這時想到 滑動視窗 那道題,也就想到單調佇列。
本題實現有不少細節
-
每次二分前都要把 \(f\) 陣列賦為負無窮,因為有無法走到的格子。
-
用雙指標維護區間。
-
單調佇列一定先從隊尾加入(
tail++
),再把不在區間的數從對頭彈出(head++
)。
code
#include<bits/stdc++.h>
using namespace std;
const int N=5e5+10,inf=1e9+10;
int n,d,k;
int f[N];
int t[N];
pair<int,int>a[N];
queue<int>q;
bool check(int mid)
{
for(int i=1;i<=n;i++) f[i]=-inf;
int head=1,tail=0;
int l=max(1,d-mid),r=d+mid,i=1,j=0;
f[0]=0;
for(;i<=n;i++)
{
while(a[i].first-a[j].first>=l&&j<i)
{
if(f[j]>-inf)
{
while(tail>=head&&f[j]>=f[t[tail]]) tail--;
t[++tail]=j;
}
j++;
}
while(tail>=head&&a[i].first-a[t[head]].first>r) head++;
if(tail>=head) f[i]=f[t[head]]+a[i].second;
if(f[i]>=k) return 1;
}
return 0;
}
int main()
{
scanf("%d%d%d",&n,&d,&k);
for(int i=1;i<=n;i++)
{
scanf("%d%d",&a[i].first,&a[i].second);
}
int l=0,r=inf;
while(l<r)
{
int mid=(l+r)>>1;
if(check(mid)) r=mid;
else l=mid+1;
}
if(l==inf) puts("-1");
else printf("%d",l);
return 0;
}
! P2254 [NOI2005] 瑰麗華爾茲
先想暴力,肯定是一個 \(O(n \times m \times T)\) 的 dp 。
\(f[t][i][j]\) 表示在第 \(t\) 時刻在第 \(i\) 行第 \(j\) 列所能獲得的最長距離。
轉移方程:
這樣可以過 \(50 \%\) 的資料,但對於 \(100 \%\) TLE 且 MLE。
這時就要用到兩個常見的最佳化了:滾動陣列最佳化空間,單調佇列最佳化時間。
滾動陣列最佳化很好理解,因為整個式子的 \(t\) 只用到了 \(t\) 與 \(t-1\),用一個 \(p\) 表示當前的 \(t\) ,\(p \otimes 1\) 表示 \(t-1\) 即可。
在一段時間內只能向一個方向移動一定的距離,在那中間取最大值,這不就又是滑動視窗,只需要單調佇列最佳化就可以了。
Music Festival
每次取的肯定是每一個集合中單調上升的序列,但這個序列的最小值一定比上一個區間的最大值大。
這可以想到預處理每個集合的最大值、最小值與序列長度。
但最長的序列本不一定會用到每給集合的所有元素,所以我們把每一個集合中的單調上升的序列預處理出來(注最大值的是一定的)。
如:
1 4 3 2 5
有三個序列:
[1 4 5],[4,5],[5]。
然後做一個簡單的 dp,可求出答案。
時間複雜度:\(O(n^2)\)。
考慮用樹狀陣列最佳化:
先按最小值排序,然後每次詢問小於最小值的最大值,然後把當前的值插入樹狀陣列中。
時間複雜度:\(O(nlogn)\)。
矩陣最佳化
*P6772 [NOI2020] 美食家
運用了多種最佳化技巧,值得學習。
首先寫出最樸素的式子:
如果有節日加上額外的愉快值。
然後最容易關注到的是 \(T \in 10^9\),這麼大那多半要用矩陣快速冪了。
但遞推式是每次更新 \(w\) 後的值,顯然無法直接用矩陣來推,但我們又關注到 \(w \le 5\),那我們用到
技巧1: 拆點。
把一個點 \(u\),變成\(u_1 → u_2→ ⋯ → u_5\) 的這樣一個結構,邊權都是 \(0\)。對於一條邊 \((u,v,w)\),我們從 \(u_w\) 向 \(v_1\) 連一條邊權為 \(c[v]\) 的邊。這樣相當於要先 \(u_1\) 走到 \(u_w\),再從 \(u_w\) 走到 \(v\),剛好經過了 \(w\) 條邊,也就是起到了從 \(f[i]\)轉移到 \(f[i+w]\) 的效果。
其實還可以拆邊,但此題 \(n < m\),顯然拆點更好。
但矩陣快速冪是來求乘法的,但此題是取 \(max\),這時用到
技巧2:新定義矩陣。
注意初始化矩陣也要改變。
這樣就可以完成 dp 轉移了。
但還有節日怎麼算
在原來的方程來看,有節日才才會參與狀態轉移,加上 \(k\) 的範圍也不大,那用普通式子把 &k& 個節日包含進去,也就是:
此時是拆過點的,然對應的點加上這個值 \(f[t_i][w_i]+=c_i\)。
這時的時間複雜度為 \(O(k \times (5n)^3 \times logT)\),還是無法透過。
技巧3:二進位制拆分。
這在揹包中出現過。
\(base^k\) 可以被 \(base^1,base^2,base^3, \cdots base^n\) 表示,把它們都先預處理處理。
時間複雜度降為:\(O((5n)^3 \times logT + k \times (5n)^2 \times logT)\)。
此題被解決。
* P3758 [TJOI2017] 可樂
此題有兩個特殊的條件:停止不動和原地爆炸。
有兩種除了方式:
第一種:停止不動相當於自己向自己連一條邊,原地爆炸相當於每一個點向一個虛點連一條有向邊,爆炸後就不能行動了。
第二種:用兩個 dp 方程轉移,一個指不包含爆炸的,一個指爆炸的方案。
時間複雜度: \(O(n^2\times log T)\)。
考慮最佳化:
一個鄰接矩陣的 \(k\) 次方相當於:第 \(i\) 行第 \(j\) 列的數字含義是從 \(i\) 到 \(j\) 經過 \(k\) 步的路徑方案總數。
此題題正好運用此點,可以用矩陣快速冪解決。
可能有用的知識:圖的矩陣表示