Skip to content

Commit 7a68f47

Browse files
authored
feat: Add A search and heap sort implementations with tests (#67)
This pull request introduces two new algorithm implementations: A* Search for graph pathfinding and Heap Sort for array sorting, along with comprehensive test suites for both. Additionally, it updates the pre-commit hooks to use `yarn` for running formatting and linting scripts. The most important changes are grouped below: ### Algorithm Implementations * Added a complete A* Search algorithm in `aStar.ts`, including support for custom heuristics, and utility functions for Manhattan and Euclidean heuristics. * Implemented Heap Sort in `heapSort.ts` with support for custom comparators and thorough documentation. ### Test Coverage * Added extensive unit tests for A* Search in `aStar.test.ts`, covering basic, grid-based, and edge-case scenarios, as well as heuristic function correctness. * Added robust test suite for Heap Sort in `heapSort.test.ts`, covering various input types and custom comparator usage. ### Tooling Improvements * Updated the `lefthook.yml` pre-commit configuration to run `prettier` and `eslint` via `yarn`, ensuring consistency with project tooling.
1 parent 6cbbcad commit 7a68f47

32 files changed

+524
-18
lines changed
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import {aStar, createManhattanHeuristic, createEuclideanHeuristic} from './aStar';
2+
3+
describe('A* Search Algorithm', () => {
4+
test('should find the shortest path in a simple graph', () => {
5+
// Graph: 0 -> 1 -> 2 -> 3
6+
const graph = [[{to: 1, weight: 1}], [{to: 2, weight: 1}], [{to: 3, weight: 1}], []];
7+
const heuristic = (v: number): number => 3 - v; // Simple heuristic
8+
9+
const result = aStar(graph, 0, 3, heuristic);
10+
expect(result.path).toEqual([0, 1, 2, 3]);
11+
expect(result.distance).toBe(3);
12+
});
13+
14+
test('should find optimal path when multiple paths exist', () => {
15+
// Graph with two paths: 0->1->3 (cost 5) and 0->2->3 (cost 3)
16+
const graph = [
17+
[
18+
{to: 1, weight: 2},
19+
{to: 2, weight: 1},
20+
],
21+
[{to: 3, weight: 3}],
22+
[{to: 3, weight: 2}],
23+
[],
24+
];
25+
const heuristic = (): number => 0; // Zero heuristic (degrades to Dijkstra)
26+
27+
const result = aStar(graph, 0, 3, heuristic);
28+
expect(result.path).toEqual([0, 2, 3]);
29+
expect(result.distance).toBe(3);
30+
});
31+
32+
test('should return null path when no path exists', () => {
33+
const graph = [[{to: 1, weight: 1}], [], [{to: 3, weight: 1}], []];
34+
const heuristic = (): number => 0;
35+
36+
const result = aStar(graph, 0, 3, heuristic);
37+
expect(result.path).toBeNull();
38+
expect(result.distance).toBe(Infinity);
39+
});
40+
41+
test('should handle source equals goal', () => {
42+
const graph = [[{to: 1, weight: 1}], []];
43+
const heuristic = (): number => 0;
44+
45+
const result = aStar(graph, 0, 0, heuristic);
46+
expect(result.path).toEqual([0]);
47+
expect(result.distance).toBe(0);
48+
});
49+
50+
test('should handle invalid source vertex', () => {
51+
const graph = [[{to: 1, weight: 1}], []];
52+
const heuristic = (): number => 0;
53+
54+
const result = aStar(graph, -1, 1, heuristic);
55+
expect(result.path).toBeNull();
56+
expect(result.distance).toBe(Infinity);
57+
});
58+
59+
test('should handle invalid goal vertex', () => {
60+
const graph = [[{to: 1, weight: 1}], []];
61+
const heuristic = (): number => 0;
62+
63+
const result = aStar(graph, 0, 5, heuristic);
64+
expect(result.path).toBeNull();
65+
expect(result.distance).toBe(Infinity);
66+
});
67+
68+
test('should work with a grid-based graph using Manhattan heuristic', () => {
69+
// 3x3 grid:
70+
// 0 - 1 - 2
71+
// | | |
72+
// 3 - 4 - 5
73+
// | | |
74+
// 6 - 7 - 8
75+
const graph = [
76+
[
77+
{to: 1, weight: 1},
78+
{to: 3, weight: 1},
79+
],
80+
[
81+
{to: 0, weight: 1},
82+
{to: 2, weight: 1},
83+
{to: 4, weight: 1},
84+
],
85+
[
86+
{to: 1, weight: 1},
87+
{to: 5, weight: 1},
88+
],
89+
[
90+
{to: 0, weight: 1},
91+
{to: 4, weight: 1},
92+
{to: 6, weight: 1},
93+
],
94+
[
95+
{to: 1, weight: 1},
96+
{to: 3, weight: 1},
97+
{to: 5, weight: 1},
98+
{to: 7, weight: 1},
99+
],
100+
[
101+
{to: 2, weight: 1},
102+
{to: 4, weight: 1},
103+
{to: 8, weight: 1},
104+
],
105+
[
106+
{to: 3, weight: 1},
107+
{to: 7, weight: 1},
108+
],
109+
[
110+
{to: 4, weight: 1},
111+
{to: 6, weight: 1},
112+
{to: 8, weight: 1},
113+
],
114+
[
115+
{to: 5, weight: 1},
116+
{to: 7, weight: 1},
117+
],
118+
];
119+
120+
const goalX = 2;
121+
const goalY = 2;
122+
const gridWidth = 3;
123+
const heuristic = createManhattanHeuristic(goalX, goalY, gridWidth);
124+
125+
const result = aStar(graph, 0, 8, heuristic);
126+
expect(result.distance).toBe(4);
127+
expect(result.path?.length).toBe(5);
128+
expect(result.path?.[0]).toBe(0);
129+
expect(result.path?.[result.path.length - 1]).toBe(8);
130+
});
131+
132+
test('should work with Euclidean heuristic', () => {
133+
// Simple 2x2 grid
134+
const graph = [
135+
[
136+
{to: 1, weight: 1},
137+
{to: 2, weight: 1},
138+
],
139+
[
140+
{to: 0, weight: 1},
141+
{to: 3, weight: 1},
142+
],
143+
[
144+
{to: 0, weight: 1},
145+
{to: 3, weight: 1},
146+
],
147+
[
148+
{to: 1, weight: 1},
149+
{to: 2, weight: 1},
150+
],
151+
];
152+
153+
const heuristic = createEuclideanHeuristic(1, 1, 2);
154+
const result = aStar(graph, 0, 3, heuristic);
155+
156+
expect(result.distance).toBe(2);
157+
expect(result.path?.length).toBe(3);
158+
});
159+
160+
test('should handle disconnected components', () => {
161+
const graph = [
162+
[{to: 1, weight: 1}],
163+
[{to: 0, weight: 1}],
164+
[{to: 3, weight: 1}],
165+
[{to: 2, weight: 1}],
166+
];
167+
const heuristic = (): number => 0;
168+
169+
const result = aStar(graph, 0, 3, heuristic);
170+
expect(result.path).toBeNull();
171+
expect(result.distance).toBe(Infinity);
172+
});
173+
174+
test('should handle graph with cycles', () => {
175+
// Triangle graph: 0 - 1 - 2 - 0
176+
const graph = [
177+
[
178+
{to: 1, weight: 1},
179+
{to: 2, weight: 3},
180+
],
181+
[
182+
{to: 0, weight: 1},
183+
{to: 2, weight: 1},
184+
],
185+
[
186+
{to: 0, weight: 3},
187+
{to: 1, weight: 1},
188+
],
189+
];
190+
const heuristic = (): number => 0;
191+
192+
const result = aStar(graph, 0, 2, heuristic);
193+
expect(result.path).toEqual([0, 1, 2]);
194+
expect(result.distance).toBe(2);
195+
});
196+
});
197+
198+
describe('Heuristic functions', () => {
199+
test('Manhattan heuristic should calculate correct distance', () => {
200+
const heuristic = createManhattanHeuristic(2, 2, 3);
201+
expect(heuristic(0)).toBe(4); // (0,0) to (2,2)
202+
expect(heuristic(4)).toBe(2); // (1,1) to (2,2)
203+
expect(heuristic(8)).toBe(0); // (2,2) to (2,2)
204+
});
205+
206+
test('Euclidean heuristic should calculate correct distance', () => {
207+
const heuristic = createEuclideanHeuristic(3, 0, 4);
208+
expect(heuristic(0)).toBe(3); // (0,0) to (3,0)
209+
expect(heuristic(3)).toBe(0); // (3,0) to (3,0)
210+
expect(heuristic(4)).toBeCloseTo(Math.sqrt(10)); // (0,1) to (3,0)
211+
});
212+
});

algorithms/graph/a-star/aStar.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* A* Search algorithm implementation for finding the shortest path in a weighted graph.
3+
* A* uses a heuristic function to guide the search towards the goal more efficiently than Dijkstra.
4+
*/
5+
6+
interface Edge {
7+
to: number;
8+
weight: number;
9+
}
10+
11+
interface AStarResult {
12+
path: number[] | null;
13+
distance: number;
14+
}
15+
16+
/**
17+
* Implements the A* search algorithm to find the shortest path from source to goal.
18+
*
19+
* @param graph - Adjacency list representation of the graph where graph[i] is an array of edges from vertex i.
20+
* @param source - The source vertex to start the search from.
21+
* @param goal - The goal vertex to reach.
22+
* @param heuristic - A function that estimates the distance from a vertex to the goal. Must be admissible (never overestimate).
23+
* @returns An object containing the path array and the total distance, or null path if no path exists.
24+
*/
25+
export function aStar(
26+
graph: Edge[][],
27+
source: number,
28+
goal: number,
29+
heuristic: (vertex: number) => number,
30+
): AStarResult {
31+
const n = graph.length;
32+
33+
if (source < 0 || source >= n || goal < 0 || goal >= n) {
34+
return {path: null, distance: Infinity};
35+
}
36+
37+
// g[n] = actual cost from source to n
38+
const gScore: number[] = Array(n).fill(Infinity);
39+
gScore[source] = 0;
40+
41+
// f[n] = g[n] + h[n] (estimated total cost)
42+
const fScore: number[] = Array(n).fill(Infinity);
43+
fScore[source] = heuristic(source);
44+
45+
const predecessors: number[] = Array(n).fill(-1);
46+
const closedSet: boolean[] = Array(n).fill(false);
47+
const openSet: boolean[] = Array(n).fill(false);
48+
openSet[source] = true;
49+
50+
while (hasOpenNodes(openSet)) {
51+
// Find node in openSet with lowest fScore
52+
const current = getLowestFScore(openSet, fScore);
53+
54+
if (current === goal) {
55+
return {
56+
path: reconstructPath(source, goal, predecessors),
57+
distance: gScore[goal],
58+
};
59+
}
60+
61+
openSet[current] = false;
62+
closedSet[current] = true;
63+
64+
for (const edge of graph[current]) {
65+
const neighbor = edge.to;
66+
67+
if (closedSet[neighbor]) {
68+
continue;
69+
}
70+
71+
const tentativeGScore = gScore[current] + edge.weight;
72+
73+
if (!openSet[neighbor]) {
74+
openSet[neighbor] = true;
75+
} else if (tentativeGScore >= gScore[neighbor]) {
76+
continue;
77+
}
78+
79+
// This path is the best so far
80+
predecessors[neighbor] = current;
81+
gScore[neighbor] = tentativeGScore;
82+
fScore[neighbor] = gScore[neighbor] + heuristic(neighbor);
83+
}
84+
}
85+
86+
// No path found
87+
return {path: null, distance: Infinity};
88+
}
89+
90+
/**
91+
* Checks if there are any nodes in the open set.
92+
*/
93+
function hasOpenNodes(openSet: boolean[]): boolean {
94+
return openSet.some((isOpen) => isOpen);
95+
}
96+
97+
/**
98+
* Finds the node in the open set with the lowest f score.
99+
*/
100+
function getLowestFScore(openSet: boolean[], fScore: number[]): number {
101+
let minScore = Infinity;
102+
let minIndex = -1;
103+
104+
for (let i = 0; i < openSet.length; i++) {
105+
if (openSet[i] && fScore[i] < minScore) {
106+
minScore = fScore[i];
107+
minIndex = i;
108+
}
109+
}
110+
111+
return minIndex;
112+
}
113+
114+
/**
115+
* Reconstructs the path from source to goal using the predecessors array.
116+
*/
117+
function reconstructPath(source: number, goal: number, predecessors: number[]): number[] {
118+
const path: number[] = [];
119+
let current = goal;
120+
121+
while (current !== -1) {
122+
path.unshift(current);
123+
if (current === source) {
124+
break;
125+
}
126+
current = predecessors[current];
127+
}
128+
129+
return path[0] === source ? path : [];
130+
}
131+
132+
/**
133+
* Creates a Manhattan distance heuristic for grid-based pathfinding.
134+
* Assumes vertices are numbered row by row in a grid.
135+
*
136+
* @param goalX - The x coordinate of the goal.
137+
* @param goalY - The y coordinate of the goal.
138+
* @param gridWidth - The width of the grid.
139+
* @returns A heuristic function that calculates Manhattan distance to the goal.
140+
*/
141+
export function createManhattanHeuristic(
142+
goalX: number,
143+
goalY: number,
144+
gridWidth: number,
145+
): (vertex: number) => number {
146+
return (vertex: number): number => {
147+
const x = vertex % gridWidth;
148+
const y = Math.floor(vertex / gridWidth);
149+
return Math.abs(x - goalX) + Math.abs(y - goalY);
150+
};
151+
}
152+
153+
/**
154+
* Creates a Euclidean distance heuristic for grid-based pathfinding.
155+
*
156+
* @param goalX - The x coordinate of the goal.
157+
* @param goalY - The y coordinate of the goal.
158+
* @param gridWidth - The width of the grid.
159+
* @returns A heuristic function that calculates Euclidean distance to the goal.
160+
*/
161+
export function createEuclideanHeuristic(
162+
goalX: number,
163+
goalY: number,
164+
gridWidth: number,
165+
): (vertex: number) => number {
166+
return (vertex: number): number => {
167+
const x = vertex % gridWidth;
168+
const y = Math.floor(vertex / gridWidth);
169+
return Math.sqrt((x - goalX) ** 2 + (y - goalY) ** 2);
170+
};
171+
}

algorithms/search/bellman-ford-search/bellmanFordSearch.test.ts renamed to algorithms/graph/bellman-ford/bellmanFordSearch.test.ts

File renamed without changes.

algorithms/search/bellman-ford-search/bellmanFordSearch.ts renamed to algorithms/graph/bellman-ford/bellmanFordSearch.ts

File renamed without changes.
File renamed without changes.

algorithms/search/topological-sort/topological-sort.test.ts renamed to algorithms/graph/topological-sort/topological-sort.test.ts

File renamed without changes.

algorithms/search/topological-sort/topological-sort.ts renamed to algorithms/graph/topological-sort/topological-sort.ts

File renamed without changes.

0 commit comments

Comments
 (0)