Gießen von Wasser – prozedurales und funktionelles Kotlin

Veröffentlicht

(Filippo Scognamiglio) ( 20. September 2019)

Probieren wir etwas anderes aus! In diesem Beitrag werden wir das berühmte Problem des „Wassergießens“ mit Kotlin zweimal lösen: auf prozedurale und auf funktionale Weise.

Das Problem

Im Standard-Puzzle“ Wasser gießen „erhalten Sie * n * Gläser mit verschiedenen bekannten Kapazitäten und Sie werden aufgefordert, eine Liste von Aktionen zu finden, die zu einer gewünschten Menge in einer oder mehreren Gläsern führen. Sie dürfen nur drei Arten von Aktionen ausführen:

  • Füllen Sie ein Glas vollständig aus
  • Leeren Sie ein Glas vollständig
  • Gießen Sie ein Glas in ein anderes, bis Der erste ist leer oder der zweite ist voll.

(Ich weiß, was Sie denken. Sie können nicht schummeln, indem Sie ein halbes Glas füllen. Ich kann Sie sehen.)

Die Domäne

Beginnen wir mit der Charakterisierung der Domäne. Wir werden einen Zustand mit den folgenden Datenklassen darstellen:

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

Als nächstes definieren wir eine Verschiebung unter Verwendung einer abstrakten Klasse mit einer Methode, die den aktuellen Zustand in transformiert die modifizierte:

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

Die versiegelte Klasse verfügt über die drei möglichen Implementierungen:

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 }

Wir benötigen auch eine Datenstruktur, um eine Liste von Bewegungen darzustellen, die in einen Endzustand übergehen:

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)
}
}

Alle diese Klassen sind vom Design her unveränderlich. und wir werden dies in den folgenden Implementierungen ausnutzen.

Die prozedurale Implementierung

Dieses Problem kann als Diagramm dargestellt werden, wobei jeder Zustand ein Knoten und jede Bewegung eine Kantenverbindung ist zwei Nachbarstaaten. Wir werden die Lösung finden, indem wir sie mithilfe eines Breath First Search-Algorithmus untersuchen.

Beginnen wir damit, jede mögliche Bewegung zu generieren. Wir können jedes Glas füllen, jedes Glas leeren und jedes Glas in ein anderes gießen:

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
}

Jetzt können wir eine Lösen Sie die -Methode, die eine gewünschte Menge verwendet und die kürzeste Liste von Zügen zurückgibt, die eine Lösung definieren.

Wir akkumulieren alle unerforschten Zustände in a MutableList , die zu Beginn nur den Anfangszustand enthält.

Dann beginnen wir mit der Iteration Bei jedem Schritt:

  • Pop den Kopf von der Grenze. Wenn dieser Zustand eine Lösung ist, sind wir fertig. Wenn der Status bereits untersucht wurde, überspringen wir ihn.
  • Fügen Sie diesen Status zu den erkundeten hinzu (dies verhindert jede Art von Radfahren).
  • Berechnen Sie alle möglichen Nachbarzustände, indem Sie anwenden jede mögliche Bewegung, und wir fügen sie am Ende der Grenze hinzu.

Dies wird garantiert abgeschlossen, entweder wir finden eine Lösung oder wir erkunden den gesamten Suchraum.

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
}

Die funktionale Implementierung

Gehen wir zur Funktionsimplementierung über. Die Gesamtstruktur wird ähnlich sein, und wir beginnen mit der Änderung der Methode 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
}

Für die funktionale Lösung lassen wir uns von Martin Odersky .

Wir definieren eine rekursive Funktion nextPaths , das eine Liste von Pfaden mit der Länge n angibt, gibt eine Folge von Listen zurück, die die Pfade der Länge n+1 enthalten.

Dies geschieht durch Aufrufen der Extend-Methode für jeden der Eingabepfade mit allen möglichen Bewegungen (Herausfiltern der erkundeten Zustände).

Auf diese Weise wird garantiert, dass die kürzesten Pfade zuerst verarbeitet werden, was zu einer weiteren Implementierung der Suche nach dem ersten Atemzug führt Algorithmus.

Da Kotlin-Sequenzen träge ausgewertet werden , wissen wir, dass längere Pfade nur berechnet werden, wenn kürzeste Pfade a Es gibt keine Lösungen für das Problem.

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) }
}

Schlussfolgerungen

Versuchen wir, die Erkenntnisse dieses kurzen Beitrags hervorzuheben:

  • Beide Implementierungen arbeiteten an den angegebenen Testfällen
  • Die prozedurale Implementierung war bei derselben Eingabe zwei- bis dreimal schneller
  • Die funktionale Implementierung war prägnanter und wohl sauberer
  • Die funktionale Implementierung könnte leicht optimiert werden, um mehrere Kerne zu nutzen.

Es ist oft nicht einfach herauszufinden, welcher Ansatz der beste ist, und funktionale Programmierung ist nicht das Silber Kugel der Codierung.

Die gute Nachricht ist, dass Sie keine Silberkugel benötigen (es sei denn, Sie müssen eine alte, sich verändernde, clownhafte Kreatur besiegen): Wir müssen Luxus haben, um eine Multi-Paradigma-Programmierung zu verwenden Sprache, sodass Sie ganz einfach das richtige Werkzeug für den Job auswählen können.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.