1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453
| \documentclass{article}
\usepackage{graphicx} \usepackage{amsmath} \usepackage{amssymb} \usepackage{xeCJK} \usepackage{multicol} \usepackage{tocloft} \usepackage{xcolor} \usepackage[cachedir=_minted-main]{minted} \usepackage[a4paper, left=2cm, right=2cm, top=2.5cm, bottom=2.5cm]{geometry}
\setminted{ breaklines=true, breakanywhere=true, fontsize=\small, frame=single, bgcolor=gray!5, tabsize=2, }
\renewcommand{\cftsecleader}{\cftdotfill{\cftdotsep}}
\usepackage[colorlinks=true, linkcolor=black, anchorcolor=black, citecolor=black, filecolor=black, menucolor=black, runcolor=black, urlcolor=blue]{hyperref}
\begin{document}
\begin{center} \section*{白茄子} \end{center}
\subsection*{题目大意}
给定一个只包含 \texttt{0} 和 \texttt{1} 的字符串 $S$。
如果一个 $01$ 序列的逆序对数量为奇数,那么称它为“茄子序列”。
对于任意一个 $01$ 序列 $T$,定义 $f(T)$ 为最小的正整数 $k$,使得我们能够把 $T$ 划分成 $k$ 个连续子段,并且每个子段都是茄子序列。如果无论如何划分都做不到,则定义 $f(T)=0$。
题目要求原串 $S$ 的所有非空子序列的 $f$ 值之和,对 $998244353$ 取模。
\subsection*{解题思路}
总长度上界达到 $10^7$,显然不可能枚举子序列,更不可能对每个子序列再跑一次分段 DP。
这道题真正要做的事情只有两步: \begin{itemize} \item 先把“一个固定的 $01$ 串,它的 $f$ 值是多少”压成一个很小的自动机状态。 \item 再在原串上做“子序列自动机 DP”,统计有多少个子序列落到每个状态。 \end{itemize}
下面从头推导这个状态。
\subsubsection*{1. 先只看一个子段,什么信息才是必要的}
如果我们只关心一个连续子段是否为茄子序列,那么真正有用的只有两个奇偶量: \begin{itemize} \item $a$:当前子段中 \texttt{1} 的个数奇偶。 \item $p$:当前子段中逆序对个数奇偶。 \end{itemize}
原因很简单。往当前子段末尾再加入一个字符时: \begin{itemize} \item 如果加入的是 \texttt{1},那么 \texttt{1} 的个数奇偶翻转,但不会新增任何逆序对。 \item 如果加入的是 \texttt{0},那么它会与前面所有的 \texttt{1} 形成逆序对,所以逆序对奇偶需要额外异或上“当前 \texttt{1} 的个数奇偶”。 \end{itemize}
于是转移就是: $$ (a,p) \xrightarrow{1} (a \oplus 1,\, p) $$ $$ (a,p) \xrightarrow{0} (a,\, p \oplus a) $$
因此,一个“尚未结束的当前子段”只有四种内部状态: $$ (0,0),\ (1,0),\ (0,1),\ (1,1) $$
而一个子段是否合法,只看最后的 $p$ 是否为 $1$。也就是说,合法状态恰好是: $$ (0,1),\ (1,1) $$
\subsubsection*{2. 固定序列时的最优分段摘要}
固定整个序列 $T$,从左到右扫描它。上一小节已经说明,一个\textbf{未结束子段}只需要记录内部状态 $(x,y)$。而对整个前缀来说,后续字符只会继续接到最后一个未结束子段后面,因此只需要记录:最后一段的内部状态,以及前面已经结束了多少个合法段。
于是定义 $$ d_{xy} $$ 表示当前已经读完某个前缀后,在所有“最后一个未结束子段的内部状态为 $(x,y)$”的切分方案中,前面已经结束的合法子段数的最小值。
这四个量 $$ D=(d_{00},d_{10},d_{01},d_{11}) $$ 合在一起,就是当前前缀的最优摘要。注意状态 $(0,0)$ 不一定表示空段,例如子段 \texttt{11} 的内部状态也会回到 $(0,0)$。
初始时我们还没有读任何字符。注意"未结束子段"允许是空串:空串里 \texttt{1} 的个数为 $0$,逆序对个数也为 $0$,所以空串的内部状态就是 $(0,0)$。此时前面没有任何已结束段,因此: $$ d_{00}=0,\qquad d_{10}=d_{01}=d_{11}=+\infty $$ 其余三个位置都是 $+\infty$,是因为在一个字符都没有读过的时候,不可能存在一种切分方案使得最后一段的内部状态不是 $(0,0)$。
现在读下一个字符时,分成两步。
如果当前未结束子段已经合法,也就是它处于 $(0,1)$ 或 $(1,1)$,那么我们就可以选择在这里截断,让它变成一个完整答案段,然后重新开始构造下一段。
因此,在读入这个字符之前,以状态 $(0,0)$ 去承接它的最小代价为 $$ r=\min \left(d_{00},\, d_{01}+1,\, d_{11}+1\right) $$ 它对应三种来源:原来就在 $(0,0)$,或者把一个合法的 $(0,1)$、$(1,1)$ 子段截断后重新开始。
接下来再把这个字符真正接到最后一段后面。根据上一小节的单段状态转移,可得:
\textbf{如果读入的是 \texttt{0}}: $$ \begin{cases} d'_{00}=r\\ d'_{10}=d_{11}\\ d'_{01}=d_{01}\\ d'_{11}=d_{10} \end{cases} $$
\textbf{如果读入的是 \texttt{1}}: $$ \begin{cases} d'_{00}=d_{10}\\ d'_{10}=r\\ d'_{01}=d_{11}\\ d'_{11}=d_{01} \end{cases} $$
至此,对于任意一个前缀,我们都可以用四元组 $D=(d_{00},d_{10},d_{01},d_{11})$ 来概括它的最优分段信息。
整串处理结束以后,最后那个未结束子段本身也必须合法,所以只有状态 $(0,1)$ 和 $(1,1)$ 可以作为最终答案。于是: $$ f(T)= \begin{cases} 0, & \min(d_{01},d_{11})=+\infty\\ \min(d_{01},d_{11})+1, & \text{否则} \end{cases} $$
这里最后的 $+1$,就是把最后那个合法但尚未结算的子段也计入答案。也就是说,第 2 节真正得到的结论,是“一个前缀可以被四元组 $D$ 完整概括”。
\subsubsection*{3. 从“固定子序列上的 DP”到“原串上的子序列 DP”}
真正的关键跳跃就在这里。
第二节做的事情,是解决下面这个子问题:\textbf{如果某个子序列 $T$ 已经被选出来了,那么怎样在线性扫描 $T$ 的过程中求出 $f(T)$。}
在这个过程中,我们始终维护四元组 $$ D=(d_{00},d_{10},d_{01},d_{11}) $$ 并且每读入一个字符 \texttt{0} 或 \texttt{1},$D$ 都会按照固定公式转移到一个新的四元组。整串处理结束以后,$f(T)$ 也只由最终落到的这个四元组决定。
也就是说,对于一个已经选出的子序列 $T$,我们完全可以把“求 $f(T)$”理解成下面这个过程: \begin{itemize} \item 初始状态是 $$ D_{\mathrm{start}}=(0,+\infty,+\infty,+\infty) $$ \item 按顺序把 $T$ 的每个字符喂进去。 \item 最终停在某个四元组 $D$。 \item 再由这个 $D$ 读出 $f(T)$。 \end{itemize}
于是,子序列 $T$ 本身的具体长相其实已经不重要了。对于后续计算来说,真正重要的只有一件事:\textbf{这个子序列在第二节的 DP 里,最终会落到哪个四元组。}
这就把问题自然地转成了:\textbf{在原串 $S$ 的所有子序列中,有多少个子序列最终会落到每个四元组。}
现在从左到右扫描原串 $S$。设当前已经处理到某个前缀,定义 $$ \texttt{cnt}[D] $$ 表示这个前缀的所有子序列中,有多少个子序列在“第二节的那个 DP”里会落到状态 $D$。
初始时只有空子序列一个,它对应的状态就是起始四元组,所以: $$ \texttt{cnt}[D_{\mathrm{start}}]=1 $$
接下来读入原串的下一个字符 $c$ 时,每个旧子序列都有且只有两种选择: \begin{itemize} \item \textbf{不选}这个字符。那这个子序列本身不变,所以它在第二节 DP 里的最终状态也不变。 \item \textbf{选}这个字符。那这个子序列就在原来的末尾多接了一个字符 $c$,因此它在第二节 DP 里的状态,会从原来的 $D$ 通过“读入一个字符 $c$”的固定转移,变成新的状态 $D'$。 \end{itemize}
这就是从“固定子序列上的 DP”跳到“原串上的子序列 DP”的根本原因:\textbf{第二节已经告诉我们,一个子序列只要知道当前四元组 $D$,再多接一个字符以后会变成什么状态就是完全确定的。}
因此我们根本不需要把每个子序列真的存下来,只需要统计每个状态里当前有多少个子序列即可。
\subsubsection*{4. 按状态统计所有子序列}
有了上一节的理解,转移就非常直接了。
假设当前扫描到字符 $c$,那么每个状态 $D$ 里的所有子序列会分成两部分: \begin{itemize} \item 一部分不选 $c$,继续留在 $D$。 \item 另一部分选 $c$,统一转移到 $\texttt{nxt}(D,c)$。 \end{itemize}
所以新的计数满足: \begin{itemize} \item 不选的贡献直接保留。 \item 选的贡献整体加到后继状态上。 \end{itemize}
当整个原串处理结束以后,所有非空子序列都会被分到某个四元组状态里。而第二节已经说明,最终状态一旦确定,$f(T)$ 也就确定了。
因此答案就是: $$ \sum_D \texttt{cnt}[D] \times \texttt{val}(D) $$
其中 $\texttt{val}(D)$ 表示“如果一个子序列最终落到状态 $D$,那么它的 $f$ 值是多少”。
注意空子序列也被统计进去了,但它始终停在起始状态,而起始状态对应的 $f$ 值为 $0$,所以不会对答案产生任何影响,不需要额外减掉。
最后只剩一个实现层面的问题:这样的四元组状态会不会很多?
乍一看,$d_{00},d_{10},d_{01},d_{11}$ 都可能越来越大,状态数似乎会爆炸。但实际上所有可达状态都满足下面两个不变量。
\textbf{性质一:对任意可达四元组,忽略掉 $+\infty$ 以后,剩下的有限值里一定至少有一个是 $0$。等价地说,四元组中至少有一个分量恰好等于 $0$。}
证明如下。初始状态 $(0,+\infty,+\infty,+\infty)$ 显然满足这一性质。假设当前状态满足它,讨论读入一个新字符后的下一状态: \begin{itemize} \item 如果当前 $d_{00}=0$,那么 $r=0$,下一状态里自然会出现一个 $0$。 \item 如果当前 $d_{00}\ne 0$,那么由归纳假设可知,当前四元组四个分量里至少有一个是 $0$。既然这个 $0$ 不在 $d_{00}$,那它就只能出现在 $d_{10},d_{01},d_{11}$ 之中。而这时 $$ \text{读入 } \texttt{0} \text{ 后的新状态为 } (r,d_{11},d_{01},d_{10}), $$ $$ \text{读入 } \texttt{1} \text{ 后的新状态为 } (d_{10},r,d_{11},d_{01}). $$ 也就是说,无论读入什么字符,$d_{10},d_{01},d_{11}$ 这三个值都会原样出现在下一状态中,只是位置发生了变化。因此下一状态里仍然至少有一个 $0$。 \end{itemize}
\textbf{性质二:对任意可达四元组,每个有限分量都只能是 $0,1,2$ 之一。}
我们用归纳法说明。
\begin{itemize} \item 初始状态是 $(0,+\infty,+\infty,+\infty)$,显然成立。 \item 假设当前状态的所有有限值都只可能是 $0,1,2$。由性质一可知,当前四元组里至少有一个分量等于 $0$,于是 $$ r=\min(d_{00},\, d_{01}+1,\, d_{11}+1) $$ 只可能取到 $0$ 或 $1$。 \item 下一状态的四个分量,无论读入 \texttt{0} 还是 \texttt{1},都只是从 $r,d_{10},d_{01},d_{11}$ 这几个值里挑出来的,因此新的有限值仍然只可能是 $0,1,2$。 \end{itemize}
所以,对任意可达状态,每个分量都只可能属于集合 $$ \{+\infty,0,1,2\} $$
这就直接说明状态数不可能爆炸。因为四个位置每个都只有常数种取值,理论上状态总数至多是 $$ 4^4=256 $$ 个,实际上再结合“至少有一个有限值为 $0$”这一条件,还会更少。
于是,从初始状态 $$ (0,+\infty,+\infty,+\infty) $$ 出发,把 \texttt{0} 和 \texttt{1} 两种转移都不断展开,实际可达状态总共只有 $40$ 个。
这个常数已经很小了,所以实现时直接对这 $40$ 个状态做上一段的子序列计数 DP 即可,完全没有必要再继续压缩状态。
\subsection*{代码实现}
下面给出完整实现。代码会在程序启动时自动构造这 $40$ 个可达状态,之后对每个测试串做线性 DP。
\subsubsection*{实现指导}
如果直接照着上面的思路写代码,最容易卡住的地方主要有两个:第一是如何把这 $40$ 个状态真正枚举出来,第二是如何把“选或不选”的子序列 DP 和状态转移接起来。一个比较顺手的实现顺序如下。
\begin{itemize} \item \textbf{先把单个状态的转移写出来。}用 \texttt{State = array<int, 4>} 表示四元组,顺序固定为 \texttt{(d00, d10, d01, d11)}。然后写两个辅助函数: \begin{itemize} \item \texttt{move\_state(d, bit)}:把第二节的转移公式原样翻译成代码,输入旧状态和新字符,输出新状态。 \item \texttt{get\_value(d)}:根据最终状态 $d$ 读出这个子序列的 $f$ 值,也就是 $\min(d_{01},d_{11})+1$ 或 $0$。 \end{itemize}
\item \textbf{再用 BFS 枚举所有可达状态。}从初始状态 \texttt{\{0, INF, INF, INF\}} 出发。用 \begin{itemize} \item \texttt{states} 存所有已经发现的状态, \item \texttt{id[state]} 记录每个状态的编号, \item \texttt{queue} 按 BFS 方式扩展。 \end{itemize} 每次弹出一个状态后,分别尝试读入 \texttt{0} 和 \texttt{1},得到两个后继状态;如果某个后继状态还没出现过,就分配新编号并入队。这样就能把整张状态图一次性建出来。
\item \textbf{然后写子序列计数 DP。}设 \texttt{dp[q]} 表示:当前已经扫描完原串某个前缀后,有多少个子序列会落到状态 \texttt{q}。初始时只有空子序列存在,所以只有起始状态的计数是 $1$。 当读到一个新字符时: \begin{itemize} \item 不选这个字符,状态不变,所以可以先令 \texttt{ndp = dp}。 \item 选这个字符,那么原来在状态 \texttt{q} 的所有子序列,都会一起转移到 \texttt{nxt[q][bit]},于是把 \texttt{dp[q]} 累加过去即可。 \end{itemize} 扫描完整个字符串后,再把所有状态上的 \texttt{dp[q] * val[q]} 累加起来,就是答案。
\item \textbf{最后在 \texttt{main()} 里先预处理一次,再回答所有询问。}由于状态图只和题目规则有关,与具体输入字符串无关,所以 \texttt{build\_automaton()} 只需要调用一次。之后每组测试数据单独调用 \texttt{solve\_one()} 即可。 \end{itemize}
\begin{minted}{cpp} #include <bits/stdc++.h> using namespace std;
static constexpr int MOD = 998244353; static constexpr int INF = (int)1e9;
using State = array<int, 4>; // 顺序为 d00, d10, d01, d11
struct Automaton { int start = 0; vector<array<int, 2>> nxt; vector<int> val; // 该状态对应的 f(T) };
int add_one(int x) { return x >= INF ? INF : x + 1; }
// 在“固定串最少分段 DP”里,读入一个新字符后的状态转移 State move_state(const State& d, int bit) { int restart = min({d[0], add_one(d[2]), add_one(d[3])}); if (bit == 0) { return State{restart, d[3], d[2], d[1]}; } else { return State{d[1], restart, d[3], d[2]}; } }
// 当前状态本身对应的 f(T) int get_value(const State& d) { int best = min(d[2], d[3]); return best >= INF ? 0 : best + 1; }
// 从初态出发,BFS 枚举所有可达状态 Automaton build_automaton() { vector<State> states; vector<array<int, 2>> nxt; vector<int> val; map<State, int> id; queue<int> q;
auto get_id = [&](const State& s) -> int { auto it = id.find(s); if (it != id.end()) return it->second; int nid = (int)states.size(); id[s] = nid; states.push_back(s); nxt.push_back({0, 0}); val.push_back(0); q.push(nid); return nid; };
Automaton aut; aut.start = get_id(State{0, INF, INF, INF});
while (!q.empty()) { int u = q.front(); q.pop();
val[u] = get_value(states[u]); for (int bit = 0; bit < 2; ++bit) { State ns = move_state(states[u], bit); nxt[u][bit] = get_id(ns); } }
aut.nxt = move(nxt); aut.val = move(val); return aut; }
int solve_one(const string& s, const Automaton& aut) { int m = (int)aut.nxt.size(); vector<int> dp(m, 0), ndp(m, 0);
// 空子序列 dp[aut.start] = 1;
for (char ch : s) { int bit = ch - '0';
// 不选当前字符 ndp = dp;
// 选当前字符 for (int q = 0; q < m; ++q) { if (dp[q] == 0) continue; int nq = aut.nxt[q][bit]; ndp[nq] += dp[q]; if (ndp[nq] >= MOD) ndp[nq] -= MOD; }
dp.swap(ndp); }
long long ans = 0; for (int q = 0; q < m; ++q) { ans = (ans + 1LL * dp[q] * aut.val[q]) % MOD; } return (int)ans; }
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
Automaton aut = build_automaton();
int T; cin >> T; while (T--) { string s; cin >> s; cout << solve_one(s, aut) << '\n'; } return 0; } \end{minted}
\subsection*{复杂度分析}
\begin{itemize} \item \textbf{预处理复杂度}:从初态出发 BFS 枚举可达状态,状态总数只有 $40$ 个,因此预处理复杂度是 $\mathcal{O}(1)$。 \item \textbf{单组时间复杂度}:设可达状态数为 $m=40$,扫描每个字符时做一次大小为 $m$ 的 DP,故时间复杂度为 $\mathcal{O}(40 \times |S|)$,也就是线性复杂度。 \item \textbf{总时间复杂度}:$\mathcal{O}\!\left(40 \times \sum |S|\right)$。 \item \textbf{空间复杂度}:自动机和 DP 数组都只有常数大小,空间复杂度为 $\mathcal{O}(1)$。 \end{itemize}
\subsection*{小结}
这道题最难的地方,不是最后的子序列 DP,而是先把“最少分段数”这个看起来很全局的量压成一个很小的自动机状态。
一旦完成了这一步,后面的部分就会变成非常标准的套路:\textbf{子序列计数 + 自动机转移}。整个算法的本质,就是先把复杂条件局部化,再把所有子序列统一交给自动机去分类统计。
\end{document}
|