Skip to content

Commit 645f14d

Browse files
feat: add A* search algorithm
The Graphs folder has Dijkstra, Bellman-Ford, and Floyd-Warshall for shortest paths, but no heuristic-guided search. Add A*, reusing the existing KeyPriorityQueue for the open set, with tests covering a weighted graph, the Dijkstra-equivalent zero-heuristic case, the start-equals-target case, and the unreachable-target case.
1 parent 5c39e87 commit 645f14d

2 files changed

Lines changed: 145 additions & 0 deletions

File tree

Graphs/AStar.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* A* Search Algorithm
3+
*
4+
* Finds the shortest path between a start node and a target node in a
5+
* weighted graph, using a heuristic function to guide the search toward
6+
* the target, generally exploring fewer nodes than an uninformed search
7+
* such as Dijkstra's algorithm.
8+
*
9+
* The algorithm is only guaranteed to find the true shortest path if the
10+
* heuristic is admissible, i.e. it never overestimates the real cost from
11+
* a node to the target. Passing a heuristic that always returns 0 makes
12+
* A* behave exactly like Dijkstra's algorithm.
13+
*
14+
* For more info: https://en.wikipedia.org/wiki/A*_search_algorithm
15+
*
16+
* @param {Object<string, Array<[string, number]>>} graph - Adjacency list mapping each node to an array of [neighbor, weight] pairs.
17+
* @param {string} start - The node to start the search from.
18+
* @param {string} target - The node to search for.
19+
* @param {(node: string) => number} heuristic - Estimated cost from a node to the target.
20+
* @returns {{ path: string[], cost: number } | null} The shortest path from start to target and its total cost, or null if no path exists.
21+
*
22+
* @example
23+
* const graph = {
24+
* A: [['B', 1], ['C', 4]],
25+
* B: [['C', 1], ['D', 5]],
26+
* C: [['D', 1]],
27+
* D: []
28+
* }
29+
* const heuristic = (node) => ({ A: 2, B: 2, C: 1, D: 0 })[node]
30+
*
31+
* aStarSearch(graph, 'A', 'D', heuristic)
32+
* // => { path: ['A', 'B', 'C', 'D'], cost: 3 }
33+
*/
34+
35+
import { KeyPriorityQueue } from '../Data-Structures/Heap/KeyPriorityQueue.js'
36+
37+
const reconstructPath = (cameFrom, current) => {
38+
const path = [current]
39+
while (cameFrom.has(current)) {
40+
current = cameFrom.get(current)
41+
path.unshift(current)
42+
}
43+
return path
44+
}
45+
46+
function aStarSearch(graph, start, target, heuristic) {
47+
const gScore = new Map([[start, 0]])
48+
const cameFrom = new Map()
49+
50+
const openSet = new KeyPriorityQueue()
51+
openSet.push(start, heuristic(start))
52+
53+
while (!openSet.isEmpty()) {
54+
const current = openSet.pop()
55+
56+
if (current === target) {
57+
return {
58+
path: reconstructPath(cameFrom, current),
59+
cost: gScore.get(current)
60+
}
61+
}
62+
63+
const neighbors = graph[current] || []
64+
for (const [neighbor, weight] of neighbors) {
65+
const tentativeGScore = gScore.get(current) + weight
66+
67+
if (tentativeGScore < (gScore.get(neighbor) ?? Infinity)) {
68+
cameFrom.set(neighbor, current)
69+
gScore.set(neighbor, tentativeGScore)
70+
openSet.update(neighbor, tentativeGScore + heuristic(neighbor))
71+
}
72+
}
73+
}
74+
75+
return null
76+
}
77+
78+
export { aStarSearch }

Graphs/test/AStar.test.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { aStarSearch } from '../AStar.js'
2+
3+
const graph = {
4+
A: [
5+
['B', 1],
6+
['C', 4]
7+
],
8+
B: [
9+
['C', 1],
10+
['D', 5]
11+
],
12+
C: [['D', 1]],
13+
D: []
14+
}
15+
16+
const heuristics = { A: 2, B: 2, C: 1, D: 0 }
17+
const heuristic = (node) => heuristics[node]
18+
const zeroHeuristic = () => 0
19+
20+
test('Finds the shortest path using an admissible heuristic', () => {
21+
expect(aStarSearch(graph, 'A', 'D', heuristic)).toEqual({
22+
path: ['A', 'B', 'C', 'D'],
23+
cost: 3
24+
})
25+
})
26+
27+
test('Matches Dijkstra (zero heuristic) on the same graph', () => {
28+
expect(aStarSearch(graph, 'A', 'D', zeroHeuristic)).toEqual({
29+
path: ['A', 'B', 'C', 'D'],
30+
cost: 3
31+
})
32+
})
33+
34+
test('Returns a path with cost 0 when start equals target', () => {
35+
expect(aStarSearch(graph, 'A', 'A', heuristic)).toEqual({
36+
path: ['A'],
37+
cost: 0
38+
})
39+
})
40+
41+
test('Returns null when no path exists', () => {
42+
const disconnectedGraph = { A: [['B', 1]], B: [], C: [] }
43+
expect(aStarSearch(disconnectedGraph, 'A', 'C', zeroHeuristic)).toBeNull()
44+
})
45+
46+
test('Finds the shortest path in a larger graph with multiple routes', () => {
47+
const largerGraph = {
48+
A: [
49+
['B', 2],
50+
['C', 5]
51+
],
52+
B: [
53+
['D', 4],
54+
['E', 2]
55+
],
56+
C: [['E', 1]],
57+
D: [['F', 1]],
58+
E: [['F', 4]],
59+
F: []
60+
}
61+
const largerHeuristic = () => 0
62+
63+
expect(aStarSearch(largerGraph, 'A', 'F', largerHeuristic)).toEqual({
64+
path: ['A', 'B', 'D', 'F'],
65+
cost: 7
66+
})
67+
})

0 commit comments

Comments
 (0)