UNPKG

aoc-automation

Version:

Advent of Code tool to automate the repetitive parts of AoC.

364 lines (315 loc) 15.2 kB
/** * 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';