Ruutjuurelised võtted

Sections 1 and 2 in English

"Ruutjuureliseks võtteks" võib nimetada misiganes algoritmi, kus ruutjuur kuidagi figureerib. Programmeerimisvõistlustel on ilmnenud mõned ruutjuure algoritmis kasutamise tüüpilisemad mustrid. Neid mustreid on väga erinevaid, aga mõned tüüpilisemad on:

Käesolevas materjalis proovime näidete abil osa neist mustritest näidete varal selgitada, et lugeja suudaks tulevikus ülesannetele selliseid lahendusi välja mõelda.

Vaata ka 2021. aasta materjali.

1. Massiivi tükeldamine

1.1. Massiivi alalõikude summa

Järgmist ülesannet on kindlasti paljud lugejad juba lahendanud ja oskavad lahendada ka keerukusega, mis on parem, kui \(O(q \sqrt{n})\). Kuna see on aga kõige lihtsam seda tüüpi ülesanne, siis toome illustratiivsuse mõttes siiski lahenduse välja.

Ülesanne 1. Olgu antud massiiv \(A\) pikkusega \(n\), kus alguses on kõik elemendid nullid. On \(q\) päringut, kaht tüüpi:

\(1 \le n, q \le 10^5\).

Jagame massiivi plokkideks suurusega \(B\). Igas plokis hoiame meeles selle ploki elementide summa. Päringute täitmine näeb siis välja nii:

1 / 3

Iga esimest tüüpi päringu täitmine võtab alati \(O(1)\) aega. Teist tüüpi päringu korral on meil vaja läbi käia ülimalt \(O(\frac{n}{B})\) plokki ja ülimalt \(O(B)\) plokivälist elementi. Kokku on iga päringu täitmise keerukus \(O(\frac{n}{B} + B)\). See tuleb kõige väikesem, kui võtta \(B = \sqrt{n}\). Siis on kogu lahenduse keerukus \(O(q \sqrt{B})\).

Selline lahendus on kontseptuaalselt ehk lihtsam, kui lahendus Fenwicki või lõikude puuga. See on ka põhjus, miks ruutjuurelised võtted kasulikud on. Keerukamate päringute puhul võib olla lihtsam ruutjuurelist lahendust välja mõelda.

1.2. Vastikumad arvutused massiivi alamlõikudel

Ülesanne 2. On antud massiiv \(A\) pikkusega \(n\), kus alguses on kõik elemendid nullid. On \(q\) päringut, nelja tüüpi:

\(1 \le n, q \le 10^5\).

Lahenduse üldskeem on sarnane. Kui meil on antud vahemik \(l \ldots r\) (ükskõik, mis päringu korral), uuendame iga vahemikku täielikult kuuluvat plokki \(O(1)\) ajas ja seejärel elemente, mis on vahemiku sees, mille plokid aga mitte.

Selleks peaksime meeles hoidma iga ploki kohta järgmisi asju:

Lisaks hoiame meeles \(A[i]\) väärtust iga \(i\) korral kahe erandiga:

1.3. Augud

Ülesanne 3. Reas on \(n\) auku, nummerdatud \(1 \ldots n\). Iga auku iseloomustab tema "jõud" ehk arv \(A[k]\). Kui pall satub auku \(k\), põrkab ta otsekohe edasi auku \(k + A[k]\). Kui sellise numbriga auku pole, väljub pall reast. On \(q\) päringut, kaht tüüpi:

\(1 \le n, q \le 10^5\).

Sarnaselt eelmistele jagame aukude rea plokkideks suurusega \(\sqrt{n}\). Iga augu \(k\) kohta hoiame meeles kaks järgmist auku:

Nüüd tuleb läbi mõelda, kuidas seda informatsiooni teist tüüpi päringutele vastamiseks kasutada ja kuidas seda informatsiooni esimest tüüpi päringute korral kiirest uuendada.

Teist tüüpi päringutega on asi lihtsam. Siin tuleb lihtsalt olukorda simuleerida, kasutades \(C[k]\) nii palju kui võimalik. Nii teeb pall ülimalt \(\sqrt{n}\) hüpet.

Oletame nüüd, et meil palutakse augu \(k\) jõudu uuendada. Esiteks uuendame muidugi ära \(B[k]\). See tähendab, et üle tuleb arvutada \(C[k]\), mis on alati kas \(B[k]\) või \(C[B[k]]\).

Lisaks tuleb uuendada mõndade teiste aukude \(C[\cdot]\), kust teekond läbi \(k\) läheb. Selleks piisab uuendada (paremalt vasakule!) august \(k\) vasakule jäävate, aga samas plokis olevate aukude \(C[\cdot]\): muude aukude \(C[\cdot]\) muutuda ei saa.

Originaalülesandes tuleb lisaks viimasele augule välja trükkida ka tehtud hüpete arv. Selleks piisab iga \(C[k]\) juures meeles pidada, mitmest august see üle hüppab. Neid väärtusi saab uuendada samal ajal ja samamoodi.

2. Päringute tükeldamine

Ülesanne 4. Olgu antud \(n \times m\) ruudustik, mille iga ruut võib olla must või valge. Esialgu on kõik ruudud valged. Meile antakse \(q\) päringut, neist igaüks on ühel kahest kujust:

  1. Antud \(x, y\). Värvi ruut \((x, y)\) mustaks.
  2. Antud \(x, y\). Leia ruudu \((x, y)\) kaugus lähimast mustast ruudust.

Ruutude \(x_1, y_1\) ja \(x_2, y_2\) kauguseks nimetame antud juhul arvu \(\left|x_1 - x_2\right| + \left|y_1 - y_2\right|\). (Seda nimetatakse nn. Manhattani kauguseks).

\(1 \le nm \le 2 \cdot 10^5\), \(1 \le q \le 2 \cdot 10^5\).

Kirjeldame esiteks kaht erinevat liiga aeglast meetodit ülesande lahendamiseks:

  1. Hoiame meeles mustade ruutude loetelu. Esimest tüüpi päringu korral lisame uue ruudu loetellu; teist tüüpi päringu korral käime läbi kõik mustad ruudud ja arvutame lähima.
  2. Hoiame igas ruudus meeles lähima musta ruudu asukoha. Esimest tüüpi päringu korral käivitame lisatud ruudust laiuti läbimise, mis uuendab kõikide ruutude kaugusi lähimast mustast ruudust. Teist tüüpi päringu korral võime vastuse lihtsalt tabelist järgi vaadata.

Need meetodid on üsna erinevad, aga mõlemad on liiga aeglased.

  1. Esimese meetodi korral võtab iga esimest tüüpi päring \(O(1)\) aega, aga iga teist tüüpi päringu korral tuleb kõik varasemad päringud läbi vaadata: iga teist tüüpi päring võtab \(O(q)\) aega. Kokku saame keerukuse \(O(q^2)\). Halvimal juhul \(q^2 = 4 \cdot 10^{10}\), mis on liiga aeglane.
  2. Teise meetodi korral võib iga esimest tüüpi päringu korral olla vaja uuendada peaaegu kõikide ruutude kaugust, seega esimest tüüpi päringud võtavad igaüks \(O(mn)\) aega. Teist tüüpi päringud võtavad \(O(1)\) aega. Kokku saame keerukuse \(O(nmq)\). Halvimal juhul \(nmq = 4 \cdot 10^{10}\), mis on liiga aeglane.

Lahendusidee on need kaks meetodit kombineerida, näiteks järgneval viisil:

Analüüsime selle keerukust.

Kokku on keerukus seega \(O(\frac{qnm}{B} + qB)\). Saab näidata, et minimaalne on keerukus siis, kui \(B = \sqrt{nm}\). Seega saame keerukuse \(O(\frac{qnm}{\sqrt{nm}} + q\sqrt{mn}) = O(q \sqrt{mn})\). Halvimal juhul \(q \sqrt{mn} \approx 2 \cdot 10^5 \cdot 450 = 9 \cdot 10^7\), mis on piisavalt kiire.

Samamoodi iga \(O(\sqrt{n})\) päringu järel andmestruktuuri ümber arvutamise teel on võimalik lahendada ka järgmine ülesane.

Ülesanne 5. Antud massiiv \(A\) pikkusega \(n\). On \(q\) päringut, kaht tüüpi:

\(1 \le n, m \le 3 \cdot 10^5\).

3. Mo algoritm

3.1. Unikaalsete elementide loendamine

Ülesanne 6. On antud massiiv \(A\) pikkusega \(n\), mis koosneb arvudest \(A[1], A[2], \ldots, A[n]\), mis kõik on vahemikus \(1 \ldots 10^5\). Meile antakse \(q\) päringut kujul:

\(1 \le n, q \le 10^5\).

Seejuures on oluline märkida, et meile antakse kõik päringud korraga kätte. See tähendab, et me ei pea päringutele vastama samas järjekorras, milles nad meile antakse. Võime neid ümber järjestada kuidas soovime, pärast paneme algsesse järjekorda tagasi. Mo algoritm põhinebki sellel, et üks teatud "naiivne" algoritm muutub väga kiireks, kui päringud on "heas" järjekorras.

"Algoritm otspunktide nihutamisega"

Vaatleme igal hetkel ühte vahemikku meie massiivis; teises massiivis hoiame iga arvu kohta meeles, mitu tükki seda arvu vahemikus on. Vahemiku otspunkte ühe võrra ``nihutades'' liigume päringult päringule.

Kui massiiv on \([3, 5, 5, 6, 5, 2, 3]\) ja päringud on \((2, 5)\), \((3, 7)\) ning \((1, 4)\), siis võib algoritm välja näha midagi sellist:

1 / 9

"Loendamismassiivi" ja vastust saame uuendada iga nihutuse järel keerukusega \(O(1)\).

See algoritm ise aga väga hea veel ei ole. Võib ju olla, et iga kahe päringu vahel tuleb nihutada mõlemat pointerit peaaegu \(n\) sammu. Nii et halvimal juhul on keerukuseks lausa \(O(nq)\). Et \(nq\) võib olla kuni \(10^{10}\), on algoritm liiga aeglane. Järgmine samm on järjestada päringud ümber kavalal moel nii, et see algoritm muutuks mitu suurusjärku kiiremaks.

Mo järjestus

Järjestame päringud ümber järgmisel moel. Kõigepealt jaotame päringud "plokkidesse":

Nii jaotuvad päringud \(\approx \sqrt{n}\) plokki.

Pärast plokkidesse jaotamist sorteerime igas plokis päringud ära parempoolse otspunkti järgi.

1 / 3
1 / 3

Võrdlejafunktsioonid

Mugav viis Mo algoritmi implementeerida on kasutada nn võrdlejafunktsioone. Oletame, et oleme C++ keeles lugenud sisse päringud ja tahame nad Mo järjestusse panna. Näiteks on meil olemasolev kood midagi niisugust:

struct Query {
  int l, r, id;     
};

int main () {
  int n, q;
  cin >> n >> q;

  vector<Query> queries;
  for (int i = 0; i < q; i++) {
    cin >> queries[i].l >> queries[i].r;
    queries[i].id = i;
  }
}

Tahaksime, et saaksime selle päringute vektori panna Mo järjestusse lihtsalt sisseehitatud sorteerimismeetodi abil. Selleks implementeerime funktsiooni comp_mo, mis võtab sisse kaks päringut \(p\) ja \(q\). Funktsioon peab tagastama true, kui päring \(p\) peab tulema järjekorras enne päringut \(q\). Üks võimalik implementatsioon on järgmine:

bool comp_mo (Query p, Query q) {
  if (p.l / BLOCK_SIZE != q.l / BLOCK_SIZE) { // kui p ja q kuuluvad erinevatesse plokkidesse
    return p.l / BLOCK_SIZE &lt; q.l / BLOCK_SIZE; // tagastame true, kui p kuulub väikesemasse
  }
  return p.r &lt; q.r; // vastasel juhul sorteerime parempoolse otspunkti järgi
}

Nüüd võime päringute vektori sorteerida sisseehitatud funktsiooni sort abil.

sort(queries.begin(), queries.end(), comp_mo);

Keerukus

Kujutame nüüd ette, et me panime päringud ümber Mo järjestusse ja jooksutame oma otspunktide nihutamise algoritmi selle peal. Paneme tähele, et:

Seega keerukus on \(O((q + n) \sqrt{n})\). Kui \(n = q = 10^5\), siis \((q + n) \sqrt{n} = (10^5 + 10^5) \sqrt{10^5} \approx 6.32 \cdot 10^7,\) mis on piisavalt kiire.

Ja see võimaldabki meil ülesande ära lahendada! Kokkuvõttes:

  1. loeme sisendi sisse ja paneme päringud ümber Mo järjestusse;
  2. kasutame oma otspunktide nihutamisega algoritmi, et saada teada kõigi päringute vastused;
  3. prindime päringute vastused (esialgses järjestuses!) välja.

3.2. Massiivi võimsus

Mo algoritmi saab sageli rakendada ülesannetes, kus on antud päringud, millel on vasak- ja parempoolne otspunkt. Meil piisab vaid kirjeldada otspunktide nihutamisega algoritmi. Siinkohal üks näide.

Ülesanne 7. On antud massiiv \(A\) pikkusega \(n\). Kõik massiivi elemendid on vahemikust \(1 \ldots 10^6\).

Vaatleme selle mingit alammassiivi \(A[l], A[l + 1], \ldots, A[r]\). Defineerime alammassiivi \(l \ldots r\) võimsuse järgnevalt. Iga \(i\) korral tähistagu \(K_i\) arvu \(i\) esinemiste arvu selles alammassiivis. Alammassiivi \(l \ldots r\) võimus arvutatakse siis valemiga $$ 1 \cdot K_1^2 + 2 \cdot K_2^2 + \cdots + 10^6 \cdot K_{10^6}^2.$$ Meile antakse \(q\) päringut kujul:

Vastata kõikidele päringutele. \(1 \le n, q \le 10^5\).

Piisab sellest, kui kirjeldame otspunktide nihutamisega algoritmi.

Hoiame meeles mingi vahemiku võimsust ja seda, kui palju on antud vahemikus iga arvu. Hakkame otspunkte nihutama. Kujutame ette, et vahemikku lisandub arv \(i\). Enne lisandumist oli vahemiku võimsus

\[ 1 \cdot K_1^2 + 2 \cdot K_2^2 + \cdots + i \cdot (K_i - 1)^2 + \cdots + 10^6 \cdot K_{10^6}^2. \]

Pärast on võimsus

\[ 1 \cdot K_1^2 + 2 \cdot K_2^2 + \cdots + i \cdot K_i^2 + \cdots + 10^6 \cdot K_{10^6}^2. \]

Võimsuse muut on

\[ iK_i^2 - i(K_i - 1)^2= 2i K_i - i. \]

Analoogilise valemi võime tuletada ka siis, kui vahemikust eemaldub arv \(i\). Sellest teadmisest piisab, et lahendada ülesanne Mo algoritmi abil.

4. Uuendustega Mo algoritm*

Ülesanne 8. On antud massiiv \(A\) pikkusega \(n\), mis koosneb arvudest \(A[1], A[2], \ldots, A[n]\), mis kõik on vahemikus \(1 \ldots 10^5\). Meile antakse \(q\) päringut kujul:

\(1 \le n, q \le 10^5\).

Nüüd me enam ei saa päringuid ümber järjestada nii, nagu vanasti. Seda seetõttu, et kui uuendustega päringud näiteks vahetavad järjekorda, siis muutuvad sellest ka kõik vastused.

Kuid kõik pole veel kadunud! Me võime kujutada ette, et meilt küsitakse hoopis selliseid päringuid:

Ja selliseid päringuid me võime jälle suvaliselt ümber järjestada ilma, et vastus sellest muutuks.

Vaatleme sisendit:

\[ A = [3, 5, 5, 7, 5, 2, 3]. \]

Päringud:

Teisendatud päringud on siis:

Proovime, kas saame uuele olukorrale kohandada ka Mo järjestuse ja otspunktide nihutamise algoritmi.

"Algoritm otspunktide nihutamisega"

Sarnaselt klassikalisele juhule vaatleme igal hetkel ühte vahemikku meie massiivis ühel kindlal ajahetkel \(t\). Vahemiku otspunkte ja ajahetke ühe võrra "nihutades" liigume päringult päringule.

Teeme algoritmi läbi ülaloleval sisendil, kus päringute järjestus on

ja uuendused on \(3: 5 \to 1\), \(5: 5 \to 6\).

1 / 11

Mo järjestus

Kui vanasti oli meil kaks pointerit (vasak- ja parempoolne), siis nüüd on meil kolm pointerit: vasakpoolne otspunkt \(l\), parempoolne otspunkt \(r\) ja ajahetk \(t\).

Kõigepealt jaotame päringud plokkidesse vasakpoolse pointeri järgi:

Nii jaotuvad päringud \(\approx n^{1/3} = \sqrt[3]{n}\) plokki. Iga ploki siseselt jaotame päringud omakorda gruppidesse parempoolse pointeri järgi:

Seega: meil on \(\approx n^{1/3}\) plokki, igas plokis \(\approx n^{1/3}\) gruppi: kokku \(\approx n^{2/3}\) gruppi. Lõppeks sorteerime igas grupis olevad päringud ajahetke järgi ära.

1 / 4

Analoogiliselt klassikalise Mo algoritmiga on mugav ja kompaktne kirjeldada seda järgmise võrdlejaga.

bool compare (Query p, Query q) {
  if (p.l / BLOCK_SIZE != q.l / BLOCK_SIZE) {
    return p.l < q.l;
  } else if (p.r / BLOCK_SIZE != q.r / BLOCK_SIZE) {
    return p.r < q.r;
  } else {
    return p.t < q.t;
  }
}

Keerukus

Arvutame keerukuse nii, nagu varemgi. Paneme tähele, et:

Kokku on niisiis keerukus \(O(q n^{2/3} + q n^{2/3} + n^{4/3} + n^{5/3}) = O(q n^{2/3} + n^{5/3})\). Kui eeldada, et \(n \approx q\), siis on sammude arv \(O(n^{5/3}) = O(n \sqrt[3]{n^2})\). Kui \(n = 10^5\), siis

\[ n^{5/3} = (10^5)^{5/3} \approx 2.15 \cdot 10^{8}. \]

See on päris piiripealne, aga natuke kõrgema ajalimiidi korral läheb läbi.