離線演算法
建國中學 賴昭勳
離線是什麼
OFFLINE?
處理有多筆詢問的問題的時候
-
在線:對每個詢問分別處理,詢問之間沒有任何關聯
-
離線:先讀入所有詢問,可以透過排序詢問等方法在不同詢問之間取得共同的資訊
莫隊演算法
有一種問題長這樣...
給你一個長度為\(n\)的整數序列,有\(Q\)筆詢問,每次詢問區間\([l, r]\)的某種東西(數量/最大最小值...)
- 看到\(n\)沒有到超級無敵大的時候(\( \leq 10^5\))
- 想要用奇怪資料結構做卻沒有辦法/不會的時候...
- 想要唬爛的時候
莫隊(Mo's Algorithm)是什麼
假設我已知\([l, r]\)的答案,能夠快速求出\([l, r + 1], [l, r - 1], [l + 1, r], [l - 1, r]\)的答案的時候
我們的目標
找到一種處理詢問的順序,使得各詢問之間的總移動量最少。
做法:對詢問分塊!
分塊想法
假設每塊大小為 \(k\)
-
先將詢問依照左界分組
- 把每塊內的詢問按照右界排序
- 沒了
具體會發生什麼事
在每一組裡面,右界會遞增,左界則會在大小為\(k\)的範圍內亂跳。
也就是說,假設有\(x\)個東西在同一組,則總移動量為
\(O(kx + n)\)
那這樣的複雜度分析是多少?
複雜度分析
假設移動一次需要\(O(m)\)的時間,則
分塊的時間是\(O(q)\)
排序的總時間是\(O(qlogq)\)
每塊處理移動\(O((kx + n)*m), \sum x = q\)
兩塊之間的移動\(O(nm)\)
總共有\(O(n / k)\)塊
所以總複雜度為 \(O(qlogq + (n^2/ k + qk)m)\)
算幾又來啦
由算幾不等式可得\(n^2/k + qk \geq 2\sqrt {n^2q}\)
故當\(n^2/k = qk \rightarrow k \approx \sqrt{n}\)時
\(n^2/k + qk\)的最小值為\(O((n + q)\sqrt{n})\)
所以最佳總複雜度為 \(O(qlogq + (n + q)\sqrt{n}\cdot m)\)
註:在這裡我們假設\(n \approx q\)
這樣好像就能好好做了?
回到莫隊的使用時機...
「假設我已知\([l, r]\)的答案,能夠快速求出\([l, r + 1], [l, r - 1], [l + 1, r], [l - 1, r]\)的答案的時候」
也就是說,要對一個題目使用莫隊,必須先找出方法快速移動一個區間的答案
來看看例題
https://tioj.ck.tp.edu.tw/problems/2122
給你一個長度為\(n\)的序列,有\(q\)筆詢問\([l, r]\),求\([l, r]\)區間眾數出現的次數
\(n, q \leq 10^5, 0 <a_i \leq 10^5\)
對於一個區間,我要存下什麼資訊?
當我移動一格的時候,這個資訊又會怎麼改變?
Hint: 眾數->出現最多次的數字,你必須知道每個數字出現幾次,還有?
Ans: 紀錄兩個陣列,一個為每數字在目前的區間中出現幾次,另一個為每個出現次數有幾個數字
當我要移動的時候...
設 \(a[i]\)代表 數字 \(i\)出現的次數,
\(b[i]\)代表有幾種數字在目前的區間出現\(i\)次。
則:
a[0] | a[1] | a[2] |
---|---|---|
1 | 2 | 2 |
b[0] | b[1] | b[2] |
---|---|---|
0 | 1 | 2 |
移除一個 \(1\)的時候:
- \(b[2]--\)
- \(a[1]--\)
- \(b[1]++\)
最後紀錄答案
另外紀錄一個變數代表「目前最大的\(x\),使\(b[x] > 0\)」
因為每次新增/減少一個東西時,\(x\)至多改變一,所以可在\(O(1)\)之內進行莫隊的「移動」
由前面的複雜度分析可得知,時間複雜度為
\(O(qlogq + (n + q)\sqrt{n})\)
實作時間!
實作這題的時候要注意一些細節:
- 詢問的排序:要在每筆詢問額外紀錄他的\(id\),最後按照順序輸出
- 移動的順序:在移動時必須保證左界\(\leq\)右界,所以應該先讓左界往左/右界往右,再讓左界往右/右界往左
附上code: https://pastebin.com/T0V5vvTf
其他例題
區間逆序數對數,\(n \leq 23000, q \leq 2\times10^5\)
Hint:按我獲取提示
搭配資料結構,考慮新增一個數字時會多形成多少對逆序數對
區間詢問:如果\(a_i\)出現\(k\)次,則會獲得價值\(a_i \times k^2\),求每個區間的價值和
Hint:
當\(a_i\)的個數增加一的時候,價值會如何增加?
XOR and Favorite Number
給你一個數列\(a_i\)和數字\(k\),每次詢問一個區間有多少個子區間 xor 起來是 \(k\)
\(n, q \leq 10^5, 0 \leq a_i, k \leq 10^6\)
Hint:
子區間xor -> 前綴xor
增加一個\(a_i\)時,只有一種數字和他xor起來是\(k\)
整體二分搜
大家有聽過二分搜吧?
正常二分搜的時候,可以把拿來二分搜的東西想成一個區間,每次查詢答案是在區間的左半還是右半,然後更新區間走下去。
那假設我要一次搜很多的詢問呢?
整體二分搜:
-
將區間切成兩半
- 對每個詢問+元素看要去左半還是右半
- 遞迴下去做
原因:可以改變詢問順序,一次處理多筆詢問
直接看例題:Coding Days
給你一個序列,支援三種操作:
1. 查詢 \([l, r]\)第 \(k\)小的值
2. 改變一個元素的值
3. 輸出 7122
\(n \leq 50000, q \leq 10000\)
這是什麼鬼
先考慮靜態(沒有第二種操作)的版本
看看有沒有方法用二分搜在 \(O(n\log C)\)之內找到答案
這裡要特別注意二分搜的做法,因為有一個小小優化,之後處理多筆詢問時將大大的改變複雜度
對答案二分搜吧
當前有個答案的可能區間\([l, r)\),每次檢查\(mid = (l + r) / 2\),看有幾個數字小於等於他
如果個數大於\(k\),則答案在\([l, mid)\),否則在\([mid, r)\)
這裡遞迴下去的時候,注意到每個數字也能被分到左邊右邊
(如果答案大於\(mid\),只需算\(mid \leq a_i < r\)的數字)
一筆詢問時,這樣複雜度不變:\(O(n\log C)\)
但是多筆詢問呢?
全部一起二分搜
目前有一個序列,每筆詢問有\(l, r, k\),現在要對每筆詢問看他的答案是否小於\(mid\)
對位置維護一個 BIT,如果\(a_i \leq mid\)就把位置 \(i\)增加一,這樣即可在\(O(\log n)\)的時間內查詢在一個區間\( \leq mid\)的個數
這樣就可以將每個詢問分成兩邊,且依照每個\(a_i\)也分成兩邊
那修改怎麼辦啊www
把修改也當成一種詢問!
處理詢問時維持原本的詢問順序。把「將\(x\)改為\(y\)」看成「刪掉一個\(x\),加上一個\(y\)」,同樣能用BIT好好做。
而且這樣的話一個修改操作也可以被分到不同的兩塊!
時間複雜度是多少?
每一層的遞迴中,所有詢問,所有元素皆只出現一次,而每次處理需要\(O((n + q) \log n)\)的時間,至多有\(O(\log C)\)層
總複雜度 \(O((n + q) \log n \log C)\)
澂逝嘜
#pragma GCC optimize("Ofast")
#pragma loop_opt(on)
#include <iostream>
#include <algorithm>
#include <utility>
#include <vector>
#define maxn 100005
#define ll long long
using namespace std;
int cnt = 0;
struct query {
int type = 0, ind = 0;
int l = 0, r = 0, k = 0;
query(int t, int a, int b, int c) {
type = t, l = a, r = b, k = c;
ind = cnt++;
}
};
int ans[maxn];
struct BIT {
int arr[maxn];
void modify(int ind, int n, int val) {
for (;ind <= n;ind += ind & (-ind)) arr[ind] += val;
}
int query(int ind) {
int ret = 0;
for (;ind;ind -= ind & (-ind)) ret += arr[ind];
return ret;
}
}bit;
int a[maxn];
int n;
void solve(vector<query> que, vector<int> ind, ll low, ll up) { //[low, up)
//cout << low << " " << up << " " << mid << endl;
if (up <= low || que.size() == 0) return;
if (up - low == 1) {
for (query i:que) {
ans[i.ind] = low;
}
return;
}
ll mid = (low + up) / 2;
//cout << low << " " << up << " " << mid << " " << endl;
vector<int> lind, rind, bm;
for (int i:ind) {
if (a[i] < mid) {
bit.modify(i, n, 1), bm.push_back(i);
lind.push_back(i);
} else {
rind.push_back(i);
}
}
/*
for (int i:lind) cout << i << " ";
cout << endl;
for (int i:rind) cout << i << " ";
cout << endl;
*/
vector<query> left, right;
for (auto q:que) {
//cout << q.ind << " ";
int tol = 0;
if (q.type == 1) {
//cout << " " << q.ind << " " << bit.query(q.r) - (q.l - 1 ? bit.query(q.l - 1) : 0) << " " << q.k<< endl;
int val = bit.query(q.r) - (q.l - 1 ? bit.query(q.l - 1) : 0);
if (val >= q.k) tol = 1;
else q.k -= val;
} else if (q.type == 2) {
if (q.l < 0) {
if (q.k < mid) {
//cout << " " << -q.l << ' ' << q.k << endl;
bit.modify(-q.l, n, -1), bm.push_back(q.l);
//cout << " " << bit.query(1) << endl;
tol = 1;
}
} else {
if (q.k < mid) {
//cout << " " << q.l << ' ' << q.k << endl;
bit.modify(q.l, n, 1), bm.push_back(q.l);
tol = 1;
}
}
}
if (tol) left.push_back(q);
else right.push_back(q);
}
//cout << endl;
for (int i:bm) {
if (i > 0) bit.modify(i, n, -1);
else bit.modify(-i, n, 1);
}
solve(left, lind, low, mid);
solve(right, rind, mid, up);
}
int main() {
ios_base::sync_with_stdio(0);cin.tie(0);
int t;
cin >> t;
while (t--) {
int q;
cin >> n >> q;
int cop[n];
vector<int> index;
for (int i = 1;i <= n;i++) cin >> a[i], cop[i] = a[i], index.push_back(i);
vector<query > que;
for (int i = 0;i < q;i++) {
int type;
cin >> type;
if (type == 1) {
int l, r,k;
cin >> l >> r >> k;
que.push_back(query(type, l, r, k));
} else if (type == 2) {
int ind, v;
cin >> ind >> v;
que.push_back(query(type, -ind, 0, cop[ind]));
//cout << -ind << " " << cop[ind] << endl;
cop[ind] = v;
que.push_back(query(type, ind, 0, v));
//cout << ind << " " << v << endl;
} else {
int x, v;
cin >> x >> v;
que.push_back(query(type, 0, 0, 7122));
}
}
solve(que, index, -(1LL<<31), 1LL<<31);
for (query q:que) {
if (q.type == 3) {
cout << "7122" << "\n";
} else if (q.type == 1) {
cout << ans[q.ind] << "\n";
}
}
}
}
這題滿分解需要用到我們還沒教過的東西
大家可以先想想第三個Subtask (\(a_i\)相異)怎麼做
Hint:
對天數(答案)整體二分
對每筆詢問紀錄他還距離目標還有多遠
CDQ 分治
還記得分治嗎?
merge sort 的時候,把區間切成兩半解決之後合併結果。
這個想法套用在離線的框架上可以做什麼事情?
操作分治!
假設我們有一系列的操作,可能是修改/詢問目前的東西
那我們可以把操作看成是按照「時間」順序排!
處理詢問的時候必須考慮「時間」在他前面的所有修改操作
操作分治,就是對這個「時間」的序列/維度進行分治
回顧分治
假設處理好(遞迴)前半操作對前半詢問的影響,
後半操作對後半詢問的影響,
那麼我們只剩下前半操作對後半詢問的影響了!
紅對紅,綠對綠都已做完
只剩下紅對綠
用修改與查詢看逆序數對
把一個數字\(a_i\)看成:
- 詢問比\(a_i\)小的數字有幾個
- 新增一個\(a_i\)
此時,我們能在\(O(n)\)內找到左半數字對右半查詢的影響
中國競程圈流傳著一個名言...
CDQ 分治可以解決高維偏序問題
給你一個數列,有\(m\)次操作。
每次給一個\(x\),把所有為\(x\)的數字都刪除。
請輸出每次操作後的逆序數對數。
先將問題抽象化
每個數字有三個屬性:\(i, a_i, t_i\),其中\(t_i\)代表這個元素會被刪除的時間
問題變成,對於每一個\(i\),有多少\(j\)符合
\((i < j, a_i > a_j \ or \ i > j, a_i < a_j), t_i > t_j\)
當我們套分治的時候,目的是希望把一個維度做掉,這時候可以把哪個維度去除呢?
考慮把操作倒回來做,每次增加一些元素,看這些元素加進來時會多多少逆序數對
套上CDQ分治
對\(index\),也就是\(i < j\)的維度做分治。
每次把當前的序列切成左右兩半,遞迴處理好各自的答案後,只需要處理左邊和右邊之間的點對就好。
左邊
\(i\)
\(a_i\)
\(t_i\)
右邊
\(j\)
\(a_j\)
\(t_j\)
左邊
\(j\)
\(a_j\)
\(t_j\)
右邊
\(i\)
\(a_i\)
\(t_i\)
或是
套上CDQ分治
對時間開一個BIT
用merge sort 的方式,對於一個\(a_i\),所有小於他的\(a_j\)全部都被用\(t_j\)丟到BIT裡面,再查詢當前比\(t_i\)小的元素有幾個,就可以去更新第\(i\)項的答案
這樣的總複雜度就是\(O(n {log}^2 n)\)
左邊
\(i\)
\(a_i\)
\(t_i\)
右邊
\(j\)
\(a_j\)
\(t_j\)
例題:
No Judge: 給你一個二維平面,處理兩種操作
- 在\(x_i, y_i\)的點上加入權重\(w_i\)
- 詢問以\(0, 0\)為左下角,\(x_i, y_i\)為右上角的矩形所覆蓋的權重和
\(q \leq 10^5, 0 \leq x_i, y_i \leq 10^9\)
Hint:
只考慮前半對後半的影響的話
你就可以把詢問和修改的一個維度(\(x_i\)或\(y_i\))排序然後用merge sort做
謝謝大家
離線真的很難
這裡面的內容我也是最近才弄懂
大家加油><
離線演算法 (資讀)
By justinlai2003
離線演算法 (資讀)
- 1,131