Water Pouring – Procedural and Functional Kotlin

Pubblicato il

(Filippo Scognamiglio) ( 20 settembre 2019)

Proviamo qualcosa di diverso! In questo post risolveremo il famoso problema del “versamento dellacqua” utilizzando Kotlin due volte: il modo procedurale e il modo funzionale.

Il problema

Nel puzzle standard di” versamento dellacqua “ti vengono dati * n * bicchieri con diverse capacità note e ti viene chiesto di trovare un elenco di azioni che portano a una quantità desiderata in uno o più bicchieri. È possibile eseguire solo tre tipi di azioni:

  • Riempire completamente un bicchiere
  • Svuotare completamente un bicchiere
  • Versare un bicchiere in un altro finché il primo è vuoto o il secondo è pieno

(So cosa stai pensando… Non puoi imbrogliare riempiendo “mezzo” un bicchiere… ti vedo).

Il dominio

Iniziamo caratterizzando il dominio. Rappresenteremo uno stato con le seguenti classi di dati:

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

Successivamente definiremo una mossa utilizzando una classe astratta con un metodo che trasforma lo stato corrente in quella modificata:

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

La classe sealed avrà le tre possibili implementazioni:

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 }

Avremo anche bisogno di una struttura dati per rappresentare un elenco di mosse che portino allo stato finale:

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

Tutte queste classi sono immutabili per progettazione, e lo sfrutteremo nelle seguenti implementazioni.

Limplementazione procedurale

Questo problema può essere rappresentato come un grafico, dove ogni stato è un nodo e ogni movimento è un bordo che collega due stati vicini. Troveremo la soluzione esplorandola utilizzando un algoritmo di Breath First Search.

Iniziamo generando ogni possibile mossa; possiamo riempire ogni bicchiere, svuotare ogni bicchiere e versare ogni bicchiere in uno diverso:

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
}

Ora possiamo creare un risolvere metodo, che prende una quantità desiderata e restituisce lelenco più breve di mosse che definiscono una soluzione.

Accumuliamo tutti gli stati inesplorati in un MutableList che, allinizio, contiene solo lo stato iniziale.

Quindi iniziamo literazione , ad ogni passaggio:

  • Fai scoppiare la testa dalla frontiera. Se questo stato è una soluzione, abbiamo finito. Se lo stato è già esplorato, lo saltiamo.
  • Aggiungi questo stato allinsieme di quelli esplorati (questo impedisce qualsiasi tipo di ciclo).
  • Calcola tutti i possibili stati vicini applicando ogni possibile mossa e le aggiungiamo alla fine del confine.

Questo è garantito per completare, o troviamo una soluzione o esploriamo lo spazio di ricerca completo.

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
}

Limplementazione funzionale

Passiamo allimplementazione della funzione. La struttura complessiva sarà simile e inizieremo modificando il metodo 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
}

Per la risolvere ci ispireremo a Martin Odersky .

Definiamo una funzione ricorsiva nextPaths che dato un elenco di percorsi con lunghezza n restituisce una sequenza di elenchi contenenti i percorsi di lunghezza n+1.

Questo viene fatto da chiamando il metodo di estensione su ciascuno dei percorsi di input con tutte le possibili mosse (filtrando gli stati esplorati).

In questo modo ci viene garantito che i percorsi più brevi vengono elaborati per primi, portando a unaltra implementazione della prima ricerca del respiro algoritmo.

Inoltre, poiché le sequenze di Kotlin sono pigramente valutate , sappiamo che i percorsi più lunghi vengono calcolati solo se i percorsi più brevi sono non sono soluzioni al problema.

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

Conclusioni

Proviamo a evidenziare le conclusioni di questo breve post:

  • Entrambe le implementazioni hanno funzionato sui casi di test indicati
  • Limplementazione procedurale è stata 2-3 volte più veloce con lo stesso input
  • Limplementazione funzionale era più concisa e discutibilmente più pulita
  • Limplementazione funzionale potrebbe essere facilmente ottimizzata per sfruttare più core

Spesso non è facile scoprire quale sia lapproccio migliore e la programmazione funzionale non è largento un proiettile di programmazione.

La buona notizia è che non hai bisogno di un proiettile dargento (a meno che tu non abbia bisogno di sconfiggere una creatura antica, mutaforma, dallaspetto clown): dobbiamo permetterci di usare una programmazione multi-paradigma lingua, in modo da poter scegliere facilmente lo strumento giusto per il lavoro.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *