Complessità Algoritmica
Notazione Big O, analisi del tempo di esecuzione e dello spazio, e come scegliere l'algoritmo giusto.
Perché Misurare la Complessità?
Immagina di dover cercare un nome in un elenco telefonico di 1.000.000 di nomi. Potresti scorrere ogni nome dall'inizio (lento) o aprire il libro a metà e dimezzare lo spazio di ricerca a ogni passo (veloce). La differenza è enorme: 1.000.000 operazioni vs ~20.
La notazione Big O descrive come il tempo di esecuzione (o lo spazio in memoria) cresce al crescere dell'input N, ignorando le costanti.
Crescita al variare di N=1000
Le Complessità Principali
O(1) — Costante
Il tempo non dipende dalla dimensione dell'input. Ideale.
// Accedere a un elemento di un array → O(1)
int x = arr[5]; // Sempre 1 operazione, indipendentemente da |arr|
// Push/pop su uno stack → O(1)
stack[top++] = valore;
// Operazioni matematiche → O(1)
int somma = a + b;
double area = 3.14 * r * r;
// Hash map lookup (caso medio) → O(1)
O(log n) — Logaritmico
Ogni passo dimezza lo spazio del problema. Con N=1 miliardo, bastano ~30 passi.
// Ricerca binaria: a ogni iterazione taglia a metà
int binary_search(int arr[], int n, int target) {
int sx = 0, dx = n - 1;
while (sx <= dx) {
int mid = sx + (dx - sx) / 2; // Evita overflow
if (arr[mid] == target) return mid;
if (arr[mid] < target) sx = mid + 1;
else dx = mid - 1;
}
return -1;
}
// Con N=1.000.000: al massimo log₂(1.000.000) ≈ 20 iterazioni!
O(n) — Lineare
Devi esaminare ogni elemento almeno una volta. Raddoppiando N, il tempo raddoppia.
// Cerca il massimo → deve vedere tutti gli N elementi
int trova_max(int arr[], int n) {
int max = arr[0];
for (int i = 1; i < n; i++) { // Esegue n-1 volte
if (arr[i] > max) max = arr[i];
}
return max;
}
// Somma di tutti gli elementi → O(n)
long somma(int arr[], int n) {
long s = 0;
for (int i = 0; i < n; i++) s += arr[i];
return s;
}
O(n log n) — Lineare-Logaritmico
Il costo degli algoritmi di ordinamento efficienti (Merge Sort, Quick Sort). Molto buono.
// Merge Sort divide il problema a metà ricorsivamente (log n livelli)
// e ad ogni livello fa O(n) lavoro → totale O(n log n)
void merge_sort(int arr[], int sx, int dx) {
if (sx >= dx) return;
int mid = (sx + dx) / 2;
merge_sort(arr, sx, mid); // Divide a sinistra → log n livelli
merge_sort(arr, mid+1, dx); // Divide a destra
merge(arr, sx, mid, dx); // Unisce → O(n) per livello
}
O(n²) — Quadratico
Un ciclo dentro un ciclo. Con N=10.000 hai 100 milioni di operazioni. Evita per N grandi.
// Bubble Sort: due cicli annidati → O(n²)
void bubble_sort(int arr[], int n) {
for (int i = 0; i < n-1; i++) { // n-1 volte
for (int j = 0; j < n-i-1; j++) { // n-i-1 volte
if (arr[j] > arr[j+1]) {
int tmp = arr[j]; arr[j] = arr[j+1]; arr[j+1] = tmp;
}
}
}
}
// Con n=1000: ~500.000 confronti
// Con n=10000: ~50.000.000 confronti (lento!)
O(2ⁿ) — Esponenziale
Cresce a dismisura. Praticabile solo per N molto piccoli (≤30).
// Fibonacci ricorsivo naive: ogni chiamata fa 2 chiamate ricorsive
long long fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2); // Albero binario di profondità n → 2^n nodi
}
// fib(10) → 177 chiamate
// fib(30) → 2.692.537 chiamate
// fib(50) → ~2^50 = 1.125.899.906.842.624 chiamate → ANNI di attesa!
Tabella di Riferimento
| Notazione | Nome | N=10 | N=100 | N=1.000 | N=1M |
|---|---|---|---|---|---|
| O(1) | Costante | 1 | 1 | 1 | 1 |
| O(log n) | Logaritmico | 3 | 7 | 10 | 20 |
| O(√n) | Radice | 3 | 10 | 31 | 1.000 |
| O(n) | Lineare | 10 | 100 | 1.000 | 1M |
| O(n log n) | Lin-Log | 33 | 664 | 9.966 | 20M |
| O(n²) | Quadratico | 100 | 10.000 | 1M | 10¹² |
| O(n³) | Cubico | 1.000 | 1M | 10⁹ | 10¹⁸ |
| O(2ⁿ) | Esponenziale | 1.024 | 10³⁰ | impossibile | impossibile |
Regole per Calcolare la Complessità
// REGOLA 1: Ignora le costanti
// f(n) = 3n + 5 → O(n) (non O(3n+5))
for (int i = 0; i < 3*n; i++) { ... } // O(n)
x = a + b + c + d; // O(1) (non O(4))
// REGOLA 2: Istruzioni consecutive → si sommano (prendi il più grande)
for (int i = 0; i < n; i++) { ... } // O(n)
for (int i = 0; i < n*n; i++) { ... } // O(n²)
// Totale: O(n) + O(n²) = O(n²)
// REGOLA 3: Cicli annidati → si moltiplicano
for (int i = 0; i < n; i++) { // O(n)
for (int j = 0; j < n; j++) { // O(n) × O(n) = O(n²)
...
}
}
// REGOLA 4: Ciclo che dimezza → logaritmico
int i = n;
while (i > 1) { i /= 2; } // O(log n)
// REGOLA 5: Funzione ricorsiva con 2 chiamate → O(2^profondità)
void f(int n) {
if (n == 0) return;
f(n-1); f(n-1); // O(2ⁿ)
}
// REGOLA 6: Ricorsione che dimezza
void g(int n) {
if (n == 0) return;
g(n/2); // O(log n)
}
// ESEMPIO COMPLESSO: Qual è la complessità?
void esempio(int arr[], int n) {
// Parte 1: O(n)
for (int i = 0; i < n; i++) printf("%d", arr[i]);
// Parte 2: O(n²)
for (int i = 0; i < n; i++)
for (int j = i; j < n; j++)
printf("%d %d", arr[i], arr[j]);
// Parte 3: O(log n)
int k = 1;
while (k < n) k *= 2;
}
// Totale: O(n) + O(n²) + O(log n) = O(n²)
Complessità Spaziale
Oltre al tempo, contiamo la memoria aggiuntiva usata dall'algoritmo (non quella dell'input).
// Bubble Sort: O(1) spazio aggiuntivo (in-place)
void bubble_sort(int arr[], int n) {
for (int i = 0; i < n-1; i++)
for (int j = 0; j < n-i-1; j++)
if (arr[j] > arr[j+1]) {
int tmp = arr[j]; arr[j] = arr[j+1]; arr[j+1] = tmp; // Solo 1 variabile tmp
}
}
// Merge Sort: O(n) spazio aggiuntivo (serve array temporaneo)
void merge(int arr[], int sx, int mid, int dx) {
int *tmp = malloc((dx - sx + 1) * sizeof(int)); // Alloca O(n) spazio
// ... merge logic ...
free(tmp);
}
// Fibonacci ricorsivo: O(n) spazio (stack di chiamate profondo n)
long long fib_ricorsivo(int n) {
if (n <= 1) return n;
return fib_ricorsivo(n-1) + fib_ricorsivo(n-2); // Stack profondo n
}
// Fibonacci iterativo: O(1) spazio
long long fib_iterativo(int n) {
if (n <= 1) return n;
long long a = 0, b = 1; // Solo 2 variabili!
for (int i = 2; i <= n; i++) {
long long c = a + b;
a = b; b = c;
}
return b;
}
Per ognuna di queste funzioni, determina la complessità temporale Big O: a) funzione con un for da 0 a n, b) due for annidati entrambi da 0 a n, c) for che incrementa i di i*2 ogni volta, d) ricorsione che chiama se stessa con n-1, e) ricorsione che chiama se stessa con n/2.
// a) O(n) — un ciclo da 0 a n
for (int i = 0; i < n; i++) printf("%d ", i);
// b) O(n²) — due cicli annidati
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++) printf("(%d,%d) ", i, j);
// c) O(log n) — i raddoppia ogni volta
for (int i = 1; i < n; i *= 2) printf("%d ", i);
// Conta quante volte puoi raddoppiare prima di superare n → log₂(n)
// d) O(n) — ricorsione lineare
void f(int n) { if (n==0) return; printf("%d ", n); f(n-1); }
// e) O(log n) — ricorsione che dimezza
void g(int n) { if (n<=1) return; printf("%d ", n); g(n/2); }Modifica la ricerca lineare e la ricerca binaria per contare quante "comparazioni" effettuano. Cerca il numero 9999 in un array da 10.000 elementi ordinati. Stampa il numero di operazioni per ognuna e calcola il rapporto.
#include <stdio.h>
int ricerca_lineare(int arr[], int n, int target, int *ops) {
*ops = 0;
for (int i = 0; i < n; i++) {
(*ops)++;
if (arr[i] == target) return i;
}
return -1;
}
int ricerca_binaria(int arr[], int n, int target, int *ops) {
*ops = 0;
int sx = 0, dx = n-1;
while (sx <= dx) {
(*ops)++;
int mid = (sx+dx)/2;
if (arr[mid] == target) return mid;
if (arr[mid] < target) sx = mid+1;
else dx = mid-1;
}
return -1;
}
int main() {
int n = 10000;
int arr[10000];
for (int i = 0; i < n; i++) arr[i] = i;
int ops_lin, ops_bin;
int target = 9999;
ricerca_lineare(arr, n, target, &ops_lin);
ricerca_binaria(arr, n, target, &ops_bin);
printf("Target: %d (array di %d elementi)\n", target, n);
printf("Lineare: %d operazioni\n", ops_lin);
printf("Binaria: %d operazioni\n", ops_bin);
printf("Rapporto: %.0fx più veloce\n", (double)ops_lin/ops_bin);
return 0;
}Implementa Bubble Sort (O(n²)) e Merge Sort (O(n log n)). Genera array casuali di dimensione N=1000, 5000, 10000. Misura il tempo di esecuzione di entrambi usando clock(). Stampa una tabella dei risultati e verifica che il rapporto segua la teoria.
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
void bubble_sort(int arr[], int n) {
for (int i=0; i<n-1; i++)
for (int j=0; j<n-i-1; j++)
if (arr[j]>arr[j+1]) { int t=arr[j]; arr[j]=arr[j+1]; arr[j+1]=t; }
}
void merge(int a[], int sx, int mid, int dx) {
int n1=mid-sx+1, n2=dx-mid;
int *L=malloc(n1*sizeof(int)), *R=malloc(n2*sizeof(int));
for(int i=0;i<n1;i++) L[i]=a[sx+i];
for(int j=0;j<n2;j++) R[j]=a[mid+1+j];
int i=0,j=0,k=sx;
while(i<n1&&j<n2) a[k++]=(L[i]<=R[j])?L[i++]:R[j++];
while(i<n1) a[k++]=L[i++];
while(j<n2) a[k++]=R[j++];
free(L); free(R);
}
void merge_sort(int a[], int sx, int dx) {
if(sx<dx){int m=(sx+dx)/2; merge_sort(a,sx,m); merge_sort(a,m+1,dx); merge(a,sx,m,dx);}
}
double misura(void (*sort)(int[],int), int n) {
int *arr = malloc(n*sizeof(int));
for(int i=0;i<n;i++) arr[i]=rand()%10000;
clock_t t=clock();
sort(arr,n);
double ms=(double)(clock()-t)/CLOCKS_PER_SEC*1000;
free(arr); return ms;
}
void merge_sort_wrap(int arr[], int n) { merge_sort(arr,0,n-1); }
int main() {
srand(42);
int sizes[]={1000,3000,5000,8000,10000};
printf("%-8s %12s %12s\n","N","BubbleSort(ms)","MergeSort(ms)");
printf("--------------------------------------\n");
for(int i=0;i<5;i++){
int n=sizes[i];
double b=misura(bubble_sort,n), m=misura(merge_sort_wrap,n);
printf("%-8d %12.2f %12.2f (%.0fx)\n",n,b,m,b/m);
}
return 0;
}Implementa 3 soluzioni per trovare se un array contiene duplicati: a) O(n²) con doppio ciclo, b) O(n log n) con ordinamento + scan, c) O(n) con hash table (usa array di flag per valori 0..MAX). Confronta tempi e operazioni.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define MAX_VAL 100000
// O(n²)
int ha_duplicati_n2(int arr[], int n) {
for (int i=0; i<n; i++)
for (int j=i+1; j<n; j++)
if (arr[i]==arr[j]) return 1;
return 0;
}
// O(n log n)
int cmp(const void *a, const void *b) { return (*(int*)a)-(*(int*)b); }
int ha_duplicati_nlogn(int arr[], int n) {
int *copia = malloc(n*sizeof(int));
memcpy(copia, arr, n*sizeof(int));
qsort(copia, n, sizeof(int), cmp);
int dup = 0;
for (int i=0; i<n-1; i++) if (copia[i]==copia[i+1]) { dup=1; break; }
free(copia);
return dup;
}
// O(n) con flag array
int ha_duplicati_on(int arr[], int n) {
char visti[MAX_VAL] = {0};
for (int i=0; i<n; i++) {
if (arr[i]>=0 && arr[i]<MAX_VAL) {
if (visti[arr[i]]) return 1;
visti[arr[i]] = 1;
}
}
return 0;
}
int main() {
int n = 5000;
int *arr = malloc(n*sizeof(int));
for (int i=0; i<n; i++) arr[i] = rand() % MAX_VAL;
clock_t t;
t=clock(); ha_duplicati_n2(arr,n); printf("O(n²): %.3f ms\n",(double)(clock()-t)/CLOCKS_PER_SEC*1000);
t=clock(); ha_duplicati_nlogn(arr,n); printf("O(n logn): %.3f ms\n",(double)(clock()-t)/CLOCKS_PER_SEC*1000);
t=clock(); ha_duplicati_on(arr,n); printf("O(n): %.3f ms\n",(double)(clock()-t)/CLOCKS_PER_SEC*1000);
free(arr);
return 0;
}