Salta al contenuto
Note per Studenti Liste concatenate in C

Liste concatenate in C

In questa pagina 5

Definizione (lista concatenata). Una lista concatenata è una sequenza di nodi allocati nello heap, ciascuno con un valore e un puntatore al successivo; la lista è identificata dal puntatore alla testa (NULL = lista vuota). Premessa: Puntatori, struct e memoria dinamica in CPuntatori e passaggio per riferimento in C, array e aritmetica dei puntatori, stringhe, struct e typedef con l'operatore ->, malloc e free, puntatore a puntatore per modificare una testa; compilazione con Makefile; errori tipici (puntatori pendenti, perdite di memoria, off-by-one).Puntatori, struct e memoria dinamica in C →. È l'implementazione concreta degli ADT di Liste, pile e codeRipasso degli ADT elementari: lista index-based (array) e position-based (lista doppiamente concatenata con sentinelle), pila LIFO, coda FIFO, deque, iteratori; interfacce, implementazioni e costi; array estendibile e coda circolare.Liste, pile e code →. La stessa struttura è presentata, in versione più elementare, in Liste concatenateLista concatenata semplice: nodi allocati dinamicamente collegati da puntatori; inserimento e rimozione in testa, scorrimento, ricerca, inserimento ordinato, deallocazione; confronto dei costi con l'array; versione in Python.Liste concatenate → e Pila e codaPila (LIFO) e coda (FIFO) come ADT: operazioni e costi; realizzazione in Python con list e collections.deque; realizzazione in C con array e indici, coda circolare; applicazioni (parentesi bilanciate, stack delle chiamate, visite).Pila e coda → (Fondamenti di Informatica); i puntatori usati qui sono quelli di Puntatori in CUn puntatore contiene un indirizzo di memoria; operatori & e *, NULL, puntatori come parametri per modificare variabili del chiamante, aritmetica dei puntatori, legame tra array e puntatori, const, puntatori a puntatori.Puntatori in C → e i blocchi di malloc vivono nello heap (Gestione della memoria in CSegmenti di memoria di un processo (codice, dati statici, stack, heap); durata delle variabili; allocazione dinamica con malloc, calloc, realloc e free; errori classici: memory leak, dangling pointer, double free, buffer overflow; strumenti di controllo.Gestione della memoria in C →).

Esempio. Dopo addHead di 11, 22, 33 in una lista vuota la lista è 3 2 1, con la testa che punta a 3. Stato dopo ciascuna chiamata (la freccia è il campo next):

chiamata lista cosa succede
(iniziale) l →\to NULL lista vuota
addHead(&l, 1) l →\to [1] →\to NULL il nuovo nodo ha next = *l = NULL, poi *l punta a lui
addHead(&l, 2) l →\to [2] →\to [1] →\to NULL il nuovo nodo ha next = vecchia testa
addHead(&l, 3) l →\to [3] →\to [2] →\to [1] →\to NULL idem

L'ordine dei due assegnamenti di addHead conta: prima aux->next = *l (si salva la vecchia testa), poi *l = aux; invertendoli si perderebbe l'intera lista.

c
typedef struct nodo {
    int val;
    struct nodo *next;
} Nodo;

Operazioni

Proprietà (testa passata per riferimento). Ogni funzione che può cambiare la testa riceve un Nodo **l (puntatore alla variabile testa del chiamante).

Esempio. Con Nodo *l = NULL; la chiamata addHead(&l, 5) rende l != NULL: senza il doppio puntatore la testa del chiamante non cambierebbe.

c
void addHead(Nodo **l, int n) {          /* O(1) */
    Nodo *aux = malloc(sizeof(Nodo));
    aux->val = n;
    aux->next = *l;
    *l = aux;
}

void addTail(Nodo **l, int n) {          /* O(n): scende fino al NULL finale */
    if (*l == NULL) {
        Nodo *aux = malloc(sizeof(Nodo));
        aux->val = n;
        aux->next = NULL;
        *l = aux;
    } else
        addTail(&(*l)->next, n);
}

int pop(Nodo **l) {                      /* O(1), precondizione: lista non vuota */
    Nodo *aux = *l;
    int res = aux->val;
    *l = aux->next;
    free(aux);
    return res;
}

Funzioni di sola lettura si scrivono con const Nodo *l e senza * doppio:

c
int lunghezza(const Nodo *l) { return l == NULL ? 0 : 1 + lunghezza(l->next); }

void stampa(const Nodo *l) {
    for (; l != NULL; l = l->next) printf("%d ", l->val);
    printf("\n");
}

Definizione (inversione in loco). Inversione in loco, O(n)O(n) e senza nodi nuovi: si ritorcono i puntatori scorrendo la lista.

Esempio. La lista 3 2 1 10 20 diventa 20 10 1 2 3 dopo inverti. Traccia su una lista più corta, 1 2 3 (ogni riga è lo stato alla fine dell'iterazione del while):

iterazione succ cur->next diventa prec cur lista già rovesciata
(prima) NULL nodo 1 vuota
1 nodo 2 NULL nodo 1 nodo 2 1
2 nodo 3 nodo 1 nodo 2 nodo 3 2 1
3 NULL nodo 2 nodo 3 NULL 3 2 1

succ serve per non perdere il resto della lista prima di sovrascrivere cur->next. All'uscita cur == NULL e prec è l'ultimo nodo, la nuova testa: per questo l'ultima istruzione è *l = prec. Ogni nodo è visitato una volta, costo Θ(n)\Theta(n) e Θ(1)\Theta(1) di memoria aggiuntiva.

c
void inverti(Nodo **l) {
    Nodo *prec = NULL, *cur = *l;
    while (cur != NULL) {
        Nodo *succ = cur->next;
        cur->next = prec;
        prec = cur;
        cur = succ;
    }
    *l = prec;
}

Liberare tutta la lista: while (*l != NULL) pop(l);.

Prova eseguita: dopo addHead di 1,2,31,2,3, addTail(10), addTail(20) la lista è 3 2 1 10 20 (lunghezza 55); dopo inverti è 20 10 1 2 3; pop restituisce 2020 e lascia 10 1 2 3.

Coda con due puntatori

Definizione (coda con puntatori a testa e coda). Per rendere O(1)O(1) anche l'inserimento in fondo si tengono due puntatori, in una struct:

typedef struct { Nodo *testa; Nodo *coda; } Coda;     /* estrae da testa, inserisce in coda */

void enqueue(Coda *q, int v) {
    Nodo *n = malloc(sizeof(Nodo));
    n->val = v; n->next = NULL;
    if (q->coda == NULL) q->testa = n;     /* coda vuota */
    else q->coda->next = n;
    q->coda = n;
}

int dequeue(Coda *q) {                     /* precondizione: non vuota */
    Nodo *n = q->testa;
    int v = n->val;
    q->testa = n->next;
    if (q->testa == NULL) q->coda = NULL;  /* si e' svuotata */
    free(n);
    return v;
}

int isEmpty(const Coda *q) { return q->testa == NULL; }

Esempio. Inseriti 10,20,30,4010,20,30,40 con enqueue, due dequeue danno 1010 e 2020 (ordine FIFO).

Prova: inseriti 10,20,30,4010, 20, 30, 40, due dequeue danno 10,2010, 20; dopo enqueue(99) si estrae 30,40,9930, 40, 99 (ordine FIFO). I due casi speciali sono quelli sulla coda vuota: in enqueue, se q->coda == NULL anche q->testa era NULL e il nuovo nodo è contemporaneamente testa e coda; in dequeue, se togliendo il nodo q->testa diventa NULL, la coda si è svuotata e anche q->coda va azzerato, altrimenti punterebbe a un nodo già liberato (puntatore pendente). Con una lista doppiamente concatenata (campo prev in più) si può anche togliere dal fondo in O(1)O(1).

Ricorsione sulle liste

Molti esercizi d'esame si risolvono con la ricorsione sul resto della lista. Schema: caso base l == NULL, poi si tratta il primo nodo e si richiama sul next. Esempi: contare i nodi con una proprietà, riempire un array di conteggi, copiare una lista in ordine inverso (la chiamata ricorsiva va prima dell'aggiunta in coda: gli elementi vengono accodati risalendo, quindi dall'ultimo al primo), confrontare due liste.

Costi

Proprietà (costi delle operazioni sulle liste concatenate)

Operazione Lista semplice Con puntatore alla coda
addHead, pop Θ(1)\Theta(1) Θ(1)\Theta(1)
addTail Θ(n)\Theta(n) Θ(1)\Theta(1)
ricerca di un valore Θ(n)\Theta(n) Θ(n)\Theta(n)
accesso al nodo ii-esimo Θ(i)\Theta(i) Θ(i)\Theta(i)

Esempio. Con n=1000n=1000 nodi addTail ne scorre 10001000 in una lista semplice e nessuno con il puntatore alla coda. Costruire una lista di nn elementi con nn addTail su lista semplice costa ∑k=0n−1k=n(n−1)2∈Θ(n2)\sum_{k=0}^{n-1} k = \frac{n(n-1)}{2} \in \Theta(n^2) (somma di SommatorieIl simbolo di sommatoria, le sue proprietà (linearità, additività, cambio di indice) e le somme notevoli di Gauss e geometrica.Sommatorie →, come in Complessità in tempo e caso pessimoPerché lo studio sperimentale non basta; complessità al caso pessimo come massimo sul numero di operazioni tra le istanze di una data taglia; stima con limiti superiore e inferiore senza trovare l'istanza peggiore; esempi arrayMax, prefixAverages e InsertionSort; efficienza asintotica e limiti dell'analisi.Complessità in tempo e caso pessimo →), contro Θ(n)\Theta(n) con addHead o con il puntatore alla coda.

Errori comuni

  • Passare Nodo *l invece di Nodo **l a una funzione che cambia la testa: la modifica si perde.
  • pop su lista vuota (dereferenzia NULL), o free del nodo prima di salvare next.
  • enqueue/dequeue che dimenticano di aggiornare entrambi i puntatori quando la coda era o diventa vuota.
  • Non inizializzare next a NULL nel nuovo nodo (la lista non termina mai).
  • Dimenticare free di tutti i nodi a fine uso.

Versione ripasso

Esercizi su questo argomento

Teoria collegata