Coulage deau – Kotlin procédural et fonctionnel

(Filippo Scognamiglio) ( 20 sept. 2019)

Essayons quelque chose de différent! Dans cet article, nous allons résoudre deux fois le fameux problème de «coulée deau» en utilisant Kotlin: la manière procédurale et la manière fonctionnelle.

Le problème

Dans le casse-tête standard » verser de leau « , vous recevez * n * verres avec différentes capacités connues et il vous est demandé de trouver une liste dactions menant à une quantité souhaitée dans un ou plusieurs verres. Vous nêtes autorisé à effectuer que trois types dactions:

  • Remplir complètement un verre
  • Vider complètement un verre
  • Verser un verre dans un autre jusquà le premier est vide ou le second est plein

(je sais ce que vous pensez… Vous ne pouvez pas tricher en remplissant «la moitié» dun verre… je peux vous voir).

Le domaine

Commençons par caractériser le domaine. Nous allons représenter un état avec les classes de données suivantes:

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

Ensuite, nous définissons un déplacement en utilisant une classe abstraite avec une méthode qui transforme létat actuel en celle modifiée:

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

La classe scellée aura les trois implémentations possibles:

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 }

Nous aurons également besoin dune structure de données pour représenter une liste de coups qui passent à un état 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)
}
}

Toutes ces classes sont immuables par conception, et nous lexploiterons dans les implémentations suivantes.

Limplémentation procédurale

Ce problème peut être représenté sous forme de graphe, où chaque état est un nœud et chaque mouvement est une arête reliant deux états voisins. Nous allons trouver la solution en lexplorant en utilisant un algorithme de Breath First Search.

Commençons par générer tous les mouvements possibles; nous pouvons remplir chaque verre, vider chaque verre et verser chaque verre dans un autre:

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
}

Nous pouvons maintenant créer un résoudre méthode, qui prend une quantité désirée et renvoie la liste la plus courte de mouvements qui définissent une solution.

Nous accumulons tous les états inexplorés dans une MutableList qui, au début, ne contient que létat initial.

Ensuite, nous commençons à itérer , à chaque étape, nous:

  • Sautons la tête de la frontière. Si cet état est une solution, nous avons terminé. Si létat est déjà exploré, nous lignorons.
  • Ajoutez cet état à lensemble des états explorés (cela empêche tout type de cycle).
  • Calculez tous les états voisins possibles en appliquant tous les mouvements possibles, et nous les ajoutons à la fin de la frontière.

Ceci est garanti, soit nous trouvons une solution, soit nous explorons lespace de recherche 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
}

Limplémentation fonctionnelle

Passons à limplémentation de la fonction. La structure générale sera similaire et nous commencerons par changer la méthode 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
}

Pour la résoudre fonctionnel, nous allons nous inspirer de Martin Odersky .

Nous définissons une fonction récursive nextPaths qui donné une liste de chemins de longueur n retourne une séquence de listes contenant les chemins de longueur n+1.

Ceci est fait par appelant la méthode extend sur chacun des chemins dentrée avec tous les mouvements possibles (filtrage des états explorés).

De cette façon, nous sommes assurés que les chemins les plus courts sont traités en premier, conduisant à une autre implémentation de la recherche de souffle dabord

De plus, puisque les séquences de Kotlin sont évaluées paresseusement , nous savons que les chemins plus longs ne sont calculés que si les chemins les plus courts sont ne sont pas des solutions au problème.

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

Conclusions

Essayons de mettre en évidence les points à retenir de ce court article:

  • Les deux implémentations ont fonctionné sur les cas de test donnés
  • Limplémentation procédurale était 2 à 3 fois plus rapide sur la même entrée
  • Limplémentation fonctionnelle était plus concise et sans doute plus propre
  • Limplémentation fonctionnelle pourrait être facilement optimisée pour tirer parti de plusieurs cœurs

Il nest souvent pas facile de trouver quelle est la meilleure approche, et la programmation fonctionnelle nest pas largent puce de codage.

La bonne nouvelle est que vous n’avez pas besoin d’une solution miracle (à moins que vous n’ayez besoin de vaincre une créature ancienne, métamorphosée, à l’aspect clown): nous avons le luxe d’utiliser une programmation multi-paradigme langue, afin que vous puissiez facilement choisir le bon outil pour le travail.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *