Skip to content

Commit f155b17

Browse files
committed
Rework pathfinding util.
The old one had bugs and an awkard API.
1 parent 842211a commit f155b17

File tree

6 files changed

+251
-107
lines changed

6 files changed

+251
-107
lines changed

solutions/aockt/util/Pathfinding.kt

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package aockt.util
2+
3+
import java.util.PriorityQueue
4+
5+
object Pathfinding {
6+
7+
/**
8+
* Performs a search.
9+
* If a [heuristic] is given, it is A*, otherwise, Dijkstra's algorithm.
10+
*
11+
* @param start The node or state to begin the search from.
12+
* @param neighbours A function that returns all possible transitions from a node and their associated cost.
13+
* The cost _must_ be a non-negative value.
14+
* @param goalFunction A predicate that determines whether a state is the search destination.
15+
* Search stops upon reaching the first node that evaluates to `true`.
16+
* @param heuristic A function that estimates the lower bound cost of reaching a destination from a given node.
17+
* Must never overestimate, otherwise the search result might not be the most cost-effective.
18+
* @param onVisit An optional callback invoked on each node visit, useful for debugging.
19+
* @param maximumCost An optional upper bound, prevents any transitions that would exceed this value.
20+
* @param trackPath If `true`, keeps track of intermediary nodes to be able to construct a search path.
21+
* If `false` _(the default)_, only the costs to reach the nodes are computed.
22+
*
23+
* @return The search result, or `null` if a suitable destination couldn't be reached.
24+
*/
25+
fun <T : Any> search(
26+
start: T,
27+
neighbours: (T) -> Iterable<Pair<T, Int>>,
28+
goalFunction: (T) -> Boolean,
29+
heuristic: (T) -> Int = { 0 },
30+
onVisit: (T) -> Unit = {},
31+
maximumCost: Int = Int.MAX_VALUE,
32+
trackPath: Boolean = false,
33+
): SearchResult<T>? {
34+
require(maximumCost > 0) { "Maximum cost must be positive." }
35+
36+
val previous = mutableMapOf<T, T>()
37+
val distance = mutableMapOf(start to 0)
38+
val visited = mutableSetOf<Pair<T, Int>>()
39+
40+
@Suppress("UNUSED_DESTRUCTURED_PARAMETER_ENTRY")
41+
val queue = PriorityQueue(compareBy<Triple<T, Int, Int>> { (node, costSoFar, priority) -> priority })
42+
queue.add(Triple(start, 0, 0))
43+
44+
if (trackPath) previous[start] = start
45+
46+
while (queue.isNotEmpty()) {
47+
val (node, costSoFar, _) = queue.poll()
48+
if (!visited.add(node to costSoFar)) continue
49+
onVisit(node)
50+
if (goalFunction(node)) return SearchResult(start, node, distance, previous)
51+
52+
for ((nextNode, nextCost) in neighbours(node)) {
53+
check(nextCost >= 0) { "Transition cost between nodes cannot be negative." }
54+
if (maximumCost - nextCost < costSoFar) continue
55+
56+
val totalCost = costSoFar + nextCost
57+
58+
if (totalCost > (distance[nextNode] ?: Int.MAX_VALUE)) continue
59+
60+
distance[nextNode] = totalCost
61+
if (trackPath) previous[nextNode] = node
62+
63+
val heuristicValue = heuristic(node)
64+
check(heuristicValue >= 0) { "Heuristic value must be positive." }
65+
queue.add(Triple(nextNode, totalCost, totalCost + heuristicValue))
66+
}
67+
}
68+
69+
return null
70+
}
71+
72+
/**
73+
* The result of a [Pathfinding] search.
74+
*
75+
* @property start The node the search started from.
76+
* @property end The destination node, or the last visited node if an exhaustive flood search was requested.
77+
* @property cost The cost from [start] to [end], or the maximum cost if an exhaustive flood search was requested.
78+
* @property path The path from [start] to [end], each node associated with the running cost.
79+
* @property distance The cost from the [start] to all the visited intermediary nodes.
80+
* @property previous The previous node in the path of all the visited intermediary nodes.
81+
* Following it recursively will lead back to the [start] node.
82+
*/
83+
class SearchResult<out T> internal constructor(
84+
val start: T,
85+
val end: T,
86+
private val distance: Map<T, Int>,
87+
private val previous: Map<T, T>,
88+
) {
89+
val cost: Int get() = distance.getValue(end)
90+
91+
val path: List<Pair<T, Int>> by lazy {
92+
check(previous.isNotEmpty()) { "Cannot generate path as search was performed with `trackPath = false`." }
93+
buildList {
94+
var current = end
95+
while (true) {
96+
add(current to distance.getValue(current))
97+
val previous = previous.getValue(current)
98+
if (previous == current) break
99+
current = previous
100+
}
101+
}.asReversed()
102+
}
103+
}
104+
}

solutions/aockt/util/Search.kt

+6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package aockt.util
33
import java.util.PriorityQueue
44

55
/** A type implementing this interface can represent a network of nodes usable for search algorithms. */
6+
@Deprecated("Prefer aockt.util.Pathfinding")
67
fun interface Graph<T : Any> {
78

89
/** Returns all the possible nodes to visit starting from this [node] associated with the cost of travel. */
@@ -18,6 +19,7 @@ fun interface Graph<T : Any> {
1819
* @property searchTree Extra information about the search, associating visited nodes to their previous node in the
1920
* path back to the origin, as well as their total cost from the origin.
2021
*/
22+
@Deprecated("Prefer aockt.util.Pathfinding")
2123
data class SearchResult<T : Any>(
2224
val startedFrom: T,
2325
val destination: T?,
@@ -30,6 +32,7 @@ data class SearchResult<T : Any>(
3032
* @property path The list of all nodes in this path, including the origin and destination nodes.
3133
* @property cost The total cost of this path.
3234
*/
35+
@Deprecated("Prefer aockt.util.Pathfinding")
3336
data class SearchPath<T : Any>(
3437
val path: List<T>,
3538
val cost: Int,
@@ -40,6 +43,7 @@ data class SearchPath<T : Any>(
4043
* Returns the shortest known path towards that [node], or `null` if the node is unreachable from the origin, or if the
4144
* node has not been visited by the search algorithm before reaching a destination.
4245
*/
46+
@Deprecated("Prefer aockt.util.Pathfinding")
4347
fun <T : Any> SearchResult<T>.pathTo(node: T): SearchPath<T>? {
4448
val cost = searchTree[node]?.second ?: return null
4549
val path = buildList {
@@ -59,6 +63,7 @@ fun <T : Any> SearchResult<T>.pathTo(node: T): SearchPath<T>? {
5963
* Returns the shortest known path towards the node that fulfilled the destination criteria.
6064
* If multiple such nodes exist, the one with the lowest cost is chosen.
6165
*/
66+
@Deprecated("Prefer aockt.util.Pathfinding")
6267
fun <T : Any> SearchResult<T>.path(): SearchPath<T>? = when(destination) {
6368
null -> null
6469
else -> pathTo(destination)
@@ -77,6 +82,7 @@ fun <T : Any> SearchResult<T>.path(): SearchPath<T>? = when(destination) {
7782
* The search will stop upon the first such node to be found.
7883
* If no function is defined, the entire graph will be searched.
7984
*/
85+
@Deprecated("Prefer aockt.util.Pathfinding")
8086
fun <T : Any> Graph<T>.search(
8187
start: T,
8288
maximumCost: Int = Int.MAX_VALUE,

solutions/aockt/y2021/Y2021D23.kt

+10-9
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
package aockt.y2021
22

3-
import aockt.util.Graph
43
import aockt.util.parse
5-
import aockt.util.search
4+
import aockt.util.Pathfinding
65
import io.github.jadarma.aockt.core.Solution
76
import kotlin.math.abs
87

@@ -143,13 +142,15 @@ object Y2021D23 : Solution {
143142
}
144143

145144
/** Common solution for both parts. */
146-
private fun solve(input: String, deep: Boolean): Int {
147-
val start = parseInput(input, deep)
148-
149-
return Graph<State> { it.neighbors() }
150-
.search(start) { it.isFinal() }
151-
.cost
152-
}
145+
private fun solve(input: String, deep: Boolean): Int =
146+
Pathfinding
147+
.search(
148+
start = parseInput(input, deep),
149+
neighbours = { it.neighbors() },
150+
goalFunction = { it.isFinal() },
151+
)
152+
?.cost
153+
?: -1
153154

154155
override fun partOne(input: String): Int = solve(input, deep = false)
155156
override fun partTwo(input: String): Int = solve(input, deep = true)

tests/aockt/util/PathfindingTest.kt

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package aockt.util
2+
3+
import io.kotest.assertions.throwables.shouldThrow
4+
import io.kotest.core.spec.style.FunSpec
5+
import io.kotest.matchers.collections.shouldContain
6+
import io.kotest.matchers.collections.shouldNotContain
7+
import io.kotest.matchers.nulls.shouldBeNull
8+
import io.kotest.matchers.nulls.shouldNotBeNull
9+
import io.kotest.matchers.shouldBe
10+
import kotlin.math.abs
11+
12+
class PathfindingTest : FunSpec({
13+
14+
/**
15+
* A test graph that encodes the following maze, each move has a constant cost of 1.
16+
* ```text
17+
* #####################
18+
* #(A)# B C D # E #
19+
* # # ##### #####
20+
* # F # G # H I # J #
21+
* # # ##### # #
22+
* # K # L # M N O #
23+
* # # ######### #
24+
* # P Q R # S T #
25+
* # # ######### #
26+
* # U # V W X (Y)#
27+
* #####################
28+
* ```
29+
*/
30+
val edges = mapOf(
31+
//@formatter:off
32+
'A' to "F", 'B' to "CG", 'C' to "BD", 'D' to "CI", 'E' to "",
33+
'F' to "AK", 'G' to "BL", 'H' to "I", 'I' to "DHN", 'J' to "O",
34+
'K' to "FP", 'L' to "GQ", 'M' to "N", 'N' to "IMO", 'O' to "JNT",
35+
'P' to "KQU", 'Q' to "LPRV", 'R' to "Q", 'S' to "T", 'T' to "OSY",
36+
'U' to "P", 'V' to "QW", 'W' to "VX", 'X' to "WY", 'Y' to "TX",
37+
//@formatter:on
38+
)
39+
val neighbours: (Char) -> Iterable<Pair<Char, Int>> = { node: Char -> edges[node].orEmpty().map { it to 1 } }
40+
41+
context("A maze search") {
42+
test("finds the shortest path") {
43+
val visited = mutableSetOf<Char>()
44+
val result = Pathfinding.search(
45+
start = 'A',
46+
neighbours = neighbours,
47+
onVisit = { visited += it },
48+
goalFunction = { it == 'Y' },
49+
trackPath = true,
50+
)
51+
52+
with(result) {
53+
shouldNotBeNull()
54+
start shouldBe 'A'
55+
end shouldBe 'Y'
56+
cost shouldBe 8
57+
path.joinToString("") { it.first.toString() } shouldBe "AFKPQVWXY"
58+
}
59+
60+
// Since no heuristic is used, the G node should have been visited.
61+
visited shouldContain 'G'
62+
}
63+
64+
test("does not compute path unless specified") {
65+
shouldThrow<IllegalStateException> {
66+
Pathfinding
67+
.search(
68+
start = 'A',
69+
neighbours = neighbours,
70+
goalFunction = { it == 'Y' },
71+
trackPath = false,
72+
)
73+
.shouldNotBeNull()
74+
.path
75+
}
76+
}
77+
78+
test("can tell when there is no path") {
79+
val result = Pathfinding.search(
80+
start = 'A',
81+
neighbours = neighbours,
82+
goalFunction = { it == 'E' },
83+
)
84+
result.shouldBeNull()
85+
}
86+
87+
test("respects maximum cost") {
88+
val result = Pathfinding.search(
89+
start = 'A',
90+
neighbours = neighbours,
91+
maximumCost = 4,
92+
goalFunction = { it == 'Y' },
93+
)
94+
result.shouldBeNull()
95+
}
96+
97+
test("does not visit inefficient nodes when using a good heuristic") {
98+
// Manhattan distance between points.
99+
val heuristic = { node: Char ->
100+
fun coordsOf(char: Char): Pair<Int, Int> {
101+
require(char in 'A'..'Z')
102+
val value = char - 'A'
103+
return value / 5 to value % 5
104+
}
105+
106+
val aa = coordsOf(node)
107+
val bb = coordsOf('Y')
108+
abs(aa.first - bb.first) + abs(aa.second - bb.second)
109+
}
110+
111+
val visited = mutableSetOf<Char>()
112+
val result = Pathfinding.search(
113+
start = 'A',
114+
neighbours = neighbours,
115+
onVisit = { visited += it },
116+
goalFunction = { it == 'Y' },
117+
heuristic = heuristic,
118+
)
119+
120+
with(result) {
121+
shouldNotBeNull()
122+
start shouldBe 'A'
123+
end shouldBe 'Y'
124+
}
125+
// The G node should be optimised out because of the heuristic.
126+
visited shouldNotContain 'G'
127+
}
128+
}
129+
})

0 commit comments

Comments
 (0)