Parte 3 Algoritmi e strutture dati elementari Algoritmi su sequenze Problema della ricerca ● Input: una sequenza A (con n elementi) e un valore x ● Output: True se x compare in A, False altrimenti ● oppure ● Output: la posizione (o una posizione) di x in A, o un valore anomalo se x non compare in A Ricerca lineare ● ● Se non si hanno informazioni sulla disposizione degli elementi di A, l'unico modo di risolvere il problema è quello di scandire tutti gli elementi di A Ci si ferma ● ● quando si trova un elemento uguale a x (successo), oppure quando gli elementi sono finiti (insuccesso) Ricerca lineare def ricerca_lineare(A,x): n=len(A) trovato=False i=0 while i<n and not trovato: if A[i]==x: trovato=True else: i=i+1 return trovato Analisi ● ● ● Terminazione e correttezza sono ovvie Il costo nel caso peggiore è di n confronti, che si verifica quando ● x è all'ultimo posto di A ● x non è presente in A Possiamo quindi dire che il costo complessivo è O(n) Ricerca binaria ● ● Se A è ordinata (ad esempio in senso crescente) si può usare un metodo più veloce L'idea è di confrontare l'elemento centrale di A con x parte sinistra ● ● ● parte destra se è uguale a x, l'algoritmo termina con successo se è maggiore di x, la ricerca continua sulla parte sinistra di A (gli elementi della parte destra sono sicuramente maggiori di x) se è minore di x, la ricerca continua sulla parte destra di A Ricerca binaria def ricerca_binaria(A,x): n=len(A) s=0 d=n-1 trovato=False while s<=d and not trovato: m=(s+d)/2 if A[m]==x: trovato=True elif A[m]>x: d=m-1 else: s=m+1 return trovato Esempio di esecuzione ● ● ● ● ● Partendo dall'input A=[1,3,4,5,8,11,15,20,37] e x=4 All'inizio s=0 e d=n-1=9 Alla prima iterazione m=4 e A[m]=8, quindi d=m-1=3 Alla seconda iterazione m=1 e A[m]=3, quindi s=m+1=2 Alla terza iterazione m=2 e A[m]=2, quindi l'algoritmo termina Analisi ● ● ● Terminazione e correttezza dipendono dal fatto che ad ogni passo il numero di elementi in considerazione (pari d-s+1) dimezza: prima o poi ne rimarranno uno o due e poi all'iterazione nessuno Il costo dipende dal numero di passaggi con cui dimezzando n si arriva a 1 Questo è pari a log2n Ordinamento ● ● ● ● Problema molto importante: data una sequenza A di numeri (o dati ordinabili), disporre gli elementi in senso crescente (o decrescente) Si dice ordinamento “sul posto” perché si modifica l'ordine della sequenza di partenza, senza crearne una nuova E' utile in tante situazioni avere dati ordinati secondo qualche criterio Esistono molti algoritmi di ordinamento Scambio ● ● ● Definiamo una funzione che data una sequenza A e due indici i e j, scambia tra di loro gli elementi di posto i e di posto j def scambia(A,i,j): temp=A[i] A[i]=A[j] A[j]=temp In Python sarebbe possibile anche farlo con un unico assegnamento (usando le tuple)... Bubble-sort ● L'idea del Bubble-sort è quella di controllare le coppie di elementi consecutivi A[i] e A[i+1] ● ● ● se A[i]>A[i+1], sono fuori posto e allora si scambiano tra di loro L'intera scansione di A non è sufficiente ad ordinare A Ma è in grado di portare l'elemento maggiore di A all'ultimo posto (perché ?) Esempio di passaggio ● Partendo da A=[4,5,2,1,8,11,7,9], i passaggi successivi sono ● [4,5,2,1,8,11,7,9] ● [4,5,2,1,8,11,7,9] scambia 5 e 2 ● [4,2,5,1,8,11,7,9] scambia 5 e 1 ● [4,2,1,5,8,11,7,9] ● [4,2,1,5,8,11,7,9] ● [4,2,1,5,8,11,7,9] scambia 11 e 7 ● [4,2,1,5,8,7,11,9] scambia 11 e 9 ● [4,2,1,5,8,7,9,11] Bubble-sort ● Eseguendo una seconda scansione si hanno i seguenti passaggi ● [4,2,1,5,8,7,9,11] scambia 4 e 2 ● [2,4,1,5,8,7,9,11] scambia 4 e 1 ● [2,1,4,5,8,7,9,11] ● [2,1,4,5,8,7,9,11] ● [2,1,4,5,8,7,9,11] scambia 8 e 7 ● [2,1,4,5,7,8,9,11] ● [2,1,4,5,7,8,9,11] ● [2,1,4,5,7,8,9,11] Bubble-sort ● ● ● Infine con una terza scansione la sequenza viene ordinata Infatti al primo passaggio si scambia 2 e 1, e ogni altro controllo non cambierà più l'ordine Ad ogni scansione un elemento va al posto giusto: ● ● ● ● La prima scansione mette l'elemento più grande all'ultimo posto La seconda scansione mette il secondo elemento più grande al penultimo posto Ecc. Quindi sono sufficienti n-1 scansioni, dato che una volta messo a posto il secondo elemento anche il primo è per forza al suo posto Bubble-sort def bubble_sort(A): n=len(A) for r in range(1,n): for i in range(0,n-r): if A[i]>A[i+1]: scambia(A,i,i+1) ● Il ciclo interno su i può essere ad ogni passaggio sempre più corto: i controlli saltati sarebbero inutili Miglioramento ● ● ● In molte situazioni sono sufficienti un numero minori di scansioni Quando in una scansione non è svolto alcuno scambio, ogni altra scansione sarà inutile e il ciclo può terminare A tal scopo si può cambiare il ciclo for su r con un ciclo while Versione migliorata def bubble_sort(A): n=len(A) r=0 scambiato=True while r<n-1 and scambiato: scambiato=False for i in range(0,n-r-1): if A[i]>A[i+1]: scambia(A,i,i+1) scambiato=True r=r+1 Calcolo del costo ● ● numero di confronti (n-1)+(n-2)+(n-3)+...+1=O(n2) numero di scambi nel caso peggiore 2 (n-1)+(n-2)+(n-3)+...+1=O(n ) 2 Perché O(n ) ● ● Una dimostrazione informale del perché n n1 1+2+3+...+n fa 2 è la seguente Sia n=12, scriviamo i numeri da 1 a 12 così 1 2 3 4 12 11 10 9 ● ● ● 5 6 8 7 La somma di ogni colonna è 13, le colonne sono 6, quindi la somma totale è 6x13=78 In generale, per n pari, le colonne sono n/2 e ognuno ha somma n+1 Anche per n dispari la formula vale Selection-sort ● Idea ● ● ● il più piccolo elemento di A deve stare al primo posto il secondo elemento più piccolo deve stare al secondo posto ● … ● l'elemento più grande deve stare all'ultimo posto Soluzione: trovo l'i-esimo elemento più grande e lo scambio con l'elemento che si trova all'iesimo posto Selection-sort def selection_sort(A): n=len(A) for i in range(0,n-1): imin=i for j in range(i+1,n): if A[j]<A[imin]: imin=j if i!=imin: scambia(A,i,imin) ● imin serve a memorizzare la posizione dell'elemento più piccolo a partire da i Esempio di esecuzione ● ● ● ● ● Partendo da A=[6,5,8,11,2,4,9] Al primo passaggio scambia 6 con 2 ottenendo [2,5,8,11,6,4,9] Al secondo passaggio scambia 5 con 4 ottenendo [2,4,8,11,5,4,9] Al terzo passaggio scambia 8 con 5 ottenendo [2,4,5,11,6,8,9] I passaggi successivi sono [2,4,5,6,11,8,9], [2,4,5,6,8,11,9] e [2,4,5,6,8,9,11] Costo ● ● ● numero di confronti (n-1)+(n-2)+(n-3)+...+1=O(n2) numero di scambi nel caso peggiore n-1 Anche questo metodo ha costo O(n 2) Insertion-sort ● ● L'idea è di ordinare una sequenza usando la stessa tecnica di un giocatore di carte (ad esempio di Ramino o Scala 40) Avendo già in mano A♠ 3♣ ● 4♠ 7♥ 9♦ se gli arriva un 5♦ lo inserisce tra il 4 e il 7 ottenendo A♠ 3♣ 4♠ 5♦ 7♥ 9♦ Insertion-sort ● ● Le carte scorrono facilmente ed è semplice creare posto ad una nuova carta Per compiere l'operazione analoga su una sequenza è necessario spostare ogni elemento, a partire dalla posizione in cui si deve inserire l'elemento, di un posto verso destra 1 3 4 7 5 9 Insertion-sort ● ● Il modo con cui si può ordinare una sequenza è il seguente: Si divide la sequenza in due parti: ● ● ● una parte a sinistra già ordinata (all'inizio solo il primo elemento) una parte a destra non ordinata (all'inizio tutti gli altri) Ad ogni passo si inserisce il primo elemento della parte a destra nella parte già ordinata (mantenendola in ordine) Insertion-sort ● ● ● L'inserimento avviene partendo dall'ultimo elemento della parte già ordinata e copiando man mano tutti gli elementi sulla cella successiva Quando si trova un elemento più piccolo dell'elemento x da inserire il ciclo si ferma A questo punto x è memorizzato al posto dell'ultimo elemento copiato Inserimento ● Ad esempio per inserire x=8 nella parte già ordinata [2,4,10,11,14,15] si parte da [2,4,10,11,14,15,8] [2,4,10,11,14,15,15] [2,4,10,11,14,14,15] [2,4,10,11,11,14,15] [2,4,10,10,11,14,15] il ciclo termina dato che 4<8, quindi scrive 8 al posto del primo 10 [2,4,8,10,11,14,15] Insertion-sort def insertion_sort(A): n=len(A) sistemare a[j] nella a[0..j-1] già ordinata for j in range(1,n): temp=a[j] i=j-1 while i>=0 and a[i]>temp: a[i+1]=a[i] i=i-1 a[i+1]=temp parte Esempio di esecuzione ● Ad esempio partendo con A=[7,9,5,2,10,3,4] i passaggi sono [7,9,5,2,10,3,4] [7,9,5,2,10,3,4] la parte già ordinata è [5,7,9,2,10,3,4] sottolineata [2,5,7,9,10,3,4] [2,5,7,9,10,3,4] [2,3,5,7,9,10,4] [2,3,4,5,7,9,10] Costo ● ● ● numero di confronti nel caso peggiore (n-1)+(n-2)+(n-3)+...+1=O(n2) numero di assegnamenti nel caso peggiore 2 (n-1)+(n-2)+(n-3)+...+1=O(n ) Pure questo metodo ha costo O(n 2) Quick-sort ● ● ● Uno degli algoritmi per l'ordinamento più efficienti ed utilizzati è il Quick-Sort Si tratta di un algoritmo ricorsivo basato sulla tecnica del “divide et impera” Ci sono due fasi ● ● divide il problema in due o più sottoproblemi dello stesso tipo e risolvili ricorsivamente combina insieme le soluzioni ottenute Divide et impera Si divide la sequenza da ordinare in due parti Si ordinano (ricorsivamente) le due parti Si ricombinano insieme le parti ordinate in modo da ottenere l'ordinamento di tutta la sequenza Quick-Sort ● ● ● La divisione della sequenza si potrebbe fare in tanti modi Nel Quick-Sort si divide in un modo che non c'è bisogno di fare niente quando si ricombinano le due parti già ordinate E' sufficiente che ogni elemento della prima parte sia ≤ di ogni elemento della seconda parte Partition ● ● Un modo per ottenere ciò è dato dalla procedura Partition Scelto un elemento x detto “pivot” ● ● si mettono nella prima parte tutti gli elementi di A ≤x si mettono nella seconda parte tutti gli elementi di A ≥x Partition def partition(A,s,d): x=A[s] j=s for i in range(s+1,d+1): if A[i]<x: j=j+1 scambia(A,i,j) scambia(A,s,j) return j Commenti ● ● All'inizio del ciclo for: ● il pivot si trova nella posizione s ● j=s Ad ogni iterazione ● gli elementi da s+1 a j sono < del pivot ● gli elementi da j+1 a i-1 sono >= del pivot ● ● Se A[i]<pivot, A[i] è spostato nella prima parte incrementando j Alla fine del ciclo ● gli elementi da s+1 a j sono < del pivot ● gli elementi da j+1 a d sono >= del pivot Esempio [9 || 10, 7, 4, 3, 2, 11, 8, 15] [9 || 10, 7, 4, 3, 2, 11, 8, 15] [9 | 7 | 10, 4, 3, 2, 11, 8, 15] [9 | 7, 4 | 10, 3, 2, 11, 8, 15] [9 | 7, 4, 3 | 10, 2, 11, 8, 15] [9 | 7, 4, 3, 2 | 10, 11, 8, 15] [9 | 7, 4, 3, 2 | 10, 11, 8, 15] [9 | 7, 4, 3, 2, 8 | 11, 10, 15] pivot parte sinistra parte destra Commenti ● ● Dopo lo scambio finale ● gli elementi da s a j-1 sono < del pivot ● il pivot si trova in posizione j ● gli elementi da j+1 a d sono >= del pivot Le due parti da ordinare con il divide et impera sono perciò ● da s a j-1, e ● da j+1 a d Quicksort def quicksort(A,s,d): if s<d: j=partition(A,s,d) quicksort(A,s,j-1) quicksort(A,j+1,d) Costo ● ● ● ● L'analisi del costo dell'algoritmo Quick-sort è molto complicata Il numero di operazioni dipende dalla scelta del pivot Una scelta buona è quella di creare con la partition due parti con più o meno la stessa grandezza La scelta ottimale per il pivot sarebbe la mediana, che crea due parti di ugual grandezza Costo ● Il numero di livelli di ricorsione è logaritmico, in ogni livello il numero di operazioni è O(n), quindi il numero di operazioni totali è O(n log 2n) 16 ● 8 8 4 4 4 4 2 2 2 2 2 2 11 1 1 1 1 1 1 1 1 11 2 2 1 1 11 Costo ● ● ● Se la scelta del pivot è casuale, in media l'andamento asintotico è sempre O(n log 2n) Nel caso peggiore, partition crea sempre una partizione sbilanciata (1 da una parte e tutti gli altri dall'altra) In tale situazione il numero di livelli di ricorsione è O(n) e quindi il numero totale di operazioni è O(n2) Operazioni insiemistiche ● ● Le operazioni insiemistiche (unione, intersezione, differenza, ecc.) possono essere facilmente implementate su insiemi rappresentati come sequenze Si ha un vantaggio considerevole se le sequenze sono ordinate Intersezione ● Siano dati due insiemi A e B ● Si usano due indici, i su A e j su B ● Ad ogni passo si confronta A[i] con B[j] ● ● Se sono uguali, tale elemento è inserito nell'intersezione C ed i e j sono incrementati Altrimenti l'indice relativo all'elemento minore viene incrementato Intersezione def intersezione_ordinate(a,b): n=len(a) m=len(b) c=[ ] i=0 j=0 while i<n and j<m: if a[i]==b[j]: c.append(a[i]) i=i+1 j=j+1 elif a[i]<b[j]: i=i+1 else: j=j+1 return c Unione ● ● ● ● Per calcolare l'unione tra A e B si usa un procedimento analogo Se A[i] è diverso da B[j] si inserisce nell'unione C il minore tra i due e l'indice corrispondente è incrementato Se sono uguali, tale valore è inserito in C e entrambi gli indici sono incrementati A fine ciclo, tutti i restanti elementi di A (o di B) sono inseriti in C Costo ● ● Sia per il calcolo dell'unione che per l'intersezione di insiemi rappresentati come sequenze ordinate il costo complessivo è O(m+n), ove m è il numero di elementi di A e n quello di B Se le sequenze non fossero ordinate, il costo salirebbe a O(m n) Strutture dati dinamiche elementari: liste concatenate Strutture dati dinamiche ● ● Le strutture dati dinamiche nascono dall'esigenza di memorizzare una collezione di elementi con un numero variabile di elementi e di poter inserire, cancellare e cercare elementi in modo efficiente Le sequenze (dette impropriamente liste in Python) non soddisfano appieno questi requisiti: inserimenti e cancellazioni non sono così efficienti Strutture dati dinamiche ● ● In particolare l'inserimento e la cancellazione richiedono un'eventuale ridimensionamento della sequenza Inoltre se l'inserimento o la cancellazione non avvengono in fondo alla sequenza, è necessario spostare gli elementi per far posto al nuovo elemento (nell'inserimento) o eliminare lo spazio occupato dall'elemento (nella cancellazione) Strutture dati dinamiche ● ● Infine, in molti linguaggi di programmazione, ad esempio in C (ma non in Python) gli array hanno una lunghezza fissa che non può essere in alcun modo alterata Diventa quindi indispensabile avere un metodo di memorizzazione di collezioni di dati che sia più flessibile rispetto alle sequenze e agli array Liste ● ● ● ● Le liste sono le strutture dati più semplici L'organizzazione è di tipo “lineare”: ogni elemento ha un immediato successore (tranne l'ultimo) ed un immediato predecessore (tranne il primo) Si possono creare liste unidirezionali e liste bidirezionali In questo corso vedremo solo le liste bidirezionali Liste bidirezionali ● ● ● ● Ogni elemento ha una chiave ed è collegato all'elemento precedente e all'elemento successivo, tramite dei riferimenti prev e next Nei due casi estremi (primo ed ultimo elemento) i riferimenti valgono None La lista è gestita memorizzando solo il riferimento al primo (first) e all'ultimo elemento (last) Nel caso di lista vuota, entrambi i riferimenti sono None Definizione delle strutture dati class nodo_lista: def __init__(self): self.key=None self.prev=None self.next=None class lista: def __init__(self): self.first=None self.last=None Esempio di lista ● ● Come esempio creiamo la lista Come primo passo creiamo tre variabili nodo_lista p1=nodo_lista( ) p2=nodo_lista( ) p3=nodo_lista( ) Esempio di lista ● ● Inseriamo le chiavi p1.key=1 p2.key=2 p3.key=3 Infine creiamo i collegamenti p1.next=p2 p2.next=p3 p3.prev=p2 p2.prev=p1 Esempio di lista ● ● Creiamo ora una variabile lista L=lista( ) L.first=p1 L.last=p2 Chiaramente serve un meccanismo più semplice di creazione di una lista tramite inserimento Inserimento all'inizio ● Per inserire un nodo di chiave x all'inizio di una lista L def ins_inizio(L,x): n=nodo_lista() n.key=x n.next=L.first if L.first != None: L.first.prev=n else: L.last=n L.first=n Inserimento alla fine ● Per inserire un nodo di chiave x alla fine di una lista L def ins_fine(L,x): n=nodo_lista() n.key=x n.prev=L.last if L.last != None: L.last.next=n else: L.first=n L.last=n Esempi ● Inserimento all'inizio di 5 ● Inserimento alla fine di 7 Inserimento ● ● L'inserimento sia all'inizio che a fine lista avviene in un numero fisso di operazioni (costo O(1)) In maniera analoga può essere svolto l'inserimento in un punto arbitrario della lista (ad esempio prima o dopo un dato nodo) Cancellazione ● Per cancellare un nodo p in una lista L occorre “scavalcare” p def cancella(L,p): if p.next!=None: p.next.prev=p.prev else: L.last=p.prev if p.prev!=None: p.prev.next=p.next else: L.first=p.next Cancellazione ● Ad esempio per cancellare il 4 Anche per cancellare il costo è O(1) Scrivere una lista sullo schermo ● Per scrivere il contenuto di una lista sullo schermo def scrivi_lista(L): p=L.first while p!=None: print p.key, p=p.next print Scansione ● In generale il frammento di codice p=L.first while p!=None: fai qualcosa con p.key p=p.next serve a scorrere in avanti l'intera lista L Scansione ● ● Infatti p inizialmente si riferisce al primo elemento di L Ad ogni passo p è aggiornato con p.next ● ● ● ● se p si riferisce al primo elemento, p.next si riferisce al secondo se p si riferisce al secondo elemento, p.next si riferisce al terzo … se p si riferisce all'ultimo elemento, p.next è None (e il ciclo termina) Scansione all'indietro ● Una lista bidirezionale, in virtù della presenza di prev, può anche essere scorsa all'indietro p=L.last while p!=None: fai qualcosa con p.key p=p.prev Lunghezza ● Per calcolare la lunghezza di una lista è sufficiente contare i nodi def lunghezza(L): p=L.first conta=0 while p!=None: conta=conta+1 p=p.next return conta Ricerca ● ● Anche se gli elementi di una lista sono ordinati, non si può usare la ricerca binaria (manca l'equivalente di A[m]) Si può invece usare la ricerca lineare def ricerca_lineare(L,x): p=L.first trovato=False while p!=None and trovato==False: if p.key==x: trovato=True else: p=p.next return trovato N-esimo elemento ● Per trovare l'n-esimo elemento di una lista (equivalente di A[n] per una sequenza A) def ennesimo(L,n): p=L.first i=0 while i<n and p!=None: p=p.next i=i+1 return p Copia ● Per creare una copia fisica di una lista occorre definire una funzione apposita che usa ins_fine def copia_lista(L): L1=lista() p=L.first while p!=None: ins_fine(L1,p.key) p=p.next return L1 Conversione da sequenza a lista ● E' possibile creare una lista a partire da una sequenza def seq_lista(a): n=len(a) L=lista() for i in range(0,n): ins_fine(L,a[i]) return L Ricorsione e liste ● ● ● ● Una definizione ricorsiva di lista è la seguente Una lista è vuota oppure è composta da un nodo n collegato ad una lista (quella che inizia con n.next) Un nodo n si può sempre pensare come primo elemento della lista formata da n, dal suo successore, dal successore del successore ecc. Questo insieme di nodi si chiama sottolista Ricorsione e liste ● ● ● ● Per ottenere una definizione ricorsiva di una funzione f che opera con le liste Il caso base è quando la lista è vuota o ha un solo elemento La regola ricorsiva mette in relazione il valore di f sull'intera lista con il valore di f sulla sottolista che inizia con il secondo elemento Avremo bisogno sempre di una funzione supplementare che “inizia” la ricorsione con la sottolista che parte da L.first Esempio di ricorsione con le liste ● ● ● Come primo esempio calcoliamo ricorsivamente la lunghezza di una lista Il caso base è ovvio: la lista vuota ha lunghezza 0 La regola ricorsiva è: se la sottolista che inizia con il secondo elemento ha lunghezza L, allora la lista ha lunghezza L+1 Esempio di ricorsione con le liste def lunghezza(L): return lungh_ric(L.first) def lungh_ric(n): if n==None: ris=0 else: ric=lung_ric(n.next) ris=ric+1 return ris Esempio di ricorsione con le liste ● ● Come secondo esempio vediamo come si scrivono le chiavi di una lista sullo schermo Nel caso base non occorre fare niente: la funzione è basata solo sulla regola ricorsiva Esempio di ricorsione con le liste def scrivi_lista(L): scrivi_ric(L.first) def scrivi_ric(n): if n!=None: print n.key, scrivi_ric(n.next) Confronto liste e sequenze sequenze liste concatenate inserimento O(n) O(1) cancellazione O(n) O(1) O(n) O(log n) se la O(n) ricerca sequenza è ordinata Pile e code Code ● Una coda è una struttura dati che consente due operazioni ● ● ● inserire un nuovo elemento restituire l'elemento inserito meno recentemente eliminandolo dalla coda La politica di gestione di una coda si chiama FIFO (First In First Out), ovvero Il primo che entra è anche il primo ad uscire Code ● ● ● Ad esempio inserendo in una coda gli elementi A, B e C (in questo ordine), il primo ad essere eliminato A, poi B Se a questo si inserisce D, il prossimo ad essere eliminato è C, poi D. Infine la coda è vuota Una coda può essere facilmente implementata con una lista concatenata Implementazione di una coda tramite lista concatenata def entra_coda(coda,x): ins_inizio(coda,x) def esci_coda(coda): x=coda.last.key cancella(coda,coda.last) return x Implementazione di una coda mediante una sequenza ● ● ● In maniera alternativa una coda limitata (cioè con un numero massimo di elementi) può essere implementata mediante una sequenza Occorrono poi due indici (p e u) che indicano, rispettivamente il primo e l'ultimo elemento della coda Tali indici sono gestiti in modo circolare (quando arrivano in fondo alla sequenza sono riportati all'inizio) in modo da sfruttare al massimo la sequenza Implementazione Supponiamo di voler gestire al massimo 10 elementi class coda_sequenza: def __init__(self): self.dati=[None]*10 self.p=-1 self.u=-1 Implementazione def entra_coda( coda,x): coda.u=coda.u+1 if coda.u==10: coda.u=0 coda.dati[coda.u]=x def esci_coda(coda): x=coda.dati[coda.p] coda.p=coda.p+1 if coda.p==10: coda.p=0 Pile ● Una pila è una struttura dati che consente due operazioni ● ● ● inserire un nuovo elemento restituire l'elemento inserito più recentemente eliminandolo dalla pila La politica di gestione di una pila si chiama LIFO (Last In First Out), ovvero L'ultimo che entra è il primo ad uscire Pile ● ● ● Ad esempio inserendo in una pilaa gli elementi A, B e C (in questo ordine), il primo ad essere eliminato C, poi B Se a questo si inserisce D, il prossimo ad essere eliminato è D, poi A. Infine la pila è vuota Anche una pila può essere facilmente implementata con una lista concatenata Implementazione di una pila mediante una lista concatenata def entra_pila(pila,x): ins_fine(pila,x) def esci_pila(pila): x=pila.last.key cancella(pila,pila.last) return x Implementazione di una pila con una sequenza ● L'implementazione di una pila limitata tramite una sequenza è simile a quella di una coda limitata, serve solo l'indice u e non serve la gestione circolare class pila_sequenza: def __init__(self): self.dati=[None]*10 self.u=-1 Implementazione def entra_pila(pila,x): pila.u=pila.u+1 pila.dati[pila.u]=x def esci_pila(pila): x=pila.dati[pila.u] pila.u=pila.u-1 return x Alberi Alberi ● ● ● Gli alberi sono una generalizzazione della lista concatenata Ogni elemento di un albero è collegato con alcuni altri elementi Le due proprietà che devono essere rispettate sono dai collegamenti (non tenendo conto del verso delle frecce) ● ● Ogni elemento deve essere collegato ad almeno a tutti gli altri (anche in modo indiretto) Non possono esistere due percorsi diversi che collegano due elementi Esempio di albero Terminologia ● ● ● ● ● Gli elementi si chiamano nodi e ognuno di essi ha una chiave C'è un solo elemento a cui non arrivano frecce: si chiama radice Ogni altro nodo N ha un'unica freccia entrante: il nodo M a cui è collegato si chiama padre (o genitore) di N, mentre N è detto figlio di M I figli di uno stesso nodo si chiamano fratelli (o sibling) I nodi che non hanno figli si chiamano foglie Terminologia ● ● ● ● Dato un nodo N, si chiamano discendenti di N i figli di N, i figli dei figli e così via Il numero di frecce che bisogna percorrere per arrivare dalla radice ad un nodo si chiama livello (o profondità) del nodo L'altezza di un albero è il massimo livello delle foglie Il massimo numero di figli è chiamato grado dell'albero Alberi ● ● Gli alberi sono molto utilizzati nell'informatica Ad esempio l'organizzazione dei file e delle directory (file system) dei moderni sistemi operativi forma un albero per ogni disco (o dispositivo di memorizzazione) in cui ● ● i nodi sono file e directory un nodo è un figlio di una directory se il nodo è contenuto nella directory ● le foglie sono i file (o le directory vuote) ● la radice è la directory principale Alberi binari ● ● ● ● Gli alberi con grado 2 sono chiamati alberi binari I figli di un nodo sono chiamati figlio di sinistra e figlio di destra Un nodo può avere sia entrambi i figli, sia un solo figlio (a sinistra o a destra), sia nessun figlio Sono una struttura dati molto utilizzata in molti settori dell'informatica Esempio ● Negli esempi useremo il seguente albero binario Alberi e sottoalberi ● Dato un nodo N si chiama sottoalbero di sinistra la parte di albero che ha come radice il figlio sinistro di N ● Analogamente è definito il sottoalbero di destra ● Ad esempio per il nodo 0 Sottoalbero di sinistra Sottoalbero di destra Definizione ricorsiva ● ● Una definizione ricorsiva di albero binario è la seguente Un albero binario è vuoto oppure è formato da un nodo (radice) a cui sono collegati due alberi binari, uno a sinistra e l'altro a destra Alberi binari ● ● ● ● Ogni nodo è collegato, mediante dei riferimenti, ai suoi due figli di sinistra (left) e di destra (right) Se un figlio non c'è si usa il valore None Inoltre è collegato all'eventuale nodo genitore (par) La radice ha par uguale a None Alberi binari class nodo_albero: def __init__(self): self.key=None self.par=None self.left=None self.right=None Esempio n1=nodo_albero() n2=nodo_albero() n3=nodo_albero() n1.key=1 n2.key=2 n3.key=3 n1.left=n2 n1.right=n3 n2.par=n1 n3.par=n3 Visite ● ● ● ● Lo svolgimento di un'operazione su tutti i nodi di un albero (binario) si chiama visita Un albero binario si può visitare in tre modi principali ● pre order o visita anticipata ● in order ● post order o visita posticipata Esistono anche tanti altri modi di visita Vedremo i tre modi principali, in cui la visita di un nodo consiste nello scrivere il valore della chiave Pre-order Il nodo è visitato prima di tutti i suoi discendenti • • def pre_order(T): if T != None: print T.key,” “, pre_order(T.left) pre_order(T.right) I nodi visitati nell'esempio sono nell'ordine 0 4 7 11 3 8 9 10 In-order Il nodo è visitato dopo i suoi discendenti di sinistra, ma prima di quelli di destra ● ● def in_order(T): if T != None: in_order(T.left) print T.key,” “, in_order(T.right) I nodi visitati sono nell'ordine 7 4 11 0 8 3 10 9 Post-order Il nodo è visitato dopo tutti i suoi discendenti ● ● def post_order(T): if T != None: post_order(T.left) post_order(T.right) print T.key,” “, I nodi visitati sono nell'ordine 7 11 4 10 9 3 0 Espressione come albero binario ● Un'espressione del tipo 7*3+(5-8/2) può essere rappresentata come albero binario Espressioni e alberi binari ● ● ● Un'espressione rappresentata mediante un albero binario ha come foglie numeri e variabili, mentre ogni nodo è un'operazione Può essere visualizzata sullo schermo mediante una visita in-order Può essere inoltre valutata (ovvero calcolata) mediante una visita post-order Gestione di un albero ● ● ● L'inserimento di un nuovo nodo “in fondo all'albero”, cioè come “figlio mancante” di un nodo già esistente è facile: basta effettuare i collegamenti E' un po' più complicato inserire un nuovo nodo n1 “in mezzo all'albero”, cioè come nuovo figlio di un nodo n2 che ha già il figlio in questione presente In tale situazione si può ad esempio mettere come figlio di n1 il figlio pre-esistente di n2 Gestione di un albero ● ● ● La cancellazione di un nodo foglia n è facile, basta eliminare i collegamenti Se il nodo n ha un solo figlio f, è sufficiente collegare il genitore di n al nodo f Se invece n ha due nodi, il modo più semplice per ovviare al problema, è quello di trovare un nodo c che abbia 0 o 1 figlio, di mettere la chiave di c al posto di quella di n e infine di cancellare c Alberi binari di ricerca ● ● Un albero binario di ricerca consente di cercare "velocemente" un elemento all'interno di un albero, in modo da raggiungere, se possibile, la ricerca binaria su una sequenza Per facilitare la ricerca gli elementi devono essere disposti in un modo particolare Alberi binari di ricerca ● In un albero binario di ricerca (ABR) ogni elemento deve essere ● ● ● Minore o uguale di tutti gli elementi che si trovano nel sottoalbero di sinistra Maggiore o uguale di tutti gli elementi che si trovano nel sottoalbero di destra Si noti che sia il sottoalbero di sinistra che quello di destra sono a loro volta ABR Esempio di ABR visitando l'albero in modo in-order si ottengono tutti i nodi secondo l'ordine Ricerca ● ● ● ● ● ● E' l'equivalente della ricerca binaria sulle sequenze Partendo dalla radice, confronta la chiave del nodo corrente n con la chiave da cercare x Se è uguale a x, la ricerca termina con successo Se è minore di x, si passa al nodo di destra (quelli di sinistra sarebbe ancora più piccoli) Se è maggiore di x, si passa al nodo di sinistra Termina con insuccesso quando si arriva ad un nodo inesistente Ricerca def cerca_abr(r,x): trovato=False while r!=None and not trovato: if r.key==x: trovato=True elif r.key>x: r=r.left else: r=r.right return trovato Ricerca (versione ricorsiva) def cerca_abr_ric(r,x): if r==None: ris=None elifr.key==x: ris=r elifr.key>x: ris=cerca_abr_ric(r.left,x) else: ris=cerca_abr_ric(r.right,x) return ris Costo ● ● ● ● Il numero di confronti, nel caso peggiore, è pari all'altezza dell'albero Infatti il numero più alti di passaggi si ha quando si cerca un elemento che sta nella foglia più profonda (o dovrebbe stare lì...) Un albero con N nodi ha altezza pari almeno a log2N Ma se è fortemente sbilanciato a sinistra o a destra l'altezza è N Inserimento ● ● ● Per inserire un nodo di chiave x in un ABR occorre innanzitutto cercare il punto in cui mettere il nuovo nodo Ciò corrisponde a cercare x, ricordandosi dell'ultimo nodo p su cui la ricerca è passata Il nuovo nodo sarà il nuovo figlio di p Inserimento def ins_abr(r,x): radice=r p=r while r!=None: p=r if r.key>x: r=r.left else: r=r.right n=nodo_albero() n.key=x n.parent=p if p!=None: if p.key>x: p.left=n else: p.right=n else: radice=n return radice Inserimento (versione ricorsiva) def ins_abr_ric(r,x): if r==None: ris=nodo_albero() ris.key=x elif r.key>x: ric=ins_abr_ric(r.left,x) ric.parent=r r.left=ric ris=r else: ric=ins_abr_ric(r.right,x) ric.parent=r r.right=ric ris=r return ris Costo ● ● Anche in questo caso il costo dipende dall'altezza dell'albero Infatti nel caso peggiore il nuovo nodo sarà inserito come nuovo figlio della foglia più profonda Minimo e massimo ● Il minimo di un ABR è la foglia più a sinistra, mentre il massimo è la foglia più a destra def minimo(r): while r.left != None: r=r.left return r def massimo(r): while r.right != None: r=r.right return r Successore ● ● ● Il successore di un nodo con chiave x è tra tutti i nodi con chiave >x quello che la chiave minore Nell'albero di esempio il successore di 7 è 10, mentre quello di 12 è 13 Il successore di un nodo che ha il figlio destro si trova prendendo il minimo del sottoalbero destro def successore(n): return minimo(n.right) Cancellazione ● ● ● ● Per cancellare un nodo n da un ABR i casi semplici sono quando n è una foglia o n ha un solo figlio In tali situazioni si opera come negli alberi binari Nel caso in cui n abbia 2 figli, il nodo che prenderà il posto di n è il suo successore Nella funzione ● ● c è il nodo che sarà realmente cancellato (n o il suo successore) f è il figlio e g è il genitore di c Cancellazione def cancella(r,n): if n.left==None or n.right==None: c=n else: c=successore(n) n.key=c.key if c.left!=None: f=c.left else: f=c.right g=c.parent if f!=None: f.parent=g if g==None: r=f elif g.left==c: g.left=f else: g.right=f return r Costo ● ● Anche in questa operazione il numero di passaggi dipende dall'altezza dell'albero Infatti potrebbe essere necessario trovare il successore di un nodo e questo potrebbe essere la foglia più profonda Considerazioni finali ● ● ● ● I tempi di esecuzione delle operazioni sugli ABR dipendono dall'altezza Nel caso peggiore l'altezza è N, pari al numero di nodi Ma con una gestione oculata si può tenere l'altezza più bassa possibile, cioè log 2N In questo modo gli ABR hanno, nella ricerca, le stesse prestazioni delle sequenze ordinate, pur consentendo inserimenti e cancellazioni più veloci