【C++】競程筆記(一維掃描線) === [TOC] 程式碼範例參考:NTUCPC Guide,此筆記僅為個人學習用途。 數線問題 --- 例題(APCS 2016 年 3 月第三題):https://zerojudge.tw/ShowProblem?problemid=b966 測資加強版:https://zerojudge.tw/ShowProblem?problemid=f855 ```cpp= #include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int N; cin >> N; vector<pair<int, int>> seg(N); for (int i = 0; i < N; ++i) { cin >> seg[i].first >> seg[i].second; } sort(seg.begin(), seg.end()); int total_length = 0; int l = seg[0].first; int r = seg[0].second; for (int i = 1; i < N; ++i) { if (seg[i].first <= r) { // 重疊或相鄰 r = max(r, seg[i].second); } else { // 不重疊,計算前一段的長度並前往下一個線段 total_length += (r - l); l = seg[i].first; r = seg[i].second; } } // 加上最後一段的長度 total_length += (r - l); cout << total_length << '\n'; return 0; } ``` 拿範例測資 1 跑看看: ``` 5 160 180 150 200 280 300 300 330 190 210 ``` 排序後:(150, 200)、(160, 180)、(190, 210)、(280, 300)、(300, 330)。 合併過程: (150, 200) 和 (160, 180) 重疊,還是 (150, 200),因為 200 最大。 (150, 200) 和 (190, 210) 重疊,更新為 (150, 210)。 (150, 210) 和 (280, 300) 不重疊,計算 (150, 210) 長度 60,從 (280, 300) 繼續開始前往下一個線段。 (280, 300) 和 (300, 330) 相鄰,更新為 (280, 330)。 最後計算 (280, 330) 長度 50。 總長度 = 60 + 50 = 110。 總之解題思路就是: 1. 用 pair 資料結構 2. 排序 pair 的 first 3. 初始化第一條線段的左右界(`L`、`R`)為 `vector<pair<int, int>> seg`(`l = seg[0].first` 跟 `r = seg[0].second`)。 4. 用迴圈跑重疊或相鄰的區間(`seg[i].first <= r`,`i = 1`),以 `max()` 更新 `R`,然後繼續跳下一個線段。 5. 不重疊或相鄰就直接計算長度(`R - L`),繼續前往下個線段。 6. 由於重疊或相鄰的區間在計算時只有 `max()`(因為題目要求不要計算重複的重疊區間),最後要記得計算他們的長度。 不過這是我本人的解法,不算是用掃描線去解的,以下是 NTUCPC Guide 的解法(掃描線): ```cpp= // from NTUCPC Guide #include <bits/stdc++.h> using namespace std; using pii = pair<int, int>; int main () { ios_base::sync_with_stdio(false); cin.tie(0); int n; cin >> n; vector<pii> events; for (int i = 0; i < n; i++) { int l, r; cin >> l >> r; events.emplace_back(pii(l, 1)); events.emplace_back(pii(r, -1)); // 1 代表左端點,-1 代表右端點 // 也就是走到那個點時,包住當前位置線段數的改變量 } sort(events.begin(), events.end()); int cur = 0; // 包住當前位置的線段數 int ans = 0; int lst = -1; // 上一個碰到的端點 for (auto [x, diff] : events) { // 如果前面那一段的是有被包住的,要加上那一段的長度 if (cur > 0) ans += x - lst; cur += diff; lst = x; } cout << ans << "\n"; } ``` 掃描線與事件 --- > 這種想像自己在走的方法稱為掃描線,就是想像有一條直線掃過去,你就是那條直線。 > from NTUCPC Guide > 掃描對象是很單純的一個序列(一些格子)配上一些區間,或者一條數線配上一些線段,然後很單純的左往右掃,這種題目的作法框架基本上都差不多,首先先找出你關心的當下狀態,然後看看狀態什麼時候會發生改變,狀態改變的事情就被稱為事件,把事件按照發生時間點(通常就是座標)排序後,就可以好好摸擬掃描的過程,如果座標範圍不大,也可以直接開一個長度是 $C$ 的陣列來直接儲存每個整數時間點發生的事件,並一一枚舉所有整數時間點。 > from NTUCPC Guide 精簡一下: 掃描線是一種想像自己是條線、從左到右(也可以由下往上)掃過整個序列或數線的解法。 它的核心概念是觀察「狀態」何時改變,並將這些變化視為「事件」。將所有事件依照發生時間(或座標)排序後,就能模擬整個掃描過程。若座標範圍不大,也可用長度為 $C$ 的陣列來記錄每個整數時間點的事件並逐一處理。 那至於啥是事件呢?像是線段的端點(起始點和終點)是事件的一種。 ### 掃描的起點 --- 用掃描線要有起始狀態,如於線段覆蓋的程式中 lst = -1,假設起點為 -1,起點狀態包住目前的線段數為 0。通常只要挑所有端點左邊當作起點即可。 > 而在對序列掃描時,可以自己加一個 index 是 0 的位置當起點。 > from NTUCPC Guide ### 邊界條件 --- 在線段長度覆蓋的問題中,一線段的 $[l, r]$ 的意思即為數線上 $[l, r]$ 的範圍,只關心到長度,而單一點的狀態不重要,只要在乎每個時間點之間的狀態。進入區間的時間是走過 $l$ 以後,離開區間的時間是走過 $r$ 以後。 ### 同時發生的事件 --- 當掃描線掃到某特定位置上,有不只一個事件要處理時,就會發生同時發生的事件。 假設有兩棟房子: * A:從 x=2 到 x=5,高度 10。 * B:從 x=2 到 x=6,高度 15。 那通常處理優先順序會如下所示(這部分依據題目要求可自行調整): 1. 加入事件(start)先於移除事件(end)。 2. 高度高的房子先處理(如:高度從高到低排序)。 3. 起點 vs 起點:高的先;終點 vs 終點:低的先。 ### 複雜的事件 --- 複雜的事件就是「區間包含查詢」問題,如包含點的線段數問題。 習題練習 --- 1. b526. 先別管這個了,你聽過微鼓勵嗎?:https://zerojudge.tw/ShowProblem?problemid=b526 ```cpp= #include <iostream> #include <map> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); long long n; int m; cin >> n >> m; // pos , delta map<long long, int> diff; for (int i = 0; i < m; ++i) { long long l, r; cin >> l >> r; // 區間加值 diff[l] += 1; diff[r + 1] -= 1; } long long cur = 0; // 目前區間的「累積反轉次數」 long long prev = 1; // 目前區間的起始位置(編號 1 開始) long long standing = 0; // 站著次數 for (auto &[pos, delta] : diff) { if (pos > n + 1) break; long long end = min(pos, n + 1); if (cur % 2 == 0 && prev < end) { // 反轉次數是偶數就代表狀態沒變化 // 當前區間是站著狀態,加總站立人數 standing += end - prev; } cur += delta; // 累計該位置的反轉次數, 更新目前的狀態。 prev = pos; // 更新起點 } // 若最後一段尚未結束,需特別補處理 if (prev <= n && cur % 2 == 0) { standing += n - prev + 1; } cout << standing << '\n'; return 0; } ``` ![掃描線](https://hackmd.io/_uploads/r1gFgvxmxe.png) 以上是由範例測資所畫出的圖。 先預設一個起始狀態,假設一開始全部人都是站著的。 那首先第一個區間微鼓勵下去之後,編號 1 ~ 3 的人全都蹲著。 第二個區間做完微鼓勵之後,1 ~ 2 維持不變,編號 3 站起,4 ~ 5 蹲下。 第三個區間做完之後,1 ~ 2 跟 4 ~ 5 站起,僅 3 號蹲下。 所以全部做完後,有 4 個人站著。 在這支程式中有個很重要的解題思路,就是我們不需要去紀錄每個人的最終狀態,而是要紀錄「哪些區間被反轉」。 具體步驟如下所示: 1. 將每次操作紀錄為區間反轉,如:微鼓勵 `[l, r]` → 在 l 處 +1,r+1 處 -1。 2. 對所有點排序、掃描,計算每段區間共幾次反轉。 3. 如果該段反轉次數是偶數,則狀態不變;奇數則反轉(站著變蹲下)。 4. 統計站著的總人數。 在這支程式中: - 差分用來記錄變化量。(n 太大了,所以只要記錄有變化的就好) - cur 變數用來記錄目前區間的累計反轉次數 - prev 作為目前區間的起始位置(題目要求從編號 1 開始) - standing 紀錄站著的人次 `for (auto &[pos, delta] : diff)` 做結構化綁定。 `long long end = min(pos, n + 1);` 表示目前區間的右端點,也是區間的結束位置(這個點是空心的,不含在內)。 `if (cur % 2 == 0 && prev < end)` 因為預設的狀態就是站著的,所以 `cur % 2 == 0` 表示反轉狀態不變,一樣站著,`prev < end` 為了避免處理長度為 0 的區間。 那題目要求站著的人數,所以接下來就 `standing += end - prev;`,表示這段區間的人都是站著的。