1 ISTITUTO TECNICO E LICEO SCIENTIFICO TECNOLOGICO “ANGIOY” LE STRUTTURE DATI PARTE 1: ARRAY Prof. G. Ciaschetti Una struttura dati è una collezione di dati in memoria alla quale è possibile dare un unico nome. Ad esempio, la classe IV C rappresenta una collezione di studenti, ed è individuata da un unico nome, appunto IV C. Oppure, ad esempio, la mia pagella è la collezione dei miei voti, che si chiama Pagella di Gianfranco Ciaschetti. Un‟array è una struttura dati omogenea e lineare. Omogenea significa che i dati che essa contiene sono tutti dello stesso tipo (o tutti interi, o tutte stringhe, o tutti float, ecc.). Lineare significa che i dati della collezione sono memorizzati in modo contiguo in memoria, uno dietro l‟altro. Gli array possono avere una o più dimensioni. Un array monodimensionale (a una dimensione) si chiama un vettore. Un array bidimensionale (a due dimensioni) si chiama una matrice. Un array che ha 3 o più dimensioni non prende nomi particolari, si chiama genericamente array. 1. Vettori Come abbiamo detto, un vettore è un array monodimensionale. Esso può essere definito in linguaggio C nel seguente modo: tipo nome[dimensione]; dove tipo indica il tipo degli elementi della collezione, nome è l‟identificatore che diamo alla nostra variabile array, e dimensione è il numero di elementi. Ad esempio, con la dichiarazione int vet[10]; stiamo dichiarando una variabile array che si chiama vet e che contiene 10 elementi di tipo intero. Ad esempio, con la dichiarazione float pippo[30]; stiamo dichiarando una variabile array che si chiama pippo e che contiene 30 elementi di tipo float. Ad esempio, con la dichiarazione char titolo[100]; stiamo dichiarando una variabile array che si chiama titolo e che contiene 100 elementi di tipo char (ci ricorda qualcosa?). Un array è utile ogni qual volta dobbiamo memorizzare parecchi dati, tutti dello stesso tipo, e troviamo sconveniente usare una variabile per ognuno di essi. Inoltre, se non sappiamo a priori quanti dati effettivamente dovremo memorizzare (ad esempio, i voti in informatica di una classe: ne abbiamo un diverso numero per ogni diversa classe, essendo diverso il numero degli studenti) 2 l‟unica possibilità che abbiamo è quella di usare un array che ha un numero di elementi sufficientemente grande per ogni possibile situazione. Tornando all‟esempio dei voti di informatica di una classe, potremmo usare un vettore float voti[30]; sapendo che in ogni classe non ci sono più di 30 alunni. In questo modo, se in una classe abbiamo, ad esempio, 21 alunni, useremo solo le prime 21 posizioni del nostro vettore, lasciando le 9 rimanenti inutilizzate. E‟ possibile accedere ai singoli elementi del vettore specificando il nome del vettore e la posizione dell‟elemento che ci interessa, tra parentesi quadre. Nell‟esempio dei voti, il primo elemento dell‟array è individuato con voti[0] (si legge voti di zero), il secondo elemento con voti[1], e così via, fino al trentesimo che è individuato con voti[29]. ATTENZIONE: Stiamo usando il vettore voti come esempio. Il nome del vettore, il tipo degli elementi e il loro numero saranno scelti di volta in volta a seconda della particolare esigenza! IMPORTANTE: In ogni istruzione del nostro programma, non è possibile mai fare operazioni sull‟intero array, ma solo su un singolo elemento alla volta. Considerando l‟esempio dei voti, sono istruzioni errate, ad esempio printf(“%d”, voti); /* ERRORE */ voti = 3; /* ERRORE */ in quanto nella printf si chiede di visualizzare sul monitor un intero, e voti non è un dato di tipo intero, ma una collezione di interi! Allo stesso modo, nell‟istruzione di assegnamento il valore 3 è un valore di tipo intero, e quindi può essere assegnato a una variabile di tipo intero, non a una collezione di interi. Sono invece operazioni lecite, ad esempio, le seguenti: printf(“%d”, voti[1]); /* visualizza il secondo elemento del vettore voti */ voti[0] = 3; /* assegna il valore 3 al primo elemento del vettore voti */ 1.1 Caricamento di un vettore Caricare un vettore significa farsi dare in input, uno per uno, gli elementi del vettore, per memorizzarli nella RAM. Riprendendo la nostra discussione sul numero di elementi del vettore, che deve essere sufficientemente grande per ogni possibile situazione, possiamo innanzitutto chiedere in input quanti elementi andremo effettivamente a caricare, diciamo N, e successivamente chiedere in input tutti gli elementi voti[0], voti[1], …, voti[N-1]. Il codice potrebbe essere il seguente: int voti[30]; /* alloca in memoria uno spazio per 30 elementi di tipo intero */ int N; /* indica il numero di elementi effettivamente caricati */ printf(“quanti elementi vuoi inserire? ”); scanf(“%d”, &N); /* qui inizia il caricamento delle prime N posizioni del vettore */ scanf(“%d”, &voti[0]); scanf(“%d”, &voti[1]); … scanf(“%d”, &voti[N-1]); 3 Ora, non sapendo a priori quale sarà il numero N inserito a run time (durante l‟esecuzione del programma), non sappiamo quante di queste scanf scrivere. Inoltre, ripetere tante volte la stessa istruzione risulta piuttosto noioso: infatti esse sono tutte uguali, tranne la posizione dell‟elemento da inserire. Una soluzione al nostro problema consiste nell‟uso di un contatore e nel porre l‟istruzione scanf all‟interno di un ciclo in cui si fa variare il contatore, facendogli assumere tutti i valori da 0 a N-1. Tutte le istruzioni scanf del codice precedente possono allora essere sostituite dal seguente ciclo: int i = 0; while(i<N) { scanf(“%d”, &voti[i]); i++; } In questo modo, qualunque sia il valore inserito N, si ripeterà N volte l‟istruzione scanf, con tutte le posizioni da 0 a N-1. Quando il numero di iterazioni da compiere è noto a priori, come in questo caso, al posto del ciclo while è preferibile usare un ciclo for. Esso non è altro che un modo più sintetico di esprimere lo stesso ciclo. Possiamo ottenere lo stesso effetto del codice qui sopra scrivendo for(i=0; i<N; i++) scanf(“%d”, &voti[i]); Il significato dell‟istruzione for su descritta è il seguente: all‟inizio poni i=0. Poi se la condizione i<N è vera, esegui la scanf, incrementa i di 1, e torna a valutare la condizione. E‟ esattamente come il ciclo while, solo scritto in forma più sintetica! Riassumendo, possiamo allora scrivere il caricamento del nostro vettore come segue: int voti[30]; int i,N; void Caricamento() { printf(“quanti elementi vuoi inserire? ”); scanf(“%d”, &N); for(i=0; i<N; i++) scanf(“%d”, &voti[i]); } 1.2 Visualizzazione di un vettore Visualizzare un vettore equivale a mostrare a video, uno per uno, tutti gli elementi memorizzati nel vettore. Si suppone quindi di conoscere già il numero N di elementi presenti, e di aver precedentemente caricato il vettore. Il codice è pressappoco lo stesso del caricamento: dobbiamo scrivere le N istruzioni 4 printf(“%d”, voti[0]); printf (“%d”, voti[1]); … printf (“%d”, voti[N-1]); quindi, di nuovo, utilizziamo un ciclo for come segue: void Visualizzazione() { for(i=0; i<N; i++) printf(“%d”, voti[i]); } 1.2 Ricerca lineare Per ricerca lineare si intende andare a cercare se è presente un particolare elemento nel vettore. Si chiama lineare perché prevede di scandire tutto il vettore, posizione per posizione, per trovare l‟elemento cercato. Si tratta allora di chiedere in input l‟elemento da cercare, salvandolo in una variabile (dello stesso tipo degli elementi del vettore), e controllare se ogni elemento del vettore è uguale a quello cercato. Più o meno, nell‟esempio dei voti, qualcosa del genere: int v; printf(“elemento da cercare? ”); scanf(“%d”, &v); for(i=0; i<N; i++) if(voti[i] == v) printf(“elemento presente nel vettore”); Ora però, se volessimo rispondere anche nel caso di ricerca infruttuosa, ad esempio scrivere “elemento non presente nel vettore”, dobbiamo modificare un po‟ le nostre istruzioni. Un errore che si fa comunemente è quello di mettere un else all‟istruzione if, nel seguente modo: int v; printf(“elemento da cercare? ”); scanf(“%d”, &v); for(i=0; i<N; i++) if(voti[i] == v) printf(“elemento presente nel vettore”); else printf(“elemento non presente nel vettore”); Chiaramente questo codice è sbagliato: all‟inizio i assume il valore 0, quindi si controlla se voti[0] è uguale a v. Se si, ok, nessun problema, abbiamo trovato il nostro elemento. Se invece sono diversi, non possiamo ancora rispondere che l‟elemento non è presente: dobbiamo prima controllare tutti gli altri elementi del vettore! Quindi potremo dare una tale risposta solo alla fine del nostro ciclo, e non al suo interno. La soluzione prevede di utilizzare una variabile flag (bandiera, in inglese), cioè che assume valori 0 e 1 (quindi di tipo intero), per indicare se abbiamo trovato il nostro elemento oppure no. All‟inizio, 5 poniamo a 0 il nostro flag (bandiera abbassata), per indicare che non abbiamo ancora trovato l‟elemento cercato. Quando accade che lo troviamo, poniamo a 1 il flag (bandiera alzata). Al termine del ciclo, se per una qualche posizione è successo di trovare il nostro elemento, il flag avrà valore 1, altrimenti il suo valore sarà rimasto pari a 0. Quindi, basta chiedersi quanto vale il flag al termine del ciclo. Chiamiamo il nostro flag, per convenienza, con il nome trovato. void RicercaLineare() { int trovato = 0; int v; printf(“elemento da cercare? ”); scanf(“%d”, &v); for(i=0; i<N; i++) if(voti[i] == v) trovato = 1; if (trovato == 1) /* al termine del ciclo */ printf(“elemento presente nel vettore”); else printf(“elemento non presente nel vettore”); } Una interessante variante è quella in cui si chiede anche la posizione nel vettore dell‟elemento trovato. Per far questo, si può procedere in due modi: o si mette la printf(“presente”) dentro al ciclo for, in modo da visualizzare il valore di i quando viene trovato l‟elemento, oppure si salva il valore di i in un‟altra variabile, per poi utilizzarla fuori dal ciclo. Di seguito, sono riportate entrambe le soluzioni void RicercaLineareVariante1() { int trovato = 0; int v; printf(“elemento da cercare? ”); scanf(“%d”, &v); for(i=0; i<N; i++) if(voti[i] == v) { printf(“elemento presente nel vettore in posizione %d”, i); trovato = 1; } if (trovato == 0) /* al termine del ciclo */ printf(“elemento non presente nel vettore”); } 6 void RicercaLineareVariante2() { int trovato = 0; int v,s; printf(“elemento da cercare? ”); scanf(“%d”, &v); for(i=0; i<N; i++) if(voti[i] == v) { s = i; trovato = 1; } if (trovato == 1) /* al termine del ciclo */ printf(“elemento presente nel vettore in posizione %d”, s); else printf(“elemento non presente nel vettore”); } Un‟altra interessante variante è quella che chiede di contare quanti elementi ci sono nel vettore uguali a un dato elemento. In questo caso, basta far assumere alla variabile trovato anche altri valori, e utilizzarla come contatore degli elementi uguali, nel seguente modo: void ContaElementiUguali() { int trovato = 0; int v; printf(“elemento da cercare? ”); scanf(“%d”, &v); for(i=0; i<N; i++) if(voti[i] == v) trovato++; printf(“sono stati trovati %d elementi nel vettore”, trovato); } Una ulteriore variante della ricerca lineare è quella in cui chiediamo di uscire dal ciclo non appena abbiamo trovato l‟elemento cercato, evitando così di effettuare ulteriori confronti. In questo caso, possiamo modificare l‟istruzione for del nostro codice come segue void RicercaLineare() { … for(i=0; (i<N)&&(trovato==0); i++) … } Non appena la variabile trovato diventerà uguale a 1, il ciclo non verrà più ripetuto. 7 ESERCIZI: dopo aver caricato un vettore di (massimo) 50 interi, contare quanti elementi pari e quanti elementi dispari ci sono nel vettore visualizzare tutti gli elementi maggiori di 10 trovare l‟elemento minimo 1.3 Shift a sinistra To shift in inglese significa spostare. In questo contesto, sta a significare che vogliamo ruotare il vettore spostando tutti gli elementi di una posizione a sinistra, rimettendo in ultima posizione il primo elemento, come nel seguente esempio: Per effettuare la rotazione a sinistra, bisogna innanzitutto salvare in un‟altra variabile il primo elemento del vettore, v = voti[0]; quindi assegnare, a partire da sinsitra, a ogni posizione l‟elemento in posizione successiva voti[0]= voti[1]; vet[1]= voti[2]; … voti[N-2]= voti[N-1]; /* ricordiamo che l’N-esimo elemento è in posizione N-1 */ e alla fine rimettere in posizione N-1 il primo elemento che avevamo salvato nella variabile v voti[N-1]= v; Di nuovo, ci viene in aiuto il ciclo for: possiamo riscrivere con un ciclo la sequenza degli spostamenti e ottenere lo stesso for(i=0; i<N-1; i++) voti[i] = voti[i+1]; /* per tutti i valori di i da 0 a N-2 */ Il codice per la rotazione a sinistra è allora il seguente: 8 void LeftShift() { int v; v = voti[0]; for(i=0; i<N-1; i++) /* per tutti i valori di i da 0 a N-2 */ voti[i] = voti[i+1]; voti[N-1]= v; /* al termine del ciclo */ } 1.4 Shift a destra Stavolta si tratta di far ruotare il vettore a destra, ossia spostare tutti gli elementi a destra di una posizione, come nell‟esempio seguente: Allo stesso modo della rotazione a sinistra, ora dobbiamo salvare in una variabile l‟ultimo elemento del vettore. v = voti[N-1]; quindi assegnare, a partire da destra, a ogni posizione l‟elemento in posizione precedente voti[N-1]= voti[N-2]; /* ricordiamo che l’N-esimo elemento è in posizione N-1 */ … voti[2]= voti[1]; voti[1]= voti[0]; e alla fine rimettere in posizione 0 l‟ultimo elemento che avevamo salvato nella variabile v voti[0]= v; Di nuovo, ci viene in aiuto il ciclo for, ma stavolta a ogni iterazione del ciclo dobbiamo decrementare il nostro contatore i, anziché aumentarlo for(i=N-1; i>0; i--) voti[i] = voti[i-1]; /* per tutti i valori di i da N-1 a 1 */ Il codice per la rotazione a destra è allora il seguente: 9 void RightShift() { int v; v = voti[0]; for(i=N-1; i>0; i--) voti[i] = voti[i-1]; voti[0]= v; } /* per tutti i valori di i da N-1 a 1 */ ESERCIZIO: simulare una scritta che corre sullo schermo, ripetendo più volte la rotazione di un vettore di caratteri. 1.4 Ordinamento di un vettore Ordinare (to sort, in inglese) un vettore significa disporre i suoi elementi in ordine crescente o decrescente. Ad esempio, ordine crecente ordine decrescente Esistono diversi algoritmi di ordinamento di un vettore, tra i quali citiamo Selection sort (ordinamento per selezione) Bubble sort (ordinamento a bolle) Insertion sort (ordinamento per inserimento – come con le carte a Scala40) Quick sort (ordinamento veloce) Merge sort (ordinamento per fusione) Noi vedremo solo i primi due tra questi, decisamente più semplici degli altri. Tuttavia, dobbiamo evidenziare il fatto che essi non sono i più veloci, che invece risultano essere il quick sort e il merge sort. Torneremo su questo aspetto tra poco. 10 SELECTION SORT Supponiamo di voler ordinare il vettore in ordine crescente. Questo algoritmo di ordinamento si sviluppa in più fasi: in una prima fase prevede di selezionare il più piccolo elemento del vettore e portarlo in prima posizione. Quindi, in una seconda fase, selezionare il secondo elemento più piccolo e portarlo in seconda posizione, e così via. Concentriamoci, per il momento, sulla prima fase. Si tratta di confrontare l‟elemento in prima posizione con il secondo, con il terzo, e così via fino all‟N-esimo, ed eventualmente scambiarlo nel caso in cui si trovi un elemento minore. Ad esempio, Ricordando la funzione per scambiare due variabili (passaggio di parametri per indirizzo, e uso di una variabile di appoggio, giusto?) void Scambia(int *x, int *y) { int temp; temp = *x; *x = *y; *y = temp; } Possiamo scrivere le istruzioni per questa prima fase nel seguente modo: for(j=1; j<N; j++) /* per tutti i valori di j da 1 a N-1 */ if (voti[j] < voti[0]) Scambia(&voti[j], &voti[0]); Passiamo ora alla seconda fase. Si tratta di ripetere, più o meno, lo stesso ciclo di sopra, ma stavolta confrontando l‟elemento in seconda posizione con tutti i suoi successivi (il primo è stato già sistemato!), per portare in seconda posizione il secondo elemento più piccolo. 11 Stavolta, le istruzioni che effettuano la seconda fase diventano for(j=2; j<N; j++) /* per tutti i valori di j da 2 a N-1 */ if (voti[j] < voti[1]) Scambia(&voti[j], &voti[1]); Dunque, si tratta di ripetere questo ciclo for per tutte le posizioni, ogni volta facendo partire l‟elemento da confrontare dalla posizione successiva a quella “da aggiustare”. Generalizzando, poiché si tratta di ripetere tante volte lo stesso ciclo for, lo inseriamo dentro un altro ciclo for in cui usiamo un indice i per indicare la posizione dove stiamo mettendo l‟elemento più piccolo, che nella prima fase sarà pari a 0, nella seconda pari a 1, e così via, quindi facciamo partire ogni volta il nostro indice j dalla posizione successiva a i. Si noti che l‟ultimo confronto va fatto tra l‟elemento in posizione N-2 e quello in posizione N-1, per cui l‟indice i assumerà come ultimo valore N-2. void SelectionSort() { int i, j; for(i=0; i<N-1; i++) /* per tutti i valori di i da 0 a N-2 */ for(j=i+1; j<N; j++) /* per tutti i valori di j da i+1 a N-1 */ if (voti[j] < voti[i]) Scambia(&voti[j], &voti[i]); } Se invece dobbiamo ordinare il vettore in modo decrescente, basta cambiare il verso della disuguaglianza nell‟istruzione if, in modo da scambiare gli elementi quando ne troviamo uno più grande. BUBBLE SORT Il nome di questo algoritmo (a bolle, in italiano) deriva dall‟osservazione che le bolle d‟aria in acqua tendono a salire in superficie. Anche questo algoritmo di ordinamento si sviluppa in più fasi: una prima fase prevede di individuare (sempre nell‟ipotesi di ordinare in modo crescente) il più grande elemento del vettore, la cosiddetta bolla, e portarlo in ultima posizione, la superficie. Quindi, in una seconda fase, si individua il secondo elemento più grande (la seconda bolla) e la si porta in 12 penultima posizione (la superficie ora è questa, visto che l‟ultimo elemento è già stato “messo a posto”), e così via. Concentriamoci, per il momento, sulla prima fase. Si tratta di confrontare tra loro il primo e il secondo elemento, e trovare la bolla tra questi. Se la bolla (il più grande) è il primo, li scambiamo per far salire la bolla in superficie, altrimenti sono già “a posto” tra loro. Successivamente, confrontiamo il secondo elemento con il terzo, e portiamo il più grande, la bolla, verso la superficie: se il secondo è il maggiore, li scambiamo, altrimenti stanno bene così. Continuiamo a confrontare il terzo elemento con il quarto, il quarto con il quinto, e così via. Alla fine di questa fase, avremo l‟elemento più grande del vettore in ultima posizione, come nell‟esempio seguente: Possiamo scrivere le istruzioni per questa prima fase nel seguente modo: for(i=0; i<N-1; i++) /* per tutti i valori di i da 0 a N-2 */ if (voti[i] > voti[i+1]) Scambia(&voti[i], &voti[i+1]); Passiamo ora alla seconda fase. Si ricomincia a individuare la bolla e farla salire fino alla penultima posizione, un po‟ come abbiamo fatto nella prima fase. 13 Stavolta, le istruzioni per la seconda fase sono for(i=0; i<N-2; i++) /* per tutti i valori di i da 0 a N-3 */ if (voti[i] > voti[i+1]) Scambia(&voti[i], &voti[i+1]); Il codice per la seconda fase è esattamente uguale a quello della prima fase, con la differenza che dobbiamo fermarci una posizione prima. La terza fase è di nuovo uguale, fermandoci con i a N-4, e così via, sistemando ogni volta la bolla in superficie. Nell‟ultima fase, si ha un solo confronto da fare tra l‟elemento in posizione 0 e l‟elemento in posizione 1. Pertanto, i dovrà partire da 0 e arrivare a 0. Poiché dobbiamo ripetere lo stesso ciclo un certo numero di volte, lo includiamo in un altro ciclo for. Anche questo algoritmo, come il precedente, presenta due cicli for annidati, cioè uno dentro l‟altro. Nel ciclo più esterno, facciamo crescere una variabile k che useremo per decrementare le posizioni dove andranno a mettersi le bolle, che dovà essere N-1 la prima volta, N2 la seconda, N-3 la terza, e così via. In generale, scriveremo N-k, con k che parte da 1 e arriva a un valore tale che N-k=0. void BubbleSort() { int i, k; for(k=1; k<N; k++) /* per tutti i valori di k da 1 a N-1 */ for(i=0; i<N-k; i++) /* per tutti i valori di i da 0 a N-k-1 */ if (voti[i] > voti[i+1]) Scambia(&voti[i], &voti[i+1]); } Complessità computazionale degli algoritmi di ordinamento I due algoritmi che abbiamo visto, quello per selezione e quello a bolle, compiono un numero di operazioni (confronti, più che altro) che è di circa N volte N, nel caso peggiore, per un vettore di N elementi. Infatti, bisogna ripetere circa N volte il ciclo più esterno, che contiene un ciclo ripetuto circa N volte. Si dice allora che la complessità computazionale di questi due algoritmi è dell‟ordine di N2, e si scrive O(N2). Come abbiamo già anticipato, questi non sono i più veloci algoritmi di ordinamento. In particolare, il quicksort e il mergesort compiono un numero minore di operazioni. Il quicksort è una funzione ricorsiva che ripete la divisione del vettore in due parti, una con elementi minori e una con elementi maggiori di un dato numero. La ricorsione consta di due fasi: la separazione e la ricostruzione. In questo algoritmo, la prima è più lenta della seconda, dovendo effettuare una selezione degli elementi in fase di divisione dell‟array. Anche il mergesort è una funzione ricorsiva che divide ricorsivamente l‟array a metà. Quest ultima, però, divide senza selezionare gli elementi, quindi più velocemente del quicksort, per poi impiegare di più per riordinare (fondere) i pezzettini di array. Entrambi questi due algoritmi effettuano un numero di operazioni che è di O(Nlog2N), che è molto meno di O(N2), al crescere di N. [con N=64, ad esempio, N2 = 64 * 64 = 4096 mentre Nlog2N = 64 * log264 = 64*6 = 384]. La ragione di questa maggior velocità sta proprio nel fatto di dividere l‟array: per ogni divisione, occorre fare circa N operazioni, ma quante sono le divisioni da fare? Il seguente esempio mostra la situazione: se abbiamo un vettore con, supponiamo, 64 elementi, dopo la prima divisione ne avremo 14 due con 32, dopo la seconda divisione avremo 4 vettori con 16 elementi, dopo la terza divisione 8 vettori con 8 elementi, dopo la quarta 16 vettori con 4 elementi, dopo la quinta 32 vettori con 2 elementi, e dopo la sesta, infine, 64 vettori con un solo elemento, che sono banalmente già ordinati. Abbiamo effettuato complessivamente 6 divisioni, ossia log264. 1.5 Ricerca binaria Ora che sappiamo come ordinare un vettore, possiamo introdurre un altro modo di effettuare la ricerca di un elemento, molto più veloce della ricerca lineare, il quale però richiede che il vettore sia ordinato! Ricordiamo che la ricerca lineare prevede di controllare tutti gli elementi del vettore, uno per uno, quindi effettua circa N operazioni, cioè impiega un tempo pari a O(N). La ricerca binaria procede un po‟ come quando dobbiamo cercare un nome nell‟elenco telefonico: supponiamo di cercare il cognome Cau, e apriamo a caso (diciamo, circa a metà) l‟elenco; supponiamo che abbiamo trovato la lettera F. Sapendo che i nomi sono ordinati, possiamo andare a cercare Cau solo nella prima parte dell‟elenco (quella prima della lettera F), tralasciando completamente la seconda parte (quella dopo la lettera F). Si procede quindi allo stesso modo, considerando solo la prima parte stavolta. Si prende a caso una pagina; supponiamo che esce la lettera B; essendo l‟elenco ordinato, sappiamo che dobbiamo cercare il nostro Cau solo nella seconda parte (dopo la lettera B, ma prima della F) tralasciando la prima (prima della lettera B). In pratica, ogni volta lo spazio di ricerca viene ridotto a metà. Se dobbiamo cercare tra N elementi, dopo la prima “apertura” a caso il prolema diventa di cercare tra N/2 elementi. Dopo la seconda “apertura” dobbiamo cercare tra N/4 elementi, e così via. Seguendo l‟esempio fatto alla fine del paragrafo precedente, possiamo concludere che la ricerca binaria effettua un numero di operazioni pari a circa log2N, quindi O(log2N), molto meno di O(N) impiegato dalla ricerca lineare. Il principio della ricerca binaria è allora il seguente: dati un inizio e una fine del vettore, calcola una posizione centrale, e confronta l‟elemento al centro con quello cercato. Se sono uguali, stop, l'abbiamo trovato. Altrimenti, se l'elemento cercato è minore di quello al centro, ripetiamo la ricerca a sinistra; se l'elemento cercato è maggore di quello al centro, ripetiamo la ricerca a destra. La situazione è spiegata nella figura seguente. 15 Osserviamo, dalla figura precedente, che se non troviamo l'elemento cercato, finiamo con l'indice fine a sinistra dell'indice inizio (mentre per tutta la durata della precedura è a destra). Scriviamo il codice per la nostra ricerca binaria: void RicercaBinaria() { int trovato = 0; int v, inizio = 0, fine = N-1, centro; printf(“elemento da cercare? ”); scanf(“%d”, &v); while((!trovato) && (inizio <= fine)) { /* calcola il centro */ centro = (inizio + fine)/2; if (voti[centro] == v) trovato = 1; else if (voti[centro] < v) inizio = centro + 1; else fine = centro -1; } if (trovato == 1) /* al termine del ciclo */ printf(“elemento presente nel vettore”); else printf(“elemento non presente nel vettore”); } Ovviamente, è possibile anche scrivere una versione ricorsiva, e non iterativa, della ricerca binaria: basta osservare che ogni volta che non troviamo l‟elemento cercato in posizione centrale, eseguiamo di nuovo (cioè richiamiamo la funzione) con nuovi valori degli indici inizio e fine. int RicercaBinariaRicorsiva(int v, int inizio, int fine) /* v è l’elemento da cercare */ { int centro; if (inizio>fine) return 0; else { centro = (inizio+fine)/2; if (voti[centro] == v) return 1; else if (voti[centro] < v) return RicercaBinaria(v, centro+1, fine); else return RicercaBinaria(v, inizio, centro-1); } } 16 La funzione può essere allora richiamata nel main come nel seguente esempio (si notino i valori iniziali degli indici inizio e fine) printf(“elemento da cercare? ”); scanf(“%d”, &v); if (RicercaBinaria(v, 0, N-1) == 0) printf(“elemento non presente”); else printf(“elemento presente”); 1.6 Vettori e puntatori Esiste una stretta relazione tra i vettori e i puntatori: il nome di un vettore in linguaggio C rappresenta anche un puntatore al primo elemento del vettore. Ad esempio, se abbiamo il vettore int vet[10]; il nome vet memorizza l'indirizzo di memoria del primo elemento dell'array. In altre parole, vet e &vet[0] sono la stessa cosa! Allo stesso modo, risulta *vet = vet[0]. Si può anche utilizzare l'aritmetica dei puntatori per accedere ai diversi elementi dell'array: risulta *(vet+1) = vet[1], *(vet+2) = vet[2], e così via, essendo vet+1 = &vet[1], vet+2 = &vet[2], e così via. Il fatto che il nome di un vettore è anche il puntatore al primo elemento del vettore stesso torna molto utile allorchè si voglia passare un vettore come parametro a una funzione: anziché passare l‟intera struttura dati, si passa in realtà l‟inidirizzo di memoria del vettore. Dunque, il passaggio di un vettore come parametro avviene sempre per indirizzo! 1.7 Stringhe Se ricordiamo come si definisce una stringa in linguaggio C, troviamo un‟analogia con i vettori. Ad esempio la dichiarazione char titolo[10]; dichiara un vettore che si chiama titolo e che contiene 10 elementi di tipo char. Non è esattamente come dichiaravamo anche le nostre stringhe? Infatti, in C una stringa non è altro che un vettore di caratteri! Con una sola particolarità: l‟ultimo carattere della stringa è aggiunto automaticamente dal compilatore, ed è sempre il carattere „\0‟, detto carattere di fine stringa. Quindi, se ad esempio dopo la precedente dichiarazione facciamo una scanf(“%s”, titolo); e in fase di esecuzione del programma digitiamo sulla tastiera, ad esempio, pippo, avremo che nel nostro vettore saranno memorizzati i seguenti caratteri 17 mentre se eseguiamo, invece, una printf, il carattere di fine stringa non verrà visualizzato e comparirà semplicemente la parola pippo. Riprendendo il paragrafo precedente, si capisce anche perché quando si fa una scanf di una stringa non c‟è bisogno di usare l‟operatore &, come invece dobbiamo fare per i dati interi o reali: il nome della stringa è il nome di un vettore, e dunque contiene già l‟indirizzo di memoria dove inizia il nostro vettore, senza doverlo trovare con l‟operatore &. scanf(“%d”, &a); /* se a è di tipo intero, scanf richiede il suo indirizzo di memoria */ scanf(“%s”, a); /* se a è di tipo stringa, contiene già l’indirizzo di memoria */ 2. Matrici All‟inizio di questa dispensa abbiamo definito una matrice come un array bidimensionale. Essa può essere definita in linguaggio C nel seguente modo: tipo nome[dimensione_righe][dimensione_colonne]; dove tipo indica il tipo degli elementi della collezione, nome è l‟identificatore che diamo alla nostra variabile array, dimensione_righe è il numero di righe della matrice, e dimensione_colonne è il numero di colonne della matrice. Ad esempio, con la dichiarazione int mat[10][10]; stiamo dichiarando una variabile array che si chiama mat e che contiene 100 elementi di tipo intero, disposti su 10 righe e 10 colonne. Ad esempio, con la dichiarazione float pippo[30][5]; stiamo dichiarando una variabile array che si chiama pippo e che contiene 150 elementi di tipo float, disposti su 30 righe e 5 colonne. Anche per le matrici, per scrivere un programma che sia generale, nel momento della dichiarazione indichiamo quante righe e quante colonne potrà avere al massimo la nostra matrice, ma non è detto che le useremo tutte. Useremo quindi due variabili, diciamo N e M, per indicare il numero di righe e il numero di colonne effettivamente utilizzate. Per lavorare con una matrice, a differenza di un vettore, dobbiamo usare due indici: un indice che rappresenta la posizione di riga, diciamo i, e un indice che rappresenta la posizione di colonna, diciamo j, un po‟ come nella griglia della battaglia navale. Ad esempio, nella matrice mat dichiarata sopra, l‟elemento di riga 0 e colonna 0 si chiamerà mat[0][0], l‟elemento di riga 0 e colonna 1 si chiamerà mat[0][1], ecc. In generale, l‟elemento di riga i e colonna j sarà individuato con mat[i][j]. Se ci sono N righe ed M colonne, allora ci saranno elementi nelle righe da 0 a N-1, ed elementi nelle colonne da 0 a M-1. 18 j i Prestando particolare attenzione alla dichiarazione di una matrice, possiamo osservare che, ad esempio, la dichiarazione int mat[10][10]; può essere vista come la dichiarazione di un vettore di 10 elementi, ognuno dei quali è un vettore di 10 interi. E infatti, una matrice non è altro che un vettore di vettori. Abbiamo allora che gli elementi di una matrice sono memorizzati in modo contiguo: prima tutti quelli della prima riga (il vettore mat[0]), poi tutti quelli della seconda riga (il vettore mat[1]), ecc. Prima di passare alle operazioni, un po‟ di definizioni: una matrice si dice quadrata se N = M (stesso numero di righe e di colonne. In questo caso basta una sola variabile per memorizzare entrambe). una matrice quadrata si dice diagonale se mat[i][j] = 0 ogni volta che i ≠ j (contiene elementi non nulli solo nella diagonale principale, ossia quando i = j). una matrice quadrata si dice triangolare inferiore se mat[i][j] = 0 ogni volta che i<j (contiene elementi non nulli solo al di sotto della diagonale principale). una matrice quadrata si dice triangolare superiore se mat[i][j] = 0 ogni volta che i>j (contiene elementi non nulli solo al di sopra della diagonale principale). la matrice trasposta di una matrice si ottiene invertendo le righe con le colonne. una matrice si dice simmetrica se è uguale alla sua matrice trasposta, ossia se mat[i][j] = mat[j][i] ogni volta che i ≠ j (la parte triangolare superiore e la parte triangolare inferiore si riflettono come in uno specchio rispetto alla diagonale principale). Il caricamento, la visualizzazione, la ricerca e altre operazioni sulle matrici si fanno come nel caso di un vettore, considerando però che dobbiamo spostarci stavolta lungo le due dimensioni, sulle righe e sulle colonne. Per caricare una matrice, ad esempio, occorre farsi dare in input tutti gli elementi della prima riga (in pratica, il caricamento di un vettore), poi tutti gli elementi della seconda riga, ecc. Si tratta quindi di ripetere, per ogni riga, il caricamento di un vettore. Di conseguenza, si dovrà avere due 19 cicli for annidati uno dentro l‟altro, uno per scorrere tutte le righe, e l‟altro per scorrere, per ogni riga, tutte le colonne. Ad esempio, con la matrice mat definita sopra CARICAMENTO DI UNA MATRICE printf(“quante righe? ”); scanf(“%d”, &N); printf(“quante colonne? ”); scanf(“%d”, &M); for(i=0; i<N; i++) /* per tutti i valori di i da 0 a N-1 cioè per tutte le righe*/ for(j=0; j<M; j++) /* per tutti i valori di j da 0 a M-1 cioè per tutte le colonne*/ scanf(“%d”, &mat[i][j]); Allo stesso modo, ogni volta che dovremo scorrere tutti gli elementi della matrice dovremo usare due cicli for annidati (visualizzazione, ricerca). A differenza dei vettori, nelle matrici non esiste un concetto di ordinamento e la possibilità di effettuare una ricerca binaria. Si noti, infine, che è possibile anche caricare la matrice “per colonne”, anziché per righe, semplicemente scambiando l‟ordine dei due cicli for. ESERCIZI: - dopo aver caricato una matrice di (massimo) 50 righe e 50 colonne, visualizzare tutti gli elementi della matrice per colonne visualizzare tutti gli elementi maggiori di 10 trovare l‟elemento minimo dire se la matrice è diagonale, triangolare superiore o triangolare inferiore trovare la somma degli elementi di ogni riga della matrice - creare la tavola pitagorica 3. Array con più di due dimensioni Si possono utilizzare array con più di due dimensioni, a patto di tenere a mente che serve un indice per ognuna delle dimensioni. Ad esempio, la seguente dichiarazione int cubo[10][10][10]; dichiara un array tridimensionale che si chiama cubo e che contiene 1000 interi, disposti su 10 righe 10 colonne e 10 caselle in profondità (si può vedere un‟array tridimensionale come una matrice in cui ogni elemento è un vettore). Un elemento di questo array sarà individuato stavolta specificando la posizione per ognuna delle tre dimensioni, come ad esempio cubo[0][0][2]. Per scorrere tutto il nostro array tridimensionale avremo bisogno allora di tre indici, diciamo i, j e k, uno per ogni diversa dimensione. 20 Allo stesso modo, è possibile dichiarare un array con 4, 5, 6 dimensioni, ecc. Ad esempio, int S[2][4][10][5]; dichiara un array che si chiama S, che ha 4 dimensioni, e che contiene 2*4*10*5 = 300 elementi di tipo intero. E‟ evidente che una simile struttura (così come ogni altro array con più di tre dimensioni) non ha nessun riscontro geometrico nella realtà, ma è possibile comunque utilizzarla in informatica. L‟importante, ricordiamo, è di specificare sempre un diverso indice per ogni diversa dimensione.