A la lliçó 01-02 vas comprovar amb el cronòmetre que cercar una tasca en una llista d'un milió d'elements triga moltíssim més que cercar-la en un índex. Però els cronòmetres tenen un problema: les seves xifres depenen del teu ordinador, de la teva versió de Python i fins i tot de què més estigui executant la teva màquina en aquell moment. Necessitem una manera de parlar d'eficiència que sigui independent de tot això, i aquesta manera és la notació Big O. És, sense exagerar, el vocabulari més important del curs: a partir d'aquí, cada operació de cada estructura vindrà etiquetada amb el seu Big O. Dedica a aquesta lliçó el temps que calgui.
Contingut
- Complexitat temporal i espacial: comptar en lloc de cronometrar
- La notació Big O: què significa exactament
- Les classes habituals, amb exemples en Python
- Comparativa: quant importa la classe de complexitat
- Millor cas, pitjor cas i cas mitjà
- Cost amortitzat (menció breu)
- Mesurant TaskFlow amb timeit: la teoria contra el cronòmetre
Complexitat temporal i espacial: comptar en lloc de cronometrar
La idea central de la complexitat algorísmica és deixar de mesurar segons i començar a comptar operacions. En lloc de preguntar "quant triga?", preguntem: quants passos executa l'algorisme en funció de la mida de l'entrada, n?
- Complexitat temporal: quantes operacions elementals (comparacions, sumes, assignacions...) fa l'algorisme a mesura que creix
n. - Complexitat espacial: quanta memòria addicional necessita a mesura que creix
n(sense comptar l'entrada mateixa).
Comptem passos a la cerca seqüencial de TaskFlow:
def cercar_en_llista(tasques, id_cercat):
for tasca in tasques: # es repeteix fins a n vegades
if tasca["id"] == id_cercat: # 1 comparacio per volta
return tasca
return NoneSi hi ha n tasques i la cercada és al final (o no hi és), el bucle fa n voltes amb una comparació cadascuna: unes n operacions. Si dupliquem les tasques, dupliquem la feina. Diem que el temps creix linealment amb n. La seva complexitat espacial, en canvi, és constant: tingui la llista la mida que tingui, la funció només necessita un grapat de variables auxiliars.
Aquesta anàlisi té dues virtuts que el cronòmetre no té:
- És universal:
noperacions sónnoperacions al teu portàtil i en un servidor de producció. - Prediu el futur: sabem què passarà amb 10 milions de tasques sense necessitat de provar-ho.
La notació Big O: què significa exactament
En comptar operacions sorgeixen detalls molestos: la cerca fa exactament n comparacions, o n comparacions més n accessos al camp "id" més 1 retorn, és a dir, 2n + 1 operacions? La resposta de la notació Big O és: tant és. L'únic que importa és com creix el cost quan n es fa gran.
Big O descriu l'ordre de creixement del cost d'un algorisme quan n tendeix a valors grans, ignorant constants multiplicatives i termes menors.
Les dues regles de simplificació:
- Ignora les constants:
2n + 1operacions →O(n). Tant fa2nque5n: tots dos es dupliquen en duplicarn, i això és el que Big O captura. - Queda't amb el terme dominant:
n² + 3n + 20→O(n²). Quann = 1.000.000, el termen²aporta un bilió d'operacions i el3namb prou feines tres milions: els termes menors es tornen irrellevants.
def exemple(tasques):
n = len(tasques) # 1 operacio
print(tasques[0]) # 1 operacio
for t in tasques: # n operacions
print(t["titol"])
for t1 in tasques: # n * n = n² operacions
for t2 in tasques:
if t1["id"] == t2["id"] and t1 is not t2:
print("id duplicat!")Cost total: 2 + n + n². Aplicant les dues regles: O(n²). El bloc que domina és el doble bucle; per a n gran, els altres ni es noten.
Un apunt de rigor: formalment, Big O expressa una cota superior del creixement ("no creix més ràpid que..."). En l'ús quotidià de la professió —i en aquest curs— s'empra com a sinònim de "el seu cost creix com...", que és la interpretació pràctica que necessites.
Les classes habituals, amb exemples en Python
Gairebé tot el que analitzarem al curs cau en cinc classes. Cadascuna, amb un exemple real sobre les tasques de TaskFlow:
O(1) — constant
El cost no depèn de n. Tant fa que hi hagi 10 tasques o 10 milions.
def primera_tasca(tasques):
return tasques[0] # accedir per index no recorre res
def total_tasques(tasques):
return len(tasques) # Python desa la mida ja calculadaAccedir a tasques[0], a tasques[500_000] o preguntar len(tasques) costa el mateix: un pas. Per què l'accés per índex és O(1) ho entendràs a fons a la lliçó 01-05.
O(log n) — logarítmica
El cost creix amb el logaritme de n: cada pas descarta la meitat de les dades. Duplicar n només afegeix un pas més.
def cerca_binaria(tasques_ordenades_per_id, id_cercat):
"""Requereix que les tasques estiguin ordenades per id."""
inici, fi = 0, len(tasques_ordenades_per_id) - 1
while inici <= fi:
mig = (inici + fi) // 2 # mirem el centre
id_mig = tasques_ordenades_per_id[mig]["id"]
if id_mig == id_cercat:
return tasques_ordenades_per_id[mig]
elif id_mig < id_cercat:
inici = mig + 1 # descartem la meitat esquerra
else:
fi = mig - 1 # descartem la meitat dreta
return NoneCom funciona: com que les tasques estan ordenades per id, mirar la del centre ens diu a quina meitat és la cercada, i l'altra meitat es descarta sencera sense mirar-la. Amb un milió d'elements: 1.000.000 → 500.000 → 250.000 → ... → 1 en uns 20 passos. És el mateix truc que fas servir en buscar una paraula en un diccionari de paper: obres pel mig i descartes mitja obra d'un cop d'ull.
O(n) — lineal
El cost creix en proporció directa a n: tocar cada element una vegada.
def comptar_pendents(tasques):
comptador = 0
for tasca in tasques: # n voltes exactes
if tasca["estat"] == "pendent":
comptador += 1
return comptadorLa nostra vella coneguda cercar_en_llista també és O(n). Qualsevol algorisme que necessiti mirar totes les dades almenys una vegada és com a mínim O(n): no hi ha manera de comptar les pendents sense visitar cada tasca.
O(n log n) — gairebé lineal
La classe dels bons algorismes d'ordenació. Intuïció: fer una feina O(log n) per cadascun dels n elements, o dividir el problema per la meitat repetidament ordenant a cada nivell.
El sorted de Python (algorisme Timsort) és O(n log n). No l'implementarem aquí; queda't amb la idea que ordenar bé costa O(n log n), sensiblement més que recórrer (O(n)) però moltíssim menys que comparar-ho tot amb tot (O(n²)).
O(n²) — quadràtica
El cost creix amb el quadrat de n: típicament, un bucle dins d'un altre, tots dos sobre les dades. Duplicar n quadruplica la feina.
def hi_ha_titols_duplicats(tasques):
for i in range(len(tasques)): # n voltes
for j in range(i + 1, len(tasques)): # fins a n voltes per cada i
if tasques[i]["titol"] == tasques[j]["titol"]:
return True
return FalseCada tasca es compara amb totes les següents: al voltant de n²/2 comparacions, que per la regla de les constants és O(n²). Amb 1.000 tasques, mig milió de comparacions; amb un milió de tasques, cinc-cents mil milions. Les solucions quadràtiques són acceptables només amb dades petites; detectar-les (i saber substituir-les: aquest mateix problema és O(n) amb un set) és una habilitat d'entrevista clàssica.
Existeixen classes pitjors —O(2ⁿ), O(n!)— pròpies de problemes de força bruta; amb prou feines ens les creuarem, però convé saber que existeixen i que són intractables fins i tot amb n modestos.
Comparativa: quant importa la classe de complexitat
Números concrets: passos aproximats que executa cada classe a mesura que creix n (suposant, per traduir a temps, uns 10 milions d'operacions simples per segon en Python):
| n | O(1) | O(log n) | O(n) | O(n log n) | O(n²) |
|---|---|---|---|---|---|
| 10 | 1 | 3 | 10 | 33 | 100 |
| 1.000 | 1 | 10 | 1.000 | 10.000 | 1.000.000 |
| 1.000.000 | 1 | 20 | 10⁶ (~0,1 s) | 2·10⁷ (~2 s) | 10¹² (~28 hores) |
Observa l'última fila: amb un milió d'elements, la diferència entre O(n) i O(n²) és la diferència entre una dècima de segon i més d'un dia. I O(log n) continua sent, a efectes pràctics, instantani. Aquesta taula explica per què l'experiment de la lliçó 01-02 va donar els resultats que va donar, i per què la tria d'estructura importa tant: cada estructura ofereix les seves operacions amb classes de complexitat diferents.
graph TB
subgraph "Creixement del cost en augmentar n"
A["O(1): pla"] --- B["O(log n): gairebé pla"]
B --- C["O(n): recta"]
C --- D["O(n log n): recta que s'empina"]
D --- E["O(n²): paràbola — es dispara"]
end
I la taula que farem servir com a referència durant tot el curs — el cost de les operacions habituals sobre les estructures natives de Python:
| Operació | list |
dict / set |
|---|---|---|
Accés per índex llista[i] |
O(1) | — |
| Accés/inserció per clau | — | O(1) de mitjana |
Cercar si conté (in) |
O(n) | O(1) de mitjana |
Afegir al final (append/add) |
O(1) amortitzat | O(1) de mitjana |
| Inserir/esborrar al principi o al mig | O(n) | — |
Els cognoms "de mitjana" i "amortitzat" s'expliquen als dos apartats següents.
Millor cas, pitjor cas i cas mitjà
Un mateix algorisme pot costar diferent segons la sort de les dades. La cerca seqüencial a TaskFlow:
- Millor cas: la tasca cercada és la primera → 1 comparació → O(1).
- Pitjor cas: és l'última o no existeix → n comparacions → O(n).
- Cas mitjà: si qualsevol posició és igual de probable, unes n/2 comparacions → O(n) (recorda: les constants com ½ s'ignoren).
| Escenari | Què descriu | Quan usar-lo |
|---|---|---|
| Millor cas | El resultat amb les dades més favorables | Gairebé mai: és informació poc fiable |
| Pitjor cas | La garantia màxima: mai no anirà pitjor | L'estàndard professional per defecte |
| Cas mitjà | El comportament esperat amb dades típiques | Quan el pitjor cas és rar i es coneix la distribució |
Llevat que es digui el contrari, quan algú dona un Big O a seques es refereix al pitjor cas: és la garantia que permet dimensionar sistemes ("mai no trigarà més que..."). El cas mitjà importa quan el pitjor cas és extraordinàriament improbable: l'exemple estrella és el dict de Python, la cerca del qual és O(1) de mitjana però pot degradar-se en situacions patològiques raríssimes (ho entendràs en estudiar les col·lisions al mòdul 5). Per això la taula anterior diu "O(1) de mitjana".
Cost amortitzat (menció breu)
Queda un cognom per explicar: l'append de list és "O(1) amortitzat". El cost amortitzat és el cost fet mitjana sobre una seqüència llarga d'operacions: gairebé tots els append són instantanis, però de tant en tant n'hi ha un que costa O(n) perquè la llista s'ha de reorganitzar per dins; repartit aquest cost ocasional entre totes les operacions, surt a O(1) per operació.
De moment, queda't només amb la idea de "car molt de tant en tant, barat gairebé sempre, i de mitjana constant". El perquè exacte —quina reorganització és aquesta i per què surt a compte— és precisament un dels plats forts de la propera lliçó (01-05), quan veurem els arrays dinàmics.
Mesurant TaskFlow amb timeit: la teoria contra el cronòmetre
Tanquem el cercle: la teoria prediu, i timeit verifica. Si cercar_en_llista és O(n), en multiplicar per 10 el nombre de tasques el temps s'hauria de multiplicar per ~10. Comprovem-ho:
import timeit
def crear_tasques(n):
return [{"id": i, "titol": f"Tasca {i}"} for i in range(n)]
def cercar_en_llista(tasques, id_cercat):
for tasca in tasques:
if tasca["id"] == id_cercat:
return tasca
return None
for n in (10_000, 100_000, 1_000_000):
tasques = crear_tasques(n)
index = {t["id"]: t for t in tasques}
pitjor = n - 1 # pitjor cas: l'ultima tasca
t_llista = timeit.timeit(lambda: cercar_en_llista(tasques, pitjor), number=20)
t_dict = timeit.timeit(lambda: index.get(pitjor), number=20)
print(f"n={n:>9} | llista: {t_llista:8.4f} s | dict: {t_dict:.6f} s")Sortida típica (les teves xifres variaran; les proporcions no):
n= 10.000 | llista: 0,0059 s | dict: 0,000002 s
n= 100.000 | llista: 0,0601 s | dict: 0,000002 s
n=1.000.000 | llista: 0,6088 s | dict: 0,000002 sLectura de l'experiment:
- La columna de la llista es multiplica per ~10 a cada fila, exactament el que prediu O(n): cost proporcional a n.
- La columna del
dictno es mou: O(1) en estat pur, confirmant la teoria del cas mitjà. - Això és el que Big O et dona i el cronòmetre només t'insinua: a la lliçó 01-02 vam veure que passava; ara sabem quant i per què, i podem predir-ho per a qualsevol n sense executar res.
A partir d'ara, aquest serà el nostre mètode amb cada estructura: analitzar el Big O de les seves operacions sobre el paper i, quan aporti, confirmar-lo amb timeit.
Errors Comuns i Consells
- Creure que O(1) significa "ràpid" i O(n) "lent". O(1) significa "cost que no depèn de n", no "instantani": una operació O(1) pot ser lenta en termes absoluts, i una O(n) amb n = 20 és menyspreable. Big O parla de creixement, no de velocitat absoluta.
- Oblidar els costos ocults de Python.
element in llistasembla una operació, però és O(n) per dins;llista.insert(0, x)també. Uninsobre llista dins d'un bucle O(n) fabrica un O(n²) invisible: és l'error de rendiment més comú en Python (ho vas veure a l'exercici 2 de la lliçó 01-02). - Comparar algorismes pel millor cas. "La meva cerca de vegades encerta a la primera" no diu res d'útil. Analitza el pitjor cas per defecte i esmenta el cas mitjà només quan el sàpigues justificar.
- Ignorar la complexitat espacial. Crear un índex
dictper cercar en O(1) gasta O(n) de memòria addicional. Gairebé sempre compensa, però has de saber que estàs pagant aquest preu: temps i espai s'intercanvien constantment. - Consell: quan dubtis del Big O d'un codi, compta els bucles imbricats que depenen de n com a primera aproximació (1 bucle → O(n), 2 d'imbricats → O(n²)) i vigila les operacions amb cost ocult a dins.
Exercicis
Exercici 1: classificar fragments
Indica la complexitat temporal (Big O, pitjor cas) de cada fragment i justifica-ho en una frase:
# (a)
def ultima_tasca(tasques):
return tasques[-1]
# (b)
def titols_en_majuscules(tasques):
return [t["titol"].upper() for t in tasques]
# (c)
def parells_de_tasques_conflictives(tasques):
parells = []
for a in tasques:
for b in tasques:
if a["id"] != b["id"] and a["titol"] == b["titol"]:
parells.append((a["id"], b["id"]))
return parells
# (d)
def existeix_id(tasques, id_cercat):
return any(t["id"] == id_cercat for t in tasques)Exercici 2: millor, pitjor i mitjà
Per a la funció existeix_id de l'exercici anterior, descriu el seu millor cas, el seu pitjor cas i el seu cas mitjà (suposant que l'id cercat, quan existeix, és en una posició aleatòria uniforme). Dona el Big O de cadascun.
Exercici 3: predicció i verificació empírica
hi_ha_titols_duplicats (vista a l'apartat d'O(n²)) compara cada tasca amb les següents. (a) Prediu: si amb n = 1.000 triga t segons, quant trigarà aproximadament amb n = 2.000 i amb n = 4.000? (b) Verifica-ho amb timeit usant tasques amb títols tots diferents (pitjor cas: no troba res i ho compara tot). (c) Reescriu la funció amb un set perquè sigui O(n) i repeteix la mesura.
Solucions
Solució 1:
- (a) O(1): l'accés per índex (encara que sigui l'últim,
[-1]) no recorre res. - (b) O(n): la comprensió visita cada tasca exactament una vegada. (Nota: també gasta O(n) d'espai, perquè crea una llista nova.)
- (c) O(n²): dos bucles imbricats complets sobre les n tasques → n² comparacions.
- (d) O(n):
anyamb un generador va comprovant tasca a tasca i s'atura en trobar la primera coincidència, però en el pitjor cas (no existeix) les recorre totes.
Solució 2:
- Millor cas: l'id cercat és a la primera posició → 1 comparació → O(1).
- Pitjor cas: l'id no existeix (o és l'últim) → n comparacions → O(n).
- Cas mitjà: posició uniforme → n/2 comparacions esperades → O(n) (la constant ½ es descarta). Conclusió típica: millor cas O(1), però l'algorisme "és" O(n), perquè per defecte parlem del pitjor cas.
Solució 3:
(a) O(n²) implica que duplicar n quadruplica el temps: amb n = 2.000 trigarà ~4t; amb n = 4.000, ~16t.
(b) i (c):
import timeit
def hi_ha_titols_duplicats_v2(tasques):
vistos = set()
for t in tasques: # n voltes
if t["titol"] in vistos: # O(1) de mitjana sobre un set
return True
vistos.add(t["titol"]) # O(1) de mitjana
return False # total: O(n)
for n in (1_000, 2_000, 4_000):
tasques = [{"id": i, "titol": f"Tasca {i}"} for i in range(n)]
t_v1 = timeit.timeit(lambda: hi_ha_titols_duplicats(tasques), number=3)
t_v2 = timeit.timeit(lambda: hi_ha_titols_duplicats_v2(tasques), number=3)
print(f"n={n}: O(n²) {t_v1:.3f} s | O(n) {t_v2:.5f} s")Resultat típic: la versió quadràtica segueix la progressió ×4 predita (per exemple 0,1 s → 0,4 s → 1,6 s), mentre que la versió amb set es limita a duplicar-se (progressió ×2, lineal) i és centenars de vegades més ràpida ja amb n = 4.000. La predicció teòrica i la mesura coincideixen: això és exactament el que Big O promet. (Cost de l'intercanvi: la v2 usa O(n) de memòria extra per al conjunt vistos.)
Conclusió
Ja domines el vocabulari central del curs: la complexitat temporal i espacial compten operacions i memòria en funció de n; la notació Big O captura l'ordre de creixement ignorant constants i termes menors; les classes O(1), O(log n), O(n), O(n log n) i O(n²) cobreixen gairebé tot el que analitzarem, amb diferències que van d'"instantani" a "més d'un dia" amb dades grans; l'anàlisi per defecte és el pitjor cas, reservant el cas mitjà i el cost amortitzat per a quan estan justificats; i timeit permet confirmar empíricament el que la teoria prediu.
Ens queda un deute pendent d'aquesta lliçó: per què l'accés llista[i] és O(1)? I quina reorganització misteriosa fa que append sigui només O(1) amortitzat? Les respostes són un nivell més avall, en com s'organitzen les dades físicament a la memòria. Aquest és el tema de la propera lliçó: arrays i memòria, la base sobre la qual es construeix tota la resta.
Curs d'Estructures de Dades
Mòdul 1: Introducció a les Estructures de Dades
- Què són les Estructures de Dades?
- Importància de les Estructures de Dades en la Programació
- Tipus d'Estructures de Dades
- Complexitat Algorísmica i Notació Big O
- Arrays i Memòria: la Base de les Estructures de Dades
Mòdul 2: Llistes
- Introducció a les Llistes
- Llistes Enllaçades
- Llistes Doblement Enllaçades
- Llistes Circulars
- Exercicis amb Llistes
Mòdul 3: Piles
- Introducció a les Piles
- Operacions Bàsiques amb Piles
- Implementació de Piles
- Aplicacions de les Piles
- Exercicis amb Piles
Mòdul 4: Cues
- Introducció a les Cues
- Operacions Bàsiques amb Cues
- Cues Circulars
- Cues de Prioritat
- Cues Dobles (Deques)
- Exercicis amb Cues
Mòdul 5: Taules Hash i Diccionaris
- Introducció a les Taules Hash
- Funcions Hash i Resolució de Col·lisions
- Diccionaris i Conjunts a la Pràctica
- Exercicis amb Taules Hash
Mòdul 6: Arbres
- Introducció als Arbres
- Arbres Binaris
- Recorreguts d'Arbres
- Arbres Binaris de Cerca
- Arbres AVL
- Arbres B
- Monticles (Heaps)
- Exercicis amb Arbres
Mòdul 7: Grafs
- Introducció als Grafs
- Representació de Grafs
- Algorismes de Cerca en Grafs
- Algorismes de Camins Mínims
- Arbres d'Expansió Mínima
- Aplicacions dels Grafs
- Exercicis amb Grafs
