Implementazione in C di una pila

La struttura dati pila

La pila è una struttura dati di tipo LIFO per cui l'ultimo elemento inserito è il primo a uscire.

Le operazioni sulla pila sono solo tre:

  • verifica che la pila [non] sia vuota (empty)
  • inserimento di un nuovo elemento (push)
  • restituzione di un elemento e cancellazione dalla pila (pop)

Esempio:

push('c')

c

push('i')

c i

push('a')

c i a

push('o')

c i a o

pop() // ottengo la o

c i a

pop() // ottengo la a

c i

Tempo di esecuzione

Inserimento: ο(1)

Cancellazione: ο(1)

Ricerca: ο(n)

Implementazione

In questa implementazione, la pila è un insieme di nodi, ciascuno dei quali contiene un valore di tipo char e un puntatore al nodo successivo.

// in stack.h
typedef struct stack * Stack;

// in stack.c
struct stack {
char c;
Stack next;
};

Nel file di header è definito un tipo Stack che rappresenta una struct stack *, quindi Stack è un puntatore.

Nel file .c è definita la struttura stack (s minuscola) che contiene il valore del nodo e il puntatore al nodo successivo. Una scrittura alternativa, che non fa uso della definizione del tipo Stack è la seguente:
// in stack.c
struct stack {
char c;
stack * next;
};

init

è stata aggiunta una funzione init() che alloca della memoria e restituisce il puntatore a un nodo vuoto.


Stack init() {
Stack s;
s = (Stack)malloc(sizeof(Stack));
s->next = NULL;
return s;
}

Il cast a Stack presente nella malloc non è strettamente necessario.

empty


int empty(Stack s) {
return s->next == NULL;
}

Dal momento che l'ultimo nodo è vuoto, faccio una verifica sul campo next.

push


void push(Stack *s, char c) {
Stack newStack;
newStack = init();
newStack->c = c;
newStack->next = *s;
*s = newStack;
}

push prende in ingresso il puntatore a uno Stack (quindi un puntatore al puntatore di stack con la s minuscola).

In questo modo quando si usa s si sta utilizzando un indirizzo di memoria, e non un valore. Nella pratica, si agisce sul dato contenuto nell'indirizzo puntato da s; viceversa sarebbe fatta una copia del contenuto di s.

Con puntatore Senza puntatore
push(Stack *s, char c)
è passato un puntatore a s
push(Stack s, char c)
è passato s: in realtà è una "copia" che ha la funzione come scope
Stack newStack;
newStack = init();
newStack->c = c;

Si inizializza un nuovo elemento Stack
Stack newStack;
newStack = init();
newStack->c = c;

Si inizializza un nuovo elemento Stack
newStack->next = *s;
il next contiene un puntatore a s
newStack->next = s;
il next contiene s (una copia del contenuto "originale")
*s = newStack;
l'indirizzo a cui punta s è sostituito dall'indirizzo a cui punta newStack
s = newStack;
s è sostituito dall'indirizzo a cui punta newStack

Al termine di queste operazioni, il contenuto di s nella funzione che utilizza push è cambiato solo nella versione con puntatore; viceversa l'esecuzione di push non ha effetto sulla funzione chiamante nella versione in cui è passato s come valore.

pop

La funzione pop restituisce un char e rimuove l'elemento dalla pila.
char pop(Stack *s) {
char c;
Stack oldS;
oldS = *s;
c = (*s)->c;
*s = (*s)->next;
free(oldS);
return c;
}

Vanno mantenute sia una copia del carattere sia del puntatore, perché dopo aver "spostato" il puntatore a next (*s = (*s)->next;) è necessario prima deallocare lo spazio ormai inutilizzato sia restituire il carattere, che "sopravvive" solo nella copia fatta precedentemente.

Se non si facesse il free dello spazio, questo resterebbe allocato senza che sia puntato da alcun puntatore. Quindi ci sarebbe uno spreco di memoria, crescente a ogni operazione, che si libererebbe solo al termine dell'esecuzione del programma tramite il garbage collector del sistema operativo.

main

main(int argc, char *argv[]) {
Stack s;
char c;
s = init();
push(&s, 'o');
push(&s, 'a');
push(&s, 'i');
push(&s, 'c');
while(!empty(s)) printf("%c", pop(&s));
pop(&s);
getchar();
}

Nel main è necessario passare come parametri non tanto il valore di s, quanto il suo indirizzo di memoria.
L'output di questo esempio sarà "oaic".
Al termine di questo esempio è stato aggiunto un getchar() per comodità.

Compitino ASD 20/01/2016 / 3

Si definiscano formalmente le relazioni O, Ω, Θ, o, ω e si dimostri la verità o la falsità di ciascuna delle seguenti affermazioni, giustificando formalmente le risposte:

  1. Se P(n) è un polinomio di grado k, allora P(n) = Θ(nk)
  2. n = O(n log log n)
  3. n log log n = O(n1+ε), per ogni ε > 0
  4. f(n) = O(g(n)) se e solo se g(n) = Ω(f(n))
  5. ω(f(n)) ∩ O(g(n)) = ∅

Definizioni

O(f(n)) = {g(n) : ∃ c > 0, n0 ≥ 0, g(n) cf(n) ∀ n ≥ n0}

O(f(n)) significa che esiste una funzione g(n) tale che, data una costante c maggiore di zero e un parametro zero n0 maggiore o uguale a zero, g(n) è minore o uguale a f(n) per la costante per ogni n maggiore o uguale a n0.

Ω(f(n)) = {g(n) : ∃ c > 0, n0 ≥ 0, g(n) cf(n) ∀ n ≥ n0}

Analogamente, o(f(n)) = {g(n) : ∃ c > 0, n0 ≥ 0, g(n) < cf(n) ∀ n ≥ n0} e ω(f(n)) = {g(n) : ∃ c > 0, n0 ≥ 0, g(n) > cf(n) ∀ n ≥ n0}

Θ(f(n)) = {g(n) : ∃ c1 > 0, c2 > 0, n0 ≥ 0, c1f(n) ≤ g(n) ≤ c2f(n) ∀ n ≥ n0}

ovvero, scelte due costanti diverse e maggiori di zero, per una costante vale O(f(n)) e per l'altra vale Ω(f(n)).

Se P(n) è un polinomio di grado k, allora P(n) = Θ(nk)

Vero, poiché per la definizione di Θ, Θ(nk) = {g(n) : ∃ c1 > 0, c2 > 0, n0 ≥ 0, c1f(n) ≤ g(n) ≤ c2f(n) ∀ n ≥ n0}.

Asintoticamente, i termini di grado inferiore a k si possono omettere, perché per ogni h < k, si ha che

limnnknh=0

Dunque, in un polinomio nella forma cink+ciink-1+ciiink-2+ ... + cin, basta considerare il termine di grado più alto (cink) e prendere un c1 < ci e un c2 > ci

n = O(n log log n)

L'uguaglianza è vera se, per la definizione di O(n), n ≤ n log log n; dividendo entrambi i termini per n, si ottiene che 1 ≤ log log n, che è vero per tutti gli n ≥ e^2

n log log n = O(n1+ε), per ogni ε > 0

n1+ε si può scrivere anche n * nε, in questo modo è possibile dividere per n entrambi i membri. L'uguaglianza è verificata se log log n ≤ nε per ogni ε > 0.

L'uguaglianza si può dire verificata se è vero che:

limnnεloglogn=0

Ma applicando de l'hôpital, vediamo che

limnεnε-11nlogn=limnεnε-1*nlogn1

che tende a +∞.

La risposta alla domanda è quindi che non è possibile generalizzare l'equivalenza per ogni ε > 0.

f(n) = O(g(n)) se e solo se g(n) = Ω(f(n))

Vero, è la proprietà della simmetria trasposta.

ω(f(n)) ∩ O(g(n)) = ∅

Falso.

Per definizione (condizioni al contorno omesse), ω(f(n)) = {g(n) > cf(n)} e O(g(n)) = {f(n) cg(n)}, quindi g(n) > cf(n) ∩ f(n) cg(n) ⇒ f(n) < cg(n), che è esattamente ω(f(n)).

Elenco degli algoritmi di ordinamento

La seguente tabella riepiloga gli algoritmi di ordinamento.
La spiegazione del funzionamento è solo un promemoria, non ha intento di essere esauriente.

Algoritmo Complessità Funzionamento
InsertionSort O(n2) (base) Si lascia il primo elemento dov'è

(passo induttivo) Per ogni elemento n, si trova il primo elemento maggiore tra quelli già ordinati, si sposta di una posizione da lì a n e si inserisce n tra quello appena minore e quello appena maggiore

SelectionSort O(n2) (base) Si scorre l'elenco trovando quello più piccolo e si scambia con il primo.

(passo induttivo) Posto che fino all'elemento n-1 l'elenco è ordinato e contiene solo elementi più piccoli di quelli ancora da ordinare, si scorre l'elenco da n alla fine e si scambiano l'n-esimo con il più piccolo.

BubbleSort O(n2) (base) Si confrontano il primo e il secondo elemento. Se il primo è minore del secondo non si fa niente, se il secondo è minore del primo si scambiano.

(passo induttivo) Si scorre tutto l'insieme effettuando scambi a coppie se il primo elemento è maggiore del secondo. Si procede finché tutto l'elenco non è ordinato. In pratica, si identifica al termine del primo ciclo l'elemeno più grande, poi il vice-più grande e così via

HeapSort O(nlog(n)) Utilizzando un heap si trasferisce la lista nell'heap e poi si rimuovono uno a uno gli elementi. Se max-heap si rimuove il più grande e si mette alla fine, se min-heap il contrario.
MergeSort Θ(nlog(n)) Algoritmo divide-et-impera
(Impera) avendo due sequenze ordinate da fondere, basta estrarre di volta in volta l'elemento più piccolo tra i due elementi correnti delle due code e incrementare l'indice.
(Divide) divide l'array in due parti uguali e applica il mergesort stesso a entrambe le metà.
QuickSort O(n2) (caso peggiore), O(nlog(n)) (caso medio) Algoritmo divide-et-impera
(Impera) concatena i due pezzi di array divisi dalla parte divide.
(Divide) divide l'array in due parti, in cui la prima contiene gli elementi ≥ x e la seconda gli elementi < x, dove x è un numero scelto casualmente.
IntegerSort

Nota: è detto anche CountingSort

O(n+k), dove k è l'intero più grande dell'array L'algoritmo usa un array A di appoggio di dimensione k, in cui a ogni elemento e dell'array di partenza incrementa A[e]. Alla fine scorre l'array A aggiungendo un elemento a ogni A[i] > 0 e decrementa A[i].
BucketSort O(n+k), dove k è l'intero più grande dell'array L'algoritmo funziona in maniera molto simile a IntegerSort, ma ordina record invece che interi e quindi non può usare solo un array di interi ma deve usare una lista di record.

Strutture dati

Riepilogo le strutture dati principali e le loro caratteristiche

Array

Struttura dati composta da un indice ordinato e un elemento.

0 Elem1
1 Elem2
2 Elem3
3 Elem4
4 Elem5

Tipicamente l'indice va da zero a n-1, quindi  in un array di 5 elementi gli indici sono 0, 1, 2, 3, 4.

L'array ha dimensioni fisse, quindi non è possibile aggiungere o togliere elementi; inoltre gli indici sono attribuiti automaticamente, quindi non è possibile assegnare loro dei valori.

Record

Struttura composta da un indice e un elemento.

A differenza degli array, l'indice non è ordinato, ma assegnato dalla macchina, che generalmente usa un indirizzo di memoria virtuale.

Per questo motivo, mentre due array nello stesso programma hanno un indice = 0 che si riferisce a due zone di memoria diverse - perché sono di due array diversi - nel caso dei record l'indirizzo di memoria è unico per il programma.

Questo comporta che un record possa essere inserito e eliminato, "allungando" e "accorciando" la dimensione della struttura in cui è utilizzato.

In una lista ogni elemento deve contenere il proprio indice, l'elemento e un indice che punta all'elemento successivo.

I record possono essere concatenati in vari modi, dando luogo a strutture dati diverse:

  • lista semplice (solo avanti)
  • lista doppia (avanti e indietro)
  • lista circolare semplice e doppia

Nella lista semplice ogni elemento contiene un puntatore all'elemento successivo, per questo può essere scorsa solo in avanti.

Nella lista doppia ogni elemento contiene anche un puntatore all'elemento precedente, così è possibile scorrerla sia in avanti sia indietro.

Nella lista circolare non esiste un ultimo elemento, perché quello che "sarebbe" l'ultimo elemento è collegato con il primo, da cui la struttura circolare.

Pila / Coda

La pila e la coda sono due strutture dati in cui è possibile inserire e rimuovere un elemento in una lista ordinata.

La differenza tra pila e coda è che la pila è una lista di tipo LI-FO (last-in, first-out): come in un ascensore, l'ultimo a entrare è il primo a uscire, mentre la coda è una lista FI-FO (first-in, first-out): come alle poste, il primo che entra è il primo che esce.

Alberi

Un albero è una struttura dotata di nodi e archi; gli archi collegano tra loro nodi.

I nodi sono organizzati secondo una gerarchia, per cui esiste un nodo radice, che può avere uno o più figli.

Ogni nodo può avere più nodi figli, ma un solo nodo padre (in questo sistema la "madre" non è contemplata :-))

Se un nodo non ha figli, è chiamato foglia.

La profondità è il numero di archi che bisogna attraversare per raggiungere il nodo:

  • La radice ha profondità zero
  • Se un nodo ha profondità k, tutti i suoi figli hanno profondità k+1

Rappresentazioni degli alberi

Per implementare un albero è possibile scegliere tra queste strutture, tutte con pro e contro:

  • vettore padri: ogni elemento contiene la propria informazione e un puntatore al padre (null se l'elemento è radice). Risalire al padre ha tempo O(1), mentre risalire ai figli O(n)
  • vettore posizionale: se ogni nodo ha un numero fissato di figli, è possibile rappresentare l'albero come un semplice vettore e calcolare la posizione di un nodo tramite la sua posizione nell'array. Per esempio in un albero binario la radice è 1, i due figli sono 2, 3, i nipoti sono 4, 5, 6, 7 eccetera, e tutto questo non presenta ambiguità, poiché ogni nodo ha esattamente due figli
  • rappresentazioni collegate: contengono, oltre all'informazione, un puntatore al nodo padre e un accesso ai figli di vario tipo:
    • se sappiamo a priori quanti figli massimi può avere un nodo, possiamo predisporre per ogni nodo un numero di puntatori ai figli
    • se NON sappiamo il numero dei figli, possiamo aggiungere un puntatore a una lista di puntatori ai figli. Questo occupa più memoria, ma permette di estendere il numero dei figli in modo illimitato
    • se NON sappiamo il numero dei figli, possiamo aggiungere due puntatori per ogni nodo, uno al primo figlio e un altro al fratello successivo. La struttura è più snella dal punto di vista della memoria ma leggermente più lenta perché per accedere a un figlio è possibile dover scorrere tutta la lista di fratelli.

Heap

Gli heap sono alberi binari in cui vale sempre la proprietà che un nodo ha valore maggiore (max-heap) o minore (min-heap) di ciascuno dei suoi sotto-nodi.

Non esistono vincoli di tipo destra-sinistra, l'unico aspetto che deve essere sempre rispettato è che il padre, se esiste è maggiore del figlio e se non esiste è il nodo massimo (per max-heap; per min-heap vale il discorso contrario).