Vandhældning – Procedurel og funktionel Kotlin

(Filippo Scognamiglio) ( 20. sep 2019)

Lad os prøve noget andet! I dette indlæg skal vi løse det berømte “vandhældning” -problem ved hjælp af Kotlin to gange: den proceduremæssige måde og den funktionelle måde.

Problemet

I standardpuslespillet” vandhældning “får du * n * briller med forskellige kendte kapaciteter og du bliver bedt om at finde en liste over handlinger, der fører til en ønsket mængde i et eller flere briller. Du må kun udføre tre slags handlinger:

  • Fyld et glas fuldstændigt
  • Tøm et glas fuldstændigt
  • Hæld et glas i et andet indtil den første er tom eller den anden er fuld

(Jeg ved hvad du tænker … Du kan ikke snyde ved at fylde ”et halvt” glas … Jeg kan se dig).

Domænet

Lad os starte med at karakterisere domænet. Vi repræsenterer en tilstand med følgende dataklasser:

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

Derefter definerer vi et træk ved hjælp af en abstrakt klasse med en metode, der omdanner den aktuelle tilstand til den modificerede:

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

Den forseglede klasse har de tre mulige implementeringer:

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 har også brug for en datastruktur til at repræsentere en liste over bevægelser, der går til 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 klasser er uforanderlige af design, og vi udnytter dette i de følgende implementeringer.

Den proceduremæssige implementering

Dette problem kan repræsenteres som en graf, hvor hver tilstand er en knude, og hvert træk er en kantforbindelse to nabolande. Vi skal finde løsningen ved at udforske den ved hjælp af en Breath First Search-algoritme.

Lad os starte med at generere alle mulige træk; vi kan fylde hvert glas, tømme hvert glas og hælde hvert glas i et andet glas:

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 oprette en løse metode, der tager den ønskede mængde og returnerer den korteste liste over bevægelser, der definerer en løsning.

Vi akkumulerer alle de uudforskede tilstande i a MutableList som i starten kun indeholder den oprindelige tilstand.

Så begynder vi iterering , ved hvert trin:

  • Pop hovedet fra grænsen. Hvis denne tilstand er en løsning, er vi færdige. Hvis staten allerede er udforsket, springer vi den over.
  • Føj denne tilstand til det sæt udforskede (dette forhindrer enhver form for cykling).
  • Beregn alle mulige nabostater ved at anvende alle mulige bevægelser, og vi tilføjer dem til slutningen af ​​grænsen.

Dette er garanteret færdig, enten finder vi en løsning, eller så udforsker vi hele søgerummet.

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 funktionelle implementering

Lad os gå til funktionsimplementeringen. Den overordnede struktur vil være ens, og vi starter med at ændre 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 funktionelle løse skal vi hente inspiration fra Martin Odersky .

Vi definerer en rekursiv funktion nextPaths at givet en liste over stier med længde n returnerer en sekvens af lister, der indeholder stier med længde n+1.

Dette gøres ved kalder udvidelsesmetoden på hver af inputstierne med alle mulige bevægelser (filtrering af udforskede tilstande).

På denne måde er vi garanteret, at korteste stier behandles først, hvilket fører til en anden implementering af åndedragets første søgning Da Kotlin-sekvenser er dovent evalueret , ved vi, at længere stier kun beregnes, hvis de 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) }
}

Konklusioner

Lad os prøve at fremhæve takeaways for dette korte indlæg:

  • Begge implementeringer arbejdede på de givne testsager
  • Den proceduremæssige implementering var 2-3 gange hurtigere på samme input
  • Den funktionelle implementering var mere kortfattet og uden tvivl renere
  • Den funktionelle implementering kunne let optimeres for at drage fordel af flere kerner

Det er ofte ikke let at finde ud af, hvad der er den bedste tilgang, og funktionel programmering er ikke sølv kodningskugle.

Den gode nyhed er, at du ikke har brug for en sølvkugle (medmindre du har brug for at besejre en gammel, shapeshifting, klovn udseende skabning): vi er nødt til luksus for at bruge en multi-paradigme programmering sprog, så du nemt kan vælge det rigtige værktøj til jobbet.

Skriv et svar

Din e-mailadresse vil ikke blive publiceret. Krævede felter er markeret med *