しょぼんブログ

数学の色々とか様々とか

連載 しょぼんコーダー 3 イベントソート

第3回では, イベントソートと呼ばれる, 時刻・位置順にイベント(変化する点)をソートして, イベントを時刻・位置順に走査することで, 目的のクエリについて答えていくテクニックについて書きます.

問題1

例題3 ABC248 D - Range Count Query (Diff: 793)
長さ Nの数列 A = (A_1, \cdots, A_N)が与えられます。
以下の形式で与えられる  Q 個のクエリに答えてください。

  • 整数 L,R,X が与えられる。 A_L , \cdots, A_R のうち、値が X に等しいものの個数を求めよ。

時間制限:2秒、メモリ制限:1024 MB
制約

  •  1 \leq N \leq 2 \times 10^5
  •  1 \leq A_i \leq N
  •  1 \leq Q \leq 2 \times 10^5
  • 各クエリについて、 1 \leq L_i \leq R_i \leq N, 1 \leq X \leq N
  •  1 \leq X_i \leq 10^9
  • 入力はすべて整数

考察

 A_1, \cdots, A_m の中の  x の個数を  \text{count}(m, x)とすると, 各クエリは,  \text{count}(R, X) - \text{count}(L-1, X) を求めればよいことになります.
 \text{count}(m, x)をすべて記録しようとすると, 時間もメモリも足りません. しかし,  A_1, A_2, \cdots と順番に数列 Aを見ていくと, 各 m=1,2,\cdots, Nについて \text{count}(m-1, x)から \text{count}(m, x)への更新は, たった1つの A_mについて 1増えて, あとは変わらないので O(1)で出来ます. ここで, 瞬間的に任意の xについて \text{count}(m, x)が求まります.
このタイミングで, どこかのクエリの R, L-1が引っ掛かったら,  \text{count}(R,X), \text{count}(L-1,X)を足したり引いたりすれば理想的です.
そこで, それぞれの『位置』 R, L-1とクエリ番号を記録したものを, 『位置』でソートして, 数列を見ていって『位置』が引っかかれば操作をすることを考えます.
「それぞれの『位置』 R, L-1とクエリ番号を記録したもの」をeventとして, eventを位置でソートします. これがイベントソートです. 計算量は 2Qある数列のソートがボトルネックになり,  O(Q \log Q)です.
ソースコードのpivotは, eventを『位置』pivotまで見たという指標です. eventの『位置』は単調増加なので, pivotも1ずつ増えていき, 結局pivotを増やす操作は 2Q回しか行わないため, 後半部分では O(N+Q)です.

n = int(input())
a = list(map(int,input().split()))
q = int(input())

count = [0] * (n+1)
event = []
ans = [0] * q
for i in range(q):
	l,r,x = map(int,input().split())
	event.append((l-1, 0, x, i)) #位置, L-1 or R, 値, ansの番号
	event.append((r, 1, x, i))

event.sort()
pivot = 0
for i in range(n+1):
	while pivot < 2 * q:
		if event[pivot][0] == i:
			if event[pivot][1] == 0:
				ans[event[pivot][3]] -= count[event[pivot][2]]
			else:
				ans[event[pivot][3]] += count[event[pivot][2]]
		else:
			break
		pivot += 1
	if i == n:
		break
	count[a[i]] += 1

for i in range(q):
	print(ans[i])

上では A_iを見るfor文の中にwhile文でeventを処理していましたが, 実は「 A_iを見る処理」と「クエリに答える処理」をひとまとまりにして両方eventと扱うことで, より簡潔に書くことができます. こちらはソートする数列の長さが N+2Qとなるので,  O( (N+Q) \log (N+Q) ) で, さきほどとは計算量はやや劣りますが, 非常に簡潔です.
先にしたい処理からeventのtupleの2つ目を昇順をすることで, 同じ『位置』のときに処理の順番を決めることができます.

n = int(input())
a = list(map(int,input().split()))
q = int(input())

count = [0] * (n+1)
ans = [0] * q
event = []

for i in range(n):
	event.append((i, 2, a[i], -1)) #位置, 数列処理は最後, 値, (使わない)

for i in range(q):
	l,r,x = map(int,input().split())
	event.append((l-1, 0, x, i)) #位置, L-1 or R, 値, ansの番号
	event.append((r, 1, x, i))

event.sort()
for ind, t, val, qind in event:
	if t == 0:
		ans[qind] -= count[val]
	elif t == 1:
		ans[qind] += count[val]
	else:
		count[val] += 1

for i in range(q):
	print(ans[i])

 A_iもクエリもひっくるめてはいハッピーって感じです. 計算量は劣りますが, 非常に書きやすい書き方になっております.
今回,  1 \leq A_i \leq Nという条件があるので, 各 mについて操作をするもの全体のリストを作ることができて, こっちだと(イベントソートに近い操作ですが)ソートする必要がなく,  O(N+Q)で実現可能です. これはバケットソートと呼ばれているそうです.
バケットソートによる解法で良いユーザー解説があったので, 詳しくはこちらをご覧ください.
Editorial - UNIQUE VISION Programming Contest 2022(AtCoder Beginner Contest 248)

計算量的には劣るかもしれませんが, もし, 数列 A_iではなく, 数直線上のとびとびの点の集合であったら, 後者の全部ひっくるめるイベントソートがよく使えます.

次の例題を見てみます.

問題2

例題4 (想定Diff : 1000)
茶店しょぼんに, ある日 1, 2, \cdots, Nの番号が振られた N人が店にちょうど1回出入りしました.
 iは, 年齢が A_iで, 時刻 L_iちょうどに店に入り, 時刻 R_iちょうどに店を出ました.
さて, 店長であるしょぼんは時刻によって店内の年齢層がどれほど違うか調べたいと思い, 特定の時刻に店内にいた人の年齢の平均値を求めたいと思いました.
そこで, 各 i = 1, 2, \cdots, Qについて, 時刻 X_i + 0.5に店内に居た人の年齢の平均値を出力してください. ただし, 時刻 X_i + 0.5に店内に誰も居なかった場合は -1を出力してください.

時間制限:4秒、メモリ制限:1GB
制約

  •  1 \leq N \leq 2 \times 10^5
  •  1 \leq A_i \leq 10^9
  •  1 \leq L_i < R_i \leq 10^9
  •  1 \leq Q \leq 2 \times 10^5
  •  1 \leq X_i \leq 10^9
  • 入力はすべて整数


入力は次のようになります.

N
A_1 L_1 R_1
A_2 L_2 R_2
...
A_N L_N R_N
Q
X_1
X_2
...
X_Q

出力は Q行で,  i行目( i=1,2,\cdots, Q)には時刻 X_i + 0.5に店内にいた人の年齢の平均値(店内にいた人が0人であった場合は -1)を出力してください. ただし, 相対誤差は 10^{-9}まで許容されます.

SAMPLE
入力

3
22 7 12
25 8 22
29 2 7
3
5
8
31415926

出力

29.0
23.5
-1

1行目:時刻 5.5には, 年齢 29の人 3のみが店内にいるので29を出力してください.
2行目:時刻 8.5には, 年齢 22の人 1と, 年齢 25の人 2が店内にいるのでその平均である 23.5を出力してください.
3行目:時刻 31415926.5には, 誰も店内にいないので -1を出力してください.

考察

すべての時刻を -0.5平行移動すると, 各クエリは X_iと整数値になって, 人 iは時刻 L_i - 0.5から時刻 R_i - 0.5まで店内にいるので, 整数の区間を考えると店内にいる時刻の区間 [ L_i, R_i ) となります. 以降, すべての時刻は -0.5平行移動したことにします.
 X_iに店にいる k人を p_1, p_2, \cdots, p_kとすると, 年齢の平均値は (A_{p_1} + A_{p_2} + \cdots + A_{p_k}) / kとなります. よって,  X_iに店内にいる人数と,  X_iに店内にいる人の年齢の合計を個別に求めれば平均値を求められます.
まず, 各 X_iについて人 1,2,\cdots, Nが店内にいるかどうかを調べるという方法は, 簡単に実装できますが O(NQ)かかり間に合いません.
そこで, イベントソートを使いましょう. 店内にいる人の人数を x, 店内に居る人の年齢の合計を yとします.
各人 iについて,  L_iになった瞬間,  x +1,  y +A_iして,  R_iになった瞬間,  x -1,  y -A_iします.
これを時系列順にやればよいのですが, まず L_i, R_ieventに追加します.
また,  X_iの時刻での x / yの値を求めないといけないので,  X_iもごちゃまぜでeventに追加します.
あとはeventをソートすればokです!!  X_iの処理を後に行う必要があることと,  X_iの番号通りに出力する必要があることに注意してください.
計算量は  O( (N+Q) \log (N+Q) )で, 間に合います.

n = int(input())

a = [0] * n
event = []
for i in range(n):
	at, l, r = map(int,input().split())
	a[i] = at
	event.append((l, 0, i)) #時刻, タイプ, 番号
	event.append((r, 1, i))

q = int(input())
ans = [0] * q

for i in range(q):
	x = int(input())
	event.append((x, 2, i))

event.sort()

X = 0
Y = 0
for time, t, ind in event:
	if t == 0:
		X += 1
		Y += a[ind]
	elif t == 1:
		X -= 1
		Y -= a[ind]
	else:
		if X == 0:
			ans[ind] = -1
		else:
			ans[ind] = Y/X

for i in range(q):
	print(ans[i])

imos法

いまやっていた操作(走査), imos法と似ていませんでしたか?
そうです. 今の問題が数列だったら, imos法が使えていました. しかし, 数直線上飛び飛びのデータなので, ただ単にimos法を適用するのは難しいのです.
でも, 数直線上飛び飛びのデータを数列にする方法は存在します. 座標圧縮です. こちらも重要なテクニックです.
一部のイベントソートは, 座標圧縮+imos法で同じことができます. 今回の問題は座標圧縮+imos法で書くことができます.
座標圧縮のコードについては, こちらをコピペしました.
座標圧縮 - yaketake08's 実装メモ

def compress(arr):
	*XS, = set(arr)
	XS.sort()
	return {cmp_e: cmp_i for cmp_i, cmp_e in enumerate(XS)}

n = int(input())

people = [] * n
detekuru = []
for i in range(n):
	a, l, r = map(int,input().split())
	people.append((a, l, r))
	detekuru.append(l)
	detekuru.append(r)

q = int(input())
query = []

for i in range(q):
	x = int(input())
	detekuru.append(x)
	query.append(x)

c = compress(detekuru)

size = len(c)
X = [0] * size
Y = [0] * size

for a, l, r in people:
	X[c[l]] += 1
	X[c[r]] -= 1
	Y[c[l]] += a
	Y[c[r]] -= a

for i in range(size-1):
	X[i+1] += X[i]
	Y[i+1] += Y[i]

for x in query:
	if X[c[x]] == 0:
		print(-1)
	else:
		print(Y[c[x]] / X[c[x]])

どんなときに使えるか

区間の端点・またはある一点のみで状況が変化して, その点を時系列順に処理すると各時刻の状況が簡単に分かるとき, このイベントソートはよく使えます.
そのとき, 各時刻の状況をすべてメモ化すると場合によっては時間・空間計算量が膨大になりますが(問題1の \text{count}関数), それをしなくてもよいことが特徴です.

練習問題

ネタバレをあまりしたくない主義なので, リンクを隠してあります. わかりにくくてすみません.
1番目のリンク (難易度:緑)
2番目のリンク (難易度:水)
3番目のリンク (難易度:青)

参考文献

イベントソートを勉強してみた【競プロ】
qiita.com

Twitter検索
twitter.com
twitter.com