Vattenhällning – Procedural och funktionell Kotlin

Publicerad

(Filippo Scognamiglio) ( 20 sep 2019)

Låt oss prova något annat! I det här inlägget ska vi lösa det berömda “vattenhäll” -problemet med hjälp av Kotlin två gånger: det procedurella sättet och det funktionella sättet.

Problemet

I standardpusslet” vattenhäll ”får du * n * glas med olika kända kapaciteter och du ombeds att hitta en lista med åtgärder som leder till önskad mängd i ett eller flera glasögon. Du får bara utföra tre slags åtgärder:

  • Fyll helt ett glas
  • Töm helt ett glas
  • Häll ett glas i ett annat tills den första är tom eller den andra är full

(Jag vet vad du tänker … Du kan inte fuska genom att fylla ”ett halvt” glas … jag ser dig).

Domänen

Låt oss börja med att karakterisera domänen. Vi kommer att representera ett tillstånd med följande dataklasser:

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

Därefter definierar vi ett drag med en abstrakt klass med en metod som omvandlar det aktuella tillståndet till den modifierade:

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

Den förseglade klassen kommer att ha de tre möjliga implementeringarna:

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 }

Vi behöver också en datastruktur för att representera en lista med drag som tar ett slutligt tillstånd:

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

Alla dessa klasser är oföränderliga genom design, och vi kommer att utnyttja detta i följande implementeringar.

Procedurimplementeringen

Detta problem kan representeras som ett diagram, där varje tillstånd är en nod och varje drag är en kant som förbinder två grannstater. Vi ska hitta lösningen genom att utforska den med en Breath First Search-algoritm.

Låt oss börja med att generera alla möjliga drag; vi kan fylla varje glas, tömma varje glas och hälla varje glas i ett annat:

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 kan vi skapa en lösa -metoden, som tar en önskad kvantitet och returnerar den kortaste listan med drag som definierar en lösning.

Vi samlar alla outforskade tillstånd i a MutableList som i början bara innehåller det initiala tillståndet.

Sedan börjar vi itera , vid varje steg:

  • Poppa huvudet från gränsen. Om detta tillstånd är en lösning är vi klara. Om tillståndet redan är utforskat hoppar vi över det.
  • Lägg till detta tillstånd i uppsättningen utforskade (detta förhindrar någon form av cykling).
  • Beräkna alla möjliga grannländer genom att använda alla möjliga drag och vi lägger till dem i slutet av gränsen.

Detta kommer garanterat att slutföras, antingen hittar vi en lösning eller så undersöker vi hela sökutrymmet.

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
}

Funktionell implementering

Låt oss gå till funktionsimplementeringen. Den övergripande strukturen kommer att vara likartad och vi börjar med att ändra createMoves -metoden:

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 det funktionella lösa ska vi hämta inspiration från Martin Odersky .

Vi definierar en rekursiv funktion nextPaths som ges en lista över sökvägar med längd n returnerar en sekvens av listor som innehåller längdvägarna n+1.

Detta görs av anropar utvidgningsmetoden på var och en av inmatningsvägarna med alla möjliga drag (filtrering av utforskade tillstånd).

På detta sätt garanteras vi att kortaste vägar behandlas först, vilket leder till en ny implementering av andningsförstöring algoritm.

Eftersom Kotlin-sekvenser är lättsamt utvärderade vet vi att längre vägar beräknas endast om kortaste vägar a är inte lösningar på problemet.

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

Slutsatser

Låt oss försöka lyfta fram takeawaysna för detta korta inlägg:

  • Båda implementeringarna arbetade med de givna testfallet
  • Procedurimplementeringen var 2-3 gånger snabbare på samma input
  • Den funktionella implementeringen var mer kortfattad och utan tvekan renare
  • Den funktionella implementeringen kan enkelt optimeras för att dra nytta av flera kärnor

Det är ofta inte lätt att ta reda på vilken som är den bästa metoden, och funktionell programmering är inte silver kodningskod.

Den goda nyheten är att du inte behöver en silverkula (såvida du inte behöver besegra en gammal, shapeshifting, clown-varelse): vi måste lyxa för att använda en multi-paradigm-programmering språk så att du enkelt kan välja rätt verktyg för jobbet.

Lämna ett svar

Din e-postadress kommer inte publiceras. Obligatoriska fält är märkta *