08-02-08 Algoritmi di ordinamento (continua) Insertion sort: molto simile all'algoritmo che usiamo comunemente per ordinare le carte da gioco in ordine crescente. - date n carte gia' ordinate, riceviamo la carta n+1 e la inseriamo nella posizione che le compete, confrontandola con la piu' piccola, inserendola alla sua sinistra se minore, oppure confrontandola via via con le successive, se maggiore....e poi cosi' proseguendo con le carte successive che riceviamo. Sulle liste: - una lista di 0 oppure 1 elemento e' gia' ordinata; - altrimenti, data lista l di lunghezza almeno 2, ordiniamo (cdr l) richiamando ricorsivamente il programma, e alla fine procediamo a inserire nella posizione corretta (car l). [ (accennato una delle lezioni scorse) Selection sort: opera un numero di confronti proporzionale al quadrato degli elementi ] Costo computazionale di insertion sort: si comporta in modo differente a seconda di come gli elementi sono distribuiti all'interno della lista. Se la lista l e' "quasi ordinata", insertion sort opera un numero di confronti proporzionale a n. Se la lista e' ordinata in ordine inverso, (car l) deve essere confrontato con tutti gli altri elementi (gia' ordinati) per essere collocato in fondo alla lista formata fino a quel momento. In questo caso il numero di confronti diventa proporzionale al quadrato di n (quindi inefficiente). Nel caso medio insertion sort ordina una lista con un numero di confronti proporzionale al quadrato di n. Quindi non e' un algoritmo di ordinamento particolarmente appetibile. (define insertion-sort (lambda (lista) (cond ((<= (length lista) 1) lista) (else (let ((lista2 (insertion-sort (cdr lista)))) (if (< (car lista) (car lista2)) (cons (car lista) lista2) (cons (car lista2) (insertion-sort (cons (car lista) (cdr lista2)))))))))) MERGE-SORT: codice finale - Gia' vista: procedura merge che fonde due liste gia' ordinate. - gia' viste: procedura che creano la prima meta' e la seconda meta' di una lista data: sottolista_sx e sottolista_dx. Algoritmo di merge-sort. - Se la lista e' una lunga 0 o 1, e' ordinata. - altrimenti, la dividiamo in due parti di lunghezza (quasi) uguale, che vengono prima ordinate ricorsivamente richiamando il programma, e poi fuse usando merge. vengono comunque riportati tutti i codici per comodita' di lettura: (define merge (lambda (L1 L2) (cond ((or (null? L1) (null? L2)) (append L1 L2)) ((< (car L1) (car L2)) (cons (car L1) (merge (cdr L1) L2))) (else (cons (car L2) (merge L1 (cdr L2))))))) (define sottolista_sx (lambda (L i) (if (= i 0) () (cons (car L) (sottolista_sx (cdr L) (- i 1)))))) (define sottolista_dx (lambda (L i) (if (= i 0) L (sottolista_dx (cdr L) (- i 1))))) Ecco infine il programma merge-sort (define merge-sort (lambda (L) (if (< (length L) 2) L (let ((pos_media (quotient (length L) 2))) (merge (merge-sort (sottolista_sx L pos_media)) (merge-sort (sottolista_dx L pos_media))))))) Costo computazionale di merge-sort (ossia: numero di confronti che vengono fatti): qualche esempio, su liste lunghe 2^n. Se la lista e' lunga 1 = 2^0, si fanno 0 confronti. Altri casi lunga 2: 1 confronto lunga 4: 2 * 1 + 3 confronti = 5 confronti lunga 8: 2 * 5 + 7 = 17 lunga 16: 2 * 17 + 15 = 49 Sia L lunga 2^n, per cui le sue meta' L1 e L2 sono lunghe 2^{n-1}. Sia C(k) il numero di confronti fatti da merge-sort per ordinare una lista lunga k. Se k = 2^n, C(k) sara' il numero di confronti utilizzati da merge-sort per ordinare L, mentre C(k/2) e' il numero di confronti utilizzati per ordinare ciascuna delle due meta' L1 ed L2. Abbiamo questa formula: C(k) = 2 * C(k/2) + k - 1 giustificazione della formula: il numero di confronti fatti per ordinare L, e' pari alla somma di tutti in confronti fatti per ordinare, prima, le due sottoliste L1 ed L2 ( C(k/2) confronti per lista, quindi 2 * C(k/2) in tutto. A questa somma va ancora aggiunto il numero di confronti che la procedura merge deve fare per comporre correttamente la lista finale ordinata, a partire dalle due sottoliste L1 ed L2 ordinate. Ora, siccome merge, ad ogni passo, sceglie un elemento (il piu' piccolo dei due car) e non lo considera piu' per confronti successivi, e' immediato vedere che il numero di confronti che merge utilizza e' pari a k-1, se, complessivamente, ci sono k elementi da fondere (distribuiti sulle due liste). Resta da capire come cresca la funzione definita induttivamente (sulle potenze di 2) dalla formula sopra: C(k) = if k = 1 then 0 else 2 * C(k/2) + k -1. La risposta e' che la crescita di C(k) e' proporzionale a k * log(k) [log = logaritmo in base 2]. La giustificazione verra' data in altri corsi. ========= Riassunto: visti: bubble-sort, selection-sort, insertion-sort, merge-sort, (non visti: heap sort, quick-sort, counting-cort, library sort.......). bubble,selection,insertion - sort sono quadratici nel numero di confronti, merge-sort segue invece l'andamento (ottimale) k * log(k). =========== Alberi (BINARI). Come le liste, sono strutture ricorsive. Ad esempio "questo e' un albero binario di interi" 5 / \ 7 9 /\ /\ 3 21 8 / 9 \ 13 Definizione di alberi binari di numeri interi: insieme T. Definizione induttiva dell'insieme T: T = { () } U NxTxT ossia: un albero binario di interi e': caso 1: un albero vuoto oppure caso 2: una tripla costituita da: - un numero - un sottoalbero sinistro (che DEVE essere un albero) - un sottoalbero destro (idem come sopra) Alcune definizioni: radice dell'albero (non vuoto): prima componente della tripla che lo rappresenta. nodo di un albero: consiste dell'informazione relativa a una posizione nell'albero (da descrivere in qualche modo) e l'elemento ivi contenuto. Ad esempio nell'albero sopra al numero 8 corrisponde un (unico) nodo, descritto dalla posizione "scendi due volte a destra a partire dalla radice", e dal numero 8 stesso. profondita' di un nodo: numero di archi che lo collegano alla radice. altezza di un albero: massimo fra tutte le profondita' dei nodi. foglia: nodo terminale (non ha sotto altri nodi). Ad esempio: 5 T= / \ 7 9 /\ /\ 3 21 8 / 9 \ 13 la radice e' 5, l'altezza dell'albero e' 3, il nodo dove e' posizionato il numero 1 ha profondita' 2. T viene descritto come tripla nel modo seguente (resta inteso: la descrizione sotto e' insoddisfacente: e' per meta' formale, per meta' grafica) 7 T ) = ( 5, 9 /\ / \ 2, 3 1 8 / \ 9 13 Osservazione: a loro volta i sottoalberi sx e dx sono formalmente delle triple 7 /\ 3 (lft T) = 3 2 2) / = (7, / , 9 9 Attenzione alla tripla a destra: il "2" che compare come terza componente e' un albero, NON un numero! (chiaramente la scrittura sopra e' insoddisfacente!! Vedi il seguito per pervenire a una scrittura soddisfacente, utilizzando le espressioni di Scheme). sarebbe piu' preciso, pur usando questo sistema di scrittura "misto", scrivere esplicitamente gli alberi vuoti con cui termina ogni albero: questo eviterebbe ambiguita' di scrittura. Quindi 7 / \ 3 2 /\ /\ 9 () () () e' una scrittura piu' soddisfacente di 7 / \ 3 2 / 9 Ricordare, come regola aurea, questo punto: ogni albero non vuoto e' una tripla, e in particolare la seconda e la terza componente sono a loro volta SEMPRE alberi proviamo a scrivere correttamente, usando le triple, questo albero binario 3 / 9 NON COSI' : (3, 9, () ) non va bene perche' 9 non e' un albero// Ma se riempiamo la scrittura sopra con gli impliciti alberi vuoti, perverremo alla rappresentazione corretta. 3 /\ 9 () /\ () () e quindi la tripla corretta e' questa: (3, ). radice (9, (), ()), () sottoalbero_sx sottoalbero_dx Se un numero n lo vogliamo interpretare come un albero, allora viene descritto dalla tripla (n, (), () ). Esercizio: usare rappresentazione degli alberi che utilizza le liste. Noi usiamo questa rappresentazione: - un albero vuoto e' la lista vuota. - un albero non vuoto e' una COPPIA la cui prima componente e' il numero che sta alla radice dell'albero e la seconda componente e' una COPPIA, il cui car e' il sottoalbero sinistro, e il cui cdr e' il sottoalbero destro. Esempio: rappresentare l'albero 7 /\ 3 2 / 9 (cons 7 (cons (sottoalbero sx) (sottoalbero dx))) = (cons 7 (cons (cons 3 (cons (sottoalbero sx) (sottoalbero dx))) (cons 2 (cons () ())) (cons 7 (cons (cons 3 (cons (cons 9 (cons () ())) ())) (cons 2 (cons () ()))) come si vede, la rappresentazione e' piuttosto laboriosa e per niente intuitiva. Si osservi tuttavia che NON useremo, di fatto, questo schema costruttivo per definire alberi, in quanto potremo fra poco sfruttare le funzioni del protocollo degli alberi. non e' detto che gli alberi debbano necessariamente essere rappresentati come sopra: potremmo ad esempio usare delle stringhe, oppure delle liste, a nostra discrezione. Rappresentazione alternativa con le liste (cenno) albero = lista vuota oppure lista di 3 elementi costuita da radice, sottoalbero sx e sottoalbero dx. Se si sceglie questa rappresentazione, l'albero sotto... 7 /\ 3 2 / 9 ....viene cosi' rappresentato. (list 7 (list 3 (list 9 () ()) ()) (list 2 () ())). Scegliamo ora, definitivamente, la rappresentazione che impiega la coppia la cui prima componente e' la radice, e la seconda componente la coppia sottoalbero_sx e sottoalbero_dx, e implementiamo le funzioni del protocollo. una struttura dati (qualunque essa sia) si caratterizza per questi elementi base: - i costruttori: come costruire gli oggetti della struttura. - i "metodi" che agli oggetti possono essere applicati per utilizzarli (vedi, in seguito, programmazione JAVA) Sugli alberi binari (di interi) occorrono queste operazioni: COSTRUZIONE: albero vuoto; crescita dell'albero a partire da una radice, e due sottoalberi sinistro e destro. (define make_empty_tree (lambda () null) (define grow_tree (lambda (radice left_subtree right_subtree) (cons radice (cons left_subtree right_subtree)))) "DISTRUTTORI" dell'albero non vuoto (operazioni utili per modificarli): vogliamo essere in grado di - estrarre la radice di un albero root - estrarre il sottoalbero sinistro lft - estrarre il sottoalbero destro rgt (define root car) <==== non e' una definizione oziosa: rende la funzione root del protocollo indipendente dalla rappresentazione (define lft (lambda (t) ; albero non vuoto (car (cdr t)))) (define rgt (lambda (t) (cdr (cdr t)))) ultima operazione richiesta TEST: un albero e' vuoto? (define empty_tree? null?) ================ Esercizio: scrivere un programma che, dato un albero, conta il numero dei suoi nodi. (define conta_nodi (lambda (t) (if (empty_tree? t) 0 (+ (conta_nodi (lft t)) (conta_nodi (rgt t)) 1)))) ESERCIZIO: dato un albero binario di interi t, e un numero n, stabilire se n compare da qualche parte nell'albero (belongs? t1 3) = #t (belongs? t1 4) = #f dove (define t1 (grow_tree 1 (grow_tree 2 () ()) (grow_tree 3 () ()))) ESERCIZIO2: Scrivere un programma che calcola l'altezza di un albero (possibile partire da qua: albero vuoto ha altezza -1) (height (grow_tree 1 () ())) = 0