aoc-automation
Version:
Advent of Code tool to automate the repetitive parts of AoC.
364 lines (315 loc) • 15.2 kB
text/typescript
/**
* Root for your util libraries.
*
* You can import them in the src/template/index.ts,
* or in the specific file.
*
* Note that this repo uses ES Modules, so you have to explicitly specify
* .js extension (yes, .js not .ts - even for TypeScript files)
* for imports that are not imported from node_modules.
*
* For example:
*
* correct:
*
* import _ from 'lodash'
* import myLib from '../utils/myLib.js'
* import { myUtil } from '../utils/index.js'
*
* incorrect:
*
* import _ from 'lodash'
* import myLib from '../utils/myLib.ts'
* import { myUtil } from '../utils/index.ts'
*
* also incorrect:
*
* import _ from 'lodash'
* import myLib from '../utils/myLib'
* import { myUtil } from '../utils'
*
*/
export const parseLines = (rawInput: string): Array<string> => rawInput.split("\n");
export const parseLinesIntoArrays = (rawInput: string, lineSplit: string, toNumber: boolean = false): Array<Array<string | number>> =>
parseLines(rawInput).map((line) => line.split(lineSplit).map((x) => toNumber ? Number(x) : x));
export class Movement {
private static opposites: Record<Direction, Direction> = {
"N": "S",
"S": "N",
"E": "W",
"W": "E",
"NE": "SW",
"SW": "NE",
"NW": "SE",
"SE": "NW"
};
static getOppositeDirection = (direction: Direction): Direction => Movement.opposites[direction];
static Directions = [
{ dx: -1, dy: 0, direction: "W", opposite: Movement.opposites[ "W"] } as MovementDelta,
{ dx: 0, dy: 1, direction: "N", opposite: Movement.opposites[ "N"] } as MovementDelta,
{ dx: 1, dy: 0, direction: "E", opposite: Movement.opposites[ "E"] } as MovementDelta,
{ dx: 0, dy: -1, direction: "S", opposite: Movement.opposites[ "S"] } as MovementDelta
];
static DirectionsWithDiagonals = [
{ dx: -1, dy: 0, direction: "W", opposite: Movement.opposites[ "W"] } as MovementDelta,
{ dx: -1, dy: -1, direction: "NW", opposite: Movement.opposites[ "NW"] } as MovementDelta,
{ dx: 0, dy: 1, direction: "N", opposite: Movement.opposites[ "N"] } as MovementDelta,
{ dx: 1, dy: 1, direction: "NE", opposite: Movement.opposites[ "NE"] } as MovementDelta,
{ dx: 1, dy: 0, direction: "E", opposite: Movement.opposites[ "E"] } as MovementDelta,
{ dx: 1, dy: -1, direction: "SE", opposite: Movement.opposites[ "SE"] } as MovementDelta,
{ dx: 0, dy: -1, direction: "S", opposite: Movement.opposites[ "S"] } as MovementDelta,
{ dx: -1, dy: 1, direction: "SW", opposite: Movement.opposites[ "SW"] } as MovementDelta,
];
}
export type MovementDelta = { dx: number, dy: number, direction: Direction, opposite: Direction };
export type Point<TValue> = { x: number, y: number, value: TValue, visited: boolean };
export type Grid<TValue> = {
points: Array<Array<Point<TValue>>>,
rows: number,
cols: number,
isInside: (point: Array<number>) => boolean,
find: (value: TValue) => Point<TValue> | undefined
};
export const parseGrid = <TValue = string>(rawInput: string, convert?: (value: string) => TValue, visited?: (value: string) => boolean): Grid<TValue> => {
const convertFn = convert ?? ((value: string) => value as unknown as TValue);
const visitedFn = visited ?? ((value: string) => false);
const points = rawInput.split("\n").map((row, y) => row.split("").map((cell, x) => ({ x, y, value: convertFn(cell), visited: visitedFn(cell) })));
return {
points,
rows: points.length,
cols: points[0].length,
isInside: ([x, y]) => x >= 0 && x < points[0].length && y >= 0 && y < points.length,
find: (value: TValue) => {
for (let y = 0; y < points.length; y++) {
for (let x = 0; x < points[0].length; x++) {
if (points[y][x].value === value) {
return points[y][x];
}
}
}
return undefined;
}
};
}
export const logGrid = <TValue>(grid: Grid<TValue>, title?: string): void => {
console.log((title || "") + "\n");
const rows = grid.rows;
const cols = grid.cols;
const rowDigits = String(rows - 1).length;
const colDigits = String(cols - 1).length;
const maxCellSpace = Math.max(colDigits, grid.points.flat().reduce((max, cell) => Math.max(max, String(cell.value).length + 1), 0));
const logX_Axis = () => console.log(`${" ".repeat(rowDigits)} | ${Array.from({ length: cols }, (_, i) => i.toString().padStart(colDigits, "0").padStart(maxCellSpace, " ")).join(" ")}`);
const logHorizontalLine = () => console.log(`${"-".repeat(rowDigits)}-${Array.from({ length: cols }, (_, i) => "-".padStart(maxCellSpace, "-")).join("-")}--`);
logX_Axis();
logHorizontalLine();
grid.points.forEach((row, y) => {
console.log(`${y.toString().padStart(rowDigits, "0")} | ${row.map( cell => " ".repeat(maxCellSpace - 2) + (cell.visited ? "*" : " ") + cell.value).join(" ")}`);
});
logHorizontalLine();
logX_Axis();
console.log("")
};
// https://medium.com/@urna.hybesis/pathfinding-algorithms-the-four-pillars-1ebad85d4c6b
export const aStar = <TValue = string>(
grid: Grid<TValue>,
start: PathNode,
end: PathNode,
movementDeltas: Array<MovementDelta>,
movementCost: (current: PathNode, neighbor: PathNode) => number = (_current, _neighbor) => 1,
canMove: (neighbor: PathNode) => boolean = (neighbor) => grid.points[neighbor.y][neighbor.x].value != "#",
includeAllPaths: boolean = false
): Array<Path> | undefined => {
// Step 1: Find openList node with lowest totalCost, move node to closedList
// Step 2: If currentNode = end, return path (reversed)
// Step 3: Get neighbors of currentNode
// - If in closedList, skip
// - If not in openList, calculate costs, set parent, add to openList
// - If in openList and costFromStart is lower, update costFromStart and parent
// Step 4: Repeat
start.costToFinish = manhattanDistance(start, end);
let isStartMovement = true;
const pathsFound: Array<PathNode> = [];
const openList = [start];
const closedList: Array<PathNode> = [];
const alternateParents: Record<string, Array<PathNode>> = {};
const ouputDebugTrace = false && includeAllPaths && grid.cols > 100;
while (openList.length > 0) {
const currentNode = openList.reduce((minNode, node) => node.totalCost < minNode.totalCost ? node : minNode, openList[0]);
const currentIndex = openList.indexOf(currentNode);
openList.splice(currentIndex, 1);
closedList.push(currentNode);
if (currentNode.x === end.x && currentNode.y === end.y) {
// Only ever > 0 if includeAllPaths is true
if (pathsFound.length > 0) {
const currentCost = currentNode.totalCost;
// If hit end with higher score than current, we can quit since we'll always find lowest to highest and will never find another lower than this
if (currentNode.totalCost > pathsFound[0].totalCost) break;
}
pathsFound.push(currentNode);
if (!includeAllPaths) break; // only want one path...
continue;
}
const neighbors = getNeighbors(grid, currentNode, movementDeltas.filter(d => isStartMovement || d.opposite != currentNode.direction));
isStartMovement = false;
for (const neighbor of neighbors) {
if (!canMove(neighbor)) continue;
// Before I only searched for closed with same direction, but it never worked on 2024 Day 16 input data.
// I'd think the opposite direction condition would simply optimize the code (making less paths to check before
// ultimately finding a neighbor with same direction) vs fixing the issue.
// TODO: Later, I should run again on day 16 and see what alternates are different based on these conditions or if any are added when direction == opposite (I'd think cost to get there is worse)
// Main conditions I added to overcome issue of 490 nodes for part 2 (while all test cases passed):
//
// 1) Added `n.direction == Movement.getOppositeDirection(neighbor.direction)` in closedNeighbor assignment
// 2) If openNeighbor is found, use (includeAllPaths && costFromStart <= openNeighbor.costFromStart) to add neighbor to openList as another item to evaluate
// 3) Added `alternateParents[key].some(n => n.x == node.x && n.y == node.y)` in addAlternateParent to never add duplicates
// 4) In getPaths, made sure directions were going the same way
//
// Scenario 1: None of above, response: 2 paths, 490 nodes
// Scenario 2: Added openNeighbor using == costFromStart, response: 2 paths, 490 nodes
// Scenario 3: Changed openNeighbor to use <= costFromStart, response: 8 paths, 524 nodes
// Scenario 4: Changed closedNeighbor to use || n.direction == 'opposite', response: 4 paths, 511 nodes
// Scenario 5: Added check to addAlternateParent to not add duplicates, response: 4 paths, 511 nodes
// Scenario 6: Changed closedNeighbor to remove || n.direction == 'opposite', response: 8 paths, 524 nodes
// Scenario 7: In getPaths made sure directions where going same way, response: 4 paths, 511 nodes
// Scenario 8 (not used, see below): Changed addAlternateParent to allow duplicates, response: 4 paths, 511 nodes
// Scenario 9: Changed closedNeighbor to use || n.direction == 'opposite', response: 4 paths, 511 nodes
//
// Final analysis: Need openNeighbor to use <= costFromStart and in getPaths, make sure directions are going the same way.
// Also, for my input data, I didn't need to prevent duplicates in addAlternateParent because all items that were
// duplicated where not part of any valid paths, but adding as I think it would be a problem if ever hit.
//
// So, Scenario 9 is what is needed. It optimizes runtime b/c if a closed item of opposite direction is found,
// the current neighbor will never be able to 'turn around' and score a better time, so it is flagged as 'closed'
// and processing for that node stops.
const closedNeighbor = closedList.find(n =>
n.x == neighbor.x && n.y == neighbor.y
// Scenario 4 / Scenario 6 / Scenario 9:
&& (!includeAllPaths || n.direction == neighbor.direction || n.direction == Movement.getOppositeDirection(neighbor.direction))
// && (!includeAllPaths || n.direction == neighbor.direction)
);
const costFromStart = currentNode.costFromStart + movementCost(currentNode, neighbor);
if (closedNeighbor) {
if (includeAllPaths && costFromStart == closedNeighbor.costFromStart) {
// Add parent if not already there...
addAlternateParent(closedNeighbor.clone(currentNode), alternateParents, ouputDebugTrace);
}
continue;
}
const openNeighbor = openList.find(n => n.x == neighbor.x && n.y == neighbor.y);
// Scenario 2:
// if (!openNeighbor || (includeAllPaths && costFromStart == openNeighbor.costFromStart)) {
// Scenario 3:
if (!openNeighbor || (includeAllPaths && costFromStart <= openNeighbor.costFromStart)) {
// if (!openNeighbor) {
neighbor.costFromStart = costFromStart;
neighbor.costToFinish = manhattanDistance(neighbor, end);
neighbor.parent = currentNode;
openList.push(neighbor);
}
else if (costFromStart < openNeighbor.costFromStart) {
// need to clone if all paths...
openNeighbor.costFromStart = costFromStart;
openNeighbor.parent = currentNode;
}
}
}
if (pathsFound.length == 0) return undefined;
const allPaths: Array<Path> = [];
for (const endPath of pathsFound) {
allPaths.push(...getPaths(endPath, alternateParents));
}
if (ouputDebugTrace) {
console.log(`Found ${allPaths.length} paths`);
allPaths.forEach((p, i) => {
console.log(`Path ${i}, cost: ${p.nodes[p.nodes.length - 1].totalCost}`);
console.log(p.nodes.map(n => `${n.x},${n.y}`).join("->\n"))
});
}
return allPaths;
};
const manhattanDistance = <TValue = string>(a: Point<TValue> | PathNode, b: Point<TValue> | PathNode): number => Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
export class Path {
nodes: Array<PathNode>;
get length(): number { return this.nodes.length; };
get totalCost(): number { return this.nodes[this.nodes.length - 1].totalCost; };
get lastNode(): PathNode { return this.nodes[this.nodes.length - 1]; };
constructor(nodes: Array<PathNode>) {
this.nodes = nodes;
}
}
export class PathNode {
x: number;
y: number;
costFromStart: number;
costToFinish: number;
get totalCost(): number { return this.costFromStart + this.costToFinish; };
direction: Direction;
parent?: PathNode;
pathIndex: number;
constructor(x: number, y: number, direction: Direction) {
this.x = x;
this.y = y;
this.direction = direction;
this.costFromStart = this.costToFinish = 0;
this.pathIndex = -1;
}
clone(parent: PathNode): PathNode {
const clone = new PathNode(this.x, this.y, this.direction);
clone.costFromStart = this.costFromStart;
clone.costToFinish = this.costToFinish;
clone.parent = parent;
return clone;
}
}
const getNeighbors = <TValue = string>(grid: Grid<TValue>, node: PathNode, movementDeltas: Array<MovementDelta>): Array<PathNode> => {
const neighbors = movementDeltas.map((delta) => {
const x = node.x + delta.dx;
const y = node.y + delta.dy;
if (!grid.isInside([x, y])) return undefined;
return new PathNode(x, y, delta.direction);
}).filter(n => n != undefined);
return neighbors as Array<PathNode>;
};
const serializeNode = (node?: PathNode) => `${node?.x},${node?.y}`;
const addAlternateParent = (node: PathNode, alternateParents: Record<string, Array<PathNode>>, trace: boolean = false) => {
const key = serializeNode(node);
if (!alternateParents[key]) {
if (trace) {
console.log(`Adding ${node.x},${node.y},${node.direction}, count: 0, exists: false`);
}
alternateParents[key] = [node];
return;
}
// Scenario 5 / Scenario 8:
if (alternateParents[key].some(n => n.x == node.x && n.y == node.y)) {
return;
}
if (trace) {
const count = alternateParents[key] ? alternateParents[key].length : 0;
const exists = alternateParents[key] && alternateParents[key].some(n => n.x == node.x && n.y == node.y);
console.log(`Adding ${node.x},${node.y},${node.direction}, count: ${count}, exists: ${exists}`);
}
alternateParents[key].push(node);
};
const getPaths = (node: PathNode, alternateParents: Record<string, Array<PathNode>>): Array<Path> => {
const allPaths: Array<Path> = [];
const path: Array<PathNode> = [];
let current: PathNode | undefined = node;
while (current) {
path.push(current);
current = current.parent;
const alternateParent = alternateParents[serializeNode(current)];
if (alternateParent) {
const pathToEnd = [...path].reverse();
// Scenario 7:
// for (const p of alternateParent.filter(n => n.direction == current!.direction)) {
for (const p of alternateParent) {
const alternatePaths = getPaths(p, alternateParents);
alternatePaths.forEach(p => allPaths.push(new Path([...p.nodes, ...pathToEnd])));
}
}
}
allPaths.push(new Path(path.reverse()));
return allPaths;
};
export type Direction = 'N' | 'E' | 'S' | 'W' | 'NE' | 'SE' | 'SW' | 'NW';