Skip to content

Commit e65f3f4

Browse files
committed
SimpleCharGrid: A simple Char-based grid with shortestPaths
* shortestPaths() finds all shortest paths from start to goal * Grid cells are simply identified by their Coordinates * ShortestPaths is extended to find all possible shortest paths (and not just some of them)
1 parent f926e4e commit e65f3f4

File tree

3 files changed

+84
-18
lines changed

3 files changed

+84
-18
lines changed

src/main/kotlin/de/ronny_h/aoc/extensions/ShortestPaths.kt

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,21 @@ import kotlin.Int.Companion.MAX_VALUE
77
// https://en.wikipedia.org/wiki/A*_search_algorithm
88

99

10-
private fun <N> reconstructPaths(cameFrom: Map<N, Collection<N>>, last: N): List<List<N>> {
11-
if (!cameFrom.contains(last)) {
12-
return listOf(listOf(last))
10+
private fun <N> reconstructPaths(cameFrom: Map<N, Collection<N>>, currentNode: N): List<List<N>> {
11+
if (!cameFrom.contains(currentNode)) {
12+
return listOf(listOf(currentNode))
1313
}
14-
return cameFrom.getValue(last)
14+
return cameFrom.getValue(currentNode)
1515
.flatMap { pred -> reconstructPaths(cameFrom, pred) }
16-
.map { path -> path + last }
17-
.toList()
16+
.map { path -> path + currentNode }
1817
}
1918

2019
private const val LARGE_VALUE = MAX_VALUE / 2
2120

2221
/**
2322
* A modified A* algorithm that finds all shortest paths from `start` to `goal`.
2423
* @param start the start node
25-
* @param isGoal predicate deciding if a node is a goal
24+
* @param isGoal predicate deciding if a node is the goal
2625
* @param neighbors is a function that returns the list of neighbours for a given node.
2726
* @param d is the distance/cost function. d(m,n) provides the distance (or cost) to reach node n from node m.
2827
* @param h is the heuristic function. h(n) estimates the cost to reach goal from node n.
@@ -33,43 +32,45 @@ fun <N> aStarAllPaths(
3332
): List<ShortestPath<N>> {
3433
// For node n, fScore[n] := gScore[n] + h(n). fScore[n] represents our current best guess as to
3534
// how cheap a path could be from start to finish if it goes through n.
36-
val fScore = mutableMapOf<N, Int>().withDefault { _ -> LARGE_VALUE } // map with default value of Infinity
35+
val fScore = mutableMapOf<N, Int>().withDefault { _ -> LARGE_VALUE } // map with default value of "Infinity"
3736

3837
// The set of discovered nodes that may need to be (re-)expanded.
3938
// Initially, only the start node is known.
4039
// This is usually implemented as a min-heap or priority queue rather than a hash-set.
41-
val openSet =
42-
PriorityQueue<N> { a, b -> fScore.getValue(a).compareTo(fScore.getValue(b)) }
40+
val openSet = PriorityQueue<N> { a, b -> fScore.getValue(a).compareTo(fScore.getValue(b)) }
4341
openSet.add(start)
4442

45-
// For node n, cameFrom[n] is the list of nodes immediately preceding it on the cheapest paths from the start
43+
// For node n, cameFrom[n] is the set of nodes immediately preceding it on the cheapest paths from the start
4644
// to n currently known.
4745
val cameFrom = mutableMapOf<N, MutableSet<N>>()
4846

4947
// For node n, gScore[n] is the currently known cost of the cheapest path from start to n.
50-
val gScore = mutableMapOf<N, Int>().withDefault { _ -> LARGE_VALUE } // map with default value of Infinity
48+
val gScore = mutableMapOf<N, Int>().withDefault { _ -> LARGE_VALUE } // map with default value of "Infinity"
5149
gScore[start] = 0
52-
5350
fScore[start] = h(start)
5451

5552
while (openSet.isNotEmpty()) {
5653
// This operation can occur in O(Log(N)) time if openSet is a min-heap or a priority queue
57-
val current = openSet.peek()
54+
val current = openSet.remove()
5855
if (current.isGoal()) {
59-
return reconstructPaths(cameFrom, current).map { x -> ShortestPath(x, gScore.getValue(current)) }
56+
// Search for nodes in openSet with fScore[node] <= gScore[current]
57+
// If the heuristic function is admissible (it never overestimates the actual cost to get to the goal)
58+
// we can be sure to expand all possible paths.
59+
if (openSet.all { n -> fScore.getValue(n) > gScore.getValue(current) }) {
60+
return reconstructPaths(cameFrom, current).map { x -> ShortestPath(x, gScore.getValue(current)) }
61+
}
6062
}
6163

62-
openSet.remove(current)
6364
for (neighbor in neighbors(current)) {
6465
// d(current,neighbor) is the weight of the edge from current to neighbor
65-
// tentative_gScore is the distance from start to the neighbor through current
66+
// tentativeGScore is the distance from start to the neighbor through current
6667
val tentativeGScore = gScore.getValue(current) + d(current, neighbor)
6768
if (tentativeGScore <= gScore.getValue(neighbor)) {
6869
if (tentativeGScore < gScore.getValue(neighbor)) {
6970
// This path to neighbor is better than any previous one. Record it!
7071
cameFrom[neighbor] = mutableSetOf(current)
7172
} else {
72-
// This path to neighbor is equal to the best one. Record it!
73+
// This path to neighbor is equal to the best one. Record it, too!
7374
cameFrom.getOrPut(neighbor) { mutableSetOf() } += current
7475
}
7576
gScore[neighbor] = tentativeGScore
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package de.ronny_h.aoc.extensions
2+
3+
class SimpleCharGrid(input: List<String>, nullElement: Char = '#') : Grid<Char>(input, nullElement) {
4+
override fun Char.toElementType() = this
5+
6+
fun shortestPaths(start: Coordinates, goal: Coordinates): List<ShortestPath<Coordinates>> {
7+
val neighbours: (Coordinates) -> List<Coordinates> = { position ->
8+
Direction
9+
.entries
10+
.map { position + it }
11+
.filter { getAt(it) != nullElement }
12+
}
13+
14+
val d: (Coordinates, Coordinates) -> Int = { a, b ->
15+
check(a taxiDistanceTo b == 1) // pre-condition: a and b a neighbours
16+
1
17+
}
18+
19+
val h: (Coordinates) -> Int = { it taxiDistanceTo goal }
20+
21+
return aStarAllPaths(start, { this == goal }, neighbours, d, h)
22+
}
23+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package de.ronny_h.aoc.extensions
2+
3+
import io.kotest.core.spec.style.StringSpec
4+
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
5+
import io.kotest.matchers.shouldBe
6+
import de.ronny_h.aoc.extensions.Coordinates as C
7+
8+
class SimpleCharGridTest : StringSpec({
9+
10+
"in a grid with a unique shortest path that path is found" {
11+
SimpleCharGrid(listOf("12", "34")).shortestPaths(C(0, 0), C(0, 1)) shouldBe
12+
listOf(ShortestPath(listOf(C(0, 0), C(0, 1)), 1))
13+
}
14+
15+
"in a small grid with multiple shortest paths all paths are found" {
16+
SimpleCharGrid(listOf("12", "34")).shortestPaths(C(0, 0), C(1, 1)) shouldBe
17+
listOf(
18+
ShortestPath(listOf(C(0, 0), C(0, 1), C(1, 1)), 2),
19+
ShortestPath(listOf(C(0, 0), C(1, 0), C(1, 1)), 2),
20+
)
21+
}
22+
23+
"in a slightly larger grid with multiple shortest paths all paths are found" {
24+
SimpleCharGrid(listOf("123", "456", "789")).shortestPaths(C(0, 0), C(2, 2)) shouldContainExactlyInAnyOrder
25+
listOf(
26+
ShortestPath(listOf(C(0, 0), C(0, 1), C(0, 2), C(1, 2), C(2, 2)), 4),
27+
ShortestPath(listOf(C(0, 0), C(0, 1), C(1, 1), C(1, 2), C(2, 2)), 4),
28+
ShortestPath(listOf(C(0, 0), C(0, 1), C(1, 1), C(2, 1), C(2, 2)), 4),
29+
ShortestPath(listOf(C(0, 0), C(1, 0), C(1, 1), C(1, 2), C(2, 2)), 4),
30+
ShortestPath(listOf(C(0, 0), C(1, 0), C(1, 1), C(2, 1), C(2, 2)), 4),
31+
ShortestPath(listOf(C(0, 0), C(1, 0), C(2, 0), C(2, 1), C(2, 2)), 4),
32+
)
33+
}
34+
35+
"in a non-rectangular grid with multiple shortest paths all paths are found" {
36+
SimpleCharGrid(listOf("#12", "345"), '#').shortestPaths(C(0, 1), C(1, 2)) shouldContainExactlyInAnyOrder
37+
listOf(
38+
ShortestPath(listOf(C(0, 1), C(0, 2), C(1, 2)), 2),
39+
ShortestPath(listOf(C(0, 1), C(1, 1), C(1, 2)), 2),
40+
)
41+
}
42+
})

0 commit comments

Comments
 (0)