Matematická olympiáda – kategorie P

Řešení úloh ústředního kola (1. soutěžní den) 53. ročníku

Vzorová řešení soutěžních úloh 53. ročníku MO - kategorie P

name=P-III-1>

P-III-1 Agenti

Úlohu si můžeme reprezentovat pomocí orientovaného grafu. Agenti představují vrcholy grafu. Skutečnost, že agent a může vydat rozkaz agentovi b, vyjádříme orientovanou hranou (a,b). Naším úkolem je nalézt v tomto grafu vrchol, z něhož se můžeme dostat do všech ostatních vrcholů.

Začneme prohledáváním grafu do hloubky z libovolného zvoleného vrcholu. Jestliže při tomto prohledávání navštívíme všechny vrcholy, našli jsme šéfa - je jím vrchol, kterým jsme prohledávání začali. V opačném případě pokračujeme tak, že zahájíme nové prohledávání do hloubky v jednom z vrcholů, které jsme dosud nenavštívili (dříve navštívené vrcholy grafu přitom necháme označené jako navštívené). To opakujeme tak dlouho, dokud nenavštívíme všechny vrcholy našeho grafu. Nechť r je vrchol, v němž jsme zahájili poslední prohledávání.

Tvrzení: Má-li náš graf aspoň jednoho šéfa, potom vrchol r je šéfem.

Důkaz: Předpokládejme, že náš graf má šéfa a že vrchol r není šéfem. Nechť je šéfem vrchol s. Musíme uvažovat dvě možnosti:

Zbývá tedy už jen ověřit (opět prohledáváním do hloubky), zda r je skutečně šéfem grafu; v opačném případě graf nemá žádného šéfa. Časová složitost celého algoritmu je O(M+N), kde N je počet vrcholů a M je počet hran grafu.
program Agenti;

const MAXM = 10000;
MAXN = 100;

var rozkazuje: array [1..MAXM] of integer;
ind_od, ind_do: array [1..MAXN] of integer;
    {agent i+6 rozkazuje agentům rozkazuje[ind_od[i]]...rozkazuje[ind_do[i]]}
    N: integer;
    navstiven: array [1..MAXN] of boolean;

procedure Nacti;
var i,M,agent: integer;
begin
   write('Počet agentů:'); readln(N);

   M:=0;
   for i:=1 to N do begin
      write('Agent ',i+6,' rozkazuje: (ukonči -1)');
      ind_od[i]:=M+1;
      read(agent);
      while (agent>0) do begin
	   M:=M+1;
	   rozkazuje[M]:=agent-6;
	   read(agent);
      end;
      ind_do[i]:=M;
   end;
end; {procedure Nacti}

procedure Prohledej(i: integer);
var j: integer;
begin
   if not navstiven[i] then begin
      navstiven[i]:=true;
      for j:=ind_od[i] to ind_do[i] do begin
         Prohledej(rozkazuje[j]);
      end;
   end;
end; {procedure Prohledej}

var i,posledni: integer;
    je_sef: boolean;

begin
   Nacti;
   for i:=1 to N do navstiven[i]:=false;
   for i:=1 to N do begin
      if not navstiven[i] then begin
         posledni:=i;
         Prohledej(i);
      end;
   end;
 
   for i:=1 to N do navstiven[i]:=false;
   Prohledej(posledni);

   je_sef:=true;
   for i:=1 to N do je_sef:= je_sef and navstiven[i];

   if je_sef then
      writeln('Šéfem je agent ', posledni+6)
   else
      writeln('Žádný agent není šéfem');
end. {program Agenti}

P-III-2 Teploty

  1. Snadno sestrojíme řešení, které potřebuje čas O(K) na zpracování jedné hodnoty ze vstupu. Stačí si v cyklicky přepisovaném poli pamatovat posledních K vstupních hodnot. Pokaždé, když přečteme další číslo ze vstupu, pole jednoduše projdeme a vypíšeme nejmenší z hodnot uložených v poli.
  2. Vzorové řešení vystačí s časem O(log K) na zpracování jednoho čísla. Představme si, že bychom si aktuálních K hodnot udržovali v haldě. Novou hodnotu do této haldy lehce přidáme v čase O(log K). Předtím, než vypíšeme minimum (které je uloženo v kořeni haldy), potřebujeme však ještě z haldy odstranit nejstarší hodnotu. Jak ale máme vědět, která z nich to je?

    Pomůžeme si tím, že hodnoty, které nám budou přicházet, vložíme nejen do haldy, ale také do fronty. Mezi těmito dvěma datovými strukturami si budeme udržovat vzájemné odkazy, abychom v každém okamžiku dokázali o každém prvku fronty říci, kde je v haldě, a naopak.

    Když tedy přijde nová hodnota, vložíme ji do haldy a na konec fronty. Následně ze začátku fronty odstraníme nejstarší hodnotu, pomocí odkazu ji najdeme v haldě a odstraníme ji také odtamtud. Nyní už jen vypíšeme hodnotu uloženou v kořeni haldy.

    Obě operace s haldou mají časovou složitost O(log K), zbývající operace dokážeme provést v konstantním čase. Paměť spotřebovaná haldou i frontou je O(K).

    #include <stdio.h>
    #define MAXK 100047
    #define INFTY 1e10 
    #define SWAP(x,y) pom=(x); (x)=(y); (y)=pom
    
    typedef struct { double val; int ptr; } tZaznam;
    int K;                   // ze zadání
    tZaznam H[MAXK],Q[MAXK]; // halda a fronta
    int qs;                  // začátek fronty
    tZaznam pom;
    
    void init(void) { // naplníme haldu i frontu "nekonečně" velkými hodnotami
      int i;
      qs=0; H[0].val=-10000; 
      for (i=0;i<K;i++) { Q[i].val=H[i+1].val=INFTY; Q[i].ptr=i+1; H[i+1].ptr=i; }
    }
    
    
    void bubbleup(int idx) { // bublej prvkem nahoru v haldě
      int next=idx,p1,p2;
      if (H[idx/2].val > H[idx].val) next=idx/2;
      if (next!=idx) {
        p1=H[idx].ptr; p2=H[next].ptr;
        SWAP(H[idx],H[next]);
        Q[p1].ptr=next; Q[p2].ptr=idx;
        bubbleup(next);
      }
    }
    
    void bubbledown(int idx) { // bublej prvkem dolů v haldě
      int next=idx,p1,p2;
      if (2*idx<=K)   if (H[2*idx  ].val < H[next].val) next=2*idx;
      if (2*idx+1<=K) if (H[2*idx+1].val < H[next].val) next=2*idx+1;
      if (next!=idx) {
        p1=H[idx].ptr; p2=H[next].ptr;
        SWAP(H[idx],H[next]);
        Q[p1].ptr=next; Q[p2].ptr=idx;
        bubbledown(next);
      }
    }
    
    int main(void) {
      int delptr;
      double x;
      scanf("%d ",&K); init();
      while (1) { 
        scanf("%lf ",&x); if (x==-1000) break;
        delptr=Q[qs].ptr; // najdeme hodnotu, kterou je třeba vymazat z haldy
        H[delptr].val=H[K].val; H[delptr].ptr=H[K].ptr; Q[H[K].ptr].ptr=delptr;
        K--; bubbledown(delptr); bubbleup(delptr); K++; // smažeme a upravíme haldu
        Q[qs].val=H[K].val=x; Q[qs].ptr=K; H[K].ptr=qs; bubbleup(K); // vložíme
        qs= (qs+1)%K;
        printf("%g\n",H[1].val);
      }
      return 0;
    }
    
  3. Další alternativní řešení, na které přišel Vladan Majerech, nepoužívá haldu, ale vystačí si s lineárním seznamem. Myšlenka je následující:
    V seznamu si budeme uchovávat některé z posledních K teplot. Ze seznamu vyřadíme ty teploty, za kterými je v seznamu nižší teplota (tedy ty teploty, které jsou vyšší než nějaká teplota, která přišla později). Seznam zřejmě obsahuje všechny teploty, které by se mohli stát později nejmenší v daném období.

    Jak to tedy udělat? Teploty si budeme ukládat do seznamu tak, jak přicházejí a u každé z nich si zapamatujeme čas, kdy přišla, abychom jí potom mohli ze seznamu vymazat. Pokaždé, když přidáváme novou teplotu, nejprve odebereme všechny prvky seznamu s teplotou menší než je nová teplota. Novou teplotu pak přidáme na konec seznamu. Protože nově přidávaná teplota je vždy větší než všechny ostatní teploty v seznamu, budou teploty v seznamu srovnány od nejmenší po největší. Lehce nahlédneme, že jsou teploty srovnány také podle času, kdy přišli, protože nově příchozí teploty dáváme vždy na konec. Díky tomu je nejstarší teplota první teplotou v seznamu a můžeme ji lehce odebrat, jakmile nebude v měřeném období. Samozřejmě tato teplota je také nejmenší teplotou v seznamu, kterou po každém přidání nové teploty vypisujeme. To je vše co potřebujeme.

    Nyní se podívejme na časovou složitost. Každý prvek je jednou přidán a jednou odebrán. Celková časová složitost s n teplotami je O(n). Ale v nejhorším případě, když přidáváme novou teplotu odebereme až O(K) teplot. Pokud ovšem spojový seznam nahradíme polem můžeme vylepšit časovou složitost na O(log K). Aby se nám teploty do pole vešly, musíme ho "zacyklit". To znamená, že po K-tém prvku bude následovat nultý prvek. Tohoto efektu dosáhneme tím, že se všemi indexi budeme počítat modulo K + 1 (pole je indexované od 0 a má K + 1 prvků). Protože jsou teploty seřazené použijeme k vyhledání hranice odebraných prvků algoritmus půlení intervalu. Ten hledá hodnotu x v seřazeném poli A od indexu L do indexu P. Vezme prostřední prvek A[(L + P) mod 2] a porovná ho s x. Jesliže je x větší hledáme x v intervalu (L + P) mod 2P, ve druhém případě hledáme v intervalu L(L + P) mod 2. Protože je pole A zacyklené, může se stát, že P < L. V tom případě musíme posunout P o K + 1 a prostřední prvek dostaneme jako A[((L + P + K + 1) div 2) mod (K + 1)].
    Nyní máme index hranice prvků, za který přidáme novou teplotu.

  4. Pro milovníky algoritmů tu máme ještě jedno řešení od Zdeňka Dvořáka, které i v nejhorším případě zpracuje jednu teplotu ze vstupu v čase O(1).

    Algoritmus bude pracovat v několika fázích. V každé fázi postupně načte a zpracuje K/2 teplot ze vstupu (pro jednoduchost předpokládejme, že K je sudé). V průběhu výpočtu si budeme vytvářet pomocná pole délky K/2. V okamžiku, kdy bude pomocné pole již vytvořeno, tak u každého prvku pole bude uloženo kromě jeho hodnoty i nejmenší prvek uložený v poli za tímto prvkem.

    V i-té fázi budeme mít již vytvořeno pole pro hodnoty z (i-2)-hé fáze, pro teploty z (i-1)-ní fáze budeme pole vyvtvářet a hodnoty z i-té fáze si budeme prozatím jen ukládat. Navíc budeme mít vždy k dispozici minimum ze všech teplot načtených v (i-1)-ní fázi a v i-té fázi.

    Po načtení k-té teploty v i-té fázi, 1<=k<=K/2, algoritmus nejdříve uloží k-tou teplotu do pomocného pole a upraví hodnotu minima teplot načtených v i-té fázi, je-li to potřeba. Poté spočte teplotu, kterou má vystoupit jako minimum z následujících tří hodnot: minimum z posledních (K/2-k) teplot v poli z (i-2)-hé fáze (toto minimum máme předpočítané), minimum teplot načtených v (i-1)-ní fázi a minimum dosud načtených teplot v i-té fázi. Nakonec spočte minimum posledních k prvků v poli z (i-1)-ní fáze jako minimum (K/2-k+1)-ního prvku a minima z posledních k-1 prvků (které spočítal po načtení předchozí teploty).

    Na konci každé fáze, algorimus zamění pomocná pole (všimněte si, že na konci každé fáze právě dopočítáme pomocné pole pro hodnoty z předchozí fáze).

    Časová složitost našeho algoritmu je O(1) pro zpracování jedné teploty v nejhorším případě a paměťová složitost je O(K).

P-III-3 Registrový počítač

  1. Na úvod si připomeňme, že v řešení krajského kola jste se mohli kromě jiného dočíst, jak lze pomocí dvou počítadel simulovat zásobník. Pro jistotu si zopakujeme, jak na to:

    Zásobník si můžeme simulovat v jednom registru (s pomocí druhého). Písmena a, b, c budou odpovídat číslům 1, 2, 3. Číslo uložené v registru R1 bude představovat náš zásobník - když ho zapíšeme v poziční soustavě o základu 4, jednotlivé cifry budou představovat vložené hodnoty (cifra na místě jednotek bude naposledy vložená hodnota). Například když do prázdného zásobníku vložíme postupně písmena a, c, b, a, bude v R1 hodnota a * 43 + c * 42 + b * 4 + a = 1 * 43 + 3 * 42 + 2 * 4 + 1 = 64 + 48 + 8 + 1 = 121.

    Jak ale s takovýmto registrem-zásobníkem pracovat? Vložit novou hodnotu x je jednoduché - pomocí registru R2 vynásobíme obsah R1 čtyřmi a potom ho x-krát zvětšíme o 1. Rovněž odebrání naposledy vložené hodnoty není těžké - je to přesně opačná operace. Vydělíme obsah registru R1 čtyřmi. Zbytek po dělení je naposledy vložená hodnota, podíl (který dostaneme v R2) je obsah zásobníku bez této hodnoty.

    Nyní můžeme již přikročit k řešení zadané úlohy. Jednou možností je simulovat (pomocí dvou zásobníků) frontu, v níž si udržujeme ta písmena, jejichž pár jsme ještě neviděli. Toto řešení je poměrně komplikované a jeho základní myšlenka spočívá v tom, že přicházející písmena vkládáme do prvního zásobníku, písmena na kontrolu vybíráme z druhého zásobníku a vždy, když se nám druhý zásobník vyprázdni, do něj přesypeme obsah prvního zásobníku.

    Ukážeme si raději jednodušší řešení. Budeme opět používat dva zásobníky. Do prvního budeme vkládat všechna přicházející malá písmena (jako hodnoty 1,2,3), do druhého velká (také jako hodnoty 1,2,3). Po dočtení vstupního slova jednoduše porovnáme obsahy obou zásobníků. Vstupní slovo bylo správné právě tehdy, je-li jejich obsah stejný. To již snadno ověříme.

    var vstup: char;
        i,co: byte;
    begin
       Read(vstup);
       while vstup<>'$' do begin 
          if vstup>='a' then begin
             if vstup='a' then co:=1; 
             if vstup='b' then co:=2; 
             if vstup='c' then co:=3;
             while not Zero(R1) do begin Dec(R1); for i:=1 to 4 do Inc(R0); end;
             while not Zero(R0) do begin Dec(R0); Inc(R1); end;
             while co>0 do begin Inc(R1); co:=co-1; end;
          end else begin
             if vstup='A' then co:=1; 
             if vstup='B' then co:=2; 
             if vstup='C' then co:=3;
             while not Zero(R2) do begin Dec(R2); for i:=1 to 4 do Inc(R0); end;
             while not Zero(R0) do begin Dec(R0); Inc(R2); end;
             while co>0 do begin Inc(R2); co:=co-1; end;
          end;
          Read(vstup);
       end;
       while not Zero(R1) and not Zero(R2) do begin Dec(R1); Dec(R2); end;
       if Zero(R1) and Zero(R2) then Accept;
    end.
    
  2. Pro zvýšení přehlednosti označíme původní registry R1, R2, R3 a nové registry Q1, Q2.

    Použijeme myšlenku, kterou znáte již z řešení domácího kola. Zakódujeme obsah všech tří registrů do jediného, druhý registr budeme používat jako pomocný při práci s prvním. Místo tří registrů s obsahem a, b, c budeme mít tedy jeden registr Q1 s obsahem 2a 3b 5c. Při simulování každé operace použijeme Q2 jako pomocný registr. Na začátku i po provedení každé operace v něm bude uložena nula.

    Operaci Inc(Rx) v původním programu nahradíme tím, že obsah nového registru Q1 vynásobíme 2, 3, resp. 5. Podobně příkaz Dec(Rx) nahradíme příslušným dělením.

    Nahradit podmínku Zero(Rx) bude trochu komplikovanější. Během vyhodnocování nějaké složené podmínky totiž nemůžeme provádět operace s registry - zjistit, zda je v Rx nula, tedy musíme před vyhodnocením příslušné podmínky. Navíc drobné problémy způsobí skutečnost, že tato podmínka se může vyskytovat i v podmínce pro příkaz while, kde bude vyhodnocována při každé iteraci (nejen před prvním voláním while), a že v jedné podmínce můžeme testovat více proměnných.

    Definujme si "makro" (kus výpočtu) SpocitejZ, které bude fungovat následovně: Pro každý registr Rx nastaví proměnnou zx tak, aby v ní byla kladná hodnota právě tehdy, je-li v Rx nula, jinak bude zx=0. Výpočet makra začne tím, že obsah Q1 vydělíme příslušným prvočíslem, přičemž si (v proměnné zx) zapamatujeme zbytek, který jsme dostali při tomto dělení. Vrátíme obsah Q1 do původního stavu. Jestliže obsah Q1 byl dělitelný příslušným prvočíslem (tedy neplatí Zero(Rx)), bude v zx nula, jinak tam bude kladný zbytek. Výraz Zero(Rx) má tedy v tomto okamžiku stejnou pravdivostní hodnotu jako výraz (zx>0).

    Každý příkaz "if P then příkazy;" nahradíme makrem SpocitejZ a příkazem "if P' then příkazy;", kde podmínka P' vznikla z P tak, že jsme v ní místo všech výskytů výrazu Zero(Rx) dali výraz (zx>0).

    Každý příkaz "while P do příkazy;" nahradíme voláním makra SpocitejZ před cyklem a na konci každé iterace, tedy následujícím kusem výpočtu: "SpocitejZ; while P' do begin příkazy; SpocitejZ; end;" Nová "makra" Inc, Dec (jimiž nahradíme každý výskyt těchto příkazů v původním programu) a SpocitejZ (na simulaci Zero) budou tedy vypadat následovně:

    var x,y,z1,z2,z3,i: byte; {nové proměnné, které nebyly v původním programu}
             
    { Inc(Rx) - x je v proměnné x, předpokládáme, že $Q2=0$ }
    
      if x=1 then y:=2 else if x=2 then y:=3 else y:=5;
      {vynásobíme obsah Q1 čislem y}
      while not Zero(Q1) do begin
         Dec(Q1);
         for i:=1 to y do Inc(Q2); 
      end;
      while not Zero(Q2) do begin
         Dec(Q2); Inc(Q1); 
      end;
    
    { Dec(Rx) - x je v proměnné x, předpokládáme, že $Q2=0$ }
      
      if x=1 then y:=2 else if x=2 then y:=3 else y:=5;
      z:=0;
      {vydělíme obsah Q1 číslem y}
      while not Zero(Q1) do begin
         Dec(Q1); 
         if Zero(Q1) then begin z:=1; break; end; {v Rx byla nula}
         for i:=1 to y-1 do Dec(Q1);
         Inc(Q2);
      end;
      if z=1 then begin
         {obnovíme původní stav Q1 - nic se nemění}
         while not Zero(Q2) do begin
            Dec(Q2); for i:=1 to y do Inc(Q1); 
         end;
         Inc(Q1);
      end else begin
         {přesuneme do Q1 podíl}
         while not Zero(Q2) do begin
            Dec(Q2); Inc(Q1); 
         end;
      end;
    
    { SpocitejZ - předpokládáme, že $Q2=0$ }
    
      {nejdříve chceme spočítat z1, čili budeme dělit dvěma}
      y:=2;  
      {vydělíme obsah Q1 číslem y}
      z1:=0;
      while not Zero(Q1) do begin
         Dec(Q1); 
         z1:=z1+1;
         if z1=y then begin z1:=0; Inc(Q2); end;
      end;
      {vrátíme zpět původní hodnotu do Q1}
      while not Zero(Q2) do begin
         Dec(Q2); for i:=1 to y do Inc(Q1);
      end;
      for i:=1 to z1 do Inc(Q1); 
      {a v proměnné z1 máme hledaný zbytek}
      {opakujeme totéž pro z2, y:=3 a z3, y:=      5}
    

    Dva důležité detaily, kterých jste si mohli povšimnout:
    1. Nesmíme zapomenout ošetřit situaci, že registr Ri obsahuje nulu. V tom případě i po provedení Dec(Ri) musí v Ri (podle definice) zůstat nula.
    2. Když jsme při simulování příkazů Inc, Dec a Zero potřebovali použít proměnné, muselo se jednat o nové proměnné, které se dosud v programu nevyskytovaly. (Co kdyby například původní program obsahoval část "for i:=1 to 3 do Inc(R1)" a my bychom při simulaci Inc(R1) použili proměnnou i?) Volných jmen pro nové proměnné máme k dispozici nekonečně mnoho, lehce tedy najdeme nějaké nepoužité.
    Snadno nahlédneme, že když tímto způsobem upravíme libovolný program, bude upravený program ekvivalentní s původním - tj. bude dávat pro každý vstup stejný výstup jako původní program. Přitom pokud původní program používal tři registry, upravený program už používá jen dva.

    Uvědomte si, že aplikováním tohoto postupu na program používající k>3 registrů dostaneme program používající k-1 registrů. Proto platí poměrně překvapivý výsledek: K libovolné úloze, kterou dokážeme řešit na registrovém počítači, existuje program, jemuž na její řešení stačí dva registry.