Zadanie
W dzisiejszym zadaniu musimy zagrać w grę “szpon”, mamy do dyspozycji dwa przyciski A i B wciśnięcie przycisku A kosztuje nas 3 kredyty a B jeden. Każdy przycisk ma zdefiniowany wektor przesunięcia szpona. Naszym zadaniem jest przesunąć szpona na pole z nagrodą, w jak najmniejszym koście w kredytach.
Wynikiem jest minimalna suma kredytów potrzebna do zdobycia nagród w każdej grze gdzie jest to możliwe.
Przykładowe dane:
Button A: X+94, Y+34
Button B: X+22, Y+67
Prize: X=8400, Y=5400
Button A: X+26, Y+66
Button B: X+67, Y+21
Prize: X=12748, Y=12176
W powyższym przykładzie mamy dwie gry, pierwsza jest niemożliwa do rozwiązania, z kolei w drugiej rozwiązaniem jest wcisnąć przycisk A 80 razy i przycisk B 40 razy, co daje razem 280 kredytów
\(80 \cdot 3 + 40 = 280\)
Rozwiązanie
Zacznijmy od parsowania danych, możemy to w łatwy sposób zrobić za pomocą wyrażeń regularnych:
import re
PATTERN = (
r"Button A: X\+(\d+), Y\+(\d+)\n"
r"Button B: X\+(\d+), Y\+(\d+)\n"
r"Prize: X=(\d+), Y=(\d+)"
)
res = 0
for x in re.finditer(PATTERN, sys.stdin.read()):
xa, ya, xb, yb, xd, yd = map(int, x.groups())
# xa,ya to wektor przesunięcia przycisku A a xb,yb przycisku B
# xd i yd oznaczają koordynaty nagrody (d jak destination)
Co dalej? Moją pierwszą myślą było rozwiązanie tego za pomocą programowania dynamicznego. Spróbujmy przygotować rekurencyjną funkcję która mogłaby znaleźć nam wynik.
@cache
def moves(x, y):
if x == xd and y == yd:
return 0 # nie musimy wydać więcej żetonów jak znajdziemy się na polu nagrody
# jeśli przestrzelimy nagrodę żadna ilość tokenów nie będzie w stanie jej zdobyć
if x > xd or y > yd:
return float("inf")
# wracamy ilość żetonów lepszego ruchu (tego co kosztuje ostatecznie mniej żetonów)
return min(3 + moves(x + xa, y + ya), 1 + moves(x + xb, y + yb))
Gdy uruchomiłem kod dostałem błąd:
RecursionError: maximum recursion depth exceeded
Nie wróży to dobrze ale spróbuję zwiększyć maksymalną głębokość rekurencji, końcowy kod wygląda tak:
import re
import sys
from functools import cache
sys.setrecursionlimit(1500)
PATTERN = (
r"Button A: X\+(\d+), Y\+(\d+)\n"
r"Button B: X\+(\d+), Y\+(\d+)\n"
r"Prize: X=(\d+), Y=(\d+)"
)
res = 0
for x in re.finditer(PATTERN, sys.stdin.read()):
xa, ya, xb, yb, xd, yd = map(int, x.groups())
@cache
def moves(x, y):
if x == xd and y == yd:
return 0
if x > xd or y > yd:
return float("inf")
return min(3 + moves(x + xa, y + ya), 1 + moves(x + xb, y + yb))
m = moves(0, 0)
if m != float("inf"):
res += m
print(res)
O dziwo zwiększenie limitu z domyślnego 1000 na 1500 działa! Program nie jest jakiś super wydajny ale wystarczy to na razie. Zobaczmy część drugą.
Część 2
Okazuje się że w tej części do obu koordynatów nagrody musimy dodać liczbę
10000000000000
💀.
Nawet nie próbuję uruchamiać mojego poprzedniego rozwiązania, ewidentnie potrzebne jest tu bystrzejsze podejście.
Pomysł
Możemy zformułować problem minimizacji kosztu i użyć jakiegoś solwera do programowania
liniowego. Zauważmy że problem można przedstawić w taki sposób:
$$
min~3a + b \\
a x_a + b x_b = x_d \\
a y_a + b y_b = y_d
$$
Nasze jedyne zmienne to a
oraz b
, i muszą one być w wyniku liczbami całkowitymi, co
nieco utrudnia sprawę solverowi.
Implementacja z użyciem scipy.optimize
part2 = 0
for x in re.finditer(PATTERN, sys.stdin.read()):
xa, ya, xb, yb, xd, yd = map(int, x.groups())
xd += 10000000000000
yd += 10000000000000
c = [3, 1]
A = [[xa, xb], [ya, yb]]
b = [xd, yd]
res = linprog(c, A_eq=A, b_eq=b, integrality=[3, 3])
print(res.status)
if res.status == 0:
an, bn = map(int, res.x)
part2 += int(res.fun)
print(part2)
Wreszcie na coś przydały się te studia! Uruchamiamy kod i… Otrzymujemy błędny wynik :(
Okazuje się że solver używa liczb zmiennoprzecinkowych pod spodem i przy tak dużych wartościach, pojawiają się błędy numeryczne, może być ciężko uzyskać rozwiązanie tą drogą.
W tym momencie sprawdziłem jeszcze kilka innych solverów: gurobi
, pulp_cbc
jednak,
każdy z nich miał taki sam problem, uznałem że jest to chyba ślepy zauek i muszę szukać
innego rozwiązania.
Eureka
Po rozpisaniu sobie tego jeszcze kilka razy na papierze stało się jasne coś co powinienem był zobaczyć dużo wcześniej. Gdybym pamiętał coś więcej z algebry liniowej to zauważyłbym że dla każdej gry są dwie opcje, albo mamy jedno rozwiązanie albo nieskończenie wiele. Układ równań (który z resztą napisałem na górze), można bardzo łatwo rozwiązać licząc, odwrotność macierze 2x2, jako że jestem leniwy i nawet tego nie chce mi się robić użyję numpy żeby mi rozwiązał cały układ równań.
import re
import sys
import numpy as np
PATTERN = (
r"Button A: X\+(\d+), Y\+(\d+)\n"
r"Button B: X\+(\d+), Y\+(\d+)\n"
r"Prize: X=(\d+), Y=(\d+)"
)
results = [0, 0]
TO_ADD = [0, 10000000000000]
for x in re.finditer(PATTERN, sys.stdin.read()):
for i in range(2):
xa, ya, xb, yb, xd, yd = map(int, x.groups())
xd += TO_ADD[i]
yd += TO_ADD[i]
A = np.array([[xa, xb], [ya, yb]], dtype=np.int64)
b = np.array([xd, yd], dtype=np.int64)
resa, resb = np.round(np.linalg.solve(A, b))
# sprawdźmy tylko czy po zaokrągleniu wynik jest poprawny
if resa * xa + resb * xb == xd and resa * ya + resb * yb == yd:
results[i] += 3 * resa + resb
print(int(results[0]))
print(int(results[1]))
W ten sposób otrzymujemy kod który rozwiązuje obie części prawie natychmiastowo.
Podsumowanie
Zadanie było bardzo ciekawe, na początku za bardzo myślałem jak programista a za mało jak matematyk. Nauczyłem się też czegoś o programowaniu liniowym, nie zawsze jest ono najlepszym rozwiązaniem, otrzymywanie dokładnych wyników nie jest takie proste kiedy nakładamy ograniczenia że zmienne muszą być liczbami całkowitymi.