水を注ぐ—手続き型および機能的なKotlin

投稿日:

(Filippo Scognamiglio)( 2019年9月20日)

別のことを試してみましょう!この投稿では、Kotlinを使用して有名な「水を注ぐ」問題を2回解決します。手続き型と機能型です。

問題

標準の「水を注ぐ」パズルでは、さまざまな既知の容量の* n *個のグラスが与えられます。 1つまたは複数のグラスで目的の量につながるアクションのリストを見つけるように求められます。実行できるアクションは次の3種類のみです。

  • グラスを完全に満たす
  • グラスを完全に空にする
  • グラスを次のグラスに注ぐまで最初のものは空であるか、2番目のものはいっぱいです

(私はあなたが何を考えているか知っています…グラスの「半分」を満たしてチートすることはできません…私はあなたを見ることができます)。

ドメイン

ドメインの特徴を明らかにすることから始めましょう。次のデータクラスで状態を表します。

data class Glass(val capacity: Int, val value: Int)
data class State(val glasses: List)

次に、現在の状態をに変換するメソッドを使用して、抽象クラスを使用して移動を定義します。変更されたもの:

sealed class Move {
abstract fun update(state: State): State
}

封印されたクラスには、3つの可能な実装があります:

data class Fill(val index: Int): Move() {
override fun update(state: State): State =
State(state.glasses.mapAtIndex(index) {
it.copy(value = it.capacity)
})
}

data class Empty(val index: Int): Move() {
override fun update(state: State): State =
State(state.glasses.mapAtIndex(index) {
it.copy(value = 0)
})
}

data class Pour(val from: Int, val to: Int): Move() {
override fun update(state: State): State {
val fromGlass = state.glasses[from]
val toGlass = state.glasses[to] val maxFromQuantity = fromGlass.value
val maxToQuantity = toGlass.capacity - toGlass.value val quantity = min(maxFromQuantity, maxToQuantity)

return State(
state.glasses
.mapAtIndex(from) {
it.copy(value = it.value - quantity)
}
.mapAtIndex(to) {
it.copy(value = it.value + quantity)
}
)
}
}// Utility extension function
private fun List.mapAtIndex(index: Int, f: (T) -> T) =
this.mapIndexed { i, t -> if (i == index) f(t) else t }

最終状態になる移動のリストを表すデータ構造も必要です。

data class Path(val finalState: State, val moves: List) {
fun extend(move: Move): Path {
val nextState = move.update(finalState)
val nextMoves = moves + move
return copy(finalState = nextState, moves = nextMoves)
}
}

このクラスはすべて設計上不変ですが、これを次の実装で活用します。

手続き型実装

この問題はグラフとして表すことができ、各状態はノードであり、各移動はエッジ接続です。 2つの隣接する状態。幅優先探索アルゴリズムを使用して解決策を見つけることで解決策を見つけます。

可能なすべての動きを生成することから始めましょう。すべてのグラスを満たし、すべてのグラスを空にして、すべてのグラスを別のグラスに注ぐことができます。

private fun generateMoves(): List {
val result = mutableListOf()

val indices = initialState.glasses.indices

for (i in indices) {
result.add(Move.Fill(i))
result.add(Move.Empty(i))
}

for (i in indices) {
for (j in indices) {
if (i != j)
result.add(Move.Pour(i, j))
}
}

return result
}

これで、 resolve メソッド。必要な量を取得し、ソリューションを定義する移動の最短リストを返します。

すべての未探索の状態をに蓄積します。 MutableList は、最初は初期状態のみを含んでいます。

次に、反復を開始します。 、各ステップで:

  • フロンティアから頭をポップします。この状態が解決策である場合、これで完了です。状態がすでに探索されている場合はスキップします。
  • 探索された状態のセットにこの状態を追加します(これにより、あらゆる種類のサイクリングが防止されます)。
  • 適用して、考えられるすべての隣接状態を計算します。考えられるすべての動きを、フロンティアの最後に追加します。

解決策を見つけるか、完全な検索スペースを探索することで、これは確実に完了します。

fun solve(amount: Int): Path? {
val explored = mutableSetOf()
val frontier = mutableListOf(Path(initialState, listOf()))

while (frontier.isNotEmpty()) {
val currentPath = frontier.removeAt(0)

if (isSolution(amount, currentPath.finalState)) {
return currentPath
}

if (explored.contains(currentPath.finalState)) {
continue
}

explored.add(currentPath.finalState)

for (move in moves) {
val nextPath = currentPath.extend(move)
frontier.add(nextPath)
}
}

return null
}

関数の実装

関数の実装に移りましょう。全体的な構造は似ています。まず、 generateMoves メソッドを変更します。

private fun generateMoves(): List {
val indices = initialState.glasses.indices

val fillMoves = indices.map { Move.Fill(it) }
val emptyMoves = indices.map { Move.Empty(it) }
val pourMoves = indices
.flatMap { i -> indices.map { j -> i to j } }
.filter { (i, j) -> i != j }
.map { (i, j) -> Move.Pour(i, j) }

return fillMoves + emptyMoves + pourMoves
}

機能的な解決については、 Martin Odersky

再帰関数 nextPaths n のパスのリストを指定したdiv>は、長さ n +1のパスを含むリストのシーケンスを返します。

これは可能なすべての移動を使用して、各入力パスでextendメソッドを呼び出します(探索された状態を除外します)。

このようにして、最短パスが最初に処理され、ブレスファースト検索の別の実装につながることが保証されます。アルゴリズム。

また、Kotlinシーケンスは遅延評価されるため、最短パスがaの場合にのみ、長いパスが計算されることがわかります。問題の解決策ではありません。

fun solve(amount: Int): Path? {
val firstPath = Path(initialState, listOf()) return nextPaths(listOf(firstPath), setOf())
.flatten()
.filter { isSolution(amount, it.finalState) }
.firstOrNull()
}

fun nextPaths(paths: List, explored: Set): Sequence> {
return if (paths.isEmpty()) emptySequence()
else {
sequence {
val nextPaths = paths.flatMap {
extendWithMoves(it, explored)
}
val nextExplored = explored + paths.map {
it.finalState
}

yield(nextPaths)
yieldAll(nextPaths(nextPaths, nextExplored))
}
}
}

fun extendWithMoves(path: Path, explored: Set): List {
return moves
.map { path.extend(it) }
.filter { !explored.contains(it.finalState) }
}

結論

この短い投稿の要点を強調してみましょう:

  • 両方の実装が特定のテストケースで機能しました
  • 手順の実装は同じ入力で2〜3倍高速でした
  • 機能の実装はより簡潔で、ほぼ間違いなくクリーンでした
  • 関数型実装は複数のコアを活用するように簡単に最適化できます

最適なアプローチを見つけるのは簡単ではないことが多く、関数型プログラミングは銀ではありませんコーディングの弾丸。

良いニュースは、銀の弾丸は必要ないということです(古代の、形を変える、ピエロのような生き物を倒す必要がない限り):マルチパラダイムプログラミングを使用するには贅沢が必要です言語なので、仕事に適したツールを簡単に選ぶことができます。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です