LabASD - Min-Heap binario

Moreno Marzolla

Ultimo aggiornamento: 2024-03-15

Fonte: https://xkcd.com/835/
Fonte: https://xkcd.com/835/

Scopo di questo esercizio è implementare un min-heap binario; formuleremo il problema in modo leggermente diverso da quanto visto a lezione (e descritto nel libro di testo), perché questa struttura dati ci servirà in futuro.

Un min-heap è un contenitore di coppie (key, prio), di capienza massima size, dove

Attenzione: nel libro, key indica la priorità rispetto alla quale mantenere l’ordinamento parziale; qui invece key è un satellite, e prio è l’attributo in base al quale ordinare. La specifica adottata in questo esercizio ci servirà per implementare gli algoritmi di Dijkstra e di Prim (che vedremo più avanti).

Ogni chiave può apparire al più una volta; le priorità invece sono numeri reali arbitrari (anche negativi), e non sono soggette ad alcun vincolo. Lo heap consente di trovare efficientemente la coppia (key, prio) con prio minima.

Un min-heap viene rappresentato tramite un array di strutture HeapElem:

typedef struct {
    int key;
    double prio;
} HeapElem;

typedef struct {
    HeapElem *heap;
    int n;
    int size;
} MinHeap;

dove size è la dimensione dell’array heap[], e n è il numero di coppie (key, prio) effettivamente presenti nello heap in un dato momento. Quindi, si avrà sempre che \(0 \leq n \leq \texttt{size}\).

La struttura MinHeap rappresenta l’oggetto heap; le funzioni che agiscono su uno heap accettano come parametro un puntatore a MinHeap.

In un min-heap ogni nodo soddisfa la seguente proprietà:

La priorità di un nodo è minore o uguale a quella di entrambi i figli (se esistono)

Il file minheap.h contiene la specifica dell’interfaccia della struttura dati min-heap. Si chiede di realizzare le funzioni indicate, lasciando minheap_change_prio() per ultima perché è la più laboriosa. È sempre possibile definire ulteriori funzioni di supporto che si ritengano utili.

La Figura 1 mostra un esempio di min-heap con capienza massima size = 8 contenente \(n = 5\) elementi. L’attributo prio di ogni nodo ha valore minore o uguale a quello dei figli (se presenti). Lo heap è rappresentato da un array di size elementi, di cui i primi n contengono le coppie (chiave, priorità) effettivamente presenti, mentre i restanti hanno contenuto indefinito.

Figura 1: Esempio di min-heap di capienza massima size = 8 contenente n = 5 elementi, e sua rappresentazione tramite array
Figura 1: Esempio di min-heap di capienza massima size = 8 contenente \(n = 5\) elementi, e sua rappresentazione tramite array

Attenzione: le formule date a lezione per individuare l’indice dei figli/del padre di un nodo \(i\) devono essere adattate al linguaggio C in cui gli array sono indicizzati a partire da zero anziché da uno.

minheap_insert()

L’inserimento di una nuova coppia (chiave, priorità) avviene dalla base dello heap. La nuova coppia viene inserita nell’elemento heap[n] e si incrementa \(n\). Successivamente, si confronta l’attributo prio del nodo appena inserito con quello del padre, effettuando uno scambio nel caso in cui venga violata la proprietà di ordine. Gli scambi con il padre vanno ripetuti fino a quando il nuovo nodo raggiunge il livello corretto (Figura 2).

Figura 2: Inserimento della coppia (key=4, prio=1)
Figura 2: Inserimento della coppia (key=4, prio=1)

minheap_change_prio()

L’operazione più laboriosa è la minheap_change_prio(), che modifica la priorità associata ad una chiave già presente nello heap. Se la priorità di un nodo aumenta, occorre applicare l’operazione move_up() (una funzione ausiliaria che viene fornita con questo esercizio; si è liberi di usarla o meno) per far “scendere” il nodo fino al livello corretto (Figura 3).

Figura 3: minheap_change_prio(h, 0, 31.0)
Figura 3: minheap_change_prio(h, 0, 31.0)

Nel caso in cui la priorità diminuisca, potrebbe rendersi necessario effettuare scambi con il padre usando un’altra funzione ausiliaria move_down() fino a quando il nuovo nodo raggiunge il livello corretto, in modo simile a quanto fatto durante l’inserimento (Figura 4).

Figura 4: minheap_change_prio(h, 7, -2.0)
Figura 4: minheap_change_prio(h, 7, -2.0)

minheap_delete_min()

Per cancellare il nodo contenente la priorità minima, occorre procedere come segue (Figura 5):

Figura 5: minheap_delete_min(h)
Figura 5: minheap_delete_min(h)

Analisi

In uno heap con \(n\) elementi, le operazioni possono essere implementate con i costi asintotici riportati nella Tabella 1.

Tabella 1: Costi asintotici delle operazioni su uno heap binario
Operazione Costo
minheap_min() \(O(1)\)
minheap_delete_min() \(O(\log n)\)
minheap_insert() \(O(\log n)\)
minheap_change_prio() \(O(n)\) oppure \(O(\log n)\)1

Il programma minheap-main.c contiene una funzione main() che legge da file una sequenza di comandi, uno per ogni riga, che manipolano uno heap inizialmente vuoto. I comandi sono descritti nella Tabella 2.

Tabella 2: Comandi nel file di input
Comando Significato
n Inizializza lo heap per contenere al più n elementi; questa istruzione compare una sola volta all’inizio
+ key prio Inserisce la chiave key con priorità prio
- Cancella la coppia <key, prio> con priorità minima
? Stampa la chiave associata alla priorità minima
c key prio Modifica la priorità associata alla chiave key
s Stampa il numero \(n\) di elementi presenti nello heap
p Stampa il contenuto dello heap (per debug)

Per compilare;

    gcc -std=c90 -Wall -Wpedantic minheap.c minheap-main.c -o minheap-main

Per eseguire in ambiente Linux/MacOSX:

    ./minheap-main minheap.in

Per eseguire in ambiente Windows

    .\minheap-main minheap.in

Suggerimenti

Funzioni di utilità

Nel file minheap.c sono presenti delle funzioni di utilità quelle definite static, in modo che non possano essere invocate da funzioni definite fuori da quel file. Lo scopo di ciascuna è indicato nei commenti al codice.

Di particolare interesse sono le funzioni move_up(h, i) e move_down(h, i), che hanno lo scopo di far salire (rispettivamente, scendere) il nodo heap[i] fino a fargli raggiungere la posizione corretta. move_up() scambia ripetutamente il nodo con il padre, mentre move_down() scambia ripetutamente il nodo con il figlio di priorità minima; in entrambi i casi, lo scambio avviene se il nodo non occupa già la posizione corretta rispetto al padre o ai figli.

Le funzioni minheap_insert(), minheap_change_prio() e minheap_delete_min() possono essere realizzate in modo abbastanza semplice sfruttando move_up() e move_down(). L’implementazione risultante è però diversa da quella vista a lezione (e descritta nel libro): chi vuole può seguire altre strade.

Ottimizzazione di minheap_change_prio()

Implementare minheap_change_prio(h, key, newprio) in modo efficiente richiede attenzione, perché trovare il nodo dello heap contenente la chiave data key richiede tempo \(O(n)\) nel caso peggiore; la chiave, infatti, potrebbe trovarsi in qualsiasi posizione. Una volta trovato il nodo, serve tempo \(O(\log n)\) per spostarlo verso l’alto o verso il basso fino a raggiungere il nuovo livello corretto. Quindi il costo asintotico di minheap_change_prio() è dominato dalla ricerca della chiave, che è l’operazione più lenta.

Sfruttando il fatto che le chiavi appartengono all’insieme \(\{0, \ldots, \texttt{size}-1\}\), e che ogni chiave compare al più una volta, possiamo effettuare la ricerca in tempo \(O(1)\) utilizzando un secondo array pos[] di lunghezza size. pos[k] è la posizione (l’indice) dell’elemento di chiave k nell’array heap[], se presente (Figura 6). Quindi, per ogni chiave k presente nello heap deve valere la proprietà:

    heap[pos[k]].key == k

Se k non è presente, si pone pos[k] = -1.

Figura 6: Uso dell’array pos[] per indicare la posizione (indice) delle chiavi nell’array heap[]
Figura 6: Uso dell’array pos[] per indicare la posizione (indice) delle chiavi nell’array heap[]

Il contenuto di pos[] può essere mantenuto aggiornato in tempo \(O(1)\) man mano che gli elementi in heap[] vengono scambiati tra loro durante le varie operazioni sullo heap. Ciò richiede un po’ di attenzione, ma non è particolarmente problematico.

File


  1. Per iniziare, realizzare minheap_change_prio() mediante una scansione dell’array heap[] per cercare la posizione dell’elemento di chiave data; questa soluzione richiede tempo \(O(n)\) nel caso peggiore. Nel seguito vengono forniti alcuni suggerimenti per ridurre il costo asintotico di questa operazione a \(O(\log n)\) nel caso peggiore.