Vannhelling – prosessuell og funksjonell Kotlin

(Filippo Scognamiglio) ( 20. sep 2019)

La oss prøve noe annet! I dette innlegget skal vi løse det berømte “vannpusteproblemet” ved hjelp av Kotlin to ganger: den prosessuelle måten og den funksjonelle måten.

Problemet

I standardpuslespillet» vannstøping «får du * n * briller med forskjellige kjente kapasiteter og du blir bedt om å finne en liste over handlinger som fører til ønsket mengde i ett eller flere briller. Du har bare lov til å utføre tre slags handlinger:

  • Fyll et glass helt
  • Tøm et glass helt
  • Hell et glass i et annet til den første er tom eller den andre er full

(Jeg vet hva du tenker … Du kan ikke jukse ved å fylle «et halvt» glass … Jeg ser deg).

Domenet

La oss starte med å karakterisere domenet. Vi skal representere en tilstand med følgende dataklasser:

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

Deretter definerer vi et trekk ved hjelp av en abstrakt klasse med en metode som forvandler den nåværende tilstanden til den modifiserte:

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

Den forseglede klassen vil ha de tre mulige implementeringene:

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 trenger også en datastruktur for å representere en liste over trekk som tar en endelig tilstand:

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 disse klassene er uforanderlige av design, og vi vil utnytte dette i de følgende implementeringene.

Prosessmessig implementering

Dette problemet kan vises som en graf, der hver tilstand er en node og hvert trekk er en kantforbindelse to naboland. Vi skal finne løsningen ved å utforske den ved hjelp av en Breath First Search-algoritme.

La oss starte med å generere alle mulige trekk; vi kan fylle hvert glass, tømme hvert glass og helle hvert glass i et annet glass:

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
}

Nå kan vi lage en løse -metoden, som tar en ønsket mengde og returnerer den korteste listen over trekk som definerer en løsning.

Vi akkumulerer alle uutforskede tilstander i a MutableList som i begynnelsen bare inneholder den opprinnelige tilstanden.

Så begynner vi å itere , ved hvert trinn vi:

  • Popp hodet fra grensen. Hvis denne tilstanden er en løsning, er vi ferdige. Hvis staten allerede er utforsket, hopper vi over den.
  • Legg denne tilstanden til settet med utforskede (dette forhindrer enhver form for sykling).
  • Beregn alle mulige naboland ved å bruke alle mulige trekk, og vi legger dem til slutten av grensen.

Dette er garantert fullført, enten finner vi en løsning eller så utforsker vi hele søkeområdet.

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
}

Den funksjonelle implementeringen

La oss gå til funksjonsimplementeringen. Den generelle strukturen vil være lik, og vi begynner med å endre 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
}

For den funksjonelle løse skal vi hente inspirasjon fra Martin Odersky .

Vi definerer en rekursiv funksjon nextPaths som gitt en liste over stier med lengde n returnerer en sekvens med lister som inneholder stiene med lengde n+1.

Dette gjøres ved kaller utvidelsesmetoden på hver av inngangsbanene med alle mulige trekk (filtrering av utforskede tilstander).

På denne måten er vi garantert at korteste stier behandles først, noe som fører til en ny implementering av åndedragets første søk Siden Kotlin-sekvenser er lat evaluert , vet vi at lengre stier bare beregnes hvis korteste stier a er ikke løsninger 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) }
}

Konklusjoner

La oss prøve å markere takeaways for dette korte innlegget:

  • Begge implementeringene arbeidet med de gitte testtilfellene
  • Prosedyrenes implementering var 2-3 ganger raskere på samme input
  • Den funksjonelle implementeringen var mer kortfattet og uten tvil renere
  • Den funksjonelle implementeringen kan enkelt optimaliseres for å dra nytte av flere kjerner

Det er ofte ikke lett å finne ut hvilken som er den beste tilnærmingen, og funksjonell programmering er ikke sølv koding av koding.

Den gode nyheten er at du ikke trenger en sølvkule (med mindre du trenger å beseire en eldgammel, shapeshifting, klovnende skapning): vi må luksus for å bruke en multi-paradigme programmering språk, slik at du enkelt kan velge riktig verktøy for jobben.

Legg igjen en kommentar

Din e-postadresse vil ikke bli publisert. Obligatoriske felt er merket med *