永続データプログラミングと競技プログラミング 〜 Haskell でがんばる競プロ
この記事は Haskell - Qiita Advent Calendar 2024 - Qiita の 5日目の記事です。
純粋関数型言語の Haskell では、値は基本的に不変です。リスト、Set、Map など基本的なデータ構造も不変データ構造として提供されています。
不変なデータ構造は変更をしても、変更前の値が残ります。結果、データを変更したあとも以前のデータを参照することができるという特性が得られます。この特性を指して、不変なデータのことを「永続データ」と呼び、永続データを駆使して問題を解くことを「永続データプログラミング」と呼ぶことがあります。
Haskell で関数型プログラミングをすると、自然と永続データプログラミングを実践することになります。
永続データプログラミングを支えるのが「永続データ構造」の存在です。
不変なデータで変更を表現するには、元のデータをコピーして、それを元に変更後のデータを作るという方法が考えられます。このときデータ構造全体をナイーブにコピーしてしまうと、抱えているデータのサイズ分だけコピーが行われて非効率です。
データ構造全体をコピーするのではなく「変更されるノードとそのノードへ直接的・間接的に参照を持つノードだけをコピーする」ことによって計算量を抑え、不変でありながらも効率的なデータ更新が可能になるよう実装されているのが、永続データ構造です。
Haskell のリスト、Set、Map など不変なデータ構造はいずれも永続データ構造であると言えます。不変なデータ構造を使って実装していくと、それは必然的に永続データプログラミングになる、と言えます。
永続データプログラミングと永続データ構造についてのあらましは、先日 永続データプログラミングと永続データ構造 - 一休.com Developers Blog に解説しましたので、参考にしてください。
Haskell で競技プログラミング
さて、そんな Haskell で競技プログラミングをする話です。
ご存知の通り、Haskell では変更可能なデータ構造を用いた命令型 (手続き的) プログラミングをすることも可能ではあります。
が、わざわざ Haskell を使って競技プログラミングを嗜むのであれば、私個人としては、なるべく関数型プログラミングで問題を解いていくことを目標としたいところです。イミュータブルすなわち永続データプログラミングのスタイルで、競技プログラミングの問題を解く!
競技プログラミングでは量的に大きなデータを扱うので、イミュータブルにやっていくなら永続データ構造が必須です。
命令型でやればメモリ使用量的にも実行速度的にも効率的であることがわかっていながら、あえて関数型プログラミングで競プロに挑むのは果たしてハンデなのでしょうか? いいえ、永続データプログラミングを意識的に実践することにより、それがアドバンテージになる場面というのは多々あります。
本稿では Haskell、関数型プログラミング、永続データプログラミングで競技プログラミングに挑むとはどんな感じなのか、私なりの考え方を紹介していきたいと思います。
永続データを意識して問題を解く 〜 命令型プログラミングとの対比
簡単な問題を取り上げます。
整数がすべて偶数なら全部
8 12 40
という数列があったら、2 で割るたび
4 6 20
2 3 10
と遷移したところで奇数が出る。そこで遷移は終わり。
この例では最大の操作回数は
これを命令的 (手続き的) に実装する場合、たとえば以下のようになるでしょう。Python で、命令的な記述を ChatGPT に書かせました。
n = int(input())
as = list(map(int, input().split()))
count = 0
while all(a % 2 == 0 for a in as):
for i in range(n):
as[i] //= 2
count += 1
print(count)
カウンターを用意して、条件を満たしている間 ··· つまりすべて偶数である間 for ループで繰り返し要素を
さて、私が Haskell で実装する場合はすこし様子が異なります。
main :: IO ()
main = do
_ <- getInt
as <- getInts
let res = iterate (map (`div` 2)) as
i = fromJust $ findIndex (any odd) res
print i
とするか、もしくは
main :: IO ()
main = do
n <- getInt
as <- getInts
let res = takeWhile (all even) $ iterate (map (`div` 2)) as
print $ length res
のように実装します。
ポイントになるのは、リストの要素を
最初の命令的な実装では、操作のたび配列の値を破壊的に変更しています。
一方、私の Haskell の実装は、リストの元の値を直接変更するのではなく、写像 (map) によってリストから別のリストを作っています。iterate
で写像を繰り返すことでリストの変更履歴が作られます。
このリストの履歴を生成してから、改めて履歴をみて奇数を含むのが何番目か findIndex
で検索する、あるいは偶数の間写像を繰り返してできあがった履歴の長さを数える、ということで答えとしています。
命令的な実装ではその場その場で元の配列を書き換えるため、データ構造を変更してしまったら、変更前の状態にはアクセスできません。短命データを使った命令型プログラミングです。Haskell の方は、繰り返しリストの写像を行う過程がすべて残るという永続データになっている。永続データプログラミング、と言えます。
「何かをしながら、何かをする」or「何かをしたあと、何かをする」
もう少し両者の違いを見てみます。
命令的な実装では、ループを回し配列を書き換えながら、カウンターをカウントアップしています。
while all(a % 2 == 0 for a in as):
for i in range(n):
as[i] //= 2
count += 1
Haskell の方は、
let res = iterate (map (`div` 2)) as
i = fromJust $ findIndex (any odd) res
写像の繰り返しと、操作回数を求める探索の計算を分離しています。
短命データは書き替えを行うとそれ以前の状態にはアクセスできません。処理効率は良いですが、ある時点のデータ構造にはその時点にしかアクセスできないので、その時々のデータ構造の状態に応じた別の計算が必要な場合、その二つの計算を切り離すことが難しい。必然的に「何かをしながら、何かをする」という実装が多くなります。「何かをするついでに」とも言えるでしょう。今回の場合は「配列を書き換えながら、カウンターを更新する」となります。
一方の永続データプログラミングでは変更前のデータにアクセスすることが可能なので、データ構造の変更 (状態遷移) と後続の処理を分離することが可能です。これにより「何かをしたあと、何かをする」という実装が可能です。今回の場合は「リストを繰り返し写像し終えてから、数える」となります。永続データが計算と計算の分離に一役買っているわけです。
もうひとつ、似たような問題を見てみましょう。
A
, B
, C
からなる文字列 A
, B
, C
しか登場しない。
この A
, B
, C
すべてが
たとえば
ACABB
は、B
ではじめて ABC
が出たことになるので、答えは
命令的に実装したものは、例えば以下のようになるでしょう。こちらも ChatGPT に書かせました。
set を利用して、break
しています。
n = int(input())
s = input()
seen = set()
for i in range(n):
seen.add(s[i])
if len(seen) == 3:
print(i + 1)
break
先の問題に同じく、命令型の実装は条件を満たすまでループを回し虎視眈々とそのタイミングを伺い、時がきたところで答えを出力しています。「set を更新しながら、3 文字揃うのを待つ」プログラムです。
一方の私の Haskell の実装は以下です。
main :: IO ()
main = do
_ <- getInt
s <- getLine
let ss = scanl (flip Set.insert) Set.empty s
i = fromJust $ elemIndex (Set.fromList ['A', 'B', 'C']) ss
print i
こちらも Set を使いましたが、Haskell の Set は永続データ構造であり、変更に伴う状態遷移の過程をすべて残すことができます。これにより文字列 A
, B
, C
三文字が揃うタイミングがいつかを見つける計算を分離できます。「
(命令型の実装は 3 文字揃ったところで break しているので効率は良いでしょう。なお、計算量的には同じ
このように命令型の実装は「何かをしながら、何かをする」実装になりやすい一方、関数型の実装は「何かをしたあと、何かをする」実装にしやすい、という特徴があります。
Haskell でプログラムを実装するときの一つの目標として、計算をなるべく独立した単位に分離し分割統治することがあると思います。その分離された小さな計算は多くの場合、写像か、畳み込みです。「写像してから、畳み込む」つまりmap / reduceですが、これが関数型プログラミングの典型的なメンタルモデルであり、競技プログラミングの実装も map / reduce に落とし込んでいくのが実装の基本になります。
おそらく多くの関数型プログラマーと共有できる感覚ではないかと思います。
「永続データ構造」が活きる
先の2問は対象としているデータが小さいものでした。ナイーブにデータ構造を写像しても特段問題にはならないでしょう。
では、もう少しデータが大きくなってきたときにも同様の考え方で実装することはできるのでしょうか? 結論、できます。
次の問題を解いてみます。先の 2 問に比較するとデータサイズも大きく、難しい問題です。
長さ
数列
1 5
2 1
3 3
4 2
2 10
とクエリが与えられます。最初に
と更新されていくので、
5
6
8
8
が、各タイミングでの大きな数 top
数列を更新しながら、そのときどきの top K の和を求める、ということでいかにも「データ構造を変更しながら、その時点の状態に応じた値を得る」ことをやる問題です。
この問題は、中核で使うデータ構造を集合として考えるとよいです。
私は BalancedMultiSet という名前で top
BalancedMultiSet の実装は、内部的には Data.IntMap を二つ使ったイミュータブルな実装になっているため、BalancedMultiSet もまた永続データ構造です。集合に値が追加されたり、削除されるたびに二つの IntMap を探索して必要に応じてそれぞれの最大 / 最小値を交換することにより、一方に top K の値が集まるようにします。本記事の趣旨とこの集合ライブラリの実装はあまり関係がないのでより詳細は割愛しますが、実装に興味がある方は提出済みのコード (提出 #60431339 - トヨタ自動車プログラミングコンテスト2023#3(AtCoder Beginner Contest 306)) をみてください。
main 関数の実装は以下のようになります。
main :: IO ()
main = do
[n, k, q] <- getInts
qs <- replicateM q getTuple
as <- newArray @IOUArray (1, n) (0 :: Int)
-- 空の BalancedMultiSet (集合) を用意
let s0 = emptyBMS k GT
ss <- scanForM s0 qs $ \s (xi, yi) -> do
prev <- readArray as xi
writeArray as xi yi
-- 集合から Ai の以前の値を削除し、新しい Ai を追加する
return $ (insertBMS yi . deleteBMS prev) s
-- 集合の状態遷移の履歴をすべて top K の和に変換する
let res = [leftSum s | s <- tail ss]
for_ res print
{-- BalancedMultiSet --}
data BalancedMultiSet = BalancedMultiSet
{ topKValue :: Int,
topKOrder :: Ordering,
left :: IntMultiSet,
right :: IntMultiSet,
leftSum :: Int
}
deriving (Show)
emptyBMS :: Int -> Ordering -> BalancedMultiSet
emptyBMS k order =
BalancedMultiSet
{ topKValue = k,
topKOrder = order,
left = emptyMS,
right = emptyMS,
leftSum = 0
}
-- BalancedMultiSet の実装詳細は略 --
これまでにみてきた問題同様の方針、つまり「集合の状態遷移をすべて済ませたあと、その履歴から答えを得る」ようにしています。
具体的には
ss <- scanForM s0 qs $ \s (xi, yi) -> do
-- snip. --
return $ (insertBMS yi . deleteBMS prev) s
ここで繰り返し、集合から古い値を削除し新しい値を追加するという状態遷移を行っています。
この問題で最終的に得たい値は top ss
には、集合が空である初期状態からクエリを一つ処理するたび集合の状態が遷移する過程すべてがリストとして束縛されています。
これでクエリを処理したその時々の集合の状態は得られるので、
let res = [leftSum s | s <- tail ss]
と、改めてデータ構造それぞれの状態を top
先の問題に同じく「何かをし終えてから、何かをする」と切り離して実装できています。
さて、この問題ではクエリが最大で
ご想像のとおり問題ないからこそ、TLE もしくは MLE にならずに、上記の実装で通っているわけです。BalancedMultiSet の実装で利用している Data.IntMap は永続データ構造です。
永続データ構造は冒頭で述べたとおりデータの変更にあたり直接・間接的に変更に関係のあるノードのみを変更して、それ以外は変更前後でノードを共有します。従って
永続データ構造であれば、たとえ
雑に言えば、Haskell では Set、Map などのデータ構造を写像により大量に状態遷移させても大丈夫だし、その考えに基づいて実装をすることができる、ということです。(もちろん、その時々の計算量を考える必要はあります)
よってこのケースでも、最初の2問同様に「何かをしおえてから、何かをする」「写像してから、畳み込む」というようにやりたいことを分離して実装することができます。
各々分離して実装できるということは、一つのコードブロックをより少ない責務にフォーカスさせて実装することができる、つまり、より認知負荷低く実装することができることを意味します。また読むときも、より認知負荷低く読むことができると思います。永続データ構造もまた、計算と計算を分離するのに役立っているわけです。
そして、処理を分離できるということはプログラムの構造をフラットに保ちやすいとも言えます。
命令型プログラミングの場合、データ構造のその時々の状態に依存した処理が連なって分離が難しいため、二重ループ、三重ループと入れ子になりがちですが、Haskell の場合、永続データ (それに加えてモナド) があるため、処理の入れ子を生じさせずフラットに実装することができます。
永続データプログラミングで ABC322 D - Polyomino を解く
ここまで見てきた方針を活かして、かの悪名高き? ABC322 D - Polyomino を解いてみましょう。
この問題は
パズルのピースのことを「ポリオミノ」と呼ぶそうです。ポリオミノは上下左右に動かしたり、回転したりはしてよいですが、ポリオミノの一部分を重ねて配置することはできません。
「D問題のわりに重実装問題」として悪名高いのがこの問題です。
しかし、ここまで見てきたとおり Haskell で「何かをし終えてから、何かをする」という分割統治の考え方に従って実装していくと、シンプルに実装することができます。
ポイントになるのは
- 二次元のグリッドを (配列ではなく) 集合 (Set) で表現する
- 3つのポリオミノそれぞれを 4回転させて、上下左右 4方向に移動させた Set をすべて先に用意する。つまり、
種類、回転3 回、4 方向に4 マス並行移動の4 個の形すべてを写像で全部先に作ってしまう。3 \times 4 \times 4\times 4 = 192 - できあがった全パーツから、3つを選び Union し、正方形判定をする
の、特に 2 の点です。
isSquare x y z
| not (Set.disjoint x y && Set.disjoint x z && Set.disjoint y z) = False
| Set.size p /= 16 = False
| otherwise = all (\v -> Set.member v p) $ range ((1, 1), (4, 4))
where
p = foldl1 Set.union [x, y, z]
main :: IO ()
main = do
gridA <- getCharGrid ((1, 1), (4, 4))
gridB <- getCharGrid ((1, 1), (4, 4))
gridC <- getCharGrid ((1, 1), (4, 4))
let a = fromArrayGS (== '#') gridA
b = fromArrayGS (== '#') gridB
c = fromArrayGS (== '#') gridC
ds = map (\[dx, dy] -> (dx, dy)) $ sequence [[0 .. 3], [0 .. 3]]
-- 回転して上下左右に並行移動した部品をすべて先に作る
as = [a'' | a' <- take 4 $ iterate rotateRightGS a, d <- ds, let a'' = Set.map (+ d) $ normalizeGS a']
bs = [b'' | b' <- take 4 $ iterate rotateRightGS b, d <- ds, let b'' = Set.map (+ d) $ normalizeGS b']
cs = [c'' | c' <- take 4 $ iterate rotateRightGS c, d <- ds, let c'' = Set.map (+ d) $ normalizeGS c']
-- うち、3つを組み合わせる全探索。正方形判定をする
res = [isSquare x y z | x <- as, y <- bs, z <- cs]
printYn $ or res
なお fromArrayGS
はグリッドの配列を Set に写像する関数、rotateRightGS
は座標の回転写像、normalizeGS
は図形を左上マス開始にする正規化の関数です。いずれもたいしたことはやっていません。実装の詳細に興味のある方は 提出 #59487824 - AtCoder Beginner Contest 322 を見てください。
この問題のデータは小さいので永続データ構造の効率性が必要な問題ではありません。一方、永続データや永続データ構造を意識できていると「Set を先に全パターン分作ってしまえばいい」「それをしてから、改めて全探索をすればいい」という 🧠 で考えられます。
図形を命令的データ構造で表現していると、回転したり上下左右移動するたびに以前の形は失われてしまうため、その都度、正方形判定をする必要があり、結果何重もの入れ子のループになってしまうところ、永続データであれば、それを避けてフラットに実装することができるわけです。
ついでに ABC307 C - Ideal Sheet も解く
図形の並行移動といえば C 問題にも関わらず水色難易度だった以下の問題があります。
問題の詳細は割愛しますが、こちらも先の問題同様の考え方で解くと、実装も含め非常にシンプルです。
main :: IO ()
main = do
[ha, wa] <- getInts
gridA <- getCharGrid ((1, 1), (ha, wa))
[hb, wb] <- getInts
gridB <- getCharGrid ((1, 1), (hb, wb))
[hx, wx] <- getInts
gridX <- getCharGrid ((1, 1), (hx, wx))
let a = fromArrayGS (== '#') gridA
b = fromArrayGS (== '#') gridB
x = fromArrayGS (== '#') gridX
res =
[ isShiftedGS c x
| [dx, dy] <- sequence [[-100 .. 100], [-100 .. 100]],
let a' = Set.map (+ (dx, dy)) a
c = Set.union a' b
]
printYn $ or res
「何かをしながら、何かをする」のではなく「何かをしたあと、なにかをする」。永続データや永続データプログラミングを意識した実装が、実装をフラットかつシンプルに保つきっかけになっていることが伝わるでしょうか?
まとめ
Haskell、というよりは関数型プログラミング、不変データ、永続データプログラミングで競技プログラミングを解くとはどういうことかを、見てきました。
命令型プログラミングの場合に比較して、永続データプログラミングを意識することで、個々の計算を独立したより細かい関数に分離することができました。問題によっては、それによって実装の認知負荷 🧠 を大きく下げることができます。決められた時間内に正確な実装を求められる競技プログラミングにおいては、実装の認知負荷を下げ、シンプルに留められることは大きな武器になります。シンプルに実装できるということは、解法の考察も複雑にならずに済む、ということでもあります。
そしてこの計算と計算の分離を可能にする一つのプログラミング要素が永続データであり、計算量を犠牲にせず永続データプログラミングを可能とするため、永続データ構造がそれを支えているのだろう、というのが私なりの考察です。
継続して競技プログラミングの問題を解いていると、関数型プログラミングの強力さに気づく機会が多くあります。関数型プログラミングで競プロをやるというのは、決して枷ではなく、場合によっては命令型プログラミングよりも (遙かに) 有利に問題を解けることもある、と実感しています。
···ただし、その域に達するまでが大変だけどな!
Discussion