Problema comis-voiajorului (Travelling Salesman Problem, prescurtat TSP) este o problemă clasică de backtracking elementar. Totodată, aceasta este o celebră problemă NP-completă, fiind foarte importantă în studiul informaticii teoretice. În acest articol voi prezenta abordarea problemei prin backtracking și optimizarea soluției utilizând branch and bound. Voi folosi câteva noțiuni din teoria grafurilor, dar nu este obligatoriu să le cunoașteți pentru a înțelege rezolvarea problemei.

Enunț

Se dau n orașe, numerotate de la 1 la n, și o listă cu m străzi bidirecționale (pe care se poate circula în ambele sensuri), identificate prin cele două orașe-extremități și prin lungime. Un comis-voiajor are misiunea de a livra colete în toate aceste orașe, cu condiția să se întoarcă în orașul din care a plecat. El poate realiza asta în multe moduri, însă îl interesează un drum de lungime minimă. Așadar, se cere determinarea unui drum de lungime minimă, care să treacă prin fiecare oraș exact o singură dată, iar din ultimul oraș vizitat să revină în primul.

Exemplu

De pe prima linie a fișierului tsp.in se citesc n și m, numărul de orașe și respectiv numărul de străzi. Pe fiecare dintre următoarele m linii se găsesc câte trei numere x, y și z, cu semnificația că strada dintre orașele x și y are lungimea z.

În fișierul tsp.out se va afișa pe prima linie lungimea minimă a unui traseu optim, iar pe a doua linie un astfel de traseu.

Iată cum arată harta orașului descris, precum și un drum optim pentru comisul-voiajor:

Exemplu: Problema comis-voiajorului

Legătura cu teoria grafurilor

Practic, ni se dă un graf neorientat cu costuri pe muchii, și trebuie să determinăm un ciclu hamiltonian (un drum care trece prin toate nodurile grafului și se întoarce în nodul inițial) de cost minim. Desigur, prin costul ciclului mă refer la suma costurilor muchiilor din care acesta este format.

Soluție prin backtracking

Folosind metoda backtracking, vom genera toate ciclurile hamiltoniene ale grafului dat, și îl vom reține pe parcurs pe cel de cost minim.

Vom reprezenta graful printr-o matrice de adiacență, adică o matrice ad în care pe ad[i][j] reținem 0 dacă nu există stradă între orașele i și j, sau lungimea străzii respective în caz contrar. De asemenea, în variabila lg vom reține lungimea traseului curent, iar în vectorul tr orașele din care este compus acesta. În plus, vom avea nevoie de o variabilă lgMin și de un vector trMin pentru stocarea traseului minim și a lungimii acestuia.

Acum urmează partea de backtracking. În primul rând, se observă ușor că nu contează din ce oraș pornim, pentru că oricum trebuie să ne întoarcem de unde am plecat; de exemplu, traseul [3, 2, 5, 1] este tot una cu [5, 1, 3, 2]. Prin urmare, vom fixa pe tr[1] valoarea 1 (adică vom porni mereu din orașul 1), și vom începe generarea prin backtracking de la poziția 2. Această idee va reduce de n ori numărul de trasee generate!

Vom defini funcția de backtracking cu antetul void bkt(int pos), unde pos reprezintă poziția curentă din traseul pe care îl generăm. Dacă nu am ajuns pe poziția n + 1, înseamnă că nu am terminat generarea. În cazul acesta, vom pune pe poziția tr[pos], pe rând, fiecare oraș care încă nu a fost vizitat, dar pentru care există stradă de la el la tr[pos - 1]. Ca să ținem evidența orașelor vizitate, vom mai lua un vector caracteristic vis, unde vis[i] este true dacă orașul i a fost vizitat, sau false în caz contrar.

Dacă am ajuns în schimb la poziția n + 1, înseamnă că am terminat generarea unui traseu. Acum trebuie să ne întoarcem în orașul inițial. Deci, vom verifica dacă există stradă de la 1 la tr[n]. Dacă nu, traseul nu este valid. Dacă da, mai rămâne să adăugăm la lungime costul ultimei muchii a ciclului, adică ad[tr[n]][1]. Apoi comparăm lg cu lgMin și actualizăm soluția dacă este cazul.

Optimizare prin branch and bound

Se observă ușor că, dacă la un apel al funcției bkt lungimea curentă este mai mare sau egală cu lungimea minimă obținută până atunci, sigur acel început de traseu nu va duce la o soluție mai bună decât cea pe care o avem deja. În acest caz, putem ieși din apel printr-un simplu return.

Acest procedeu de optimizare se numește branch and bound, și se aplică adesea problemelor de backtracking în care trebuie să determinăm un cost minim. El reduce crucial timpul de execuție a programului, deoarece sare peste o grămadă de trasee nefolositoare.

Mai jos este un GIF făcut de mine, care ilustrează modul în care funcționează backtracking-ul în această problemă:

Vizualizarea algoritmului

Sursă C++

Iată o sursă C++ ce implementează algoritmul descris mai sus, rezolvând problema comis-voiajorului.

Complexitate

În cel mai nefavorabil caz, numărul total de cicluri hamiltoniene ale grafului dat este $(n - 1)!/2$; asta se întâmplă atunci când graful este complet. Există cazuri în care, chiar și cu optimizarea făcută prin branch and bound, vom fi nevoiți să generăm toate aceste cicluri; de exemplu atunci când toate muchiile grafului au același cost.

Prin urmare, complexitatea algoritmului este $O(n!)$. Teoretic, este o complexitate foarte proastă, însă în practică (pe teste obișnuite, cu n până în 40-60), algoritmul se comportă destul de bine. Există și o soluție ce folosește programare dinamică, de complexitate exponențială, însă nu este foarte practică din cauza cantității mare de spațiu necesară. Voi analiza această abordare într-un articol viitor.

Problema comis-voiajorului se găsește în arhiva educațională de pe InfoArena, sub numele de Ciclu hamiltonian de cost minim. Acolo este făcută pentru grafuri orientate, indexate de la 0. Deci sursa de mai sus trebuie modificată un pic ca să funcționeze pe InfoArena. Oricum, va obține doar 70 de puncte; pentru restul trebuie folosită programare dinamică.

Dacă aveți vreo întrebare legată de problema comis-voiajorului, nu ezitați să o adresați într-un comentariu, mai jos 🙂

Îți place conținutul acestui site?

Dacă vrei să mă susții în întreținerea server-ului și în a scrie mai multe articole de calitate pe acest blog, mă poți ajuta printr-o mică donație. Află aici cum o poți face!