Appunti Matematici 12

Page 1

Patrizio Gravano

APPUNTI MATEMATICI

ALGORITMI E STRUTTURE DI DATI numero 12 – dicembre 2015



IN QUESTO NUMERO

Questo numero monografico contiene una introduzione per quanto possibile elementare agli algoritmi, partendo dal concetto stesso per considerare gli aspetti più generali della materia, senza, ovviamente, pretesa di considerare questo elaborato come sostitutivo di un testo di studio. Il punto di partenza, innegabilmente, deve essere costituito dalla definizione, dalla struttura e dalle proprietà degli algoritmi. È data per scontata (del resto già trattata in precedenti numeri di Appunti matematici) la teoria elementare degli insiemi, la teoria degli operatori booleani, l’algebra proposizionale, mentre saranno considerati gli elementi essenziali e applicativi dell’algebra dei circuiti. (in un recente viaggio a Istanbul, città vecchia)

I passi successivi, specie per la parte più elementare, sono abbastanza scontati: i dati (variabili e costanti), la definizione dello schema input → elaborazione → output; la distinzione delle variabili: int, float, boolean, char; il testing e il debugging; la condizione logica if – then – else; la selezione semplice e quella doppia; l’iterazione precondizionata e quella postcondizionata; le iterazioni (definite e indefinite); la selezione multipla; gli array; array n-dimensionali (n ≥ 2). (Patrizio Gravano)


GLI ALGORITMI

1. Introduzione ai concetti dell’algoritmica Il punto di partenza è necessariamente la definizione formale di algoritmo. Un algoritmo è un procedimento sistematico di calcolo, formalizzato da un numero finito di passi (o istruzioni) a complessità limitata, finalizzate alla risoluzione di una famiglia di problemi (generalità). Ad esempio, attività usuali quali “preparare la pizza” o “comprare un abito” verificano le condizioni definitorie di algoritmo. Dalla definizione formale di algoritmo si comprendono le caratteristiche che ogni algoritmo coerente deve possedere: -

finitezza (quindi numero finito di istruzioni elementari, evitando che si verifichino cicli infiniti (loop));

-

determinatezza (intesa come assenza di ambiguità e di indipendenza dall’esecutore, realizzando condizioni di risultato deterministico);

-

realizzabilità

(intesa

sostanzialmente

come

attuabilità

da

parte

dell’esecutore in possesso delle necessarie risorse, a disposizione del linguaggio di programmazione, con esecuzione in tempo finito). Un algoritmo è efficace se risolve un problema, mentre è efficiente se lo risolve con il più contenuto utilizzo di risorse. In sintesi l’enunciato di un problema definisce la relazione esistente tra input e output. Uno stesso problema può essere risolto con diversi algoritmi. La sequenza è sostanzialmente la seguente: ingresso/i ↓ Operazioni/istruzioni elementari non ambigue ← algoritmo → procedimento formale ↓

Uscita/e (dipendente/i da input(s))


La relazione problema – algoritmo è ben schematizzata come segue: problema ← diversi algoritmi → tecniche di programmazione → miglior algoritmo.

1.1. Pseudocodifica Solitamente prima di scrivere le istruzioni utilizzando la sintassi di un linguaggio di programmazione, esse vengono scritte in modo informale ma comunque obiettivo, successivamente trasformato in un programma con le istruzioni di un definito linguaggio di programmazione. Essa (Rota) “serve a descrivere la strategia per affrontare un problema e a organizzare in modo razionale l’algoritmo risolutivo”. Detto altrimenti essa è sostanzialmente “l’elencazione dei passaggi logici che conducono al risultato finale, espressi utilizzando termini di uso comune.”

2. Lo schema input → elaborazione → output Le istruzioni di input/output, dette anche I/O, consentono di elaborare, in condizioni sempre diverse: a input diversi risultando generalmente output diversi. I dati in ingresso, altrimenti detti istanza del problema, vengono elaborati, ottenendo un insieme di dati di uscita. Così facendo l’algoritmo è generale. Sostanzialmente l’algoritmo è costituito dal set sequenziale di istruzioni da effettuarsi sugli ingressi in modo tale che si possano avere gli output corrispondenti. Va quindi ricordata la duplicità degli elementi “ingredienti” degli algoritmi: i dati e le istruzioni.


3. I diagrammi di flusso Gli algoritmi possono essere rappresentati in formato grafico mediante i diagrammi di flusso (flow chart) costituito da blocchi elementari, riconducibili a quattro operazioni fondamentali (o primarie o di base). Esse sono, rispettivamente, il trasferimento di informazioni, l’esecuzione di calcoli e l’elaborazione, l’assunzione di decisioni o selezione, e l’esecuzione di iterazioni.

I diagrammi di flusso sono detti anche mappe concettuali. Ogni blocco ha un ingresso e una uscita. L’arco orientato è rappresentativo della sequenza delle operazioni, mentre la “freccia” indica il flusso dell’esecuzione. Il nodo di congiunzione definisce la congiunzione di due percorsi alternativi. È dato per scontato che si conoscano l’algebra di Boole e le nozioni elementari di logica. È ben noto che gli operatori dell’algebra di Boole sono alla base dell’implementazione dei circuiti digitali. Ad ogni operatore logico corrisponde un simbolo. Queste due pregevoli figure sintetizzano i contenuti formali e di verità degli operatori logici booleani.


In sintesi è bene ricordare che dal linguaggio comune (o da uno pseudocodice) si può ottenere un diagramma di flusso, che, implementato secondo un assegnato linguaggio di programmazione, viene trasformato in un programma.

4. Variabili e costanti Va considerato che nell’elaborazione degli algoritmi un ruolo essenziale rivestono i dati, distinguibili in dati di partenza (input), dati intermedi, e dati finali (output). Già il termine “variabile”, evoca il caso di una grandezza, di un dato, che possono assumere valori diversi nel corso dell’elaborazione, o con riferimento a elaborazioni successive. Esse vengono memorizzate nella memoria dinamica del calcolatore. Per esse, di volta in volta, ne va definito il nome e la natura. È sempre utile utilizzare un nome che ben riconduca al contenuto di essa. Quanto al tipo si distingue tra intero (int), decimale (float), carattere (char), logico (bool).


Questo è vero in astratto. Ăˆ bene sempre riferirsi al linguaggio di programmazione che si utilizza. La definizione di una variabile avviene come nell’esempio seguente: int

variabile 1

Il tipo precede sempre l’identificatore. L’inizializzazione di una variabile avviene mediante assegnazione. All’uopo si utilizza il simbolo �. int

variabile 1;

variabile 1 � 0;

//definizione // inizializzazione

Queste due stringe si intendono che è definita una variabile, chiamata variabile1, di tipo intero cui è stato attribuito il valore (variabile) eguale a zero. Per certi linguaggi di programmazione (quali Java) il tipo piĂš correttamente sarebbe stato byte. Ăˆ bene fare qualche considerazione sui tipi di dato intero (int) gestibili in C++. Generalmente un dato di tipo int occupa 4 byte di memoria e gestisce gli interi nel range (-231 , 231 − 1) . Dovendo elaborare numeri molto piĂš piccoli sono sufficienti solo due byte, potendo però lavorare con variabili intere entro un range piĂš limitato, ovvero (-215 , 215 − 1). Volendo, se del caso, si può ampliare il range che diviene del tipo (-263 , 263 − 1), đ?‘˘đ?‘Ąđ?‘–đ?‘™đ?‘–đ?‘§đ?‘§đ?‘Žđ?‘›đ?‘‘đ?‘œ 8 byte. Al riguardo si parla di intero tipo long. Sono ammesse le operazioni di assegnazione di una variabile a una variabile, come nella riga seguente num2 â†? num1;


Essa vuol significare che il contenuto (variabile) della num1 viene collocato nell’area di memoria definita da num2. Sono gestibili assegnazioni più complesse, quali ad esempio quella di una espressione (sia numerica che algebrica) ad una variabile. num2 ← (num1 * 4) + 100 Detta stringa va intesa nel modo seguente: il contenuto della num1 moltiplicato per 4 così ottenuto viene sommato al numero cento e il risultato di detta operazione è assegnato alla variabile num2. Le grandezze che non variano nel tempo sono dette costanti, risultando definite da un indicatore e da un valore assegnato, come nell’esempio seguente: RADICEDIDUE ← 1,41 Solitamente le costanti si indicano con caratteri maiuscoli.

4.1 Variabili di tipo int Le variabili di tipo int contengono valori interi (e solo interi) compresi tra – 32768 e + 32767, anche se tale range può variare a seconda dell’ambiente di programmazione. Si riveda quanto detto in precedenza a proposito di C++. Con riferimento alla gestione della variabili di tipo int sono utilizzabili i seguenti operatori: +, -, *, /, ^, MOD, RANDOM. I primi quattro sono gli ordinari operatori aritmetici, mentre MOD divide i due numeri e restituisce in output il resto della divisione. L’operatore RANDOM genera randomize un intero compreso tra 0 e un assegnato valore massimo possibile. Ad esempio, RANDOM (100) genera un intero qualunque positivo ≤ 100.


4.2 Variabili reali float Esse gestiscono i valori reali. Ăˆ ammesso ogni valore compreso tra un đ?‘&#x;đ?‘šđ?‘–đ?‘› e un đ?‘&#x;đ?‘šđ?‘Žđ?‘Ľ . Con la lettera r si denota un numero reale. Sono ammessi gli ordinari operatori aritmetici, la radice quadrata (SQRT); LOG

e

LN

(di

immediata

comprensione

!),

ROUND

che

determina

l’arrotondamento all’intero, ma anche TRUNC, che definisce la parte intera e DEC che definisce la parte decimale. TRUNC(num1) considera la parte intera del numero contenuto nella location num1. Ăˆ possibile lavorare sulle espressioni di variabili per ottenere una variabile risultato. Per le operazioni l’ordine di priorità è quello noto dall’aritmetica.

4.3 Variabili booleane In prima battuta ci si può limitare agli operatori NOT, AND e NOR, giĂ ricordati. Consideriamo il caso della NOT. Essa nega il valore di una variabile. Se per esempio si avesse bool var1 â†? TRUE; allora NOT(var1) → var2 ⇒ var2 â†? FALSE Per le variabili bool si hanno i seguenti operatori =, <>, <, >, <=. Il risultato delle operazioni può assumere solo i valori ammessi TRUE oppure FALSE. In C++ una variabile booleana occupa 2 byte di memoria.


4.4 Variabili carattere Per le variabili carattere di tipo char si possono memorizzare tutti i caratteri di tastiera. A ogni carattere corrisponde un codice di otto bit per la codifica ASCII e un codice di 16 bit (2 byte) per la UNICODE. Ogni variabile di tipo char memorizza un solo carattere alla volta. Per esempio facendo riferimento alla tabella dei codici ASCII l’operatore CHR porta in output il carattere corrispondente al numero ASCII associato a quel carattere. Ad esempio la scrittura CHR(65) fornisce il carattere A (maiuscola !). L’operatore inverso è ORD. Per esempio ORD(A) conduce al codice ASCII della lettera A, ovvero a 65. Gli operatori PRED e SUCC restituiscono, rispettivamente, i caratteri precedente e successivo di quello dato. La

sintassi

dei

due

operatori

è

immediata.

Per esempio

si

scrive

PRED(<carattere>). In particolare C++ interpreta i caratteri servendosi della codifica UNICODE, costituita da 2 byte di memoria. Ad ogni carattere corrisponde un intero tra 0 e 65.535.

4.5 Operatori Le operazioni eseguibili seguono inevitabilmente alla considerazione dei vari tipi di caratteri. Vorrei riferirmi direttamente all’ambiente C++. Gli operatori aritmetici sono + per l’addizione, - per la sottrazione;


* per la moltiplicazione; / per la divisione; % per il modulo (o resto). Detti operatori sono detti binari, perché effettuano operazioni su due operandi. Ma nell’ambiente C++ operano pure operatori aritmetici composti. Si consideri una stenografia del tipo var = var + k intende che al contenuto della variabile var viene aggiunto il valore della costante k. In altri termini var ← var + k. Questo stato di cose è formalizzato in C++ come segue: var +=k Esiste ovviamente un operatore aritmetico composto per ognuno dei precitati operatori aritmetici composti. Ad esempio la scrittura var *=3, in interpreta che il contenuto della variabile viene moltiplicato per tre, o come solitamente scriviamo ha il senso della seguente istruzione var ← 3* var Ma in C++ esistono ovviamente anche operatori unari, che operano, modificandone il valore, su una sola variabile. Per esempio ++num equivale a incrementare di 1 il valore della variabile num, ovvero equivalentemente num ← mun + 1. Analogo senso ha, mutatis mutandis, la scrittura --num. Si è correttamente scritto che essi sono importanti perché favoriscono la sintesi delle istruzioni. Capiterà quindi una istruzione quale la seguente num1=++num2


Questo costrutto così si interpreta: assegnato il valore della variabile num2 lo si incrementa di + 1, quindi il valore ottenuto lo si colloca nella location di memoria num1. Bisogna prestare attenzione che è cosa diversa lo scrivere num1=num2++. In questo caso si avrebbe che ciò è riconducibile a num1 ← num2 quindi num1 = num2 ← num2 + 1. Per gli operatori relazionali (detti anche di confronto) non vi è molti di più da dire. Bisogna forse ulteriormente ricordare che in C++ si ha pure l’operatore != che significa “diverso”. La scrittura bool test = num1==num2, assegna alla variabile test il valore true o false a seconda che sia risultato num1 = num2 oppure num1 ≠ num2. Sempre con riferimento all’ambiente C++ viene definito un operatore booleano, detto and di cortocircuito, &&, che limita il controllo alla prima espressione. Se essa è False ci si arresta e ciò incide con la velocità di elaborazione. Ciò è una diretta conseguenza della tabella di verità di AND. Vorrei rimarcare che i simboli dei vari operatori sono rispettivamente & per and; && per and-cortocircuito; ⎸per l’operatore or; ⎸⎸ per l’operatore or-cortocircuito; ! riferito al not (unario di negazione); ^ definito come xor (exclusive or).

5. Sequenzialità L’algoritmo è costituito da istruzioni elementari, che vengono eseguite una alla volta sequenzialmente partendo “dall’alto” e via via verso il basso.


Si parla al riguardo di sequenza di istruzioni, che costituisce uno dei modelli della programmazione imperativa. A livello avanzato andrà introdotto il concetto di testing, inteso quale insieme di operazioni che hanno la finalità di validare il programma rispetto alle specifiche assegnate. Esistono poi tecniche, dette di debugging, utili a individuare l’istruzione che provoca l’errore, provvedendo poi alla sua eliminazione.

6. If – then – else

Esso si caratterizza per un blocco decisorio dotato di un ingresso e di due uscite a cui si rimanda sulla base della condizione logica TRUE, FALSE, non essendo ammissibile ogni altra ipotesi. A seconda che sia TRUE o FALSE si procede su uno dei due (sono solo due !, ovviamente) rami. “A valle” della figura di test, possono presentarsi due distinte ipotesi: “a valle” della figura di test vi è un solo blocco istruzioni e il ramo uscente dal ramo FALSE del test confluisce in un nodo unitamente al ramo di uscita dal blocco istruzioni, avendo in questo caso un esempio di selezione semplice; “a valle” delle uscite del blocco test si hanno due Blocchi, detti Blocco1 e Blocco2, di istruzioni. Il rami in uscita dai due blocchi confluiscono in un modo che garantisce la sequenzialità mediante un successivo Blocco3 di istruzioni. In questo secondo caso di parla di selezione doppia. Le figure seguenti sono immediatamente comprensibili.


Ecco la rappresentazione grafica della selezione semplice

Il diagramma che riguarda la selezione doppia è invece il seguente

La porzione di diagramma di flusso compreso tra l’uscita del blocco di test e l’ingresso del Blocco3 è detta istruzione condizionale (semplice o doppia). Alcuni chiamano l’istruzione di selezione “ALTERNATIVA” per la ragione ben ovvia ben rappresentata dalla flow chart. È bene considerare anche questo ulteriore caso.

Trattasi della selezione multipla (detta anche switch).


Trattasi del caso della iterazione precondizionata. Sussiste una ulteriore iterazione detta postcondizionata, ben rappresentata dalla seguente porzione di flow chart.

Vorrei sviluppare la porzione di diagramma di flusso appena rappresentata facendo qualche riflessione sul cosiddetto ciclo for. Occorre considerare una ulteriore struttura di controllo detta ciclo iterativo. Esso è anche detto ciclo for. Esso contiene una variabile interna inizializzata a 0 che misura il numero di iterazioni. La porzione di pseudocodice potrebbe essere la seguente:

1. I ⇽ 0, Max ⇽ m; 2. If I < Max go to 3. else go to 6.; 3. esegui istruzioni assegnate then go to 4.;


4. I ⇽ I + 1; 5. go to 3.; 6. esci dal ciclo iterativo;

Le istruzioni vengono ripetute N volte. È ammesso un ciclo for nidificato. Sono in questo caso necessarie (essendoci due cicli) due variabili per il conteggio. La porzione di pseudocodice è la seguente:

1. i ⇽ 1; 2. if i ≤ N is true go to 3. else go to 8.; 3. j = 1 4. if j ≤ M is true go to 5. else go to 6.; 5. istruzioni then go to 7.; 6. i ⇽ i + 1 then go to 2, ; 7. j ⇽ j +1 then go to 4.; 8. end

// uscita dal ciclo nidificato

Qualche ulteriore riflessione deve essere fatta per i costrutti DO-WHILE e WHILE. Cominciamo dal primo, ovvero da DO-WHILE (fai una certa cosa finché una data condizione è vera). È la cosiddetta postcondizione. Il listato corrispondente è molto semplice.

1. istruzioni; 2. if condition is true go to 1. else go to 3.;


3. uscita dal ciclo.

Come detto esiste anche una precondizione detta ciclo precondizionale. Il corrispondente pseudocodice è il seguente.

1. if condition is true go to 2. else go to 3.; 2. esegui istruzioni then go 1.; 3. esci dal ciclo.

7. Un primo approfondimento sui “dati” Con il termine dato si intende ogni valore assumibile da una variabile. Tipo di dato fa invece riferimento ad una collezione di valori su cui sono ammesse date operazioni. Ciò premesso, viene definita la struttura di dati, con riferimento all’insieme di operazioni che permettono di manipolare gli elementi della medesima o di creare altre strutture, oltre alla organizzazione degli elementi in memoria con finalità di ottimizzazione. Il primo esempio di struttura dati è il vettore, costituito da una sequenza di elementi dello stesso tipo. Esso definisce una struttura di tipo lineare (in quando viene definito un primo elemento, un secondo, etc.), omogenea (in quanto i vari elementi sono dello stesso tipo), e statica (poiché il numero degli elementi è costante nel tempo).

Relativamente alle strutture dati viene definita la specifica sintattica, intesa come elenco delle operazioni sulla struttura e descrizione dei domini di partenza e di arrivo, cui segue la specifica semantica, a volte scritta in


linguaggio naturale, centrata sull’effetto “che l’operazione ha sulla struttura dei dati�.

7.1 I vettori (array ad una o piĂš dimensioni) Caratteristica peculiare dei vettori è che essi contengono un numero prefissato di elementi dello stesso tipo (omogenei), individuabili tramite indici. I vettori unidimensionali sono contraddistinti dal formalismo A ⌋ âŚŒche definisce un vettore di n elementi. Per le matrici si utilizza un formalismo del tipo B = âŚ‹âŚŒâŚ‹âŚŒ intendendo riferirsi ad una matrice m*n.

7.1.1 Vettori ad una dimensione Gli elementi sono ordinati secondo una sola dimensione. Ogni elemento occupa una “cellaâ€? e quindi ad ogni elemento è associabile un numero d’ordine. Il numero degli elementi costituisce la lunghezza del vettore. In astratto ogni elemento di un vettore può essere a sua volta un vettore ! Un vettore è dichiarato se si rispetta una sintassi del tipo int Nomevettore ⌋nâŚŒ ove n è un intero > 0. Per quanto scritto ognuno degli n elementi di Nomevettore è un intero. I vettori sono manipolabili nel senso che sono ammesse due distinte operazioni, ovvero l’inserimento di un elemento nella data cella ≤ n, essendo n prefissato, e la lettura del contenuto di una cella. La scrittura Nomevettore ⌋ι ≤ nâŚŒ â†? đ?‘›0 significa che il numero intero đ?‘›0 è collocato nella cella Îą con 1 ≤ Îą ≤ n e Îą ∈ N.


Ăˆ infatti possibile introdurre una variabile chiamata indice definita intera. int indice â†? 1 si intende che indice vale 1. Se ad essa è fatta seguire la seguente istruzione Nomevettore ⌋indiceâŚŒ â†? 2 intendo dire che in posizione 1 (indice) è collocato il valore 2. Ăˆ ovvio che si deve procedere alla inizializzazione, ovvero alla condizione di base che ogni elemento del vettore sia identicamente eguale a 0. Viene introdotto il ciclo di conteggio.

int celle â†? n; Nomevettore ⌋celleâŚŒ; for (indice â†?1; indice <= celle; indice++) Nomevettore ⌋indiceâŚŒ â†? 0;

Per la lettura si ha l’istruzione seguente output (Nomevettore⌋2âŚŒ) che visualizza il contenuto (l’elemento) di indice 2, ovvero in posizione 2. La dimensione fisica del vettore può non coincidere con la dimensione logica ovvero con il numero di celle effettivamente utilizzate. Sui vettori sono definite operazioni di origine matriciale, quali lo shift a sinistra o a destra, la rotazione (o shift completo), la somma, il prodotto per una costante e il prodotto di vettori, da intendersi come l’usuale prodotto scalare. Un esempio di rotazione completa, riferita ai tempi đ?‘Ą1 đ?‘’ đ?‘Ą2 , per un vettore di dimensione n è espressa dalla seguente semplice tabella đ?‘Ą1

�1 �2 �3 ‌ . . ��


đ?‘Ą2

đ?‘Žđ?‘› đ?‘Ž1 đ?‘Ž2 ‌ . . đ?‘Žđ?‘›âˆ’1

đ??¸đ?‘ đ?‘ đ?‘Ž definisce uno shift verso destra di una posizione senza perdita di dati. Per quale đ?‘Ąđ?‘˜(đ?‘›) con k intero il vettore assume la stessa configurazione del tempo iniziale ? In C++ l’istruzione definitoria per il vettore è tipo

nomevettore ⌋dimensioneâŚŒ

L’indice è compreso tra 0 e n-1, ove n è la dimensione del vettore. Esempio per definire un vettore costituito da n elementi, per esempio n = 100 si scrive int dim = 100; string Professori ⌋dimâŚŒ; Per esempio per scrivere da tastiera il cognome del 50 professore si può scrivere cout << “nome e cognome del 50° professore =â€? << Professori ⌋49âŚŒ (se come indice si parte da zero). Gli array godono di due proprietĂ importanti. Una prima proprietĂ , detta forte, si sintetizza dicendo che gli indici delle celle di un vettore sono numeri consecutivi. La seconda proprietĂ , detta debole, non consente di aggiungere celle. Per i vettori nasce la problematica di mantenere un ordinamento con inserimenti e cancellazioni. Dato un array di dimensione h con n > h celle che contengono gli elementi del vettore. Deve essere n ≤ h < 4n. Per n = 0 si pone h = 1. Quando n > h si ha il doubling (raddoppiamento) h â†? 2h.


Quando n < h/4 di ha il dimezzamento (halving) ovvero h â†? h/2. (h è sempre una potenza di 2, ovvero 2đ?‘–đ?‘›đ?‘Ąđ?‘’đ?‘&#x;đ?‘œ ). Ogni nuovo elemento viene collocato in n quindi si incrementa n di 1.

7.1.1.1 Esercizi elementari sui vettori In Bertossi, Montresor, (Algoritmi e strutture dati, CittĂ StudiEdizioni, seconda edizione) ho rinvenuto un interessante pseudocodice che consente la ricerca del minimo (đ?‘Ľđ?‘šđ?‘–đ?‘› ≤ x : x ∊ X) in un vettore. Mi si è posto il problema di rielaborare lo pseudocodice in termini di flow chart, ampliando il problema alla definizione della frequenza di esistenza del minimo entro l’array (f = quante volte quel minimo compare, f ⊞ 1) ma anche considerando la collocazione – riferita all’indice di posizione – entro il dato vettore. Dalla lettura del testo Bertossi Montresor, Algoritmi e strutture dati, seconda edizione, CittĂ StudiEdizioni) ho rinvenuto uno pseudocodice che codifica la ricerca di un minimo in un vettore di elementi numerici. Questo pseudocodice mi è stato molto istruttivo, ma andando oltre i contenuti di quelle pagine, devo constatare che in generale un vettore può contenere piĂš di un minimo. Per esempio il vettore (1, 2, - 2, 1, - 2) contiene il minimo – 2 con molteplicitĂ di frequenza 2 nelle posizioni 3 e 5, rispettivamente. Per la individuazione dei minimi entro il vettore ho utilizzato un vettore P⌋nâŚŒ, inizializzato identicamente a zero, salvo il primo elemento (sequenza di n-1 zeri, che segue un uno). PoichĂŠ disegnare la flow chart è quanto mai laborioso ho deciso di sintetizzare gli esiti con un pseudocodice molto personale, come segue

1. Assegnazione del vettore V di dimensione n; 2. Inizializzazione del vettore P di dimensione n a valori đ?‘?đ?‘˜ = 0 tranne đ?‘?1 = 1; 3. Inizializzazione della variabile f al valore 1 ( f â†? 1);


4. k = 1 // (inizializzazione di k a 1, k ← 1); 5. min ← A⦋1⦌ // minimo provvisorio; 6. k ← k +1 // implementazione di k; 7. test : 1 < k ≤ n; 8. if test 7. is false go to 9., else to go 10.; 9. stampa min, stampa f, stampa P⦋n⦌; 9.1. end; 10. test: A⦋k⦌ < min; 11. test 10. True go to 12. else go to 17.; 12. min ← A⦋k⦌ // nuovo minimo; 13. for j < k P(j) = 0 14. f ← 1 //perché avendo un nuovo minimo la frequenza riparte da 1; 15. P(k) = 1 e P(j≠k) = 0 ⩝ j ≠ k; 16. go to 6.; 17. test A⦋k⦌ = min; 18. If 17. is true go to 19. else to go to 23.; 19. f = f +1; 20. P(k) = 1 // con conservazione degli zeri e degli uni già in memoria; 21. go to 6.; 22. P⦋k⦌ = 0; 23. go to 6.;

Tra gli end del listato c’è il vettore P⦋n⦌ costituito da una sequenza di 0 e di 1. Vorrei quindi introdurre il sottoprogramma – in termini di pseudocodice – relativo alla stampa delle posizioni dei minimi entro il vettore A⦋n⦌. Ad esempio per il vettore A = (2, 5, 7, 9, 2) si ha P = (1, 0, 0, 0, 1) Esso è dato dal seguente pseudocodice


1. Entra il vettore P⦋n⦌ // esso è costituito da una sequenza di zeri e di uno che convenzionalmente riferiscono la posizione del minimo (o dei minimi) entro il vettore A⦋n⦌; 2. τ ← 1; // 3. test : 1 ≤ τ ≤ n; 4. se test 3. è False to go to 5. else go to 6. ; 5. stampa “il minimo si trova nella/e posizione/i ….. “; 6. if P⦋τ ⦌ = 1 is true go to 7. else go to 8.; 7. memorizza che un minimo si trova nella posizione τ; 8. go to 9.; 9. τ = τ + 1; 10. go to 3;

Questi passaggi formali – ancorché scritti in modo semi informale – danno l’idea delle modalità con le quali procedere. In Algoritmi e strutture dati di Demetrescu, Finocchi, Italiano, McGraw Hill, sono contenuti alcuni interessanti algoritmi di ordinamento di array vettoriali (pag. 90 e seguenti). Ho trovato istruttive le pseudocodifiche colà riportate. Vorrei riportare queste mie due pseudocodifiche che definiscono un ordinamento degli elementi di un vettore A⦋n⦌, di dimensione n, ovvero costituito da n elementi. La prima è la seguente.

1. entra A⦋n⦌; 2. j ← 1; 3. if j < n go to 4. else go to 7.; 4. if A⦋j⦌ > A⦋j+1⦌ go to 5. else go to 6.; 5. (m ← A⦋j+1⦌, A⦋j+1⦌ ← A⦋j⦌, A⦋j⦌ ← m) then go to 6.; 6. j ← j + 1 then go to 3.;


7. return A(≼)⌋nâŚŒ // vettore ordinato Il secondo approccio è il seguente. 1. entra A⌋nâŚŒ; 2. j â†? 1; 3. k = j + 1; 4. if k ≼ n go to 8. else go to 5.; 5. (m â†? A⌋j+1âŚŒ, A⌋j+1âŚŒ â†? A⌋jâŚŒ, A⌋jâŚŒ â†? m) then go to 6.; 6. j â†? j +1; 7. go to 3.; 8. return A(≼)⌋nâŚŒ // vettore ordinato

7.1.2 Matrici (vettori a due dimensioni) Come evidente occorre riallacciarsi alla teoria delle matrici. Esse sono, come è noto, costituite da una collezione di elementi incasellati in righe e colonne. Le matrici m * n sono costituite, come noto, da m righe e da n colonne ed ogni elemento della matrice, che per ora supponiamo intero, è individuato univocamente da un numero di riga e da un numero di colonna per i quali si verificano le condizioni 1 ≤ i ≤ m e 1 ≤ j ≤ n. La coppia ordinata (i, j) consente di definire univocamente la location del numero entro la matrice. La scrittura đ?‘Ž(đ?‘–đ?‘œ đ?‘—0 ) definisce univocamente l’elemento a della matrice A che si trova all’incrocio della riga đ?‘–đ?‘œ e della colonna đ?‘—0 . Una matrice m*n, contiene, ovviamente, m*n celle, quindi m*n elementi. Il numero m*n è detto dimensione della matrice. La stringa di dichiarazione di una matrice è per esempio la seguente int Nomematrice ⌋mâŚŒâŚ‹nâŚŒ;


È possibile sia m = n. In questo caso si parla di matrice quadrata. In generale è m ≠ n. Entry e lettura di un elemento sono le operazioni elementari di manipolazione delle matrici.

Come già detto una cella è riconducibile univocamente mediante una coppia ordinata (i, j). Ad esempio l’assegnazione del valore 7 alla casella (3, 2) viene così formalizzata: int Nomematrice ⦋3, 2 ⦌ ← 7 La procedura di assegnazione può avvenire per conteggio. Un esempio concreto è contenuto in Camagni, Algoritmi e basi per la programmazione, Hoepli Informatica, 2006, pag. 325-326, cui si rimanda. Per le matrici si adotta la consueta nomenclatura, ben nota dall’algebra lineare. Parimenti le operazioni, con le limitazioni ben note, sono quelle date dall’algebra lineare. Tra gli interessanti esercizi proposti nell’ottimo testo di Bertossi e Montresor, Algoritmi e strutture dati, CittaStudiEdizioni, ve ne è uno che chiede di memorizzare una matrice in un vettore, previo aver definita la relazione di corrispondenza tra gli elementi della matrice e il vettore di destinazione.


Si rimanda a quell’ottimo testo, anche se propongo il seguente pseudocodice di mia elaborazione.

1. entra A⌋mâŚŒâŚ‹nâŚŒ // è assegnata una matrice di m righe ed n colonne; 2. entra V⌋zâŚŒ = (0, ‌.., 0) // è assegnato un vettore di dimensione z =m*n i cui elementi sono identicamente eguali a zero; 3. i â†? 1 and j â†? 1 and k â†? 1; 4. if i â‹œ n go to 5. else go to 8.; 5. đ?‘Łđ?‘˜ â†? đ?‘Žđ?‘—đ?‘– //assegnazione dell’elemento della matrice all’elemento k-esimo del vettore V⌋zâŚŒ; 6. k â†? k +1; 7. i â†? i + 1; 8. go to 4.; 9. j â†? j +1; 10. if j â‹œ m go to 4 else go to 10.; 11. write đ?‘‰đ??ž

//a questo punto la vettorializzazione è completata;

12. end

j e i sono gli indici di colonna e riga, rispettivamente e lo pseudocodice definisce una trasformazione che rende vettore una matrice operando sulle colonne. In pratica le prime m posizioni del vettore contengono gli m elementi della prima colonna, in posizione k =(m +1) si trova l’elemento đ?‘Ž2,1 etc. Ho definito un secondo pseudocodice che opera in modo dissimile vettorializzando una matrice con riferimento alle righe, ovvero definendo corrispondenza del tipo đ?‘Ž11 → đ?‘Ł1 , ‌.., đ?‘Ž1đ?‘› → đ?‘Łđ?‘› , đ?‘Ž21 → đ?‘Łđ?‘›+1 etc. Esso è il seguente:

1. entra la matrice A⌋i,jâŚŒ, entra il vettore V⌋ijâŚŒ : V⌋k = ijâŚŒ = 0, per ogni intero assoluto ≤ij; 2. i = 1, j = 1, k = 0; 3. k â†? k +1;


4. đ?‘Łđ?‘˜ â†? đ?‘Žđ?‘–đ?‘— ; 5. j â†? j +1; 6. if j ≤ n is true go to 3. else go to 7.; 7. i â†? i +1; 8. if i ≤ m is true go to 3. else go to 9.; 9. write V⌋kâŚŒ;

7.2 Stringhe Anche la stringa è una struttura di dati sequenziali (uno precede l’altro, ‌) in numero finito (lunghezza della stringa). Una stringa di zero elementi è detta vuota. Ogni elemento è in una data posizione e detta posizione definisce l’indice della stringa. Esse sono dimensionate automaticamente. string aaaaaaa â†? “ciaoâ€?; bbbbbb â†? “ â€?;

// definizione di stringa vuota (spazio in output)

ccccccc � “patrizio�;

Vi è una prima “operazioneâ€? sulle stringhe detta concatenazione, o “somma di stringheâ€?. Ad esempio la scrittura string dddddd â†? aaaaaaa + bbbbbbb + cccccccc; contiene “ciao patrizioâ€? Una stringa è ottenibile per concatenazione di caratteri char carattere1 â†? “pâ€?,

carattere2 � “g�;


string sigla â†? carattere1 + carattere2 Essa restituisce la sigla “pgâ€? Da una data stringa è possibile procedere alla estrazione di una sottostringa, mediante l’operatore subStr. Data la stringa funzionale questa ha lunghezza 10. subStr (“funzionaleâ€?, 2, 7); definisce una sottostringa della stringa data chiamata funzionale. Il primo indice (2) definisce l’indice di inizio della nuova stringa, che nel caso di specie corrisponde al carattere “uâ€?, mentre il secondo indice corrisponde alla lunghezza della nuova stringa. Con riferimento ai dati si ha la stringa “unzionaâ€?. Ăˆ poi possibile estrarre un singolo elemento sempre con lo spesso operatore. subStr (“funzionaleâ€?, đ?‘›0 , 1) che restituisce l’elemento di indice đ?‘›0 ≤ n della data stringa. Esiste pure un operatore lungStr che consente di calcolare la lunghezza di una stringa. Ricordare che il carattere bianco (lo spazio tra due parole) coincide con una stringa di dimensione 1.

7.2.1. Le stringhe in C++ Come ben noto una stringa è una sequenza di caratteri. Nel linguaggio di programmazione C++ le stringhe sono comprese tra virgolette doppie, come ad esempio “Patrizioâ€?. La dichiarazione è immediata. Ad esempio si ha: string name = “Patrizioâ€?; Ăˆ data l’istruzione di formato


getline(cin, nome); legge i caratteri digitati e memorizza il contenuto nella variabile nome. Per le applicazioni operative si rimanda ad un testo di C++ quale l’ottimo Manuale di C++ di Cesare Rota, Hoepli Informatica, 2009. Anche per le stringhe è definita la lunghezza, costituita dal numero di caratteri che la compongono. L’istruzione int n = nome.length(); indica la lunghezza (numero di caratteri) della variabile contenuta in nome. La concatenazione di stringhe avviene con la seguente istruzione: stringa1 = stringa1 + “ ” + stringa2; L’estrazione di una stringa secondaria (chiamata solitamente sottostringa) è codificata con la seguente istruzione: s.substr(inizio, lung) s è il nome della stringA, inizio è l’indice dei caratteri contenuti in essa (partendo dall’indice di posizione zero) mentre lung è il numero di caratteri che costituiscono la sottostringa creata. Per i confronti tra stringhe si utilizzano i soliti operatori relazionali, che nel caso del linguaggio di programmazione C++ sono i seguenti: ==

per la relazione di eguaglianza;

<

per la relazione “è minore di”;

> >=

per la relazione “è maggiore di” per la relazione “è maggiore eguale”;

<=

per la relazione “è minore eguale”;

!=

per la relazione “è diverso”.

Il controllo delle stringhe è lessicografico.


7.2.2 Ipotesi di pseudocodice per un confronto tra stringhe Ho elaborato il seguente pseudocodice per il confronto tra stringhe alfanumeriche. Occorre verificare se due stringhe sono eguali (se sono quindi la stessa stringa). Vi è una condizione necessaria ma non sufficiente, ovvero le due stringhe devono avere la medesima lunghezza. Il listato elaborato è il seguente:

1. entra s.1 ed entra s.2 // assegnazione delle stringhe; 2. conta il numero degli elementi di s.1 e di s.2; 3. assegna i valori di lunghezza trovati n(s.1) e n(s.2); 4. if n(s.1) ≠ n(s.2) go to 5 else go to 6; 5. dichiara le stringhe diverse; 6. assegna valore vero agli indici di posizione i e j riferiti alle stringhe assegnate; 7. leggi s.1(i) e s.2(j) and go to 8; 8. if s.1(i) ≠ s.2(j)go to 5. else go to 9.; 9. i ← i + 1 and j ← j + 1 then go to 10.; 10. if i > n(s.1) go to 11. else go to 8.; 11. dichiara le due stringhe eguali. 12. end

7.3 Record Il record è una struttura sequenziale costituita da un numero prestabilito di variabili di natura anche diversa. È bene quindi ricordare che si parte dalla definizione della struttura e dalla dichiarazione della variabile. record Nomerecord: string cognome⦋k⦌;


string nome⦋k’⦌; int anno; char canale; char materia; bool iscrittoesame; int votoesame. I campi di un record sono manipolabili.

Uso dell’operatore unario dot. alunnoXXX.cognome ← “Rossi”; alunnoXXX.nome ← “Paolo”; alunnoXXX.anno ← “primo”; alunnoXXX.materia ← “chimica”; alunnoXXX.iscrittoesame ← True; alunnoXXX.votoesame ← “15”.

In alcuni linguaggi è possibile utilizzare l’operatore with. with alunnoXXX alunnoXXX.cognome ← “Rossi”; alunnoXXX.nome ← “Paolo”; alunnoXXX.anno ← “primo”; alunnoXXX.materia ← “chimica”; alunnoXXX.iscrittoesame ← True; alunnoXXX.votoesame ← “15”


È possibile operare pure su record di record.

8. Codifica di una funzione Le funzioni consentono di definire procedure. La relativa codifica avviene formalmente. La funzione si costituisce di una testata e delle istruzioni sequenziali. La testata ha la seguente struttura: tipo_restituito nome_funzione (<lista_parametri_formali>); Esempio: una funzione che determina se un numero reale è pure intero. bool numero_intero (real numero_reale);

La codifica completa risulta la seguente: bool numero_intero (real numero_reale); // sezione dichiarativa locale … // sezione esecutiva if numero_reale = ⦋numero reale⦌ // con ⦋.⦌ mi riferisco a parte intera. return TRUE; else


return FALSE;

9. Ancora sullo pseudocodice Con il termine pseudocodice ci si riferisce solitamente ad un linguaggio di programmazione fittizio, non direttamente legato ad alcun interprete o compilatore. Ha un vantaggio: rendere l’essenza dell’algoritmo, senza perdersi nei meandri sintattici. L’assegnamento è espresso con il simbolo ← mentre al solito l’eguaglianza è indicata con il simbolo =. La scrittura a ←→ b, indica che i valori delle due variabili, a e b, sono scambiati tra di loro. Sono ovvi i due costrutti condizionali seguenti: if condizione then istruzione if condizione1 then condizione2 else istruzione2 La ripetizione iterativa illimitata di una istruzione si ha con il seguente costrutto While condizione do istruzione (//finché è verificata la condizione esegui l’istruzione indicata). Ma sono dati anche particolari costrutti iterativi limitati. for indice ←estremoinferiore to estremo superiore do istruzione. La funzione iif (condizione, v, u) restituisce v se la condizione è vera, e u se falsa.

10. Sequenze La sequenza è una struttura dati dinamica, in quanto il numero degli elementi può variare nel tempo, e lineare, ovvero in sequenza.


Gli elementi possono essere anche duplicati. Ăˆ importante l’ordine che definisce la sequenza. Esempio tipico è lo schedario aziendale. Data un sequenza S di n elementi ogni elemento è indicato con la formalizzazione đ?‘ đ?‘– con 1 ≤ i ≤ n, ove n è il numero degli elementi. All’elemento di valore đ?‘ đ?‘– è associata la posizione đ?‘?đ?‘œđ?‘ đ?‘– . Dalla posizione si accede all’elemento. Il comando insert consente di inserire un elemento di dato valore in una data posizione. La sintassi è insert (p, v) con p = đ?‘?đ?‘œđ?‘ đ?‘– consente di collocare nella posizione occupata dall’elemento i l’elemento di valore v. Si ha lo scorrimento in avanti degli elementi a partire da quello i considerato che va in posizione (i+1)esima, etc. L’istruzione remove(p) con p = đ?‘?đ?‘œđ?‘ đ?‘– cancella l’elemento corrispondente.

11. Insiemi In questo caso non è rilevante la posizione degli elementi nĂŠ sono ammesse duplicazioni (come in matematica!). Si rimanda alla terminologia propria dell’insiemistica matematica. Gli operatori che operano sugli insiemi (sets in inglese) sono semplici e di immediata interpretazione. Li riporto brevemente, senza commenti. integer size() // verifica la cardinalitĂ di un insieme finito, ovvero conta il numero degli elementi; bool containes(x) // restituisce TRUE se x ∈ X; remove(x) insert(x) // inserisce l’elemento x ove giĂ non presente in X; set union (setA, set B);


set intersection(set A, setB); set difference (setA, set B).

12. Dizionari (o mappe) Dizionario definisce la relazione univoca tra insiemi R: D → C. D è detto dominio, mentre C codominio. Gli elementi di D prendono il nome di chiavi e gli elementi di D quello di valori. Viene definita una relazione chiave-valore. lookup (item k) // restituisce in visione il valore associato alla chiave k (se dato). Si hanno anche i comandi insert (k, v) e remove (k). Questo ultimo rimuove l’associazione della chiave k. Si avrà modo di considerare in futuro altre strutture, quali alberi e grafi.

13. Pile e code Il concetto di pila attiene al collocamento di oggetti (anche in senso figurato) uno sopra all’altro. Classico esempio è quello delle pratiche burocratiche. In genere la prima ad essere tolta sarà quella in cima alla pila, con logica LIFO, last in first out.


Per le pile ho sintetizzato il tutto nel seguente schema logico ove ho ammesso che la pila abbia un contenuto massimo. 1. Assegnazione pila then go to 2.; 2. se la pila è piena vai a 3. altrimenti vai a 4.; 3. standby (costanza elementi) then go to 4.; 4. entra nuovo elemento? Se si vai a 5. altrimenti vai a 6.; 5. vai a 2.; 6. esce l’elemento in testa? Se si vai a 3. altrimenti vai a 2.;

Per le code vale la logica contraria. Uno può vedersi la cosa come uno shift da sinistra verso destra. Per questa situazione se si dovesse procedere con una indicizzazione decrescente da sinistra verso destra n, n-1, …, 1, 0. L’uscita dell’elemento primo della fila, cui corrisponde l’indice 0, rimodula per i soggetti il corrispondente indice. Il comando di aggiunta enqueue(elem x) aggiunge un elemento alla coda, che diviene l’ultimo elemento della coda. Il comando dequeue() → toglie dalla coda il primo elemento e lo restituisce, contrariamente al comando first() → elem che restituisce il primo elemento – first appunto – senza toglierlo dalla coda.


Mi sono posto il problema di dare una rappresentazione formale al caso di una coda che contiene un numero n di elementi tali che n(t) ≤ đ?‘›đ?‘šđ?‘Žđ?‘Ľ . Il modello logico corrispondente è il seguente. 1. entra coda(t=0); 2. se n(t) < đ?‘›đ?‘šđ?‘Žđ?‘Ľ go to 3. else go to 4.; 3. n(t+1) ≠n(t) go to 4. else go to 5.; 4. coda(t+1) then go to 2.; 5. coda(t+1)⇽ coda(t)then go to 2.;

Vorrei esemplificare ulteriormente considerando il caso di una coda nella quale ���� = 3.

La coda A – B – C ha C come primo elemento, B come secondo e A come terzo. Se đ?‘›đ?‘šđ?‘Žđ?‘Ľ = 3 la coda è piena. Lo step successivo sarĂ l’uscita di C dalla coda e la traslazione in avanti dei due elementi B ed A. La nuova coda sarà ⎕ - A – B. Gli indici di posizione nel caso dell’uscita sono definiti dalle transazioni seguenti 1 → ∞(= đ?‘˘đ?‘ đ?‘?đ?‘–đ?‘Ąđ?‘Ž đ?‘‘đ?‘Žđ?‘™đ?‘™đ?‘Ž đ?‘?đ?‘œđ?‘‘đ?‘Ž), n → n -1 (per n > 1) corrispondente alla traslazione in avanti. L’ingresso non modifica l’indice di posizione dei vari elementi presenti in coda. Queste semplici considerazioni sono espressione del principio fifo, ovvero first in first out.

14. Alberi Un albero è matematicamente definito come una coppia (N, A) di nodi e di coppie di nodi, dette archi. Si parte da una radice (elemento senza padre) avente dei figli.


L’arco (u,v) definisce la relazione padre-figlio tra u e v. Usando un linguaggio demografico la profonditĂ di un albero è il numero delle generazioni. Essa è anche detta altezza dell’albero. Un nodo privo di figli è detto foglia. Si avrĂ modo di definire le rappresentazioni di tipo puntatori collegate al alberi. Gli alberi possono essere oggetti di visite, dovendosi visitare tutti i nodi. La visita in profonditĂ si ha considerando i nodi aperti come elementi del tipo pila. Nella visita in ampiezza i nodi vengono visitati per livelli, partendo dalla radice.

14.1 Visite agli alberi La “visitaâ€? ad un albero può essere condotta secondo diverse modalitĂ . Questi distinti modi di visita sono detti “ordiniâ€?. Sia r la radice di un albero T. Sia đ?‘‡đ?‘˜ uno dei k sottoalberi radicati nel k figli di r. Gli alberi sono, come giĂ detto, costituiti da un insieme finito di elementi detti nodi. Tra le operazioni ammesse su di essi vi sono le visite all’albero, definite da operatori che consentono di muoversi tra i nodi. Il target di queste attività è l’esame di ogni nodo dell’albero una ed una sola volta. Sia dato un albero T (che sta per tree) di radice r, che come tale non ha padre. Se è k il numero dei figli vengono definiti k sottoinsiemi di T radicati nei k figli di T. Vengono definiti i concetti di previsita, invisita e postvisita. Nel caso della previsita si parte dalla radice.


Ho deciso di formalizzarla dal punto di vista matriciale nel senso che dato un albero ad esso è associata una matrice, peraltro costituita da un buon numero di zeri. Il primo indice è un indice di livello (generazionale). Si consideri per esempio un albero del tipo seguente costituito da una radice che ha tre figli, il primo dei quali ha due figli il secondo un solo figlio e il terzo due soli figli. La matrice rappresentativa è una matrice di tre righe (corrispondenti alla generazione) e di cinque colonne (numero massimo dei membri di una medesima generazione). Il simbolo ᆥ denota la condizione di non visita immediata tra due elementi contigui. Dalla terza riga detto simbolo separa i fratelli dai cugini. Gli interi posti tra parentesi tonde denotano l’ordine successivo di visita a partire dall’origine (radice). đ?‘Ž11 (1)

ᆥ

đ?‘Ž21 (2)

ᆥ �22 (5) ᆥ�23 (7) 0

0

0

0

0

0

�31 (3) �32 (4) ᆥ�33 (6)ᆥ�34 (8) �35 (9)

đ??źđ?‘› đ?‘?đ?‘&#x;đ?‘Žđ?‘Ąđ?‘–đ?‘?đ?‘Ž l’ordine di visita è definito dalla radice e quindi dai sottoalberi aventi come radice i figli della radice dell’albero dato. đ??żđ?‘Ž postvisita viene fatta al contrario muovendo da un elemento dell’ultima generazione e ripercorrendo i sottoalberi al contrario. Ciò fatto l’ultimo elemento visitato è la radice di tree dato. đ??śđ?‘œđ?‘› đ?‘–đ?‘™ đ?‘“đ?‘œđ?‘&#x;đ?‘šđ?‘Žđ?‘™đ?‘–đ?‘ đ?‘šđ?‘œ matriciale utilizzato per la previsita si ha il seguente ordine di percorrenza definito dai numero tra parentesi tonde. Visita a partire dall’origine (radice).

đ?‘Ž11 (9)

ᆥ

0

0

0

0


đ?‘Ž21 (3)

ᆥ �22 (5) ᆥ�23 (8) 0

0

�31 (1) �32 (2) ᆥ�33 (4)ᆥ�34 (6) �35 (7)

Va infine considerata l’invisita. In un albero di k figli si stabilisce un i < k e si effettuano le invisite dei primi i alberi (sottoalberi di quello dato), quindi si visita la radice e infine si eseguono le invisite ulteriori

Viene poi definito un albero detto binario, caratterizzato dalla circostanza che ogni nodo ha al piĂš (nella migliore delle ipotesi) due figli.

Due alberi aventi la stessa radice (priva di padre), gli stessi nodi e ogni noto ha gli stessi figli, sono distinti quando un nodo x è figlio sinistro del padre y in un caso e figlio destro del medesimo padre nel secondo caso.


15. Grafi Un grafo è definito da G = (V, E). V è un insieme di vertici, detti anche nodi. E è un insieme di coppie di vertici. Gli elementi di E sono detti archi. Detti archi possono essere orientati o meno, dando luogo ad archi orientati o meno. Essi definiscono la relazione tra elementi e ne definiscono la esistenza (presenza dell’arco) o l’assenza (quando tra due vertici non esiste un arco corrispondente). Matematicamente E ⊆ V X V. Se (x,y) ∊ E si dice che (x, y) è incidente sui vertici x e y. Se il grafo è orientato è possibile dire che l’arco esce da x ed entra in y. In un grafo orientato se (x,y) ∊ E allora (y, x) ∉ E, per ogni (x, y). Un grafo orientato cosĂŹ definito sarebbe un grafo non orientato. Tutti gli y* tali che (x, y*) ∊ E si dicono i vicini di x. Dato un vertice v se ne definisce il grado del vertice v come il numero degli archi incidenti su v, esso è denotato come δ(v). In caso di grafo orientato si introducono le due ulteriori nozioni di grado in ingresso e in uscita per v, con le notazioni đ?›żđ?‘– (v) e đ?›żđ?‘˘ (v), considerando separatamente gli archi entranti e quelli uscenti dal vertice v. I gradi sono dotati di una ulteriore proprietĂ detta connettivitĂ e a volte connettivitĂ forte. La connessione presuppone (n -1) archi, ove n è il numero dei vertici. Il concetto presuppone quello di cammino. Un cammino dai nodi x ed y è costituito da un numero intero di archi che per passaggio diretto (lunghezza unitaria) o per tappe intermedie portano per connessione da x a y.


La connessione forte si ha quando per ogni (x, y) ∊ E esiste sempre un cammino orientato. Dato un grafo possiamo definire da esso i sottografi. Dato il grafo G = (V, E) viene definito sottografo G’ = (V’, È) un oggetto per il quale V’ ⊆ V e È ⊆ E. G può essere considerato sottografo di se stesso.

15.1 Strutture rappresentative di grafi Occorre distinguere tra grafi non orientati e grafi orientati. Consideriamo in primis il caso del grafo non orientato. Sia dato G = (V, E). Dalla semplice ispezione del grafo (non orientato) è possibile costruire, sotto forma di colonna la cosiddetta lista degli archi. Essa è una lista minima nel senso che se (a, b) ∊ E allora (b, a) ∊ E. Per queste finalità le coppie (a, b) e (b, a) sono la medesima coppia. Viene poi definita la lista di adiacenza. La prima colonna contiene i vertici. Per ognuno di essi si considerano gli elementi direttamente collegati (in ordine alfabetico, generalmente). Viene associato quindi un numero d’ordine da 0 a k-1 per le coppie di E. Nella matrice delle liste di adiacenza di G si sostituiscono per ciascun vertice i numeri d’ordine (da o a k-1) degli archi corrispondenti, come previamente definiti. Ricordare sempre che le coppie non sono ordinate. Infine viene definita la matrice di adiacenza, come matrice di ordine n, ove n è il numero degli archi. Gli elementi della diagonale principale sono identicamente eguali a 0.


Gli elementi di essa sono eguali a 1 in corrispondenza di (x, y) ∊ E, oppure eguale a o per (x, y) ∉ E. Infine viene utilizzata la matrice di incidenza di un grafo G. Si tratta di una tabella a doppia entrata vertice – arco. Essa è immediatamente costituita da zeri e da uni, a seconda che per il dato vertice x ∊ V sia (x, y) ∊ E, al variare di y.

Per i grafi orientati bisogna avere qualche avvertenza in più e ricordare che (x, y ) ≠ (y, x). Nella matrice di incidenza oltre al valore 0 sono contenuti i valori ±1. La scrittura 1 in corrispondenza dell’elemento x e della coppia (x, y) denota un arco che da x entra in y, mentre la scrittura – 1 avrebbe definito un arco entrante in x da y. Se (x,y) ∉ E allora si colloca il valore 0. Non si considerano ipotesi per le quali se esiste un arco entrante da x in y ne possa esistere uno che da y entra in x, che doterebbe la matrice di valori ±1 in corrispondenza al caso considerato.


Non ho rinvenuto una matrice dei cammini. Essa dovrebbe definire l’esistenza di un cammino (almeno) tra i vertici x e y. Dovrò pensare se essa possa avere qualche utilitĂ . Ăˆ bene riportare il concetto di connessione forte di grafi orientati che si realizza quando per ogni coppia di nodi distinti esiste almeno un cammino che li collega. Il cammino deve sussistere nei due sensi da u a v e viceversa. Se non esiste un cammino da un nodo qualunque ad un altro nodo distinto da esso allora il grafo è non connesso strettamente. Relativamente ai grafi non orientati viene definita la connessione, che fa riferimento al fatto che per ogni coppia di nodi esiste un percorso che li collega. Relativamente ai grafi non orientati l’esistenza di una coppia di nodi (a, b) per i quali non sia possibile un percorso da a a b consente di definire detto grafo quale non connesso. Dato un grafo orientato e fortemente connesso viene definita la componente fortemente connessa di G se G’⊆ G se non esiste un G’’⊆ G tale che G’’ sia fortemente connesso e sia G’ ⊆ G’’. Analogamente si definisce la componente connessa di un grafo non orientato. Ăˆ ora utile definire due ulteriori concetti. Dato un grafo orientato viene definito cammino ogni sequenza di nodi collegati da archi orientati. Si ha sempre un nodo di partenza, un nodo di arrivo e un numero discreto di nodi intemedi. Detta somma definisce la lunghezza del cammino. I cammini si distinguono in semplici quando partendo da un nodo non si giunge nuovamente ad esso, e in chiusi quando il cammino, partendo da un nodo riporta – dopo k step – al nodo di partenza. Quando đ?‘˘0 = đ?‘˘đ?‘˜ e đ?‘˘đ?‘– ≠đ?‘˘đ?‘— ∀ (i, j ) ≠(0, k) il cammino è detto ciclo.


Per i grafi non connessi vengono utilizzati i concetti corrispondenti di catena e circuito.

15.2 Albero libero e albero di copertura È dato un grafo non orientato connesso per il quale per ogni coppia di nodi esiste una ed una sola catena semplice. Detto grafo è detto albero libero. Condizioni di esistenza di un libero sono: 1) la connessione; 2) minimalità del numero degli archi (eguale al numero dei nodi – 1); 2’) inesistenza di circuiti. Viene poi definito il cosiddetto albero radicato. Un nodo di un albero libero viene designato arbitrariamente come radice e i nodi ulteriori vengono ordinati nei livelli. Un livello contiene tutti e soli gli elementi (nodi) di pari distanza dalla radice. Un albero libero di n nodi consente di generare n alberi radicati. Un affinamento ulteriore si ha introducendo il cosiddetto albero ordinato, ottenuto a partire da un albero radicato, quando per ogni livello è introdotto un criterio che consente di stabilire una relazione d’ordine di precedenza tra elementi distinti dello stesso livello. Dato un grafo connesso G = (V, E) viene definito un corrispondete albero di copertura. Esso è un albero libero T = (V, È) con È ⊆ E sotto la condizione che ogni coppia di nodi – ovvero ogni elemento di È – sia connessa da una ed una sola catena. Una foresta è un grafo non connesso i cui elementi sono alberi. Per i grafi orientati dato un grafo G viene definito il grafo trasposto.


Esso si indica con il formalismo đ??ş đ?‘‡ ed è costituito da tutti e soli i nodi di G e dagli archi aventi versi invertiti rispetto a quelli dati per G.

15.3 Operazioni sui grafi L’istruzione creativa di un grafo vuoto è: Graph(). Per aggiungere nodi e archi si usano i costrutti seguenti: insertNode(Node u); insertEdge(node u, node v). Le

istruzioni

di

cancellazione

(o

rimozione)

di

archi

e

nodi

sono

rispettivamente: deleteEdge(Node u, Node v); deleteNode(Node u). A volte può essere utile conoscere l’insieme dei nodi adiacenti ad un dato nodo. All’uopo si utilizza la seguente istruzione: SET adj(Node u). Per evidenziare tutti i nodi si utilizza l’istruzione seguente: SET V().

15.4 Visite ai grafi La visita di un grafo presuppone che ogni vertice e ogni arco venga “visitatoâ€? almeno una volta. Una prima tipologia di visita è detta BFS, acronimo di Breandth-First-Search. In questo caso di visita, detta in ampiezza, i nodi sono visitati a partire da un nodo arbitrario detto radice. Ad esempio sia dato un grafo i cui vertici sono 1, 2, 3, e 4 (vertici di un ideale quadrato).


La visita è correttamente definita dalla sequenza di nodi e archi seguente, quando si ammetta che il vertice 1 sia la radice, ovvero r = 1. 1, (1,2), (1,4), 2, (2, 1), (2,3), 3, (3, 2), (3, 4), 4, (4,3), (4,1)

Esiste anche un secondo metodo di visita, detto DFS, acronimo di DepthFirst-Search, ovvero la visita in profondità, nota anche come scandaglio. Con riferimento ai lati 1, 2, 3, 4 di un quadrato considerati essi in senso orario si ha la seguente rapresentazione: 1, (1,2), 2, (2,1) (2, 3), 3, (3, 2), (3, 4), 4, (4, 3), (4, 1).

16. Liste con puntatori

Viene memorizzata una lista (list) di n elementi in n record, detti celle, tali che l’i-esimo record contenga l’i-esimo elemento della lista e l’indirizzo (puntatore)

della

cella

contenente

l’elemento

successivo

(monodimensionalità), come in figura. È ammesso il caso della bidirezionalità. In questo caso ogni record contiene l’elemento ma anche gli indirizzi delle celle precedente e successiva secondo la schematizzazione seguente: ⟦ind. prec - ⎕ - ind. succ⟧


17. Cammini minimi Ad ogni arco di un grafo orientato è assegnato un numero w detto costo, o peso. Per un cammino il costo è la somma dei costi riferiti agli archi del cammino. La problematica consiste nel minimizzare il costo per un cammino nel senso che per andare da un nodo x ad un nodo y sono possibili solitamente piĂš cammini. Occorre trovare una funzione di costo w: E → R per la quale sia w(x, y) = min, ovvero sia đ?‘¤đ?‘?đ?‘Žđ?‘š đ?‘– ≤đ?‘¤đ?‘?đ?‘Žđ?‘š đ?‘— ∀ j ≠i con i ≤ n e j ≤ n. n indica il numero dei possibili cammini dal nodo x al nodo y. Il cammino minimo non attiene tanto al numero di nodi attraversati quanto piuttosto ai pesi assegnati ai singoli passaggi. Paradossalmente può essere piĂš “costosoâ€? passare da x a y tramite un unico nodo distinto z, piuttosto che attraverso un numero discreto di nodi intermedi. Si può pensare al traffico. Nelle ore centrali una strada breve può essere percorsa in tempi maggiori rispetto ad una piĂš lunga ma priva di traffico. Questo esempio, che illustra il noto algoritmo di Dijkstra, ben chiarisce i termini della questione nella determinazione del cammino ottimo.


Si deve ricordare che esistono pure altri algoritmi quali quelli di Johnson e di Bellmann-Ford-Moore.

18. Code con priorità L’esempio tipico di coda con priorità (priority queue) è la coda con la corrispondente codifica di gravità della patologia che si realizza nel pronto soccorso. Ad ogni elemento è associata una priorità, definita da un parametro numerico (codice)in relazione al quale è sempre ammissibile un confronto – definito dalla relazione ≤, valido per ogni coppia di elementi dell’aggregato. Sono ammesse code a priorità crescente (a valori più alti) e code a priorità decrescente (a valori più bassi). Le code con priorità decrescente sono usualmente utilizzate per i cammini minimi. Operativamente si procede con la sequenza coda con priorità → albero B → vettore HEAP. L’albero B deve godere di particolari proprietà. Esse sostanzialmente sono: 1)

livello massimo delle foglie eguale ad h;

2)

il numero dei nodi di livello minore di h è 2ℎ - 1;

3)

tutte le foglie di livello h sono a sinistra;

4)

ogni nodo diverso dalla radice contiene un elemento della coda con

priorità non minore rispetto a quella del padre.

19. Unione di insiemi disgiunti Occorre ora fare qualche cenno generale alla merge/union find set. Non sono definite operazioni di inserimento e di cancellazione.


X ⊆ S and Y ⊆ S ove S è un insieme detto generativo. X âˆŞ Y è un nuovo insieme. MFSET è una istruzione di inizializzazione che crea n componenti. Sia dato un insieme A = {1, 2, 3,4} l’applicazione di detta istruzione conduce a S = {{1, 2, 3, 4}} . Ciò è vero in quanto ogni qual volta find(h) ≠find(k) è possibile eseguire merge(h,k).

20. ComplessitĂ Ăˆbene partire dal concetto di tempo di calcolo, solitamente considerando solo le operazioni che incidono maggiormente sui tempi di calcolo, nel caso pessimo oppure in quello medio in termini probabilistici. In ambo i casi si conta il numero di operazioni. Per tempo ammortizzato si intende il tempo richiesto, nel caso pessimo, per eseguire m operazioni e si formalizza come P(m)/m Viene quindi definita la complessitĂ computazionale asintotica, T(n) al tendere di n all’infinito, non considerando le costanti moltiplicative e additive. Data una funzione f(n) con la notazione O(f(n)) si definisce un insieme di funzioni đ?‘”đ?‘– (n) che verificano la condizione đ?‘”đ?‘– (n)≤ c*f(n) per due costanti positive c ed m âŠ? m : m ≤ n; Si dice che đ?‘”đ?‘– (n)è di ordine omicron di f(n). Data una funzione f(n) con la notazione Ί(f(n)) si si definisce un insieme di funzioni đ?‘”đ?‘– (n) che verificano la condizione đ?‘”đ?‘– (n) ⊞ c*f(n) per due costanti positive c ed m âŠ? m : m ≤ n; Si dice che đ?‘”đ?‘– (n)è di ordine omega di f(n). Quindi viene definito l’ordine theta.


Data una funzione f(n) con la notazione Θ(f(n)) si si definisce un insieme di funzioni đ?‘”đ?‘– (n) che verificano le condizione c*f(n)≤ đ?‘”đ?‘– (n) ≤ d*f(n) per tre costanti positive c, d ed m âŠ? m : m ≤ n. Le proprietĂ seguenti sono molto utili.

ProprietĂ riflessiva. ∀ c ∊ R, ∀ f(n) si ha 1) c*f(n) ∊ O(f(n)); 2) c*f(n) ∊ Ί(f(n)); 3) c*f(n) ∊ Θ(f(n)).

ProprietĂ transitiva g(n) ∊ O(f(n)) e f(n) ∊ O(h(n)) ⇒ g(n) ∊ 0(h(n); g(n) ∊ Ί(f(n)) e f(n) ∊ Ί(h(n)) ⇒ g(n) ∊ Ί(h(n); g(n) ∊ Θ(f(n)) e f(n) ∊ Θ(h(n)) ⇒ g(n) ∊ Θ(h(n). ProprietĂ di simmetria g(n) ∊ Θ(f(n)) ⇔ f(n) ∊ Θ(g(n))

ProprietĂ di simmetria trasposta g(n) ∊ O(f(n) ⇔ f(n) ∊ Ί(g(n)) Infine le proprietĂ di somma e prodotto che valgono sia per O che per Ί e per Θ. Ad esempio si ha ⌋f(n) + g(n)âŚŒ ∊ O(max ⌋f(n), g(n)âŚŒ) g(n) ∊ O(f(x) e h(n) ∊ O(f(x) ⇒ g(n)*f(n) ∊ O(f(n)*g(n))


Opera la ben nota relazione tra ordini di grandezza per la quale Θ(1), Θ(log n), ‌‌., Θ(đ?‘›đ?‘› )

Le proprietĂ riflessiva, transitiva e simmetrica definiscono una relazione di equivalenza, detta classe di complessitĂ .


Bibliografia essenziale

Queste note non sono sostitutive dei pregevoli testi in circolazione. Vorrei in particolare citare i seguenti.

1)

Bertossi,

Montresor,

Algoritmi

e

strutture

dati,

II

edizione,

CittĂ StudiEdizioni, 2010;

2)

Camagni, Algoritmi e basi della programmazione, Hoepli Informatica, 2006;

3)

Cerri, Ortolani, Venturi, Corso di sistemi automatici, Vol. 1, Hoepli, 2012

4)

Demetrescu, Finocchi, Italiano, Algoritmi e strutture dati, II edizione, McGraw-Hill, 2008, 2004

5)

Rota, Manuale di C++, Hoepli informatica, 2009;


Proprietà letteraria e intellettuale

Nell’elaborare il presente documento ho inevitabilmente attinto a fonti. Esse sono indicate nel testo, di volta in volta. Per quanto attiene alle “figure” – utilissimo supporto – queste sono state estratte da Internet nella presunzione che quanti le hanno collocate ne avessero titolo. In questo caso non mi è stato possibile citare la fonte. Preciso che questo elaborato non ha fini di lucro. È consentita la diffusione, anche totale, dell’elaborato purché essa avvenga senza finalità lucrative e commerciali a condizione che venga citata la fonte, con l’indicazione dell’autore e del soggetto diffusore dell’opera.


pubblicazione a cura di Pascal McLee

2015 Š mclee consulting | web solutions. all rights reserved. web www.pascalmclee.com - mail pas.meli@gmail.com - mob. +39 335 6856486


Issuu converts static files into: digital portfolios, online yearbooks, online catalogs, digital photo albums and more. Sign up and create your flipbook.