Derramamento de água – Kotlin processual e funcional

(Filippo Scognamiglio) ( 20 de setembro de 2019)

Vamos tentar algo diferente! Nesta postagem, vamos resolver o famoso problema de “despejar água” usando Kotlin duas vezes: a forma procedimental e a forma funcional.

O problema

No quebra-cabeça padrão“ servir água ”, você recebe * n * copos com diferentes capacidades conhecidas e você é solicitado a encontrar uma lista de ações que levam a uma quantidade desejada em um ou mais copos. Você só pode realizar três tipos de ações:

  • Encher um copo completamente
  • Esvaziar um copo completamente
  • Derramar um copo no outro até o primeiro está vazio ou o segundo está cheio

(Eu sei o que você está pensando… Você não pode trapacear enchendo “meio” um copo… Estou vendo).

O domínio

Vamos começar caracterizando o domínio. Vamos representar um estado com as seguintes classes de dados:

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

Em seguida, definimos um movimento usando uma classe abstrata com um método que transforma o estado atual em o modificado:

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

A classe selada terá as três implementações possíveis:

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 }

Também precisaremos de uma estrutura de dados para representar uma lista de movimentos que leva a um estado 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)
}
}

Todas essas classes são imutáveis ​​por design, e vamos explorar isso nas seguintes implementações.

A implementação procedural

Este problema pode ser representado como um gráfico, onde cada estado é um nó e cada movimento é uma conexão de borda dois estados vizinhos. Vamos encontrar a solução explorando-a usando um algoritmo Breath First Search.

Vamos começar gerando todos os movimentos possíveis; podemos encher cada copo, esvaziar cada copo e despejar cada copo em um diferente:

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
}

Agora podemos criar um solve método, que pega uma quantidade desejada e retorna a lista mais curta de movimentos que definem uma solução.

Nós acumulamos todos os estados inexplorados em uma MutableList que, no início, contém apenas o estado inicial.

Então, começamos a iteração , em cada etapa nós:

  • Destacamos a cabeça da fronteira. Se esse estado for uma solução, estamos prontos. Se o estado já foi explorado, nós o pulamos.
  • Adicione este estado ao conjunto dos explorados (isso evita qualquer tipo de ciclo).
  • Calcule todos os possíveis estados vizinhos aplicando cada movimento possível e nós os adicionamos ao fim da fronteira.

Isso é garantido para completar, ou encontramos uma solução ou exploramos todo o espaço de busca.

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
}

A implementação funcional

Vamos passar para a implementação da função. A estrutura geral será semelhante e começaremos alterando o método 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
}

Para o funcional , vamos nos inspirar em Martin Odersky .

Definimos uma função recursiva nextPaths que dada uma lista de caminhos com comprimento n retorna uma sequência de listas contendo os caminhos de comprimento n+1.

Isso é feito por chamando o método extend em cada um dos caminhos de entrada com todos os movimentos possíveis (filtrando os estados explorados).

Desta forma, temos a garantia de que os caminhos mais curtos são processados ​​primeiro, levando a outra implementação da primeira pesquisa de respiração algoritmo.

Além disso, como as sequências de Kotlin são avaliadas lentamente , sabemos que caminhos mais longos são calculados apenas se os caminhos mais curtos forem não são soluções para o 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) }
}

Conclusões

Vamos tentar destacar os resultados desta breve postagem:

  • Ambas as implementações funcionaram nos casos de teste fornecidos
  • A implementação procedural foi 2–3 vezes mais rápida na mesma entrada
  • A implementação funcional foi mais concisa e sem dúvida mais limpa
  • A implementação funcional pode ser facilmente otimizada para tirar proveito de vários núcleos

Muitas vezes não é fácil descobrir qual é a melhor abordagem, e a programação funcional não é a prata bala de codificação.

A boa notícia é que você não precisa de uma bala de prata (a menos que precise derrotar uma criatura de aparência de palhaço, antiga e mutante): temos que nos dar ao luxo de usar uma programação multiparadigma idioma, para que você possa escolher facilmente a ferramenta certa para o trabalho.

Deixe uma resposta

O seu endereço de email não será publicado. Campos obrigatórios marcados com *