AtCoderをSQLで解いて提出(ABC 170 E - Smart Infants)
はじめに
AtCoderのサポート言語にSQLはありませんが、
Python標準搭載のSQLiteを用いるとSQLで解くことができます。
PythonユーザーにとってはAtCoderで稀に要求されるMultiSetというデータ構造の代替になりえます。
お題
概要
次のような設定の問題です
- 複数の幼稚園児と複数の幼稚園があります。
- 幼稚園児はどこか1つの幼稚園に所属します。
- 幼稚園はAtCoderのレートを持っています。(ぶっとんでる🤣)
- 1人の幼稚園児の転園を行うクエリがQ回きます
- クエリがくるごとに、転園後の「"各幼稚園の所属園児の最大レート"の最小値」を出力する問題です
通常解法
公式解説されているので詳細は割愛します。
ポイントは、次になります。
- MultiSetというデータ構造が要求される
- 最大・最小の取得、データの追加・削除がO(log N)で可能
- MultiSetはPythonにはない
- MultiSetの独自実装はすごく大変
- 今回の問題については、ならし計算量とheapqを使うと回避することもできます。DNA1980さんの解説
SQLで解いてみる
SQLite3を使います
SQLite3のドキュメントはこちらで、
提出コードはこちらです
ポイント0: ローカル関数で高速化
def main():
...
main()
- Pythonの定番テクニックです
- グローバル変数へのアクセスが遅いので、全部をローカルに書くことで高速化します
- PyPyだと大きな差はないことや、逆に遅くなるケースもありますが、一応入れます
ポイント1: インメモリDB
con = sqlite3.connect(':memory:', isolation_level=None)
ファイルに書き込むと遅いのでインメモリDBモードでSQLiteを起動します。
PythonのSQLite3は自動でコミットしたりする機能がありますが、isolation_level=None
で無効化しておきます
ポイント2: 不要機能のOFF
PRAGMA trusted_schema = OFF;
PRAGMA journal_mode = OFF;
PRAGMA synchronous = OFF;
PRAGMA temp_store = memory;
PRAGMA secure_delete = OFF;
不要機能をOFFにして高速化します。
詳細はSQLiteのドキュメントにあります
ポイント3: テーブルとインデックス作成
CREATE TABLE infants (kindergarten INT, rate INT);
CREATE INDEX infant_index ON infants(kindergarten, rate);
CREATE TABLE kindergartens (rate INT);
CREATE INDEX rates ON kindergartens(rate);
CREATE TABLE ans(min_rate INT);
- infants(園児)テーブルは各園児の所属幼稚園(kindergarten)とレートのテーブルです
- kindergartens(幼稚園)テーブルは所属園児の最大レートのテーブルです
- ansテーブルはクエリごとにその時点でのkindergartensの最小値を格納するテーブルです
- クエリごとには答えを出力せずにansテーブルに答えを貯めておいて、最後にansテーブルの中身をまとめて出力して問題に解答します
-
Python <-> SQLite
のやりとりを減らして高速化できます
後に、SELECT MAX(rate) FROM infants WHERE kindergarten = ..
というクエリを使うのですが、これを高速にできるようなインデックスを作ります
それが CREATE INDEX infant_index ON infants(kindergarten, rate);
の部分です。
- インデックスのMAXは2分探索で高速に処理される
- インデックスタプルの左側がWHEREにくるようにする 参考
kindergartensも同様に、後でMINをとるクエリを使うためにインデックスを張っておきます
ポイント4: トリガーで自動更新
CREATE TRIGGER kindergarten_rate
AFTER UPDATE ON infants
BEGIN
UPDATE kindergartens SET rate =
(SELECT MAX(rate) FROM infants WHERE kindergarten = old.kindergarten)
WHERE rowid = old.kindergarten;
UPDATE kindergartens SET rate =
(SELECT MAX(rate) FROM infants WHERE kindergarten = new.kindergarten)
WHERE rowid = new.kindergarten;
INSERT INTO ans
SELECT MIN(rate) FROM kindergartens;
END;
-
infantsテーブルに更新があると自動でkindergartenテーブルとansテーブルに反映されるようにします。こうすることで、いちいちUpdateを発行せずに済み、
Python <-> SQLite
のやりとりを減らして高速化できます -
rowidを用いて高速化します。
- SQLiteでは行固有のカラムであるrowidが自動で作られています。
- rowidはinsertのたびに1から自動採番でふられます
- Insertをうまく行うことで、rowidと幼稚園IDが一致するようにします。
- これは後述の「ポイント6: PythonループでなくSQLで初期化」で行います
ポイント5: トランザクションで高速化?
cur.execute("begin")
- トランザクションをはると複数のInsertが高速になるという記事を見かけたので、トランザクションを開始しておきます。
- commitは不要なのでしません
- 速くなってるような、なってないようなという感じですがおまじないでいれておきます
ポイント6: stdin.readlineで高速読み取り
for _ in range(N):
# a: rate, b: kindergarten
rate, kindergarten = map(lambda x: int(
x), sys.stdin.readline().split())
cur.execute("INSERT INTO infants VALUES(?, ?)", (kindergarten, rate))
- 標準入力から読み取った値をinfantsテーブルに格納します
-
input()
ではなくsys.stdin.readline()
を使うと速くなります
ポイント7: PythonループでなくSQLで初期化
WITH RECURSIVE cnt(x) AS (
SELECT 1
UNION ALL
SELECT x+1 FROM cnt
LIMIT 200001
)
INSERT INTO kindergartens
SELECT MAX(rate)
FROM cnt LEFT OUTER JOIN infants
ON cnt.x = infants.kindergarten
GROUP BY x
初期化のポイントは次になります
- SQLで行うことで
Python <-> SQLite
のやりとりを減らして高速化- 普通にやるとPythonのループ内でINSERTを何回も発行するが遅い
- 「ポイント4: トリガーで自動更新」のために、幼稚園IDとDBのrowidが一致するようにする。幼稚園ID順で行が並ぶように、NULLも含めてinsertする
上記を実現するために、次のテクニックを利用します
-
WITH RECURSIVE ...
の部分で、1~200001の連番の入っているテーブルcntを作成-
WITH RECURSIVE
の解説はこちら
-
-
LEFT OUTER JOIN...
することで、1~200001のkindergartenに所属する園児レート(なければNULL)のテーブルを作成します -
MAX(rate) GROUP BY x
で幼稚園IDごとの最大値(なければNULL)をSELECT
あとはクエリを処理して結果出力するだけ
for _ in range(Q):
# c: infant, d: next kindergarten
infant, next_kindergarten = map(
lambda x: int(x), sys.stdin.readline().split())
cur.execute("UPDATE infants SET kindergarten = ? WHERE rowid = ?",
(next_kindergarten, infant))
cur.execute("select * from ans")
ans = cur.fetchall()
for (result, ) in ans:
sys.stdout.write(str(result) + "\n")
- infantsテーブルにUpdateすると「ポイント4: トリガーで自動更新」のトリガーが自動で実行され、kindergartenテーブルとansテーブルに結果が反映されます
- 全クエリを格納後にansの中身を出力すれば答えです
まとめ
Python標準搭載のSQLiteでSQLを使ってAtCoderの問題が解けることを解説しました。
PythonやSQLの高速化テクニックが必要でめっちゃ大変でした
私のような物好きな人はSQLを使ってみてはどうでしょうか。
追記: 最近もSQLで解ける問題が出たみたいです
追記2:
現在では平方分割によるmultisetライブラリが共有されているので、そっちを使う方が簡単そうです
Discussion