~dricottone/riddler-230106

0216807bec87546055061031b3ebb14870072800 — Dominic Ricottone 1 year, 10 months ago 7c0a2b1
Solution to classic riddler
7 files changed, 265 insertions(+), 0 deletions(-)

A .gitignore
A Makefile
A classic/README.md
A classic/__init__.py
A classic/__main__.py
A classic/main.py
A classic/test.py
A .gitignore => .gitignore +2 -0
@@ 0,0 1,2 @@
**/__pycache__/


A Makefile => Makefile +10 -0
@@ 0,0 1,10 @@
express:
	./main.py

classic:
	python -m classic

clean:
	rm -rf classic/__pycache__/

.PHONY: clean express classic

A classic/README.md => classic/README.md +65 -0
@@ 0,0 1,65 @@
# Class Riddler

## The problem

```
$ python -m classic help
The astronomers of Planet Xiddler are back in action! Unfortunately,
this time they have used their telescopes to spot an armada of 
hostile alien warships on a direct course for Xiddler. The armada
will be arriving in exactly 100 days. (Recall that, like Earth,
there are 24 hours in a Xiddler day.)

Fortunately, Xiddler’s engineers have just completed construction of
the planet’s first assembler, which is capable of producing any
object. An assembler can be used to build a space fighter to defend
the planet, which takes one hour to produce. An assembler can also
be used to build another assembler (which, in turn, can build other
space fighters or assemblers). However, building an assembler is
more time-consuming, requiring six whole days. Also, you cannot use
multiple assemblers to build one space fighter or assembler in a
shorter period of time.

What is the greatest number of space fighters the Xiddlerian fleet
can have when the alien armada arrives?
```

## My assumption

It is always preferable to front-load assembler production.
While an assembler could be produced at any point in time, it is always
preferable to gain the multiplicative effect earlier.
If an assembler is set to produce fighters, it should never produce
assemblers again, because it would have been more efficient to produce that
assembler earlier.
Furthermore, if an assembler to set to produce fighters, in the next cycle
all assemblers will be set to produce fighters.
Essentially, the problem is *when to switch production from assemblers to
fighters*.
This also reduces the complexity of the problem. Instead of *hour-by-hour*
choices, the strategies will be formed by *blocks of 6 days* (the time
required to produce an assembler).
As an example, there are two strategies to produce 4 assemblers.
 1. One assembler spends (18 * 24 =) 432 hours producing assemblers.
    The second and third assemblers will, upon their independent creation at
    the 144 and 288 hours marks, begin producing fighters.
    That is 288 and 144 fighters respectively, for a sum of 432.
    The yield is 4 assemblers and 432 fighters in 432 hours.
 2. The first assembler spends (12 * 24 =) 288 hours producing assemblers
    and, upon creation at the 144 hour mark, the second assembler spends 144
    hours producing assemblers.
    The yield is 4 assemblers and 0 fighters in 288 hours.
However, strategy #2 is trivially superior to #1. In the unconsumed 144 hours
4 assemblers will produce (4 * 6 * 24 =) 576 fighters.

## My solution

```
$ python -m classic
Built 7864320 fighters with 32768 assemblers
 * 0 fighters were built concurrent to the assemblers
 * 7864320 fighters were built in the unconsumed 240 hours
```

See also `python -m classic test` and `python -m classic dump`.


A classic/__init__.py => classic/__init__.py +0 -0
A classic/__main__.py => classic/__main__.py +33 -0
@@ 0,0 1,33 @@
#!/usr/bin/env python3

import sys

import classic.main as main
import classic.test as test

if len(sys.argv) > 1 and sys.argv[1] == "test":
    test.main()
elif len(sys.argv) > 1 and sys.argv[1] == "help":
    print(
        "The astronomers of Planet Xiddler are back in action! Unfortunately,\n"
        "this time they have used their telescopes to spot an armada of \n"
        "hostile alien warships on a direct course for Xiddler. The armada\n"
        "will be arriving in exactly 100 days. (Recall that, like Earth,\n"
        "there are 24 hours in a Xiddler day.)\n\n"
        "Fortunately, Xiddler’s engineers have just completed construction of\n"
        "the planet’s first assembler, which is capable of producing any\n"
        "object. An assembler can be used to build a space fighter to defend\n"
        "the planet, which takes one hour to produce. An assembler can also\n"
        "be used to build another assembler (which, in turn, can build other\n"
        "space fighters or assemblers). However, building an assembler is\n"
        "more time-consuming, requiring six whole days. Also, you cannot use\n"
        "multiple assemblers to build one space fighter or assembler in a\n"
        "shorter period of time.\n\n"
        "What is the greatest number of space fighters the Xiddlerian fleet\n"
        "can have when the alien armada arrives?"
    )
elif len(sys.argv) > 1 and sys.argv[1] == "dump":
    main.dump()
else:
    main.main()


A classic/main.py => classic/main.py +85 -0
@@ 0,0 1,85 @@
#!/usr/bin/env python3

from typing import Generator, NamedTuple

HOURS_TO_PRODUCE_ASSEMBLER = 6 * 24  # 6 days * 24 hours/day = 144 hours

# equivalent constant, but this name is more legible in certain contexts
FIGHTERS_PRODUCED_IN_ONE_UNIT = HOURS_TO_PRODUCE_ASSEMBLER

class Strategy(NamedTuple):
    available_hours: int
    produced_fighters: int

class MaxStrategy(NamedTuple):
    available_hours: int
    produced_fighters: int
    produced_assemblers: int

def assembler_schedule(available_hours) -> dict[int, Strategy]:
    """A schedule of quantities of assemblers that are possible within
    `available_hours`.

    Index the schedule by a target quantity of assemblers. This provides the
    optimal strategy to produce that quantity. A strategy is represented by a
    tuple of available hours and number of fighters produced incidentally.

    The first assembler is given; there is no strategy for 0 assemblers.

    If there is no strategy to produce a quantity given `available_hours`, the
    schedule will not be indexable by that quantity.
    """
    # initialize the 'do nothing' strategy
    schedule = { 1: Strategy(available_hours, 0) }

    while available_hours >= HOURS_TO_PRODUCE_ASSEMBLER:
        # consume 6 days
        available_hours -= HOURS_TO_PRODUCE_ASSEMBLER

        # find the maximal number of assemblers at this hour
        assemblers = max(schedule.keys())

        # with N assemblers, for n in 1..N, n will produce assemblers
        for i in range(1, assemblers + 1):
            produced_assemblers = assemblers + i
            produced_fighters = (assemblers - i) * HOURS_TO_PRODUCE_ASSEMBLER
            schedule[produced_assemblers] = Strategy(available_hours, produced_fighters)

    return schedule

def evaluate_fighters(strategy: Strategy, assemblers: int) -> int:
    """Helper function to calculate final production of fighters."""
    return strategy.produced_fighters + (assemblers * strategy.available_hours)

def evaluate_schedule(schedule: dict[int, Strategy]) -> Generator[tuple[int, int, Strategy], None, None]:
    """For each strategy, determine how many fighters would be produced by
    setting all assemblers to produce fighters for the availabe hours.
    """
    for assemblers in schedule.keys():
        fighters = evaluate_fighters(schedule[assemblers], assemblers)
        yield (fighters, assemblers, schedule[assemblers])

def max_schedule(schedule: dict[int, Strategy]) -> MaxStrategy:
    """Find the maximal strategy within a schedule.

    A tuple of hours remaining, fighters proeduced, and assemblers produced.
    """
    # initialize with the 'do nothing' strategy
    return max(tuple(evaluate_schedule(schedule)), key=lambda x: x[0])

def dump():
    schedule = assembler_schedule(100 * 24)
    print("FinalFighters,ProducedAssemblers,ProducedFighters,UnconsumedHours")
    for fighters, assemblers, strategy in evaluate_schedule(schedule):
        print(f"{fighters}\t{assemblers}\t{strategy.produced_fighters}\t{strategy.available_hours}")

def main():
    schedule = assembler_schedule(100 * 24)
    maximum = max_schedule(schedule)
    print(f"Built {maximum[0]} fighters with {maximum[1]} assemblers")
    print(f" * {maximum[2].produced_fighters} fighters were built concurrent to the assemblers")
    print(f" * {maximum[0] - maximum[2].produced_fighters} fighters were built in the unconsumed {maximum[2].available_hours} hours")

if __name__ == "__main__":
    main()


A classic/test.py => classic/test.py +70 -0
@@ 0,0 1,70 @@
#!/usr/bin/env python3

from .main import *

def _test_schedule(generated_schedule, literal_schedule):
    """Helper function for `test_schedules`."""
    diff = set(generated_schedule.keys()) - set(literal_schedule.keys())
    try:
        assert len(diff) == 0
    except:
        print(f"Generated schedule contains an impossible quantity of assemblers: {diff}")

    for n in sorted(literal_schedule.keys()):
        try:
            assert generated_schedule[n][0] == literal_schedule[n][0]
            assert generated_schedule[n][1] == literal_schedule[n][1]
        except:
            print(f"Generated schedule is wrong for {n} assemblers")
            print(f"Is:        {generated_schedule[n]}")
            print(f"Should be: {literal_schedule[n]}")

def test_schedules():
    # check that only one strategy exists for schedules with too few hours
    for i in range(HOURS_TO_PRODUCE_ASSEMBLER):
        res = assembler_schedule(i)
        _test_schedule(res, { 1: (i, 0) })

    # check that complex strategies can be formed
    res = assembler_schedule(HOURS_TO_PRODUCE_ASSEMBLER)
    _test_schedule(res, {
        1: (HOURS_TO_PRODUCE_ASSEMBLER, 0),
        2: (0, 0),
    })

    # check handling of remaining hours without fighter production potential
    res = assembler_schedule(HOURS_TO_PRODUCE_ASSEMBLER + 1)
    _test_schedule(res, {
        1: (HOURS_TO_PRODUCE_ASSEMBLER + 1, 0),
        2: (1, 0),
    })

    # check handling of remaining hours with fighter production potential
    res = assembler_schedule(HOURS_TO_PRODUCE_ASSEMBLER * 2)
    _test_schedule(res, {
        1: (HOURS_TO_PRODUCE_ASSEMBLER * 2, 0),
        2: (HOURS_TO_PRODUCE_ASSEMBLER, 0),
        3: (0, FIGHTERS_PRODUCED_IN_ONE_UNIT),
        4: (0, 0),
    })

    # check that non-trivial ranges are bounded correctly
    res = assembler_schedule(HOURS_TO_PRODUCE_ASSEMBLER * 3)
    _test_schedule(res, {
        1: (HOURS_TO_PRODUCE_ASSEMBLER * 3, 0),
        2: (HOURS_TO_PRODUCE_ASSEMBLER * 2, 0),
        3: (HOURS_TO_PRODUCE_ASSEMBLER, FIGHTERS_PRODUCED_IN_ONE_UNIT),
        4: (HOURS_TO_PRODUCE_ASSEMBLER, 0),
        5: (0, FIGHTERS_PRODUCED_IN_ONE_UNIT*3),
        6: (0, FIGHTERS_PRODUCED_IN_ONE_UNIT*2),
        7: (0, FIGHTERS_PRODUCED_IN_ONE_UNIT),
        8: (0, 0),
    })

def main():
    print("Running test suite...")
    test_schedules()

if __name__ == "__main__":
    main()