CSP-S2019做題總結
哈哈了,六道題一半是靠題解訂出來的,格雷碼就是來平衡難度的...
題目
[CSP-S2019] 格雷碼
當時初二,唯一一道考場 AC 。
我們定義 \(n+1\) 位格雷碼,由 \(n\) 位格雷碼的 \(2^n\) 個二進位制串按順序排列再加字首 \(0\),和按逆序排列再加字首 \(1\) 構成,共 \(2^{n+1}\) 個二進位制串。另外,對於 \(n\) 位格雷碼中的 \(2^n\) 個二進位制串,我們按上述演算法得到的排列順序將它們從 \(0 \sim 2^n-1\) 編號。給出 \(n,k\) ,求按上述演算法生成的 \(n\) 位格雷碼中的 \(k\) 號二進位制串。(\(1\le n\le64,0\le k<2^n\)
題目已經明示了遞迴結構,如果我們設 \(f_{n,k}\) 表示 \(n\) 位格雷碼串的 \(k\) 號二進位制串,則有
\[f_{n,k}=\begin{cases}f_{n-1,k}&k<2^{n-1}\\f_{n-1,2^{n-1}-(k-2^{n-1}+1)}&k\ge 2^{n-1}\end{cases} \]那個 \(2^{n-1}-(k-2^{n-1}+1)\) 其實就是 \(k\) 去掉前面一半後再逆序得到的結果,除了這點應該沒啥難理解的。唯一要注意的是不能把式子合併為 \(2^n-k-1\) ,不然當 \(n=64\) 的時候就沒了。時間複雜度 \(\mathcal{O}(n)\)
#include <cstdio> typedef unsigned long long ull; inline void work(int n, ull k) { //注意這裡是1ull,不然1預設是int型別,右移64位就溢位了 if (n == 0) return ; ull len = 1ull << (n - 1); if (k <= len - 1) putchar('0'), work(n - 1, k); else putchar('1'), work(n - 1, len - k + len - 1); } int main() { int n; ull k; scanf("%d%llu", &n, &k); work(n, k); putchar('\n'); return 0; }
[CSP-S2019] 括號樹
還算是正常的 tgT2 難度,只不過當時太菜了 (現在也是) 只會暴力。
給出一棵 \(n\) 個結點的樹,每個結點上有一個括號 (
或 )
,定義 \(k_i\) 表示從根節點到 \(i\) 的路徑上括號串所有子串中有多少個合法括號序列(合法括號序列和子串定義同一般定義)。(\(1\le n\le 5\times10^5\))
因為子串這東西不好找,而注意到根結點到結點 \(i\) 的路徑上所有合法括號序列子串其實就是以路徑上所有結點結尾的合法序列數量之和,所以我們可以定義 \(f_i\) 表示樹上以 \(i\) 結尾的合法括號序列個數,最後統計答案的時候樹上字首和一下就好。接下來就考慮如何求出 \(f_i\) 。
我們來形式化定義一下合法括號序列。如果設 (
表示 \(1\) ,)
表示 \(-1\) ,\(s_i\) 表示字首和,則序列 \(a_{l..r}\) 是合法序列當且僅當:
- \(s_l=s_r\)
- \(\forall l\le i\le r,s_i\ge s_l\)
如果我們設 \(c_s\) 表示當前路徑中 \(s\) 出現的次數,則去掉當前結點的次數, \(c_s\) 其實就是當前結點滿足 1 條件的序列個數。而考慮 2 條件的限制,我們定義 \(las_s\) 表示 \(s\) 上一次出現時候的值,只要減去 \(las_{s-1}\) 上的 \(c_s\),即最後一個不合法的位置上 \(c_s\) 的值就得到了滿足 1,2 兩個條件的序列個數。我們 \(\rm dfs\) 一遍序列,可以維護上面所述的過程,但條件 2 不好找,我們可以用 \(\rm vector\) 記錄一下,之後等回溯的時候再處理(詳見程式碼),最後字首和一下,處理處理輸出就好了,時間複雜度 \(\mathcal{O}(n)\) 。
#include <cstdio>
#include <vector>
#include <cstring>
const int N = 5e5 + 10; typedef long long ll;
int las[N << 1], c[N << 1]; char s[N]; ll ans[N], Ans;
struct edge{ int v, next; }E[N << 1]; int p[N], cnt; std::vector<int> vec[N];
inline void init() { memset(p, -1, sizeof (p)); cnt = 0; }
inline void insert(int u, int v) { E[cnt].v = v; E[cnt].next = p[u]; p[u] = cnt++; }
void dfs(int u, int sum)
{
sum += s[u] == '(' ? 1 : -1; ans[u] = c[sum]++;
int last = las[sum]; las[sum] = u; vec[las[sum - 1]].push_back(u);
for (int i = p[u], v; i + 1; i = E[i].next) v = E[i].v, dfs(v, sum);
for (int i = 0; i < vec[u].size(); ++i) ans[vec[u][i]] -= c[sum + 1];
--c[sum]; las[sum] = last;
}
void dfs(int u)
{ for (int i = p[u], v; i + 1; i = E[i].next) { v = E[i].v; ans[v] += ans[u]; dfs(v); } }
int main()
{
init(); int n; scanf("%d%s", &n, s + 1); c[n + 1] = 1;
for (int i = 2, fa; i <= n; ++i) scanf("%d", &fa), insert(fa, i);
dfs(1, n + 1); dfs(1); for (int i = 1; i <= n; ++i) Ans ^= (i * ans[i]);
printf("%lld\n", Ans); return 0;
}
[CSP-S2019] 樹上的數
D1T3 開始不正常起來了,從此 CSP-S2019 在神題的路上一去不復返了。
給一棵有 \(n\) 個結點的樹,樹上每個結點上有一個數 \(a_i\) 且滿足 \(a\) 是一個 \(1\sim n\) 的排列。接下來要進行恰好 \(n-1\) 次刪邊,每次操作要選擇一條未被刪去的邊,交換邊連線兩點上面的數字並刪去這條邊。當所有的邊都被刪去後,將數字按照從 \(1\sim n\) 的順序依次排列得到一個排列 \(\mathcal{P}\) ,求出在最優操作方案下得到的字典序最小的 \(\mathcal{P}\) 。(\(1\le n\le 2000\))
看了兩個晚上的神仙思維題。這題直接突破太難了,所以我們慢慢考慮部分分。
暴力
如果你考場上成功打出了 \(\mathcal{O}(n!n)\) 之類的嗯暴力,收穫了 \(\tt 10pts\) ,恭喜你收穫了考場滿分。這一部分比較簡單就不放程式碼和講解了。
菊花圖
這題菊花圖的部分分是比鏈的好想的,大概是因為菊花圖對應的路徑都比較短。
比如這個菊花圖,我們假設刪邊順序是 \((u_1,x),(u_2,x),(u_3,x),(u_4,x),(u_5,x),(u_6,x)\) ,則手玩一下可以發現 \(a_x\) 移動到 \(a_{u_1}\) ,\(a_{u_1}\) 移動到 \(a_{u_2}\) ,\(a_{u_2}\) 移動到 \(a_3\) 以此類推。我們可以貪心構造這個順序,從 \(1\sim n\) 列舉每一個數,每次貪心選取刪邊順序的下一條邊,該數字最後的位置就是該邊對應的 \(u_i\) 。
鏈
鏈的關係中,我們就要分析邊與邊之間的關係了,如果一個數字 \(k\) 想從初始位置 \(u_1\) 移動到 \(u_m\) ,則路徑上的點 \(u_1,u_2,\cdot\cdot\cdot,u_m\) 上要有以下性質:
- 對於起點 \(u_1\),其出邊 \((u_1,u_2)\) 一定是先被刪掉的邊。
- 對於結尾點 \(u_m\),其入邊 \((u_{m-1},u_m)\) 一定是這一點最後被刪除的邊。
- 對於中間點 \(u_i\) ,其入邊 \((u_{i-1},u_i)\) 先於出邊 \((u_i,u_{i+1})\) 被刪。
我們對於每個點的出入邊可以獲得一個刪邊的順序,依然按 \(1\sim n\) 列舉每個點,檢查每個數字從初始位置從左到右走的點中的最小編號。這個點不能走,當且僅當該點的順序已確定且不滿足需求的順序。
正解
跟鏈類似,將一個數字 \(k\) 從初始位置 \(u_1\) 移動到 \(u_m\) ,在路徑上的點 \(u_1,u_2,\cdot\cdot\cdot,u_m\) :
- 對於起點 \(u_1\),其出邊 \((u_1,u_2)\) 一定是這一點第一條被刪掉的邊。如果不是的話 \(k\) 就會被換到其他點上。
- 對於終點 \(u_m\),其入邊 \((u_{m-1},u_m)\) 一定是這一點最後一條被刪除的邊。如果不是,\(k\) 也會被換到其他點上。
- 對於中間點 \(u_i\) ,其入邊 \((u_{i-1},u_i)\) 先於出邊 \((u_i,u_{i+1})\) 被刪,且在該點的所有邊裡被刪除的順序是相鄰的。如果不滿足後一條性質,\(k\) 在中間會被換到其他點上。
注意到這些限制其實都是限制在某一點的邊上的,所以我們可以單獨考慮每個點的情況,依然是 \(1\sim n\) 列舉每個數字,從這個數字的初始位置開始 \(\rm dfs\) ,依次檢查路徑上的點是否可以作為中間點或者終點即可。
明白了這點之後,這題就差不多.....剛開始了。因為實現檢查每個點是否滿足中間點或終點的條件非常繁瑣。我這邊用的是連結串列和並查集的實現,其中連結串列管理某個點的邊是否被應用了 在某邊之後或之前被刪 的限制,並查集管理某個點的邊的限制形成的鏈式結構,且用兩個陣列 \(beg,end\) 儲存某個點的所有邊中,被 固定 為第一條或最後一條被刪的邊。
對於一個點,它能作為終點的條件為:
- 不是起點。
- 入邊必須能作為該點的最後一條被刪的邊。
- 當該點度數為 \(1\) 時最後一條和第一條被刪的邊為同一條。
對於一個點,它能作為中間點的條件為:
- 入邊之後不能有除出邊外緊接著要刪掉的邊。
- 出邊之前不能有除入邊外緊接著要刪掉的邊。
- 將入邊和出邊的限制關係加入後,如果會使該點的第一條和最後一條被刪的邊加入了同一條關係鏈,則此時該點的所有邊都在這條關係鏈中。
根據以上條件判斷一個點是否能作為中間點或者終點,尋找每個數字的最小編號終點,之後在路徑上應用出入邊的限制即可,具體細節塞到程式碼註釋惹。最終時間複雜度 \(\mathcal{O}(n^2)\) ,但因為大常數,所以過 \(n\le 2000\) 都勉強。
#include <cstdio>
#include <cstring>
#include <algorithm>
inline void read(int& x)
{
x = 0; char ch; int f = 1;
while ((ch = getchar()) < '0' || ch > '9')
f = (ch ^ '-' ? 1 : -1);
while (x = (x << 1) + (x << 3) + ch - '0',
(ch = getchar()) >= '0' && ch <= '9') ;
x *= f;
}
//beg,end 每個點對應的最先/最後被刪的邊
const int N = 2e3 + 10; int pnt[N], beg[N], end[N], deg[N], T, n;
struct edge{ int v, next; }E[N << 1]; int p[N], cnt;
inline void insert(int u, int v) { E[cnt].v = v; E[cnt].next = p[u]; p[u] = cnt++; }
struct DSU
{
int f[N]; bool pre[N], nxt[N]; //pre,nxt 一條邊是否確定了前後關係
void clear()
{
memset(pre, 0, sizeof (pre)); memset(nxt, 0, sizeof (nxt));
for (int i = 1; i <= n; ++i) f[i] = i;
}
int getf(int x) { return x == f[x] ? x : f[x] = getf(f[x]); }
void merge(int x, int y)
{
int tx = getf(x), ty = getf(y);
f[ty] = tx; nxt[x] = pre[y] = true;
}
bool same(int x, int y) { return getf(x) == getf(y); }
}dsu[N];
inline void init()
{
memset(beg, 0, sizeof (beg)); memset(end, 0, sizeof (end));
memset(deg, 0, sizeof (deg)); memset(p, -1, sizeof (p)); cnt = 0;
for (int i = 1; i <= n; ++i) dsu[i].clear();
}
int dfs(int u, int fa)
{
int mn = n + 1;
//fa != 0: 不是起點 | end[u] == 0 || end[u] == fa 入邊是最後刪的邊
//!dsu[u].nxt[fa] 入邊之後必須再無刪邊
//!(beg[u] != 0 && deg[u] > 1 && dsu[u].same(fa, beg[u]))
//^入邊和最後刪邊不在同一條關係鏈中,最後一條鏈時除外^
if (fa != 0 && (end[u] == 0 || end[u] == fa) && !dsu[u].nxt[fa] &&
!(beg[u] != 0 && deg[u] > 1 && dsu[u].same(fa, beg[u]))) mn = std::min(mn, u);
//^嘗試以 u 點作為終點^
for (int i = p[u], v; i + 1; i = E[i].next)
{
v = E[i].v; if (v == fa) continue;
if (fa == 0) //嘗試以 v 作為起點之後的點
{
if (beg[u] != 0 && beg[u] != v) continue; //如果起點最後刪掉的邊不是這條不行
if (dsu[u].pre[v]) continue; //如果這條邊刪之前有必須刪的邊不行
if (end[u] != 0 && deg[u] > 1 && dsu[u].same(v, end[u])) continue;
//^如果這條邊與最後刪邊在同一關係鏈中且仍有未加入關係鏈的邊不行^
mn = std::min(mn, dfs(v, u));
}
else //嘗試以 v 作為路徑中的點
{
if (fa == end[u] || v == beg[u] || dsu[u].same(fa, v)) continue;
//^如果入邊是最後刪邊,出邊是最先刪邊,出入邊已經在同一條關係鏈中不行^
if (dsu[u].pre[v] || dsu[u].nxt[fa]) continue;
//^出邊之前必須刪邊,入邊之後必須刪邊則不行^
if (beg[u] != 0 && end[u] != 0 && deg[u] > 2 &&
dsu[u].same(fa, beg[u]) && dsu[u].same(v, end[u])) continue;
//^如果這樣出入邊會導致最先刪邊和最後刪邊在同一關係鏈且仍有其他邊沒在關係鏈中不行^
mn = std::min(mn, dfs(v, u));
}
}
return mn;
}
bool modify(int u, int fa, int tar)
{
if (u == tar) { end[u] = fa; return true; }
for (int i = p[u], v; i + 1; i = E[i].next)
{
v = E[i].v; if (v == fa) continue;
if (!modify(v, u, tar)) continue;
if (fa == 0) beg[u] = v; //這條邊是起點
else dsu[u].merge(fa, v), --deg[u]; //這條邊是中間的點
return true;
}
return false;
}
int main()
{
read(T);
while (T--)
{
read(n); init(); for (int i = 1; i <= n; ++i) read(pnt[i]);
for (int i = 1, x, y; i < n; ++i)
{
read(x); read(y); insert(x, y); insert(y, x);
++deg[x]; ++deg[y];
//^deg 表示一個點的邊關係構成鏈的數量,初始時為度數,之後每加入一個關係減一^
}
for (int i = 1, x; i <= n; ++i) //先確定優先順序更高的結點
{
x = dfs(pnt[i], 0); //找到能找到的終點最小值
modify(pnt[i], 0, x); //之後更新邊的鏈關係
printf("%d ", x);
}
printf("\n");
}
return 0;
}
[CSP-S2019] Emiya 家今天的飯
為啥 D2T1 會考一道計數 \(\rm dp\) 啊(惱),組合數學永遠的痛。
給出一個 \(n\) 行 \(m\) 列的表格,每個格子上有 \(a_{i,j}\) 個棋子,現在要在其中選出 \(k\) 個棋子,滿足以下條件:
- \(k \ge 1\) 。
- 每一行只能取一個。
- 每一列至多取 \(\left\lfloor\dfrac{k}{2}\right\rfloor\) 個。
求所有滿足條件的取法方案數,答案對 \(998,244,353\) 取模。(\(1\le n\le 100,1\le m\le 2000,0\le a_{i,j}<998,244,353\))
首先發現,前兩個限制容易考慮,而最後一個限制不是很好直接計算,所以考慮容斥最後一個限制,即把最終的答案轉化為 每行取一個的方案數 - 每行取一個方案數且存在一列取超過一半的方案數 。我們分別 \(\rm dp\) 即可。
首先處理總方案數,設 \(g_{i,j}\) 為前 \(i\) 行取了 \(j\) 個的方案數,則顯然有轉移,即列舉選還是不選,其中 \(s_i\) 表示第 \(i\) 行 \(a_{i,j}\) 的和,下同:
\[g_{i,j}=g_{i-1,j}+s_i\times g_{i-1,j-1} \]\(\mathcal{O}(n^2)\) 直接轉移即可。
接下來考慮存在一列超過一半的方案數。可以發現,如果存在這樣的一列,則其餘列必不可能超過一半,正確性顯然。則我們列舉哪一列超過了限制,其餘列怎麼選對方案的合理性就沒有影響了,所以我們列舉一個超過限制的列 \(col\) ,並設 \(f_{i,j,k}\) 表示前 \(i\) 行,在 \(col\) 列選了 \(j\) 個,在其餘列選了 \(k\) 個的方案數,則顯然有轉移:
\[f_{i,j,k}=f_{i-1,j,k}+a_{i,col}\times f_{i-1,j-1,k}+(s_i-a_{i,col})\times f_{i-1,j,k-1} \]最終方案數就是 \(\sum_{j>k}f_{n,j,k}\) 。如果直接樸素轉移,複雜度為 \(\mathcal{O}(mn^3)\) ,只能收穫 \(\tt 84pts\) 。考慮優化轉移。
注意到在最終統計答案時,我們並不關心 \(j,k\) 的具體值,而關注的是 \(j,k\) 的大小關係,所以我們可以壓縮一維狀態,設 \(f_{i,j}\) 表示前 \(i\) 行中在列舉到的 \(col\) 列選的數量比其餘列多 \(j\) 個,則轉移變為:
\[f_{i,j}=f_{i-1,j}+a_{i,col}\times f_{i-1,j-1}+(s_i-a_{i,col})\times f_{i-1,j+1} \]注意到 \(j\) 可能小於 \(0\) ,為了避免 \(\tt RE\) 要處理一下。最終方案數就是 \(\sum_{j>0}f_{n,j}\) ,複雜度為 \(\mathcal{O}(mn^2)\) ,即為最終複雜度,足以通過本題。
#include <cstdio>
#include <cstring>
typedef long long ll;
const int N = 105, M = 2005, mod = 998244353;
int f[N][N << 1], g[N][N], a[N][M], s[N];
int main()
{
int n, m, s1 = 0, s2 = 0; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j)
scanf("%d", &a[i][j]), s[i] = (s[i] + a[i][j]) % mod;
for (int col = 1; col <= m; ++col)
{
memset(f, 0, sizeof (f)); f[0][n] = 1;
for (int i = 1; i <= n; ++i)
for (int j = n - i; j <= n + i; ++j)
f[i][j] = (f[i - 1][j] + (!j ? 0 : (ll)a[i][col] * f[i - 1][j - 1] % mod) +
(ll)(s[i] - a[i][col]) * f[i - 1][j + 1] % mod) % mod;
for (int j = 1; j <= n; ++j) s2 = (s2 + f[n][n + j]) % mod;
}
g[0][0] = 1;
for (int i = 1; i <= n; ++i)
for (int j = 0; j <= n; ++j)
g[i][j] = (g[i - 1][j] + (!j ? 0 : (ll)s[i] * g[i - 1][j - 1] % mod)) % mod;
for (int i = 1; i <= n; ++i) s1 = (s1 + g[n][i]) % mod;
printf("%d\n", (s1 - s2 + mod) % mod); return 0;
}
[CSP-S2019] 劃分
還記得格雷碼的 再 嗎,伏筆回收,這題毒瘤高精度。
給出一個長為 \(n\) 的序列 \(a\) ,找到一些分界點 \(1\le k_1<k_2<\cdot\cdot\cdot<k_p<n\),使得
\[\sum_{i=1}^{k_1}a_i\le \sum_{i=k_1+1}^{k_2}a_i\le \cdot\cdot\cdot\le \sum_{i=k_p+1}^n a_i \]注意 \(p\) 可以等於 \(0\) ,此時表示不分開。在此基礎上,最小化
\[\left(\sum_{i=1}^{k_1}a_i\right)^2+\left( \sum_{i=k_1+1}^{k_2}a_i\right)^2+ \cdot\cdot\cdot\le \left(\sum_{i=k_p+1}^n a_i\right)^2 \]求出這個最小值。(\(2\le n\le4\times10^7,1\le a_i\le10^9\))
但凡出題人讓對某個數取模.jpg 首先根據均值不等式的知識,我們知道:
這啟示我們如果能劃分就要儘量劃分,這樣才能使最終的結果最小,也就是說每次劃分要使得劃分的最後一段最短。通過這個思路,我們很自然想到 \(\rm dp\) ,我們設 \(f_{i}\) 以 \(i\) 結尾的劃分序列的最小代價,則有貪心轉移:
\[f_i=\min_{j=1}^{i-1} f_j+(s_i-s_j)^2[las_j\le (s_i-s_j)] \]其中 \(las_i\) 表示以 \(i\) 結尾的劃分段之和,\(s_i\) 表示字首和,最終答案即為 \(f_n\) 。但顯然 \(\mathcal{O}(n^2)\) 的轉移不足以通過這道資料範圍及其毒瘤的題,所以考慮優化。注意到這個形式非常像滑動視窗,且時間複雜度要求 \(\mathcal{O}(n)\) ,所以考慮單調佇列優化。
如果我們設 \(g_i\) 表示劃分段結尾為 \(i\) 時上一個劃分段的末尾,則根據之前所說的最後一段最短,\(g_i\) 即為:
\[g_i=\max_{pos=1}^{i-1} pos[s_i-s_{pos}\ge s_{pos}-s_{g_{pos}}] \]移項,有:
\[g_i=\max_{pos=1}^{i-1} pos[2s_{pos}-s_{g_{pos}}\le s_i] \]令 \(f(x)=2s_x-s_{g_x}\) ,則 \(g_i=\max_{pos=1}^{i-1}pos[f(pos)\le s_i]\) 。如果 \(x,y\) 滿足\(f(x),f(y)\le s_i\) ,且 \(x<y\) ,則 \(x\) 無論如何都不會成為 \(g_i\) 。這樣我們就匯出了單調佇列的思路。
單調佇列其實就兩個部分,一個是處理過時決策,一個是入隊維護單調性。首先看處理過時決策,因為 \(s_i\) 是單調遞增的,所以如果隊頭 \(head\) 的下一位 \(next\) 都滿足 \(f(next)\le s_i\) 的話,因為 \(next\) 比 \(head\) 晚進隊,即 \(next \ge head\) ,所以可以直接彈出隊首。它可能在之前 \(s_i\) 較小時是最優解,但當 \(s_i\) 不斷變大時就被後面的值超過了,即過時了。處理完過時決策後隊首就是這一次的 \(g_i\) 。而入隊維護單調性時,彈出隊尾所有 \(tail\) 滿足 \(f(tail)\ge f(x)\) ,因為 \(f(x)\) 顯然越小越好,而 \(x\) 比它們大 \(f\) 的值還比它們小,顯然可以直接取代。最終可以做到 \(\mathcal{O}(n)\) 求出 \(g_i\) 。求出來 \(g_i\) 之後這題才真正開始,統計答案需要高精度,各種卡空間卡時間,真是大毒瘤。總之最終時間複雜度 \(\mathcal{O}(n)\) ,足以通過本題。
#include <cstdio>
#include <cstring>
#define val(x) ((s[x] << 1) - s[g[x]])
typedef unsigned long long ull; const int base = 1e9;
const int mod = (1 << 30) - 1, N = 4e7 + 10, M = 1e5 + 10;
int g[N], b[N], p[M], q[N]; ull s[N];
template <typename T>
inline void read(T& x)
{
x = 0; char ch; int f = 1;
while ((ch = getchar()) < '0' || ch > '9')
f = (ch ^ '-' ? 1 : -1);
while (x = (x << 1) + (x << 3) + ch - '0',
(ch = getchar()) >= '0' && ch <= '9') ;
x *= f;
}
struct bigInt
{
int len; ull num[4];
bigInt() { len = 0; memset(num, 0, sizeof (num)); }
bigInt(ull x)
{ len = 0; while (x) num[len++] = x % base, x /= base; }
bigInt operator+(const bigInt& x)
{
bigInt res;
res.len = len > x.len ? len : x.len; ull k = 0;
for (int i = 0; i < res.len; ++i)
{
res.num[i] = num[i] + x.num[i] + k;
k = res.num[i] / base; res.num[i] -= base * k;
}
while (k) { res.num[res.len++] = k % base; k /= base; }
return res;
}
bigInt operator*(const bigInt& x)
{
bigInt res;
res.len = len + x.len - 1; ull k = 0;
for (int i = 0; i < len; ++i)
for (int j = 0; j < x.len; ++j)
res.num[i + j] += num[i] * x.num[j];
for (int i = 0; i < res.len; ++i)
{
res.num[i] += k;
k = res.num[i] / base;
res.num[i] -= base * k;
}
while (k) { res.num[res.len++] = k % base; k /= base; }
return res;
}
}ans;
inline void print(const bigInt& x)
{
printf("%llu", x.num[x.len - 1]);
for (int i = x.len - 2; ~i; --i) printf("%llu", x.num[i]);
}
int main()
{
int n, type; read(n); read(type);
if (type)
{
ull x, y; int z, m, l, r;
read(x); read(y); read(z); read(b[1]); read(b[2]); read(m);
for (int i = 3; i <= n; ++i)
b[i] = (x * b[i - 1] & mod) + (y * b[i - 2] & mod) + z & mod;
for (int j = 1; j <= m; ++j)
{
read(p[j]); read(l); read(r);
for (int i = p[j - 1] + 1, a; i <= p[j]; s[i] = s[i - 1] + a, ++i)
a = b[i] % (r - l + 1) + l;
}
}
else for (int i = 1, a; i <= n; s[i] = s[i - 1] + a, ++i) read(a);
int head = 1, tail = 1;
for (int i = 1; i <= n; ++i)
{
while (head < tail && val(q[head + 1]) <= s[i]) ++head;
g[i] = q[head];
while (head <= tail && val(q[tail]) >= val(i)) --tail;
q[++tail] = i;
}
for (int pos = n; pos; pos = g[pos])
{
bigInt tmp(s[pos] - s[g[pos]]);
ans = ans + tmp * tmp;
}
print(ans); putchar('\n'); return 0;
}
[CSP-S2019] 樹的重心
為什麼這題評分會比 D1T3 低啊,不過有一說一我已經看不懂我之前在寫什麼了。
給出一個有 \(n\) 個結點的樹 \(\mathcal{T}\),求出 \(\mathcal{T}\) 單獨去掉每條邊後分裂出來的兩棵子樹重心編號之和,即
\[\sum_{(u,v)\in E}\left(\sum_{1\le x\le n \text{且}x\text{號點是}\mathcal{T}_u'\text{的重心}}x+\sum_{1\le y\le n \text{且}y\text{號點是}\mathcal{T}_u'\text{的重心}}y\right) \](\(1\le n\le 3\times10^5\))
一道非常神仙的二次掃描換根 \(\rm dp\) 。首先我們要知道重心的一種求法:
如果我們現在在 \(x\) ,判斷 \(x\) 是否是重心,如果是,則找到重心演算法結束,否則進入 \(x\) 的重兒子遞迴搜尋。
類似倍增求 \(\rm lca\) ,我們第一遍 \(\rm dfs\) 考慮構造一個重兒子鏈,每次倍增往下跳,這樣可以做到對於一個結點 \(\mathcal{O}(\log n)\) 的時間求重心。而這樣只能找到一個重心,但一棵樹可能有兩個重心。不過沒關係,我們只需要找到一個重心後判一下它的重兒子和父親是不是重心就行了,因為另外一個重心顯然只可能是這倆的其中一個。現在對於指定子樹找重心的問題解決了,接下來我們解決刪邊的問題。
刪去一條邊 \((u,v)\) 得到的兩棵子樹有兩個部分:子樹內和子樹外。我們令 \(v\) 對應的是子樹內,\(u\) 對應的是子樹外,如圖,綠色對應的是子樹內,紅色對應的是子樹外:
對於子樹內的部分很好處理,我們按照之前維護出來的以 \(1\) 為根的重兒子鏈跳就好了,但對於子樹外的就不太好處理了。因為它的重兒子鏈有一點變化,但不是很大,只有 \(u\) 的重兒子有所變化,但跳到 \(u\) 的重兒子後就一切跟 \(1\) 為根時一樣了。所以這啟示我們找到 \(u\) 為根時的重兒子,之後暴力更新 \(u\) 對應的重兒子鏈。具體來講,在第二次 \(\rm dfs\) 換根的過程中,對於 \((u,v)\) 這條邊,我們把 \(u,v\) 為根時它們對應的 \(size,fa,son\) 更新一下,然後更新重兒子鏈,接著跳重兒子鏈找重心,接著把根換到 \(v\) 遞迴下去,最後在退出 \(u\) 時,回溯一下結點資訊就好。
最後一個問題,怎麼找到 \(u\) 的重兒子。顯然,\(u\) 為根時,作比較的應該是 \(u\) 原本的重兒子和它父親結點在以 \(u\) 為根時對應的 \(size\) 。但非常遺憾,如果 \(v\) 對應的就是 \(u\) 的重兒子,它被刪掉之後這個比較就是有問題的。所以我們還要額外維護一個次重兒子。當在換根的過程中重兒子被刪掉時就用它作比較。這樣這道題就做完了,一些實現細節可以詳見程式碼,時間複雜度 \(\mathcal{O}(n\log n)\) 。
#include <cstdio>
#include <cstring>
inline int max(const int& a1, const int& a2) { return a1 > a2 ? a1 : a2; }
inline void swap(int& a1, int& a2) { int t = a1; a1 = a2; a2 = t; }
const int N = 3e5 + 10;
struct edge{ int v, next, u; }E[N << 1]; int p[N], cnt;
inline void init() { memset(p, -1, sizeof p); cnt = 0; }
inline void insert(int u, int v) { E[cnt].u = u; E[cnt].v = v; E[cnt].next = p[u]; p[u] = cnt++; }
//ff,ssize,sson 是在換根之後對應更新的資訊
int size[N], son[N], mson[N], pi[N][40], fa[N], sson[N], ssize[N], ff[N]; long long ans;
void dfs1(int u, int f)
{
size[u] = 1; fa[u] = f;
for (int i = p[u], v; i + 1; i = E[i].next)
{
v = E[i].v; if (v == f) continue;
dfs1(v, u); size[u] += size[v];
if (size[v] > size[son[u]]) mson[u] = son[u], son[u] = v;
else if (size[v] > size[mson[u]]) mson[u] = v;
}
pi[u][0] = son[u];
for (int i = 1; i <= 35; i++) pi[u][i] = pi[pi[u][i - 1]][i - 1];
}
//判斷是否是重心
inline int judge(int u, int sum) { return u * (max(ssize[sson[u]], sum - ssize[u]) <= sum / 2); }
void dfs2(int u, int f)
{
for (int i = p[u], v, b; i + 1; i = E[i].next)
{
v = E[i].v; if (v == f) continue;
ssize[u] = size[1] - size[v]; ff[u] = ff[v] = 0;
if (son[u] == v) sson[u] = mson[u];
else sson[u] = son[u];
if (ssize[f] > ssize[sson[u]]) sson[u] = f;
pi[u][0] = sson[u];
for (int j = 1; j <= 35; j++) pi[u][j] = pi[pi[u][j - 1]][j - 1];
//跳重兒子鏈類似倍增lca
b = u; for (int j = 35; j >= 0; j--) if (ssize[u] - ssize[pi[b][j]] <= ssize[u] / 2) b = pi[b][j];
ans += judge(sson[b], ssize[u]) + judge(b, ssize[u]) + judge(ff[b], ssize[u]);
b = v; for (int j = 35; j >= 0; j--) if (ssize[v] - ssize[pi[b][j]] <= ssize[v] / 2) b = pi[b][j];
ans += judge(sson[b], ssize[v]) + judge(b, ssize[v]) + judge(ff[b], ssize[v]);
ff[u] = v; dfs2(v, u);
}
sson[u] = pi[u][0] = son[u]; ff[u] = fa[u];
for (int i = 1; i <= 35; i++) pi[u][i] = pi[pi[u][i - 1]][i - 1];
ssize[u] = size[u];
}
int main()
{
int T, n, x, y; scanf("%d", &T);
while (T--)
{
init(); memset(son, 0, sizeof son); memset(ff, 0, sizeof ff); memset(fa, 0, sizeof fa);
scanf("%d", &n);
for (int i = 1; i < n; i++) scanf("%d%d", &x, &y), insert(x, y), insert(y, x);
dfs1(1, 0);
for (int i = 1; i <= n; i++) ssize[i] = size[i];
for (int i = 1; i <= n; i++) sson[i] = son[i];
for (int i = 1; i <= n; i++) ff[i] = fa[i];
dfs2(1, 0);
printf("%lld\n", ans); ans = 0;
}
return 0;
}
總結
- 在面對類似格雷碼這樣不算太難的題目,出題人一般會挖個小坑卡掉一些人,一定要注意這些小坑,比如格雷碼的
1ull << n
。 - 括號樹這種子樹差分的思想很常用,一個點子樹內的貢獻等於進入子樹前整體的貢獻和進入子樹後遞歸回來回溯時的貢獻之差。
- 面對一道感覺有點奇妙性質但無從下手的題目,類似樹上的數時,可以先從一些簡單的部分分入手,逐個突破。
- 將不好直接計算的方案數補集轉換,像 Emiya 家的飯一樣,或者直接容斥甚至二反掉也是可以的,只是注意別一頭盯著直接算鑽死衚衕。
- 面對資料範圍比較離譜的題目,比如劃分這道題時,一般都是推個 \(\rm dp\) 然後用各種 trick 加速,不過加速的前提是有 \(\rm dp\) 思路,所以要先思考一個成熟的 \(\rm dp\) 思想,再根據資料範圍想優化。常見的 \(\mathcal{O}(n)\) 優化有單調佇列,斜率優化等,\(\mathcal{O}(\log n)\) 的就矩陣快速冪,根據資料範圍選擇即可。
- 求重心的其中一種方法是跳重兒子鏈,一般在樹的重心這種多次求解,且跟子樹關係比較大的使用,依次刪所有的邊可以考慮二次掃描換根 \(\rm dp\) 。