Teoria complexității

Mihai Budiu -- mihaib+@cs.cmu.edu
http://www.cs.cmu.edu/~mihaib/

16 noiembrie 1999

Subiect:
teoria modernă a complexității: ce se poate și nu se poate calcula
Cunoștințe necesare:
cunoștințe elementare de logică, complexitatea algoritmilor
Cuvinte cheie:
limbaj, mașină Turing, complexitate, oracol


Cuprins




Introducere

Dintre ramurile informaticii, teoria complexității a fost întotdeauna pentru mine cea mai fascinantă, pentru că pune cele mai cutezătoare întrebări și dă cele mai cuprinzătoare răspunsuri. La un moment dat intenționam să îmi dedic chiar activitatea de cercetare acestui domeniu, dar pașii mei s-au păstrat pe o cărare mult mai practică. Am încercat însă să fiu un amator informat în ceea ce privește acest fascinant domeniu. Articolul de față va încerca să vă transmită cîteva dintre motivele fascinației mele.

Pentru că subiectul acesta este foarte generos, mă văd din nou obligat în a face un articol cu mai multe episoade; îmi cer scuze cititorilor care nu vor cumpăra toate numerele revistei.

Am mai publicat în PC Report despre subiecte înrudite: un articol amplu în 1997 discuta despre direcții noi de cercetare în algoritmi, iar cele două numere din PC Report anterioare celui de față au fost consacrate celei mai importante probleme nerezolvate din teoria complexității, problema satisfiabilității. Cititorii interesați pot găsi aceste texte în pagina mea de web.

Din păcate îndeletnicirea mea este oarecum ingrată: în primul rînd este dificil să vorbești despre ceva la care nu te pricepi cu adevărat; în al doilea rînd, substanța însăși a acestui domeniu este foarte formală; nu i se poate da dreptate fără a folosi o doză substanțială de matematică, ori acest gen de literatură nu este de loc pe potriva revistei PC Report.

Ca atare voi recurge la o practică reprobabilă: voi încerca să vă povestesc despre rezultatele cele mai remarcabile obținute în acest domeniu, dar fără a oferi întotdeauna demonstrații matematice, și adesea chiar fără a exprima foarte precis noțiunile despre care vorbesc. Voi consuma cu nerușinare ``crema'' acestui domeniu, vînturînd în fața ochilor dumneavoastră cele mai spectaculoase rezultate, dar fără a vă arăta toată sudoarea care stă în spatele lor. Dacă asta vă va trezi curiozitatea în a aprofunda domeniul, sau dacă doar veți privi cu mai mult respect domeniul informaticii, nu ca pe o simplă meșteșugăreală, dar ca pe o știință în toată regula, mă voi putea declara satisfăcut.

Cititorul cu înclinații matematice poate însă să fie liniștit: toate lucrurile despre care pomenesc sunt de fapt foarte precis formalizate, și suportate de demonstrații în toată legea. În plus, matematica folosită în teoria complexității este relativ simplă: toate aceste lucruri ar trebui să fie accesibile unui elev de liceu care-și dă (ceva mai multă) osteneală; cu certitudine, matematica studiată în facultate este mult mai complicată decît cea întîlnită în studiul teoriei complexității.

În mod interesant, asta nu înseamnă că teoria complexității este mai ușoară; dimpotrivă, minți briliante au cucerit cu multă trudă rezultatele acesteia. Ceea ce e adevărat este că matematica folosită în teoria complexității are mult mai puțină structură, și că obiectele manipulate sunt mult mai simple. Cel mai adesea avem de-a face cu obiecte finite, și aproape întotdeauna cu obiecte numărabile, discrete. Obiectul fundamental este șirul de caractere formate din litere ale unui alfabet finit: o structură mult mai simplă decît cele întîlnite de pildă în analiza matematică.

Desigur, faptul că o problemă are un enunț simplu nu înseamnă de loc că are și o soluție simplă; cea mai puternică mărturie este probabil Marea teoremă a lui Fermat, al cărei enunț poate fi explicat și unui școlar, dar a cărei rezolvare a cerut trei sute de ani de eforturi încordate. De fapt, vom vedea că în teoria complexității există o sumă de probleme fundamentale nerezolvate; putem chiar spune că majoritatea problemelor fundamentale sunt încă nerezolvate.

Dar informatica este o știință exactă extrem de tînără; mult mai tînără decît matematica sau fizica. Sunt convins că cele mai interesante rezultate vor apărea abia în viitor; ceea ce ne minunează în ziua de astăzi este echivalentul uimirii pitagorenilor în fața triunghiului dreptunghic: doar umbra unei colosale frumuseți matematice.

Teoria complexității: o știință despre limitele inferioare

Teoria complexității (complexity theory) se ocupă cu studiul lucrurilor care se pot calcula atunci cînd resursele pe care le avem la dispoziție sunt limitate. Resursele fundamentale în teoria tradițională erau:

Timpul
disponibil pentru execuția unui program;

Spațiul
sau cantitatea de memorie disponibilă pentru a stoca date.

dar dezvoltările mai recente au arătat că alte genuri de resurse au un impact foarte important asupra lucrurilor care se pot calcula, cum ar fi:

Aleatorismul,
sau cantitatea de biți aleatori pe care-i avem la dispoziție;

Paralelismul,
sau numărul de elemente de procesare care pot opera în paralel;

Interacțiunea,
sau numărul de mesaje schimbate între două entități care calculează;

Sfaturi de la un oracol:
chiar dacă nu știm să rezolvăm o anumită problemă, dacă presupunem că cineva vine și ne dă răspunsul, există apoi în continuare probleme grele?

Dintr-un anumit punct de vedere, teoria complexității este exact opusul teoriei algoritmilor, care probabil partea cea mai dezvoltată a informaticii teoretice: dacă teoria algoritmilor ia o problemă și oferă o soluție a problemei în limitele unor resurse, teoria complexității încearcă să arate cînd resursele sunt insuficiente pentru a rezolva o anumită problemă. Teoria complexității oferă astfel demonstrații că anumite lucruri nu pot fi făcute, pe cînd teoria algoritmilor arată cum lucrurile pot fi făcute.

De exemplu, atunci cînd învățăm un algoritm de sortare ca quicksort, demonstrăm că problema sortării a n numere se poate rezolva în timp proporțional cu n log n. Știm deci că sortarea se poate face în timp cel mult n log n, sau mai puțin.

Pe de altă parte, teoria complexității ne arată că pentru a sorta n numere oarecare ne trebuie cel puțin timp n log n, și că este imposibil să sortăm mai repede, dacă nu avem informații suplimentare despre valorile de sortat.

Combinînd aceste două rezultate, deducem că problema sortării are complexitatea exact n log n, pentru că:

  1. Avem un algoritm de timp n log n;
  2. Am demonstrat că nu se poate mai bine de atît.

Această stare de fapt este una extrem de fericită, și din păcate foarte rară: pentru majoritatea problemelor pe care le cunoaștem, există o distanță mare între cea mai bună posibilitate de rezolvare pe care o cunoaștem și limita inferioară cea mai ridicată pe care o putem demonstra. Situația este ca în figura 1.

Figura 1: Existența unui algoritm pentru a rezolva o problemă oferă o limită superioară pentru complexitatea problemei: în regiunea albastră, din dreapta, putem rezolva problema. Teoria complexității găsește limite inferioare: dacă ne plasăm în regiunea roșie, cu certitudine problema este insolvabilă. Foarte adesea, între cele două regiuni există o zonă (hașurată cu negru) despre care nu putem spune nimic. Din păcate, această zonă este adesea foarte mare ca întindere.
\begin{figure}\centerline{\epsfxsize=12cm\epsffile{limite.eps}}\end{figure}

Chiar pentru probleme aparent banale, complexitatea minimă este adesea necunoscută. De exemplu, algoritmul cel mai bun cunoscut pentru a înmulți două numere de n cifre are complexitatea n log log n, dar limita inferioară oferită de teoria complexității este de n. Algoritmul învățat în școala primară pentru înmulțire are complexitatea n2. Nimeni nu știe cît de jos poate fi împinsă această limită, dar teoria complexității ne garantează ca nu mai jos de n (de fapt în cazul de față nu e nevoie de nici o teorie pentru a ne spune asta: în timp mai puțin de n nu poți nici măcar să citești toate cifrele numerelor de înmulțit).

Pentru foarte multe probleme importante, distanța între limitele inferioară și superioară cunoscute este enormă: de exemplu pentru problema satisfiabilității, căreia i-am consacrat ultimele mele două articole, limita superioară este o funcție exponențială (2n) iar cea inferioară este una polinomială (nk), pentru un k constant, independent de problemă.

Într-un anume sens, teoria complexității are o sarcină mult mai grea decît cea a algoritmilor: teoria algoritmilor demonstrează propoziții de genul: ``există un algoritm de complexitate n log n care înmulțește două numere''. Acest lucru este de obicei făcut chiar construind acel algoritm. Pe de altă parte, teoria complexității trebuie să demonstreze teoreme de genul ``Nu există nici un algoritm care rezolvă această problemă în mai puțin de n log n pași''. Ori așa ceva este nu se poate demonstra în mod direct: există un număr infinit de algoritmi, deci nu putem pur și simplu să-i verificăm pe toți.

Modele de calcul și resurse

Am tot menționat tot felul de limite (ex. n2), dar nu am spus ce înseamnă acestea. Atît teoria complexității, cît și teoria algoritmilor, măsoară complexitatea în același fel, și anume asimptotic. Asta înseamnă că măsurăm numărul de operații făcute ca o funcție de cantitatea de date care ne este oferită spre prelucrare, și că împingem această cantitate spre infinit. Atunci complexitatea este exprimată ca o funcție pe mulțimea numerelor naturale: pentru fiecare mărime de intrare, avem o complexitate. Dacă putem avea mai multe date de intrare cu aceeași mărime (de exemplu, cînd sortăm putem avea mai mulți vectori de lungime n), atunci complexitatea pentru intrarea n este luată ca fiind maximumul dintre toate valorile: max(|i|=n) f(i), unde f este complexitatea și i sunt datele de intrare (simbolul | | reprezintă mărimea datelor).

Calculatoarele: procesoare de limbaje

Cum măsurăm datele de intrare și cum măsurăm resursele? Întrebarea este perfect legitimă, și merită o clarificare.

În primul rînd, toate datele pe care le vom prelucra sunt exprimate folosind literele unui alfabet. Nu contează prea tare care este alfabetul, atîta vreme cît are cel puțin două semne diferite.

Dacă fixăm un alfabet, putem vorbi de șiruri de caractere (strings) din acel alfabet. Putem de asemenea vorbi de limbaje: un limbaj este o mulțime de șiruri. Definiția aceasta pare foarte simplă, dar este foarte utilă: de aici înainte putem enunța absolut toate procesările făcute de un calculator în termeni de operații pe limbaje.

De exemplu, putem considera limbajul format din mulțimea șirurilor care încep cu semnul + sau -, urmate de un șir de cifre, urmate eventual de o virgulă, de alte cifre, și eventual niște cifre între paranteze. Acest limbaj poate descrie numerele raționale, inclusiv pe cele periodice, în notația pe care am învățat-o în școala primară; de exemplu -312,413(3) este un șir din această mulțime. Putem de asemenea caracteriza alte limbaje foarte interesante, ca: limbajul tuturor expresiilor aritmetice corecte, limbajul Pascal, format din toate șirurile de caractere care sunt programe Pascal corecte, etc.

Un calculator va primi deci la intrare un șir finit de caractere, și va produce la ieșire un altul. Putem măsura foarte precis lungimea unui șir de caractere prin numărul de caractere care-l compun. Aceasta este definiția ``mărimii'' datelor de intrare.

Un algoritm se va aștepta ca datele de intrare să fie dintr-un anumit limbaj; în acest caz, el va transforma fiecare cuvînt de la intrare într-un cuvînt dintr-un alt limbaj. De pildă, un algoritm care sortează un șir de numere se așteaptă ca la intrare datele să fie un șir de numere separate de spații; pentru fiecare astfel de șir de numere, el va produce la ieșire un alt șir de numere. Limbajul datelor de intrare este format din șirurile de numere separate de spații, limbajul de la ieșire este același, dar toate numerele dintr-un șir la ieșire vor fi sortate. Ce face algoritmul dacă datele de intrare nu sunt un șir de numere, ci niște gunoaie? Putem spune că nu ne interesează comportarea algoritmului în astfel de cazuri.

Vedeți, aparent desfacem firul în patru: cine are nevoie de niște definiții atît de amănunțite, încît sunt aproape lipsite de sens? Matematicienii au nevoie: după ce am clarificat exact noțiunile cu care operăm, avem foarte multă libertate în a le manipula în mod precis.

Operații cu limbaje

Adesea vom specifica limbajele pornind de la limbaje mai simple, cu ajutorul unor operații. De exemplu, putem construi limbaje intersectînd două limbaje, sau luînd reuniunea lor: doar avem de-a face cu mulțimi de cuvinte. De exemplu, dacă intersectăm limbajul tuturor cuvintelor din limba româna -- să-l notăm cu R, un limbaj format din toate cuvintele din DEX și derivatele lor -- cu limba latină vom obține un nou limbaj, care constă din toate cuvintele moștenite din latină.

Alte operații pe care le putem face sunt: complementarea unui limbaj (complementul unui limbaj L este cel format din toate cuvintele care nu sunt în L) sau concatenarea a două limbaje (L1 concatenat cu L2 este un limbaj format din cuvinte pentru care prima parte e un cuvînt din L1 iar a doua din L2). De exemplu, limbajul propozițiilor românești de două cuvinte este un subset al limbajului R concatenat cu el însuși.

Decidabilitate

Un loc aparte în studiul complexității îl au mașinile care dau totdeauna un răspuns ``da'' sau ``nu''. Cu alte cuvinte, limbajul de ieșire are doar două elemente. Spunem despre astfel de mașini că decid un limbaj: limbajul decis este format din cuvintele de la intrare pentru care mașina răspunde ``da''. Toate problemele pentru care așteptăm un răspuns ``da'' sau ``nu'' se numesc ca atare probleme de decizie; problema SAT, a satifiabilității, căreia i-am dedicat două numere în PC Report, este un exemplu de problemă de decizie.

Problemele de decizie sunt o subclasă foarte interesantă a tuturor problemelor, pentru că adesea putem enunța orice problemă ca o colecție de probleme de decizie. Această noțiune a fost prezentată în căzut problemei SAT ca ``autoreducere''. Ideea este însă destul de simplă.

De exemplu, să considerăm problema ``care sunt factorii primi ai numărului n?''. Putem reduce această problemă la mai multe probleme de decizie, care ne dau exact aceeași informație: ``este 2 un factor prim al lui n'', ``este 3 ...'', etc. Dacă știm răspunsurile la toate aceste probleme de decizie, putem obține răspunsul și la problema inițială.

Mașina Turing

Am clarificat cum arată datele; cum măsurăm însă resursele consumate? Consumate de fapt de cine?

Aveți perfectă dreptate: ne trebuie un model precis de calcul. Cel mai folosit este un model propus în anii treizeci de marele matematician englez Alan Turing: mașina Turing. Mașina Turing este un calculator redus la esențe, abstractizat. Mașina Turing (figura 2) este compusă din următoarele piese:

Figura 2: Mașina Turing este un model de calcul abstract propus de matematicianul Alan Turing. Ea constă dintr-o bandă infinită, pe care pot fi scrise simboluri, un cap de scriere-citire, care poate fi plimbat pe suprafața benzii, și o unitate de control, care cuprinde un număr finit de reguli care indică mașinii ce să facă la fiecare mișcare în funcție de litera curentă de pe bandă și starea în care mașina se află. În pofida simplității ei, mașina Turing poate calcula orice poate fi calculat cu cele mai performante supercomputere.
\begin{figure}\centerline{\epsfxsize=8cm\epsffile{turing.eps}}\end{figure}

  1. O bandă infinită de hîrtie cu pătrățele; în fiecare pătrățel se poate scrie exact un caracter din alfabetul nostru; banda este inițial plină cu ``spații'', mai puțin partea de la început, unde este scris șirul cu datele de intrare;

  2. Un cap de citire-scriere, care se poate mișca deasupra benzii, la stînga sau la dreapta;

  3. O unitate de control, care conține un număr finit de reguli. Unitatea de control este la fiecare moment dat într-o stare; stările posibile sunt fixate dinainte, și sunt în număr finit. Fiecare regulă are forma următoare:

    Dacă

    atunci:

Și asta-i tot! Un algoritm de calcul este descris de o astfel de mașină, prin toate stările posibile, și toate aceste reguli, numite reguli de tranziție, care indică cum se trece de la o stare la alta.

``Ce e prostia asta?'' ați putea exclama, ``ce poți face cu mașina asta handicapată?'' Ei bine, poți face...totul. Alegeți limbajul dumneavoastră favorit, să zicem C; ei bine, se poate descrie o mașină Turing care să interpreteze (adică să execute) orice program C care i s-ar da scris pe bandă. Putem mima intrarea și ieșirea unui calculator cerînd ca datele de intrare să fie scrise de la început pe bandă, iar ca datele de ieșire să apară pe bandă cînd mașina termină de calculat1.

Orice alte modele de calcul care au fost propuse de-a lungul timpului, au fost dovedite a fi mai puțin expresive, sau tot atît de expresive cît mașina Turing. Nimeni nu a fost în stare, pînă acum, să zică: ``eu cred că de fapt mașina Turing e prea limitată; iată care zic eu că sunt operațiile elementare pe care le avem la dispoziție, din care ar trebui să exprimăm orice algoritm'', și să ofere ceva care să fie construibil, și care să poată face lucruri pe care mașina Turing nu le poate face.

Din cauza asta logicianul Alonzo Church a emis ipoteza că mașina Turing este modelul cel mai general de calcul care poate fi propus; acest enunț, care nu este demonstrabil în sens matematic, se numește ``Teza lui Church''. Acesta este un postulat asupra căruia trebuie să cădem de acord înainte de a putea conversa orice lucru privitor la teoria complexității.

Dacă avem un model de calcul, putem defini foarte precis ce înțelegem prin complexitate:

Timpul de calcul
pentru un șir dat la intrare, este numărul de mutări făcut de mașina Turing înainte de a intra în starea ``terminat'';

Spațiul
consumat pentru un șir de intrare, este numărul de căsuțe de pe bandă pe care algoritmul le folosește în timpul execuției sale.

Clase de complexitate robuste

Cînd spun că mașina Turing este la fel de puternică ca orice alt model de calcul, nu înseamnă că poate calcula la fel de repede ca orice alt model de calcul, ci că poate calcula aceleași lucruri.

Putem să ne imaginăm tot felul de modificări minore ale mașinii Turing, care o vor face să poată rezolva anumite probleme mai repede. De exemplu, putem să ne imaginăm că mașina are dreptul să mute capul la orice căsuță dintr-o singură mișcare, fără să aibă nevoie să meargă pas-cu-pas; atunci banda s-ar comporta mai asemănător cu o memorie RAM obișnuită.

Atunci cum de putem discuta despre complexitate, dacă timpul de rezolvare depinde de modelul de execuție?

Aici teoria complexității se desparte de teoria algoritmilor; teoria algoritmilor folosește de regulă modelul RAM pentru a evalua complexitatea unui algoritm. Teoria complexității face niște diviziuni mai grosolane.

Și anume, există niște clase de complexitate care sunt complet invariante la variatele definiții ale mașinii Turing: că are două capete de acces la bandă, că poate sări oriunde, complexitatea rămîne aceeași. O astfel de clasă de complexitate este clasa tuturor problemelor care se pot rezolva în timp polinomial, o alta este cea a tuturor problemelor care se pot rezolva în spațiu polinomial, etc. Aceste clase de complexitate sunt numite ``robuste'', pentru că definiția lor nu depinde de modelul de mașină. Acest lucru se poate demonstra arătînd că o mașină de un anumit model poate simula pe cele diferite fără a cheltui prea multe resurse suplimentare.

Teoria complexității mai decretează și că toate problemele care se pot rezolva în timp polinomial sunt rezolvabile, iar toate cele care au nevoie de mai mult timp sunt intractabile. Această definiție este în general adevărată din punct de vedere practic, dar trebuie luată cu un grăunte de sare: cîteodată un algoritm n3 este inacceptabil, dacă problema este prea mare, altădată unul de timp chiar exponențial este tolerabil, dacă mașina cu care-l rulăm este foarte rapidă. Din punct de vedere teoretic, separația însă este perfect acceptabilă, pentru că întotdeauna ne gîndim la probleme pentru care mărimea datelor de intrare tinde spre infinit.

Aș vrea să mai fac o observație foarte importantă: o problemă este foarte strîns legată de limbajul în care o exprimăm. Iată un exemplu: să considerăm problema înmulțirii a două numere. Dacă limbajul în care lucrăm exprimă numerele în baza 2 (sau orice altă bază mai mare de 2), atunci se cunosc algoritmi de complexitate n log n pentru a face înmulțirea. Pe de altă parte, dacă insistăm să scriem numerele în baza 1 (adică numărul 5 este reprezentat de cinci ``bețișoare'', ca la clasa I), atunci, în mod evident, complexitatea înmulțirii a două numere de lungime totală n este n2: chiar rezultatul înmulțirii lui n cu n este scris cu n2 bețișoare, deci nu are cum să ne ia mai puțin timp, pentru că trebuie să scriem rezultatul!

Și mai spectaculoasă este diferența pentru algoritmul care verifică dacă un număr este prim: dacă limbajul de intrare exprimă numerele în baza 2, cel mai bun algoritm cunoscut are complexitatea 2n, exponențială, dar dacă scriem numerele în baza 1, complexitatea este n2. Acest lucru se întîmplă pentru că un număr scris în baza 1 este mult mai lung decît scris în baza 2 n este scris în baza 2 cu log n cifre).

Deci, cînd enunțați o problemă pentru rezolvare cu calculatorul, trebuie să specificați și limbajul în care problema este descrisă; abia apoi puteți discuta despre complexitatea rezolvării.

Simulări; mașina Turing universală

Oare cum au reușit matematicienii să demonstreze că mașina Turing este atît de puternică încît este echivalentă cu orice alt model propus de calcul? De pildă, un model extrem de faimos, care este folosit încă foarte ades în teoria limbajelor de programare, este modelul lambda-calculului. Acest model manipulează niște formule simple conform unor reguli de re-scriere.

Ce sens are să spunem că mașina Turing este la fel de puternică precum lambda-calculul?

Răspunsul stă în simulare: putem arăta că, orice am face cu lambda-calculul, putem efectua și cu o mașină Turing. Există deci o corespondență între fiecare transformare efectuată de regulile lambda-calculului și un set de transformări pe care mașina Turing le poate face cu simbolurile de pe bandă.

De fapt mașina Turing este atît de puternică încît putem construi o mașină Turing care să simuleze orice altă mașina Turing posibilă.

Pentru a înțelege cum este posibil așa ceva, trebuie să realizăm două lucruri:

Dacă vă gîndiți puțin veți realiza că de fapt aceste lucruri nici măcar nu sunt prea surprinzătoare: de fapt un interpretor de BASIC face același lucru: primește descrierea unei mașini (programul BASIC) și apoi simulează execuția acestei mașini pe niște date de intrare.

calculabilitate

Teorema găurii; diagonalizarea

O întrebare pe care un matematician și-o pune imediat este: are vreo importanță cîte resurse dăm unei mașini Turing? Nu cumva totul se poate calcula cu aceeași cantitate de resurse?

Există niște teoreme deosebit de interesante din acest punct de vedere. O teoremă arată că dacă dăm unei mașini mai mult timp (sau spațiu), atunci ea poate efectua lucruri pe care nici una din mașinile care au mai puțin timp nu le poate efectua. Această teoremă se numește ``teorema găurii'' (gap theorem), pentru că arată că între feluritele clase de complexitate există diferențe: clasa limbajelor care se pot decide în timp n3 este diferită de clasa limbajelor care se pot decide în timp n4.

Enunțul teoremei este de fapt destul de încîlcit, și se bazează pe niște detalii tehnice, pe care o să le trec sub tăcere. Demonstrația este însă relativ simplă, și se bazează pe o tehnică ades folosită în teoria complexității, numită diagonalizare. Această tehnică a fost folosită în demonstrația lui Cantor, pentru a arăta că numerele reale nu sunt numărabile.

Ideea de bază este următoarea: mașina cu mai multe resurse își poate permite să facă orice face mașina cu mai puține resurse, iar după aceea să mai și prelucreze rezultatul. Cu alte cuvinte, putem construi o mașină cu resurse mai multe, care dă un rezultat diferit de orice mașină mai simplă. Rezultă ca mașina cu mai multe resurse calculează o funcție diferită de toate celelalte!

Numărabilitate

Teorema găurii se bazează în mod explicit pe faptul că nu există prea multe mașini Turing! Din moment ce fiecare mașină Turing poate fi descrisă printr-un șir de caractere finit, înseamnă că putem enumera toate mașinile Turing. Există astfel un număr numărabil de mașini Turing (deci cu mult mai puține decît există numere reale!).

O consecință interesantă a acestui fapt este că calculatoarele nu pot manipula numerele reale. Există mai multe numere reale decît algoritmi posibili! Limbajele care operează cu numere reale, de fapt manipulează niște aproximări cu cîteva zeci sau zecimale ale acestora. Nu putem face mai bine de atît nici dacă operăm cu reprezentări simbolice ale numerelor reale (vom putea manipula unele numere reale, dar nu pe toate; vor exista întotdeauna operații cu numere reale care nu sunt calculabile).

Există și alte consecințe interesante ale acestui fapt. De exemplu, pute privi mașinile Turing ca pe niște aparate care calculează funcții de la numerele naturale (N) la N. Ei bine, există $2^{\vert\mbox{\bf N}\vert}$ astfel de funcții, adică puterea continuului (adică tot atîtea cît numere reale). Dar mașinile Turing sunt doar în număr de $\vert\mbox{\bf N}\vert$, deci mult mai puține. Asta înseamnă că există o sumedenie de funcții N $\rightarrow$ N care nu se pot calcula cu calculatoarele!

Ce dacă, veți spune, poate toate funcțiile care ne interesează în practică se pot de fapt calcula. Vom vedea de fapt că există funcții extrem de importante care nu sunt calculabile, oricît de multe resurse am pune la bătaie!

Problema opririi (the halting problem)

Ramura extremă a teoriei complexității, care se ocupă cu lucrurile care nu se pot calcula, nici dacă avem o cantitate nelimitată de resurse, se numește teoria calculabilității.

Teoria calculabilității are o sumedenie de rezultate foarte interesante, și implicații foarte mari pentru teoria compilatoarelor (dar nu numai).

Am văzut că putem descrie orice mașină Turing folosind un șir de caractere; putem apoi face prelucrări asupra acestui șir de caractere, pentru a calcula tot felul de proprietăți ale mașinii Turing codificate astfel. De fapt exact asta face și un compilator: primește un program într-un limbaj (adică descrierea unei mașini Turing), și generează un alt program ``optimizat'', care face același lucru, dar mai eficient.

Asta e foarte frumos: putem folosi chiar mașinile Turing pentru a transforma și calcula lucruri despre mașini Turing. Din păcate optimismul nostru trebuie temperat: sunt foarte multe lucruri pe care nu le putem calcula.

De pildă ne putem pune, poate cea mai naturală, întrebare: dacă bag anumite date de intrare într-o mașina Turing, după cît timp îmi dă rezultatul? Îmi dă rezultatul vreodată, sau intră într-o buclă infinită?

La această întrebare putem răspunde enunțînd cea mai faimoasă teoremă din teoria calculabilității, ``teorema opririi'': nu există o mașină Turing, care primind la intrare descrierea unei alte mașini Turing T și un șir de date de intrare x, să poată spune dacă T se oprește vreodată cînd primește pe x la intrare. Figura 3 ilustrează problema opririi.

Figura 3: Problema opririi: o ipotetică mașină care ar putea rezolva problema opririi ar primi descrierile altei mașini pe bandă sîa intrărilor lor, și ar oferi un răspuns ``da'' sau ``nu'', după cum mașina evaluată termină vreodată execuția sau nu.
\begin{figure}\centerline{\epsfxsize=10cm\epsffile{oprire.eps}}\end{figure}

Teorema asta a fost demonstrată de Alan Turing, în 1936, deci cu mult înainte să existe calculatoarele în sensul modern al cuvîntului.

Demonstrația teoremei opririi este extrem de simplă, și se face prin reducere la absurd. Demonstrația este extrem de înrudită cu celebrul paradox al mincinosului, cunoscut de grecii antici, care spune că fraza ``Eu mint'' nu poate fi nici adevărată, nici falsă, pentru că în orice caz s-ar auto-contrazice.

Să presupunem că există o mașină H, care rezolvă problema opririi. Atunci vom construi o nouă mașină, să-i spunem H1, care face următorul lucru:

  1. Primește la intrare o mașină M și un șir x;

  2. Simulează funcționarea lui H pe această intrare, pentru a vedea dacă M se oprește atunci cînd primește pe x;

  3. Dacă H zice ``da, M se oprește'', atunci H1 intră într-o buclă infinită;

  4. Dacă H zice ``nu'', atunci H1 se oprește imediat.

Din moment ce H1 este o mașină Turing, putem să o descriem și pe ea însuși folosind un șir de caractere. Ce se întîmplă însă dacă pornim mașina H1 avînd pe banda de la intrare chiar propria ei descriere, de două ori, o dată pe post de M și o data pe post de x?

Ei bine, dacă H1 se oprește cu această intrare, atunci înseamnă că H1 va executa pasul (3) de mai sus, deci intră într-o buclă infinită, contradicție!

Dacă H1 nu se oprește niciodată, atunciH1 va executa pasul (4), deci se va opri imediat, altă contradicție!

Aceste comportări sunt aberante, deci presupunerea noastră că H1 există trebuie să fie falsă; dar H1 este construită folosind H și cîteva piese banale, deci H nu există!

Acest rezultat este extrem de important pentru practică. Aceasta ne spune că nu vom putea niciodată să verificăm automat corectitudinea oricărui algoritm, pentru că în general nu putem spune dacă un algoritm se va termina vreodată.

Consecințe ale teoremei opririi

De asemenea, rezultă o serie întreagă de consecințe foarte importante. Iată unele dintre ele, din perspectiva teoriei compilatoarelor:

  1. Este imposibil să spunem dacă două programe sunt echivalente sau nu (adică dacă produc aceleași rezultate cînd primesc aceleași date de intrare);

  2. Este imposibil să spunem dacă rezultatul unui program poate fi vreodată zero;

  3. Este imposibil să spunem dacă toate accesele unui program într-un vector de elemente sunt în interiorul vectorului, sau programul poate accesa memorie nealocată;

  4. Este imposibil să spunem dacă un program este cel mai mic program care implementează o anumită funcție.

Atenție: nu vreau să spun că aceste lucruri sunt nedecidabile pentru orice program: e clar că două programe Pascal identice sunt echivalente. Ceea ce această teoremă spune, este că există programe pentru care aceste lucruri nu pot fi determinate.

Dacă restrîngem setul de programe pe care-l putem scrie, atunci putem desigur demonstra mai multe lucruri despre programele noastre. De exemplu, în limbajul Java, suntem asigurați de faptul că un program nu va accesa niciodată memorie nealocată, pentru că înainte de a verifica orice acces la memorie, mașina virtuală Java verifică dacă aceasta a fost alocată sau nu.

Dar e vorba de limbaje diferite de intrare: programele scrise în Java sunt un subset al tuturor programelor.

Semi-decidabilitate

După cum am văzut, există probleme pentru care nu putem niciodată calcula răspunsul.

Putem însă identifica printre problemele indecidabile o submulțime, a problemelor semi-decidabile, acele probleme pentru care putem calcula ``jumătate'' de răspuns: putem răspunde întotdeauna corect cînd suntem întrebați de o instanță ``da'', dar cînd suntem întrebați de o instanță ``nu'' s-ar putea să dăm răspunsul corect, sau s-ar putea să nu terminăm niciodată de calculat.

Problema opririi este de fapt o problema semi-decidabilă: dacă ni se dă o mașină M și un șir x pentru care mașina se oprește, vom fi capabili să descoperim acest lucru simulînd funcționarea mașinii M cu intrarea x pînă atinge starea finală. Dacă M însă nu se oprește cînd primește pe x, nici simularea nu se va termina niciodată.

Oracole

O întrebare interesantă este următoarea: sunt tot felul de probleme indecidabile; dar sunt cumva unele dintre ele mai grele decît altele? Poate să pară ciudat că întrebăm astfel de lucruri despre probleme pe care oricum nu le putem rezolva, dar răspunsul este și mai surprinzător.

Contextul matematic pentru a formula aceste întrebări este numit al ``oracolelor''. Putem găsi o interpretare filozofică interesantă pentru acest gen de construcții: să zicem că facem un pact cu diavolul, care ne va răspunde corect la anumite întrebări nedecidabile. De pildă, ne va da întotdeauna răspunsul corect la întrebări despre oprirea unei mașini Turing. Vedeți, răspunsul există, orice mașină fie se oprește, fie nu se oprește, ceea ce nu există este o metodă de a calcula răspunsul. Să zicem că Mefistofel însă ne poate da răspunsul, folosind puterile sale supranaturale.

Vom vedea că noțiunea de oracol este foarte utilă nu numai atunci cînd vrem răspunsuri la întrebări nedecidabile, ci și atunci cînd vrem răspunsuri la întrebări pentru care nu avem destule resurse. Mai multe despre asta în partea a doua a acestui articol, în numărul viitor al revistei.

Matematic, un oracol este o a doua bandă pentru mașina noastră, pe care sunt scrise dinainte răspunsurile (corecte!) la întrebările pe care mașina le pune (vedeți figura 4).

Figura 4: Mașini cu Oracole: un oracol este o anumită funcție pe care mașina nu o poate calcula, dar de care se poate folosi. Matematic modelăm oracolul printr-o bandă infinită, pe care sunt dinainte scrise răspunsurile corecte la toate întrebările pe care mașina le-ar putea pune. În general ne interesează să vedem ce ar putea face în mod suplimentar o mașina Turing dacă ar putea răspunde unor anumite întrebări (dar nu oricărei întrebări); atunci o echipăm cu un oracol care știe toate răspunsurile la întrebările în chestiune.
\begin{figure}\centerline{\epsfxsize=10cm\epsffile{oracol.eps}}\end{figure}

Ierarhia aritmetică

Dacă avem un oracol, mai există lucruri pe care nu le putem calcula? Exista alte întrebări la care nu putem răspunde?

Ei bine, da. Există întrebări care sunt în continuare ne-decidabile. De exemplu, întrebarea ``este limbajul acceptat de mașina M finit'' nu poate fi elucidată nici în prezența unui oracol pentru problema opririi.

Dar dacă presupunem că avem un oracol care ne răspunde și la această întrebare? Mai există și altceva care nu se poate calcula? Din nou, răspunsul este ``da'': de exemplu nu putem răspunde nici acum la întrebarea ``este limbajul acceptat de mașina M co-finit'' (adică șirurile ne-acceptate sunt în număr finit).

Și așa mai departe: există o ierarhie infinită mașini de oracole din ce în ce mai puternice, care pot răspunde la aceste întrebări, dar care sunt neputincioase în fața unor întrebări mai complicate. Această ierarhie de probleme ne-decidabile se numește ierarhia aritmetică.

Concluzii

Voi încheia aici prima parte a acestui articol. Subiectele prezentate aici pot fi caracterizate ca făcînd parte din pre-istoria teoriei complexității: sunt fapte cunoscute începînd din anii 1920 pînă prin deceniul șase al secolului nostru. În numărul următor din PC Report îmi voi apleca atenția asupra evoluțiilor recente din domeniu, care sunt extrem de spectaculoase, și voi privi cu mai multă atenție problemele care se pot ataca avînd resurse limitate.

În textul de față am studiat mai mult problemele care nu se pot rezolva, oricît de multe resurse computaționale am avea la dispoziție, numite probleme indecidabile. Am văzut că foarte multe probleme practice sunt indecidabile; mai ales problemele care privesc programele însele.

Dar importanța acestui articol constă mai ales în faptul că arată cum se pot formaliza foarte precis noțiunile de proces de calcul și funcție calculată de un program, în așa fel încît să le putem supune ochiului scrutător al matematicii.

Alte surse de informație

Cititorilor care doresc să aprofundeze aceste subiecte le recomand următoarele cărți:

Automata and Computability, Dexter Kozen, Springer Verlag, 1996.

Computational Complexity, C. H. Papadimitriou, Addison-Wesley, 1994.

Introduction to Automata Theory, Languages and Computation, J. E. Hopcroft and J. D. Ullman, Addison-Wesley, 1979.

Teoria clasică a complexității

Acest text este partea a doua a unui foileton despre teoria complexității, cea mai abstractă ramură a informaticii. Deși încerc ca fiecare articol din serie să fie de sine-stătător, o vedere de ansamblu poate fi obținută parcurgîndu-le pe toate; textele anterioare sunt disponibile (pe lîngă revista PC Report) și din pagina mea de web.

Am consacrat acestei teme două texte în lunile octombrie și noiembrie, despre problema satisfiabilității boolene, și numărul din decembrie al revistei, care introducea obiectele de bază cu care operează teoria complexității, după care discuta despre calculabilitate.

Cu această ocazie vreau să fac și o corectură: teorema pe care am prezentat-o în numărul anterior ca ``teorema găurii'' este de fapt numită teorema ierarhiei; enunțul teoremei găurii este altul, dar nu o să-l prezint aici.

Recapitulare

Voi reaminti cititorilor că teoria complexității discută despre ceea ce se poate calcula și nu se poate calcula atunci cînd avem anumite resurse la dispoziție. Teoria calculabilității discută cazul extrem al lucrurilor care nu se pot calcula deloc, chiar dacă avem la dispoziție o cantitate infinită de resurse. În mod surprinzător, există foarte multe lucruri care nu se pot calcula, așa cum am arătat în articolul anterior.

Pentru a putea discuta precis despre ce se poate calcula, teoria complexității operează cu niște modele matematice ale mașinilor de calcul, descrise sub forma unor automate simple, care comunică cu lumea exterioară folosind niște benzi pe care sunt scrise litere, și care au o unitate de control cu un număr finit de reguli, care indică automatului care este pasul următor de făcut în fiecare circumstanță.

Modelul cel mai ades folosit este cel al mașinii Turing, numite în cinstea marelui matematician englez, Alan Turing. Turing a fost unul dintre pionierii acestei teorii; el este însă mai cunoscut prin activitatea lui din al doilea război mondial, cînd a contribuit la spargerea cifrurilor mașinii Enigma, care cripta comunicațiile Germaniei naziste. Munca lui Turing a permis aliaților să intercepteze informații foarte importante despre mișcările mașinii de război germane.

Turing era un matematician de excepție, care studiase la Princeton cu Albert Einstein, și colaborase cu John von Neumann, părintele american al calculatoarelor. Din nefericire soarta lui Turing a fost tragică: contribuțiile sale militare din al doilea război mondial au rămas confidențiale pentru multă vreme; după război a fost condamnat pentru homosexualitate, și forțat să urmeze un tratament hormonal, despre care autoritățile timpului credeau că poate ``vindeca'' această ``boală''. Influența medicamentelor și umilirea îndurată însă au fost prea mult pentru Turing, care s-a sinucis înghițind cianura, în 1954, la doar 42 ani. În cinstea sa, cel mai important premiu care recunoaște contribuțiile în domeniul informaticii, decernat anual de prestigioasa asociație ACM (Association for Computing Machinery), este numit premiul Turing.

În mod interesant, deși mașina Turing este un mecanism foarte ``primitiv'', nimeni nu a reușit să sugereze un model de calcul mai puternic. În măsura în care funcționarea neuronilor din creierul uman este cea descrisă de neurofiziologi, creierul însuși poate fi simulat cu o mașină Turing (dar există mult mai multe necunoscute decît certitudini în ceea ce privește funcționarea creierului). Dacă acceptăm această ipoteză (numită și teza lui Church), putem privi rezultatele teoriei complexității ca pe niște reguli universale, care privesc toate ``aparatele'' care procesează informație în univers.

Să ne mai amintim că obiectele procesate de mașinile Turing sunt limbaje, adică mulțimi de cuvinte. O mașină Turing primește la intrare un cuvînt dintr-un anumit limbaj, și produce la ieșire un cuvînt dintr-un alt limbaj, care este răspunsul așteptat. Dacă răspunsurile sunt doar ``da'' sau ``nu'' (adică un singur bit), atunci spunem că mașina Turing decide limbajul format din toate cuvintele pentru care răspunsul este ``da''.

Pietre de hotar

Putem împărți istoria teoriei complexității, de la origini pînă în prezent, în cinci etape mari. Iată-le descrise pe scurt:

Programul formalist al lui Hilbert

Putem considera ca părinte al teoriei complexității pe marele matematician german David Hilbert (1862-1943). Hilbert a încercat să fundamenteze matematica pe baze axiomatice; el a recunoscut faptul că între logică și matematică există o relație foarte strînsă; el a observat că demonstrarea oricărei probleme din matematică se poate reduce la verificarea satisfiabilității unei formule din calculul predicativ2.

Hilbert vedea ca încununare a matematicii producerea unui algoritm care ar putea să rezolve problema satisfiabilității, care ar putea fi atunci folosit pentru a demonstra în mod mecanic orice teoremă.

Teorema lui Gödel și teorema lui Turing

Visele lui Hilbert au fost însă spulberate de matematicianul Kurt Gödel și faimoasa sa teoremă de incompletitudine. Gödel a arătat că un algoritm de decizie pentru satisfiabilitate nu există, pentru orice sistem axiomatic care este suficient de cuprinzător pentru a descrie mulțimea numerelor naturale și operațiile aritmetice pe ea. Dacă operăm cu structuri mai simple, ca algebra booleană, introdusă în articolul nostru despre satisfiabilitate, problema este decidabilă; dacă însă structura matematică este suficient de bogată, (și mulțimea numerelor naturale este în definitiv o structură foarte simplă, fără de care cu greu putem vorbi de matematică evoluată), există propoziții adevărate care nu pot fi demonstrate matematic. Voi clarifica într-un alt episod semnificația acestui enunț.

O altă problemă fundamentală, propusă de Hilbert, era de a verifica dacă un sistem dat de axiome este consistent sau nu (cu alte cuvinte, dacă axiomele nu se contrazic între ele). De exemplu, știm din liceu că axioma paralelelor a lui Euclid, din geometria plană, este independentă de celelalte axiome ale geometriei, și că putem construi geometrii în care această axiomă este adevărată, precum și geometrii perfect coerente în care axioma este falsă. Hilbert își dorea posibilitatea de a verifica dacă nu cumva un sistem ales de axiome se contrazice pe sine. A doua teoremă de incompletitudine a lui Gödel a sfărîmat și acest vis al lui Hilbert, demonstrînd că este imposibil de demonstrat consistența unui sistem de axiome raționînd doar în interiorul sistemului (cu alte cuvinte, nu putem demonstra consitența axiomelor pornind doar de la acele axiome); dacă vrem să facem acest lucru, trebuie să raționăm într-un sistem mai cuprinzător decît cel studiat. Avem astfel o reducere infinită, pentru că putem pune apoi chestiunea consistenței sistemului mai cuprinzător, etc.

Teorema lui Cook și NP-completitudinea

Cu timpul oamenii s-au obișnuit cu ideea ca sunt lucruri care nu se pot calcula. Despre cele care se puteau calcula însă erau convinși că sunt la-ndemînă. Teoria complexității însă a arătat că fiecare problemă are o complexitate inerentă; anumite probleme se pot rezolva repede, altele mai greu. Există probleme care au o complexitate exponențială: dacă crești datele de intrare cu o constantă, timpul de calcul se dublează. Astfel de probleme devin foarte repede intractabile: dacă creștem problema de 100 de ori, complexitatea crește de 2100 ori, depășind astfel vîrsta estimată a universului! Chiar dacă începeam să calculăm la Big Bang, astăzi tot n-am fi terminat!

Teorema lui Cook, demonstrată în 1971, a turnat și mai mult gaz peste foc: există foarte multe probleme care au soluții simple, și ușor de verificat (adică putem verifica foarte eficient dacă o pretinsă soluție este corectă), dar pentru care nimeni nu știe cum să găsească o soluție suficient de rapid!

Din acel moment, problema centrală a teoriei complexității s-a mutat în această zonă: am renunțat să mai rezolvăm problemele inerent grele, putem oare măcar rezolva acest gen de probleme, ale căror soluții se pot verifica ușor? Aceste probleme formează o clasă numită NP, despre care vom vorbi mai mult în cuprinsul acestui articol. Întrebarea centrală a teoriei complexității este dacă P=NP. (P este clasa problemelor pe care le putem rezolva rapid.)

S-au publicat mai multe demonstrații eronate ale ambelor aserțiuni: P=NP și P $\not=$ NP. Deocamdată răspunsul rămîne un mister. Teoreticienii au atacat problema din tot felul de perspective, încercînd să demonstreze că pentru problemele din NP sunt necesare strict mai multe resurse decît pentru cele din P; atunci am ști că cele două clase sunt diferite. Desigur, doar existența unei diferențe între cele două clase nu este totul pentru practicieni, dacă nu știm și cît de mare este această diferență. Tot ce știm este că NP este cuprinsă între P și EXP; P este clasa problemelor cu o complexitate polinomială, iar EXP cea a problemelor cu complexitate exponențială; între aceste două clase există o sumedenie de clase intermediare, de exemplu cea a problemelor cvasi-polinomiale, care sunt mai mari decît cele polinomiale, dar mai mici decît cele exponențiale (de exemplu funcția nlog n). Chiar un rezultat care ar separa NP de EXP ar fi grozav, pentru că ne-ar da soluții pentru problemele din NP mai bune decît cunoaștem la ora actuală.

Teoreticienii aveau practic două scule fundamentale în repertoriu pentru a raționa despre complexitate: diagonalizarea și simularea, ambele prezentate pe scurt în prima parte a acestui articol, în numărul anterior al revistei.

Simularea se folosește de faptul că o mașină Turing adesea se poate comporta ca o altă mașină Turing, ``simulîndu-i'' execuția. Această tehnică se numește în viața de zi cu zi ``interpretare'', și este exact ceea ce se întîmplă cu limbajele interpretate: BASIC, Lisp, etc: interpretorul de BASIC simulează execuția programelor scrise în BASIC.

Diagonalizarea este o tehnică foarte ingenioasă pentru a arăta că un obiect nu este într-o mulțime, pentru că este diferit de fiecare obiect din mulțime printr-o anumită trăsătură.

Teoreticienii au încercat să demonstreze că P $\not=$ NP arătînd că mașinile din NP sunt diferite de toate mașinile din P. Dar aceste încercări au fost brusc curmate de un rezultat șocant.

Teorema lui Solovay; relativizare

În 1977 logicianul Solovay a demonstrat o teoremă foarte importantă, care a arătat că orice metodă bazată pe diagonalizare și simulare este sortită eșecului. Este deci un fel de meta-teoremă, care arată că o întreagă clasă de metode de demonstrație sunt neputincioase în a diferenția P de NP. Acest rezultat a paralizat complet cercetarea din domeniu pentru cîțiva ani. Voi încerca să prezint aici esența argumentului.

În episodul anterior am introdus noțiunea de mașină Turing echipată cu un oracol. Un oracol este un mecanism (fictiv) care permite unei mașini Turing să rezolve anumite probleme instantaneu. Putem vedea un oracol ca pe o subrutină, care se termină instantaneu, și care poate calcula răspunsul la o anumită întrebare. De exemplu, un oracol pentru problema primalității unui număr este un dispozitiv care ne spune instantaneu dacă numărul este prim sau nu.

În același fel, un oracol pentru clasa NP este un dispozitiv care răspunde instantaneu la orice întrebare din clasa NP. Dacă vă deranjează această abstracție matematică, imaginați-vă că este o subrutină oricît de complicată, dar al cărei timp de execuție nu se cronometrează. Deși abstracte, oracolele sunt niște scule foarte utile pentru teorie, după cum vom vedea din teorema lui Solovay.

Mai introducem o notație: dacă o mașină Turing M are un oracol pentru problema X, atunci notăm mașina cu MX. Asta înseamnă deci că M poate calcula ca orice mașină Turing obișnuită, dar în plus poate răspunde imediat la orice întrebare din X.

Teorema lui Solovay arăta că există două oracole A și B care au următoarele proprietăți: PA = NPA, dar ${\bf P}^B \not= {\bf NP}^B$. Asta înseamnă că există oracole care fac mașinile din NP mai puternice decît cele din P, și există oracole care fac ambele tipuri de mașini la fel de puternice.

De ce este această teoremă catastrofală? Pentru că s-a arătat că orice argument bazat pe diagonalizare sau simulare rămîne adevărat atunci cînd mașinilor li se aplică oracole. Cu alte cuvinte, dacă arătăm prin diagonalizare că o mașină X nu este într-o anumită clasă C, atunci pentru orice oracol Q vom ști sîcă XQ nu este în clasa CQ. Acest fenomen se numește relativizare: teoremele care sunt demonstrate prin diagonalizare sau simulare sunt adevărate și atunci cînd mașinile din teoremă capătă oracole, deci teoremele sunt adevărate relativ la orice oracol.

Acum este clar de ce teorema a fost o veste foarte proastă: nu avem nici o șansă să arătăm cu aceste tehnici că P = NP, pentru că aplicînd oracolul B de mai sus, am arăta automat că PB = NPB, ceea ce e nu poate fi adevărat, conform teoremei lui Solovay. La fel stau lucrurile și pentru propoziția opusă: ${\bf P}\not= {\bf NP}$ nu poate fi demonstrată prin aceste tehnici, pentru că oracolul A ar contrazice acest rezultat.

Teorema lui Razborov-Rudich; demonstrații naturale

Mulți cercetători au fost foarte descurajați de acest rezultat, și au abandonat studiul teoriei complexității cu totul. Toate sculele pe care le aveau la dispoziție erau inutile! Încetul cu încetul însă comunitatea și-a ieșit din apatie, și o nouă tehnologie de demonstrație a fost dezvoltată. Ultima parte a acestui articol va discuta despre această tehnologie mai pe larg, dar o să o introduc aici pe scurt.

Este clar că metoda care este folosită nu trebuie să sufere de relativizare, pentru că atunci conform teoremei lui Solovay ea este neputincioasă în a diferenția P și NP.

Cercetătorii au început atunci să opereze cu un alt model computatîonal, cel al circuitelor neuniforme. Acestea nu sunt altceva decît niște circuite combinaționale, precum cele folosite în construcția calculatoarelor, formate din porți logice ``sau'', ``și'', ``nu''. Circuitele acestea primesc datele la intrare, și generează răspunsul la ieșire. Pentru fiecare mărime de date de intrare avem de-a face cu un alt circuit: pentru datele de intrare de mărime 2 vom avea un circuit cu două intrări, pentru datele de mărime 3 vom avea un circuit cu 3 intrări, etc3.

Cercetarea părea să fie pe calea cea bună; cercetătorii vroiau să demonstreze că P $\not=$ NP arătînd că circuitele pentru probleme NP au nevoie de niște proprietăți suplimentare pentru a putea calcula, comparat cu cele din clasa P; de exemplu sperau să arate că orice problemă din P se poate rezolva cu circuite de o anumită ``adîncime'', pe cînd pentru NPeste nevoie de mai mult de atît.

Rezultatele curgeau unul după altul, aparent apropiindu-se de țină. O lovitură de teatru însă a urmat în 1994: matematicianul rus Razborov și americanul Steven Rudich au demonstrat o a doua teoremă de relativizare, care a avut un șoc comparabil cu cel al teoremei lui Solovay. Această teoremă este tehnic mai complicată decît cea a lui Solovay, dar voi încerca să expun aici esența ei.

Teorema Razborov-Rudich introduce noțiunea de demonstrații ``naturale'' (natural proofs), care manipulează circuitele boolene într-un anumit fel. Apoi cei doi trec în revistă toate rezultatele majore și arată că demonstrațiile folosite sunt naturale, sau echivalente cu demonstrații naturale.

Apoi cei doi demonstrează un rezultat foarte interesant, pe care îl voi parafraza simplificat: dacă există probleme în NP mai grele decît cele din P (adică P $\not=$ NP), atunci nu exista demonstrații naturale că P $\not=$ NP! Cu alte cuvinte, dacă faptul pe care vrei să-l demonstrezi este adevărat, atunci nu poți să-l demonstrezi, cel puțin nu în acest fel!

Situația actuală

Aceasta este situația la ora actuală în teoria complexității. Cel puțin pe frontul P=NP, activitatea este din nou înțepenită: în lipsa unei tehnici noi de demonstrație, orice efort este sortit dinainte eșecului!

Desigur, așa cum arată teorema de incompletitudine a lui Gödel, în orice sistem axiomatic există teoreme adevărate care nu pot fi demonstrate. Este posibil ca chiar teorema P $\not=$ NP să fie o astfel de teoremă! Mai precis, este posibil ca acest enunț să fie independent de teoria axiomatică a mulțimilor Zermelo-Frankel. Atunci clar nu există nici o demonstrație a acestui fapt (ori a opusului lui), și singurul lucru pe care-l putem face este să luăm un astfel de enunț ca pe o axiomă!

Mulți cercetători însă cred că această problemă nu este independentă, și speră în continuare să găsească un răspuns. Pe ce cale se va face aceasta, la ora actuală este complet neclar.

Reduceri și completitudine

În restul acestui articol ne vom ocupa de a doua perioadă din istoricul de mai sus; vom vedea o sumă de rezultate obținute de teoreticieni, vom studia impactul a trei tipuri de resurse asupra calculului: spațiul, timpul și oracolele, și vom vedea o serie întreagă de probleme nerezolvate, unele de o mare importanță practică.

Vom introduce pentru început două noțiuni fundamentale în teoria complexității: cea de reducere între probleme și cea de completitudine a unei probleme într-o clasă de complexitate.

Reduceri

Am întîlnit noțiunea de reducere și în textul meu anterior despre problema satisfiabilității. De data asta vom studia însă un caz mai general.

Noțiunea de reducere este foarte ades folosită în matematică, unde spunem în mod frecvent că ``o problemă transformată într-un anume fel se reduce la o alta''. În teoria complexității reducerea este însă tot un proces de calcul, care transformă instanțe ale unui probleme în instanțe ale alteia. Ca orice procesare făcută de automatele de care ne interesăm, reducerile transformă un limbaj într-altul.

În teoria complexității ne interesează foarte mult și cît de multe resurse consumă procesul de reducere. În general vrem ca reducerea însăși să fie mai simplă decît problema inițială sau cea finală; degeaba reducem o problemă de complexitate polinomială la alta dacă reducerea consumă un timp exponențial!

Dacă problema A se reduce la problema B folosind o reducere de complexitate m, notăm acest fapt cu $A \leq_{m} B$. Folosirea semnului $\leq$ (mai mic sau egal) nu este întîmplătoare: în general o reducere induce o relație de ordine pe mulțimea problemelor, fiind reflexivă (o problemă se reduce la sine însăși) și tranzitivă (dacă $A \leq_m B$ și $B \leq_m C$, atunci $A \leq_m C$). Dacă două probleme se reduc una la alta, spunem că sunt la fel de grele: $A \leq_m B$ și $B \leq_m A$, atunci A e la fel de grea ca B (aceasta este proprietatea de antisimetrie a relației). În general proprietatea de simetrie nu este adevărată: dacă A se reduce la B, B nu se reduce neapărat la A; B poate să fie mai grea decît A.

Odată ce avem o noțiune de reducere, putem defini completitudinea unei probleme referitoare la o clasă. Dacă avem colecția de probleme X, și o problemă $A \in X$, atunci spunem că A este completă în X, sau A este X-completă, vis-a-vis de o reducere cu complexitatea m, dacă pentru orice problemă $B \in X$, $B \leq_m A$.

Figura 5: În această figură mulțimea de probleme A are un set de probleme C care sunt A-complete. Asta înseamnă că rezolvarea oricărei probleme din A se reduce la o problemă din C. Dacă A include o sub-clasă B, atunci dacă B se intersectează cu C, atunci B=A, în sensul că ambele clase sunt la fel de grele. Singura posibilitate ca B sa fie diferit de A este ca B sa nu se intersecteze cu C. Aceste relații stau la baza multor încercări de a demonstra ca P = NP(sau opusul).
\begin{figure}\centerline{\epsfxsize=2.3cm\epsffile{completitudine.eps}}\end{figure}

Clase de complexitate importante

Deocamdată punem deoparte noțiunea de reducere, și discutăm clasele de complexitate cele mai importante studiate. Pentru fiecare din aceste clase se cunosc probleme complete. Vom prezenta clasele în ordinea crescătoare a complexității lor; cel mai adesea însă relația reală între clase consecutive este necunoscută.

Spațiu constant

Prima clasă interesantă de complexitate este cea a mașinilor care folosesc o cantitate fixă de spațiu pentru a-și face calculele, care cantitate este independentă de mărimea datelor de intrare. Chiar cînd datele de intrare tind la infinit, mașina folosește la fel de puțin spațiu.

Un fapt interesant despre această clasă este că, de fapt, dacă dai unei mașini spațiu constant, nu are ce face cu el: există o altă mașină care face același lucru fără nici un fel de spațiu suplimentar. Există un nume special pentru mașinile Turing care nu folosesc nici un fel de spațiu pe bandă (în afară de a citi șirul de la intrare): ele se numesc automate finite.

Toate limbajele care se pot decide în spațiu constant se numesc limbaje regulate. Aceste limbaje sunt extrem de importante în practică; există o seamă întreagă de medii de programare care manipulează limbaje regulate. Programe și interpretoare faimoase care manipulează limbaje regulate sunt Perl, Lex, grep, awk, sed, etc. Perl este de pildă limbajul preferat pentru a scrie extensii pentru serverele de web de pe Internet.

Limbajele regulate sunt atît de utile pentru că se demonstrează că ele pot fi acceptate în timp linear: cu alte cuvinte pentru orice limbaj regulat există o mașină Turing care decide un cuvînt de lungime n în timp O(n). Acest timp este practic cel mai mic timp posibil, pentru că în mai puțin de O(n) nu putem nici măcar citi toate literele de la intrare!

Un exemplu de limbaj regulat ne-trivial: adunarea. Dacă primim trei numere, a, b, c, scrise întrețesut pe bandă (adică o cifră de la a, una de la b, una de la c, atunci putem verifica folosind un automat finit dacă c = a+b.

Spațiu logaritmic

O clasă foarte interesantă de complexitate este cea a limbajelor care pot fi acceptate folosind spațiu logaritmic în lungimea cuvîntului de intrare. Pentru orice cuvînt de lungime n mașina folosește nu mai mult de O(log n) celule de memorie pentru a procesa cuvîntul.

Clasa aceasta se notează cu L, de la Logaritm. Se arată că această clasă este strict mai cuprinzătoare decît cea a limbajelor regulate: există limbaje ne-regulate în L.

De ce folosim tocmai logaritmul? În spațiu logaritmic mașina poate manipula un număr finit de contoare a căror valoare este cuprinsă între 1 și n (fiecare contor are log n cifre). Putem spune deci că clasa L este cea a mașinilor care au nevoie doar să țină minte cîteva poziții din șirul de intrare, și nimic mai mult.

Ce putem face în spațiu logaritmic? O mulțime de lucruri! De pildă putem înmulți două numere: folosim contoare pentru a parcurge numerele de înmulțit, și pentru a face adunarea de la dreapta la stînga.

O altă problemă foarte importantă pe care o putem rezolva doar în spațiu logaritmic este de a calcula valoarea unei formule boolene, atunci cînd cunoaștem valorile tuturor variabilelor care o compun.

Timp polinomial

Cea mai folosită clasă de complexitate este cea a timpului polinomial, notată cu P. Putem defini foarte precis P matematic folosind următoarea notație TIME(f(n))= clasa problemelor care se pot decide în timp f(n). Atunci ${\bf P}\ = \cup_{k \in N} TIME(n^k)$.

Se arată imediat că orice se poate calcula în spațiu logaritmic, nu are niciodată nevoie de mai mult decît timp polinomial, sau altfel spus, ${\bf L}\ \subseteq {\bf P}$.

Practic toți algoritmii eficace învățați în școală sunt din clasa P; teoreticienii consideră că dacă o problemă nu este în P, atunci nu poate fi rezolvată practic, pentru că resursele cerute sunt prea multe. De exemplu, programarea lineară, algoritmul Gauss-Seidel de inversare a unei matrici, sau algoritmul care calculează ieșirea unui circuit combinațional cînd intrarea este dată, sunt toți algoritmi de timp polinomial.

De fapt ultima problemă este chiar P-completă, vis-a-vis de reduceri în spațiu logaritmic. Deci orice altă problemă din P se poate reduce la problema evaluării unui circuit boolean! Observați că evaluarea unui circuit boolean este mai dificilă decît a unei formule boolene, deși orice formulă boolean poate fi scrisă ca un circuit.

Relația exactă dintre L și P nu este cunoscută; nu se știe dacă L este strict inclusă în P sau cele două clase sunt de fapt egale. Dacă cineva ar găsi o metodă de a rezolva problema valorii unui circuit boolean folosind un algoritm din L, atunci am demonstra automat că L = P, datorită completitudinii problemei.

Această întrebare este foarte importantă pentru practică, pentru că algoritmii din L pot fi asimilați cu algoritmii care se pot executa foarte eficient pe o mașină masiv paralelă. Chestiunea L = P se poate deci interpreta ca întrebarea: ``poate orice algoritm din P fi executat foarte eficient în paralel?''.

Spațiu polinomial

În general este ușor de văzut că dacă o problemă are nevoie de timp t atunci nu consumă mai mult de spațiu t, pentru că în fiecare unitate de timp poate consuma cel mult o unitate nouă de spațiu. Dacă notăm cu PSPACE clasa problemelor care consumă spațiu polinomial în mărimea datelor de intrare, atunci avem deci imediat P $\subseteq$ PSPACE.

Ce putem rezolva avînd la dispoziție spațiu polinomial? Ei bine, putem rezolva toate jocurile cu informație perfectă. Mai mult decît atît, aceste probleme sunt PSPACE-complete vis-a-vis de reduceri în timp polinomial. Ce sunt jocurile cu informație perfectă? Sunt toate jocurile gen Go sau dame pe table de mărime n x n (aceasta este mărimea datelor de intrare). Aș spune șah, dar este destul de greu de generalizat șah-ul pentru table arbitrar de mari.

Așa cum știm din teoria jocurilor (sau dacă nu știm, vă spun eu), orice joc de acest gen are o strategie perfectă pentru fiecare jucător, care obține cel mai bun rezultat, independent de mișcările celuilalt. Problema de a afla dacă primul la mutare poate cîștiga mereu este cea a jocurilor cu informație perfectă.

În mod surprinzător (sau poate v-ați obișnuit cu aceste întrebări nerezolvate, și vi se pare naturală ignoranța noastră în acest domeniu), nimeni nu știe dacă P= PSPACE. Ceea ce știm cu siguranță este că L $\subset$ PSPACE este o incluziune strictă.

Interesant, anumite probleme practice foarte utile sunt de asemenea PSPACE-complete; de exemplu problemele de planificare care apar în inteligența artificială, cum ar fi programarea traseului unui robot. O altă problemă importantă care este completă pentru PSPACE este problema pe care o întîlnesc cei care scriu compilatoare atunci cînd analizează un program cu pointeri pentru un limbaj ca C, care permite crearea de pointeri către funcții: determinarea la compilare a informației ``points-to'', care spune spre care obiecte un pointer poate arăta, este și ea completă pentru PSPACE.

Timp exponențial

Deși nu am arătat cum, același gen de demonstrație care arată că L$\subseteq$ P poate fi folosit pentru a arăta că PSPACE $\subseteq$ EXP. EXP este definită ca fiind clasa tuturor problemelor care se rezolvă în timp exponențial: ${\bf EXP}= \cup_{k
\in N} TIME(2^{n^k})$.

Nu avem habar dacă incluziunea PSPACE$\subseteq$ EXP este strictă sau nu.

Spațiu exponențial

Dacă putem folosi timp exponențial, putem folosi și spațiu exponențial. Această clasă monstruoasă este puțin întîlnită în practică, deși există probleme practice care pot fi plasate aici.

Dincolo de spațiu exponențial

Există și clase de complexitate mai mari decît acestea. Vom prezenta o astfel de clasă în ultima parte a acestui text, într-un număr ulterior, cînd discutăm despre complexitatea de decizie a feluritelor teorii logice.

Complexitatea nedeterministă; mașini alternante

Modelul de mașină cu care am lucrat pînă acum este destul de realist, în sensul că pare imediat construibil în practică. Singura ciudățenie abstractă au fost oracolele.

Dar așa cum modelul ezoteric al oracolelor, deși ne-implementabil, a fost de o enormă importanță metodologică, permițindu-ne să tragem niște concluzii foarte importante, teoreticienii au mai elaborat două variațiuni la modelul mașinilor Turing, care se dovedesc extrem de importante în practică.

Modelele pe care le voi introduce acum pot fi simulate cu mașini Turing obișnuite, cu o pierdere oarecare de eficacitate. Dar prezența acestor mașini ne permite să facem niște diviziuni mai fine în interiorul feluritelor clase de complexitate, și să punem niște întrebări foarte importante.

Mașini nedeterministe

Vă reamintiți că definiția mașinii Turing spunea exact ce trebuie să facă automatul în fiecare stare, în funcție de simbolul de pe bandă. Ei bine, acum vom permite o oarecare ambiguitate: mașina noastră va avea posibilitatea să facă nu una, ci mai multe mișcari la un moment dat.

Trebuie să redefinim conceptul de acceptare: înainte mașina accepta dacă secvența ei de stări parcurse se termina cu o stare anumită, numită ``acceptare''. Dar acum, cînd mașina poate urma mai multe trasee, care este criteriul de acceptare? Vom spune că mașina acceptă atunci există cel puțin o cărare care duce la o acceptare.

Figura 6 arată cum stau lucrurile cu astfel de mașini.

Figura 6: O mașină deterministă, de îndată ce are un șir pe banda de intrare, are o traiectorie fixată în spațiul stărilor: putem spune oricînd în ce stare mașina se va afla. Prin contrast, mașinile nedeterministe se pot afla în stări cu mai mulți succesori, în care pot alege traiectoria. O mașină deterministă acceptă dacă cel puțin o traiectorie a ei accepta.
\begin{figure}\centerline{\epsfxsize=11cm\epsffile{nedeterminism.eps}}\end{figure}

Intuitiv putem să ne imaginăm astfel de mașini în două feluri; alegeți-l pe cel preferat:

Mașinile nedeterministe le generalizează pe cele deterministe; par de asemenea mai puternice: e clar că orice problemă care se poate executa în timp determinist T se poate executa și în timp nedeterminist T, pentru că orice mașina deterministă este un caz particular de mașină nedeterministă.

Apar astfel clasele de complexitate prefixate cu litera N: NL, NP, NPSPACE, NEXP. De asemenea, o clasă nedeterministă este mai slabă decît clasa deterministă superioară: avem ierarhia: $\L
\subseteq {\bf NL}\subseteq {\bf P}\subseteq {\bf NP}\subseteq {\bf PSPACE}\subseteq
{\bf NPSPACE}\subseteq {\bf EXP}\subseteq {\bf NEXP}$. Despre majoritatea incluziunilor din acest lanț nu știm dacă sunt stricte.

Un singur rezultat pozitiv luminează aceste incertitudini: în mod surprinzător în 1970 Savitch a demonstrat că PSPACE= NPSPACE, deci nedeterminismul nu ajută cu nimic puterea computațională pentru acest gen de probleme.

În plus mai știm adesea că anumite incluziuni între clase distante sunt stricte: de exemplu avem teoreme de genul ``fie L$\not=$ P, fie P$\not=$ PSPACE'', sau, mai simplu spus, ${\bf L}\not= {\bf PSPACE}$.

Așa cum am mai menționat, clasa asupra căreia s-au aplecat cei mai mulți cercetători este NP. Articolele mele anterioare arătau că problema satisfiabilității boolene (``are un circuit boolean intrări pentru care ieșire este 1'') este completă pentru NP vis-a-vis de reduceri polinomiale.

O să menționez în final o problemă completă pentru NEXP: jocul de poker generalizat în trei adversari (jocuri în mai multe persoane, cu aleatorism și informație ascunsă).

Clase complementare

O întrebare naturală este: dacă putem generaliza mașina Turing să aibă astfel de stări în care poate face o alegere, nu putem crea și niște stări speciale în care orice alegere să trebuiască să ducă la o stare acceptoare? În modelul paralel, cu fire de execuție, cerem ca toate firele lansate în paralel să termine cu succes, și nu unul singur dintre ele.

Vom distinge deci două tipuri de stări: stările existențiale: cel puțin o cărare care iese din ele trebuie să ducă la o acceptare (acestea sunt stările din mașinile nedeterministe, introduse mai sus), și stările universale: toate cărările plecînd din acea stare trebuie să ducă la o acceptare.

Într-adevăr, aceste mașini recunosc alte clase de limbaje. O mașina care are doar stări universale recunoaște anumite clase de limbaje interesante. Se poate arăta că aceste limbaje sunt complementele limbajelor acceptate de mașinile nedeterministe, de aceea numele lor se prefixează cu ``Co''. De exemplu, avem clasa limbajelor CoL, care pot fi acceptate de o mașină cu stări universale în spațiu logaritmic. Avem CoNP, limbajele care pot fi acceptate de mașinile polinomiale cu stări universale.

Din nou, mașinile perfect deterministe sunt un caz particular de mașini cu stări universale, în care în fiecare stare avem o singură decizie posibilă. De aici rezultă imediat o serie de incluziuni: ${\bf L}\subseteq {\bf NL}\cap Co{\bf NL}$, ${\bf P}\subseteq {\bf NP}\cap
Co{\bf NP}$, etc.

Ca să fim mai concreți, să vedem o problemă care este în CoNP. Problema este foarte interesantă: dacă am două hărți, sunt ele ne-izomorfe? Adică, oricum aș asocia țările dintr-una cu țările din cealaltă, nu voi obține niciodată aceeași relație de vecinătate (dacă sunteți familiari cu terminologia grafurilor, aceasta este de fapt problema non-izomorfismului grafurilor).

Această problemă este în CoNP, pentru că vreau să verific că orice permutare a unei hărți este diferită de cealaltă. Putem deci construi o mașină cu o stare universală, din care se explorează în paralel toate permutările posibile.

Vedeți, problema cealaltă, a izomorfismului grafurilor este în NP: ``sunt două grafuri izomorfe?'' se poate rezolva în timp nedeterminist polinomial. Complementul acestei probleme este însă în CoNP. E interesant de observat că adesea o problemă nu este la fel de grea ca problema complementară. De pildă, e ușor să demonstrezi că nu a plouat peste noapte: străzile sunt uscate. Dar dacă străzile sunt ude, nu poți să știi dacă nu cumva au spălat strada. Un lucru și complementul lui nu sunt la fel de ușor de demonstrat.

De fapt aici nu am exprimat decît o credință: relația dintre clasele de complexitate și complementele lor este cel mai adesea necunoscută; singurul rezultat concret este dat teorema Immerman-Szelepcsényi, din 1988, care arăta că NL= CoNL.

Întrebarea NP= CoNP este o întrebare cu consecințe foarte importante pentru practică; dacă aceste clase sunt egale (ceea ce lumea crede foarte puțin probabil), atunci există demonstrații scurte pentru non-izomorfismul grafurilor: poți scrie o dovadă sumară că două grafuri nu sunt identice.

Mașini alternante, ierarhia polinomială

Putem generaliza și mai departe mașinile Turing, permițindu-le să aibă simultan stări existențiale și universale. În acest fel obținem noi clase de complexitate. Ceea ce caracterizează aceste mașini nu este numărul de stări de fiecare fel, ci numărul de alternanțe de stări pe fiecare cărare. De exemplu, dacă avem o stare existențială, urmată apoi pe fiecare ramură de stări universale, și doar atît, avem o alternanță 2. Cu cît permitem mai multe alternanțe, cu atît mașina pare mai puternică.

În mod interesant, putem modela aceste mașini și folosind oracole. O mașină care face o alternanță existențială urmată de una universală poate fi modelată de o mașină din NP care are un oracol din CoNP. Oracolul poate răspunde la orice întrebare universală, iar mașina ia doar o decizie existențială. Putem nota această mașină cu ${\bf NP}^{Co{\bf NP}}$.

Se definesc astfel clasele $\Pi_k = Co{\bf NP}^{\Pi_{k-1}}$ și $\Sigma_k =
{\bf NP}^{\Sigma_{k-1}}$; punctul de pornire este $\Sigma_1 = {\bf NP}$. Figura 7 ilustrează relația dintre aceste probleme. O problemă din $\Pi_k$ are un număr de k alternanțe, și prima stare este peste tot una universală. O problemă din $\Sigma_k$ are tot k alternanțe, dar prima stare pe orice cărare este una existențială. Figura 7 ilustrează această ierarhie de dificultăți, numită ``ierarhia polinomială''.

Din nou, nu se cunoaște nimic despre strictețea incluziunilor. Se știe însă că dacă pentru un k anume $\Sigma_k = \Sigma_{k+1}$, atunci acest lucru este adevărat pentru oricare număr mai mare decît k: ierarhia se prăbușește la nivelul k: $\Sigma_j =
\Sigma_k \forall j > k$. Totalitatea tuturor acestor clase pentru k constant se notează cu PH $=\cup_i \Sigma_i$. Dacă mergem mai departe, și îi permitem și lui k să varieze odată cu dimensiunea problemei, atingem PSPACE.

Pentru fiecare dintre aceste clase de complexitate există probleme care sunt complete prin reduceri polinomiale. De exemplu, iată un limbaj complet pentru $\Pi_2$: limbajul format din descrierea tuturor circuitelor booleene care au numărul minim de porți care implementează o anumită funcționalitate.

Figura 7: Ierarhia polinomială PH. Aproape nici una dintre incluziunile din această figură nu sunt demonstrate a fi stricte. Lumea crede că într-adevăr relația este cea din figură. ER sunt limbajele regulate, L=spațiu logaritmic, P=timp polinomial. Celelalte clase sunt definite in text.
\begin{figure}\centerline{\epsfxsize=11cm\epsffile{polinomiala.eps}}\end{figure}

Concluzii

În acest text am văzut ca nu tot ce se poate calcula în mod necesar se poate calcula repede: există clase de complexitate foarte ``grele''.

Am mai văzut că modelele matematice abstracte ale mașinilor cu oracole, mașinilor nedeterministe și mașinilor alternante sunt niște scule foarte puternice pentru a explora structura diferitelor probleme din punct de vedere al complexității. Am mai văzut că există clase de complexitate extrem de variate, și populate de probleme foarte interesante: am văzut de pildă clasa problemelor care se pot paraleliza foarte eficient, L, clasa PSPACE a jocurilor, care este (probabil) mult mai grea decît clasa problemelor tractabile, și clase nedeterministe și alternante, care formează o ierarhie infinită de obiecte din ce în ce mai greu de calculat.

În episodul următor vom ataca subiecte și mai stranii: vom vorbi despre complexitatea circuitelor ne-uniforme, și vom vedea cum se pot formaliza foarte precis noțiunile de criptare, pseudo-aleatorism și calcul bazat pe întîmplare. Pe luna viitoare!

Aleatorism și complexitate

Acest text este un al treilea episod consacrat teoriei complexitătii. În oarecare măsură el este independent de episoadele anterioare sau ulterioare, dar pentru cititorul doritor de o privire de ansamblu, celelalte părți sunt disponibile din pagina mea de web.

Teoria complexității este ramura cea mai abstractă a informaticii teoretice, care studiază ce se poate și nu se poate calcula, atunci cînd punem tot felul de constrîngeri asupra resurselor de calcul pe care le avem la dispoziție.

În primul text din această serie, am discutat despre teoria extremă a calculabilității, care investighează lucruri care nu se pot calcula oricît de multe lucruri am avea la dispoziție. În mod surprinzător, există mult mai multe lucruri ne-calculabile decît calculabile; există deci o infinitate de lucruri pe care nu le vom putea dovedi niciodată.

Al doilea episod a investigat cum se schimbă peisajul lucrurilor calculabile dacă restrîngem timpul pe care i-l punem la dispoziție unui calculator, respectiv spațiul de memorie pe care-l poate folosi. Am văzut că există probleme deosebit de interesante pentru care este necesară o cantitate foarte mare de resurse, de exemplu jocurile între doi jucători. Am văzut însă că se pot calcula lucruri foarte interesante chiar dacă avem 0 resurse de memorie, și foarte puțin timp la dispoziție: acestea sunt limbajele regulate4, care au foarte multe aplicații practice.

Acest al treilea episod are drept personaj central un tip foarte surprinzător de resursă de calcul: aleatorismul. Toate clasele de complexitate prezentate în acest text sunt bazate în mod fundamental pe aleatorism.

Aleatorism (randomness) și amplificare (boosting)

Ce este aleatorismul în teoria complexității, și cum îl putem modela? Cum putem introduce în modelul simplu de calcul pe care l-am folosit, al mașinii Turing, aleatorism?

Despre ce e aleatorismul în viața de zi cu zi, și cum poate fi el produs, sunt încă dezbateri aprinse între filozofi. Noi vom lua drept bună o sursă foarte simplă de aleatorism: un ban cu două fețe (diferite). Vom presupune că banul este perfect echilibrat, și că odată aruncat, poate ateriza cu aceeași probabilitate, de 1/2, pe oricare dintre fețele sale. Astfel, o fisă ne oferă de fiecare dată cînd este aruncată un bit aleator: un ``0'' sau un ``1'', fiecare cu probabilitate 1/2. Presupunem de asemenea ca între aruncări independente ale banului nu există nici o relație (adică ele sunt independente): presupunem că faptul că banul a aterizat de zece ori la rînd cu fața ``1'' în sus nu face mai probabilă apariția ulterioară a unei fețe ``0''. Unii filozofi și ``bunul simț'' al foarte multor inși nu vor fi de acord cu aceste presupuneri, dar acesta este luxul matematicii: putem face orice presupuneri, atîta vreme cît le enunțăm clar.

Atunci cînd modelăm matematic aleatorismul pentru o mașină Turing, ne imaginăm că cineva a dat dinainte cu banul și a înscris pe o bandă infinită rezultatele aruncărilor. Această bandă este disponibilă mașinii Turing, care de fiecare dată cînd are nevoie de un bit aleator, citește un bit de pe această bandă. Această bandă, cu biți aleatori, nu poate fi decît citită, iar capul de citire nu se poate niciodată întoarce înapoi. Așa cum am zis, biții de pe bandă sunt uniform distribuiți (adică 0 și 1 apar fiecare cu probabilitate 1/2) și independenți unul de altul.

În general, atunci cînd introducem aleatorism în calcul, pierdem certitudinea. Putem pierde certitudinea în două feluri diferite: în primul fel, s-ar putea ca rezultatele calculate de algoritm să fie eronate. În al doilea fel, s-ar putea ca rezultatele să fie sigur corecte, dar să nu fie clar cît de repede algoritmul termină execuția. Cum de putem tolera așa ceva? Ce ne facem cu calculatoarele, dacă întrebăm cît fac 7*9 și din cînd în cînd primim răspunsul 62? Ce încredere mai putem avea în algoritmi, atunci cînd ei nu sunt 100% fiabili?

Acestea sunt întrebări foarte legitime; avem însă la dispoziție o tehnică remarcabil de simplă prin care putem spori arbitrar certitudinea unui rezultat. Această tehnică se numește aplificare (boosting), și constă în repetarea de mai multe ori a unui calcul incert5.

Ideea este destul de simplă și ușor de demonstrat matematic. Voi argumenta insă în cuvinte: să presupunem că facem un experiment care are șansa să iasă prost cu o probabilitate mică, dar ne-nulă (neapărat mai mică de 1/2). Atunci, dacă repetăm experimentul de 20 de ori, probabilitatea ca mai mult de 10 experimente să iasă prost este foarte mică. Soluția este deci de a repeta experimentul, și de a considera răspunsul corect pe cel care apare majoritar. Probabilitatea de a greși în acest fel scade în mod exponențial cu numărul de repetiții. Scăderea aceasta foarte rapidă este esențială: este suficient să facem un număr relativ mic de repetiții pentru a reduce probabilitatea la o valoare infimă. Un lucru foarte important este că adesea numărul de repetiții pe care trebuie să-l facem nu depinde de mărimea problemei pe care o rezolvăm. Vă reamintesc că timpul de execuție al unui algoritm depinde practic întotdeauna de mărimea datelor de intrare; dacă însă probabilitatea de eșec a unui algoritm aleator este independentă de mărimea datelor de intrare (și cel mai adesea, este), atunci numărul de repetiții este constant, același, indiferent de cît de mare este problema!

Din punct de vedere practic algoritmii aleatori sunt perfect tolerabili: putem reduce probabilitatea de eroare atît de mică încît să fie mai probabil ca între timp calculatorul însuși să se strice, sau astfel încît să fie mai probabil că se va produce o calamitate de mari proporții, după care rezultatele calculului vor mai interesa pe foarte puțină lume.

Complexitate probabilistică; RP, ZPP și BPP

În ultimii douăzeci de ani cercetătorii din teoria complexității și a algoritmilor au găsit aplicații surprinzătoare ale aleatorismului în rezolvarea de probleme. Așa cum avem clasa de complexitate a problemelor care se pot rezolva în timp polinomial, avem și clase de complexitate pentru probleme care se pot rezolva în timp polinomial dacă folosim aruncarea cu banul ca resursă.

Am văzut în episodul anterior al acestui foileton complex (adică ``foileton despre complexitate'') că despre foarte multe clase de complexitate nu se știe dacă sunt identice sau doar incluse una într-alta. Astfel, nu se știe daca P=NP, sau dacă P=PSPACE, etc. Din păcate la fel stau lucrurile și în ceea ce privește aleatorismul: nu e clar dacă avînd aleatorism la-ndemînă putem calcula rapid lucruri pe care altfel nu le putem face decît foarte greu.

Vom discuta mai jos în text despre relația dintre aleatorism și dificultatea problemelor. Pînă atunci să notăm că, la ora actuală, există o mulțime de probleme la care singurele soluții eficiente cunoscute sunt probabilistice: nimeni nu cunoaște soluții deterministe de timp polinomial, dar există soluții probabiliste de timp polinomial. Mai mult de atît: problemele de acest gen sunt extrem de importante în viața de zi cu zi; una dintre cele mai importante probleme pe care nu știm s-o rezolvăm decît în mod aleator este criptarea.

Pînă atunci să aruncăm o privire asupra unor clase mari de probleme care se pot rezolva probabilistic. Ca și în cazul mașinilor obișnuite, deterministe, teoreticienii consideră că problemele care se pot rezolva în timp polinomial în dimensiunea datelor de intrare sunt rezolvabile. De aceea lumea este interesata mai ales de clase de complexitate aleatoare pentru care timpul de găsire al soluției este de asemenea polinomial. De aceea trebuie să vă așteptați (dacă ați uitat cumva cum se cheamă această secțiune) ca numele acestora să se termine toate cu litera P, de la ``polinomial''.

Monte Carlo și Las Vegas

Cum spuneam, există două clase de algoritmi aleatori, care pot greși răspunsul, sau care pot depăși timpul de execuție. Ambele sunt numite după localități faimoase prin jocurile de noroc. Algoritmii care pot da rezultate greșite ocazional se numesc Monte-Carlo. Algoritmii care sunt întotdeauna corecți în rezultat, dar pot depăși un timp rezonabil de execuție se numesc Las Vegas.

Trebuie să atragem atenția că pentru cele două clase de algoritmi de obicei se măsoară lucruri diferite: pentru algoritmii Monte-Carlo masurăm, ca de obicei, cel mai mare timp de execuție (worst case): cînd spunem că pentru date de intrare de mărime n un algoritm Monte-Carlo termină în timp O(f(n)), înseamnă că, indiferent de cum dăm cu banul, cel mai lung dintre timpii de execuție pentru feluritele date de intrare de această mărime este mai mic de f(n).

Dimpotrivă, pentru algoritmii Las Vegas măsurăm ceea ce se numește timp mediu de execuție pentru cazult cel mai rău (worst-case expected running time). Asta pentru că timpul de execuție este o variabilă aleatoare; asta înseamnă că dacă dăm cu banul ceva, timpul e unul, dacă dam cu banul altceva, timpul e altul, chiar pentru aceleași date de intrare. Fiecare secvență de aruncări cu banul are o anumită probabilitate: secvența 00000 apare cu probabilitatea 2-5, pentru că vrem să apară cinci zerouri consecutive, deci avem 1/2 * 1/2 * 1/2 * 1/2 * 1/2. Timpul mediu de execuție este valoarea medie a variabilei aleatoare ``timp de execuție'': $\Sigma_i p_i T(i, x)$. În această formulă suma se face dupa toate șirurile aleatoare posibile i; pi este probabilitatea ca șirul i să apară, iar T(i, x) este timpul de execuție pentru date de intrare x cînd șirul aleator a fost i6

Iată un exemplu foarte simplu de algoritm Las Vegas de sortare: primim un șir de numere de lungime n, îl amestecăm în mod aleator, după care aplicăm QuickSort. Rezultatul va fi întotdeauna corect sortat, dar timpul de execuție variază între n log n și n2. Din fericire, există foarte puține șiruri aleatoare pentru care timpul de rulare este n2, așa încît timpul mediu de execuție este n log n.

Aparent n-am făcut mare brînză, pentru că știm deja că QuickSort în general sortează în timp n log n$. Dar acest lucru era valabil dacă datele de intrare se presupuneau egal probabile în orice ordine. Ori, în anumite circumstanțe, s-ar putea ca datele de intrare să apară mereu într-o ordine defavorabilă (de exemplu dacă datele de intrare vin de la un echipament de monitorizare a rețelei, s-ar putea ca fluctuătiile diurne regulate să facă ca datele să aibă mereu o formă nefavorabilă pentru un QuickSort simplu. Dar dacă noi amestecăm datele înainte de a le prelucra, în așa fel încît orice șir de intrare să devină egal probabil, am distrus ne-uniformitatea din datele de intrare, permițînd algoritmului să opereze în cazul său mediu!

BPP și RP

Dacă ne restrîngem atenția la probleme de decizie (adică la algoritmi care trebuie să dea mereu doar unul din răspunsurile ``da'' sau ``nu'')7, atunci putem distinge două clase de complexitate printre algoritmii Monte-Carlo.

Prima clasă de complexitate se numește RP, de la Randomized Polynomial time (timp aleator polinomial). Acești algoritmi pot face erori doar pentru unul din răspunsuri; mai exact, dacă răspunsul pentru niște date de intrare trebuie să fie ``nu'', algoritmul răspunde 100% corect; dac'a r'aspunsul trebuie s'a fie ``da'', algoritmul ar putea cu probabilitate <1/4 să dea din greșeală răspunsul ``nu''. O problemă extrem de importantă practică este în clasa RP: testarea primalității unui număr. La ora actuală se cunosc numai algoritmi care vor recunoaște întotdeauna corect un număr prim, dar care ar putea declara (eronat) că anumite numere compuse sunt de fapt prime, însă cu probabilitate mică (adică doar dacă sunt ghinioniste în aruncarea cu banul). Acesta este în sine un fapt foarte important, pentru că toți algoritmii importanți de criptografie cu cheie publică se bazează în construirea cheii pe generarea de numere prime, care la rîndul ei se bazează pe testarea primalității. Asta înseamnă de fapt că toată lumea care folosește RSA (o metodă foarte răspîndită de criptare; de exemplu atît Netscape cît și Internet Explorer folosesc această metodă pentru anumite comunicații criptate prin Internet) are șansa să folosească o cheie care poate fi ``spartă'' ușor, pentru că nu e compusă din produsul a două numere prime! Din fericire, folosind tehnica amplificării, lumea reduce probabilitatea acestui eveniment la o valoare suficient de mică pentru a face algoritmul foarte folositor în practică.

A doua clasă de complexitate de algoritmi Monte-Carlo poate greși cu probabilitate mică în oricare sens: dacă răspunsul e ``da'', atunci ar putea zice ``nu'' cu probabilitate <1/4, sau invers. Această clasă se numește BPP, de la Bounded-error Probabilistic Polynomial time. Amplificarea unui astfel de algoritm se face repetînd execuția sa, și luînd drept corect răspunsul care apare cel mai des.

Am văzut în numărul anterior al revistei că pentru fiecare clasă de complexitate putem vorbi de complementul său. Astfel avem clasele CoRP: clasa limbajelor pentru care dacă răspunsul este ``da'', atunci algoritmul îl găsește întotdeauna, iar dacă răspunsul este ``nu'', algoritmul poate greși cu probabilitate <1/4 (am inversat în definiția lui RPpe ``da'' cu ``nu''). La fel se definește CoBPP, dar, din cauză că definiția lui BPP este simetrică, CoBPP = BPP. Pe de altă parte, RP $\not=$ CoRP.

Mai mult de atît, intersecția RP$\cap$ CoRP = ZPP se numește Zero-error Probabilistic Polynomial time. Se poate ușor arăta că de fapt ZPP este chiar clasa algoritmilor Las Vegas cu timp mediu polinomial.

Se cunosc anumite relații între aceste clase de complexitate și cele clasice, dar, ca de obicei, nu se știe dacă relațiile de incluziune sunt sau nu stricte: ${\bf P}\subseteq {\bf RP}\subseteq {\bf NP}$, și ${\bf RP}\subseteq {\bf BPP}$.

Figura [*] sumarizează starea cunoștințelor noastre în acest moment.

Nota: revista PC Report și-a exprimat dezinteresul pentru articole pe teme atît de teoretice, ca atare acest articol este neterminat.

Demonstrații interactive

Demonstrații verificabile probabilistic; teorema PCP

Complexitatea circuitelor

Secvențe pseudo-aleatoare

Funcții neinversabile

Criptografie

Relația dintre aleatorism și dificultate

Teorema elasticului

Logică și complexitate



Note

... calculat1
Rezervăm pentru fiecare mașina Turing o stare specială pentru a indica ``am terminat de calculat''.
... predicativ2
Atenție: problema SAT, a satisfiabilității, pe care am discutat-o în două numere din PC Report, se referea la calculul propozițional și nu la cel predicativ. Cele două probleme sunt foarte înrudite; în calculul predicativ însă satisfiabilitatea este în general nedecidabilă, pe cînd pentru calculul propozițional am oferit chiar noi un algoritm pentru decizia satisfiabilității, chiar dacă de timp exponențial. Vom clarifica într-un articol ulterior relația dintre logică și complexitate.
... etc3
De ce se numesc aceste circuite ``neuniforme'' vom vedea într-un episod ulterior.
... regulate4
În primul text din serie arătam că orice operație de calcul poate fi văzută ca transformarea unui limbaj într-un altul: fiecare șir de caractere primit la intrare de o mașina generează la ieșire un altul, care este răspunsul mașinii. Un limbaj era definit ca o mulțime de șiruri de caractere.
... incert5
Pentru algoritmii a căror durată de execuție depinde de aleatorism, putem executa mai multe copii ale algoritmului în paralel.
...$i$6
În realitate trebuie să fim puțin mai riguroși, pentru că aceasta este (uneori) o sumă infinită, deci trebuie să facem o trecere la limită și să discutăm și condiții de convergență.
... ``nu'')7
Tehnica de a restrînge atenția la problemele de decizie este foarte adesea folosită în teoria complexității; toate clasele mari de complexitate, de exemplu P, NP, etc. sunt definite doar pentru probleme de decizie. Am arătat însă într-un articol anterior că acest gen de limitare nu este foarte drastic, pentru că putem extrapola adesea rezultatele problemelor de decizie și pentru probleme la care răspunsul nu e doar ``da'' sau ``nu'', cum ar fi, de pildă, problemele de sortare. De aceea în general nu facem distincția explicită.