Water gieten – procedurele en functionele Kotlin

(Filippo Scognamiglio) ( 20 sep.2019)

Laten we eens iets anders proberen! In deze post gaan we het beroemde “watergieten” -probleem twee keer oplossen met Kotlin: de procedurele manier en de functionele manier.

Het probleem

In de standaard” water gieten “-puzzel krijg je * n * glazen met verschillende bekende capaciteiten en u wordt gevraagd om een ​​lijst met acties te zoeken die leiden tot een gewenste hoeveelheid in een of meer glazen. Je mag maar drie soorten handelingen uitvoeren:

  • Een glas helemaal vullen
  • Een glas helemaal leegmaken
  • Het ene glas in het andere schenken tot de eerste is leeg of de tweede is vol

(ik weet wat je denkt … Je kunt niet vals spelen door een “half” glas te vullen … ik kan je zien).

Het domein

Laten we beginnen met het karakteriseren van het domein. We gaan een staat vertegenwoordigen met de volgende dataklassen:

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

Vervolgens definiëren we een zet met behulp van een abstracte klasse met een methode die de huidige toestand omzet in de gewijzigde:

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

De verzegelde klasse heeft de drie mogelijke implementaties:

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 }

We hebben ook een datastructuur nodig om een ​​lijst met zetten weer te geven die naar een eindtoestand gaat:

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

Al deze klassen zijn onveranderlijk qua ontwerp, en we zullen dit uitbuiten in de volgende implementaties.

De procedurele implementatie

Dit probleem kan worden weergegeven als een grafiek, waarbij elke toestand een knooppunt is en elke beweging een rand die twee buurlanden. We gaan de oplossing vinden door deze te verkennen met behulp van een Breath First Search-algoritme.

Laten we beginnen met het genereren van elke mogelijke zet; we kunnen elk glas vullen, elk glas legen en elk glas in een ander schenken:

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
}

Nu kunnen we een oploss methode, die een gewenste hoeveelheid aanneemt en de kortste lijst met zetten retourneert die een oplossing definiëren.

We verzamelen alle onontgonnen toestanden in een MutableList die in het begin alleen de begintoestand bevat.

Daarna beginnen we met itereren , bij elke stap:

  • Pop het hoofd van de grens. Als deze toestand een oplossing is, zijn we klaar. Als de staat al is verkend, slaan we deze over.
  • Voeg deze staat toe aan de reeks onderzochte (dit voorkomt elke vorm van fietsen).
  • Bereken alle mogelijke buurstaten door toe te passen elke mogelijke zet, en we voegen ze toe aan het einde van de grens.

Dit is gegarandeerd voltooid, we vinden een oplossing of we verkennen de volledige zoekruimte.

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
}

De functionele implementatie

Laten we naar de functie-implementatie gaan. De algehele structuur zal vergelijkbaar zijn, en we zullen beginnen met het wijzigen van de genererenMoves methode:

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
}

Voor de functionele oplossen laten we ons inspireren door Martin Odersky .

We definiëren een recursieve functie nextPaths dat gegeven een lijst met paden met lengte n een reeks lijsten teruggeeft die de paden met lengte n+1 bevatten.

Dit wordt gedaan door het aanroepen van de uitbreidingsmethode op elk van de invoerpaden met alle mogelijke zetten (waarbij de onderzochte toestanden worden uitgefilterd).

Op deze manier zijn we er zeker van dat de kortste paden als eerste worden verwerkt, wat leidt tot een andere implementatie van de eerste ademhaling zoeken algoritme.

Omdat Kotlin-reeksen lui worden geëvalueerd , weten we dat langere paden alleen worden berekend als de kortste paden een zijn geen oplossingen voor het probleem.

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

Conclusies

Laten we proberen de afhaalrestaurants van dit korte bericht te benadrukken:

  • Beide implementaties werkten aan de gegeven testcases
  • De procedurele implementatie was 2-3 keer sneller met dezelfde input
  • De functionele implementatie was beknopter en aantoonbaar schoner
  • De functionele implementatie kan gemakkelijk worden geoptimaliseerd om te profiteren van meerdere kernen.

Het is vaak niet eenvoudig om erachter te komen wat de beste aanpak is, en functioneel programmeren is niet de zilveren bullet of coding.

Het goede nieuws is dat je geen silver bullet nodig hebt (tenzij je een oud, van vorm veranderend, clownachtig wezen moet verslaan): we moeten luxe gebruiken om een ​​multi-paradigma-programmering te gebruiken taal, zodat u gemakkelijk het juiste gereedschap voor de klus kunt kiezen.

Geef een reactie

Het e-mailadres wordt niet gepubliceerd. Vereiste velden zijn gemarkeerd met *