|
#!/usr/bin/env python3 |
|
import enum |
|
from collections.abc import Iterable, Sized |
|
from dataclasses import dataclass |
|
from typing import Self, final |
|
|
|
|
|
def is_empty(x: Sized): |
|
return len(x) == 0 |
|
|
|
|
|
@final |
|
@dataclass(frozen=True, kw_only=True, slots=True) |
|
class Cli: |
|
left: int |
|
right: int |
|
weights: list[int] |
|
allowed_range: range |
|
|
|
@classmethod |
|
def parse(cls) -> Self: |
|
from argparse import ArgumentParser, ArgumentTypeError |
|
|
|
def parse_int_list(arg: str) -> list[int]: |
|
"""Convert comma-separated numbers into a list of integers.""" |
|
try: |
|
return [int(x.strip()) for x in arg.removesuffix(",").split(",")] |
|
except ValueError: |
|
raise ArgumentTypeError(f"Invalid number list: {arg}") |
|
|
|
parser = ArgumentParser( |
|
description="Balance a scale by adding one missing weight.", |
|
epilog="Example: astra-balancer --left=5 --right=4 70,58,43,64,85,55,60,59", |
|
) |
|
parser.add_argument( |
|
"--left", type=int, required=True, help="Number of slots on the left side" |
|
) |
|
parser.add_argument( |
|
"--right", type=int, required=True, help="Number of slots on the right side" |
|
) |
|
parser.add_argument( |
|
"weights", |
|
type=parse_int_list, |
|
help="List of known weights excluding the missing one. (comma-separated)", |
|
) |
|
parser.add_argument( |
|
"--min", |
|
type=int, |
|
default=2, |
|
help="Minimum possible missing weight. (default: 2)", |
|
) |
|
parser.add_argument( |
|
"--max", |
|
type=int, |
|
default=99, |
|
help="Maximum possible missing weight. (default: 99)", |
|
) |
|
|
|
args = parser.parse_args() |
|
|
|
if not (1 <= args.left <= 5): |
|
raise ValueError( |
|
f"Left side count must be between 1 and 5, got {args.left}." |
|
) |
|
if not (1 <= args.right <= 5): |
|
raise ValueError( |
|
f"Right side count must be between 1 and 5, got {args.right}." |
|
) |
|
|
|
expected_weights_len = args.left + args.right - 1 |
|
# this ensures `weights` has at least one item |
|
if len(args.weights) != expected_weights_len: |
|
raise ValueError( |
|
f"Expected {expected_weights_len} weights, got {len(args.weights)}." |
|
) |
|
if args.min > args.max: |
|
raise ValueError( |
|
f"Invalid range: min ({args.min}) cannot be greater than max ({args.max})." |
|
) |
|
|
|
allowed_range = range(args.min, args.max + 1) |
|
for w in args.weights: |
|
if w not in allowed_range: |
|
raise ValueError( |
|
f"Weight {w} is outside of the allowed range ({args.min} ~ {args.max})." |
|
) |
|
|
|
return cls( |
|
left=args.left, |
|
right=args.right, |
|
weights=args.weights, |
|
allowed_range=allowed_range, |
|
) |
|
|
|
|
|
@final |
|
class Position(enum.Enum): |
|
LEFT = enum.auto() |
|
RIGHT = enum.auto() |
|
|
|
def __str__(self) -> str: |
|
return self.name.capitalize() |
|
|
|
|
|
@final |
|
@dataclass(frozen=True, kw_only=True, slots=True) |
|
class MissingItem: |
|
weight: int |
|
position: Position |
|
|
|
def __str__(self) -> str: |
|
return f"{self.weight} ({self.position})" |
|
|
|
|
|
@final |
|
@dataclass(frozen=True, kw_only=True, slots=True) |
|
class Output: |
|
left: list[int] |
|
right: list[int] |
|
missing: MissingItem |
|
|
|
def __str__(self) -> str: |
|
return f"Left: {self.left}, Right: {self.right}, Missing: {self.missing}" |
|
|
|
|
|
def find_balanced_combinations( |
|
left_capacity: int, right_capacity: int, weights: list[int], weight_range: range |
|
) -> Iterable[Output]: |
|
results: list[Output] = [] |
|
cache = set() # for deduplication |
|
|
|
generate_state = ( |
|
(lambda left, right: (frozenset(left), frozenset(right))) |
|
if left_capacity != right_capacity |
|
else ( |
|
lambda left, right: ( |
|
( # to work around the restriction of python's lambdas |
|
lambda l, r: ( |
|
(l, r), |
|
(r, l), |
|
) |
|
)(frozenset(left), frozenset(right)) |
|
) |
|
) |
|
) |
|
|
|
add_state = ( |
|
(lambda state: cache.add(state)) |
|
if left_capacity != right_capacity |
|
else (lambda state: cache.add(state[0]) is None and cache.add(state[1])) |
|
) |
|
|
|
in_cache = ( |
|
(lambda state: state in cache) |
|
if left_capacity != right_capacity |
|
else (lambda state: state[0] in cache or state[1] in cache) |
|
) |
|
|
|
def iter_fn(left: list[int], right: list[int], remaining: list[int]): |
|
def base_case(left: list[int], right: list[int]): |
|
""" |
|
Check the possibility of balance. |
|
""" |
|
|
|
# `len(weights) == left_capacity + right_capacity - 1` |
|
# According to the above rule and `is_empty(remaning)` (`remaining` is chosen from `weights`), |
|
# if one side is full, another side must be the one missing a weight. |
|
is_full_side_left = len(left) == left_capacity |
|
full_side, not_full_side = ( |
|
(left, right) if is_full_side_left else (right, left) |
|
) |
|
missing_weight = sum(full_side) - sum(not_full_side) |
|
if missing_weight in weight_range: |
|
pos = Position.RIGHT if is_full_side_left else Position.LEFT |
|
results.append( |
|
Output( |
|
left=left, |
|
right=right, |
|
missing=MissingItem(weight=missing_weight, position=pos), |
|
) |
|
) |
|
|
|
def recursive_case( |
|
new_remaining, *, mutated_side: list[int], immutated_side: list[int] |
|
): |
|
""" |
|
Try placing one of the items from the `remaining` list onto one side. |
|
|
|
`len(remaining) == len(weights) - len(left) - len(right)` ensures that |
|
the remaining list represents the items that still need to be placed on either side. |
|
|
|
In each recursive step, we have one of two options for each item in `remaining`: |
|
either place the item on the left side or on the right side (mutated side), |
|
the other side staying unchanged is the immutated side, |
|
as an item can only be placed on one side. |
|
|
|
This recursive process explores all possible ways the remaining items can be |
|
distributed between the two sides, gradually growing `left` and `right` while shrinking `remaining`. |
|
The total number of combinations results from the sum of the possible configurations |
|
for each of the two sides. |
|
""" |
|
new = mutated_side.copy() |
|
new.append(item) |
|
iter_fn(new, immutated_side, new_remaining) |
|
|
|
state = generate_state(left, right) |
|
if in_cache(state): |
|
return |
|
else: |
|
add_state(state) |
|
|
|
if is_empty(remaining): |
|
base_case(left, right) |
|
else: |
|
item = remaining[0] # safety: the caller ensures it has at least one item |
|
new_remaining = remaining[1:] |
|
if len(left) < left_capacity: |
|
recursive_case(new_remaining, mutated_side=left, immutated_side=right) |
|
if len(right) < right_capacity: |
|
recursive_case(new_remaining, mutated_side=right, immutated_side=left) |
|
|
|
iter_fn([], [], weights) |
|
results.sort(key=lambda x: x.missing.weight) |
|
return results |
|
|
|
|
|
def main(): |
|
cli = Cli.parse() |
|
|
|
for output in find_balanced_combinations( |
|
cli.left, cli.right, cli.weights, cli.allowed_range |
|
): |
|
print(output) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |