- はじめに
- 何が難しいのか
- 典型1: K番目の取得は決め打ち二分探索
- 典型2: マンハッタン距離は45°回転
- 典型3: 平面走査
- 典型4: 並列二分探索
- 典型5: Segment Tree
- おわりに
- 参考文献
はじめに
問題の解説をしつつ、競プロ楽しいよ~という布教をする回です。
特に、競プロを未経験の方に、競プロにおいて「典型*1を組み合わせて問題を解く」とはどのようなものなのかを実感してもらえたらと思います。
解説する問題はこいつ ↓ です。とりあえず読んでみてください。
※ 問題文中にあるマンハッタン距離とは、縦横でのみ移動できるとしたときの2点間の距離のことです。厳密には、2点 間のマンハッタン距離は
と表されます*2。
一見するとかなりシンプルな問題ですが、実は出題されているコンテスト(AtCoder Beginner Contest)の中ではかなりの難問に分類されており、典型知識を多重に組み合わせて解く必要があります。
どういった点が難しいのか、どのように解いていくのかを、競プロ関連の前提知識は使わないように(無理なところは適宜端折って)解説していくので、是非最後まで読んで「解けた!」が届く快感を味わってください。
また、できる限り丁寧に解説したつもりではありますが、問題自体が難しいので「解けた!」が万人には届けられないかもしれません。それでも、途中まで読んだ中で「なるほど!」が届けばなと思います。
何が難しいのか
改めて、問題の概要を確認します。
- 二次元座標平面上に
個の相異なる点が与えられる。
個の質問に答える。各質問
の内容は次の通り:
- マンハッタン距離を用いるとして、座標
から
番目に近い点までの距離は?
- マンハッタン距離を用いるとして、座標
- 実行時間制限: 7sec
- 与えられる各座標の x, y はいずれも
以上
以下
この問題、答えを求めること自体はそう難しくはありません。
各質問 について、以下のような手順で質問に答えることができます:
とする配列
を作成する。
を昇順にソートする。
の
番目が答え。
しかし、残念ながらこの解法で実装したコードを提出しても、TLE(Time Limit Exceeded / 実行時間制限超過)となり、不正解となります。プログラムの実行時間が長すぎるのでダメ!と怒られるわけですね。
この問題の実行時間制限は 7sec と設定されています。対して、プログラムの実行時間はどのくらいになるのでしょうか。
上記解法においてボトルネック(= 最も実行時間への影響が大きい箇所)となっているのは 2. のソート部分で、計算量は である(≒
の定数倍程度のステップ数を要する)ことが知られています*3。さらに、
回質問することを踏まえると、全体の計算量は
になります。
一般的なPCが1秒間に処理できるステップ数はおよそ 回程度と言われています。つまり、実行時間はざ~っくりと見積もると
\begin{align} \frac{10^5 \times 10^5 \times \text{log} 10^5}{10^9} \gt 50 \end{align}
という感じで、ガバガバ議論にはなりますが 7sec には到底収まりそうにないことが分かります。
更に言うと、上記解法の 1. だけ見ても計算量は で、この時点で既に 7sec は厳しいです。つまりは、「各質問ごとに各点を見る」という当たり前の処理すらさせてもらえない時間制限になっているのです。
シンプルな見た目をしていますが、実はかなりの無茶ぶりを要求されているということがご理解いただけたでしょうか?
いよいよここからは、競プロの典型知識を用いてこの問題を解きほぐしていきます!
典型1: K番目の取得は決め打ち二分探索
早速ですが、問題の言い換えを行います。
番目の質問
- 座標
から
番目に近い点までの距離は?
は、次と同値です。
- 「
からの距離が
未満であるような点の個数は
個未満」を満たす、最大の
は?
ちょっと長いので、判定問題として
\begin{equation} P_i (d) :=(X_i, Y_i) からのマンハッタン距離が d 未満であるような点の個数は K_i 個未満であるか?\end{equation}
とおいてあげると、最終的に求めたいものは となる最大の
となります。
さて、ここからが重要です。
は、
についての単調性、すなわち
なる
について、
という性質を持ちます。( を大きく取れば距離
未満の範囲にある点の個数は単調増加するので、当然です。)
この性質を用いれば、次のスライドのように範囲を半分ずつ絞っていくことで、判定問題 を高々
回程度解くだけで所望の
を求めることができます。いわゆる二分探索というやつです。
なお、 と評価できるので、
を解く回数は高々
回程度です。
このように、何かしらの数値を求める問題に対して単調性のある判定問題に置き換えて二分探索を適用する手法を、決め打ち二分探索と呼びます。
とはいえ、まだ問題はほとんど解決していません。
というのも、決め打った に対して判定問題
が高速に解けないようでは、判定問題に置き換えた意味がないからです。
ここからは、この判定問題を解く方法を掘り下げていきます。
典型2: マンハッタン距離は45°回転
からマンハッタン距離が
未満である領域を図示すると、次のような菱形になります。
は、この領域に含まれる点が
個未満かどうかを判定する問題に相当します。

ただ、この菱形というのは色々と扱いづらいです。
そこで、以下のような座標変換を行ってみます:
\begin{align} u &:= x+y \\ v &:= x-y \end{align}
各位置ベクトルに対して45°の回転行列をかけたあと 倍すると理解してもよいでしょう。この変換により先ほどの領域は、u-v座標系で表すと次の通り正方形になります。

これにより、正方形区画に点が何個含まれるかを求める問題に帰着することができました。これが解けるかどうかは別として、少なくとも菱形で考えるよりはなんとなく見通しがよさそうですよね。
このように、マンハッタン距離に関する問題はとりあえず45°回転してから考えるというのが典型となっています。
以降はこのu-v座標系で議論を進めていきます。
なお、座標変換により
となっています。
典型3: 平面走査
「正方形区画内の点の個数」を、もう少しだけ扱いやすい形で表現します。
座標平面上の各格子点を二次元グリッドのマス目に置き換え、点が存在するなら 1, しないなら 0 を書き込みます。これにより、二次元グリッド上で正方形区画内の総和を求める問題として定式化できます。

なお、 の範囲を思い出すと、グリッドサイズが縦横いずれも
となっていることに注意しましょう。(つまり、プログラム上で明にこのグリッドを二次元配列で持つことはできません*4。悲しい。)
さて、このような大きなグリッドに関する問題に対しては、平面走査と呼ばれる典型的なアプローチがあります。まずは、次のスライドをご覧ください。
何がしたいのかは一旦置いておくとして、行ごとに走査しながら配列に値を加算していく様子が伺えます。このように、何かしらのデータを持ち、行(あるいは列)ごとにデータを更新しながら平面を走査することを、総称して平面走査と呼びます。
急にわけのわからない操作をし始めたぞコイツ…?と思われているかもしれませんが、実はこの操作に超重要な秘密が隠されています!
↓この緑部分の総和(4)から…

↓この黄色部分の総和(1)を引くと…

求めたかった正方形区画の総和(4-1 = 3)が得られます!
この理由は単純で、矩形和の引き算に対応しているからです。

ということで、正方形区画の総和は次の要領で求められます:
- 平面走査を行う。その途中で、次の二つの値を別途保存しておく。
- 黄色部分の総和
- 緑部分の総和
- 上記で保存した2数の差が答え。
計算量を考えてみましょう。グリッドの横幅を ,
を
とおくと、
- 平面走査については、長さ
の配列を用意したうえで更新が
回発生するので、
- 黄色部分、緑部分の総和を求めるのは
で、これは平面走査の
があるためボトルネックにならない。
- 黄色部分、緑部分の総和を求めるのは
- → 判定問題
1回あたり
- → 全体
あれ…??????全然よくなってない…(´・ω・`)
典型4: 並列二分探索
平面走査などというトンデモびっくりアプローチまで取り出して頑張ったのに、計算量は何も改善されていませんでした。果たして、今までの努力は何だったのでしょうか…。
ここで、全く新しい視点を導入します。
今までは質問1つごとにどう解くか?ということだけを考えていましたが、思い切って 個の質問を一斉に解くことを考えてみましょう。
より正確には、 個の質問に対して二分探索を同時に実施し、
個の判定問題
を1回の平面走査中にまとめて解くことにします。
今までの議論と同様に考えると、グリッド上に 個の正方形区画が与えられ、それぞれの総和を求める問題を解くことになります。実はこれ、正方形が何個だろうとやることは全く変りません。すなわち、平面走査の途中で各正方形区画ごとに対応した黄色部分の総和と緑部分の総和を保存し、最後にそれぞれ差をとればよいです。
ありがとう、平面走査…!!!!
計算量を考えます。
- 平面走査については、長さ
の配列を用意したうえで更新が
回発生するので、
- 黄色部分、緑部分の総和を求めるのは各
ごとに
で、まとめると
- 黄色部分、緑部分の総和を求めるのは各
- → 判定問題
をまとめて解く処理1回あたり
- → 全体
惜しい!!
ここにきて、配列の区間和を計算する箇所がボトルネックになってしまいました。
典型5: Segment Tree
いよいよ最後の典型です。
平面走査中に行いたい処理を改めて書き下すと、次の2つです:
- 1点更新
- 区間和取得
平面走査のデータを通常の配列で持ってしまうと、どうしても区間和取得に区間長分の計算量がかかってしまいます。
そこで登場するのが、競プロにおいて典型中の典型、キング・オブ・典型データ構造である Segment Tree です*5。
Segment Tree でデータを持つと、上記の2操作がいずれも (
: グリッドの横幅)で処理できます。
詳細は端折りますが、ざっくり概要だけ解説します。
Segment Tree は図のような完全二分木の構造をしており、葉の部分には平面走査のデータ、その他の節点には左右の子の和が保存されています。ここで、完全二分木の高さは 、節点数は
になることに注意してください。

このようにデータを持つと、1点更新は図のように 個の節点の更新で済みます。

また、任意の区間和についても参照する節点数が で抑えられることが示せます*6。

というわけで、Segment Tree を用いれば1点更新と区間和の取得の両方を高速に処理できます。
Segment Tree を用いた場合の計算量は、次の通りです:
- 平面走査については、長さ
の配列を用意したうえで更新が
回発生するので、
- 黄色部分、緑部分の総和を求めるのは各
ごとに
で、まとめると
- 黄色部分、緑部分の総和を求めるのは各
- → 判定問題
をまとめて解く処理1回あたり
- → 全体
長い長い戦いの末、ついに2乗の項を完全に消すことができました。
ステップ数の計算はもう面倒なのでしませんが、実行時間はちゃんとこの解法で制限に収まります。
以上、解説を終わります。お疲れさまでした。
おわりに
典型に典型を重ねて考察を進める雰囲気を、少しは味わっていただけたでしょうか?
個人的には、自身の知りうる典型知識を如何にうまく組み合わせて解法に到達するかを考えるのが、競プロ(というか、AtCoder Beginner Contest)の醍醐味のように思います。
この記事を読んで、内容を全部理解できたかどうかは正直どうでもいいです。(そもそも初学者が解く問題の難易度じゃないので。)
それよりも、なんとなく「面白そう!」と思った方は、間違いなく競プロ向いてると思うので、是非この機会に始めてみてはいかがでしょうか。
逆に、「難しそう…こわ…近寄らんとこ…」と感じた方であっても、「はじめに」で述べた通りこの記事の中に少しでも「なるほど!」と高揚感を得られた箇所があれば、それが競プロを楽しむモチベーションになっているということだけでもを知っていただければ幸いです。
それでは。
参考文献
Editorial - AtCoder Beginner Contest 233
CODE THANKS FESTIVAL 2017 H - Union Sets (並列二分探索解法) - ARMERIA