# APCS 20250615 題解
> 作者:沈宗叡(暴力又被TLE)
## [1. 小心陷阱](https://zerojudge.tw/ShowProblem?problemid=q836)
### 簡易題述
- 給定初始生命值 $k$,數線上從 $0$ 開始每次右跳「剩餘生命值」格。
- 若是落點在 $x_1$ 的倍數,生命值減少 $y_1$;落在 $x_2$ 的倍數減少 $y_2$。兩者可疊加。
- 生命值 $\le 0$ 時,結束並輸出停止位置。
### 解法
模擬即可。
$Time: O(?), Space: O(1)$
```python=
def main():
from sys import stdin
e = stdin.readline
k = int(e())
x1, y1 = map(int, e().split())
x2, y2 = map(int, e().split())
pos = 0 # 初始位置 0
while k > 0:
pos += k
if pos % x1 == 0: k -= y1
if pos % x2 == 0: k -= y2
print(pos) # 結束位置
main()
```
## [2. 轉盤得分](https://zerojudge.tw/ShowProblem?problemid=q837)
### 簡易題述
- $m$ 個輪盤上各有長度 $n$ 的小寫字母字串,逆時針成環狀。
- $k$ 個回合,每次輸入 $m$ 個旋轉格數,依照指定次數旋轉每一個輪盤。
- 對每個位置計算同字母最高頻率,最後輸出 $k$ 輪的總和。
### 解法
也是模擬即可。
旋轉的部分,可以用 string slicing 暴力、`collections.deque.rotate(x)` 硬轉,也可以記錄每個字串的旋轉值,取值時再算出旋轉後的 index。
C 或 C++ 要考慮餘數可能是負的問題,但 Python 不必,Py 又贏 :D
計數部分,可以善用 Python 的 `collections.Counter`,直接省去字串計數的實作。
至於旋轉是要用扣的還是用加的,可以隨便選一個再跑範測看對不對ww
$Time: O(k \cdot m \cdot n), Space: O(m \cdot n)$
```python=
def main():
from sys import stdin
from collections import Counter
sub = int.__sub__
e = stdin.readline
m, n, k = map(int, e().split())
l = [e().rstrip() for _ in range(m)] # 所有字串
shift = (0,) * m # 初始化旋轉量
ans = 0
for _ in range(k):
# 累加每個位置的旋轉量
shift = tuple(map(sub, shift, map(int, e().split())))
# 計算每個位置的答案
ans += sum(max(Counter(s[(i + j) % n]
for s, j in zip(l, shift)).values())
for i in range(n))
print(ans)
main()
```
## [3. 貪心闖關](https://zerojudge.tw/ShowProblem?problemid=q838)
### 簡易題述
- 給一個陣列表示 $1 \sim n$ 各位置的沙包數量,以及耐重上限 $t$
- 每回合:
- 找出沙包數最少的位置,多個則找最左的
- 沙包數超過 $t$ 則結束
- 否則,搬到右邊第一個仍有沙包的位置,並將當前位置歸零
- 當前搬了 $w$ 個沙包就得 $w$ 分
- 統計總得分
### 解法
一堆人砸 C++ 的 `set`(約等於 `sortedcontainers.SortedList`,只是底層是 RBT 紅黑樹),有的還砸兩個,真的有夠毒瘤。甚至有人要在兩顆線段樹上二分搜?
我們是善良的 Py 人,當然好好(?)地用 heap 和 雙頭ㄌㄌ(doubly linked list)模擬。
「找沙包數最少的位置」可以用 min-heap 輕鬆做到,「右邊第一個仍有沙包的位置」則也可用雙頭ㄌㄌ輕鬆找到。
但要怎麼把沙包搬過去?
理論上正確的做法是實作 increase-key,但普通的 heap 辦不到,要實作成樹狀,而且很難寫。
所以我們可以「懶惰」一點,如果要更新某個值,直接把新值丟進 heap,在 `heappop` 的時候額外判斷「這個沙包數量是不是對的」,如果錯就代表那是舊的值,跳過他。這種 lazy update 的概念在競賽中很常用。
$Time: O(n \log_2 n), Space: O(n)$
```python=
def main():
from sys import stdin
from heapq import heapify, heappop, heappush
e = stdin.readline
n, k = map(int, e().split())
l = list(map(int, e().split()))
# 用來找最少沙包的 min-heap
q = list(zip(l, range(n)))
heapify(q)
# 雙頭ㄌㄌ
le, ri = list(range(-1, n)), list(range(1, n + 2))
ri[-1] = n # sentinel
ans = 0
while q:
v, i = heappop(q)
if v > k: break
if l[i] != v: continue
l[i] = 0
ans += v
# ㄌㄌ上刪掉這點
ri[le[i]] = ri[i]
le[ri[i]] = le[i]
# 搬過去
r = ri[i]
if r < n: # 右邊還有沙包
l[r] += v
heappush(q, (l[r], r))
print(ans)
main()
```
其實瞪著範測稍微遍歷一下可以發現線性解:
考慮某個位置 $i$ 的沙包,他要被搬去哪?如果 $a_i \gt k$ 則搬不動跳過;否則找右邊第一個 $a_j \ge a_i$ 的位置 $j$,因為若是 $a_j \lt a_i$ 則 $j$ 應該會較 $i$ 先被搬空。
而「找右邊第一個 $\ge$ 自己的」是單調棧裸題。
以下實作中,用 `top` 變數快取 `stk[-1]` 以加快 access 速度。`17` 行則是將 `top` 寫回 `stk`,取代 `stk[0]` 中的 $\inf$。
`top` 初始化為 $\inf$,則維護單調棧的 `while` 不用多判斷 `stk` 是否為空。
最後別忘了將 `stk` 內剩餘的沙包的加總(就是不用往右邊搬的沙包)。
$Time: O(n), Space: O(n)$
```python=
def main():
from sys import stdin
e = stdin.readline
n, k = map(int, e().split())
ans = 0
stk = [] # 嚴格遞減 stack
top = float("INF") # > k 即可
for v in map(int, e().split()):
while top <= v:
ans += top
v += top
top = stk.pop()
if v <= k:
stk.append(top)
top = v
if stk: stk[0] = top
print(ans + sum(stk))
main()
```
C++ 版本(僅主要邏輯):
```cpp=
const int N = 3e5;
ll stk[N];
void solve() {
int n, k;
cin >> n >> k;
ll ans = 0;
stk[0] = 0x3f3f3f3f'3f3f3f3f; // inf 作為 sentinel
int t = 0; // stack top
for (;n--;) {
ll v;
cin >> v;
for (; stk[t] <= v; --t) ans += stk[t], v += stk[t];
if (v <= k) stk[++t] = v;
}
for (;t;) ans += stk[t--];
cout << ans << '\n';
}
```
再說回那些砸線段樹的,不管怎樣,砸兩棵線段樹真的太毒了!
因此以下我實作了一個 zkw 線段樹 + BIT 上二分搜的解法(實作量 $10 \sim 20$ 分鐘),兩者的功用分別等價於第一個解的 heap 和 雙頭ㄌㄌ。
因為最小值是要全局查詢,所以我將 zkw 開滿到完美二元樹,這樣可以 $O(1)$ 全局查詢最小值 index。
~~常數巨大,不負眾望地暴力又被TLE。~~
$Time: O(n \log_2 n), Space: O(n)$
```python=
def main():
from sys import stdin
from itertools import repeat
inf = float("INF")
e = stdin.readline
n, k = map(int, e().split())
m = 1 << n.bit_length() # 開滿到完美二元樹 >n的最小2冪
l = list(map(int, e().split())) + [inf] # 多開的節點全都指向 inf
zkw = [0] * m
zkw += range(n) # 葉子指向自己所代表的位置
zkw += repeat(n, m - n) # 多開的節點全都指向 inf
for i in range(m - 1, 0, -1): # O(n) bottom-up 建樹
zkw[i] = min(zkw[i << 1], zkw[i << 1 | 1], key=l.__getitem__)
bit = [i & -i for i in range(n + 1)] # 初始化全 1 的 BIT
ans = 0 # 所求
while l[zkw[1]] <= k: # 沙包最少的位置要能搬
i = zkw[1] # 找到沙包最少的位置 (全局查詢最小值index)
v = l[i]
ans += v
l[i] = inf # 改成inf 不可能再搬這邊
# 修改完 l[i] O(log n)更新 zkw
ii = (i + m) >> 1
while ii:
zkw[ii] = min(zkw[ii << 1], zkw[ii << 1 | 1], key=l.__getitem__)
ii >>= 1
# O(log n) 將 BIT 上此點刪除
ii = i + 1 # 1-based
while ii <= n:
bit[ii] -= 1
ii += ii & -ii
# O(log n) BIT 查詢到此點的前綴和
pre = 0
ii = i
while ii:
pre += bit[ii]
ii &= ii - 1
# O(log n) BIT 上二分搜找下一個 1 (有沙包的位置)
# 即為找 left most BIT前綴和 > pre
cur = 0
ii = 0
b = 1 << n.bit_length() - 1 # 由高位往下二分搜
while b:
if ii + b <= n and cur + bit[ii + b] <= pre:
ii += b
cur += bit[ii]
b >>= 1
# 實際上搜到的 left most BIT前綴和 <= pre
# ii 需要+1 但要從1-based改回0-based 所以扣回來
# ii += 1; ii -= 1
if ii < n: # 如果右邊有沙包
l[ii] += v # 搬過去
# 修改完 l[ii] O(log n) 更新 zkw
ii = (ii + m) >> 1
while ii:
zkw[ii] = min(zkw[ii << 1], zkw[ii << 1 | 1], key=l.__getitem__)
ii >>= 1
print(ans)
main()
```
C++ 版本(葬送可讀性):
```cpp=
#define REP(i, s, t) for (int i = (s); i < (t); ++i)
const int N = 3e5;
const ll inf = 0x3f3f3f3f'3f3f3f3f;
int bit[N + 1];
ll l[N + 1], zkw[1 << 20];
void solve() {
int n, k;
cin >> n >> k;
int m = 1 << (32 - __builtin_clz(n)); // 1 << n.bit_length()
REP(i, 0, n) cin >> l[i];
l[n] = inf;
REP(i, 0, n) zkw[i + m] = i;
REP(i, m + n, m << 1) zkw[i] = n;
for (int i = m; --i; ) zkw[i] = l[zkw[i << 1]] <= l[zkw[i << 1 | 1]] ? zkw[i << 1] : zkw[i << 1 | 1];
REP(i, 1, n + 1) bit[i] = i & -i;
ll ans = 0, v;
for (int i;(v = l[i = zkw[1]]) <= k;) {
ans += v;
l[i] = inf;
for (int ii = i + m; ii >>= 1; ) zkw[ii] = l[zkw[ii << 1]] <= l[zkw[ii << 1 | 1]] ? zkw[ii << 1] : zkw[ii << 1 | 1];
for (int ii = i + 1; ii <= n; ii += ii & -ii) --bit[ii];
int pre = 0, cur = 0, j = 0;
for (int ii = i; ii; ii &= ii - 1) pre += bit[ii];
for (int b = m; b >>= 1;) if (j + b <= n and cur + bit[j + b] <= pre) cur += bit[j += b];
if (j < n) {
l[j] += v;
for (j += m; j >>= 1; ) zkw[j] = l[zkw[j << 1]] <= l[zkw[j << 1 | 1]] ? zkw[j << 1] : zkw[j << 1 | 1];
}
}
cout << ans << '\n';
}
```
> 說實在這題非常不公平,C++ 使用者可以直接用 `multiset` $O(n \log_2 n)$ 水過這題,Python 卻沒有內建 SortedList 類型的資料結構。
> APCS simulation 出題團隊會避免這種語言間存在明顯優劣勢的題目,所以**大家都該寫 APCS 模擬測驗!**
## [4. 最遠分組](https://zerojudge.tw/ShowProblem?problemid=q839)
### 簡易題述
- 給定完全圖的距離鄰接矩陣,要將 $n$ 個點劃分為 $k$ 個非空組
- 最大化所有組對之間最短距離的最小值
### 解法
**這題跟 APCS 模擬測驗的 [顯卡爭霸戰](https://apcs-simulation.com/problem/apcs0404-Java-Python) 概念上很像,所以大家都該寫 APCS 模擬測驗!**
先說 APCS 考點內的解法。
為了要讓組組之間的最短距離盡量大,就應該讓短距離的邊都在組內,所以先對邊依照邊權排序,由小到大連邊,但規定要分成 $k$ 組,所以也不能全加。
觀察到:「是否能分成 $k$ 組」對「選了多少邊」有單調性,加更多邊必越不可能分 $k$ 組,可以用這個單調性對「要選多少邊」二分搜,並判斷當前選擇的邊數「是否能分成 $k$ 組」。
計算連通塊數量可以用 BFS/DFS 輕鬆 $O(n)$ 辦到。
輸入距離鄰接矩陣的時候,可以只存半邊就好,因為邊是無向邊。以下用 `itertools` 的 `islice` 取前半部分、`chain.from_iterable` 把每一行 `islice` 完後接起來。
$Time: O(n ^ 2 \log_2 n), Space: O(n ^ 2)$
```python=
def main():
from sys import stdin
from itertools import islice, chain
from operator import itemgetter
e = stdin.readline
def check(x: int) -> bool: # 連通塊數量 >= k?
vis = [False] * n # 記錄每一點是否遍歷過
# 建鄰接串列
G = [[] for _ in range(n)]
for _, i, j in islice(l, x):
G[i].append(j)
G[j].append(i)
count = 0 # 計算連通塊數量
for i in range(n):
if vis[i]: continue
vis[i] = True
count += 1
if count >= k: # 至少有 k 個連通塊
return True
# 遍歷並標記一整個連通塊
cur = [i]
while cur:
nxt = []
for i in cur:
for j in G[i]:
if vis[j]: continue
vis[j] = True
nxt.append(j)
cur = nxt
return False # 不足 k 個連通塊
n, k = map(int, e().split())
l = sorted(chain.from_iterable(
islice(((v, i, j) for j, v in enumerate(map(int, e().split()))), i)
for i in range(n)), key=itemgetter(0)) # 依照邊權升冪排序
# 二分搜答案
le, ri = 1, n * (n - 1) >> 1
while le < ri:
mid = (le + ri) >> 1
if check(mid):
le = mid + 1 # 不足 k 個連通塊
else:
ri = mid # 至少有 k 個連通塊
# 加入 l[le-1] 就會不足 k 個連通塊
print(l[le - 1][0]) # 輸出 l[le-1] 的邊權
main()
```
實際上「加邊、判斷連通塊數量」的部分是 MST 最小生成樹 的裸題,所以砸一個 DSU 下去就解決了。
DSU 的實作上,我會用負數代表 rank,越負表示 rank 越大,正數則表示父節點 index。
我以前會寫迭代式的 `find`,但最近我發現遞迴式更快...(信仰破滅 QAQ)
以下是 Kruskal 演算法的實作,瓶頸在於對邊權排序。
$Time: O(n ^ 2 \cdot (\log_2 n + \alpha(n))), Space: O(n ^ 2)$
```python=
def main():
from sys import stdin
from itertools import islice, chain
from operator import itemgetter
e = stdin.readline
def find(x): # DSU 找根節點
p = dsu[x]
if p < 0: return x
dsu[x] = p = find(p) # 路徑壓縮
return p
n, k = map(int, e().split())
dsu = [-1] * n # 初始化為 rank 1
for v, a, b in sorted(chain.from_iterable(
islice(((v, i, j) for j, v in enumerate(map(int, e().split()))), i)
for i in range(n)
), key=itemgetter(0)): # 按照邊權升冪排序
a, b = find(a), find(b)
if a == b: continue # 已經在同一組 不用再合併
# 合併
n -= 1 # 連通塊數量-1
if n < k: break # 不足k組 當前邊權為答案
# 按秩合併
if dsu[a] == dsu[b]: dsu[a] -= 1
elif dsu[a] > dsu[b]: a, b = b, a
# 合併
dsu[b] = a
print(v)
main()
```
~~雖然說 $\log$ 是常數~~,有沒有辦法可以 $O(n ^ 2)$?
再仔細想想,如果 MST 將自己身上最大的 $k-1$ 條邊都刪掉,就會變成 $1 + (k-1) = k$ 個連通塊,因此最後一條被刪掉的邊(就是 MST 上第 $k-1$ 大的邊)即為所求。
因為輸入是完全圖,使用 Prim 演算法 可以在 $O(n ^ 2)$ 內找出 MST,更適合此題。
有了 MST 後,找出第 $k-1$ 大的邊的部分可以用選擇演算法 $O(n)$(像是 C++ 中的 `nth_element`),而 python 只有內建基於 heap 的 `heapq.nlargest` $O(n \log_2 k)$。
$Time: O(n ^ 2 + n \log_2 k), Space: O(n ^ 2)$
```python=
def main():
from sys import stdin
from itertools import filterfalse
from heapq import nlargest
inf = float("INF")
e = stdin.readline
n, k = map(int, e().split())
G = [list(map(int, e().split())) for _ in range(n)]
mst = [False] * n
dis = [inf] * n
dis[0] = 0 # 從 0 開始建 MST
for _ in range(n): # n 次加點找出 MST
# 找出不在MST中 邊權最小的點
i = min(filterfalse(mst.__getitem__, range(n)), key=dis.__getitem__)
mst[i] = True # 連線 加入MST
# 由新的點往外擴張 更新邊權
row = G[i]
for j in filterfalse(mst.__getitem__, range(n)):
dis[j] = min(dis[j], row[j])
print(nlargest(k - 1, dis)[-1]) # 找第k-1大邊
main()
```
再砸一下「實務上最快的選擇演算法」Floyd–Rivest algorithm(但這題 $n \le 500$,~~常數比效果大~~):
$Time: O(n ^ 2 + n), Space: O(n ^ 2)$
```python=
def main():
from sys import stdin
from itertools import filterfalse
inf = float("INF")
e = stdin.readline
n, k = map(int, e().split())
G = [list(map(int, e().split())) for _ in range(n)]
mst = [False] * n
dis = [inf] * n
dis[0] = 0 # 從 0 開始建 MST
for _ in range(n): # n 次加點找出 MST
# 找出不在MST中 邊權最小的點
i = min(filterfalse(mst.__getitem__, range(n)), key=dis.__getitem__)
mst[i] = True # 連線 加入MST
# 由新的點往外擴張 更新邊權
row = G[i]
for j in filterfalse(mst.__getitem__, range(n)):
dis[j] = min(dis[j], row[j])
from math import log, exp, copysign
# Floyd–Rivest algorithm from WIKIPEDIA
def select(le, ri):
while ri > le:
if ri - le > 300:
r = ri - le + 1
i = rk - le + 1
z = log(r)
s = 0.5 * exp(2 * z / 3)
sd = copysign(0.5 * (z * s * (r - s) / r) ** 0.5, i - r / 2)
_le = max(le, int(rk - i * s / r + sd))
_ri = min(ri, int(rk + (r - i) * s / r + sd))
select(_le, _ri)
t = dis[rk]
i, j = le, ri
dis[le], dis[rk] = dis[rk], dis[le]
if dis[ri] > t:
dis[ri], dis[le] = dis[le], dis[ri]
while i < j:
dis[j], dis[i] = dis[i], dis[j]
i, j = i + 1, j - 1
while dis[i] < t:
i += 1
while dis[j] > t:
j -= 1
if dis[le] == t:
dis[le], dis[j] = dis[j], dis[le]
else:
j += 1
dis[j], dis[ri] = dis[ri], dis[j]
if j <= rk:
le = j + 1
if j >= rk:
ri = j - 1
rk = len(dis) - k + 1
select(1, len(dis) - 1)
print(dis[-k+1]) # 找第k-1大邊
main()
```
再附上個用 `nth_element` 的 C++ 版本(僅主要邏輯)
```cpp=
#define REP(i, s, t) for (int i = (s); i < (t); ++i)
const int N = 500, inf = 0x3f3f3f3f;
int G[N][N], dis[N + 1]; // dis[N] = inf 作為 sentinel
bool mst[N];
void solve() {
memset(dis, 0x3f, sizeof(dis)); // dis 初始化為 inf
int n, k;
cin >> n >> k;
REP(i, 0, n) REP(j, 0, n) cin >> G[i][j]; // 輸入鄰接矩陣
dis[0] = 0; // 從 0 開始建 MST
REP(_, 0, n) {
// 找出不在MST中 邊權最小的點
int i = n;
REP(j, 0, n) if (not mst[j] and dis[j] < dis[i]) i = j;
mst[i] = 1; // 連線 加入MST
// 由新的點往外擴張 更新邊權
REP(j, 0, n) if (not mst[j] and G[i][j] < dis[j]) dis[j] = G[i][j];
}
nth_element(dis + 1, dis + n - k + 1, dis + n); // O(n) 找第k-1大邊
cout << dis[n - k + 1] << '\n';
}
```
## 吐槽
總之,這次的 p1, p2 挺無聊的。
至於 p3, p4,都需要稍微感性的觀察(淺淺通靈),但僅需要稍微超綱的知識,就能用毒瘤作法輕鬆砸過。