Els vectors, com que tenen els elements consecutius a memòria, tenen problemes a l'hora d'inserir i esborrar, perquè han de moure tots els elements. Això implica que aquestes operacions són en vectors, és a dir: amb un cost proporcional a la mida del vector.
En aquest tema introduïm un nou contenidor, la llista, la característica principal del qual és que permet insercions a qualsevol punt amb un cost , és a dir, independent de la mida.
Una llista és un contenidor que permet inserir i esborrar elements en qualsevol posició de manera ràpida. Per aconseguir-ho, està formada per nodes (com els anells d'una cadena) enllaçats entre si (simple o doblement).
Les llistes NO tenen índexs, perquè no hi ha manera de calcular la posició d'un element a partir del primer (com en el cas dels vectors). Només es pot accedir als elements mitjançant iteradors.
Inserció i esborrat: Degut a l'estructura interna, inserir o esborrar un element en una llista només requereix canviar 4 punters (4 adreces de memòria), així que la inserció i l'esborrat són , és a dir, independents de la mida de la llista.
Recorregut: Però el preu a pagar és que, com que els elements no són consecutius a memòria, un recorregut és molt més lent (en dos ordres de magnitud!). Això és degut a la velocitat de la jerarquia de memòria, als nivells de cache i al fet que accedir a posicions seqüencialment accelera l'accés. (És interessant entendre els temps d'accés de diverses operacions.)
Accés aleatori: En un cas encara pitjor, per accedir a una posició arbitrària d'un vector, aplicant una proporcionalitat, podem calcular a quina posició de memòria està. Però en una llista, per accedir a la posició -èsima, necessitem visitar totes les anteriors, ja que és una cadena de punters impossible de preveure.
| Contenidor | Accés aleatori | Inserció | Recorregut |
|---|---|---|---|
vector<T> | 1x | ||
list<T> | 100x-200x |
insert i eraseEls mètodes de les llistes són equivalents als dels vectors,
incloent alguns mètodes nous que s'apliquen a tots dos: insert
i erase.
| Mètode | Descripció |
|---|---|
push_back(x) | Afegeix x al final. |
push_front(x) | Afegeix x al principi. |
pop_back() | Elimina l'últim element. |
pop_front() | Elimina el primer element. |
it = insert(it, x) | Insereix x a la posició it. |
it = erase(it) | Elimina l'element a la posició it. |
front() | Referència al primer element. |
back() | Referència a l'últim element. |
insert#include <list>
#include <iostream>
using namespace std;
void sorted_insert(list<int>& L, int x) {
list<int>::iterator it = L.begin();
while (it != L.end() and *it < x) {
++it;
}
L.insert(it, x); // cost O(1)
}
int main() {
list<int> L;
int x;
while (cin >> x) {
sorted_insert(L, x);
}
for (int x : L) {
cout << x << endl;
}
}
Un iterador és un objecte que representa una posició en un
contenidor. És com una fletxa que marca una posició, sense
afectar el contenidor. Els iteradors permeten tenir "marcadors" a
posicions d'una llista, per exemple, i indicar on volem fer
certes operacions. També permeten indicar intervals d'elements a
recórrer (com en el cas de sort).
begin() apunta al primer element i end() al sentinella finalEls iteradors tenen un tipus específic per a cada contenidor, per exemple els següents són dos iteradors, un per un vector i un altre per una llista:
vector<int>::iterator vit = my_vector.begin();
list<int>::iterator lit = my_list.begin();
El tipus d'un iterador realment "pertany a" (per això els ::)
la classe del contenidor. La raó és poder posar el mateix nom
iterator a tots els iteradors, però que se sàpiga de quina
classe són. Si diguéssim que tenim una classe desconeguda de
contenidor C, llavors C::iterator seria un iterador als
elements d'aquesta classe.
Donat un contenidor, es poden obtenir iteradors al principi i al
final de la seqüència d'elements. Però s'utilitzen intervals
asimètrics, on el primer element està dins el contenidor i
l'últim a fora. És a dir, begin() és un iterador al primer
element del vector, però end() és un punter a un element
fictici passat l'últim element. Es pot dir, doncs, que
end() és com un centinella.
| Mètode | Descripció |
|---|---|
begin() | Retorna un iterador al primer element. |
end() | Retorna un iterador al centinella. |
Un iterador es pot assignar (=), incrementar (++), comparar
(==, !=) i desreferenciar (*), que és accedir a l'element
d'aquella posició. Amb aquestes poques operacions es poden fer
recorreguts a una llista o un vector pràcticament amb el mateix
codi.
Com que una llista no té índexs, l'única manera de manipular-ne els elements és a través d'iteradors:
list<int> L = {1, 2, 3, 4, 5};
list<int>::iterator it = L.begin(); // it apunta al 1r element
it++; // 2n element
it++; // 3r element
while (it != L.end()) {
*it += 10; // sumem 10 a l'actual
it++;
}
// L = {1, 2, 13, 14, 15}
advance permet moure un iterador diverses posicions d'un sol copEn llistes, com que no hi ha índexs, avançar elements
requeriria un bucle, i per això la STL inclou la funció
advance(it, n) que fa avançar un iterador it en n
posicions.
list<int> L {1, 2, 3, 4, 5};
list<int>::iterator it = L.begin();
advance(it, 3); // for (int i = 0; i < 3; i++) it++;
*it *= 10;
// L = {1, 2, 3, 40, 5}
```
<div id="iteracions" />
### Amb iteradors podem fer recorreguts ascendents, constants i descendents
Donada una llista com
```c++
list<int> L = {1, 2, 3, 4, 5};
Un recorregut per tots els seus elements en ordre ascendent seria:
for (list<int>::iterator it = L.begin(); it != L.end(); it++) {
cout << *it << endl;
}
Un recorregut per tots els elements d'una llista const seria:
for (list<int>::iterator it = L.begin(); it != L.end(); it++) {
cout << *it << endl;
}
Un recorregut en ordre descendent seria:
for (list<int>::reverse_iterator rit = L.rbegin(); rit != L.rend(); rit++) {
cout << *rit << endl;
}
(Interessant l'ús d'una r al nom de l'iterador, igual que amb
els mètodes, per distingir-los i que no hi hagi confusió.)
insert col·loca un element abans de l'iterador indicatEls iteradors van molt bé per indicar a on cal fer certes coses. Com per exemple, insertar i esborrar, el punt fort de les llistes. El següent codi en mostra un exemple:
list<string> L = {"abc", "xyz"};
list<string>::iterator it = L.begin();
L.insert(it, "000"); // insereix al principi
advance(it, 2); // it++; it++;
L.insert(it, "lmo"); //
it = L.end();
L.insert(it, "999");
// L = {"000", "abc", "lmo", "xyz", "999"}
La inserció es fa de tal manera que l'element insertat quedarà a la posició que l'iterador apuntaba, i per tant la resta d'elements han de moure's cap al final del contenidor.
Però, per què insert retorna un iterador? En el cas de que
volguem fer vàries insercions en bucle, és molt important
adonar-se que, amb respecte a la iteració, el fet d'insertar ens
fa anar un element cap enrere, perquè si estem a un element, i
a l'insertar aquest es desplaça més enllà, és com si haguéssim
retrocedit, i encara que el bucle incrementi l'iterador, tornarem
a estar a on estàvem.
Aquest fet és la font de molts errors difícils de veure.
Exemple 1: afegir un '0' abans de cada dígit entre '1' i
'9':
void prefix_digits_with_zero(list<char>& L) {
list<char>::iterator it = L.begin();
while (it != L.end()) {
if (*it >= '1' && *it <= '9') {
it = L.insert(it, '0'); // en inserir ens movem "cap enrere"!
advance(it, 2); // saltem el '0' i després el dígit
} else {
it++;
}
}
}
Exemple 2: Insereix un 0 abans dels nombres negatius
void insert_zeros_before(list<double>& L) {
for (auto it; it != L.end(); it++) {
if (*it < 0) {
it = L.insert(it); // Inserir "endarrereix" l'iterador!!
it++; // saltar el nou insertat!
}
}
}
Exemple 3: Insereix un 0 després dels nombres negatius
void insert_zeros_after(list<double>& L) {
for (auto it; it != L.end(); it++) {
if (*it < 0) {
it++; // posar-se al següent (end ok!)
it = L.insert(it); // inserir abans del següent
}
}
}
erase esborra l'element actual i retorna el següent iterador vàlidEl següent codi esborra un element a la posició indicada per un
iterator.
list<char> L = {'a', 'b', 'c', 'd', 'e'}
list<char>::iterator it = L.begin();
it++;
it++;
L.erase(it); // esborrem la 'c'
// L = {'a', 'b', 'd', 'e'}
L'esborrat es fa de tal manera que l'element després de l'esborrat quedarà a la posició que l'iterador apuntaba, i per tant la resta d'elements han de moure's cap al principi del contenidor, és a dir, s'apropen a la posició de l'iterador.
En certs contenidors, com les llistes, el fet d'esborrar un element invalida l'iterador, en el sentit que l'iterador que teníem ja no serveix, perquè pel fet d'haver esborrat l'element, apunta a puna posició que no existeix.
En general, els esborrats puntuals no tenen dificultat, perquè
encara que l'iterador que tinguem no s'invalidi, no passa res.
Però erase retorna un iterador com a resultat, per a què
serveix això? El retorn d'erase és precisament és el següent
element, perquè si estem al mig d'una iteració, ens interessa
poder reprendre el fil i seguir la iteració en el següent
element.
erase per continuar béAixí doncs l'erase també té un problema amb els esborrats en
bucle, en part perquè els iteradors s'invaliden, però també per
una raó similar a insert, perquè així com insert fa moure els
elements més enllà quan n'insertes un, erase fa l'invers: quan
esborres un element, la resta s'apropen a la posició de
l'iterador. I cal tenir en compte això per fer les iteracions
correctament.
Exemple 1: eliminar tots els negatius d'una llista de reals
void erase_negative(list<double>& L) {
list<double>::iterator it = L.begin();
while (it != L.end()) { // `while` perquè no sempre hi ha un it++!
if (*it < 0) {
it = L.erase(it); // Esborrar **avança** l'iterador!!
} else {
it++;
}
}
}
Exemple 2: eliminar zeros abans d'un enter negatiu
void erase_zero_before_negative(list<int>& L) {
auto it = L.begin();
while (it != L.end()) {
auto next = it;
++next;
if (*it == 0 and next != L.end() and *next < 0) {
it = L.erase(it);
} else {
++it;
}
}
}
Aquesta funció recorre la llista, i cada cop que troba un zero
seguit d'un nombre negatiu, esborra el zero. El retorn d'erase
permet seguir correctament la iteració sense invalidar l'iterador
principal.
Com que l'interval begin - end és asimètric i end està fora
del contenidor, fer iteracions cap enrere pot ser incòmode amb
iteradors "normals":
list<int>::iterator it = L.end(); // no és l'últim (cal fer it-- primer)
for (it--; it > L.begin(); it--) {
cout << *it << endl;
}
cout << *it << endl;
El codi queda molt extrany i confús perquè:
Al principi no comencem amb l'últim element, cal fer it--
per estar-hi a sobre (es fa a la primera part del for).
Com que begin() no és un sentinella, el seu element anterior
està indefinit, i llavors no queda clar que poguem posar
it >= L.begin(), ja que farem it-- tot seguit, així que
per estar segurs cal fer la iteració fins el segon element i a
fora del bucle processar el primer.
És molt molest haver de fer l'esforç mental de fer iteracions així en general, per això existeix un tipus d'iterador per moure's des del final cap al principi.
| Mètode | Descripció |
|---|---|
rbegin() | Retorna un iterador revers al darrer element. |
rend() | Retorna un iterador al centinella revers. |
Les iteracions cap enrere són equivalents a les normals excepte en el nom dels iteradors i els mètodes, d'altra manera seria molt més empipador escriure aquests recorreguts.
list<int> L {1, 2, 3, 4, 5};
list<int>::reverse_iterator rit = L.rbegin();
rit++; // últim element (no el end!)
rit++; // penúltim element
while (rit != L.rend()) {
*rit += 10;
rit++;
}
// L = {10, 20, 30, 4, 5}
const_iterator per recórrer sense modificarEn una funció com:
double suma_lista(const list<double>& L) { ... }
on no és possible modificar la llista, utilitzar un
list<int>::iterator dóna un error de compilació, perquè un
iterador normal no garanteix que no modificarà l'element. En
aquests casos cal utilitzar un const_iterator:
double suma_lista(const list<double>& L) {
double suma = 0.0;
for (list<int>::const_iterator it = L.begin(); it != L.end(); it++) {
suma += *it;
}
return suma;
}
En combinació amb els iteradors inversos, hi ha 4 possibilitats:
| Tipus d'iterador | Modificable | Invers |
|---|---|---|
list<T>::iterator | sí | no |
list<T>::const_iterator | no | no |
list<T>::reverse_iterator | sí | sí |
list<T>::const_reverse_iterator | no | sí |
auto evita escriure tipus d'iterador massa llargsCom que els tipus d'iteradors poden ser molt llargs d'escriure:
// Iterador a una llista d'iteradors a vectors... XD
list<vector<int>::iterator>::iterator it = L.begin();
sovint s'utilitza la paraula reservada auto, de C++, amb la
qual es pot demanar al compilador que “dedeueixi” el tipus
directament de l'iterador, per estalviar-nos haver-lo d'escriure.
Al principi utilitzar auto és un auto-engany (🤯), perquè si no
tenim clar els tipus d'iteradors és fàcil confondre's, però a la
llarga auto és molt útil per facilitar la lectura d'un
programa.
Amb auto un bucle ascendent d'una llista es converteix en:
for (auto it = L.begin(); it != L.end(); it++) {
cout << *it << endl;
}
vector amb pocs canvisSimplement substituint list per vector, podem escollir el
contenidor que ens convé més, sense canviar el codi.
Tot i que cal recordar:
vector, insert i erase
també invaliden els iteradors, malgrat que això pugui semblar
paradoxal, però és perquè al créixer, un vector pot saltar a
una altra zona de memòria per falta d'espai per créixer, per
tant cal seguir utilitzant els iteradors que retornen insert
i erase igualment!Tot i que les llistes tenen un cost d'inserció i esborrat , no s'utilitzen amb massa freqüència com podria semblar perquè les CPUs modernes penalitzen el recorregut d'estructures a memòria amb punters enllaçats, i un cas d'ús a on en un contenidor només d'inserti i esborri en punts intermitgos molt més que no pas es recorre el contenidor és difícil de trobar.