La ADT List
Nel linguaggio informatico una lista è una struttura dati che permette l’accesso agli
elementi spostandosi tra elementi adiacenti (successivo e precedente). La lista assomiglia ad una
sequenza di oggetti non numerati per cui l’unico modo per accedere all’elemento numero i
è
partire dall’inizio e contarne uno dopo l’altro fino a raggiungere l’i
-esimo. Un esempio
concreto può essere un mazzo di carte: per sapere, ad esempio, la decima carta, dobbiamo
partire dall’inizio (che va opportunamente definito) scartare le prime 9 carte e quindi
ottenere la decima.
A differenza del mazzo di carte, tuttavia, la lista non permette di scegliere una “carta”
(elemento della lista) a caso, bensì permette solo l’accesso diretto ad elementi ben definiti
quali l’inizio (testa, head) o la fine (coda o tail) della lista.
Alcune operazioni sono estremamente semplici da eseguire su una lista:
- l’inserimento e l’eliminazione in testa o in coda;
- lo split (chiamato anche splice) della lista su un elemento ben preciso.
Alcune operazioni sono semplici a patto che sia disponibile un elemento (posizione)
ben specifico:
- eliminazione dell’elemento;
- inserimento prima o dopo l’elemento;
- split rispetto all’elemento.
Infine ci sono operazioni che richiedono diverse istruzioni su una lista:
- accesso all’elemento di indice
i
(specialmente se i
non è “vicino” all’inizio o alla fine); - ricerca di un elemento.
Osserva
Per comodità utilizzeremo un modo semplice di indicare le liste utilizzando delle frecce
->
. La lista che contiene i numeri (ordinati) da 1
a 5
, sarà indicata nel seguente
modo
1 -> 2 -> 3 -> 4 -> 5
Un altro esempio può essere la lista
2 -> 4 -> 0 -> 2 -> 3 -> 3
nella quale si nota come vi possano essere elementi con lo stesso contenuto (stesso numero
in questo caso).
Il concetto di posizione nella lista
Come visto sopra, le operazioni su una lista possono essere basate su uno specifico
elemento della lista, parlare di elementi è troppo generico e rischio di confondersi
con il contenuto, per questo è meglio parlare di posizione nella lista.
Definizione: Posizione e Valore
Una posizione in una lista è la rappresentazione di un “posto” all’interno del
quale possiamo inserire un valore (o contenuto). Ad una posizione nelle liste,
inoltre, si associa anche una posizione precedente (predecessor) e una
posizione successiva (successor).
Esempi di posizione sono
- prima,
- ultima,
- penultima,
- la prima posizione con valore
123
, - la precedente alla prima posizione con valore
123
, - la prima posizione dalla fine con valore
123
, - …
Cerchiamo di capire meglio il concetto di posizione con qualche esempio.
Esempio
Consideriamo la seguente lista
10 -> -2 -> 120 -> 0 -> 123 -> 0 -> 11
- valore della prima posizione:
10
- valore dell’ultima posizione:
11
- valore della penultima posizione:
0
Nelle liste come negli array, le posizioni possono anche essere associate a degli
indici, ad esempio la prima posizione ha indice 0
, la seconda ha indice 1
e
così via. In effetti gli array sono strutture dati in cui le posizioni vengono
indicate da indici.
Attenzione
L’uso del termine posizione può generare confusione soprattutto le prime volte che
si lavora con le liste. Bisogna sempre tenere a mente che nelle liste per posizione
non si intende un indice, ma un segnaposto che contiene un valore (value o
content), un successore e, nelle liste doppiamente concatenate, un predecessore.
Inoltre, bisogna stare attenti a non confondere la posizione con il contenuto, ad
esempio in una lista di interi il numero 12
può comparire più di una volta, ma
in posizioni diverse, inoltre 12
in questo caso non è necessariamente l’indice.
Posizione in Java
Il concetto di posizione nella lista sopra definito può essere espresso in una
interfaccia Java nel seguente modo
public interface IListPosition {
Object value();
IListPosition next();
public void setNext(IListPosition newNext);
IListPosition prev();
public void setPrev(IListPosition newPrev);
}
Si noti che:
- il valore (value) è di tipo
Object
per poter indicare qualsiasi tipo, - ogni posizione permette di accedere al successore mediante
next()
ed
al predecessore mediante prev()
, - i metodi
prev
e next
restituiscono a loro volta una posizione.
Esercizio
Realizzare una classe Java ListPosition
che implementa l’interfaccia IListPosition
definita sopra. Prevedere oltre ai metodi dell’interfaccia anche dei metodi per
impostare: valore, predecessore e successore.
Liste singolarmente concatenate
Una lista singolarmente concatenata è una lista in cui ogni posizione è collegata
alla successiva, ma non alla precedente.
Nello schema a fianco, ogni posizione è rappresentata da un oggetto con due parti:
un content per memorizzare il valore ed un next per collegare una posizione alla
successiva.
Per segnalare la “fine della lista” (una posizione senza una posizione
seguente), è sufficiente impostare il valore di next a null
. L’inizio della
lista, invece, viene individuato mediante un riferimento al primo elemento che
spesso chiamiamo head (o first).
Liste doppiamente concatenate
Una lista doppiamente concatenata è una lista in cui ogni posizione è collegata
sia alla successiva sia alla precedente.
Nello schema a fianco, ogni posizione è rappresentata da un oggetto con tre parti:
un content per memorizzare il valore, un next per collegare una posizione alla
successiva, ed un prev per collegare una posizione alla precedente.
Per segnalare la “fine della lista” (una posizione senza una posizione
seguente), è sufficiente impostare il valore di next a null
. L’inizio della
lista, invece, viene individuato mediante un riferimento al primo elemento che
spesso chiamiamo head (o first). Nel caso di liste doppiamente concatenate
abbiamo anche la necessità di segnalare che il primo elemento non ha alcun
predecessore, anche in questo caso si può usare una riferimento impostate al valore
null
.
Attenzione
La presenza di head può sembrare superflua, infatti la prima posizione della
lista si riconosce dal fatto che prev è null
e l’ultimo dal fatto che
next è null
. La necessità del riferimento head deriva dal problema di
tenere un “punto di accesso” alla lista. In pratica head è un riferimento
che ci permette di “accedere” alla lista.
Osserva: Lista vuota
Ci si può chiedere come indicare che una lista è vuota, cioè non contiene
elementi. Il modo semplice (valido per liste singolarmente e doppiamente
concatenate) è avere il riferimento head impostato a null
.
Operazioni su liste
La struttura dati lista può, ovviamente, essere utilizzata per tutte le usuali
operazioni di accesso e manipolazione dei dati.
Inoltre le liste, a differenza degli array, permettono di eseguire alcune operazione, come
il merge e lo split (chiamato splicing), in maniera molto efficiente.
In questa lezione vedremo il codice Java per alcune
operazioni utilizzando l’interfaccia IListPosition
discussa sopra.
Per rappresentare una lista useremo una semplice classe contenente:
- un riferimento, chiamato
head
, che punti alla testa della lista; - un intero
n
che conta gli elementi attualmente memorizzati nella
lista; - un riferimento, chiamato
tail
, che punti alla coda della lista, solo nel
caso di liste doppiamente concatenate.
// SinglyLinkedList.java
public class SinglyLinkedList {
private IListPosition head;
private int n;
public SinglyLinkedList() {
head = null;
n = 0;
}
}
// DoublyLinkedList.java
public class DoublyLinkedList {
private IListPosition head;
private IListPosition tail;
private int n;
public DoublyLinkedList() {
head = tail = null;
n = 0;
}
}
Nel codice sotto, inoltre, faremo uso della classe concreta ListPosition
che implementa
l’interfaccia IListPosition
e che prevede il seguente costruttore per inizializzare
tutti i tre parametri content
, next
e prev
(per evitare confusione con i nomi dei
metodi i campi per next
e prev
sono chiamati predecessor
e successor
, rispettivamente).
public class ListPosition implements IListPosition {
private Object content;
private IListPosition successor;
private IListPosition predecessor;
public ListPosition(Object c, IListPosition n, IListPosition p) {
content = c;
successor = n;
predecessor = p;
}
// Implementazione (ovvia) di IListPosition
// ...
}
Inserimento e cancellazione
Vediamo quattro tipi di operazione di inserimento/cancellazione
- inserimento/cancellazione in testa
- inserimento/cancellazione in coda (solo doubly linked)
- inserimento/cancellazione dopo una data posizione
- inserimento/cancellazione prima di una data posizione (solo doubly linked)
Inserimento in testa
L’inserimento in testa è possibile sia per le singly che per le doubly linked list,
tuttavia bisogna fare attenzione al caso di liste vuote (e doppia attenzione nel caso
di doubly linked list).
public void insertAtHead(Object o) {
// crea IListPosition per l'oggetto da inserire
// Il suo 'next' sarà quello che è adesso in testa
// Funziona anche se la lista è vuota, perché?
ListPosition newPosition = new ListPosition(o, head, null);
// Inserisco la 'posizione' appena creata come nuova testa
head = newPosition;
// aggiorno il conteggio degli elementi
n++;
}
Lo stesso metodo nelle doubly linked list va modificato per tenere conto:
- del riferimento alla coda
tail
(solo se la lista è vuota prima dell’inserimento) e - del puntatore
prev
della vecchia testa (se la lista non è vuota).
public void insertAtHead(Object o) {
ListPosition newPosition = new ListPosition(o, head, null);
head = newPosition;
if(tail == null) { // la lista era vuota (tail -> null)
tail = newPosition;
} else { // esisteva una head e quindi aggiorno il suo prev
newPosition.next().setPrev(newPosition); // !!!
}
n++;
}
La seguente riga
newPosition.next().setPrev(newPosition);
non fa altro che accedere alla vecchia testa (che adesso è il successore della nuova testa)
e modificare il suo prev
in modo da puntare alla nuova testa.
Inserimento in coda
Esercizio
L’inserimento in coda per una doubly linked list è la versione “speculare” dell’inserimento
in testa sempre per la doubly linked list. Realizzare il codice Java è un buon esercizio per
comprendere meglio quanto discusso sopra sull’inserimento in testa.
Esercizio
Non avendo un riferimento diretto alla coda della lista, la singly linked list non permette
l’inserimento allo stesso modo della doubly linked list. Tuttavia, è possibile comunque
fare l’inserimento in coda ottenendo prima il riferimento all’ultimo elemento della lista (il
riferimento tail
se ci fosse) e poi inserendo successivamente a questo. Realizzare il codice
Java per l’inserimento in coda in una lista singolarmente concatenata.
Inserimento dopo
Un’altra possibilità per l’inserimento in una lista è indicare la posizione del
nuovo elemento rispetto ad un elemento già esistente. Il caso più semplice è
l’inserimento dopo la posizione pos
.
public void insertAfter(Object o, IListPosition pos) {
IListPosition newPos = new ListPosition(o, pos.next(), null);
pos.setNext(newPos);
n++;
}
Esercizio
Come nel caso di insertAtHead
il caso di doubly linked list richiede un po'
più di attenzione data la presenza dei riferimenti prev
e del riferimento tail
alla coda della lista. Realizzare il metodo
public void insertAfter(Object o, IListPosition pos);
in una doubly linked list.
Inserimento prima
Data una posizione all’interno della lista, si può immaginare all’inserimento di
un nuovo elemento nella posizione precedente. Nel caso si liste singolarmente
concatenate questa operazione è lievemente più complessa di insertAfter
poiché
manca il riferimento prev
che indica la posizione precedente (è comunque
possibile tale operazione , vedi esercizio sotto).
Esercizio
Realizzare il codice Java per l’inserimento in una doubly linked list di un nuovo
elemento che sia nella posizione precedente ad un elemento dato. La firma del metodo
da implementare è la seguente
public void insertBefore(Object o, IListPosition pos);
Esercizio
L’inserimento prima di una posizione data può essere fatto anche in una singly linked
list, tuttavia questa operazione richiede di individuare la posizione all’interno della
lista ed anche la posizione che precede quest’ultima. Realizzare il metodo
public void insertBefore(Object o, IListPosition pos);
nella classe SinglyLinkedList
.
Suggerimento Iniziare cercando una posizione p
tale che (p == pos
), durante
questa ricerca tenere traccia anche della posizione che precede p
(attenzione che
p
potrebbe anche essere head
).
Importante
Le operazione insertBefore
e removeBefore
possono essere realizzate anche in singly
linked list, ma questo risulta più complicato rispetto ad insertAfter
e removeAfter
.
Per questo motivo tali operazioni sono raramente implementate in una singly linked
list. Nel caso la struttura utilizzata richiedesse frequenti utilizzo di insertBefore
e/o removeBefore
è consigliabile usare una doubly linked list.
Cancellazione in testa
Dagli esempi sopra si può vedere come l’inserimento sia un operazione “semplice”
nelle liste, anche la cancellazione, come vederemo, richiede poche istruzioni.
Iniziamo vedendo la cancellazione dell’elemento che si trova nelle testa della
lista. In questo caso per cancellazione si intende che la posizione viene rimossa
dalla lista e che l’elemento che seguiva quello rimosso, diventa la nuova testa
della lista.
Il codice Java qui sotto mostra la rimozione in testa per una singly linked
list.
public Object removeAtHead() {
if (head == null) {
return null; // Rimozione da una lista vuota, errore?
}
IListPosition removed = head;
head = head.next();
n--;
return removed.value();
}
Si noti che il codice restituisce anche il valore dell’elemento eliminato,
senza questa restituzione il contenuto verrebbe perso a meno che non fosse
stato precedentemente memorizzato.
Nel caso di doubly linked list è necessario aggiustare anche i riferimenti
prev
ed eventualmente la coda.
Esercizio
Realizzare il metodo java
public Object removeAtHead();
in una lista doppiamente concatenata.
Ricerca
Come già visto negli array, anche nelle liste è possibile cercare uno specifico
valore all’interno di una lista. A differenza degli array in cui l’accesso avviene
mediante indicizzazione (cioè con la posizione), nelle liste l’accesso è sequenziale,
utilizzando i riferimenti next
e prev
.
Per cercare all’interno di un lista, si usa il concetto di iteratore che rappresenta
un “puntatore” all’elemento attuale della lista, durante la ricerca l’iteratore viene
spostato in avanti ad ogni ciclo, fino a raggiungere la fine della lista.
public IListPosition search(Object o) {
IListPosition iterator = head;
while(iterator != null) {
if(iterator.value().equals(o)) {
return iterator;
}
iterator = iterator.next();
}
return null;
}
Attenzione
Nel codice sopra il confronto tra l’elemento cercato o
e il valore di una posizione
avviene con il metodo equals
. Questo perché in Java (come in molti altri linguaggi),
l’operatore di uguaglianza ==
interroga l’uguaglianza dei riferimenti e non del loro
contenuto.
Splicing
Una operazione molto semplice da eseguire su una lista è la sua divisione in due
o più sottoliste. Questa operazione viene definita splicing (o split) della
lista, ovviamente per effettuare lo splicing dobbiamo sapere in quale posizione
dovrà avvenire lo spezzamento.
public SinglyLinkedList splice(IListPosition splicePosition) {
SinglyLinkedList tailList = new SinglyLinkedList();
tailList.head = splicePosition.next();
splicePosition.setNext(null);
IListPosition iterator = tailList.head;
while(iterator != null) {
tailList.n++;
iterator = iterator.next();
}
n -= tailList.n;
return tailList;
}
Merge
Oltre a spezzare una lista, è possibile anche unire due o più liste utilizzando
l’operazione di merge (fusione) di due liste.
Il seguente codice Java aggiunge alla fine della lista corrente una lista other
restituendo la nuova lista.
public SinglyLinkedList merge(SinglyLinkedList other) {
// current list is empty
if (head == null) {
head = other.head;
n = other.n;
return this;
}
// Finds the tail of the current list
IListPosition last = head;
while(last.next() != null) {
last = last.next();
}
last.setNext(other.head);
n+= other.n;
return this;
}
Esercizi su liste
Ricerca e indicizzazione
Esercizio: Elemento di indice
Anche se le liste sono strutture dati che non prevedono in modo naturale
l’indicizzazione (0,1,...
) dei propri elementi, è possibile (a volte utile)
scrivere un metodo che acceda all’elemento di indice \(i\) della lista.
L’indice rappresenta (solitamente partendo da \(0\)) l’ordine all’interno
della lista a partire dalla testa.
Scrivere un metodo
public IListPosition positionAtIndex(int i);
che restituisce la posizione che si trova nella posizione i
della lista.
Inserimento, Merge e split
Esercizio: Merge in posizione
Scrivere un metodo Java che inserisce un’intera lista all’interno di
un’altra, a partire da una posizione data.
public void insertAllAt(SinglyLinkedList other, IListPosition pos);
Se la lista di partenza è
1 -> 2 -> 5 ->
e la lista other
è
3 -> 4 ->
la chiamata a insertAllAt
con la posizione contenente 2
cambierà la lista
originale in
1 -> 2 -> 3 -> 4 -> 5 ->