Al Mòdul 1 vam aprendre el llenguatge de l'eficiència: la notació asimptòtica. Ara aprendrem a fer-lo servir: donat un fragment de codi Python real, com se'n calcula la complexitat temporal? En aquesta lliçó desenvoluparem el mètode d'anàlisi línia a línia: assignar un cost a cada operació, sumar els costos de les línies seqüencials, multiplicar per les repeticions dels bucles i simplificar amb les regles que ja coneixem. L'aplicarem pas a pas a les funcions que ja vam escriure per a RutaBus, incloent-hi una funció recursiva, i acabarem amb una cosa que sorprèn molts desenvolupadors: operacions de Python que semblen d'un sol pas però amaguen un bucle a dins.
Dos avisos abans de començar. Primer: les definicions de O, Ω i Θ les vam donar a la lliçó 01-03 i aquí les donem per sabudes (recorda: O gran és una fita superior del creixement del cost). Segon: en tota aquesta lliçó analitzem el pitjor cas — l'escenari d'entrada que més feina provoca —, perquè és l'anàlisi més habitual i la més prudent. A la lliçó 02-03 veurem que també existeixen el millor cas i el cas mitjà, i quan interessa cadascun.
Contingut
- El model de cost: operacions elementals
- Codi seqüencial: sumar costos
- Bucles simples: multiplicar per les repeticions
- Anàlisi completa de
parada_mes_propera - Bucles imbricats: parells de parades connectables
- Bucles dependents: la suma aritmètica
- Crides a funcions: el cost no desapareix
- Funcions recursives: comptar les crides
- Costos ocults de les operacions de Python
El model de cost: operacions elementals
Per analitzar codi sense cronometrar-lo necessitem un acord: què compta com "un pas"? La convenció és aquesta:
- Una operació elemental és qualsevol operació el temps de la qual no depèn de la mida de l'entrada: una assignació (
x = 5), una operació aritmètica (a + b,a * b), una comparació (a < b), un accés a una posició de llista (llista[i]), unreturn. - Cada operació elemental costa O(1): temps constant. No ens importa si en una màquina concreta una multiplicació triga el doble que una suma — la lliçó 01-03 ja ens va ensenyar que les constants es descarten.
Amb aquest model, analitzar un algorisme es redueix a comptar quantes operacions elementals s'executen en funció de n (la mida de l'entrada). El resultat és una funció de cost T(n) que després simplifiquem al seu ordre asimptòtic.
Compte: la paraula clau és elemental. Com veurem a l'últim apartat, en Python hi ha expressions d'una sola línia que no són elementals perquè amaguen un recorregut complet. De moment, assumim que treballem amb les operacions de la llista anterior.
Codi seqüencial: sumar costos
La primera regla de l'anàlisi línia a línia:
Regla 1 (seqüència): el cost de diverses instruccions executades una rere l'altra és la suma dels seus costos.
# Fragment de RutaBus: preparar dades d'un trajecte
origen = "Plaça Major" # O(1): assignació
destinacio = "Estació Nord" # O(1): assignació
preu_bitllet = 1.50 # O(1): assignació
preu_total = preu_bitllet * 2 # O(1): multiplicació + assignacióCost total: O(1) + O(1) + O(1) + O(1) = O(1). Tant és que siguin 4 línies o 400: si cap no depèn de n, la suma continua sent una constant i per tant O(1). Aquesta és la traducció pràctica de "descartar constants" que vam veure a 01-03: un bloc seqüencial d'operacions elementals, per llarg que sigui, costa temps constant.
Bucles simples: multiplicar per les repeticions
Regla 2 (bucle): el cost d'un bucle és el cost del seu cos multiplicat pel nombre d'iteracions.
Recuperem compta_parades_iteratiu de la lliçó 01-02:
def compta_parades_iteratiu(parades):
comptador = 0 # O(1) — s'executa 1 vegada
for _ in parades: # el bucle s'executa n vegades (n = len(parades))
comptador += 1 # O(1) per iteració
return comptador # O(1) — s'executa 1 vegadaComptem amb n = nombre de parades:
| Línia | Cost unitari | Vegades que s'executa | Cost total |
|---|---|---|---|
comptador = 0 |
O(1) | 1 | O(1) |
cos del bucle (comptador += 1) |
O(1) | n | O(n) |
return comptador |
O(1) | 1 | O(1) |
T(n) = O(1) + O(n) + O(1). Aplicant la regla del terme dominant de 01-03: T(n) = O(n). Lineal: si la xarxa de RutaBus duplica les seves parades, comptar triga el doble.
Un matís important: el nombre d'iteracions no sempre és exactament n. Un bucle for parada in parades[1:] itera n−1 vegades; un que recorre la meitat de la llista itera n/2 vegades. Asimptòticament n−1, n/2 i n són tots O(n) — les constants multiplicatives i additives es descarten. El que importa és que el nombre de voltes creix linealment amb n.
Anàlisi completa de parada_mes_propera
Apliquem el mètode complet a la primera funció que vam escriure per a RutaBus (lliçó 01-01). Anotem el cost de cada línia com a comentari:
def parada_mes_propera(usuari_x, usuari_y, parades):
millor_parada = parades[0] # O(1): accés + assignació
millor_distancia = distancia(usuari_x, usuari_y,
millor_parada["x"],
millor_parada["y"]) # O(1): crida a distancia (vegeu l'apartat 7)
for parada in parades[1:]: # n-1 iteracions
d = distancia(usuari_x, usuari_y,
parada["x"], parada["y"]) # O(1) per iteració
if d < millor_distancia: # O(1) per iteració
millor_distancia = d # O(1) per iteració (com a molt)
millor_parada = parada # O(1) per iteració (com a molt)
return millor_parada # O(1)Procediment:
- Abans del bucle: dues línies O(1) → O(1) en total.
- Cos del bucle: quatre operacions O(1) → O(1) per iteració. (Les dues últimes només s'executen si la comparació és certa, però com que analitzem el pitjor cas assumim que s'executen sempre; i encara que no ho fessin, 2 operacions davant de 4 és una constant que es descarta.)
- El bucle: O(1) per iteració × (n−1) iteracions = O(n).
- Després del bucle: O(1).
T(n) = O(1) + O(n) + O(1) = O(n). Confirmem formalment el que a 01-01 només intuíem: trobar la parada més propera amb un recorregut lineal costa temps lineal. Per a la xarxa completa d'una ciutat (milers de parades) continua sent perfectament assumible.
Bucles imbricats: parells de parades connectables
Regla 3 (imbricació): quan un bucle en conté un altre, els costos es multipliquen: iteracions de l'extern × iteracions de l'intern × cost del cos.
Nova necessitat de RutaBus: l'equip de planificació vol saber quins parells de parades són prou a prop per connectar-les amb un tram directe de línia (posem, a menys de 2 km). La solució natural: comparar cada parada amb cadascuna de les altres.
def parells_connectables(parades, distancia_maxima):
"""Retorna els parells de parades a menys de distancia_maxima km."""
parells = [] # O(1)
for p1 in parades: # n iteracions
for p2 in parades: # n iteracions PER CADA p1
if p1["nom"] < p2["nom"]: # O(1): evita duplicats i (p, p)
d = distancia(p1["x"], p1["y"],
p2["x"], p2["y"]) # O(1)
if d <= distancia_maxima: # O(1)
parells.append((p1["nom"], p2["nom"])) # O(1)
return parells # O(1)Anàlisi:
- El cos més intern és O(1).
- El bucle intern l'executa n vegades → O(n) per cada volta de l'extern.
- El bucle extern fa n voltes → n × O(n) = O(n²).
La comparació p1["nom"] < p2["nom"] és un truc per processar cada parell una sola vegada (evita examinar tant ("A","B") com ("B","A") i els parells d'una parada amb si mateixa), però no canvia l'ordre: l'if s'avalua en les n² combinacions encara que només prosperi en la meitat, i n²/2 continua sent O(n²).
És greu, un O(n²)? Depèn de n, i aquí connecta amb la taula de temps de 01-03: amb 100 parades són 10.000 comparacions (instantani); amb 10.000 parades d'una àrea metropolitana són 100 milions (segons). Els algorismes quadràtics són el primer senyal d'alarma que un desenvolupador aprèn a detectar: un bucle dins d'un altre bucle, tots dos sobre la mateixa col·lecció.
Bucles dependents: la suma aritmètica
A parells_connectables hi ha un malbaratament evident: quan p1 és la parada 7, no cal comparar-la amb les parades 0 a 7 (ja s'han comparat abans o és ella mateixa). La versió afinada fa que el bucle intern depengui de l'extern:
def parells_connectables_v2(parades, distancia_maxima):
parells = []
n = len(parades)
for i in range(n): # i = 0, 1, ..., n-1
for j in range(i + 1, n): # nomes les parades POSTERIORS a i
d = distancia(parades[i]["x"], parades[i]["y"],
parades[j]["x"], parades[j]["y"])
if d <= distancia_maxima:
parells.append((parades[i]["nom"], parades[j]["nom"]))
return parellsAra el bucle intern no fa sempre n voltes: en fa n−1 quan i=0, n−2 quan i=1... i 0 quan i=n−1. Ja no podem multiplicar sense més; cal sumar les iteracions reals:
Aquesta és la famosa suma aritmètica, i el seu valor exacte és:
Un truc visual per recordar-la: escriu la suma dues vegades, una del dret i una del revés, i aparella termes:
(n-1) + (n-2) + ... + 1 + 0 + 0 + 1 + ... + (n-2) + (n-1) = (n-1) + (n-1) + ... + (n-1) + (n-1) ← n parelles que sumen n-1 cadascuna
El doble de la suma és n(n−1), per tant la suma és n(n−1)/2. Aplicant les regles de simplificació de 01-03 (descartar constants com el ½, quedar-se amb el terme dominant n²): O(n²).
Conclusió que convé interioritzar: la versió v2 fa la meitat de feina real que la v1 — una millora útil a la pràctica —, però el seu ordre de creixement és el mateix, O(n²). La notació asimptòtica és deliberadament cega als factors constants; per passar d'O(n²) a alguna cosa millor no n'hi ha prou amb retallar iteracions: cal canviar d'estratègia (d'això tracta el Mòdul 3). Aquest patró "bucle intern que comença on va l'extern → n(n−1)/2 → O(n²)" apareix constantment: memoritza'l.
Crides a funcions: el cost no desapareix
Regla 4 (crides): el cost d'una crida a funció és el cost d'executar-ne el cos amb els arguments donats. Encapsular codi en una funció no el fa gratuït.
A parada_mes_propera vam dir alegrement que distancia(...) era O(1). Això cal justificar-ho mirant-ne el cos:
def distancia(x1, y1, x2, y2):
return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) # aritmetica fixa: O(1)Restes, quadrats, una arrel: un nombre fix d'operacions elementals, independent de quantes parades tingui la xarxa → O(1). Correcte.
Però compte amb aquest altre cas, molt típic:
def es_transbordament(parada, linia_l1, linia_l2):
"""La parada pertany a totes dues linies?"""
return hi_ha_parada_iterativa(linia_l1, parada) and \
hi_ha_parada_iterativa(linia_l2, parada)Una sola línia, dues crides... i cada crida a hi_ha_parada_iterativa (lliçó 01-02) és un recorregut lineal: O(n). Total: O(n) + O(n) = O(n), no O(1). I si aquesta funció es crida dins d'un bucle sobre les n parades, el conjunt passa a ser O(n²). La regla pràctica: quan vegis una crida, pregunta't sempre quant costa per dins — o busca-ho a la seva documentació. Aquest serà exactament el problema dels "costos ocults" de l'últim apartat.
Funcions recursives: comptar les crides
Per a una funció recursiva no hi ha cap bucle per multiplicar, però la idea és la mateixa amb una altra disfressa:
Cost d'una recursió = (nombre de crides) × (cost de cada crida, sense comptar la crida recursiva)
Analitzem compta_parades_recursiu de la lliçó 01-02:
def compta_parades_recursiu(parades):
if not parades: # O(1)
return 0 # O(1)
return 1 + compta_parades_recursiu(parades[1:]) # compte amb parades[1:]!Pas 1 — comptar les crides. Cada crida treballa amb una llista una unitat més curta que l'anterior. Per a una línia amb 4 parades:
compta_parades_recursiu([A, B, C, D]) → crida 1 (mida 4)
compta_parades_recursiu([B, C, D]) → crida 2 (mida 3)
compta_parades_recursiu([C, D]) → crida 3 (mida 2)
compta_parades_recursiu([D]) → crida 4 (mida 1)
compta_parades_recursiu([]) → crida 5 (cas base, mida 0)Per a n parades: n + 1 crides. El nombre de crides creix linealment amb n.
Pas 2 — cost de cada crida. Aquí ve la sorpresa. La comparació i el + 1 són O(1), però parades[1:] crea una llista nova copiant tots els elements menys el primer: si la llista té k elements, l'slice costa O(k) (ho confirmarem a la taula de l'apartat següent). Així doncs, les crides costen, respectivament, n, n−1, n−2, ..., 1, 0 operacions de còpia. Et sona, aquesta suma? És una altra vegada la suma aritmètica: n(n+1)/2 → O(n²).
Resultat honest: tal com està escrita en Python, compta_parades_recursiu és O(n²), mentre que la seva germana iterativa és O(n). Si la reescrivíssim passant un índex en lloc de trossejar la llista (evitant l'slice), cada crida seria O(1) i la recursió completa quedaria en O(n), com dicta la intuïció de "n+1 crides de cost constant". Doble moral: (1) la tècnica per a recursions és comptar crides i multiplicar pel cost de cadascuna; (2) en Python, aquest cost "de cadascuna" pot amagar còpies.
Per a recursions més riques — per exemple una funció que es crida dues vegades a si mateixa amb la meitat del problema, com farà merge sort — aquest recompte artesanal es formalitza amb les anomenades equacions de recurrència. Encara no les necessitem: les plantejarem i les resoldrem per a un cas real a la lliçó 04-03 (Merge Sort). Queda't amb la versió intuïtiva: dibuixa l'arbre de crides, compta quantes n'hi ha i quant costa cadascuna.
Costos ocults de les operacions de Python
Python és un llenguatge de molt alt nivell: una sola expressió pot executar un bucle complet en C per sota. Per a l'anàlisi de complexitat això és una trampa: el codi es llegeix com una línia però costa com un bucle. Aquesta taula recull els casos que més anàlisis arruïnen (n = mida de la col·lecció implicada; tots en el pitjor cas):
| Operació Python | Cost real | Per què |
|---|---|---|
llista[i], llista[i] = v |
O(1) | Accés directe per posició |
len(llista) |
O(1) | Python guarda la longitud, no la compta |
x in llista |
O(n) | Recorre la llista comparant element a element |
x in conjunt, clau in dicc |
O(1)* | Taula hash (*de mitjana; a 02-03 matisarem què significa) |
llista[a:b] (slicing) |
O(b−a) | Copia tots els elements del tram |
llista.append(x) |
O(1) amortitzat | Gairebé sempre immediat (l'"amortitzat" s'explica a 02-03) |
llista.insert(0, x), llista.pop(0) |
O(n) | Desplaça tots els elements una posició |
s1 + s2 (strings) |
O(len(s1)+len(s2)) | Els strings són immutables: se'n crea un de nou copiant tots dos |
min(llista), max(llista), sum(llista) |
O(n) | Recorregut complet |
sorted(llista), llista.sort() |
O(n log n) | Ordenació (l'obrirem al Mòdul 4) |
Vegem dues d'aquestes trampes en codi de RutaBus.
Trampa 1: in sobre llista dins d'un bucle. Volem les parades comunes a dues línies (possibles transbordaments):
linia_l1 = ["Plaça Major", "Hospital Central", "Estació Nord", "Parc del Riu"]
linia_l2 = ["Avinguda del Port", "Plaça Major", "Universitat", "Terminal Sud"]
def transbordaments(linia_a, linia_b):
comunes = []
for parada in linia_a: # n iteracions
if parada in linia_b: # O(n) CADA VEGADA, no O(1)!
comunes.append(parada)
return comunesSembla un bucle simple → O(n), però l'in sobre una llista és un recorregut lineal amagat: n iteracions × O(n) per comprovació = O(n²). És exactament la mateixa imbricació de parells_connectables, només que un dels dos bucles no es veu. Solució idiomàtica: convertir linia_b en un set (parades_b = set(linia_b), cost O(n) una sola vegada) i preguntar parada in parades_b, que és O(1) — l'algorisme complet baixa a O(n). El preu és memòria extra, i mesurar aquest preu és justament el tema de la propera lliçó.
Trampa 2: concatenació de strings en bucle. Generar el rètol amb el recorregut d'una línia:
def retol_linia(parades):
retol = ""
for parada in parades:
retol = retol + " - " + parada # copia TOT l'acumulat a cada volta
return retolA la volta k, retol ja fa proporcionalment k caràcters i la concatenació el copia sencer: costos 1, 2, 3, ..., n → suma aritmètica → O(n²). La versió idiomàtica, " - ".join(parades), construeix el resultat en un sol pas de cost O(total de caràcters): lineal.
Errors Comuns i Consells
- Sumar on cal multiplicar (i viceversa): les instruccions en seqüència se sumen; les instruccions dins d'un bucle es multipliquen per les iteracions. Dos bucles seguits sobre n elements són O(n) + O(n) = O(n); un bucle dins d'un altre és O(n · n) = O(n²).
- Creure que menys línies = menys cost:
if parada in linia_bés una línia i costa O(n). El cost es mesura en operacions executades, no en línies escrites. Davant de qualsevol funció o mètode de biblioteca, consulta'n el cost (la taula anterior cobreix l'essencial). - Concloure O(n²/2) o O(3n): són ordres mal escrits. Després de comptar, simplifica sempre amb les dues regles de 01-03: fora constants, fora termes no dominants. O(n²/2) és O(n²); O(3n) és O(n).
- Oblidar de què és n: a
transbordamentshi ha dues entrades (dues línies). Si tenen mides diferents, el rigorós és dir O(a · b). Diem O(n²) assumint mides semblants, però convé ser explícit quan no ho són. - Ignorar el cost dels slices en recursions:
f(llista[1:])sembla elegant però copia la llista a cada nivell. Si la recursió té profunditat n, aquestes còpies sumen O(n²). Alternativa: passar índexs (f(llista, i+1)). - Consell: entrena l'ull amb aquesta drecera mental — quantes vegades s'executa la línia més interna del codi? Aquest recompte, simplificat, és gairebé sempre la complexitat de l'algorisme.
Exercicis
Exercici 1. Analitza línia a línia la funció següent de RutaBus i dona'n la complexitat temporal en el pitjor cas. Indica quantes vegades s'executa cada línia.
def primera_i_ultima_arribada(horaris):
"""horaris: llista d'hores 'HH:MM' d'arribades a una parada."""
primera = horaris[0]
ultima = horaris[0]
for h in horaris:
if h < primera:
primera = h
for h in horaris:
if h > ultima:
ultima = h
return (primera, ultima)Exercici 2. L'equip de qualitat de RutaBus va escriure aquesta funció per detectar parades duplicades al fitxer mestre de la xarxa. Analitza'n la complexitat i proposa'n una versió O(n) fent servir la taula de costos de Python. Justifica el cost de totes dues.
def hi_ha_duplicades(parades):
for i in range(len(parades)):
for j in range(i + 1, len(parades)):
if parades[i] == parades[j]:
return True
return FalseExercici 3. Sense executar-la, determina la complexitat d'aquesta funció recursiva que inverteix l'ordre de les parades d'una línia (útil per mostrar el trajecte de tornada). Pista: compta les crides i fixa't en el cost de cada crida.
def trajecte_tornada(parades):
if not parades:
return []
return trajecte_tornada(parades[1:]) + [parades[0]]Solucions
Solució 1. Les dues assignacions inicials i el return són O(1) (s'executen 1 vegada). Cada bucle executa el seu cos n vegades amb cost O(1) per iteració → O(n) cadascun. Són bucles en seqüència, no imbricats, així que se sumen: T(n) = O(1) + O(n) + O(n) + O(1) = O(2n) → O(n). Error comú aquí: veure dos for i respondre O(n²) — només es multiplica quan l'un és dins de l'altre.
Solució 2. És el patró de bucle dependent: el bucle intern fa n−1, n−2, ..., 1, 0 voltes → suma aritmètica n(n−1)/2 → O(n²) en el pitjor cas (sense duplicats: cal comprovar tots els parells; fixa't que si troba un duplicat aviat acaba abans — aquesta diferència entre escenaris és justament el tema de 02-03). Versió lineal amb un conjunt:
def hi_ha_duplicades_v2(parades):
vistes = set()
for p in parades: # n iteracions
if p in vistes: # O(1): membership en set
return True
vistes.add(p) # O(1)
return Falsen iteracions × O(1) = O(n). El preu: fins a O(n) de memòria extra per a vistes — ho quantificarem a la lliçó 02-02.
Solució 3. Crides: igual que compta_parades_recursiu, una per mida n, n−1, ..., 0 → n+1 crides. Cost de cada crida de mida k: l'slice parades[1:] costa O(k) i a més la concatenació llista + [x] crea una llista nova copiant els k−1 elements ja invertits: un altre O(k). Total per crida: O(k). Sumant k = n, n−1, ..., 1: suma aritmètica → O(n²). La inversió iterativa (recórrer de darrere cap endavant amb append, o directament parades[::-1], que copia una sola vegada) és O(n).
Conclusió
Ja sabem calcular, i no només anomenar, la complexitat temporal d'un algorisme. El mètode cap en quatre regles: les operacions elementals costen O(1); el codi en seqüència suma; els bucles multipliquen per les seves iteracions (i quan el bucle intern depèn de l'extern, apareix la suma aritmètica n(n−1)/2 → O(n²)); i les crides a funcions costen el que costa el seu cos — també les recursives, on comptem crides i multipliquem pel cost de cadascuna. Amb aquest mètode hem confirmat que parada_mes_propera és O(n), hem vist néixer l'O(n²) als parells de parades connectables i hem destapat els costos ocults de Python: l'in sobre llistes, els slices i la concatenació de strings, capaços de convertir un aparent O(n) en un O(n²) real. Tot això, recorda, mesurant el pitjor cas.
Però el temps és només la meitat de la factura. En substituir una llista per un set per accelerar transbordaments, o en veure que cada crida recursiva apila una còpia de la llista, hem estat pagant amb memòria sense comptabilitzar-ho. A la propera lliçó (02-02) aprendrem a fer exactament la mateixa anàlisi línia a línia, però mesurant bytes en lloc de passos: la complexitat espacial.
Curs d'Anàlisi i Disseny d'Algorismes
Mòdul 1: Introducció als Algorismes
Mòdul 2: Anàlisi d'Algorismes
- Anàlisi de Complexitat Temporal
- Anàlisi de Complexitat Espacial
- Casos de Complexitat: Millor, Pitjor i Mitjà
Mòdul 3: Estratègies de Disseny d'Algorismes
Mòdul 4: Algorismes Clàssics
- Cerca Binària
- Ordenació per Inserció
- Ordenació per Mescla (Merge Sort)
- Ordenació Ràpida (Quick Sort)
- Algorisme de Dijkstra
- Algorisme de Floyd-Warshall
