Turnarea apei – Kotlin procedural și funcțional

(Filippo Scognamiglio) ( 20 sept. 2019)

Să încercăm ceva diferit! În acest post vom rezolva faimoasa problemă „turnarea apei” folosind Kotlin de două ori: modul procedural și modul funcțional.

Problema

În puzzle-ul standard„ turnarea apei ”vi se oferă * n * pahare cu diferite capacități cunoscute și vi se cere să găsiți o listă de acțiuni care să ducă la o cantitate dorită într-unul sau mai multe pahare. Aveți voie să efectuați doar trei tipuri de acțiuni:

  • Umpleți complet un pahar
  • Goliți complet un pahar
  • Turnarea unui pahar în altul până primul este gol sau al doilea este plin

(Știu la ce te gândești … Nu poți trișa umplând „jumătate” de pahar … te văd).

Domeniul

Să începem prin caracterizarea domeniului. Vom reprezenta o stare cu următoarele clase de date:

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

Apoi definim o mutare folosind o clasă abstractă cu o metodă care transformă starea curentă în cea modificată:

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

Clasa sigilată va avea cele trei implementări posibile:

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 }

Vom avea nevoie, de asemenea, de o structură de date pentru a reprezenta o listă de mișcări care duce la o stare finală:

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

Toate aceste clase sunt imuabile prin design, și vom exploata acest lucru în următoarele implementări.

Implementarea procedurală

Această problemă poate fi reprezentată ca un grafic, în care fiecare stare este un nod și fiecare mișcare este o margine de conectare două state vecine. Vom găsi soluția explorând-o folosind un algoritm Breath First Search.

Să începem generând fiecare mișcare posibilă; putem să umplem fiecare pahar, să golim fiecare pahar și să turnăm fiecare pahar într-unul diferit:

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
}

Acum putem crea un rezolva metoda , care ia cantitatea dorită și returnează cea mai scurtă listă de mutări care definesc o soluție.

Acumulăm toate stările neexplorate în a MutableList care, la început, conține doar starea inițială.

Apoi începem să iterăm , la fiecare pas noi:

  • Scoatem capul de la frontieră. Dacă această stare este o soluție, am terminat. Dacă starea este deja explorată, o omitem.
  • Adăugați această stare la setul celor explorate (acest lucru împiedică orice fel de ciclism).
  • Calculați toate stările vecine posibile aplicând fiecare mișcare posibilă și le adăugăm la capătul frontierei.

Acest lucru este garantat pentru finalizare, fie că găsim o soluție, fie că explorăm spațiul de căutare complet.

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
}

Implementarea funcțională

Să trecem la implementarea funcției. Structura generală va fi similară și vom începe prin schimbarea metodei 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
}

Pentru funcția rezolvați ne vom inspira din Martin Odersky .

Definim o funcție recursivă nextPaths care a dat o listă de căi cu lungimea n returnează o secvență de liste care conțin căile de lungime n+1.

Acest lucru este realizat de apelarea metodei de extindere pe fiecare dintre căile de intrare cu toate mișcările posibile (filtrarea stărilor explorate).

În acest fel ni se garantează că cele mai scurte căi sunt procesate mai întâi, ducând la o altă implementare a respirației prima căutare algoritm.

De asemenea, Deoarece secvențele Kotlin sunt leneșe evaluate , știm că căile mai lungi sunt calculate numai dacă cele mai scurte căi a Nu sunt soluții la problemă.

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

Concluzii

Să încercăm să evidențiem rezultatele acestui post scurt:

  • Ambele implementări au funcționat la cazurile de test date
  • Implementarea procedurală a fost de 2-3 ori mai rapidă pe aceeași intrare
  • Implementarea funcțională a fost mai concisă și probabil mai curată
  • Implementarea funcțională ar putea fi ușor optimizată pentru a profita de mai multe nuclee

De multe ori nu este ușor să aflăm care este cea mai bună abordare, iar programarea funcțională nu este argintul glonț de codificare.

Vestea bună este că nu aveți nevoie de un glonț de argint (cu excepția cazului în care trebuie să învingeți o creatură veche, schimbătoare de forme, cu aspect de clovn): trebuie să luăm lux pentru a folosi o programare multi-paradigmă limba, astfel încât să puteți alege cu ușurință instrumentul potrivit pentru job.

Lasă un răspuns

Adresa ta de email nu va fi publicată. Câmpurile obligatorii sunt marcate cu *