Zadanie

Dzisiaj na wejściu otrzymujemy listę stringów które możemy wykorzystać do tworzenia innych większch stringów za pomocą konkatenacji.

Dostajemy też listę długich stringów które będziemy próbować zbudować.

r, wr, b, g, bwu, rb, gb, br

brwrr
bggr
gbbr
rrbgbr
ubwu
bwurrg
brgr
bbrgwb

Naszym zadaniem jest policzyć ile długich ciągów znaków jesteśmy w stanie stworzyć za pomocą fragmentów z pierwszej linijki.

Przykłady:

  • brwrr - br + wr + r
  • bggr - b + g + g + r
  • ubwu - nie da się!

Rozwiązanie

W faktycznym inpucie naszego zadania stringi budulcowe są sporo dłuższe i jest ich więcej, jedną z fajnych struktur do przechowywania takiego typu danych jest trie, pozwala ono bowiem na weryfikowanie w wydajny sposób czy dany prefix występuje w jakim kolwiek ze słow z dużego słownika.

Trie działa w ten sposób że każdy poziom drzewka zawiera odnośniki do kolejnych liter które mogą wystąpić dalej, ewentualnie może też mieć znak specjalny, w moim przypadku znak $, który oznacza koniec słowa.

W funkcji parsującej, dokonałem też tworzenia drzewka trie, jest ono w rzeczywistości po prostu wielopoziomową mapą, w której kluczami są kolejne litery (lub $), a wartościami mapy wyższego poziomu.

def parse(data: str):
    towels, desired = data.split("\n\n")
    towels = towels.split(", ")
    desired = desired.split("\n")

    trie = dict()
    for towel in towels:
        cur = trie
        for c in towel:
            if c not in cur:
                cur[c] = dict()
            cur = cur[c]
        cur[END] = None

    return trie, desired

Funkcja szukająca została zaimplementowana za pomocą rekurencji, żeby zwiększyć nieco przejrzystość kodu:

if __name__ == "__main__":
    trie, tracked, desired = parse(sys.stdin.read().strip())

    def backtracking(cur, string):
        cur = tracked[cur_id]
        if not string:
            if END in cur:
                return True
            return False

        c = string[0]
        res = False

        # jeśli możemy zakończyć słowo, próbujemy znaleźć wzorzec od początku
        if END in cur and c in trie:
            res = backtracking(trie[c], string[1:]) or res

        # ale też jeśli możem próbujemy kontynuować aktualnie zaczęty wzorzec
        if c in cur:
            res = backtracking(cur[c], string[1:]) or res

        return res

    count = 0
    for string in desired:
        res = backtracking(trie, string)
        if res:
            count += 1

    print("Part 1: ", count)

Niestety program freezuje się już na pierwszym stringu, problem jest taki że duplikujemy wielokrotnie naszą pracę, przydałoby się zaimplementować jakąś memoizację poprzednich wyników.

Najprościej byłoby użyć po prostu functools.cache tak samo jak zrobiliśmy to w dniu 11, jednak problem jest taki że ten dekorator umie cachować tylko wartości które są hashowalne, niestety nasze obikty dict, nie są hashowalnie więc musimy zaimplementować własny cache.

Jako funkcję hashującą możemy po prostu użyć id obiektu, ponieważ jest on unikalny, a jako cache możemy użyć zwykłego słownika:

def backtracking(cur, string):
    cache_id = (id(cur), string)
    if cache_id in cache:
        return cache[cache_id]
    ...
    cache[cache_id] = res
    return res

Po tej prostej modyfikacji program działa praktycznie natychmiastowo, a wynik jest poprawny.

Część 2

Żeby zdobyć drugą gwiazdkę nie wystarczy tylko policzyć ile stringów możemy zbudować, ale też na ile różnych sposobów możemy to zrobić. Na szczęście nasz kod jest już dobrze przystosowany do takiego zadania i niewiele trzeba zmienić żeby to osiągnąć.

Jako że obie części zadania są do siebie bardzo podobne, dodaje tutaj kod rozwiązujący je na raz:

import sys

END = "$"


def parse(data: str):
    towels, desired = data.split("\n\n")
    towels = towels.split(", ")
    desired = desired.split("\n")

    trie = dict()
    for towel in towels:
        cur = trie
        for c in towel:
            if c not in cur:
                cur[c] = dict()
            cur = cur[c]
        cur[END] = None

    return trie, desired


if __name__ == "__main__":
    trie, desired = parse(sys.stdin.read().strip())

    cache = dict()

    def backtracking(cur, string):
        cache_id = (id(cur), string)
        if cache_id in cache:
            return cache[cache_id]

        if not string:
            if END in cur:
                return 1
            return 0

        c = string[0]
        res = 0

        if END in cur and c in trie:
            res += backtracking(trie[c], string[1:])

        if c in cur:
            res += backtracking(cur[c], string[1:])

        cache[cache_id] = res
        return res

    part1 = 0
    part2 = 0
    for string in desired:
        res = backtracking(trie, string)
        if res:
            part1 += 1
            part2 += res

    print("Part 1: ", part1)
    print("Part 2: ", part2)

Podsumowanie

Dzisiaj trafiło się kolejne przyjemne zadanie, które było idealnym przykładem na przechowywanie danych w trie, a także na zastosowanie memoizacji. Kod z pierwszego zadania był na tyle dobry że napisanie drugiej części zajeło mi 2 minuty.