www.agora.ro

Din GInfo 1/2000

SOLUȚIILE problemelor propuse

Vă prezentăm în continuare soluțiile problemelor de la Olimpiada de Informatică a Europei Centrale din 1999. Rezolvările aparțin lui Bogdan Dumitru din Ploiești, medaliat cu argint la CEOI '99

P010001: Livrări

Această problemă, deși a avut un text greoi, a fost cea mai simplă (și evident cea mai slab punctată) dintre cele 6 probleme prezentate concurenților spre rezolvare. Specificația toate posibilitățile indică clar metoda de rezolvare: backtracking. Tot ce mai trebuia făcut era optimizarea programului pe cât posibil, având în vedere numărul mare de soluții posibile.

Procedura Load citește caracterele puse la dispoziție, memorând în vectorul NR câte "exemplare" avem din fiecare caracter. Procedura Preprocess construiește vectorii CAR și CATE; în CAR reținem caracterele distincte sortate crescător (numărul lor este reținut de variabila L); în CATE reținem de câte ori avem fiecare caracter. Numărul total de caractere este S.

Procedura recursivă Rec implementează metoda backtracking propriu-zisă. Dacă am ajuns pe nivelul S+1, am obținut o soluție, pe care o tipărim; dacă nu, completăm nivelul curent (notat K) cu toate caracterele avute la dispoziție în momentul respectiv, în ordinea crescătoare indusă de vectorul CAR și reapelăm procedura, nu înainte de a decrementa corespondentul din CATE al caracterului ales. Ordinea crescătoare de alegere a caracterelor garantează ordinea lexicografică a soluțiilor.

Procedura Bkt deschide fișierul de ieșire la scriere și îi asociază un buffer (necesar având în vedere dimensiunea fișierului - maxim 2MB) și lansează procedura recursivă. Fiecare soluție este scrisă de procedura Scrie, apelată ori de câte ori se validează condiția de ieșire din recursivitate.

Complexitatea soluției: pentru fiecare caracter adăugat unei soluții se testează cel mult toate caracterele distincte, care sunt în număr de cel mult 26 (constanta notată NMAX); numărul de operații efectuate devine astfel proporțional cu O (Output * NMAX), unde Output semnifică dimensiunea fișierului de ieșire. Această complexitate (minimă de altfel) asigură trecerea cu brio a tuturor testelor propuse în concurs.

Listing LIVRARI.PAS

P010002: Numere de telefon

Aceată problemă a urmărit să testeze cunoștințele de programare dinamică ale concurenților, precum și abilitatea de a manipula structuri mari de date (de ordinul sutelor de KB).

Cele maxim 50.000 de cuvinte de maxim 50 de caractere fiecare nu puteau fi memorate într-un vector de pointeri la string-uri, din simplul motiv că ar fi fost necesari 50.000*50=2.500.000 octeți, adică peste 2.400 KB de memorie. Nici listele nu reprezentau o soluție, o "legătură" între 2 celule ocupând 4 octeți.

Singura soluție de memorare era o matrice de caractere, în care cuvintele erau scrise "la rând". Soluția se bazează pe faptul că lungimea totală a cuvintelor nu depășea 300.000. În plus, trebuia reținut un vector de 50.000 de elemente, care să conțină lungimile cuvintelor. Aparent, metoda prezintă multe dezavantaje, ca de exemplu faptul că accesarea unui cuvânt oarecare nu se poate face decât după ce se calculează deplasamentul lui față de prima celulă din matrice, nemaivorbind de faptul că respectivul cuvânt s-ar putea să ocupe mai multe linii din matrice. De fapt, algoritmul descris mai jos va folosi eficient această matrice.

Metoda de rezolvare este următoarea: pentru fiecare poziție din numărul de telefon reținem numărul minim de cuvinte cu care putem să codificăm numărul până la respectiva poziție, precum și ultimul cuvânt folosit (pentru reconstituire). Baleiem numărul de telefon și, din poziția curentă, încercăm să optimizăm, folosind toate cuvintele avute, obținute baleind matricea creată. Algoritmul este o variantă de programare dinamică, cunoscută sub numele de metoda înapoi. Dacă reușim să ajungem la ultima poziție, există soluție; în caz contrar, nu.

Procedura Load citește cuvintele date și le memorează în matrice. Am ales ca matricea să aibă 30 de linii a câte 10.000 de caractere fiecare. Astfel, un cuvânt (a cărui lungime maximă este 50) se va afla pe cel mult două linii ale matricei.

Procedura Init_OK inițializează matricea OK, utilizată pentru a testa dacă este posibilă sau nu codificarea unei anumite cifre printr-un anumit caracter.

Procedura Init initializeaza vectorul V (în V[i] reținem numărul minim de cuvinte folosite pentru a codifica subșirul format cu primele i cifre din numărul de telefon).

Procedura Work încearcă, având soluția optimă pentru primele i cifre, să completeze soluția cu toate cuvintele avute. Acestea sunt considerate pe rând (citind matricea) și testate cu ajutorul matricei OK. Dacă un anumit cuvânt se potrivește, se face actualizarea.

Procedura Imposibil determină dacă problema nu are soluție, caz în care scrie un mesaj corespunzător și suspendă execuția programului.

Procedura Reconst reconstituie soluția, procedeu deloc banal. Mai întâi se determină numerele de ordine ale cuvintelor folosite (în vectorul USE), apoi acestea se sortează (în vectorul CUSE).

În procedura Scrie se baleiază matricea de cuvinte; când se găsește un cuvânt care a fost folosit în obținerea soluției, acesta este scris în fișierul de ieșire.

Pentru fiecare din cele maxim 100 de cifre ale numărului de telefon, se baleiază matricea de cuvinte, a cărei dimensiune poate fi cel mult 300.000. Complexitatea poate fi astfel O(Numărul de Cifre * Input). În practică, optimizările făcute (se iese din ciclu la prima nepotrivire găsită) coboară mult timpul de execuție. Sursa rulează aproape instantaneu pe întreg setul de teste propuse în concurs.

Listing TELEFON.PAS

P010003: Joc de paritate

Această problemă a fost considerată de majoritatea concurenților drept cea mai "inteligentă" problemă dintre cele propuse spre rezolvare în prima zi de concurs.

După lectura textului se conturează o primă idee de rezolvare: trebuie să concatenăm intervalele date pentru care limita superioară a unuia și limita inferioară a celuilalt sunt numere consecutive, în încercarea de a obține o contradicție (un interval despre care să știm că include atât un număr par, cât și un număr impar de numere 1 - lucru evident imposibil).

Această idee prezintă un dezavantaj imens: ea creează noi și noi intervale, existând multe situații pentru care memoria pusă de obicei la dispoziție (câteva sute de KB) nu este deloc suficientă.

Remarcăm că în loc de a concatena intervale, obținând intervale noi, tot mai mari, ar fi mult mai bine dacă am "scădea" intervale, obținând intervale mai mici, cu care se lucrează mai ușor. Vom spune, pentru simplificarea exprimării, că un interval este par, respectiv impar, după cum el conține un număr par, respectiv impar de cifre 1. Principiul de bază al rezolvării va fi: având paritățile intervalelor [X,Y] și [X,Z], cu Y<Z, reținem paritățile intervalelor [X,Y] și [Y+1,Z] (adică a intervalului de lungime minimă și a "diferenței" între cele două intervale). Evident, orice prelucrare de intervale nu se poate baza decât pe coincidențe ale limitelor intervalelor (în afara acestora neputând apărea nici o contradicție).

Mai rămâne de văzut cum "reducem" într-o manieră eficientă intervalele.

Soluția cea mai bună pare a fi următoarea: reținem un vector A în care fiecare element reprezintă un interval (dat prin limite și paritate). Vectorul este permanent sortat după limita de început a intervalelor. La citirea unui nou interval, acesta este adăugat la sfârșitul vectorului.

Găsim apoi (dacă există) un interval care să înceapă în același punct cu intervalul citit. Notând cu I acest interval iar cu J noul interval, deosebim 3 cazuri:

1) limita superioară a intervalului I este mai mare decât limita superioară a intervalului J; în acest caz se inversează intervalele, urmând ca algoritmul să trateze cazul 3);

2) limita superioară a intervalului I este egală cu limita superioară a intervalului J; apar aici două subcazuri:

2.1) dacă cele două intervale au aceeași paritate, noua informație este redundantă și este eliminată;

2.2) în caz contrar, am detectat o contradicție; se afișează numărul de răspunsuri citite (1) și se suspendă execuția programului;

3) limita superioară a intervalului I este mai mică decât limita superioară a intervalului J; atunci intervalul J este înlocuit cu un nou interval, cu proprietățile:

- limita inferioară este egală cu limita superioară a intervalului I, la care se adaugă 1;

- limita superioară este egală cu limita superioară a intervalului J;

- intervalul este par dacă intervalele I și J aveau aceeași paritate și impar în caz contrar;

Se continuă în același mod baleierea vectorului de intervale, reducând acolo unde este posibil.

Dacă se termină citirea, înseamnă că nu a fost detectată nici o contradicție. Se afișează deci numărul răspunsurilor citite și se termină execuția.

Procedura Load încarcă câte un interval care va fi prelucrat de procedura Work, cea mai importantă din program.

În această procedură (Work), noul interval este adăugat în poziția L a vectorului, nu înainte de a incrementa corespunzător variabila. Se parcurge mergând "înapoi" vectorul A (al intervalelor) până la găsirea primului interval pentru care limita inferioară este cel mult egală cu limita inferioară a ultimului interval citit. În funcție de relația între limita superioară a acestui interval și limita superioară a ultimului interval, apar trei cazuri, care se tratează așa cum a fost descris mai sus. Se trece apoi la următorul interval, operându-se în continuare toate reducerile posibile.

La detectarea unei contradicții, se apelează procedura Stop care scrie numărul răspunsurilor citite până atunci și întrerupere execuția programului.

Complexitatea maximă a acestui algoritm este O(N2). Însă, pe un anumit test, este foarte puțin probabil ca baleierea vectorului să se facă integral de fiecare dată (un astfel de caz ar fi acela în care intervalele sunt date în ordinea descrescătoare a limitelor inferioare). În orice caz, sursa prezentată rezolvă setul de teste propus în concurs folosind doar o parte din timpul de rulare maxim admis.

Listing PARITATE.PAS

P010004: Vizita orașului

Aceasta este o problemă clasică de grafuri: determinarea ciclului de lungime minimă într-un graf neorientat.

Observăm mai întâi că, dacă între două noduri sunt mai multe muchii, nu vom folosi niciodată două dintre ele în alcătuirea ciclului cerut (pentru că dacă le-am folosi, ar însemna ca ciclul să fie alcătuit numai din acele două noduri deoarece trebuie să fie elementar; dar acest lucru contravine condiției din enunț k>2 - ciclul trebuie să fie format din cel puțin trei noduri). Deducem deci că, dacă între două noduri există mai multe muchii o reținem doar pe cea de cost minim.

Vom determina întâi ciclul de lungime minimă care conține neapărat nodul 1, apoi ciclul de lungime minimă care conține neapărat nodul 2 și vom continua până la nodul N. Minimul acestor cicluri va constitui, evident, soluția problemei.

Cum determinăm ciclul de lungime minimă care trece prin nodul I? Considerând un nod arbitrar J de pe acest ciclu, vom putea scrie ciclul ca reuniune a două drumuri de la I la J, drumuri ce nu au în comun decât nodurile I și J. Evident, unul dintre aceste drumuri are costul egal cu cel al drumului minim de la I la J (altfel, ar putea fi substituit cu acesta, costul ciclului minim devenind mai mic). Celălalt drum este al doilea drum minim care are costul cel puțin egal cu al primului. Considerând acest al doilea drum ca o reuniune de drumuri (fie numărul acestora K), el va fi format din K-1 drumuri optime (minime) și un drum de cost cel puțin egal cu al drumului optim corespunzător. Ideea ce se conturează de aici este următoarea: aplicăm algoritmul Dijkstra, având ca nod sursă nodul I. Acest algoritm determină drumurile minime de la nodul I la celelalte noduri, drumuri ale căror reuniune formează un arbore cu rădăcina I (acest lucru este evident - legăturile de tip "tată" care sunt construite de acest algoritm generează, la reconstituire, un arbore). În afara arborelui creat ramân, evident, M-(N-1)=M-N+1 muchii (unde M era numărul inițial de muchii). Se consideră oricare asemenea muchie și ciclul format de ea împreună cu drumurile minime de la capetele ei până la rădăcina arborelui. În cazul în care costul unui asemenea ciclu este mai mic decât costul ciclului optim, atunci acesta este actualizat.

Procedura Load citește fișierul de intrare și reține, în matricea A, pentru fiecare pereche de noduri muchia de cost minim dintre ele.

Procedura Dijkstra implementează algoritmul Dijkstra având un nod sursă.

Procedura Actua calculează costul fiecărui ciclu format dintr-o muchie neinclusă în arborele rezultat și drumurile optime de la capetele ei la nodul sursă.

Procedura principală este Work, care apelează procedura Dijkstra considerând fiecare nod pe rând ca fiind nod sursă.

În cazul în care graful format doar din muchiile de cost minim este un arbore, problema nu admite soluție, caz detectat de procedura Imposibil. În orice alt caz, se apelează procedura Reconst, care reconstituie ciclul de lungime minimă, afișat apoi de procedura Scrie.

Din fiecare nod, este aplicat algoritmul Dijkstra, care are complexitate pătratică. De asemenea, numărul muchiilor rămase este cel mult proporțional cu N2. Așadar complexitatea algoritmului este O(N3). Cum limita lui N este 100, programul rulează în timp pentru orice test.

Listing VIZITA.PAS

P010005: Pe tabla de șah

La prima citire a textului, metoda de rezolvare cea mai indicată pare a fi Branch & Bound. Însă această metodă are deficiențele ei: este destul de greu implementabilă, iar programul rezultat consuma multă memorie (nemaivorbind de faptul că este posibil să nu se încadreze în timpul de execuție).

Dacă însă estimăm numărul total de configurații posibile, remarcăm că acesta este egal cu numărul de submulțimi de 8 elemente ale unei mulțimi de 16 elemente, altfel spus: combinări de 16 luate câte 8, adică 12.870.

Mai remarcăm, de asemenea, că fiecare configurație poate fi codificată printr-un număr, reprezentând echivalentul zecimal al numărului binar obținut prin liniarizarea ei. Algorimul de rezolvare începe să se contureze: va fi o "expandare", bazată pe ideea din algoritmul lui Lee. Vom folosi o structură de date de tip coadă, inițializată cu starea inițială. La fiecare pas, vom adăuga toate configurațiile care se pot obține din configurația curentă și care nu au fost obținute încă. Astfel, fiecare configurație este obținută într-un număr minim de pași. Procesul se termină la obținerea configurației finale (evident, întotdeauna există soluție - mai mult, se poate demonstra ușor că ea se obține în cel mult 24 de pași).

Pentru o mai bună înțelegere a rezolvării, se pot considera cele 12.870 de configurații posibile ca fiind nodurile unui graf. Între două asemenea noduri există muchie de cost unitar dacă ele reprezintă configurații ce se pot obține una din alta printr-o singură mutare. Algoritmul implementeaza astfel o parcurgere BF (în lățime) a grafului, pornind din nodul ce reprezintă starea inițială și oprindu-se pe nivelul ce include nodul ce reprezintă starea finală.

Procedura Load citește stările inițială și finală.

Procedura Egale tratează cazul banal, în care cele două stări sunt identice, caz în care scrie în fișierul de ieșire 0 (numărul de mutări) și suspendă execuția programului.

Explicația procedurilor Board_To_Number și Number_To_Board este puțin mai delicată. Am considerat toate cele 12.870 configurații posibile, sortate descrescător după echivalentul zecimal al numărului binar obținut prin liniarizarea unei asemenea configurații și am asociat fiecărei configurații indicele corespunzător ei în această sortare. Astfel, procedura Board_To_Number primește o configurație și returnează numărul ei de ordine în șirul configurațiilor sortate; procedura Number_To_Board primește un număr (cuprins între 1 și 12.870) și returnează configurația aflată la indicele respectiv. Ambele proceduri folosesc matricea C, determinată de procedura Calc_Comb; C[i,j] are valoarea combinari de i luate câte j; metoda de determinare a matricei C este triunghiul lui Pascal.

Procedura Init inițializează coada V cu configurația de start. Păstrăm de asemenea un vector caracteristic S (care indică dacă o configurație a fost obținută sau nu), precum și un vector T, care arată pentru fiecare configurație anterioară ei (configurația din care a fost obținută).

Procedura Work completează coada până la găsirea soluției. Ea folosește procedura Move care, primind o configurație, generează toate configurațiile care se pot obține din ea printr-o mutare.

După găsirea soluției se apelează procedura Reconst care, utilizând vectorul T, reconstituie drumul de ajungere din configurația inițială în cea finală. Soluția este scrisă în fișierul de ieșire de procedura Scrie.

Fiecare configurație poate fi obținută din cel mult alte 24 de configurații (numărul de interschimbări de celule vecine într-o matrice de dimensiuni 4Ž4). Se poate deci considera complexitatea de ordinul O(Numărul total de configurații).Acest număr este o constantă (12.870), deci complexitatea este 0(1). Sursa rulează aproape instantaneu pe orice test.

Listing SAH.PAS

P010006: Jocul de-a constructorul

Aceasta a fost una dintre cele mai frumoase probleme din concurs, problema aparținând unui gen cunoscut sub numele de "problemă de idee", necesitând multă imaginație, vedere în spațiu și, nu în ultimul rând, cunoștințe avansate de prelucrarea structurilor mari de date. Programul rezultat este foarte scurt, însă raționamentele care stau în spatele lui sunt migăloase și, sperăm, interesante de urmărit.

Mai întâi, trebuie stabilit când problema admite soluție și când nu. Pentru aceasta, să considerăm un plan arbitrar de oraș; evident, el va avea cel puțin un turn de înălțime maximă. Atunci acel turn va fi, în mod clar, atât maximul turnurilor privind din față, cât și din lateral dreapta. În consecință, o condiție necesară pentru ca planul să fie realizabil este că maximele celor două șiruri să fie egale. În continuare, se va demonstra în mod constructiv că această condiție este și suficientă.

Considerând îndeplinită această condiție, să construim orașul cu un număr minim de blocuri. Notăm cu L linia pe care, la vederea din dreapta, se atinge maximul înălțimilor (dacă sunt mai multe, una dintre ele); analog, notăm cu C coloana pe care, la vederea din față, se atinge maximul înălțimilor (dacă sunt mai multe, una dintre ele). Pentru a satisface cerințele vederii din față, așezăm pe linia L, corespunzător fiecărei coloane, câte un turn având înălțimea citită. Astfel condițiile vederii din față sunt îndeplinite folosind, evident, un număr minim de blocuri (suma elementelor ce alcătuiesc primul șir citit). Trecem acum la cerințele vederii din lateral dreapta. Condiția pe linia L este satisfăcută (se vede turnul de înălțime maximă). Parcurgem acum restul liniilor. Să presupunem că pe o linie ni se cere un turn de înălțime H, căutăm înălțimea H printre cerințele vederii din față. Dacă o găsim, notăm cu CL coloana pe care se află și "translatăm" turnul respectiv pe coloana CL până în dreptul liniei cerute. Astfel, satisfacem cerința vederii din lateral, iar privind din față, nu s-a schimbat nimic (turnul a "culisat" în lungul unei coloane, deci nu se vede nici o schimbare). Dacă, însă, nu găsim înălțimea H printre cerințele vederii din față, suntem nevoiți să construim un turn de înălțime H, pe care îl plasăm pe linia a cărei condiție o îndeplinim în momentul respectiv și pe coloana C, fiind astfel "mascat" la privirea din față de turnul de înălțime maximă. În acest al doilea caz, numărul total de blocuri necesare se incrementează cu H.

Să construim acum orașul cu număr maxim de blocuri. Din nou, vom satisface întâi cerințele vederii din față. De data aceasta însă, vom parcurge coloanele și vom pune pe fiecare linie câte un turn de înălțimea cerută. Calculăm numărul de blocuri folosite ca fiind suma elementelor primului șir citit înmulțită cu numărul de linii al matricei (fiecare turn este pus pe fiecare componentă a coloanei pe care stă el). Însă dacă privim acum din lateral, vedem pe fiecare linie câte un turn de înălțime maximă. Parcurgem deci liniile și, pe fiecare linie, "tăiem" tot ce depășește înălțimea cerută respectivei linii. Notând cu HMAX maximul înălțimilor și H - înălțimea cerută liniei curente, vom "tăia" (HMAX - H)* numărul de coloane al matricei blocuri, număr ce va trebui scăzut din numărul inițial de blocuri folosite.

În program, față de explicațiile prezentate aici, apare o mică modificare și anume vectorii înălțimilor nu sunt reținuți așa cum sunt citiți ci sunt reținuti doi vectori A și B, în care A[i], respectiv B[i], indică de câte ori este cerută înălțimea i la vederea din față, respectiv de câte ori este cerută înălțimea i la vederea din dreapta. Acest lucru este posibil, pentru că, evident, ordinea liniilor și coloanelor nu contează, și este necesar, pentru că matricea are dimensiunile maxime 100.000Ž100.000 (ceea ce este evident prea mult) dar maximul înalțimilor este doar 5.000.

Procedura Load citește vectorii înălțimilor, calculează maximele lor și contruiește vectorii A și B.

Procedura Imposibil detectează cazul fără soluție, ieșind forțat după scrierea unui mesaj corespunzător.

Procedurile Minim și Maxim calculează valorile cerute, notate cu MIN și MAX, după algoritmii descriși mai sus.

Rezultatele sunt afișate de procedura Scrie.

Cea mai mare parte a timpului de rulare este consumată de citire; prelucrările ulterioare sunt proporționale cu HMAX, care este cel mult 5.000. Așadar, ordinul de complexitate este O (Input + HMAX), adică minimul posibil. Programul trece orice test, încadrându-se fără probleme în timpul de execuție admis.

Listing ORAS.PAS

[cuprins]