Tehnica Two-Pointers în C++
Two-Pointers este o tehnică folosită adesea în concursurile de informatică, dar mai cu seamă în problemele de la interviurile de angajare. Ea nu este un algoritm concret – nici măcar un șablon –, ci mai degrabă o filosofie. Aceasta ne spune că putem optimiza diverse probleme de parcurgere a vectorilor folosind doi pointeri (left) și (right), pe care la fiecare pas să-i incrementăm sau decrementăm, după caz. În acest articol vom rezolva șapte probleme interesante, care în total explorează trei moduri diferite de a aplica tehnica Two-Pointers.
Problema 1.
Se dă un vector indexat de la format din numere naturale nenule. Să se determine capetele unei subsecvențe a lui ce are suma elementelor egală cu Dacă nu există soluție, se va returna perechea De exemplu, pentru și un răspuns posibil este (link)
Soluția naivă presupune să explorăm în mod explicit fiecare subsecvență posibilă. Această idee poate fi implementată în două moduri, ambele conducând însă la algoritmi ineficienți. Prima variantă este să ne fixăm cu două for
-uri indicii și ai subsecvenței curente, și să mai luăm un for
pentru a calcula suma elementelor acesteia. Complexitate
for (int l = 1; l <= n; l++) for (int r = l; r <= n; r++) { int s = 0; for (int i = l; i <= r; i++) s += v[i]; if (s == k) return make_pair(l, r); }return make_pair(0, 0);
Cealaltă variantă se obține observând că al treilea for
din algoritmul de mai sus este redundant. Nu are sens să recalculăm suma pentru fiecare pereche Pentru un fixat, știm că suma elementelor până la este egală cu suma elementelor până la plus Așadar, putem actualiza suma curentă pe parcursul celui de-al doilea for
. Noua complexitate este
for (int l = 1; l <= n; l++) { int s = 0; for (int r = l; r <= n; r++) { s += v[r]; if (s == k) return make_pair(l, r); }}return make_pair(0, 0);
Soluția în
Totuși, încă se efectuează o grămadă de iterații inutile. Observăm că, dacă subsecvența curentă are suma elementelor mai mare decât atunci nu mai are rost să-l incrementăm pe Este clar că toate subsecvențele cu vor avea și ele suma mai mare decât
Asta pentru că elementele vectorului sunt pozitive – altfel, ideea n-ar mai fi funcționat. Să luăm drept exemplu și De acum încolo, vom nota cu suma elementelor din subsecvența Avem și Dacă pentru ne-am opri cu în am rata soluția care este și unică.
Deci, când suma curentă îl depășește pe ne oprim din a-l incrementa pe și trecem la următorul Iar aici intervine observația cheie. După ce îl incrementăm pe are sens să-l resetăm complet pe sau îl putem lăsa așa și eventual să-i adăugăm un
Răspunsul este că da, îl putem lăsa pe exact așa cum este. Se vede clar din schema de mai sus că, pentru capătul din stânga fixat în cel din dreapta nu are rost să fie mai mic decât Am obține o sumă mai mică decât care deja este mai mică decât
Two-Pointers in effect
Și iată că am ajuns la formularea clasică a șmenului:
Inițializăm suma curentă cu Pornim cu și marcând că începem cu subsecvența vidă ce începe în La fiecare pas, comparăm cu Dacă returnăm perechea Altfel, dacă îl incrementăm pe iar dacă îl incrementăm pe
Să analizăm puțin și complexitatea algoritmului. Cel mai nefavorabil caz este cel în care nu avem soluție, pentru că parcurgem tot vectorul. Mai exact, ne oprim abia după ce La fiecare pas, incrementăm exact una dintre variabilele și Prin urmare, numărul maxim de iterații efectuate este Pointer-ul ia pe rând toate valorile de la la iar pornește din și crește din în până ne oprim, atingând în cel mai rău caz valoarea Așadar, algoritmul este liniar, având complexitatea
int l = 1, r = 0, s = 0;while (s != k) { if (s < k) { if (r == n) return make_pair(0, 0); s += v[++r]; } if (s > k) s -= v[l++];}return make_pair(l, r);
Iată și o implementare ușor mai clară, care scapă de if
-ul ăla enervant. Îl iterăm frumos pe de la la iar la fiecare pas îl ajustăm pe incrementându-l cât este nevoie.
int l = 1, s = 0;for (int r = 1; r <= n; r++) { s += v[r]; while (s > k) s -= v[l++]; if (s == k) return make_pair(l, r);}return make_pair(0, 0);
Nu în ultimul rând, am pregătit mai jos o animație care ar trebui să clarifice totul
Vă recomand să rezolvați și extinderea la 2D a problemei. Ideea este să comprimăm coloanele matricei, cum am explicat și aici.
Dacă aveam și numere negative
După cum spuneam, soluția asta merge doar pe numere pozitive. O soluție mai generală, și poate chiar mai straight-forward, se bazează pe sume parțiale, și am explicat-o aici. Dezavantajul este că necesită un vector de frecvență, care în cazul numerelor negative ar trebui decalat. Nu mai zic că, dacă lucrăm cu numere ceva mai mari, acesta ar trebui transformat într-un tabel de hashing. Pe scurt, soluția cu Two-Pointers este mai simplă și nu necesită memorie auxiliară
Problema 2.
Pentru un număr natural cuprins între și să se determine două numere naturale și astfel încât Se garantează că există o soluție cu (link)
În principiu, putem folosi direct ideea de la problema precedentă, deoarece șirul produselor parțiale ale secvenței este și el (strict) monoton. Problema este că nouă deja ni se dau niște numere foarte mari. E prea greu să lucrăm și cu produsele lor. Aici intervine o tehnică foarte drăguță, numită logaritmare, care se bazează pe faptul că
indiferent de baza aleasă pentru logaritm. Prin urmare, în loc să calculăm produse de numere mari, putem calcula sume din logaritmii lor. Logaritmii naturali de exemplu. Aceștia încap lejer pe double
.
Tot ce mai rămâne de făcut este să logaritmăm numărul dat, pentru că asta e valoarea la care trebuie să ajungă suma noastră. Știm din enunț că se poate scrie drept produsul unor numere mai mici decât de unde e clar că descompunerea sa în factori primi nu poate conține niciun factor mai mare de Așadar, putem aplica algoritmul clasic de factorizare, adunând pe parcurs logaritmii necesari. Deci, în ceea ce privește operațiile cu numere mari, avem nevoie doar de împărțirea la un număr mic.
Dar încă n-am terminat, pentru că am lua time limit cu sursa asta. Trebuie să mai optimizăm un lucru, și anume numărul de împărțiri efectuate. În loc să-l împărțim pe la un număr mic de foarte multe ori, îl putem împărți o dată și bine la o putere mare a acestuia. Mai exact, la unde este (aproximativ) valoarea maximă a tipului int
. După aceea, vom face și împărțiri direct la cât mai este nevoie. Aici aveți sursa completă, iar mai jos doar partea relevantă.
double target = 0;for (int prime : sieve) { if (p.isOne()) break; if (p % prime == 0) { int pwr = 1, exp = 0, add = 0; while (pwr <= 2e9 / prime) { add++; pwr *= prime; } while (p % pwr == 0) { exp += add; p /= pwr; } while (p % prime == 0) { exp++; p /= prime; } target += log(prime) * exp; }}
double sum = 0;int l = 1, r = 1;while (abs(sum - target) > 1e-6) if (sum > target) sum -= log(l++); else sum += log(++r);cout << l << ' ' << r << '\n';
Problema 3.
Se dă un vector de numere naturale. Să se determine câte subsecvențe ale lui conțin între și elemente distincte. (link)
În primul rând, trebuie să scăpăm de și Nu putem folosi Two-Pointers dacă trebuie să ținem cont simultan de două restricții similare – una de maxim și una de minim. Rezolvarea acestui inconvenient este foarte simplă: Reducem problema la a calcula numărul de subsecvențe care conțin cel mult elemente distincte. Astfel, răspunsul va fi
Să ne imaginăm că în loc de cel mult avem exact elemente distincte. Putem aplica Two-Pointers ca până acum. Inițializăm secvența curentă cu și repetăm următorul procedeu până când terminăm de explorat fiecare posibil: Dacă avem mai puțin de elemente distincte, îl incrementăm pe iar dacă avem mai mult de îl incrementăm pe Frecvența valorilor luate în considerare poate fi ținută într-un unordered_map
, iar numărul acestora într-o variabilă
Pentru a trece de la exact la cel mult, nu avem decât ca, pentru fiecare de la la să adăugăm valoarea la răspuns: Știm că este cel mai mic astfel încât de unde poate lua valori.
auto count = [&](int max) { int64_t ans = 0; unordered_map<int, int> frq; int l = 1, cnt = 0; for (int r = 1; r <= n; r++) { if (++frq[v[r]] == 1) while (++cnt > max) { if (--frq[v[l]] == 0) cnt--; l++; } ans += r - l + 1; } return ans;};cout << count(y) - count(x - 1) << '\n';
Problema 4.
Se dă un vector indexat de la format din numere întregi, ordonate crescător. Să se determine indicii a două elemente din care au suma egală cu Cele două elemente trebuie să fie situate pe poziții diferite. Dacă nu există soluție, se va returna perechea De exemplu, pentru și un răspuns posibil este (link)
De data aceasta, cei doi pointeri nu mai pot merge în aceeași direcție: Fie că-l incrementăm pe sau pe suma dintre și tot în sus va merge, pentru că vectorul este sortat. Însă, noi trebuie să avem posibilitatea de a o și micșora. De aceea, pe îl vom itera de la stânga la dreapta, iar pe de la dreapta la stânga.
Când suma curentă este mai mică decât îl incrementăm pe înlocuindu-l astfel cu un număr mai mare (sau egal). Similar, când suma depășește target-ul, îl decrementăm pe înlocuindu-l cu un număr potențial mai mic. Ne oprim când
În mod evident, complexitatea algoritmului este Repet, ideea funcționează doar pentru că vectorul este sortat. Demonstrația corectitudinii se bazează pe un invariant foarte asemănător cu cel descris la prima problemă, așa că o las ca temă pentru cititor.
int l = 1, r = n;while (l < r) { if (v[l] + v[r] == k) return make_pair(l, r); if (v[l] + v[r] < k) l++; else r--;}return make_pair(0, 0);
Problema 5.
Se dă un vector indexat de la format din numere întregi. Să se determine indicii a trei elemente din care au suma egală cu Cele trei elemente trebuie să fie situate pe poziții diferite. Dacă nu există soluție, se va returna tripleta (link)
Cele trei numere și trebuie să verifice așadar relația Altfel spus, Păi, pentru un fixat, răspunsul se obține rezolvând problema precedentă pe vectorul obținut prin ștergerea lui unde Partea cu ștergerea lui din vector pare complicată, pentru că am obține doi noi subvectori, unul la stânga la și unul la dreapta sa. Aceștia ar trebui tratați ca și cum ar fi unul singur atunci când aplicăm Two-Pointers pe ei. Însă, impunând ca să fie cel mai din dreapta element dintre cele trei, va fi de ajuns să-i căutăm pe și doar în stânga sa.
Până acum, complexitatea este de ordinul Un de la iterarea lui și altul de la căutarea liniară a elementelor și în stânga lui. Mai trebuie doar să sortăm vectorul inițial, pentru că altfel n-am putea aplica Two-Pointers pentru și Asta înseamnă încă un dar asta nu influențează complexitatea, deoarece crește mai repede. Atunci când sortăm vectorul, trebuie avut grijă să nu pierdem indicii inițiali ai elementelor în
vector<pair<int, int>> w(n + 1);for (int i = 1; i <= n; i++) w[i] = make_pair(v[i], i);sort(w.begin() + 1, w.end());
for (int i = 3; i <= n; i++) { int l = 1, r = i - 1; while (l < r) { if (w[l].first + w[r].first == -w[i].first) return make_tuple(w[l].second, w[r].second, w[i].second); if (w[l].first + w[r].first < -w[i].first) l++; else r--; }}return make_tuple(0, 0, 0);
Problema 6.
Se dă un vector de numere naturale. Să se ordoneze elementele sale în așa fel încât cele pare să fie situate la început, iar cele impare la sfârșit. Sortarea trebuie să fie efectuată in-place, adică nu ne putem ajuta de o structură de date auxiliară. De exemplu, o sortare posibilă pentru vectorul este (link)
În primul rând, vom trata toate numerele pare ca pe și toate numerele impare ca pe Putem sorta in-place vectorul dat iterând doi pointeri (de la stânga) și (de la dreapta), menținând la fiecare pas următorul invariant: Subsecvența conține doar uri, iar subsecvența conține doar uri. Când cu unul dintre pointeri găsim un element care nu e la locul lui în stânga sau în dreapta), îl ținem pe loc, până când va găsi și celălalt pointer un asemenea element. În acel moment, interschimbăm elementele și după care avansăm cu ambii pointeri.
Complexitatea este Iată și codul:
int l = 1, r = n;while (l < r) if (v[l] % 2 == 1 && v[r] % 2 == 0) swap(v[l], v[r]); else if (v[l] % 2 == 1) r--; else l++;
Problema 7.
Se dă un vector format din numere din mulțimea Să se sorteze vectorul in-place. (link)
Aici nu ne mai putem descurca cu Two-Pointers. Avem nevoie de Three-Pointers Aceștia vor fi și Subsecvența conține numai numai numai iar conține elementele ce n-au fost încă explorate.
La fiecare pas, ne uităm la elementul Dacă este egal cu trebuie mutat în prima secvență, așa că vom face swap între și după care incrementăm ambii pointeri. Dacă este foarte bine, îl lăsăm pe acolo și incrementăm ul. Dacă trebuie mutat către finalul vectorului. Facem swap între și după care îl decrementăm pe Din nou, complexitatea este liniară.
int l = 1, m = 1, r = n;while (m <= r) if (v[m] == 0) swap(v[l++], v[m++]); else if (v[m] == 1) m++; else swap(v[m], v[r--]);
Sfârșit! Sper că acum este clar cum stă treaba cu tehnica Two-Pointers: care este intuiția din spate și de ce (sau când) este corectă Ca întotdeauna, dacă aveți vreo întrebare, nu ezitați să o lăsați mai jos într-un comentariu!