46 6. Array e Algoritmi sugli Array Oltre ai tipi semplici, i linguaggi di programmazione offrono modi per aggregare dati. Gli array sono una struttura dati che ci permette di aggregare elementi che hanno lo stesso tipo. Un array è una sequenza di celle che contengono dati (dello stesso tipo). La dichiarazione di un array specifica nome e dimensione (cioè numero di celle) e tipo degli elementi: Ad esempio la dichiarazione int a[100]; dichiara che l’array di nome a avrà 100 elementi di tipo intero. Ogni cella di a è numerata a partire da 0, e può essere acceduta e/o modificata specificando l’array seguito dal suo numero d’ordine fra parentesi quadre. Ad esempio a[0] è il primo elemento di a, a[7] l’ottavo. NB – Gli elementi dell’array a[i] sono variabili del tipo dichiarato all’atto della dichiarazione dell’array e possono essere utilizzate come tali. Notare che ad a[100] non corrisponde alcun elemento dell’array; sia in fase di compilazione che in fase di esecuzione questo tipo di errore (indice fuori del range) non viene rilevato. Nel seguito presentiamo alcuni algoritmi su arrays sia mediante il diagramma di flusso che il frammento di codice C che presenta le dichiarazioni di varabili e i cicli principali. 6.1. Algoritmi di Ricerca Consideriamo dapprima un problema fondamentale che usa la struttura di array unidimensionale (un vettore). Questo problema, detto problema di Ricerca di un elemento in un vettore, può esser formulato e risolto in più modi, che analizzeremo nel seguito. Nella sua generalità, il problema della ricera di un elemento può essere formulato come segue: "Dato un vettore A di n elementi e un elemento y, determinare se y occorre in A". 6.1.1. Ricerca di un elemento x in un array a (int a[100] ) di n elementi, sapendo che x compare in a. L’algoritmo deve ritornare nella variabile j l’indice a cui si trova x. ∃i ∈ [0, 99] [a[i] = x] a[j] = x Asserzione iniziale: Asserzione finale: j←0 ( i 0 i (j-1) a[i] 0 = j = 99 no a[j]≠ x acc j si j←j+1 a[j ] = x x) 0 j 99 47 Frammento di codice C corrispondente al precedente diagramma: int j, x; int a[100]; j = 0; //INV:(per ogni i, 0≤i≤(j-1) a[i]≠ x) AND (0 ≤ j ≤ 9 9) while (a[j] != x) j++; printf(%d,j); Notare che se non sapessimo che l’elemento x compare nell’array, il precedente frammento di programma non sarebbe corretto . Complessità : L’algoritmo è lineare nella dimensione dell’array. 6.1.2. Ricerca di un elemento x in un array a. L’algoritmo deve ritornare nella variabile j l’indice a cui si trova x se c'è, opppure j = 100 se x non occorre nell’array. Asserzione finale ((j < 100) → a[j] = x) ∧ ( j = 100 → x∉ a[0..99]) j←0 ( i 0 i si j←j+1 (j-1) a[i] x) (0 j 99) m =∧ nj < 99 a[j]≠x no a[j]= x (x a[0..(j-1)] j = 99) si a[j]= x a[j ]= x no x a[0..j] 0 j 99 0 j j = 100 j←j+1 (x a[0..j] j j = 100) (a[j]= x Frammento di codice C corrispondente al precedente diagramma: int j, x; int a[100]; j = 0; //INV:(per ogni i, 0 ≤ i ≤(j-1) a[i]≠ x) AND (0≤j≤99) while ((a[j] != x) && (j< 99)) j++; if (a[j] != x) j++; printf(%d,j); Complessità : Il precedente algoritmo è lineare nella dimensione dell’array. 99) 48 Se gli elementi del vettore non sono ordinati, questa ricerca lineare non può essere evitata. Il ciclo while fa interrompere la ricerca quando si è trovata una occorrenza di x. Notiamo, che se x può occorrere più di una volta in a, ed è richiesto di trovarne tutte le occorrenze, sia questo algoritmo che quello del Paragrafo 6.1.1 non sono corretti, perchè trovano solo la prima occorrenza di x. In questo caso occorre usare un ciclo for j = 1, n ed esaminare sempre tutte le caselle del vettore. 6.1.3. Ricerca di un elemento x in un array a mediante la Pesiera Binaria. L’algoritmo deve ritornare in una variable trovato il valore True se x c'è, e il valore False se x non occorre nell’array. Il vettore è ordinato in modo crescente e tutti gli elementi sono diversi. Abbiamo visto che la ricerca sequenziale di un elemento in un array ha complessità lineare nella dimensione dell’array. Nel caso in cui si sappia che l’array è ordinato, si può fare di meglio. Per lo meno, possiamo sfruttare l’informazione che l’array è ordinato per interrompere la ricerca appena si trova un elemento maggiore di quello cercato. Anche in questo caso, comunque, la complessità dell’algoritmo sarebbe lineare nel caso peggiore. Per vedere come possiamo procedere, consideriamo come esempio un array A di dieci elementi: A = 1 | 3 | 5 | 6 | 8 | 9 | 10 | 15 | 20 | 25 Supponiamo di voler cercare y = 15. Se confrontiamo 15 con il quinto elemento di A, siamo sicuri che, poichè 15 è maggiore di 8, esso, se c'è, si troverà certamente nella metà destra dell’array. Possiamo quindi continuare la ricerca (con lo stesso metodo) nella porzione di array: 9 | 10 | 15 | 20| 25 Il confronto con un elemento dell’array ha fatto (all'incirca) dimezzare la porzione di array da controllare. Confrontando di nuovo 15 con l’elemento di mezzo dell’array, che contiene 15, possiamo concludere che 15 è presente in A nella sua settima posizione. Quindi invece dei nove confronti che avrei fatto con la ricerca sequenziale, la ricerca binaria ha effettuato solo tre confronti. Supponiamo ora di cercare un elemento che non è nell’array, ad esempio y = 17. Il confronto con 8 dice che 17, se c’è, si deve trovare nella porzione superiore dell’array; il confronto con 15 dice che 17 si deve trovare in 20 | 25 Dal confronto con 20, infine, scopriamo che 17 non può essere nell’array. In questo caso ho confrontato 17 tre volte, mentre la ricerca sequenziale l’avrebbe confrontato con tutti gli elementi dell’array minori di 17 che sono 8. Cerchiamo adesso di analizzare l'algoritmo in modo più sistematico. L'algoritmo si basa sul fatto che x non può occorrere in segmenti del vettore che contengono solo numeri o tutti maggiori o tutti minori di x. Quindi, dato un tratto del vettore a, che contiene le caselle da j a k, possiamo confrontare con x la casella centrale e poi , se x non c'è, limitare la ricerca successiva solo a metà del tratto. Nella Figura 19(a) è illustrata la situazione in cui la lunghezza del vettore è dispari, mentre nella Figura 19(b) la lunghezza del vettore è pari. 49 a[j] a[j+1] j j+1 …. …. …….. a[m-1] m-1 a[m] a[m+1] m m+1 …. …. ……….. a[k-1] k-1 a[k] k (a) a[j] a[j+1] j j+1 …. …. …….. a[m] m a[m+1] m+1 …. …. a[k-1] a[k] ……….. k-1 k (b) a[m] j=k=m (c) Figura 19 – Passo base dell'algoritmo della Pesiera Binaria. Il vettore considerato ha lunghezza l jk = k - j + 1. (a) Lunghezza del vettore dispari: m è la posizione dell'elemento centrale. (b) Lunghezza del vettore pari: m è la posizione dell'elemento centrale più a sinistra. (c) Lunghezza del vettore unitaria: la posizione centrale coincide con l'elemento. Quando la lunghezza del vettore è dispari, c'è un solo elemento centrale, mentre, nel caso pari ce ne sono due. Possiamo definire (le parentesi quadre denotano la parte intera dell'argomento): m = [(j + k)/2] Possiamo allora considerare il seguente algoritmo, scritto in linguaggio pseudonaturale: Search(a,x) j=1, k = n, trovato = False while j≠k and trovato = False do m = [(j+k)/2] if a(m) = x then trovato = True else if x < a(m) then k = m-1 else j = m+1 endif endif end if trovato = False then if a(j) = x then trovato = True return trovato Analizziamo l'algoritmo: esso può fermarsi quando x viene trovato oppure quando il vettore da analizzare si è ridotto ad una singola cella che non contiene x. ji-1, ki-1, trovatoi-1 Condizione iniziale: j0 = 1, k0 = n, trovato0 = False Condizione terminale: i (trovatot = True) ∨( (trovatot = False) ∧ (jt = kt) ) mi = (ji + ki)/2 ji, ki, trovatoi li = ki - ji + 1 Asserzione finale: ((j < 100) → a[j] = x) ∧ ( j = 100 → x∉ a[0..99]) 50 Avremo inoltre: trovatoi = if a(mi-1) = x then True else trovatoi-1 ji = if x ≤ a(mi-1) = then ji-1 else (mi-1+1) ki = if x ≥ a(mi-1) = then ki-1 else (mi-1−1) li = if x = a(mi-1) = then li-1 else ki - ji + 1 Terminazione : Guardando le relazioni tra le lunghezze del vettore in ingresso e in uscita, osserviamo che la lunghezza si riduce sempre, a meno che sia x = a(mi-1). Quindi, o trovato diventa vero (e il ciclo termina) o la lunghezza del tratto di vettore considerato diminuisce. Assumendo che x non occorra nel vettore, allora trovato non diventa mai vero e la lunghezza del vettore diminuirà ad ogni ciclo, arrivando quindi al valore 1, che corrisponde alla condizione j = k (e il ciclo termina). Correttezza : L'algoritmo non può trovare un x che non c'è, quindi può sbagliare solo perdendo il valore x. La correttezza si può provare dimostrando che, se x occorre nel vettore, ad ogni riduzione della lunghezza del vettore, il tratto considerato contiene ancora x. Usiamo il principio di induzione matematica. Passo base: B0 : Quando i = 0, il vettore considerato contiene x. Infatti, in questo caso j0 = 1, k 0 = n e quindi il vettore considerato è tutto il vettore a, in cui, per ipotesi, x occorre. Ipotesi induttiva: Hp : Il vettore (ji-1, ki-1) e tutti quelli considerati in precedenza contengono il valore x Tesi: Ts : Il vettore (ji, ki) contiene ancora il valore x. Osservando il ciclo, vediamo che si hanno tre casi: 1. a(mi-1) = x In questo caso il valore di x è stato trovato e l'algoritmo è corretto 2. a(mi-1) > x In questo caso si considera il vettore (ji-1, m i-1-1). Ma essendo il vettore a ordinato in modo crescente, il valore di x non può trovarsi nella parte di vettore eliminata, perchè questa contiene solo valori maggiori di x. Quindi il nuovo vettore contiene ancora x. 3. a(mi-1) < x In questo caso si considera il vettore (mi-1+1, ki-1). Ma essendo il vettore a ordinato in modo crescente, il valore di x non può trovarsi nella parte di vettore eliminata, perchè questa contiene solo valori minori di x. Quindi il nuovo vettore contiene ancora x. Quindi il valore di x è sempre contenuto nei vettori considerati all'interno del ciclo. Quando si esce dal ciclo, c'è ancora un caso da considerare, quello per cui j = k. Se x è in questa casella, esso viene trovato dal confronto all'uscita dal ciclo. Complessità : Il caso peggiore è quello in cui x non occorre in a, perchè si devono fare tutte le riduzioni fino al vettore di lunghezza 1. Quindi, all'ingresso del ciclo, il vettore considerato ha lunghezza n, dopo il primo ciclo ha lunghezza all'incirca n/2, dopo il secondo ha lunghezza n/22, e dopo l'i-mo avrà lunghezza n/2i. Quindi, all'uscita, per i = t, si avrà: n/2t = 1 à t = lg2n Quindi l'algoritmo ha complessità C(n) = O(lg2n). Vediamo infine il frammento di programma C che implementa l'algoritmo: 51 int inf = 0; int sup = DIM-1; int med = (sup+inf)/2; /*INV: x∈a[0..sup] se e solo se x ∈ a[inf..sup] AND 0<=inf,sup<DIM and inf<=med<=sup */ while ( (inf <= sup) && (a[med] != x) ) { if (a[med] > x) sup = med-1; else inf = med+1; med = (sup+inf)/2; } if (inf > sup) return(-1); else return(med); 6.1.4. Dato un array a (int a[100]) ed un intero x, restituire nella variabile occ il numero di occorrenze di x in a. Questo algoritmo di ricerca non si limita a dire se x occorre in a, ma conta anche il numero di volte ( ≥ 0) che x occorre. j←0 occ ←0 occ è il numero di occorrenze di x in a[0..(j-1)] occ j< 100 no si acc a[j]= x no si occ ← occ+1 occ è il numero di volte che x occorre in a[0..j] j←j+1 Asserzione finale: occ è il numero di volte che x occorre in a[0..99] 52 Frammento di codice C corrispondente al precedente diagramma: int j, occ; int a[100]; occ = 0; // INV: occ e’ il numero di occorrenze di x in a[0..(j-1)] for (j=0; j<=99; j++) if (a[j] == x) occ++; printf(%d,occ); Si noti la somiglianza fra questo algoritmo ed il precedente. La complessità è lineare nella dimensione dell’array. 6.2. Algoritmi su Array In questo paragrafo analizziamo degli algoritmi semplici che si possono applicare come parti di algoritmi più complessi, come vedremo nel prossimo paragrafo. 6.2.1. Ricerca del massimo elemento di un array a. L’algoritmo deve ritornare nella variabile max il valore del massimo elemento. Asserzione finale: i 0 j←0 i 99 | a[i]=max j 0 j 99 a[j] max max ←a[0] ( i 0 i 99 a[i]=max) ( k 0 k j-1 a[k] max) max j < 100 si no no a[j]> max max ← a[j] ( i 0 i 99| a[i]=max) ( k 0 k j| a[k] j←j+1 Frammento di codice C corrispondente al precedente diagramma: int j, max; int a[100]; max = a[0]; /*INV: (∃i∈[0,99]| max=a[i]) AND (∀k∈[0,j-1], a[k]≤ max)*/ for (j=0; j<=99; j++) if (a[j] > max) max = a[j]; printf(%d,max); Complessità : Il precedente algoritmo è lineare nella dimensione dell’array. max) 53 6.2.2. Dato un array a (int a[100]) dire se l’array è ordinato oppure no. A questo scopo, restituire la varibile ordinato, che vale 1 se l’array è ordinato e 0 altrimenti. Asserzione finale: ( (ordinato = 1) →∀i ∈[0, 98]| a[i] ≤ a[i+1]) ) ∧ ( ( ordinato = 0) → (∃ i | 0 ≤ i ≤ 98 |a[i] > a[i+1]) ) acc←0 j←0 ( i 0 i (j-1) a[i] a[i+1] ) 0 j 98 si j←j+1 m = n j< 98 a[j]≤a[j+1]∧ no (a[j] > a[j+1] ) a[j]≤a[j+1] i 0 i ( i 0 97 a[i] a[i+1] j = 98) si i 0 98 a[i] > a[i+1] i i 98 a[i] a[i+1] no ordinato←0 ordinato←1 ordinato Frammento di codice C corrispondente al precedente diagramma: int j, ordinato; int a[100]; j = 0; //INV: (per ogni i, 0≤ i ≤(j-1) a[i]≤ a[i+1]) AND 0≤ j≤ 99 while (a[j]<=a[j+1] && j< 98) j++; if (a[j] ]<=a[j+1]) ordinato=1; else ordinato=0; printf(%d,j); Complessità : Il precedente algoritmo è lineare nella dimensione dell’array. 6.3. Algoritmi di Ordinamento Ordinare gli elementi di un vettore (per sempio, in modo crescente) è un problema molto importante, per cui esistono diversi algoritmi con caratteristiche differenti. 54 6.3.1. Ordinamento per Selezione Il modo più semplice per ordinare un array in modo crescente è quello di cercare il minimo elemento e scambiarlo con il primo elemento, poi cercare il minimo dell’array dalla seconda posizione in poi e scambiarlo con il secondo elemento e così via. Ad una generica iterazione avremo: i sup La porzione dell’array da 0 a (i-1) è ordinata e contiene gli elementi più piccoli dell’array. Cerco il minimo dell’array da i a sup e lo scambio con l’elemento i-esimo. A questo punto l’array da 0 a i è oridinato e contiene gli elementi più piccoli dell’array. Il ciclo principale dell’algoritmo è il seguente: int i; int sup = DIM-1; /* INV: sia a’ l’array iniziale, a[0..(i-1)] è ordinata AND 0<=i<=sup AND per ogni j, i<=j<=sup a[i-1]<=a[j] (cioè gli elementi di a[0..(i-1)] sono tutti più piccoli di quelli di a[i..sup]) */ for (i=0; i<sup; i++) scambia(a,i,IndiceMinimo(a,i,sup); Le funzioni Scambia e IndiceMinimo operano come descritto nel seguito. /* PRE: sup<=DIM-1 (dove DIM è il numero degli elementi di b) AND 0<=i<=sup POST: sia m il valore ritornato da indiceMinimo(b,i,sup) per ogni j, 0≤j≤sup, m≤a[j] */ int indiceMinimo(int b[],int i,int sup) { int temp=a[i]; int j; for(j=i; j<=sup; j++) if temp>a[j] temp = a[j]; } /* PRE: 0<=i,j<=DIM-1 (dove DIM è il numero degli elementi di b) POST: sia b’[0..DIM-1] l’array iniziale alla fine per ogni k tale che 0≤k≤DIM-1 ((k!=i AND k!=j) implica b[k] = b’[k]) AND b[i] = b’[j] AND b[j] = b’[i] */ void scambia(int b[],int i,int j) { int temp=b[i]; b[i] = b[j]; b[j] = temp; } 55 Complessità : Il ciclo for viene eseguito DIM volte (dove DIM è il numero degli elementi dell’array). La funzione di ricerca del minimo la prima volta eseguirà DIM operazioni, la seconda DIM-1 e così via. Per cui il numero di operazioni fatte è DIM + (DIM-1) + ….. + 2 + 1 = O(DIM2) 6.3.2. Ordinamento per Inserzione Il modo più semplice per ordinare un array in modo crescente è quello di cercare il Nell’algoritmo di ordinamento per inserzione presupponiamo che la porzione iniziale dell’array (tratteggiata) sia ordinata a: i Per ordinare la porzione inizile più la posizione i-esima, dobbiamo inserire l’elemento a[i] nella posizione giusta in a[0..i] spostando tutti gli elementi che seguono (fino ad i) di una posizione a destra. Quindi il ciclo principale dell’algoritmo sarà il seguente: int i; int sup = DIM-1; /* INV: sia a’ l’array iniziale, a[0..(i-1)] è ordinata e contiene tutti e soli gli elementi di a’[0..(i-1)] */ for (i=1; i<=sup; i++) inserisci(a,i); La funzione inserisci scambia l’elemento i-mo con tutti quelli che lo precedono e che sono maggiori (in questo modo gli elementi che lo seguono sono shiftati di una posizione). /* PRE: 0<=i<=DIM-1 AND sia v’[0..(DIM-1)] l’array iniziale, v’[0..(i-1)] è ordinata. POST: v[0..i] è ordinata e contiene tutti e soli gli elementi di V’[0..i] */ void inserisci(int v[],int i) { int j = i - 1; /* sia v’[0..(DIM-1)] l’array iniziale (v’[0..(i-1)] è ordinata), v[0..i] contiene gli stessi elementi di v’[0..i] AND v[0..j] e v[(j+1)..i] sono ordinate AND 0<=j<=i-1 */ while ((j>=0) && (v[j]>v[j+1])) scambia(v,j,j+1); } La porzione dell’array da 0 a (i-1) è ordinata e contiene gli elementi più piccoli dell’array. Cerco il minimo dell’array da i a sup e loscambio con l’elemento i-esimo. 56 A questo punto l’array da 0 a i è oridinato e contiene gli elementi più piccoli dell’array. Complessità : Il ciclo for viene eseguito DIM volte (dove DIM è il numero degli elementi dell’array). La funzione di inserzione esegue tante operazione quanti sono gli elementi dell’array che seguono l’elemento da inserire. Nel caso migliore in cui l’array è già ordinata ogni chiamata dell’inserzione eseguirà un numero costante di operazioni. Per cui la complessità è lineare nella dimensione dell’aray. Nel caso peggiore in cui l’array è ordinata in modo decrescente (come per l’algoritmo precedente) la prima volta inserisci eseguirà 1 operazione la seconda 2 e così via fino all’ultima in cui vengono eseguite DIM operazioni. Per cui avrò Dim + (DIM-1) + ….. + 2 + 1 = O(DIM2) Per cui la complessità dell’algoritmo è quadratica nella dimensione dell’array. 6.3.3. Ordinamento per Interscambio (Bubble Sort) Questo algortimo di ordinamento si basa sul fatto che un array a[0..sup] è ordinato se ∀i 0 ≤ i < sup a[i] ≤ a[i+1]. Quindi, se scorriamo ripetutamente l’array, possiamo scambiare gli elementi adiacenti a[i] e a[i+1], nel caso in cui a[i] > a[i+1]. Ad un certo punto, quando non ci sono più elementi adiacenti fuori posto, l’array sarà ordinato. La considerazione che possiamo inoltre fare è che il ciclo for (i=0; i<sup; i++) if (a[i]>a[i+1]) scambia(a,i,i+1) ha come invariante: a[i] è il massimo di a[0..i]. Infatti, gli scambi fanno affiorare il massimo elemento in ultima posizione. Alla fine del ciclo avremo: a[sup] è il massimo di a[0..sup]. Quindi la seconda volta che scorriamo l’array possiamo applicare l’algoritmo solo alla porzione di a[0..(sup-1)] e così via. Otteniamo quindi il seguente. int i,j; int sup = DIM-1; /* INV: sia a’ l’array iniziale, a[j..sup] è ordinata AND 0<=j<=sup AND per ogni k, 0<=k<j a[k]<=a[j] (cioè gli elementi di a[j..sup] sono tutti più grandi di quelli di a[0..(j-1)]) */ for (j=sup; j>0; j--) /* INV: a[i] è il massimo di a[0..i] AND 0<=i<=j AND l’invarinate precedente */ for (i=0; i<j; i++) if (a[i]>a[i+1]) scambia(a,i,i+1); Complessità : Il ciclo for esterno viene eseguito DIM-2 volte (dove DIM è il numero degli elementi dell’array). Per ogni iterazione si esegue il ciclo interno che, la prima volta, richiede DIM-2 iterazioni, la seconda DIM-3 e così via. Di nuovo, la complessità è quadratica nel numero degli elementi dell’array, cioè O(DIM2).