「Count The Rectangles」 – Educational Codeforces Round 68 E

Pocket

「ねえ、この問題の解き方って分かる?」

「”Count The Rectangles“…… Educational Codeforces ですか。私、えでゅふぉは参加したことないんですよね」

「僕もリアルタイムで参加したことはないんだけど、過去問を解こうかなと思ったら、この問題が解けなくて」

「先輩は解説見たんですか?」

「一応見たんだけど、こういう問題が苦手で、どう思いつけばいいのか分からないんだよね」

「なるほど…… とりあえず見てみますね」

Count The Rectangles

概要

「二次元平面上に存在する、縦か横の線分がたくさん与えられて、そのうち4本選んで長方形を作る問題ですね。答えるのは長方形の数、つまり長方形を形成するような4本の辺の選び方の数ですね」

「うん。長方形を作るには、縦線 2 本と横線 4 本が 4 つの交点を持てば良くて、角ははみ出していてもいいというルールだね」

「線の数が 5000 以下なので、縦横 2500 ずつでも、答えの最大値は ${}_{2500}\text{C}_{2}\times {}_{2500}\text{C}_{2}$ なので、long long の範囲には収まりますね」

「int だとオーバーフローする場合があるから注意しないとね」

「座標は [-5000, 5000] の範囲なので、計算量としては $O(N^2\log N)$ や $O((\text{片方の座標の範囲}\times N\log N))$ が間に合いそうですね。全座標だと $10000^2$ になるのでちょっと不安ですかね」

「サンプル 1 の図はこうだね。答えは 7 。確かに、7 つの長方形が作れるね」

「サンプル 2 はこうですけど、途切れているので長方形は作れませんね」

考察

「答えとしてあり得る値が $O(N^4)$ で int に収まらない大きさなので、一つずつ数えるのはもちろんダメですよね」

「そうだね。効率的に数えないといけないね」

「となると、複数個をまとめて考えたい気持ちになりますね…… 例えば、上下の辺を固定すれば、両方に交わる縦線の中から 2 本選ぶ方法を数えるだけなので、${}_n\text{C}_2$ で計算できますよね」

「そうすると、一回で $O(N^2)$ の個数をまとめて計算できるので、必要な計算回数が $O(N^2)$ 回程度になりそうだね」

「ただ、上下の辺を決めても、両方に交わる縦線を数えるのが $O(1)$ ではできなさそうなんですよね…… x 座標だけ記録しておけば、セグメントツリーやBIT で区間和の計算として $O(\log x\text{座標の範囲})$ になるので、そうしたいんですけど……」

「なるほど、そう考えていくのか」

「とりあえず思いつく方針は、走査線を使う方法ですかね。上下を固定する方法にするかは分かりませんが、どっちにしても走査線を使うことになりそうな……」

「走査線ってどうやるの?」

「y=-5000 の直線部分に注目して、その注目部分を少しずつ上に上げていくイメージですね」

「具体的にどうやって計算するの?」

「走査線(注目する y 座標を示す線)を -5000 から上に上げていきながら、“有効な縦線” を記録しておきます。”有効な縦線” というのは、“下の方で横線を交わっているため、新たな横線と交われば長方形を作れる縦線の x 座標” です。図にするとこうなります」

「サンプル 1 の例では、y=-1 まではずっと有効な縦線なしです。それまで横線がないので」

y=-1 になったら、線分 (-2, -1, 4, -1) と交わる縦線の x 座標が “有効な縦線” に追加されるんだね」

「はい。また、y=2 を超えても、x=5 は追加されません。この縦線はまだ横線と交わっていないので」

「y=3 になったら、新たな横線が見つかるね。このときはどうなるの?」

「このときは、横線の x 座標の範囲が [1, 5] なので、“有効な縦線” の x 座標のうち、ここに含まれる(つまりこの横線を交わる)ものを数えます。今回は 2 本ですね。この場合、2 本の中から 2 本を選ぶことで長方形を作れるので、${}_2\text{C}_2=1$ 通りの長方形を作れます。この値を答えに足します」

「なるほど。そして、y=3 を過ぎたら “有効な縦線” に 5 が追加されているんだね」

「はい。そして、先程と同様に、y=4 に達したら横線 [-2, 6] と交わる縦線の数を数えて、2 本選ぶ方法を答えに足します」

「今回は ${}_4\text{C}_2=6$ だね。これとさっきの値を合わせて 7 だね」

「はい。走査線の移動が 10000 回、一回の計算に高々 N 回なので、計算量も間に合います。ただ、サンプル 1 の場合は答えが合うんですけど、ダメな例もありますね」

「どういう場合?」

「こんなときです」

「なるほど、この場合、上の横線まで来たときの “有効な縦線” を {0, 2, 3, 5} の 4 本にするとまずいんだね」

「はい。4 本の x 座標だけ記録しておくと、上の横線で ${}_4\text{C}_2=6$ 通り足してしまいます。実際は、内側 2 本と外側 2 本の 2 通りの選び方しか条件を満たさないので、2 通りですね」

「そうか、じゃあどうしたらいいんだろう」

“有効な縦線” を、対象の横線ごとに管理すれば大丈夫ですかね?」

「対象の横線ごとに管理?」

「こういうイメージです」

「なるほど。こうして、各グループごとに、その中から 2 本選ぶ方法を計算するんだね。これで ${}_2\text{C}_2+{}_2\text{C}_2=2$ だね。計算量は大丈夫かな?」

「走査線を走らせるのに 10001 回で、一回ごとに以下の計算を行うわけですよね」

  1. 走査線の y 座標に横線 h がある場合(h を上辺とする長方形を数える)
    1. h と交わる縦線の x 座標を、h 対応する “有効な縦線”グループ に追加
    2. h より下の横線(下辺の候補)に対応する全グループに対し、h の x 座標の範囲内の値の個数 n を数え、${}_n\text{C}_2$ (左右辺の選び方)を答えに足す
  2. 走査線の y 座標に上端がある縦線の x 座標を全グループから消す

「この計算が $O(N\log N)$ くらいに収まれば大丈夫そうだね」

「はい。今回は、(1.1) の計算で全ての縦線を見るので $O(N)$、(1.2) の計算では、各横線グループに対して セグメントツリーや BIT (Binary Indexed Tree) を持っておけば、区間和の計算として $O(N\log (x\text{座標の範囲}))$ で計算できます」

「セグメントツリーや BIT を、横線の数だけ持つってこと?」

「はい。セグメントツリーや BIT の配列の長さが 10001 で、個数が 5000 以下なのでメモリが少し不安ですね」

「ということは、(1.1) の計算では、横線 h と交わる縦線の x 座標について、横線 h に対応するセグメントツリーや BIT の位置を 1 に変えるってことだね」

「はい。(2) の計算は、削除セット set<int> del[10001] を用意しておき、(1.1) の段階で追加した縦線の上端 y と x 座標について、del[y].insert(x) を行います」

「del[y] は int の集合なんだね。どういう意味なの?」

「del[y] は 走査線が y を超えるときに全横線のグループから削除すべき x 座標の集合を表します。例えば下の状態であれば、del[3]={4}, del[4]={-1, 2} になります」

「うーん、ちょっとややこしいね」

「もうちょっとコードっぽく処理を書くとこうなります。線分は構造体 struct segment{int x1, x2, y1, y2}; とします」

  1. 入力受け。座標は [0, 10000] にしておく。横線 (horizontal) と縦線(vertical) で分けて記録。座標は x1<=x2, y1<=y2 になるように必要なら入れ替え。
  2. 横線を y 座標でソート
  3. 次に見る横線のインデックス h_index を 0 にしておく。
  4. 答えを入れる ans も 0 にしておく
  5. 横線の数だけ BIT を用意(bit[i] が横線 i に対応)。サイズ 10001 、全て 0 初期化
  6. 削除用セット set<int> del[10001] を用意。
  7. 走査線の位置 y について、0 から 10000 までループ(h = horizontal[h_index] とする)
    1. h_index < 横線数 かつ horizontal[h_index].y1 == y なら横線の処理
      1. 全ての縦線 v について、v が h と交わるなら、bit[h_index] の v.x1 要素に 1 を足す
      2. del[v.y2] の集合に v.x1 を追加する
    2. h 以下の横線についてループ(添字 i を 0 から h_index-1 まで)
      1. bit[i] の [h.x1, h.x2] の区間和を n とする
      2. ans に n*(n-1)/2 を足す
    3. h_index を 1 増やす
  8. del[y] に含まれる値 x についてループ
    1. 添字 i を 0 から h_index – 1 までループ
      1. bit[i] の x における値が 1 なら 1 減らす
  9. ans を出力する

「へー、横線を y 座標でソートしておくことで、走査線に重なる横線を探す処理で 毎回$O(N)$ かからないようにしてあるんだね。(7.1, 7.2, 7.3) の処理は、h_index が毎回増えるから最大でも N 回しか行われないね」

提出

「コードはこうなりますね。とりあえずサンプルは合ったので、提出してみましょう」

「あれ、5 つ目のテストケースで MLE だよ」

「本当ですね。一番メモリを食いそうなのはどこでしょうか…… やっぱり BIT ですよね」

「どうする?」

「うーん、例えば、全部横線だけみたいな場合に、BIT は 5000 個用意されるんですけど、その場合は縦線が無いので、横線と縦線の少ない方に合わせればメモリは減らせますね」

「それって面倒じゃない?」

「いえ、横線が多い場合に x 座標と y 座標を入れ替えてあげれば大丈夫ですよ。その場合でも答えは変わらないですし」

「なるほど。じゃあこんな感じかな」

「これで提出して…… 時間がかかってますね…… やった、AC です!」

「おおー。すごいね。解説ではたしか、下辺を決めてたけど、こんな方法でも解けるんだね」

「解説では下辺固定ですか。なるほど、そっちも考えてみましょうか」

「え、大変じゃない?」

「いや、今回の方法をそう変わらないですよ。まず、下辺と交わる縦線を列挙して上端をメモして、下辺から走査線を上げていけばいいんです。こうすれば、BIT を複数持つ必要がなくなるので、 MLE の心配も無さそうですね」

「そうか、”有効な縦線” を 1 つの集合として持つときに発生した問題がなくなるんだね」

「はい。実装はこんな感じでしょうか。下辺の添字 h_index を決めて、その横線を下辺とする長方形を数える関数 scan() を用意しています」

「こっちの方がシンプルだね」

「関数化できるとコードが簡潔になっていいですね。走査線の上げ方も変えてみました。こっちは、横線を下から順にたどりながら、そこまでに消すべき x 座標を消す方法です。走査線を必要なところまで一気に上げるイメージですね。もちろん、横線を y 座標でソートしておかないと大変なことになります」

このコードでも AC だね」

「走査線に慣れていない場合は、後のやり方の方が考えやすいかもですね」

「いやー、難しいね。こういう問題の解法がなかなか思いつかないんだよね」

「私も走査線を使う問題はあまり解いたことがなくて、まだ自分のものにできていないです」

「でも思いつくんだからすごいなあ」

「今回はたまたま最初に思いついた方針が正解だったというか…… 計算を省略したい気持ちで “使える縦線の中から 2 本選ぶ方法” でまとめて計算するのは定石ですよね。そこから、“使える縦線” を毎回全探索してたらダメっぽいので、差分だけ更新したい気持ちになって、どうやって状態を保持してどういう順番で更新しようか……と考えると走査線の発想に至りました」

「なるほど、“まとめて計算”、”差分だけ更新”、”差分が少なくなるような更新順序” か。そう言われると、走査線に行き着くのは必然に思えてきてしまうなあ」

「たくさん問題を解いて、こういうパターンを蓄積していきたいですね」

Pocket

スポンサーリンク
レクタングル大
レクタングル大

シェアする

  • このエントリーをはてなブックマークに追加

フォローする