Zadanie

Dopravný podnik vám prináša nový revolučný spôsob platenia za dopravu!!!

Kúpte si jeden z našich lístkov a jazdite už navždy1 bezplatne!!!

S \(k\)-zastávkovým lístkom sa môžete previezť, jednu, dve, dokonca až \(k\) zastávok!!!

Kúpte si lístok už dnes a v cene dostanete aj vyhrievaný vankúšik, ktorý vám spríjemní čas strávený čakaním na zastávkach!!!

Reklama ťa zaujala množstvom výkričníkov. Vieš, že v nových električkách sú nové označovače lístkov so širšími otvormi a bolo treba upraviť formát lístkov. Okrem zmeny rozmeru však dopravný podnik zmenil aj typy lístkov a vymyslel aj novú, neodolateľnú marketingovú stratégiu, ktorej svedkom si, bohužiaľ, aj ty.

Keďže nemáš vodičák, používaš hromadnú dopravu. Každý deň sa cestou do školy prevezieš \(n\) zastávok. Prvá zastávka je pri tvojom dome, posledná pri škole. Doteraz ti vyhovovala ročná električenka, ale tá ti práve vypršala a neostáva ti nič iné než si vybrať nejaký revolučný \(k\)-zastávkový lístok.

S týmto lístkom sa môžeš previezť najviac \(k\) zastávok, no potom musíš vystúpiť a počkať na ďalší spoj2. Na zastávke pri dome nikdy nečakáš, lebo vieš naspamäť časy, kedy ti chodia autobusy. Na poslednej zastávke tiež nemusíš čakať a môžeš sa hneď radostne rozbehnúť do školy3.

S vyhrievaným vankúšikom sa premôžeš a na zastávkach dokopy počkáš aj \(t\) minút. Občas mávaš pri cestovaní spoločnosť, takže si hovoríš, že čakanie zvládneš. Preto si chceš kúpiť najlacnejší lístok, s ktorým budeš na ceste do školy čakať najviac \(t\) minút. Šetríš si totiž na tú vec, ktorú si chceš už dlho kúpiť bez vedomia rodičov.

Úloha

Trasa má dĺžku \(n\) – postupne prechádza zastávkami \(1, 2, \dots n\). Pre každú zastávku \(2\)\(n-1\) dostanete počet minút, ktorý sa na danej zastávke čaká na ďalší spoj. Tiež dostanete ceny \(k\)-zastávkových lístkov pre \(k = 1, 2, 3, \dots , n-1\). Z ľubovoľnej zastávky s číslom \(z\) sa dá s \(k\)-zastávkovým lístkom dostať na jednu jazdu na zastávky \(z-k\)\(z+k\).

Celkovo si ochotný čakať najviac \(t\) minút a chceš nájsť najlacnejší lístok, s ktorým sa dostaneš zo zastávky \(1\) na zastávku \(n\) s prestupovým čakaním najviac \(t\).

Formát vstupu

Na prvom riadku vstupu sa nachádzajú dve čísla \(n\) a \(t\) (\(2 \leq n \leq 100\, 000, 0 \leq t \leq 10^9\)) – dĺžka trasy a celkový čas, ktorý môžeš čakať na zastávkach. Na druhom riadku bude \(n-1\) čísel \(c_k\) (\(0 \leq c_k \leq 10^9\)) – cena \(k\)-zastávkového lístka pre \(k = 1, 2, 3, \dots , n-1\). Na treťom riadku vstupu je \(n-2\) čísel \(t_i\) (\(0 \leq t_i \leq 10^9\)) – časy čakania na zastávkach \(2\)\(n-1\).

Formát výstupu

Vypíšte jedno číslo – cenu najlacnejšieho lístka, s ktorým budete čakať na zastávkach dokopy najviac \(t\) minút.

Príklad

Input:

6 9
1 42 9 2 54
5 6 5 4

Output:

2

S 1- alebo 2-zastávkovým lístkom by sme čakali pridlho, s 3-zastávkovým lístkom stačí čakať 5 minút, no lacnejší je 4-zastávkový, tak zoberieme ten.


  1. Platí do ukončenia akcie.

  2. Začínaš si do hĺbky uvedomovať, aký výborný nápad sú \(k\)-zastávkové lístky.

  3. Ale komu sa chce behať, že?

V tomto vzoráku si ukážeme viacero všeobecných techník na riešenie úloh a optimalizovanie programov. Ak sa ich naučíte, pomôžu vám vyriešiť veľa rôznych problémov.

Najprv si ukážeme bruteforce riešenie. Ďalej bruteforce zoptimalizujeme pomocou memoizácie, prevedieme rekurziu na dynamické programovanie a ukážeme si, kedy použiť binárne vyhľadávanie. Takto získame riešenie so zložitosťou \(O(n^2 \log n)\) hodné \(5/8\) bodov s veľmi malou námahou.

Na záver ešte trošku porozmýšľame a dopracujeme sa k \(O(n \log n)\) riešeniu, v ktorom zoptimalizujeme dynamické programovanie pomocou deque.

Riešenie hrubou silou

Ak neviete, kde začať alebo ak ste už unavení z úvodného uvažovania, vždy je dobré začať s naprogramovaním riešenia hrubou silou – najjednoduchšie naprogramovateľné, funkčné riešenie, čo nám napadne – väčšinou pomerne neefektívne. V našej úlohe takéto riešenie vyskúša pre každý lístok, či sa s ním dá cieľ dosiahnuť s čakaním menším ako \(t\) a z vyhovujúcich lístkov vyberie ten najlacnejší.

Kostra programu by mohla vyzerať nasledovne:

n, t = map(int, input().split())
# Na začiatok polí pridáme nuly, aby sme mali zastávky očíslované od 1
cena  = [0, ]  + list(map(int, input().split()))
cakaj = [0, 0] + list(map(int, input().split()))

# Nejak spočítame, čí sa dá dostať z 1 na n s k-lístkom
def dasa(k):
   ...

odpoved = cena[-1]

for k in range(1, n):
    if cena[k] < odpoved and dasa(k):
        odpoved = cena[k]

print(odpoved)

Uvedomíme si, že zo zastávky s číslom \(x\) sa nám nikdy neoplatí vrátiť späť (ísť na zastávku s menším číslom) a teda sa budeme hýbať len na zastávky \(x+1, x+2, \dots, x+k\).

To, či s \(k\)-lístkom vieme dosiahnuť cieľ, vieme zistiť jednoduchou rekurziou. Ak sme na nejakej zastávke, to či sa dá odtiaľto dostať do cieľa zistíme tak, že sa skúsime pohnúť na ďalšiu a spýtame sa, či sa odtiaľto vieme dostať do cieľa v časovom limite. Musíme ale vyskúšať všetky susedné zastávky.

Funkcia \(f\) ako parameter dostane číslo zastávky \(x\), odkiaľ sa chceme dostať do cieľa a zvyšný čas, ktorý máme na čakanie \(t\). Vyskúša všetkých \(k\) pohybov na zastávky \(x+1, \dots, x+k\) – zavolá \(f(x+1, t-t_x), \dots, f(x+k, t-t_x)\) a vráti True ak sa dá do cieľa dostať z niektorej zo zastávok \(x+1, \dots, x+k\). Inak fukcia vráti False.

def dasa(k):
    def f(x, t):
        if t < 0:
            return False
        if x >= n:
            return True
        for dalsi in range(x+1, x+k+1):
            if f(dalsi, t-cakaj[x]):
                return True
        return False

    # Z 1 na n sa dá dostať k-lístkom práve vtedy, ak
    return f(1, t) == True

Rekurzia vyskúša každú cestu zo zastávky 1 do \(n\) najviac raz a ciest je najviac toľko, koľko je všetkých podmnožín čísel \(1, 2, \dots, n\). Časovú zložitosť tohto riešenia vieme zhora odhadnúť ako \(O(n^3 \cdot 2^n)\): Pre jeden z \(n\) lístkov over \(2^n\) ciest. Každá cesta má najviac \(n\) zastávok (je potrebných najviac \(n\) volaní) a každé volanie zbehne v čase \(O(k)\).

Nepočítajme nič dvakrát 1 - Memoizácia

V predošlom riešení sa môže stať, že rekurzívnu funkciu voláme viackrát s tými istými parametrami. Počítame teda viackrát to isté. Tomuto sa môžeme vyhnúť, ak si budeme spočítané výsledky rekurzie pamätať. Ak potom zavoláme funkciu s tými istými parametrami, namiesto výpočtu sa len pozrieme do pamäte. Vypočítané výsledky si môžeme pamätať vo viacrozmernom poli (každý rozmer zodpovedá jednému parametru), alebo v mape, kde je kľúčom \(k\)-tica parametrov.

def dasa(k):
    memo = {}

    def f(x, t):
        if t < 0:
            return False
        if x >= n:
            return True
        if (x, t) in memo:
            return memo[(x, t)]
        
        for dalsi in range(x+1, x+k+1):
            if f(dalsi, t-cakaj[x]):
                memo[(x, t)] = True
                return True
        
        memo[(x, t)] = False
        return False
   	
    return f(1, t) == True

Pre každý z \(n\) lístkov je počet rôznych volaní funkcie (rôznych dvojíc parametrov (zastávka, čas)) najviac \(n \cdot t\). Každé volanie trvá najviac \(O(k)\) času. Pre jeden lístok zistíme \(dasa(k)\) v čase \(O(n \cdot t \cdot k)\) a teda celý náš program bude mať čas behu zhora ohraničený \(O(n^3 \cdot t)\).

Použitie memoizácie si môžete pozrieť aj v riešení úlohy z minulej série Optimálna šifrovačka.

Nepočítajme nič dvakrát 2

Napriek tomu, že pre každú dvojicu parametrov voláme rekurziu len raz, ak máme spočítaný výsledok pre nejaké parametre, môžeme už vedieť výsledok pre iné: Ak vieme, že cestu stihneme so zvyšným časom \(47\), cestu určite stihneme aj s hocijakým väčším zvyšným časom. Stačí nám teda zapamätať si ten najmenší čas, s ktorým sa vieme dostať do cieľa z \(x\). Rôznych volaní funkcie \(f\) tak už nebude \(n \cdot t\) ale len \(n\).

Namiesto toho, aby sme pre dvojicu (zastávka \(x\), čas \(t\)) počítali, len či sa dá dosiahnuť cieľ (true/false), budeme pre zastávku \(x\) počítať, aký je najmenší čas potrebný na dosiahnutie cieľa, keď začneme v \(x\). Toto opäť vieme rátať podobnou rekurziou ako vyššie – pozrieme sa na najbližšie zastávky, pre ne rekurzívne spočítame hodnoty \(f(x+1), f(x+2), \dots, f(x+k)\) a vyberieme si, kam sa chceme pohnúť – na zastávku, odkiaľ sa najskôr dostaneme do cieľa. Teda \(f(x) = \min\{f(x+1), f(x+2), \dots, f(x+k)\} + t_x\) a \(f(n) = 0\)

def dasa(k):
    memo = {}

    def f(x):
        if x >= n:
            return 0
        if x in memo:
            return memo[x]
        memo[x] = min( f(dalsi) for dalsi in range(x+1, x+k+1) ) + cakaj[x]
        return memo[x]
        
    # Z 1 na n sa dá dostať k-lístkom práve vtedy, ak
    return f(1) <= t

Jedno volanie stále zbehne v čase \(O(k)\), ale rôznych vstupov funkcie \(f\) je len \(O(n)\). Výpočet pre jedno \(k\) teda trvá \(O(n \cdot k)\), a vďaka tomu bude celková zložitosť programu \(O(n^3)\).

Nepočítajme nič zbytočne - Binárne vyhľadávanie

Skúsme teraz zoptimalizovať inú časť programu – skúšanie všetkých lístkov. Pri tejto úlohe je dobré si uvedomiť, že rôzne ceny lístkov sú tu hlavne na zmätenie. To podstatné je zistiť minimálne \(k\) také, že vieme trasu prejsť s čakaním menším ako \(t\). Ak máme minimálne \(k\), na výpočet výsledku stačí nájsť najmenšiu cenu z \(c_k, c_{k+1}, \dots, c_n\).

Ak už totiž vieme, že sa dá trasa prejsť s \(k\)-lístkom, určite sa bude dať prejsť aj s \(k+1\)-lístkom a s hocijakým lístkom s väčším dosahom. Ak sa trasa nedá prejsť s \(k\)-lístkom, nebude sa dať prejsť so žiadnym lístkom s menším dosahom. Výsledky funkcie \(dasa(k)\) budú teda s rastúcim \(k\) najskôr len False a od istej hranice budú len True.

Vieme sa teda pozrieť najskôr na \(dasa(n/2)\), ak je to False, \(k > n/2\), inak \(k \leq n/2\). Vylúčili sme polovicu možností a pokračujeme pýtaním sa na \(dasa(n/4)\) alebo \(dasa(3n/4)\) … Po približne \(\log_2 n\) krokoch nám zostane jediná možnosť.

Binárne vyhľadávanie sa dá využiť vždy, keď hľadáme hodnotu v monotónnej postupnosti (nerastúcej alebo neklesajúcej). Vieme hľadať najľavejší výskyt aj najpravejší výskyt hodnoty. Špeciálnym prípadom sú funkcie ako \(dasa()\), kde sú hodnoty False/True, alebo 0/1.

def najmensie_k():
    l = 0      # este sa to neda stihnut
    r = n-1    # s takymto dosahom sa to stiha
    
    while r - l > 1:
        piv = (l + r) // 2    # prvok v strede intervalu (l, r>
        if not dasa(piv):
            l = piv
        else:
            r = piv
    return r

print(min(cena[k] for k in range(najmensie_k(), n)))

Chuťovka: Pre vyhnutie sa chybám v kóde binárneho vyhľadávania môže byť vhodné použiť binárne vyhľadávanie štandardnej knižnice jazyka. V pythone sa dá dokonca zneužiť aj pre náš účel.

from bisect import bisect_left

def najmensie_k():
    class vyhladavatko:
        def __getitem__(self, key):
            return dasa(key)

    return bisect_left(vyhladavatko(), True, 1, n)

V našej úlohe teda \((\log n)\)-krát zavoláme funkciu \(dasa()\) a dosiahneme čas \(O(n^2 \log n)\).

Toto riešenie nám umožní dosiahnuť \(5/8\) bodov (riešenie v C++ určite, s týmto pythonovským kódom 4) a ani sme nemuseli veľa rozmýšľať (pokiaľ poznáte tieto všeobecné techniky, všetky predošlé úvahy dokopy vám zaberú len niekoľko minút).

Dynamické programovanie

Vráťme sa naspäť k optimalizovaniu našej rekurzívnej funkcie \(f(x)\) – najmenší potrebný čas na dosiahnutie cieľa zo zastávky \(x\). Na vypočítanie \(f(x)\) sme potrebovali mať spočítané hodnoty \(f(x+1), f(x+2), \dots, f(x+k)\), a na ich spočítanie sme potrebovali mať vypočítané hodnoty až po \(f(x + k + k)\), atď. Od začiatku ale vieme, že \(f(n) = 0\). Na spočítanie \(f(n-1)\) potrebujeme len \(f(n)\), na spočítanie \(f(n-2)\) len \(f(n-1), f(n)\), atď.

Hodnoty \(f(x)\) teda nemusíme počítať v rekurzívnej funkcii, ale stačí použiť jednoduché cykly. Vytvoríme si pole \(f[]\), kde \(f[x]\) označuje minimálny čas potrebný na dosiahnutie cieľa zo zastávky \(x\). \(f[n] = 0\) Pre znižujúce sa \(x\) postupne spočítame \(f[x] = \min\{f[x+1], f[x+2], \dots, f[x+k]\} + t_x\), teda presne to isté, čo počítala rekurzívna \(f\).

Toto spôsobí len konštantné zrýchlenie programu, ale povedie nás to k optimálnemu riešeniu. V niektorých prípadoch dokážeme pomocou dynamického programovania aj znížiť pamäťovú zložitosť programu.

def dasa(k):
    # pole velkosti n+k je praktickejsie, nemusime specialne osetrovat pripad: dalsi > n
    f = [0] * (n+k)
    for x in range(n-1, 0, -1):
        f[x] = min( f[dalsi] for dalsi in range(x+1, x+k+1)) + cakaj[x]
    return f[1] <= t

Nepočítajme nič dvakrát, časť tretia

Ešte stále sa opakujeme? Áno: \[ f[x] = \min\{f[x+1], f[x+2], f[x+3], \dots, f[x+k]\} + t_x\] \[ f[x+1] = \min\{f[x+2], f[x+3], \dots, f[x+k], f[x+k+1]\} + t_{x+1}\]

Ak už vieme minimum pre \(x+1, \dots, x+k\), minimum pre \(x+2, \dots, x+k+1\) by sme mohli vedieť spočítať rýchlejšie. Totiž, ak je minimom jedno z čísel \(f[x+2], f[x+3], \dots, f[x+k]\), tak \[ \min\{f[x+2], \dots, f[x+k+1]\} = \min\{\min\{f[x+1],\dots, f[x+k]\}, f[x+k+1]\}\] a teda na spočítanie nového minima by nám stačilo jedno porovnanie (s \(f[x+k+1]\)).

Jediný nepríjemný prípad je, ak je \(\min\{f[x+1], \dots, f[x+k]\} = f[x+1]\). Vtedy by sme potrebovali vedieť, aký bol druhý najmenší prvok z \(f[x+1] \dots, f[x+k]\)

Budeme postupovať sprava doľava, tak ako doteraz a počítať hodnoty \(f[x]\) – minimálny čas potrebný na presun z \(x\) do cieľa aj s časom čakania \(t_x\). Chceli by sme si pamätať niekoľko zastávok, ktoré sa môžu niekedy stať minimom a vedieť tieto hodnoty aktualizovať pre \(f[x-1]\).

Budeme si udržovať pozície zastávok, na ktorých je závislá hodnota \(f[x]\), teda niektoré z \(x+1, \dots, x+k\) a pamätať si ich budeme usporiadané podľa ich hodnôt \(f\) v štruktúre deque – obojsmerný spájaný zoznam. Vieme z neho vyberať a vkladať do neho prvky z oboch strán. Nech sú naľavo tie zastávky, ktoré majú najmenšie \(f\), teda tie, z ktorých sa najrýchlejšie dostaneme do cieľa.

Na začiatku algoritmu sme na zastávke \(n-1\), v deque si pamätáme zastávku \(n, f[n] = 0\).

Novú hodnotu \(f[x]\) spočítame vždy jednoducho, ako minimálny čas, potrebný na cestu do cieľa z jednej z k zastávok napravo od \(x\) a čas čakania na \(x\), čo je \(f[deque.left] + t_x\).

Po výpočte \(f[x]\) musíme deque aktualizovať. Chceme do nej niekam vložiť zastávku \(x\) (lebo hodnota \(f[x]\) bude potrebná pre zastávky naľavo od \(x\)). Keďže žiadnu zastávku, ktorú máme v deque nebudeme vo výpočtoch používať dlhšie ako \(x\), môžeme z deque vyhodiť všetky zastávky, ktoré potrebujú väčší čas na dosiahnutie cieľa – žiadna z týchto zastávok by sa už totiž nemohla stať novým minimom. Takisto zastávky, ktoré sú ďalej ako \(k\) od \(x\) už nikdy nepoužijeme a potrebujeme ich odstrániť z konca aj zo začiatku deque.

Vyhodíme tak všetky nepotrebné zastávky z konca a zo začiatku a nakoniec vložíme \(x\).

from collections import deque

def dasa(k):
    f = [0] * (n+1)
    mozne_mini = deque([n])

    for x in range(n-1, 0, -1):
        f[x] = f[mozne_mini[0]] + cakaj[x]

        # odstranime zastavky z konca, ktore nikdy nebudu minimom
        while len(mozne_mini) > 0 and (f[mozne_mini[-1]] > f[x] or mozne_mini[-1] - (x-1) > k):
            mozne_mini.pop()
        # odstranime zastavky zo zaciatku, ktore nikdy nebudu minimom
        while len(mozne_mini) > 0 and mozne_mini[0] - (x-1) > k:
            mozne_mini.popleft()
        # pridame nove potencialne minimum
        mozne_mini.append(x)

    return f[1] <= t

V tomto riešení každú zastávku najviac raz vložíme do deque a najviac raz ju vyberieme. Preto bude mať funkcia \(dasa(k)\) časovú zložitosť \(O(n)\). Ak použijeme binárne vyhľadávanie na nájdenie najmenšieho \(k\), celý program potrebuje len čas \(O(n \log n)\).

Diskusia

Tu môžte voľne diskutovať o riešení, deliť sa o svoje kusy kódu a podobne.

Pre pridávanie komentárov sa musíš prihlásiť.