【C++】競程筆記(一維掃描線)

程式碼範例參考:NTUCPC Guide,此筆記僅為個人學習用途。

數線問題

例題(APCS 2016 年 3 月第三題):https://zerojudge.tw/ShowProblem?problemid=b966

測資加強版:https://zerojudge.tw/ShowProblem?problemid=f855

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#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 跑看看:

1
2
3
4
5
6
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. 初始化第一條線段的左右界(LR)為 vector<pair<int, int>> segl = seg[0].firstr = seg[0].second)。
  4. 用迴圈跑重疊或相鄰的區間(seg[i].first <= ri = 1),以 max() 更新 R,然後繼續跳下一個線段。
  5. 不重疊或相鄰就直接計算長度(R - L),繼續前往下個線段。
  6. 由於重疊或相鄰的區間在計算時只有 max()(因為題目要求不要計算重複的重疊區間),最後要記得計算他們的長度。

不過這是我本人的解法,不算是用掃描線去解的,以下是 NTUCPC Guide 的解法(掃描線):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#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;
}

掃描線

以上是由範例測資所畫出的圖。

先預設一個起始狀態,假設一開始全部人都是站著的。

那首先第一個區間微鼓勵下去之後,編號 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;,表示這段區間的人都是站著的。