Classi di complessità computazionale

La teoria della complessità computazionale classifica i problemi in base alle risorse (tempo, spazio) necessarie a un modello di calcolo per risolverli, in funzione della dimensione $n$ dell'input. Serve a rispondere a una domanda semplice: quanto è "difficile" un problema, indipendentemente dall'algoritmo specifico o dal computer usato?

Mappa: cosa serve per cosa

1. Notazione asintotica

Prima di parlare di classi serve un linguaggio per confrontare la crescita delle funzioni di costo.

\[f(n) = O(g(n)) \iff \exists\, c > 0,\ n_0 \in \mathbb{N} \ \text{tali che}\ \forall n \ge n_0,\ f(n) \le c \cdot g(n)\] \[f(n) = \Omega(g(n)) \iff \exists\, c > 0,\ n_0 \in \mathbb{N} \ \text{tali che}\ \forall n \ge n_0,\ f(n) \ge c \cdot g(n)\] \[f(n) = \Theta(g(n)) \iff f(n) = O(g(n)) \ \text{e} \ f(n) = \Omega(g(n))\]

In parole: $O$ è un limite superiore (non cresce più veloce di), $\Omega$ un limite inferiore, $\Theta$ una crescita esatta (a meno di costanti).

Gerarchia tipica delle classi di crescita (dalla più lenta alla più veloce):

\[O(1) \subset O(\log n) \subset O(n) \subset O(n \log n) \subset O(n^2) \subset O(n^k) \subset O(2^n) \subset O(n!)\]

Ad esempio, un algoritmo $O(2^n)$ diventa inutilizzabile molto prima di uno $O(n^k)$ al crescere di $n$, anche se per $n$ piccoli può sembrare competitivo.

Questa notazione descrive quanto cresce un costo, ma non dice rispetto a cosa lo si sta misurando: lo stesso algoritmo ha in genere un costo diverso nel caso pessimo, ottimo o medio, e le strutture dati con operazioni di costo variabile si analizzano con il costo ammortizzato. Questi quattro modi di misurare il costo sono trattati in dettaglio, con esempi, in teoria_costo.md.

2. Il modello di calcolo: macchina di Turing

Le classi di complessità si definiscono formalmente rispetto alla macchina di Turing (MdT), un modello astratto con un nastro infinito, una testina di lettura/scrittura e un insieme finito di stati.

Le classi si definiscono in termini di tempo (numero di passi) o spazio (celle di nastro usate) in funzione di $n = |x|$, la lunghezza dell'input $x$:

\[\mathrm{TIME}(f(n)) = \{\, L \mid L \text{ è deciso da una DTM in tempo } O(f(n)) \,\}\] \[\mathrm{NTIME}(f(n)) = \{\, L \mid L \text{ è deciso da una NTM in tempo } O(f(n)) \,\}\]

3. Classe P

\[\mathrm{P} = \bigcup_{k \ge 1} \mathrm{TIME}(n^k)\]

P (Polynomial time) è l'insieme dei problemi decisionali risolvibili da una macchina di Turing deterministica in tempo polinomiale rispetto alla dimensione dell'input. È considerata (con qualche eccezione pratica) la classe dei problemi "trattabili".

Esempi di problemi in P:

import "math"

func dijkstraEsisteCammino(grafo map[string]map[string]float64, sorgente, destinazione string) bool {
	// Verifica raggiungibilità con costo minimo: versione base O(V^2), quindi comunque in P.
	dist := make(map[string]float64)
	for nodo := range grafo {
		dist[nodo] = math.Inf(1)
	}
	dist[sorgente] = 0
	visitati := make(map[string]bool)

	for {
		u := ""
		migliore := math.Inf(1)
		for nodo, d := range dist {
			if !visitati[nodo] && d < migliore {
				migliore = d
				u = nodo
			}
		}
		if u == "" {
			break
		}
		if u == destinazione {
			return true
		}
		visitati[u] = true

		for v, peso := range grafo[u] {
			nd := dist[u] + peso
			if nd < dist[v] {
				dist[v] = nd
			}
		}
	}
	return !math.IsInf(dist[destinazione], 1)
}

// Esempio: grafo := map[string]map[string]float64{"A": {"B": 1, "C": 4}, "B": {"C": 1}, "C": {}}

4. Classe NP

\[\mathrm{NP} = \bigcup_{k \ge 1} \mathrm{NTIME}(n^k)\]

NP (Nondeterministic Polynomial time) è l'insieme dei problemi decisionali risolvibili da una macchina di Turing non deterministica in tempo polinomiale.

C'è una caratterizzazione equivalente e più intuitiva, basata sulla verifica invece che sulla ricerca:

$L \in \mathrm{NP}$ se e solo se esiste un certificato (o "prova") $y$, di lunghezza polinomiale in $n$, e un verificatore deterministico polinomiale $V$ tale che: \(x \in L \iff \exists\, y,\ |y| = O(n^k),\ V(x, y) = 1\)

In parole: NP raccoglie i problemi per cui, se qualcuno ti suggerisce la soluzione, puoi verificarla velocemente — anche se trovarla da zero può essere molto costoso.

Esempi di problemi in NP:

func verificaClique(grafo map[string][]string, sottoinsieme []string) bool {
	// Verificatore in O(k^2): dato un candidato, controlla che sia una cricca.
	// Trovare il sottoinsieme da zero è invece il problema NP-difficile.
	nodi := sottoinsieme
	for i := 0; i < len(nodi); i++ {
		for j := i + 1; j < len(nodi); j++ {
			if !contiene(grafo[nodi[i]], nodi[j]) {
				return false
			}
		}
	}
	return true
}

func contiene(lista []string, elemento string) bool {
	for _, v := range lista {
		if v == elemento {
			return true
		}
	}
	return false
}

// Esempio: grafo := map[string][]string{"A": {"B", "C"}, "B": {"A", "C"}, "C": {"A", "B"}}
// verificaClique(grafo, []string{"A", "B", "C"})  // true

Nota bene: $\mathrm{P} \subseteq \mathrm{NP}$ sempre (ogni problema risolvibile in fretta è anche verificabile in fretta, ignorando il certificato). La domanda $\mathrm{P} \stackrel{?}{=} \mathrm{NP}$ è il problema aperto più famoso dell'informatica teorica (uno dei Millennium Prize Problems).

5. NP-completezza e NP-difficoltà

I problemi NP-completi sono, informalmente, i "più difficili" di NP: se se ne trovasse uno solo risolvibile in tempo polinomiale, allora $\mathrm{P} = \mathrm{NP}$ e ogni problema in NP diventerebbe trattabile.

Il teorema di Cook-Levin (1971) dimostra che SAT è NP-completo — è stato il primo problema per cui si è dimostrata questa proprietà, ed è la base da cui si dimostrano NP-completi tutti gli altri (tramite riduzioni a catena).

La costruzione delle riduzioni, con esempi passo-passo (SAT $\to$ 3-SAT $\to$ Clique $\to$ Vertex Cover $\to \dots$), è approfondita in teoria_riduzioni.md.

Esempi di problemi NP-completi: SAT, 3-SAT, Clique, Vertex Cover, Hamiltonian Cycle, commesso viaggiatore (decisionale), Subset Sum, coloring di grafi ($k \ge 3$).

6. co-NP

\[\mathrm{co\text{-}NP} = \{\, L \mid \overline{L} \in \mathrm{NP} \,\}\]

co-NP è la classe dei complementi dei linguaggi in NP: un problema è in co-NP se le istanze negative ammettono un certificato verificabile in tempo polinomiale.

Esempio: TAUTOLOGIA (una formula booleana è vera per ogni assegnazione?) è in co-NP, perché il complemento — "esiste un'assegnazione che la rende falsa" — è SAT-like ed è in NP.

Non si sa se $\mathrm{NP} = \mathrm{co\text{-}NP}$ (è un'altra domanda aperta, collegata a $\mathrm{P} \stackrel{?}{=} \mathrm{NP}$: se $\mathrm{P} = \mathrm{NP}$ allora necessariamente $\mathrm{NP} = \mathrm{co\text{-}NP}$, perché P è chiusa per complemento).

7. PSPACE ed EXPTIME

\[\mathrm{PSPACE} = \bigcup_{k \ge 1} \mathrm{SPACE}(n^k) \qquad \mathrm{EXPTIME} = \bigcup_{k \ge 1} \mathrm{TIME}(2^{n^k})\]

Relazioni note (catena di inclusioni):

\[\mathrm{P} \subseteq \mathrm{NP} \subseteq \mathrm{PSPACE} \subseteq \mathrm{EXPTIME}\]

Si sa per certo (teorema della gerarchia del tempo) che $\mathrm{P} \subsetneq \mathrm{EXPTIME}$ — quindi almeno una delle inclusioni sopra è stretta — ma non si sa quale. Questo è il motivo per cui $\mathrm{P} \stackrel{?}{=} \mathrm{NP}$ resta aperto.

8. Quadro riassuntivo dei problemi per classe

Classe Significato intuitivo Esempi
P Risolvibile in fretta Ordinamento, cammino minimo, primalità
NP Verificabile in fretta SAT, Clique, TSP (decisionale)
co-NP Complemento verificabile in fretta Tautologia, primalità (storicamente, prima di AKS: un numero composto ha un fattore come certificato facile, quindi la primalità è banalmente in co-NP)
NP-completo Il più difficile di NP SAT, 3-SAT, Vertex Cover, Hamiltonian Cycle
NP-difficile Almeno difficile quanto NP (non necessariamente in NP) TSP (ottimizzazione), Halting Problem
PSPACE Spazio polinomiale QBF, giochi generalizzati
EXPTIME Tempo esponenziale Generalizzazioni di giochi con stati esponenziali

9. Formulario compatto

\[\mathrm{P} = \bigcup_k \mathrm{TIME}(n^k) \qquad \mathrm{NP} = \bigcup_k \mathrm{NTIME}(n^k) \qquad \mathrm{co\text{-}NP} = \{L : \overline{L} \in \mathrm{NP}\}\] \[L \in \mathrm{NP} \iff \exists\, y,\ |y| = O(n^k),\ V(x,y) = 1 \ \text{in tempo polinomiale}\] \[\mathrm{P} \subseteq \mathrm{NP} \cap \mathrm{co\text{-}NP} \subseteq \mathrm{NP} \subseteq \mathrm{PSPACE} \subseteq \mathrm{EXPTIME}\] \[L \text{ NP-completo} \iff L \in \mathrm{NP} \ \wedge \ \forall L' \in \mathrm{NP},\ L' \le_p L\]

Il simbolo $\le_p$ (riduzione polinomiale) è definito e usato estesamente in teoria_riduzioni.md.