1. 模擬費用流貪心(可撤銷貪心)
一個非常玄妙的算法。
*I. CF280D k-Maximum Subsequence Sum
一道用數據結構維護的模擬費用流貪心。首先當 \(k=1\) 時我們顯然選擇區間最大子段和。但 \(k>1\) 時首先選擇區間最大子段和並不一定最優。
這個問題一臉可以費用流的樣子:建立 \(n+1\) 個點 \(1,2,\cdots,n+1\),第 \(i\) 個點向第 \(i+1\) 個點連一條流量為 \(1\),費用為 \(a_i\) 的邊。詢問 \(l,r,k\) 相當於點 \(l\sim r+1\) 向匯點 \(T\) 連一條流量為 \(1\),費用為 \(0\) 的邊,超級源點 \(S\) 向源點 \(S'\) 連一條流量為 \(k\),費用為 \(0\) 的邊,源點 \(S'\) 向點 \(l\sim r+1\) 連一條流量為 \(1\),費用為 \(0\) 的邊,求最大費用最大流。
顯然直接流是不可接受的,考慮模擬費用流:注意到每次從 \(S\) 到 \(T\) 找最長路相當於求 \(l\sim r\) 的最大子段和(可以為空),不妨設為 \([l',r']\)。然后需要將權值取反表示反邊流量 \(+1\),正邊流量 \(-1\)。做 \(k\) 次上述操作即為答案。因此,線段樹維護區間取反(需要實時維護每個區間取反后的信息,這個 trick 在平衡樹專題 FHQ treap 部分例題 [NOI2021] 密碼箱有提到過),區間最大子段和及其端點即可。時間復雜度 \(\mathcal{O}(m\log n+qk\log n)\)。\(m\) 是修改次數,\(q\) 是詢問次數。
const int N = 1e5 + 5;
struct data {
int sum, pre, suf, ans, prep, sufp, ansl, ansr;
} I, pos[N << 2], neg[N << 2];
data operator + (data x, data y) {
data z = I; z.sum = x.sum + y.sum;
if(x.pre > z.pre) z.pre = x.pre, z.prep = x.prep;
if(x.sum + y.pre > z.pre) z.pre = x.sum + y.pre, z.prep = y.prep;
if(y.suf > z.suf) z.suf = y.suf, z.sufp = y.sufp;
if(y.sum + x.suf > z.suf) z.suf = y.sum + x.suf, z.sufp = x.sufp;
if(x.ans > z.ans) z.ans = x.ans, z.ansl = x.ansl, z.ansr = x.ansr;
if(y.ans > z.ans) z.ans = y.ans, z.ansl = y.ansl, z.ansr = y.ansr;
if(x.suf + y.pre > z.ans) z.ans = x.suf + y.pre, z.ansl = x.sufp, z.ansr = y.prep;
return z;
}
int n, m, a[N], rev[N << 2];
void init(int x, int p) {
int pp = max(0, a[p]), po = a[p] < 0 ? -1 : p;
pos[x] = {a[p], pp, pp, pp, po, po, po, po};
int np = max(0, -a[p]), no = a[p] > 0 ? -1 : p;
neg[x] = {-a[p], np, np, np, no, no, no, no};
}
void push(int x) {
pos[x] = pos[x << 1] + pos[x << 1 | 1];
neg[x] = neg[x << 1] + neg[x << 1 | 1];
}
void swp(int x) {rev[x] ^= 1, swap(pos[x], neg[x]);}
void down(int x) {if(rev[x]) swp(x << 1), swp(x << 1 | 1), rev[x] = 0;}
void build(int l, int r, int x) {
if(l == r) return init(x, l);
int m = l + r >> 1;
build(l, m, x << 1), build(m + 1, r, x << 1 | 1), push(x);
}
void update(int l, int r, int p, int x) {
if(l == r) return init(x, p);
int m = l + r >> 1; down(x);
if(p <= m) update(l, m, p, x << 1);
else update(m + 1, r, p, x << 1 | 1);
push(x);
}
void modify(int l, int r, int ql, int qr, int x) {
if(ql <= l && r <= qr) return swp(x);
int m = l + r >> 1; down(x);
if(ql <= m) modify(l, m, ql, qr, x << 1);
if(m < qr) modify(m + 1, r, ql, qr, x << 1 | 1);
push(x);
}
data query(int l, int r, int ql, int qr, int x) {
if(ql <= l && r <= qr) return pos[x];
int m = l + r >> 1; data ans = I; down(x);
if(ql <= m) ans = query(l, m, ql, qr, x << 1);
if(m < qr) ans = ans + query(m + 1, r, ql, qr, x << 1 | 1);
return ans;
}
int main(){
cin >> n, I = {0, 0, 0, 0, -1, -1, -1, -1};
for(int i = 1; i <= n; i++) a[i] = read();
build(1, n, 1), m = read();
for(int i = 1, l, r, k; i <= m; i++) {
if(!read()) l = read(), a[l] = read(), update(1, n, l, 1);
else {
l = read(), r = read(), k = read();
int ans = 0; vpii oper;
while(k--) {
data res = query(1, n, l, r, 1);
if(res.ansl == -1) break;
ans += res.ans, oper.pb(res.ansl, res.ansr);
modify(1, n, res.ansl, res.ansr, 1);
} print(ans), pc('\n');
for(pii it : oper) modify(1, n, it.fi, it.se, 1);
}
}
return flush(), 0;
}
*II. P3620 [APIO/CTSC 2007] 數據備份
一個初步想法是每次選代價最小的連,但這樣不一定最優,因為與它相鄰的兩段就不能被選,可能導致我們因為 \(k\) 的限制必須選擇一個代價更大的連。但這種情況僅出現在需要舍掉當前 \(i\) 並選擇其兩端 \(i-1,i+1\) 連邊時:因為對於不包含 \(i+1\) 但包含 \(i-1\) 的連邊情況,若 \(i-1\) 比 \(i\) 代價更小則貪心策略會優先選到 \(i-1\) 而非 \(i\),若 \(i\) 比 \(i-1\) 代價更小則將 \(i-1\) 換成 \(i\) 不影響選擇的合法性且會讓答案不劣。對於包含 \(i+1\) 但不包含 \(i-1\) 的連邊亦然。也就是說如果這個貪心策略單獨選擇了 \(i\),那么 \(i-1\) 和 \(i+1\) 在最優方案中必不可能只出現一個。對於連續間隔選擇的一段區間同理。
上述結論讓這個貪心策略的反悔變得容易:如果選擇了 \(i\),那么將 \(d_{i-1}+d_{i+1}-d_i\) 加入小根堆,選擇則表示將 \(i\) 撤銷掉並選擇 \(i-1\) 和 \(i+1\)。更一般的,如果選擇了連續間隔的一段 \(l,l+2,\cdots,r\) 且其貢獻為 \(c\),那么它的反悔方案代價 \(c_i=c_{pre_i}+c_{suf_i}-c\ \left(c=\dfrac{l+r}2\right)\)。若選擇這個決策就將 \(c_i\) 加入答案(即通過小根堆選最小的 \(c_i\))並刪除 \(pre_i\) 與 \(suf_i\)。\(pre\) 和 \(suf\) 用雙向鏈表維護。時間復雜度 \(\mathcal{O}(n\log n)\)。
模擬費用流:注意到每條邊必然連接相鄰的兩個點,因此將所有點按照編號奇偶性分類:從超級源點 \(S\) 向源點 \(S'\) 連一條流量為 \(k\),費用為 \(0\) 的邊限制流量,從源點 \(S'\) 向 \(1,3,5,\cdots,2d+1\ (2d+1\leq n)\) 連一條流量為 \(1\),費用為 \(0\) 的邊,從 \(2i+1\) 向 \(2i\) 和 \(2i+2\) 連一條流量為 \(1\), 費用分別為 \(s_{2i+1}-s_{2i}\) 和 \(s_{2i+2}-s_{2i+1}\)(若不存在則不連),從 \(2,4,6,\cdots,2d\) 向匯點連一條流量為 \(1\),費用為 \(0\) 的邊,則這張圖的 MCMF 就是答案。實際上在這張圖上的找最短路增廣取反就是上述做法中取出小根堆堆頂,取相反數后向兩邊擴展的過程。
const int N = 1e5 + 5;
const int inf = 2e9;
int n, k, ans, s[N], d[N], pre[N], suf[N];
priority_queue <pii, vector <pii>, greater <pii>> q;
int main() {
fprintf(stderr, "%.3lf\n", (&Med - &Mbe) / 1048576.0);
cin >> n >> k;
for(int i = 1; i <= n; i++) cin >> s[i], d[i] = s[i] - s[i - 1];
for(int i = 2; i <= n; i++) q.push({d[i], i}), pre[i] = i - 1, suf[i] = i + 1;
d[1] = 1e9, d[n + 1] = 1e9;
while(k--) {
while(q.top().fi != d[q.top().se]) q.pop();
pii t = q.top(); int p = t.se, nw = 0; q.pop(), ans += t.fi;
nw += d[pre[p]], d[pre[p]] = inf, pre[p] = pre[pre[p]], suf[pre[p]] = p;
nw += d[suf[p]], d[suf[p]] = inf, suf[p] = suf[suf[p]], pre[suf[p]] = p;
d[p] = t.fi = nw - t.fi, q.push(t);
}
cout << ans << endl;
return flush(), 0;
}
2. 數據結構維護貪心
I. P3545 [POI2012]HUR-Warehouse Store
首先忽略所有顧客,計算出每天結束時擁有的貨物數量即 \(s_i\) 的前綴和。為了使滿足需求的顧客數量最多,我們按照 \(b_i\) 從小到大排序。若當前顧客可以被滿足,即 \(b_i\geq \min_{j=p_i}^n s_j\) 那么就滿足,並將 \(s_{p_i}\sim s_n\) 減去 \(b_i\) 表示從顧客到來的時間 \(p_i\) 到最后的每一天晚上貨物的擁有數量都要少掉 \(b_i\)。可以用線段樹維護區間加減與區間最值。
另一種解法:從左往右考慮每個顧客 \(i\),若當前剩余貨物能滿足就滿足,否則若已經滿足的顧客的 \(b\) 的最大值 \(>b_i\),顯然將最大值更新為 \(b_i\) 更優。用大根堆維護。
兩種解法的時間復雜度均為 \(\mathcal{O}(n\log n)\),前者常數大一些。本題的核心思想在於:貪心選擇 \(b_i\) 更小的顧客。
const int N = 3e5 + 5;
ll n, cur, a[N], b[N], id[N];
vint ans;
ll val[N << 2], laz[N << 2];
void build(int l, int r, int x) {
if(l == r) return val[x] = a[l], void();
int m = l + r >> 1;
build(l, m, x << 1), build(m + 1, r, x << 1 | 1);
val[x] = min(val[x << 1], val[x << 1 | 1]);
}
void tag(int x, ll v) {laz[x] += v, val[x] += v;}
void push(int x) {if(laz[x]) tag(x << 1, laz[x]), tag(x << 1 | 1, laz[x]), laz[x] = 0;}
void modify(int l, int r, int ql, int qr, int x, int v) {
if(ql <= l && r <= qr) return tag(x, v), void();
int m = l + r >> 1; push(x);
if(ql <= m) modify(l, m, ql, qr, x << 1, v);
if(m < qr) modify(m + 1, r, ql, qr, x << 1 | 1, v);
val[x] = min(val[x << 1], val[x << 1 | 1]);
}
ll query(int l, int r, int ql, int qr, int x) {
if(ql <= l && r <= qr) return val[x];
ll m = l + r >> 1, ans = 1e18; push(x);
if(ql <= m) ans = query(l, m, ql, qr, x << 1);
if(m < qr) cmin(ans, query(m + 1, r, ql, qr, x << 1 | 1));
return ans;
}
int main() {
cin >> n;
for(int i = 1; i <= n; i++) a[i] = read() + a[i - 1];
for(int i = 1; i <= n; i++) b[i] = read(), id[i] = i;
sort(id + 1, id + n + 1, [&](int u, int v) {return b[u] < b[v];});
build(1, n, 1);
for(int i = 1, p = id[1]; i <= n; p = id[++i])
if(query(1, n, p, n, 1) >= b[p])
modify(1, n, p, n, 1, -b[p]), ans.pb(p);
cout << ans.size() << endl, sor(ans);
for(int it : ans) cout << it << " "; cout << endl;
return flush(), 0;
}
*II. [BZOJ3441]烏鴉喝水
神仙題。首先對問題進行初步分析:烏鴉肯定是能喝就喝,因為留到后面再喝不會讓局面變得更優(貪心思想所在)。因此本題變成了一道萌萌模擬題,但 \(\mathcal{O}(nm)\) 的復雜度不可接受,思考如何優化。
接下來的部分看了題解:設 \(d_i=\dfrac{x-w_i}{a_i}+1\) 表示每個水罐最多能貢獻第 \(d_i\) 次喝水,其中除法下取整。顯然的結論是:若 \(d_i\) 更小的水罐能被喝到,則 \(d_i\) 更大的水罐也一定能(本題的核心思想)。因此,考慮將所有水罐按 \(d_i\) 從小到大排序,然后依次考慮每個水罐 \(i\):
設當前答案為 \(a\),現在是第 \(r\) 輪喝水且烏鴉當前在第 \(las\) 個水罐處。求出從 \(las\) 到 \(n\) 還有多少水罐存活,記為 \(cnt\)。若 \(a+cnt< d_i\) 則烏鴉可以喝完這 \(cnt\) 個水罐,令 \(a\gets a+cnt\),\(r\gets r+1\),\(las\gets 1\) 表示新開一輪。否則二分找到從 \(las\) 開始第 \(d_i-a\) 個存活的水罐的位置,設為 \(p\),那么烏鴉在從 \(las\) 喝到 \(p\) 時,\(d_i\) 就無用了,因為此時的答案 \(a\) 已經等於 \(d_i\),這意味着 \(d_i\) 不會再對答案產生任何貢獻。我們將 \(d_i\) 所表示的水罐 \(j\) 標記為非存活水罐,然后繼續下一個水罐 \(d_{i+1}\) 直到所有水罐都被喝完或 \(r>m\)。
說簡單點,就是我們通過關注當前存活的水罐中 \(d\) 值最小的那一個並實時動態維護每個水罐的存活情況從而加速模擬。用 BIT 可以支持上述所有操作:單點修改,區間求和以及二分一個權值不大於某個閾值的最大位置(BIT 上倍增)。時間復雜度 \(\mathcal{O}((n+m)\log n)\)。
const int N = 1e5 + 5;
int n, m, ans, rd = 1, lg, a[N], w[N], c[N], x;
int add(int x, int v) {while(x <= n) c[x] += v, x += x & -x;}
int query(int x) {int s = 0; while(x) s += c[x], x -= x & -x; return s;}
int query(int l, int r) {return query(r) - query(l - 1);}
int find(int val) {
int p = 0, s = 0;
for(int i = lg; ~i; i--) {
int np = p + (1 << i);
if(np > n) continue;
if(s + c[np] <= val) s += c[np], p = np;
} return p;
}
pii d[N];
int main(){
cin >> n >> m >> x, lg = log2(n);
for(int i = 1; i <= n; i++) w[i] = read(), add(i, 1);
for(int i = 1; i <= n; i++) a[i] = read(), d[i] = {max(0ll, (x - w[i]) / a[i] + 1), i};
sort(d + 1, d + n + 1);
for(int i = 1, las = 1; i <= n; i++) {
int dd = d[i].fi - d[i - 1].fi;
if(dd == 0) {add(d[i].se, -1); continue;}
while(rd <= m && dd) {
int res = query(las, n);
if(res < dd) rd++, las = 1, ans += res, dd -= res;
else las = find(query(las - 1) + dd) + 1, ans += dd, dd = 0;
} add(d[i].se, -1);
} cout << ans << endl;
return flush(), 0;
}
III. P4098 [HEOI2013]ALO
考慮枚舉作為區間第二大值的元素 \(a_i\),忽略最大值。單調棧找到在 \(i\) 左邊第二個大於 \(a_i\) 的數的位置 \(p\)(如果不存在就是 \(0\)),那么在 \(p+1\sim i\) 之間的任何數都能被選到,因為如果包含 \([p,i]\) 那么 \(i\) 就不是第二大了,而 \([p+1,i]\) 就是一個合法區間。特別的,若 \(p=0\) 則可以將右端點向右擴展直到 \(a_i\) 成為第二大,因為 \(a_i\) 不是最大值所以合法區間必然存在。故 \(a_i\) 可以異或上任何 \(a_j\ (j\in[p+1,i])\),求一段區間異或某個數的最大值用可持久化 trie。右邊同理。時空復雜度 \(\mathcal{O}(n\log V)\)。
int n, ans, node, a[N], R[N], son[N << 5][2], val[N << 5];
void modify(int pre, int &x, int v, int bit) {
val[x = ++node] = val[pre] + 1, cpy(son[x], son[pre], 2);
if(bit == -1) return;
int c = v >> bit & 1;
modify(son[pre][c], son[x][c], v, bit - 1);
}
int query(int v, int bit, int x, int y) {
if(bit == -1) return 0;
int c = v >> bit & 1;
if(val[son[y][c ^ 1]] - val[son[x][c ^ 1]]) return (1 << bit) + query(v, bit - 1, son[x][c ^ 1], son[y][c ^ 1]);
return query(v, bit - 1, son[x][c], son[y][c]);
}
int p[N], q[N], id[N], mx;
struct MonotoneStack {
int stc[N], *T = stc;
void clear() {while(*T) T--;}
int top() {return *T;}
void upd(int p) {while(*T && a[*T] < a[p]) T--;}
void push(int p) {upd(p), *++T = p;}
} stc;
void solve() {
stc.clear(), node = 0;
for(int i = 1; i <= n; i++) modify(R[i - 1], R[i], a[i], B);
for(int i = 1; i <= n; i++) stc.upd(i), p[i] = stc.top(), stc.push(i), id[i] = i;
sort(id + 1, id + n + 1, [&](int x, int y) {return p[x] != p[y] ? p[x] < p[y] : x < y;}), stc.clear();
for(int i = 1, cur = 1; i <= n; i++) {
while(cur < p[id[i]]) stc.push(cur++);
stc.upd(id[i]), q[id[i]] = stc.top();
}
for(int i = 1; i <= n; i++) {
if(a[i] == mx) continue;
cmax(ans, query(a[i], B, R[q[i]], R[i]));
}
}
int main() {
cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i], cmax(mx, a[i]);
solve(), reverse(a + 1, a + n + 1), solve(), cout << ans << endl;
return cerr << "Time : " << clock() << endl, flush(), 0;
}
*IV. P3045 [USACO12FEB]Cow Coupons G
好題。自己想的思路假了很多次,最后還是看了題解。
首先可以證明前 \(k\) 頭牛一定被買(反證法),但不一定在它們身上使用優惠券。接下來考慮加入一頭牛 \((c_i,p_i)\)。若 \(c_j+p_i>c_i+p_j\) 即 \(p_i>c_i+p_j-c_j\) 則需重新分配優惠券並花 \(p_j-c_j+c_i\)。否則要花費 \(p_i\) 的代價買下這頭牛。因此維護未買奶牛的 \(p_i\) 與 \(c_i\) 及已買奶牛的 \(p_j-c_j\) 的小根堆,每次看究竟是最小的 \(p_j-c_j+c_i\) 更小還是最小的 \(p_i\) 更小。注意實時更新每頭奶牛的狀態,時間復雜度 \(\mathcal{O}(n\log n)\)。比較類似模擬費用流貪心。
核心思想:考慮加入每個元素時不同策略的代價,通常可以使用用數據結構維護保證我們每次取出更小的代價。另一個經典應用:April Fool’s problem (medium & hard).
const int N = 5e4 + 5;
ll n, k, m, ans, cst, p[N], c[N], vis[N];
priority_queue <pii, vector <pii>, greater <pii>> q1, q2, q3;
int main(){
cin >> n >> k >> m, ans = k;
for(int i = 1; i <= n; i++) q2.push({p[i] = read(), i}), q3.push({c[i] = read(), i});
for(int i = 1, v; i <= k; i++) {
pii buy = q3.top(); q3.pop();
if(m >= buy.fi) vis[v = buy.se] = 1, m -= buy.fi, q1.push({p[v] - c[v], v});
else cout << i - 1 << endl, exit(0);
}
while(ans < n) {
while(vis[q2.top().se]) q2.pop();
while(vis[q3.top().se]) q3.pop();
if(q2.top().fi < q3.top().fi + q1.top().fi) {
pii buy = q2.top();
if(m < buy.fi) break;
ans++, q2.pop(), vis[buy.se] = 1, m -= buy.fi;
} else {
pii buy = q3.top(), rep = q1.top();
if(m < buy.fi + rep.fi) break;
ans++, q3.pop(), q1.pop(), vis[buy.se] = 1;
m -= buy.fi + rep.fi, q1.push({p[buy.se] - c[buy.se], buy.se});
}
}
cout << ans << endl;
return flush(), 0;
}
*V. [BZOJ2264]Free Goodies
仍然是神仙題。由於 Petra 的策略相對固定,即如果按照 \(p_i\) 為第一關鍵字從大到小,\(j_i\) 為第一關鍵字從小到大排序,那么 P 一定會選取第一個未被選擇的禮物。而 Jan 就不一樣了,因為當前的決策可能只是局部最優解而非全局最優。
不妨設 P 先手,考慮 P 和 J 按順序選取每一個禮物,那么 J 第 \(i\) 次操作選擇的是第 \(2i\) 個禮物。從小到大考慮每一次操作選取的禮物編號 \(p_i\),初始值為 \(2i\):J 有能力把 \(p_i\) 向右移,但無法左移,因為 P 的策略是取最左邊的禮物。換句話來說,將 \(p_i\) 從小到大排序后一定有 \(p_i\geq 2i\)。反證法可以證明其必要性,構造法可以證明其充分性。因此從右往左(即按 \(i\) 從大到小)每次貪心選擇最大的禮物即可,可以用 set
或線段樹維護。對於 J 先手同理,有 \(p_i\geq 2i-1\)。時間復雜度 \(\mathcal{O}(Tn\log n)\)。
const int N = 1e3 + 5;
const int inf = 1e9 + 7;
struct stuff {
int a, b;
bool operator < (const stuff &v) const {
return a != v.a ? a > v.a : b < v.b;
}
} c[N];
struct data {
int p, val;
friend data operator + (data u, data v) {
if(u.val != v.val) return u.val < v.val ? v : u;
return c[u.p].a < c[v.p].a ? u : v;
}
} val[N << 2];
char s[N];
int n, x, y, vis[N];
void build(int l, int r, int x) {
if(l == r) return val[x] = {l, c[l].b}, void();
int m = l + r >> 1;
build(l, m, x << 1), build(m + 1, r, x << 1 | 1);
val[x] = val[x << 1] + val[x << 1 | 1];
}
void modify(int l, int r, int p, int x) {
if(l == r) return val[x] = {l, -inf}, void();
int m = l + r >> 1;
if(p <= m) modify(l, m, p, x << 1);
else modify(m + 1, r, p, x << 1 | 1);
val[x] = val[x << 1] + val[x << 1 | 1];
}
data query(int l, int r, int ql, int qr, int x) {
if(ql <= l && r <= qr) return val[x];
int m = l + r >> 1; data ans = {-1, -1};
if(ql <= m) ans = ans + query(l, m, ql, qr, x << 1);
if(m < qr) ans = ans + query(m + 1, r, ql, qr, x << 1 | 1);
return ans;
}
void solve() {
scanf("%d %s", &n, s + 1), mem(vis, 0, N), x = y = 0;
for(int i = 1; i <= n; i++) scanf("%d %d", &c[i].a, &c[i].b);
sort(c + 1, c + n + 1), build(1, n, 1);
int p = s[1] == 'P' ? 2 : 1;
while(p + 2 <= n) p += 2;
while(p > n) p -= 2;
while(p >= 1) {
data res = query(1, n, p, n, 1);
y += res.val, modify(1, n, res.p, 1), vis[res.p] = 1, p -= 2;
}
for(int i = 1; i <= n; i++) x += (!vis[i]) * c[i].a;
cout << x << " " << y << endl;
}
int main(){
int T; cin >> T;
while(T--) solve();
return flush(), 0;
}
啟示:兩個人在博弈時,若一個人的決策順序相對固定,為了使另一個人最優可以將問題轉化為括號序列匹配,利用 \(p_i\geq 2i\) 的性質解題。
*VI. P4647 [IOI2007] sails 船帆
思維諤諤題(sweet)。不難發現桅桿的順序對最終答案並沒有影響,以及我們要盡量讓帆的分布盡量均勻,即 \(2,4\) 劣於 \(3,3\),根據 \(\dbinom{i}{2}\) 的凸性可證。
但如果先考慮高度更大的桅桿,我們不知道如何平衡相較於別的桅桿多出的那一部分高度掛的帆的個數與剩下部分的掛的帆的個數。於是這引出了另一個核心思想 & 啟示:在限制條件較多且滿足包含關系時,優先考慮限制較嚴格的條件。
因此按高度從小到大排序並考慮每個桅桿 \(i\),找到前 \(H_i\) 個高度中所放置的船帆個數前 \(K_i\) 小的,求和類計入答案並將這些高度的帆個數 \(+1\)。可以用平衡樹(FHQ treap)維護,前綴 \(+1\) 時若第 \(K_i\) 小和第 \(K_{i+1}\) 小的值相同,設為 \(v\),則需要將 \(K_i\) 前綴按值 \(v-1\) 分裂成 \(T_{lp}\ (\forall x\in T_{lp},val_x<v)\) 與 \(T_{rp}\ (\forall x\in T_{rp},val_x=v)\),\(H_i-K_i\) 后綴按值 \(v\) 分裂成 \(T_{ls}\) 與 \(T_{rs}\),為 \(T_{lp}\) 和 \(T_{rp}\) 打上 tag 后為了保持有序性,四棵樹之間的順序從小到大應變為 \(lp,ls,rp\) 和 \(rs\)。時間復雜度 \(\mathcal{O}((n+H)\log H)\)。
const int N = 1e5 + 5;
const int inf = 1e9;
ll laz[N], val[N], sum[N];
int R, rd[N], ls[N], rs[N], sz[N];
void push(int x) {
sum[x] = val[x] + sum[ls[x]] + sum[rs[x]];
sz[x] = sz[ls[x]] + sz[rs[x]] + 1;
}
void tag(int x, ll v) {sum[x] += sz[x] * v, val[x] += v, laz[x] += v;}
void down(int x) {
if(!laz[x]) return;
if(ls[x]) tag(ls[x], laz[x]);
if(rs[x]) tag(rs[x], laz[x]);
laz[x] = 0;
}
int merge(int x, int y) {
if(!x || !y) return x | y;
down(x), down(y);
if(rd[x] > rd[y]) return rs[x] = merge(rs[x], y), push(x), x;
return ls[y] = merge(x, ls[y]), push(y), y;
}
void splitv(int p, int &x, int &y, int v) {
if(!p) return x = y = 0, void();
down(p);
if(v >= val[p]) splitv(rs[p], rs[x = p], y, v);
else splitv(ls[p], x, ls[y = p], v);
push(p);
}
void splitk(int p, int &x, int &y, int k) {
if(!p) return x = y = 0, void();
down(p);
if(k <= sz[ls[p]]) splitk(ls[p], x, ls[y = p], k);
else splitk(rs[p], rs[x = p], y, k - sz[ls[p]] - 1);
push(p);
}
int Gmin(int p) {
if(!p) return inf;
while(1) {down(p); if(ls[p]) p = ls[p]; else return val[p];}
assert(0);
}
int Gmax(int p) {
if(!p) return -inf;
while(1) {down(p); if(rs[p]) p = rs[p]; else return val[p];}
assert(0);
}
ll n, ans;
struct sail {
int h, k;
bool operator < (const sail &v) const {
return h < v.h;
}
} s[N];
void modify(int k) {
int y, z, yy, zz; splitk(R, y, z, k), ans += sum[y];
int mx = Gmax(y), mn = Gmin(z); tag(y, 1);
if(mx != mn) return R = merge(y, z), void();
splitv(y, y, yy, mx), splitv(z, zz, z, mn);
R = merge(merge(y, zz), merge(yy, z));
}
int main(){
cin >> n, srand(time(0));
for(int i = 1; i <= n; i++) s[i].h = read(), s[i].k = read();
sort(s + 1, s + n + 1);
for(int i = 1; i <= n; i++) {
for(int p = s[i - 1].h + 1; p <= s[i].h; p++)
sz[p] = 1, rd[p] = rand(), R = merge(p, R);
modify(s[i].k);
} cout << ans << endl;
return flush(), 0;
}
VII. [BZOJ4209]西瓜王
題意有點難懂,大概就是從一段區間 \([l,r]\) 內選出 \(k\ (2\mid k)\) 個數,滿足奇數和偶數的個數都是偶數且總和最大。
若區間前 \(k\) 大有偶數個奇數和偶數,那么它們的和就是答案。否則答案為去掉前 \(k\) 大最小的奇數並加上非前 \(k\) 大的最大偶數,或者去掉點 \(k\) 大最小的偶數並加上非前 \(k\) 大的最大奇數后的最大值。注意若非前 \(k\) 大的奇數或偶數不存在則該情況不能計入貢獻。時間復雜度 \(\mathcal{O}((n+q)\log n)\)。
const int N = 3e5 + 5;
const int K = N * 20;
int n, T, k, a[N], d[N];
int node, R[N], ls[K], rs[K];
struct Data {
ll odd, even;
Data friend operator - (Data x, Data y) {return {x.odd - y.odd, x.even - y.even};}
Data friend operator + (Data x, Data y) {return {x.odd + y.odd, x.even + y.even};}
Data friend operator * (Data x, int y) {return {x.odd * y, x.even * y};}
void init(int _odd, int _even) {odd += _odd, even += _even;}
} val[K], sum[K];
void init(int x, int v) {
if(v & 1) val[x].init(1, 0), sum[x].init(v, 0);
else val[x].init(0, 1), sum[x].init(0, v);
}
void modify(int pre, int &x, int l, int r, int p) {
ls[x = ++node] = ls[pre], rs[x] = rs[pre];
if(l == r) return val[x] = val[pre], sum[x] = sum[pre], init(x, d[p]);
int m = l + r >> 1;
if(p <= m) modify(ls[pre], ls[x], l, m, p);
else modify(rs[pre], rs[x], m + 1, r, p);
val[x] = val[ls[x]] + val[rs[x]], sum[x] = sum[ls[x]] + sum[rs[x]];
}
pair <Data, Data> query(int x, int y, int k) {
int l = 1, r = n;
Data ans = {0, 0}, tot = {0, 0};
while(l < r) {
int m = l + r >> 1;
Data son = val[rs[y]] - val[rs[x]];
if(k <= son.even + son.odd) l = m + 1, x = rs[x], y = rs[y];
else
ans = ans + son, tot = tot + sum[rs[y]] - sum[rs[x]],
r = m, x = ls[x], y = ls[y], k -= son.even + son.odd;
}
return {ans + (d[l] & 1 ? (Data){k, 0} : (Data){0, k}),
tot + (d[l] & 1 ? (Data){k * d[l], 0} : (Data){0, k * d[l]})};
}
ll queryodd(int x, int y, int k) {
ll l = 1, r = n;
while(l < r) {
int m = l + r >> 1, sz = val[rs[y]].odd - val[rs[x]].odd;
if(k <= sz) l = m + 1, x = rs[x], y = rs[y];
else r = m, x = ls[x], y = ls[y], k -= sz;
}
return d[l];
}
ll queryeven(int x, int y, int k) {
ll l = 1, r = n;
while(l < r) {
int m = l + r >> 1, sz = val[rs[y]].even - val[rs[x]].even;
if(k <= sz) l = m + 1, x = rs[x], y = rs[y];
else r = m, x = ls[x], y = ls[y], k -= sz;
}
return d[l];
}
int main(){
cin >> n;
for(int i = 1; i <= n; i++) d[i] = a[i] = read();
sort(d + 1, d + n + 1);
for(int i = 1; i <= n; i++) a[i] = lower_bound(d + 1, d + n + 1, a[i]) - d;
for(int i = 1; i <= n; i++) modify(R[i - 1], R[i], 1, n, a[i]);
T = read();
while(T--) {
int l = read(), r = read(); k = read();
if(!k) {print(0), pc('\n'); continue;}
Data tmp = val[R[r]] - val[R[l - 1]];
if((tmp.odd >> 1) + (tmp.even >> 1) < k >> 1) {print(-1), pc('\n'); continue;}
pair <Data, Data> res = query(R[l - 1], R[r], k);
Data num = res.fi; ll val = res.se.odd + res.se.even;
if(num.even & 1) {
ll ans = 0;
if(num.even != tmp.even) {
ll veven = queryeven(R[l - 1], R[r], num.even + 1);
ll vodd = queryodd(R[l - 1], R[r], num.odd);
cmax(ans, val + veven - vodd);
}
if(num.odd != tmp.odd) {
ll veven = queryeven(R[l - 1], R[r], num.even);
ll vodd = queryodd(R[l - 1], R[r], num.odd + 1);
cmax(ans, val + vodd - veven);
}
print(ans), pc('\n');
}
else print(val), pc('\n');
}
return flush(), 0;
}
3. 神仙思路貪心題大賞
*I. P5912 [POI2004]JAS
首先對問題進行轉化:相當於我們需要求原樹最淺的一個點分樹深度。假設點 \(i\) 在倒數第 \(d_i+1\) 次被問到,那么任意兩個 \(d\) 值相同的點 \(u,v\) 之間的簡單路徑必然存在一個點 \(a\) 使得 \(d_a>d_u\),因為這樣才能使它們進入不同的點分樹子樹,說人話即 \(a\) 是深度相同的點 \(u,v\) 在點分樹上的 LCA(的祖先)。
考慮這樣一個貪心:我們記 \(S_i\) 表示 \(i\) 的子樹內所有可能不滿足條件的 \(d\) 值,即存在 \(u\in \mathrm{subtree}(i)\) 使得不存在 \(v\in \mathrm{path}(u,i)\) 滿足 \(d_v>d_u\) 的所有 \(d_u\) 的集合,以二進制狀壓形式存儲。合並 \(i\) 的兩個子樹 \(u,v\) 時,首先 \(d_i\) 應大於任何一個既在 \(S_u\) 又在 \(S_v\) 內的元素,否則顯然不合法。此外,\(d_i\) 還不應存在於 \(S_u\) 和 \(S_v\) 中,我們選擇所有滿足條件的最小的 \(d_i\) 即可。\(S_i\) 即所有 \(S_u\) 與 \(\{d_i\}\) 的並去掉所有 \(<d_i\) 的元素后的集合。
根據點分樹的結論答案不可能超過 \(\log n\),因此時間復雜度 \(\mathcal{O}(n\log n)\),可以做到線性:通過位運算我們求出可行的 \(d_i\) 集合,取 \(\mathrm{lowbit}\) 即可,拿到了最優解。
const int N = 5e4 + 5;
const int K = 1 << 16;
int cnt, hd[N], nxt[N << 1], to[N << 1];
void add(int u, int v) {nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v;}
int n, f[N], S[N], lg[K], buc[K];
void dfs(int id, int fa) {
int msk = 0, ban = 0, leaf = 1;
for(int i = hd[id]; i; i = nxt[i]) {
int it = to[i];
if(it == fa) continue;
leaf = 0, dfs(it, id), f[id] = max(f[id], f[it]);
msk |= ban & S[it], ban |= S[it];
} if(leaf) return S[id] = 1, void();
msk = (K - (1 << lg[msk])) & (K - 1 - ban);
int c = !msk ? lg[n] + 1 : buc[msk & -msk];
f[id] = max(f[id], c);
S[id] = (ban & (K - (1 << c))) | (1 << c);
}
int main(){
cin >> n;
for(int i = 2; i < K; i++) lg[i] = lg[i >> 1] + 1;
for(int i = 1; i < 16; i++) buc[1 << i] = i;
for(int i = 1; i < n; i++) {
int u = read(), v = read();
add(u, v), add(v, u);
} dfs(1, 0), cout << f[1] << endl;
return 0;
}
啟示:遇到無從下手的問題時先嘗試抽象問題並分析性質(本題中是兩點間的簡單路徑必然存在點分樹的 LCA)使其更好理解。樹上問題先從葉子開始,可以先考慮一些簡單樹再嘗試總結策略。樹形 DP 先考慮僅合並兩個子樹的情況。
*II. P3269 [JLOI2016]字符串覆蓋
神仙思維題。一個時空復雜度均非常優秀的解法。
顯然對於最大值和最小值需要分開計算。首先我們求出一些基礎的東西輔助解題:\(n\) 個子串 \(s_i\) 的 next 數組(KMP)以及與母串 \(T\) 在每個位置的匹配情況。這個可以在 \(\mathcal{O}(nL)\) 的時間內預處理出來。
最大值:
遇到這種題目我們似乎無從下手,那么嘗試把 \(n=4\) 作為突破口。考慮 \(n!\) 枚舉欽定每個字符串出現位置按開頭從左到右的順序,那么一個貪心的想法是把出現順序在前面的字符串盡量往前放。但這樣有個問題,就是在放第 \(i\) 個字符串時有兩種情況:是否與 \(s_{i-1}\) 重疊,因為兩種情況都有可能成為最優解(反例容易舉出)。但若確定了是哪種情況,貪心策略就保證了方案唯一:若不重疊,則越往前放越好(給剩下來的字符串留足空間);若重疊則越往后放越好(因為不劣)。因此再 \(2^{n-1}\) 枚舉相鄰的兩個字符串是否重疊即可。注意統計答案是不應只關注前一個字符串,因為可能出現 \(l_1<l_2<r_2<l_3<r_3<r_1\) 的情況,其中 \(l_i,r_i\) 是 \(s_i\) 在 \(T\) 中的出現位置,因此需記錄的是當前所有字符串的右端點最大值即 \(\max r_i\)。時間復雜度 \(\mathcal{O}(n!2^nnL)\)。
當然可以更優:用 \(\log\) 級別的查找即 lower_bound
代替線性查找即可做到 \(\mathcal{O}(nL+n!2^nn\log L)\)。
最小值:
一個顯然的想法是舍棄所有被其它字串覆蓋的子串,若相同則僅保留一個,因為要使答案最小讓其被完全覆蓋一定最優。那么剩下來的子串就一定滿足若 \(l_i<l_j\) 則一定有 \(r_i<r_j\),這是很強的一個性質,並且結合最優化的限制,給予我們動態規划的思想:設 \(f_{i,S}\) 表示前 \(i\) 位放置了集合 \(S\) 內的子串的最小值且第 \(i\) 位被覆蓋,轉移時枚舉 \(p\in S\) 且 \(s_p\) 與 \(T\) 在 \(i\) 處匹配。分兩種情況討論,一種是與已放置字符串有交集,另一種是不交,綜合一下轉移方程如下:
當 \(j>i-len_p\) 時 \(i-j<len_p\) 故進行貢獻為 \(len_p\) 的轉移不會使答案變得更小(即更優),而當 \(j\leq i-len_p\) 時 \(i-j>len_p\) 所以進行貢獻為 \(i-j\) 的轉移也不會影響答案,因此可以看做對於每個 \(j\in [0,i)\) 都進行 \(len_p\) 和 \(i-j\) 的轉移。\(len_p\) 可以通過直接記錄 \(f_{i,S}\) 前綴最小值優化,而 \(i-j\) 的轉移可以設 \(g_{i,S}\) 表示 \(\min_{j=0}^if_{j,S}-j\) 進行優化。再加上滾動數組,本部分時間復雜度 \(\mathcal{O}(n2^nL)\),空間復雜度更是僅有驚人的 \(\mathcal{O}(2^n)\)!
也許你會問:直接用求最小值的 DP 求最大值不就行了嗎?非也,因為轉移方程中 \(\min(len_p,i-j)\) 的部分並沒有變成 \(\max\),故此時 \(len_p\) 只能從 \(j\leq i-len_p\) 轉移,而 \(i-j\) 只能從 \(j>i-len_p\) 轉移,所以需要加一個線段樹維護區間修改與區間最值,很麻煩,不如直接貪心更方便。而且一道題目鍛煉兩種思維,豈不妙哉?
復雜度分析:本題的時間復雜度為 \(\mathcal{O}(n!n2^n\log L+n2^nL)\),空間復雜度為 \(\mathcal{O}(nL+2^n)\)。很顯然后者已經達到了理論下界。實現起來不算麻煩,而且效率非常優秀,以 33ms 的極限速度與僅僅 900K 的空間占用奪得最優解。
#include <bits/stdc++.h>
using namespace std;
#define mem(x, v, s) memset(x, v, sizeof(x[0]) * (s))
template <class T1, class T2> void cmin(T1 &a, T2 b){a = a < b ? a : b;}
template <class T1, class T2> void cmax(T1 &a, T2 b){a = a > b ? a : b;}
const int N = 1e4 + 5;
char t[N], s[4][N];
int n, tL, len[4], nxt[4][N];
bool mat[4][N];
void KMP(char *s, int sL, int *nxt, bool *mat) {
for(int i = 2; i <= sL; i++) {
nxt[i] = nxt[i - 1];
while(nxt[i] && s[nxt[i] + 1] != s[i]) nxt[i] = nxt[nxt[i]];
if(s[nxt[i] + 1] == s[i]) nxt[i]++;
}
for(int i = 1, p = 0; i <= tL; i++) {
while(p && s[p + 1] != t[i]) p = nxt[p];
if(s[p + 1] == t[i]) p++;
if(p == sL) mat[i] = 1, p = nxt[p];
else mat[i] = 0;
}
}
bool OverLap(char *t, char *s, int tL, int sL, int *nxt) {
for(int i = 1, p = 0; i <= tL; i++) {
while(p && s[p + 1] != t[i]) p = nxt[p];
if(s[p + 1] == t[i]) p++;
if(p == sL) return 1;
}
return 0;
}
int GetMax() {
if(n == 1) return len[0];
static int id[4], ans, pos[4][N], cnt[4]; ans = 0, mem(cnt, 0, 4);
for(int i = 0; i < n; i++) id[i] = i;
for(int i = 0; i < n; i++) for(int j = 1; j <= tL; j++) if(mat[i][j]) pos[i][cnt[i]++] = j - len[i];
do {
for(int S = 0; S < 1 << n - 1; S++) {
int cur = -1, res = 0, rbound = 0;
for(int bit = 0; bit < n; bit++) {
int i = id[bit];
if(!bit) {cur = pos[i][0], rbound = cur + len[i] - 1, res = len[i]; continue;}
int p = -1, pr = id[bit - 1];
if(S >> bit - 1 & 1) {
int rlim = min(tL - len[i] + 1, cur + len[pr] - 1);
int it = upper_bound(pos[i], pos[i] + cnt[i], rlim) - pos[i];
if(it == 0 || pos[i][it - 1] < cur) break;
p = pos[i][it - 1];
}
else {
int it = lower_bound(pos[i], pos[i] + cnt[i], cur + len[pr]) - pos[i];
if(it == cnt[i]) break;
p = pos[i][it];
}
res += max(0, p + len[i] - 1 - max(rbound, p - 1));
cmax(rbound, p + len[i] - 1), cur = p;
}
cmax(ans, res);
}
} while(next_permutation(id, id + n));
return ans;
}
int GetMin() {
if(n == 1) return len[0];
static int ban[4], id[4], m; mem(ban, 0, 4), m = 0;
for(int i = 0; i < n; i++) for(int j = 0; j < n; j++)
if(strcmp(s[i] + 1, s[j] + 1)) ban[j] |= OverLap(s[i], s[j], len[i], len[j], nxt[j]);
for(int i = 0; i < n; i++) for(int j = i + 1; j < n; j++) ban[j] |= !strcmp(s[i] + 1, s[j] + 1);
for(int i = 0; i < n; i++) if(!ban[i]) id[m++] = i;
static int f[2][16], g[2][16];
mem(f, 0x3f, 2), mem(g, 0x3f, 2), f[0][0] = g[0][0] = 0;
for(int i = 1, cur = 1, pr = 0; i <= tL; i++, swap(cur, pr)) {
for(int j = 0; j < 1 << m; j++) {
f[cur][j] = N;
for(int k = 0; k < j; k++) {
if(!(j >> k & 1)) continue;
int S = j - (1 << k), p = id[k];
if(mat[p][i]) cmin(f[cur][j], min(f[pr][S] + len[p], g[pr][S] + i));
}
g[cur][j] = min(g[pr][j], f[cur][j] - i), cmin(f[cur][j], f[pr][j]);
}
}
return f[tL & 1][(1 << m) - 1];
}
void solve() {
scanf("%s %d", t + 1, &n), tL = strlen(t + 1);
for(int i = 0; i < n; i++) {
scanf("%s", s[i] + 1);
KMP(s[i], len[i] = strlen(s[i] + 1), nxt[i], mat[i]);
}
cout << GetMin() << " " << GetMax() << "\n";
}
int main(){
int T; cin >> T;
while(T--) solve();
return 0;
}
啟示:在時間復雜度可以承受的前提下盡可能確定更多信息,也許其所帶來的重要性質使 DP 或貪心變得可行。
*III. P2587 [ZJOI2008]泡泡堂
還算有趣的題目,經過 ycx 的點撥后豁然開朗。
求最小值就是用 \(2n\) 減掉對方得分最大值,因為雙方得分和為定值 \(2n\)。故只需求出一方打另一方的最大值,不妨設己方戰力從大到小排序后為 \(a_i\),對方為 \(b_i\)。
考慮我們有 \(k\) 局非負,那么就是 \(a_i\) 的前 \(k\) 大打 \(b_i\) 的前 \(k\) 小,並且一定是 \(a_i\to b_{n-k+i}\),即 \(a\) 第 \(i\) 大的打 \(b\) 前 \(k\) 小中第 \(i\) 大的。由於匹配不可能交叉故有上述結論,可以用調整法證明。
現在只需要求每個 \(a_i\) 能平或勝多少個對手,分別記為 \(d,w\),顯然當 \(k\in[i,i+w-1]\) 時 \(a_i\) 對答案的貢獻都是 \(2\)(因為若 \(a_i\) 與 \(b_j\) 匹配則可以確定 \(k=i+n-j\),而當 \(k\in[i,i+w-1]\) 時 \(a_i\) 一定與 \(b\) 中前 \(w\) 小之一匹配,故貢獻為 \(2\)),當 \(k\in [i+w,i+d-1]\) 時 \(a_i\) 對答案的貢獻是 \(1\),差分維護即可,時間復雜度是排序的線性對數。
const int N = 1e5 + 5;
int n, a[N], b[N];
int solve(int *a, int *b) {
static int res[N << 1], ans; mem(res, 0, N), ans = 0;
for(int i = n, p = n, q = n; i; i--) {
while(p && b[p] > a[i]) p--;
while(q && b[q] >= a[i]) q--;
int c = n - i + 1;
res[c] += 2, res[c + p]--, res[c + q]--;
}
for(int i = 1; i <= n; i++) cmax(ans, res[i] += res[i - 1]);
return ans;
}
int main() {
cin >> n;
for(int i = 1; i <= n; i++) a[i] = read();
for(int i = 1; i <= n; i++) b[i] = read();
sort(a + 1, a + n + 1), sort(b + 1, b + n + 1);
cout << solve(a, b) << " " << 2 * n - solve(b, a) << endl;
return 0;
}
看完題解后學會了一個更為簡潔的做法:若當前 \(\max a_i>\max b_j\) 則用 \(a\) 最大打 \(b\) 最大並彈出;若 \(\min a_i>\min b_j\) 同理。否則可以類似田忌賽馬的思想用最弱打最強。為什么這樣是對的呢?因為顯然最大和最小值最好也只能平對方,但如果用最小值換對方最大值,己方最大值打對方最小值就可以獲勝(如果是平則用最小值換最大值時也有 \(1\) 的貢獻,不劣),而兩個不大於 \(1\) 的數相加顯然不大於 \(2\),故貪心策略正確。時間復雜度是排序的線性對數。
const int N = 1e5 + 5;
int n, a[N], b[N];
int solve(int *a, int *b) {
int ans = 0, al = 1, bl = 1, ar = n, br = n;
for(int i = 1; i <= n; i++) {
if(a[al] > b[bl]) ans += 2, al++, bl++;
else if(a[ar] > b[br]) ans += 2, ar--, br--;
else ans += a[al] == b[br], al++, br--;
}
return ans;
}
int main() {
cin >> n;
for(int i = 1; i <= n; i++) a[i] = read();
for(int i = 1; i <= n; i++) b[i] = read();
sort(a + 1, a + n + 1), sort(b + 1, b + n + 1);
cout << solve(a, b) << " " << 2 * n - solve(b, a) << endl;
return 0;
}
啟示:很多題目都是相交劣於不交,可以利用這個性質解題。多嘗試運用調整法。