view day17.rb @ 39:0e17e4bd97a9 default tip

Rewrite as four-dimensional route finding The grid isn't just a 2D grid. The constraints make it 4D: * X * Y * Last direction * Number of steps in that direction By tracking all four dimensions, we can find the shortest route for _all_ combinations of the constraint. Previously, we were dropping routes that were currently longer but ended up shorter because they could take subsequent steps that other routes couldn't.
author IBBoard <dev@ibboard.co.uk>
date Sun, 22 Sep 2024 11:30:53 +0100
parents 8e92cb172e6b
children
line wrap: on
line source

#! /usr/bin/env ruby

require 'set'

if ARGV.length != 1
        abort("Incorrect arguments - needs input file")
elsif not File.exist? (ARGV[0])
	abort("File #{ARGV[0]} did not exist")
end

file = ARGV[0]

Coord = Struct.new(:x, :y)
Node = Struct.new(:coords, :direction, :num_prev_steps)
RouteNode = Struct.new(:node, :cost, :previous_node)

module Directions
        UP = 0
        LEFT = 1
        DOWN = 2
        RIGHT = 3
end

$up = Coord.new(0, -1)
$left = Coord.new(-1, 0)
$down = Coord.new(0, 1)
$right = Coord.new(1, 0)
directions = [$up, $left, $down, $right]

city_map = File.open(file, "r").each_line(chomp: true).map{ |line| line.each_char.map(&:to_i)}

routes = city_map.map {|row| row.map { (0..3).map { (0..2).map {nil}}}}

$width = city_map[0].length
$height = city_map.length

Colour = Struct.new(:r, :g, :b)

start_space = Coord.new(0, 0)
end_space = Coord.new($width - 1, $height - 1)

class PQueue
        def initialize()
                # Placeholder at the root to simplify calculations
                @objects = [nil]
        end

        def push(element)
                new_idx = @objects.size
                @objects << element
                _reorder_insert(new_idx)
        end

        def _reorder_insert(idx)
                parent_idx = idx / 2
                if idx > 1 and @objects[parent_idx].cost > @objects[idx].cost
                        _swap(idx, parent_idx)
                        _reorder_insert(parent_idx)
                end
        end

        def _swap(idx1, idx2)
                @objects[idx2], @objects[idx1] = @objects[idx1], @objects[idx2]
        end

        def pop()
                # Swap for easy popping without moving everything
                _swap(1, @objects.size - 1)
                priority_obj = @objects.pop
                _reorder_remove(1)
                priority_obj
        end

        def _reorder_remove(idx)
                child1_idx = idx * 2
                child2_idx = child1_idx + 1
                last_idx = @objects.size - 1

                if child1_idx <= last_idx
                        child1_val = @objects[child1_idx].cost
                        child2_val = child2_idx <= last_idx ? @objects[child2_idx].cost : Float::INFINITY
                        child_idx, child_val =  child1_val < child2_val ? [child1_idx, child1_val] : [child2_idx, child2_val]

                        if @objects[idx].cost >= child_val
                                _swap(idx, child_idx)
                                _reorder_remove(child_idx)
                        end
                end
        end

        def empty?
                @objects.size <= 1
        end

        def to_s
                @objects.map {|val| "#{val.coords.x},#{val.coords.y}=#{val.cost}" if val }
        end
end

# Our route is constrained to "four consecutive spaces". Zig-zag through the middle
# obeys that rule and it's either the shortest route _or_ we won't explore a longer route
max_score = city_map.each_with_index.map {|row, i| i < row.length - 1 ? [row[i], row[i+1]] : [row[i]]}.flatten.reduce(:+)

$colours = (0..max_score).map do |i|
	angle = (i.to_f / max_score) * 360
	if angle <= 60
		r = 0xff
		g = 0xff * angle / 60
		b = 0
	elsif angle <= 120
		r = 0xff * (120 - angle) / 60
		g = 0xff
		b = 0
	elsif angle <= 180
		r = 0
		g = 0xff
		b = 0xff * (angle - 120) / 60
	elsif angle <= 240
		r = 0
		g = 0xff * (240 - angle) / 60
		b = 0xff
	elsif angle <= 300
		r = 0xff * (240 - angle) / 60
		g = 0
		b = 0xff
	else
		r = 0xff
		g = 0
		b = 0xff * (360 - angle) / 60
	end
	Colour.new(r.to_i, g.to_i, b.to_i)
end

def update_map(new_node, map)
	up = $height - new_node.node.coords.y
	# We printed a new line after the map, so we're already in column 0 and go X forward (right)
	forward = new_node.node.coords.x
        colour = $colours[new_node.cost]
        cell = map[new_node.node.coords.y][new_node.node.coords.x]
        #puts [cell, new_node, $height, $width, up, forward, colour]
        print "\e[s"
        if forward > 0
                print "\e[#{forward}C"
        end
        if up > 0
                print "\e[#{up}A"
        end
        print "\e[48;2;#{colour.r};#{colour.g};#{colour.b}m#{cell}\e[0m\e[u"
end

puts "#{city_map.map {|row| row.join("")}.join("\n")}"
# sleep(5)  # For screen recording

unvisited = PQueue.new
# Fake two starting steps from outside so that we use the "turn" logic to get
# routes with zero previous steps
start_node_1 = RouteNode.new(Node.new(start_space, Directions::RIGHT, 0), 0, nil)
start_node_2 = RouteNode.new(Node.new(start_space, Directions::DOWN, 0), 0, nil)
unvisited.push(start_node_1)
unvisited.push(start_node_2)
visited = Set.new
candidate_node = nil

def is_shorter?(cur_shortest, new_node)
        not cur_shortest or cur_shortest.cost > new_node.cost
end

def is_valid?(x, y)
        0 <= x and x  < $width and 0 <= y and y < $height
end

while not unvisited.empty?
        candidate_node = unvisited.pop
        candidate_node_node = candidate_node.node

        if visited.include?(candidate_node_node)
                # Longer route to an already visited node
                next
        end
        visited.add(candidate_node_node)

        candidate_coords = candidate_node_node.coords
        update_map(candidate_node, city_map)


        if candidate_coords == end_space
                break
        end

        current_direction = candidate_node_node.direction

        # We can always turn
        anticlockwise = (current_direction + 1) % 4
        clockwise = (anticlockwise + 2) % 4

        next_steps = [
                [anticlockwise, 0],
                [clockwise, 0]
        ]
        next_steps.append([current_direction, candidate_node_node.num_prev_steps + 1]) if candidate_node_node.num_prev_steps < 4

        next_steps.each do |direction, num_prev_steps|
                dir = directions[direction]
                new_x = candidate_coords.x + dir.x
                new_y = candidate_coords.y + dir.y
                if is_valid?(new_x, new_y)
                        next_num_prev_steps = num_prev_steps + 1
                        cur_shortest = routes[new_y][new_x][direction][next_num_prev_steps]
                        if is_shorter?(cur_shortest, candidate_node)
                                new_shortest = RouteNode.new(Node.new(Coord.new(new_x, new_y), direction, next_num_prev_steps), candidate_node.cost + city_map[new_y][new_x], candidate_node)
                                routes[new_y][new_x][direction][next_num_prev_steps] = new_shortest
                                unvisited.push(new_shortest)
                        end
                end
        end
end

if candidate_node
        route_node = candidate_node
        coords = []
        while route_node
                coords.append(route_node.node.coords)
                route_node = route_node.previous_node
        end
        coords.each do |step|
                cell = city_map[step.y][step.x]
                up = $height - step.y
                forward = step.x
                print "\e[s"
                if forward > 0
                        print "\e[#{forward}C"
                end
                if up > 0
                        print "\e[#{up}A"
                end
                print "\e[1;30m\e[1;47m#{cell}\e[0m\e[u"
        end
        puts candidate_node.cost
end