commit 8a7196bbd8b468fd471f15b328f56f7840b4bb8b Author: Nikolaj Date: Fri Feb 11 13:17:15 2022 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4914db9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +env/ +.vscode/ +__pycache__/ \ No newline at end of file diff --git a/exceptions.py b/exceptions.py new file mode 100644 index 0000000..ea89ee8 --- /dev/null +++ b/exceptions.py @@ -0,0 +1,60 @@ +"""Exceptions used for the vial solver.""" +from collections import Counter + +class InvalidVial(Exception): + """Raised when an invalid vial is initialized.""" + def __init__(self, balls, size): + self.message = "The vial you tried to initialize is not possible." + self.message += f"\n(balls: {balls}, size: {size})" + super().__init__(self.message) + +class InvalidGame(Exception): + """Raised when an invalid game is initialized.""" + def __init__(self, vial_string): + self.message = "The game you tried to initialize is not possible." + self.message += f"\n(input string: {vial_string})" + + count = Counter(list(vial_string.replace(" ", ""))) + count_counter = Counter(list(count.values())) + if 1 in count_counter.values(): + wrong_letter = list(count.keys())[ + list(count.values()).index(list(count_counter.keys())[ + list(count_counter.values()).index(1) + ]) + ] + pointers = ' '*len("(input string: ") + indices = [index for index, value in enumerate(list(vial_string)) + if value == wrong_letter] + for i, index in enumerate(indices): + if i > 0: + index -= indices[i-1]+1 + + pointers += ' '*index + "^" + self.message += "\n"+pointers + super().__init__(self.message) + +class NoSolutions(Exception): + """Raised when an invalid game is initialized.""" + def __init__(self): + self.message = "There are no valid solutions for the game." + super().__init__(self.message) + +class VialFull(Exception): + """Raised when a full vial is pushed to.""" + def __init__(self, balls, size): + self.message = "The vial you tried to push to is already full." + self.message += f"\n(balls: {balls}, size: {size})" + super().__init__(self.message) + +class VialEmpty(Exception): + """Raised when an empty vial is popped.""" + def __init__(self, balls, size): + self.message = "The vial you tried to pull from is empty." + self.message += f"\n(balls: {balls}, size: {size})" + super().__init__(self.message) + +class HeapEmpty(Exception): + """Raised when extract_min() is tried on an empty heap.""" + def __init__(self): + self.message = "The heap you tried to extract from is empty." + super().__init__(self.message) diff --git a/fibonacci_heap.py b/fibonacci_heap.py new file mode 100644 index 0000000..594d626 --- /dev/null +++ b/fibonacci_heap.py @@ -0,0 +1,90 @@ +"""An implementation of a fibonacci heap.""" +from __future__ import annotations +from math import log2, ceil + +import exceptions + +class FibonacciNode(): + """A node for the fibonacci heap.""" + def __init__(self, element, value): + self.element = element + self.value = value + self.children = [] + self.size = 1 + + def __len__(self): + return self.size + + def __lt__(self, other: FibonacciNode): + return self.value < other.value + + @property + def degree(self): + """Return the degree (number of children) of the node.""" + return len(self.children) + + def add_child(self, child: FibonacciNode): + """Adds a child to the node.""" + self.children.append(child) + self.size += child.size + +class FibonacciHeap(): + """The fibonacci heap.""" + def __init__(self, element, value: int): + new_node = FibonacciNode(element, value) + self.roots = [new_node] + self.min = new_node + self.size = 1 + + def __len__(self): + return self.size + + def merge(self, other: FibonacciHeap): + """Merge two fibonacci heaps.""" + self.roots += other.roots + self.size += other.size + if self.min is None or other.min < self.min: + self.min = other.min + + def insert(self, element, value: int): + """Insert an element into the heap.""" + self.merge(FibonacciHeap(element, value)) + + def extract_min(self): + """Extract the element with the smallest value.""" + if self.min is None: + raise exceptions.HeapEmpty() + element = self.min.element + value = self.min.value + self.roots += self.min.children + self.roots.remove(self.min) + self.size -= 1 + if self.roots == []: + self.min = None + else: + self._rearrange() + self.min = min(self.roots) + + return element, value + + def _rearrange(self): + heap_size = len(self) + required_roots = (0 if heap_size == 0 else + 1 if heap_size == 1 else + ceil(log2(heap_size))) + + while len(self.roots) > required_roots: + degree_array = [None for _ in range(required_roots)] + for root in self.roots: + root_degree = root.degree + if degree_array[root_degree] is None: + degree_array[root_degree] = root + else: + if degree_array[root_degree] < root: + degree_array[root_degree].add_child(root) + self.roots.remove(root) + else: + root.add_child(degree_array[root_degree]) + self.roots.remove(degree_array[root_degree]) + + break diff --git a/main.py b/main.py new file mode 100644 index 0000000..6d6e3f6 --- /dev/null +++ b/main.py @@ -0,0 +1,29 @@ +"""Main module.""" +import time +from vial_game import Game +from vial_solver import solve_game, format_instructions + + +def main(): + """The main method for the vial solver.""" + colors = [(75, "c"), (215, "o"), (130, "b"), (245, "g"), (226, "y"), + (77, "l"), (90, "u"), (70, "m"), (198, "i"), (20, "e"), + (160, "r"), (22, "d")] + for color, letter in colors: + print(f" \033[38;5;{color}m⚫\033[39m = {letter} |", end="") + print("\b") + try: + vial_string = input() + except KeyboardInterrupt: + print() + return + game = Game(vial_string) + print(game) + + start = time.time() + solution = solve_game(game) + print(f"\rFound solution with {len(solution)} steps.") + print(format_instructions(solution, start)) + +if __name__ == "__main__": + main() diff --git a/vial_game.py b/vial_game.py new file mode 100644 index 0000000..fc0484d --- /dev/null +++ b/vial_game.py @@ -0,0 +1,144 @@ +"""Implementation of the vial game.""" +from itertools import product +from collections import Counter +import exceptions + +def _valid_game(vial_string): + count = Counter(list(vial_string.replace(" ", ""))) + return len(set(count[i] for i in count if not i.isnumeric())) == 1 + +class Vial(): + """Implementation of a vial.""" + def __init__(self, balls: list[str], size: int): + if len(balls) > size: + raise exceptions.InvalidVial(balls, size) + self.balls = balls + self.size = size + + def __repr__(self): + return "".join(self.balls) + "-"*(self.size-len(self.balls)) + + def __len__(self): + return len(self.balls) + + @property + def is_full(self): + """Test if the vial is full.""" + return len(self.balls) >= self.size + + @property + def is_empty(self): + """Test if the vial is empty.""" + return len(self.balls) == 0 + + @property + def is_solved(self): + """Test if the vial is solved.""" + return self.is_empty or ( + self.is_full and all(i == self.balls[0] for i in self.balls)) + + @property + def is_semi_solved(self): + """Test if the vial is solved.""" + return (all(i == self.balls[0] for i in self.balls) and + len(self.balls) > self.size//2) + + @property + def top_ball(self): + """Get the top ball of the vial.""" + return self.balls[-1] or None + + @property + def min_moves(self): + """get the minimum amount of moves to solve the vial.""" + return len([i for x, i in enumerate((self.balls)) if + any(i != self.balls[j] for j in range(x))]) + + def copy(self): + """"Return a copy of itself.""" + return Vial(self.balls.copy(), self.size) + + def push(self, ball: str): + """Add ball to the vial.""" + if self.is_full: + raise exceptions.VialFull(self.balls, self.size) + self.balls += [ball] + + def pull(self): + """Remove the top ball.""" + if self.is_empty: + raise exceptions.VialEmpty(self.balls, self.size) + return self.balls.pop() + +class Game(): + """Implementation of a full game of vials.""" + def __init__(self, vial_string: str): + if not (vial_string == "" or _valid_game(vial_string)): + raise exceptions.InvalidGame(vial_string) + balls = [list(i) for i in vial_string.split(" ") if not i.isnumeric()] + balls += [ + [] for _ in range( + sum([int(i) for i in vial_string.split(" ") if i.isnumeric()])) + ] + vial_size = max([len(i) for i in balls]) + self.vials = [Vial(i[::-1], vial_size) for i in balls] + + def __repr__(self): + return "|".join([repr(i) for i in self.vials]) + + def vial_string(self): + """Represents the game as a string, ignoring vial order.""" + return "|".join(sorted([repr(i) for i in self.vials])) + + @property + def is_solved(self): + """Test if the game is solved.""" + return all(i.is_solved for i in self.vials) + + @property + def possible_from_vials(self): + """Return a list of indexes for non-empty vials.""" + return [i for i, vial in + enumerate(self.vials) if (not + (vial.is_empty or + vial.is_semi_solved))] + + @property + def possible_to_vials(self): + """Return a list of indexes for non-full vials.""" + return [i for i, vial in enumerate(self.vials) if not vial.is_full] + + @property + def min_moves(self): + """Returns the minimum amount of moves to solve the game.""" + if self.is_solved: + return 0 + + return max(sum(i.min_moves for i in self.vials), 1) + + def copy(self): + """Return of copy of itself.""" + game_copy = Game("") + game_copy.vials = [i.copy() for i in self.vials] + return game_copy + + def move(self, from_vial: int, to_vial: int): + """Move the top ball from one vial to another.""" + self.vials[to_vial].push(self.vials[from_vial].pull()) + + def compatible(self, from_vial: int, to_vial: int): + """Test if you can move the top ball from one vial to another.""" + from_vial, to_vial = (self.vials[i] for i in [from_vial, to_vial]) + return ((not from_vial.is_empty) and + (to_vial.is_empty or + from_vial.top_ball == to_vial.top_ball)) + + def possible_moves(self, last_to_vial): + """Generate all possible moves.""" + possible_from_vials = self.possible_from_vials + possible_to_vials = self.possible_to_vials + if last_to_vial and last_to_vial in possible_from_vials: + possible_from_vials.remove(last_to_vial) + + move_list = product(possible_from_vials, possible_to_vials) + return [i for i in move_list if self.compatible(*i)] diff --git a/vial_solver.py b/vial_solver.py new file mode 100644 index 0000000..5f50ffa --- /dev/null +++ b/vial_solver.py @@ -0,0 +1,92 @@ +"""A solver for a vial-type game.""" +import time +import math +from fibonacci_heap import FibonacciHeap +from vial_game import Game +from exceptions import NoSolutions + +def print_progress(amount_done): + extra_symbol = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'][ + math.floor(amount_done * 160) % 8 + ] + + progress_bar = "\r|{}{}{}| {}% ".format( + '█'*math.floor(amount_done*20), + extra_symbol, + ' '*(math.ceil((1-amount_done)*20)-1), + math.floor(amount_done*100) + ) + print(progress_bar, end="") + + +def solve_game(starting_state: Game): + """Solves a vial game.""" + if starting_state.is_solved: + return [] + + attempted_states = {starting_state.vial_string(): []} + states = FibonacciHeap((starting_state, []), starting_state.min_moves) + amount_done = 0 + moves_amount = -1 + + while len(states) > 0: + (current_state, moves), value = states.extract_min() + if len(moves) > moves_amount: + moves_amount = len(moves) + print(f"{len(states) + 1} ({len(attempted_states)})") + + if amount_done + 0.00125 < len(moves)/value: + amount_done = (len(moves)/value) + print_progress(amount_done) + + last_to_vial = moves[-1][1] if len(moves) > 1 else None + possible_moves = current_state.possible_moves(last_to_vial) + for from_vial, to_vial in possible_moves: + new_state = current_state.copy() + new_state.move(from_vial, to_vial) + if new_state.vials[to_vial].is_solved: + star = "*" + else: + star = " " + new_state_moves = moves + [(from_vial, to_vial, star)] + if new_state.is_solved: + return new_state_moves + + if new_state.vial_string() not in attempted_states: + attempted_states[new_state.vial_string()] = new_state_moves + new_state_value = new_state.min_moves + len(new_state_moves) + states.insert((new_state, new_state_moves), new_state_value) + + print("\r", end="") + raise NoSolutions() + +def format_instructions(instructions, start_time): + """Format instructions outputted by solve_game().""" + time_in_milliseconds = math.floor((time.time()-start_time)*1000) + time_text = "" + if time_in_milliseconds >= (1000*60): + time_in_minutes = str(math.floor(time_in_milliseconds/(1000*60))) + time_text += time_in_minutes+"m " + if time_in_milliseconds >= 1000: + time_in_seconds = str(math.floor(time_in_milliseconds/1000)%60) + time_text += time_in_seconds+"s " + + time_text += str(time_in_milliseconds%1000)+"ms " + + formatted_instructions = f"Time elapsed: {time_text}\n" + formatted_instructions += "Instructions:\n" + for i, instruction in enumerate(instructions): + if i%7 == 0 and i != 0: + formatted_instructions += "\n" + if i%21 == 0 and i != 0: + formatted_instructions += "\n" + if i%63 == 0 and i != 0: + formatted_instructions += "\n\n" + + from_vial = instruction[0] + to_vial = instruction[1] + star = instruction[2] + step = f" {star}{from_vial:2} --> {to_vial:2}{star} \t" + formatted_instructions += step + + return formatted_instructions