Quest 18: When Roots Remember

  • Keep top level comments as only solutions, if you want to say something other than a solution put it in a new post. (replies to comments can be whatever)
  • You can send code in code blocks by using three backticks, the code, and then three backticks or use something such as https://topaz.github.io/paste/ if you prefer sending it through a URL

Link to participate: https://everybody.codes/

  • Pyro@programming.dev
    link
    fedilink
    arrow-up
    2
    ·
    9 days ago

    Python

    Couldn’t finish the series when it was released but I’m returning to finish it now. The set of free branches of part3 is too large for brute-force but you can exploit the quirk in the input where each free branch only contributes positively or negatively.

    from collections import defaultdict
    from dataclasses import dataclass
    import re
    
    # regex to match numbers in the input data
    MATCH_NUMS_PATTERN = re.compile(r"(-?\d+)")
    
    # Plant state class
    @dataclass
    class Plant:
        id: int
        thickness: int
        # is_free indicates whether the plant has a free branch.
        # it is also used to turn the effect of free branches on or off.
        is_free: bool = False
    
    # Parses the plant input data into a list of Plant objects and a graph representing the connections between plants
    # The graph root is the the final plant and the leaves are the free branches. 
    def parse_plants(data: str):
        plants: list[Plant] = []
        graph = defaultdict(list)
    
        # Divide the input into blocks for each plant
        for block in data.split("\n\n"):
            # line iterator to control consumption of lines in the block 
            lines_iter = iter(block.splitlines())
    
            # get the plant's id and thickness from the first line of the block
            id, thickness = map(int, re.findall(MATCH_NUMS_PATTERN, next(lines_iter)))
            curr_plant = Plant(id, thickness)
            plants.append(curr_plant)
    
            # parse the remaining lines in the block to get the plant's branches
            for line in lines_iter:
                if line.startswith("- free"):
                    curr_plant.is_free = True
                else:
                    from_plant, thickness = map(int, re.findall(MATCH_NUMS_PATTERN, line))
                    graph[curr_plant.id].append((from_plant, thickness))
        
        return plants, graph
    
    # Recursively calculates the energy for a given plant.
    # Naive implementation with no memoization, but enough for the input size.
    def get_energy_at(plants: list[Plant], graph: dict[int, list[tuple[int, int]]], plant_id: int):
        plant: Plant = plants[plant_id-1]
        energy = 0
    
        # if the plant has a free branch, its energy is equal to its thickness.
        # otherwise, its energy is the sum of the incoming energy from its branches, multiplied by the thickness of each branch.
        if plant.is_free:
            energy = plant.thickness
        else:
            for from_plant, thickness in graph[plant_id]:
                energy += thickness * get_energy_at(plants, graph, from_plant)
    
        # energy only moves through the plant if it is less than or equal to the plant's thickness.
        return energy if plant.thickness <= energy else 0
    
    # Part 1 is simple: just calculate the energy at the final plant with all free branches on.
    def part1(data: str) -> int:
        plants, graph = parse_plants(data)
        return get_energy_at(plants, graph, len(plants))
    
    # Part 2: use the boolean data to turn free branches on or off and calculate the energy at the final plant for each configuration.
    def part2(data: str) -> int:
        # split the input data into plant data and boolean data
        plant_data, bool_data = data.split("\n\n\n")
        plants, graph = parse_plants(plant_data)
    
        all_energy = 0
        for line in bool_data.splitlines():
            # transform the boolean string into a list of integers and set the is_free attribute of each plant accordingly
            bools = map(int, line.split(' '))
            for i, b in enumerate(bools):
                plants[i].is_free = b == 1
    
            all_energy += get_energy_at(plants, graph, len(plants))
        return all_energy
    
    # Part 3: calculate the maximum possible energy at the final plant, 
    #   then calculate the cumulative difference in energy between the maximum and each provided configuration of free branches.
    # To calculate the maximum possible energy:
    #   First, I tried to progressively turn free plants off or on but that doesn't work and the energy stays at 0
    #   Since this is a set of constraints, this can be solved by SMT solvers like z3
    #   However, there is a quirk in the input data that allows for a simpler solution:
    #       Each free branch contributes either positively or negatively ONLY
    #       So we can simply turn off all free branches that contribute negatively and get the max energy.
    # I don't like this solution because it relies on a quirk in the input data and doesn't work for all inputs,
    #   even the sample data
    def part3(data: str) -> int:
        # split the input data into plant data and boolean data
        plant_data, bool_data = data.split("\n\n\n")
        plants, graph = parse_plants(plant_data)
    
        # calculate the contribution of each free branch
        plant_contrib = defaultdict(int)
        for plant in plants:
            # free branches won't have any outgoing edges
            if plant.is_free:
                continue
    
            # for a non-leaf plant, we cumulate the contribution of each of its free branches
            for from_plant, thickness in graph[plant.id]:
                if not plants[from_plant-1].is_free:
                    continue
    
                # assert our assumption about the input data that 
                #   each free branch contributes either positively or negatively ONLY
                if plant_contrib[from_plant]:
                    assert (plant_contrib[from_plant] < 0) == (thickness < 0), (
                        "this approach only works if all free branches contribute "
                        "either positively or negatively ONLY"
                    )
                
                plant_contrib[from_plant] += thickness
    
        # turn off all free branches that contribute negatively
        for id, contrib in plant_contrib.items():
            if contrib >= 0:
                continue
            plants[id-1].is_free = False
    
        # get max energy for this configuration
        max_energy = get_energy_at(plants, graph, len(plants))
    
        # calculate the cumulative difference in energy between the maximum and 
        #   each provided configuration of free branches.
        energy_diff = 0
        for line in bool_data.splitlines():
            bools = map(int, line.split(' '))
            for i, b in enumerate(bools):
                plants[i].is_free = b == 1
    
            dd_energy = get_energy_at(plants, graph, len(plants))
            # we skip configurations that do not activate the final plant
            if dd_energy == 0:
                continue
            energy_diff += max_energy - dd_energy
        
        return energy_diff
    
  • hades@programming.devOPM
    link
    fedilink
    arrow-up
    2
    ·
    9 months ago

    Rust

    use regex::Regex;
    use z3::{
        Optimize, Params,
        ast::{Bool, Int},
    };
    
    #[derive(Default)]
    struct Plant {
        thickness: i64,
        free: Option<i64>,
        connected: Vec<(usize, i64)>,
    }
    
    fn parse_plant_spec(input: &str) -> Plant {
        let mut result = Plant::default();
        let first_re = Regex::new(r"Plant \d+ with thickness (\d+):").unwrap();
        let free_re = Regex::new(r"- free branch with thickness (\d+)").unwrap();
        let branch_re = Regex::new(r"- branch to Plant (\d+) with thickness (-?\d+)").unwrap();
        for line in input.lines() {
            if let Some((_, [thickness])) = first_re.captures(line).map(|c| c.extract()) {
                result.thickness = thickness.parse().unwrap();
            } else if let Some((_, [thickness])) = free_re.captures(line).map(|c| c.extract()) {
                result.free = Some(thickness.parse().unwrap());
            } else if let Some((_, [plant, thickness])) = branch_re.captures(line).map(|c| c.extract())
            {
                result
                    .connected
                    .push((plant.parse().unwrap(), thickness.parse().unwrap()));
            } else {
                panic!("cannot parse line: {line}");
            }
        }
        result
    }
    
    fn eval_plant(plants: &[Plant], number: usize, free_branches: &[i64]) -> i64 {
        let plant = &plants[number - 1];
        if plant.free.is_some() {
            assert_eq!(1, plant.thickness);
            assert_eq!(1, plant.free.unwrap());
            free_branches[number - 1]
        } else {
            let incoming = plant
                .connected
                .iter()
                .map(|&(plant_number, branch_thickness)| {
                    eval_plant(plants, plant_number, free_branches) * branch_thickness
                })
                .sum::<i64>();
            if incoming >= plant.thickness {
                incoming
            } else {
                0
            }
        }
    }
    
    pub fn solve_part_1(input: &str) -> String {
        let plants = input
            .split("\n\n")
            .map(parse_plant_spec)
            .collect::<Vec<_>>();
        eval_plant(&plants, plants.len(), &vec![1; plants.len()]).to_string()
    }
    
    pub fn solve_part_2(input: &str) -> String {
        let (plants, tests) = input.split_once("\n\n\n").unwrap();
        let plants = plants
            .split("\n\n")
            .map(parse_plant_spec)
            .collect::<Vec<_>>();
        tests
            .lines()
            .map(|test| {
                eval_plant(
                    &plants,
                    plants.len(),
                    &test
                        .split(" ")
                        .map(|v| v.parse().unwrap())
                        .collect::<Vec<i64>>(),
                )
            })
            .sum::<i64>()
            .to_string()
    }
    
    fn eval_plant_z3(plants: &[Plant], number: usize, free_branches: &[Option<Bool>]) -> Int {
        let plant = &plants[number - 1];
        if plant.free.is_some() {
            assert_eq!(1, plant.thickness);
            assert_eq!(1, plant.free.unwrap());
            free_branches[number - 1]
                .as_ref()
                .unwrap()
                .ite(&Int::from_i64(1), &Int::from_i64(0))
        } else {
            let incoming = plant
                .connected
                .iter()
                .map(|&(plant_number, branch_thickness)| {
                    eval_plant_z3(plants, plant_number, free_branches) * Int::from_i64(branch_thickness)
                })
                .reduce(|a, b| a + b);
            let incoming = incoming.unwrap_or_else(|| Int::from_i64(0));
            incoming
                .ge(Int::from_i64(plant.thickness))
                .ite(&incoming, &Int::from_i64(0))
        }
    }
    
    fn maximum_achievable_brightness(plants: &[Plant]) -> i64 {
        let mut free_branches = vec![None; plants.len()];
        plants.iter().enumerate().for_each(|(i, p)| {
            if p.free.is_some() {
                free_branches[i] = Some(Bool::fresh_const("free"));
            }
        });
        let solver = Optimize::new();
        let mut params = Params::new();
        params.set_symbol("opt.maxsat_engine", "wmax");
        solver.set_params(&params);
        let brightness = eval_plant_z3(plants, plants.len(), &free_branches);
        solver.maximize(&brightness);
        match solver.check(&[]) {
            z3::SatResult::Sat => solver
                .get_model()
                .unwrap()
                .eval(&brightness, true)
                .unwrap()
                .as_i64()
                .unwrap(),
            _ => panic!("unsat"),
        }
    }
    
    pub fn solve_part_3(input: &str) -> String {
        let (plants, tests) = input.split_once("\n\n\n").unwrap();
        let plants = plants
            .split("\n\n")
            .map(parse_plant_spec)
            .collect::<Vec<_>>();
        let maximum = maximum_achievable_brightness(&plants);
        tests
            .lines()
            .map(|test| {
                eval_plant(
                    &plants,
                    plants.len(),
                    &test
                        .split(" ")
                        .map(|v| v.parse().unwrap())
                        .collect::<Vec<i64>>(),
                )
            })
            .map(|v| if v > 0 { maximum - v } else { 0 })
            .sum::<i64>()
            .to_string()
    }