Skip to content

Commit

Permalink
Merge pull request #18 from TomPlum/master
Browse files Browse the repository at this point in the history
Math additions (Point2D, AdventMap, Direction and Graphing Algorithms)
  • Loading branch information
TomPlum authored Dec 16, 2024
2 parents b886ca4 + 32052b9 commit 7e7ac9d
Show file tree
Hide file tree
Showing 16 changed files with 1,038 additions and 170 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ subprojects {
apply(plugin = "maven-publish")

ext {
set("releaseVersion", "2.5.0")
set("releaseVersion", "2.6.0")
}

repositories {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ data class Node<T>(val value: T, val distance: Int): Comparable<Node<T>> {
fun <N> dijkstraShortestPath(
startingPositions: Collection<N>,
evaluateAdjacency: (currentNode: Node<N>) -> Collection<Node<N>>,
processNode: (currentNode: Node<N>, adjacentNode: Node<N>) -> Node<N>,
terminates: (currentNode: Node<N>) -> Boolean
terminates: (currentNode: Node<N>) -> Boolean,
processNode: (currentNode: Node<N>, adjacentNode: Node<N>) -> Node<N> = { _, adjacentNode -> adjacentNode }
): Int {
// A map of nodes and the shortest distance from the given starting positions to it
val distance = mutableMapOf<N, Int>()
Expand Down Expand Up @@ -83,4 +83,139 @@ fun <N> dijkstraShortestPath(

val message = "Could not find a path from the given starting positions to the node indicated by the terminates predicate."
throw IllegalStateException(message)
}

data class DijkstraAllPaths<N>(
/**
* The shortest distance from the starting
* position to the end position.
*/
val shortestDistance: Int,


/**
* A list of all the nodes from all
* variations of the shortest path.
*/
val shortestPaths: List<Set<N>>
)

/**
* Calculates the shortest distance to all nodes in a weighted-graph from the given [startingPositions]
* and terminates based on the given [terminates] predicate.
*
* All shortest paths (that is paths that all share the same, shortest distance) are tracked.
*
* If you do not need all the shortest paths, but simply any of them for the distance,
* then use [dijkstraShortestPath] instead as it will be more performant.
*
* @param startingPositions A collection of nodes to start from.
* @param evaluateAdjacency A function that is passed an instance of the current node and should return a collection of all adjacent nodes or "neighbours" that should be evaluated as part of the pathfinding.
* @param processNode An optional function that is applied after the adjacency evaluation for each neighbouring node. Can be used to mutate the state of the node before evaluation.
* @param terminates A boolean predicate used to determine when the destination node has been reached. When it evaluates as true, the algorithm is terminated and the shortest path for the current node is returned.
*
* @returns The shortest path from the starting nodes to the node that produces true when passed into the [terminates] predicate.
*/
fun <N> dijkstraAllShortestPaths(
startingPositions: Collection<N>,
evaluateAdjacency: (currentNode: Node<N>) -> Collection<Node<N>>,
terminates: (currentNode: Node<N>) -> Boolean,
processNode: (currentNode: Node<N>, adjacentNode: Node<N>) -> Node<N> = { _, adjacentNode -> adjacentNode }
): DijkstraAllPaths<N> {
// A map of nodes and the shortest distance from the starting positions to it
val distance = mutableMapOf<N, Int>()

// A map to track predecessors for each node (to reconstruct paths)
val predecessors = mutableMapOf<N, MutableSet<N>>()

// Unsettled nodes prioritized by their distance
val next = PriorityQueue<Node<N>>()

startingPositions.forEach { startingPosition ->
next.offer(Node(startingPosition, 0))
distance[startingPosition] = 0
}

var shortestDistance: Int? = null

while (next.isNotEmpty()) {
// Take the next node from the queue
val currentNode = next.poll()

// If we have determined the shortest distance and this node's distance exceeds it, stop processing
if (shortestDistance != null && currentNode.distance > shortestDistance) {
break
}

// If the termination condition is met, record the shortest distance
if (terminates(currentNode)) {
if (shortestDistance == null) {
shortestDistance = currentNode.distance
}
}

// Find all adjacent nodes
evaluateAdjacency(currentNode).forEach { adjacentNode ->
// Perform additional processing on the adjacent node before evaluation
val evaluationNode = processNode(currentNode, adjacentNode)

// The new shortest path to the adjacent node
val updatedDistance = currentNode.distance + evaluationNode.distance

// If the distance of this new path is shorter than or equal to the known shortest distance
if (updatedDistance <= distance.getOrDefault(evaluationNode.value, Int.MAX_VALUE)) {
// Update distance
distance[evaluationNode.value] = updatedDistance

// Update predecessors
predecessors.computeIfAbsent(evaluationNode.value) { mutableSetOf() }.add(currentNode.value)

// Add the adjacent node to the queue
next.add(Node(evaluationNode.value, updatedDistance))
}
}
}

// Now we need to collect all nodes that are part of the shortest paths
val shortestPathNodes = mutableSetOf<N>()

// Helper function to collect all nodes along the shortest paths
fun collectShortestPathNodes(node: N) {
if (shortestPathNodes.contains(node)) {
return // Already visited
}

shortestPathNodes.add(node)

predecessors[node]?.forEach { parent ->
collectShortestPathNodes(parent)
}
}

// Recursive helper to backtrack and collect all paths
fun collectPaths(node: N, currentPath: Set<N>): List<Set<N>> {
val newPath = setOf(node) + currentPath
val currentNodePredecessors = predecessors[node]

return if (currentNodePredecessors.isNullOrEmpty()) {
// No more predecessors, this is the start of a path
listOf(newPath)
} else {
currentNodePredecessors.flatMap { predecessorNode ->
collectPaths(predecessorNode, newPath)
}
}
}

// Collect all nodes for the shortest path, starting from the end nodes
// This step ensures we get all nodes in all shortest paths, not just the first one
val endNodes = distance.filter { terminates(Node(it.key, it.value)) }.keys
endNodes.forEach { collectShortestPathNodes(it) }

val shortestPaths = endNodes.flatMap { node -> collectPaths(node, emptySet()) }

return DijkstraAllPaths(
shortestDistance = shortestDistance ?: 0,
shortestPaths = shortestPaths
)
}
28 changes: 28 additions & 0 deletions library/src/main/kotlin/io/github/tomplum/libs/math/Direction.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ enum class Direction(private val degree: Int) {
LEFT(270),
TOP_LEFT(315);

companion object {
fun fromChar(value: Char) = when (value) {
'^' -> UP
'>' -> RIGHT
'v' -> DOWN
'<' -> LEFT
else -> throw IllegalArgumentException("Invalid Direction String: $value")
}
}

/**
* Rotates the current direction by the given [angle].
* Angles can be negative to rotate anti-clockwise.
Expand All @@ -26,6 +36,24 @@ enum class Direction(private val degree: Int) {
.find { it.degree == normalise(angle) }
?: throw IllegalArgumentException("Invalid Angle $angle")

/**
* Determines if the direction is opposite the [other].
*
* @param other The other direction to compare against.
* @returns true if the [other] direction is opposite this one, else false.
*/
fun isOpposite(other: Direction): Boolean = when {
this == UP -> other == DOWN
this == RIGHT -> other == LEFT
this == DOWN -> other == UP
this == LEFT -> other == RIGHT
this == TOP_RIGHT -> other == BOTTOM_LEFT
this == BOTTOM_RIGHT -> other == TOP_LEFT
this == BOTTOM_LEFT -> other == TOP_RIGHT
this == TOP_LEFT -> other == BOTTOM_RIGHT
else -> false
}

private fun normalise(angle: Int): Int {
var targetDegree = degree + angle

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package io.github.tomplum.libs.math.equation

/**
* Represents a system of two linear equations in the form:
*
* a1 * x + b1 * y = c1
* a2 * x + b2 * y = c2
*
* This class provides a method to solve the system using Cramer's rule.
*
* @param a1 The coefficient of x in the first equation.
* @param b1 The coefficient of y in the first equation.
* @param c1 The constant term in the first equation.
* @param a2 The coefficient of x in the second equation.
* @param b2 The coefficient of y in the second equation.
* @param c2 The constant term in the second equation.
*/
data class LinearEquation(
val a1: Double,
val b1: Double,
val c1: Double,
val a2: Double,
val b2: Double,
val c2: Double
) {
/**
* Solves the system of two linear equations using Cramer's rule.
*
* Cramer's rule states that for a system of two linear equations:
*
* a1 * x + b1 * y = c1
* a2 * x + b2 * y = c2
*
* The solution is given by:
*
* x = (c1 * b2 - c2 * b1) / (a1 * b2 - a2 * b1)
* y = (a1 * c2 - a2 * c1) / (a1 * b2 - a2 * b1)
*
* If the determinant (a1 * b2 - a2 * b1) is 0, the system has no unique solution.
*
* @return A Pair of values (x, y) representing the solution to the system,
* or null if the system has no unique solution.
*/
fun solve(): Pair<Double, Double>? {
val determinant = a1 * b2 - a2 * b1

// If determinant is zero, the system has no unique solution
if (determinant == 0.0) {
return null
}

val determinantX = c1 * b2 - c2 * b1
val determinantY = a1 * c2 - a2 * c1

val x = determinantX / determinant
val y = determinantY / determinant

return x to y
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import io.github.tomplum.libs.math.point.Point
*
* Lots of the days involve the concept of a 'Map' or a 'Maze' which can be represented as a cartesian grid.
* A cartesian-style grid is internally maintained that maps tiles to [Point] coordinates.
* Several sub-type abstract classes exist mapping the different dimensions against the tiles.
* Several subtype abstract classes exist mapping the different dimensions against the tiles.
*
* @param T The type of [MapTile] that will be recorded in the grid.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ abstract class AdventMap2D<T: MapTile<*>>: AdventMap<Point2D, T>() {
return positions.flatMap { pos -> pos.orthogonallyAdjacent() }.associateWith(this::getTile)
}

/**
* Determines if the given [position] falls within the given [bounds].
* If [bounds] are not provided, then they default to the current known
* bounds of the map [data] denoted by ([xMin], [yMin]) and ([xMax], [yMax]).
*
* @param position The position to check for.
* @param bounds Opposite corners of a custom square boundary to check within.
* @returns true if the given [position] falls within the bounds, else false.
*/
protected fun isWithinBounds(position: Point2D, bounds: Pair<Point2D, Point2D>? = null): Boolean {
val (xMin, yMin) = bounds?.first ?: Point2D(xMin()!!, yMin()!!)
val (xMax, yMax) = bounds?.second ?: Point2D(xMax()!!, yMax()!!)
return position.x in xMin..xMax && position.y >= yMin && position.y <= yMax
}

/**
* @return The minimum x-ordinate currently recorded in the map.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ data class Point2D(val x: Int, val y: Int) : Point, Comparable<Point2D> {

/**
* Orthogonally adjacent points are the 4 points immediately horizontal or vertical.
* A.K.A 'Edge Adjacent'
* Also-known-as 'Edge Adjacent'
* @see adjacent for a function that returns on the diagonal too.
* @return The four points that are orthogonally adjacent.
*/
Expand All @@ -61,9 +61,31 @@ data class Point2D(val x: Int, val y: Int) : Point, Comparable<Point2D> {
/**
* Calculates the Manhattan Distance between two [Point2D]s.
* The distance between the points is measured along the axes at right angles.
*
* For the Euclidean distance between two points, see [realDistanceBetween].
*/
fun distanceBetween(point: Point2D): Int = abs(this.x - point.x) + abs(this.y - point.y)

/**
* Calculates the "real" distance between two [Point2D]s
* as the square root of dx^2 + dy^2.
*
* This treats the points like standard coordinates on a
* cartesian grid system and calculates the distance of a
* line drawn between the two, also known as the Euclidean
* distance.
*
* For the Manhattan distance between two points, see [distanceBetween].
*
* @param point The point to check the distance to.
* @return The distance as a [Double] for full precision.
*/
fun realDistanceBetween(point: Point2D): Double {
val dx = point.x.toDouble() - this.x.toDouble()
val dy = point.y.toDouble() - this.y.toDouble()
return sqrt(dx * dx + dy * dy)
}

/**
* Calculates the positive clockwise angle between two [Point2D] in degrees.
* Angles are calculated from the sector's true north in the range of 0 =< angle < 360.
Expand All @@ -83,12 +105,17 @@ data class Point2D(val x: Int, val y: Int) : Point, Comparable<Point2D> {
/**
* Shifts the [Point2D] one unit in the given [direction] unless specified by the [units] parameter.
* E.g. (0, 0) shifted [Direction.RIGHT] would become (1, 0)
*
* @param direction The direction to shift in.
* @param units The number of units to shift by.
* @param isRasterSystem Whether the shift should be treated as if it's in a raster or screen coordinate system (Up goes down below the x-axis).
*
* @return A point at the shifted location.
*/
fun shift(direction: Direction, units: Int = 1): Point2D = when (direction) {
Direction.UP -> Point2D(x, y + units)
fun shift(direction: Direction, units: Int = 1, isRasterSystem: Boolean = false): Point2D = when (direction) {
Direction.UP -> Point2D(x, if (isRasterSystem) y - units else y + units)
Direction.RIGHT -> Point2D(x + units, y)
Direction.DOWN -> Point2D(x, y - units)
Direction.DOWN -> Point2D(x, if (isRasterSystem) y + units else y - units)
Direction.LEFT -> Point2D(x - units, y)
Direction.TOP_RIGHT -> Point2D(x + units, y + units)
Direction.BOTTOM_RIGHT -> Point2D(x + units, y - units)
Expand Down
Loading

0 comments on commit 7e7ac9d

Please sign in to comment.