# APCS 20251116 高級題解 > 作者:暴力又被TLE ## [1. 字母配對](https://zerojudge.tw/ShowProblem?problemid=r626) ### 題述 > 給 $n$ 個長度 $k_{1 \sim n}$ 的 stack,裡面的物品以字母表示,並各自有分數。 > 每次可以選 top 字母相同的兩個 stack pop,並獲得兩者分數總合。 > 求最大分數總和? > > $n \le 6$ > $k_i \le 12$ > 因為個人變數使用的習慣,$n$ 是 stack 數量,$k_i$ 是 stack 長度,與原題不同 ### 解法 考慮**多維DP**。 因為只需要考慮 stack 的 top,所以可以用一個長度為 $n$ 的數列,表示「每個 stack pop 了幾個」,像是 $[0,0,0]$ 就會是初始狀態,而 $[1, 0, 1]$ 就會是第 $1$、$3$ 個 stack 執行一次操作後的狀態。 想到這樣設計狀態後,轉移應該很顯然,比較麻煩的是如何實作。 狀態總共 $\Pi_{i = 1}^n k_i$ 個,轉移則 $O(n ^ 2)$,總共 $O(n ^ 2 \ \Pi_{i = 1}^n k_i)$,其實有點炸,但實務上不太容易跑滿,應該。 先放程式碼: ```python= def main(): from sys import stdin from functools import lru_cache read = iter(stdin.read().split()).__next__ @lru_cache def dp(idx): best = 0 for i in range(1, n): if idx[i] == len(stacks[i]): continue for j in range(i): if idx[j] == len(stacks[j]): continue ic, iv = stacks[i][idx[i]] jc, jv = stacks[j][idx[j]] if ic == jc: new_idx = list(idx) new_idx[i] += 1 new_idx[j] += 1 best = max(best, dp(tuple(new_idx)) + iv + jv) return best n = int(read()) stacks = [[(read(), int(read())) for _ in range(int(read()))] for _ in range(n)] print(dp((0,) * n)) main() ``` 首先,題目的輸入就不太 python-friendly,同一行內有數字、字元,以下提供一種特殊的輸入方法,變成類似 Cpp 的 `cin`,每次呼叫就讀入一個 token,`str.split` 預設會切割所有空白 ` ` 和換行 `\n`,因此可以這樣使用。 把所有 stack 讀入 `stacks`,並由初始狀態 `(0, 0, ..., 0)` 開始 Top-down DP,並用 `functools.lru_cache` 記憶化。 `lru_cache` 是一個「裝飾器」,就是「輸入是函數、輸出也是函數的一個函數」,他的主要邏輯大概像: ```python= def cache(function: Callable) -> Callable: memo = {} def wrapper(*args): if args not in memo: memo[args] = function(*args) return memo[args] return wrapper ``` 實際上他還實作了很多額外的東西,像是 LRU(Least Recent Used)就是會把 `memo` 中太久沒用到的 `key` 丟掉,進而節省空間,但平常不會設定 `maxsize`,所以沒有阿茲海默的問題,他有一個專門的 `maxsize=None` 版本 `functools.cache`,然而因為在 python 3.9 才加入,因此在 ZJ 用不了(目前 APCS 在 Python 3.10、ZJ 仍在 Python );除此之外,他也是 thread-safe 的,保證了安全性,但也意味著他的效率會比較低。 因為他是用 `dict` 做記憶化,輸入 function 的 argument **必須 hashable**,因此在遞迴地 call `dp` 時要將 `new_idx: list` 轉成 `tuple` 再傳入。 這個解寫起來十分簡單直覺,但常數太大被 ZJ TLE 了。 來看看第二個實作: ```python= def main(): from sys import stdin read = iter(stdin.read().split()).__next__ def encode(arr): res = 0 b = 1 for v in arr: res += v * b b *= base return res def dp(idx): key = encode(idx) if ~(val := memo[key]): return val best = 0 for i in range(1, n): if idx[i] == len(l[i]): continue ic, iv = l[i][idx[i]] for j in range(i): if idx[j] == len(l[j]): continue jc, jv = l[j][idx[j]] if ic == jc: new_idx = list(idx) new_idx[i] += 1 new_idx[j] += 1 best = max(best, dp(new_idx) + iv + jv) memo[key] = best return best n = int(read()) l = [[(read(), int(read())) for _ in range(int(read()))] for _ in range(n)] base = max(map(len, l)) + 1 memo = [-1] * (base ** n) print(dp((0,) * n)) main() ``` 主要改動是將 `lru_cache` 換成一個 `memo: list[int]`,並且用 `encode` 函數將**狀態數列**以 $k+1$ 進位轉換成**數字**作為 `memo` 的 index。這個就能過 ZJ 了。 進一步加速: ```python= def main(): from sys import stdin read = iter(stdin.read().split()).__next__ n = int(read()) l = [] lens = [] base = 1 bases = [] for i in range(n): k = int(read()) lens.append(k) bases.append(base) base *= k + 1 l.append([(read(), int(read())) for _ in range(k)]) dp = [-1] * base ans = dp[0] = 0 its = [0] * n for idx in range(base): v = dp[idx] if v != -1: if v > ans: ans = v for i in range(1, n): if its[i] == lens[i]: continue ic, iv = l[i][its[i]] iv += v for j in range(i): if its[j] == lens[j]: continue jc, jv = l[j][its[j]] if ic != jc: continue new_idx = idx + bases[i] + bases[j] nv = iv + jv if nv > dp[new_idx]: dp[new_idx] = nv for i in range(n): its[i] += 1 if its[i] <= lens[i]: break its[i] = 0 print(ans) main() ``` 改成 Bottom-up 省去遞迴的大開銷,並且用 `its` 手刻加法進位器,**均攤來說**該迴圈的時間複雜度是 $O(1)$。 然而實際上這個解沒有快很多,因為有很多狀態無法到達,可以直接跳過,但維護 `its` 卻創造了固定開銷。 ### 推類題 (夾帶私貨) - [TIOJ 2371. E. 迷宮鑰匙圈 (Maze)](https://tioj.ck.tp.edu.tw/problems/2371) - 多維 DP - [APCSS. 太簡單的石板(Easy-peasy Puzzel)](https://apcs-simulation.com/problem/apcs1103) - 多維 DP(極難) - [APCSS. 暴力又被TLE 想讓人滅台](https://apcs-simulation.com/problem/apcs1703) - 進位制轉換(?) ## [2. 列印工廠](https://zerojudge.tw/ShowProblem?problemid=r627) ### 題述 > 給 $n$ 個工作的 最早開始時間、最晚結束時間、所需時間。 > 排列出一個順序,使工作互不重疊,並最大化「工作之間的最短間隔」。 > > $n \le 8$ > $0 \le \text{時刻} \le 1000$ ### 解法 看到 $n \le 8$ 應該可以大膽猜測時間複雜度帶 $n!$ 項;又看到「最大化最小值」,有 95% 以上是在考「對答案二分搜」。分析完考點,這題就做完了。 實際上,這屬於「單機排程」(single-machine scheduling)問題,這類問題通常都是 NP-hard,這題應該也能歸約到 NPC。 對於一個固定的排列,最大化的「最短間隔」可以在 $O(n \log R)$ 內對答案二分搜求得,因此枚舉 $n!$ 種排列,並且取最大即為答案。 「最大化」的二分搜,我自己很容易寫錯邊界條件的 +1-1(一般的二分搜模板都是最小化),因此我現在都習慣把一個反向的 `range` 當成新的值域,在上面二分搜,從此沒寫錯過。 枚舉全排列的部分,請善用 `itertools`、善待自己。`itertools` 裡面有無數寶藏,等著你挖掘。 ```python= def main(): from sys import stdin from itertools import permutations from bisect import bisect_left e = stdin.readline n = int(e()) s = [0] * n d = [0] * n t = [0] * n for i in range(n): s[i], d[i], t[i] = map(int, e().split()) lim = max(d) # check if gap can be >= x def check(x: int) -> bool: end = -x for i in p: end = max(end + x, s[i]) + t[i] if end > d[i]: return False return True ans = 0 r = range(lim, 0, -1) for p in permutations(range(n)): x = bisect_left(r, True, key=check) if x < lim and r[x] > ans: ans = r[x] print(ans) main() ``` 令人肝腸寸斷的是,`bisect` 在 python 3.10 才加入 `key` 這個 argument,所以上面的程式碼在 ZJ 上會直接 RE,但是在 APCS 還是能用的! 如果你在考場上忽然發現這個悲劇的事實,然而平常被 `bisect` 寵壞了忘記怎麼二分搜,可以進 Python IDLE $\to$ File $\to$ Module Browser,輸入 `bisect`,就會跳出原始碼,就可以複製貼上啦! <img src="https://hackmd.io/_uploads/rydMqa_ebe.png" alt="Image" width="80%"/> 下面我們好好寫二分搜: ```python= def main(): from sys import stdin from itertools import permutations e = stdin.readline n = int(e()) s = [0] * n d = [0] * n t = [0] * n for i in range(n): s[i], d[i], t[i] = map(int, e().split()) lim = max(d) def check(x): end = -x for i in p: end = max(end + x, s[i]) + t[i] if end > d[i]: return False return True ans = 0 for p in permutations(range(n)): if not check(ans): continue lo, hi = ans, lim while lo < hi: mid = lo + hi >> 1 if check(mid): lo = mid + 1 else: hi = mid if lo > ans: ans = lo - 1 print(ans) main() ``` ### 推類題 - [LC 3710. Maximum Partition Factor](https://leetcode.com/problems/maximum-partition-factor/description/) - 對答案二分搜 - [APCSS. 卡拉 OK(Karaoke)](https://apcs-simulation.com/problem/apcs2104_ex_py) - 對答案二分搜 - [APCSS. 分數密碼 (Fraction)](https://apcs-simulation.com/problem/apcs1303) - 枚舉排列 - [LC 2081. Sum of k-Mirror Numbers](https://leetcode.com/problems/sum-of-k-mirror-numbers/description/) - 枚舉排列 ## [3. 翻來覆去](https://zerojudge.tw/ShowProblem?problemid=r628) ### 題述 > 給定一個 $1 \sim n$ 的排列,表示一個 Stack,還有另一個空的 Stack。 > 每次操作可以: > 1\. `pop` 其中一個 Stack 並輸出 > 2\. `pop` 並 `push` 到另一個 Stack > 求輸出 $1 \sim n$ 至少需要幾次操作? > > $n \le 1e5$ ### 解法 由小到大枚舉 $i = 1 \sim n$,每次找到 $i$ 的位置,並把它前面的全部「倒」到另一個 Stack,再輸出。 令 $pos_i$ 為數字 $i$ 在輸入的數列 $a_{1 \sim n}$ 中的位置。 我們可以發現,所求就等於: $$ \sum_{i=1}^{n} \bigl| { j\in[i,n]\ :\ pos_j\in[\min(pos_{i-1},pos_i),\max(pos_{i-1},pos_i)] }\bigr| $$ 也就是對於每個 $i$ 計數 $j \ge i$ 且 $j$ 在 $i-1, i$ 之間的數量。 這其實跟經典的「逆序數對」問題非常像,他是計數 $i \lt j$ 且 $a_i \gt a_j$ 的數量,而這題有分治的解法(一邊 merge sort 一邊計數),然而分治常數大又難寫,因此大部分人都會直接使用可以「單點加值、查詢區間和」的資料結構,最適合、最簡單的資結是「樹狀數組 BIT(Binary Indexed Tree)」,可以先看 [這篇文章](https://cp.wiwiho.me/fenwick-tree/) 學習。 先看不使用資結的 naive $O(n ^ 2)$ 解法: 首先輸入並算出 `pos`。因為 BIT 是 1-based,所以這邊將 `pos` 也用 1-based 存,並定義 `pos[0] = 0`。 接著就是由大到小遍歷 `v`,這樣才能依序將值加入 BIT,每次加入前先將 `sum(bit[s:t])` 加入答案。 但這樣其實算的是 $\gt v$ 的數量而不是 $\ge v$,所以將 `ans` 初始化為 $n$。 ```python= def main(): from sys import stdin e = stdin.readline n = int(e()) l = list(map(int, e().split())) pos = [0] * (n + 1) for i, v in enumerate(l, 1): pos[v] = i ans = n bit = [0] * (n + 1) i = pos[n] for v in range(n - 1, -1, -1): ni = pos[v] s, t = i, ni if s > t: s, t = t, s ans += sum(bit[s:t]) bit[i] += 1 i = ni print(ans) main() ``` 接下來加上 BIT 的維護: `18 ~ 23` 行的兩個 `while`,這是區間查詢 BIT 的特殊寫法(我在洛谷上學到的:[浅谈树状数组的优化及扩展](https://www.luogu.com.cn/article/790vjft4)),稍微快也比較好寫。 ```python= def main(): from sys import stdin e = stdin.readline n = int(e()) l = list(map(int, e().split())) pos = [0] * (n + 1) for i, v in enumerate(l, 1): pos[v] = i ans = n bit = [0] * (n + 1) i = pos[n] for v in range(n - 1, -1, -1): ni = pos[v] s, t = i, ni if s > t: s, t = t, s while t > s: ans += bit[t] t &= t - 1 while s > t: ans -= bit[s] s &= s - 1 x = i while x <= n: bit[x] += 1 x += x & -x i = ni print(ans) main() ``` 於是就這樣 AC 了! --- 能不能再更快? 觀察到:因為題目給定的數列是排列,即保證沒有重複數字,因此 BIT 維護的那個數列中只有 $0, 1$,最適合維護 $0, 1$ 的資料結構就是 `bitset` 了! 簡單來講,使用分塊法,塊長 $64$,每次區間查詢時,第一塊和最後一塊可能不完整,因此使用 `bitset` 查詢,而中間的塊則用 BIT 維護;需要特判查詢區間在同一塊內的情況。 時間複雜度 $O(n \log \frac{n}{64})$,空間從原本 $2e5$ 個 `int` 降到 $1e5 + 2 \times \frac{1e5}{64}$ 個 `int`。 ```cpp= #include <iostream> using namespace std; #define pcc __builtin_popcountll char num[20], *p1 = num; int pos[100001], bit[1564]; unsigned long long bitset[1564]; int main() { cin.tie(0)->sync_with_stdio(0); int n; cin >> n; int m = (n >> 6) + 1; long long ans = n; for (int i = 1, v; i <= n; ++i) cin >> v, pos[n - v] = i; for (int i = pos[0], v = 1; v <= n; ++v) { int ni = pos[v], s, t; if (i < ni) s = i, t = ni; else s = ni, t = i; if (s >> 6 == t >> 6) { ans += pcc(bitset[s >> 6] & (-1ull << (s & 63)) & (-1ull >> (63 ^ (t & 63)))); } else { ans += pcc(bitset[s >> 6] >> (s & 63)) + pcc(bitset[t >> 6] << (63 ^ (t & 63))); ++(s >>= 6); t >>= 6; for (; t > s; t &= t - 1) ans += bit[t]; for (; s > t; s &= s - 1) ans -= bit[s]; } bitset[i >> 6] |= 1ull << (i & 63); for (++(i >>= 6); i <= m; i += i & -i) ++bit[i]; i = ni; } cout << ans << '\n'; return 0; } ``` ### 推類題 海苔裡面是考點爆雷,建議不要開。 - [CSES. Josephus Problem II](https://cses.fi/problemset/task/2163) - BIT ||上二分搜(請不要用平衡樹吃毒)|| - [CSES. Nested Ranges Count](https://cses.fi/problemset/task/2169) - BIT ||+ 離散化|| - [CSES. Increasing Subsequence II](https://cses.fi/problemset/task/1748) - BIT ||優化 DP 轉移|| - [CSES. Distinct Values Queries](https://cses.fi/problemset/task/1734) - BIT ||+ 離線 + 掃描線|| - [CSES. Increasing Array Queries](https://cses.fi/problemset/task/2416) - BIT ||+ 差分 + 離線 + 掃描線|| - [CSES. Polynomial Queries](https://cses.fi/problemset/task/1736) - BIT ||+ 數學(開三棵分別維護 $ax^2 + bx + c$ 的 $a, b, c$)|| - [CSES. Sliding Window Mex](https://cses.fi/problemset/task/3219) - BIT ||上二分搜|| - [CSES. Pyramid Array](https://cses.fi/problemset/task/1747) - BIT ||+ 有趣思考|| ### 延伸閱讀:線段樹 [線段樹全面剖析](https://hackmd.io/@ericshen19555/segment_tree_weaker_treap_better)(雖然仍 WIP 但基礎的都完成了)