Aquesta és la lliçó on el mòdul paga els seus deutes. El hash del mòdul 5 emmudia davant de "dona'm les tasques ordenades per id" i "les de prioritat entre 1 i 3"; l'inordre de 06-03 sortia misteriosament ordenat. Totes dues coses tenen la mateixa explicació: l'arbre binari de cerca (ABC, o BST en anglès), un arbre binari amb una regla de col·locació — menors a l'esquerra, majors a la dreta — que converteix cada comparació en un descart de mig arbre. Aquí implementaràs la classe ArbreCerca completa (inserir, cercar, mínim/màxim, esborrar amb els seus tres casos, recorregut ordenat i cerca per interval), la faràs servir com a índex de tasques de TaskFlow, i en descobriràs també el taló d'Aquil·les: amb dades que arriben ja ordenades, l'ABC degenera en llista i tot es torna O(n) — el problema que motivarà la lliçó següent.
Contingut
- La propietat ABC: una regla, totes les conseqüències
- Cercar i inserir: descendir comparant
- Mínim, màxim i l'inordre que surt ordenat
- Esborrar: els tres casos
- Cerca per interval: el deute del mòdul 5, saldat
- El cost és O(altura): la demo de l'arbre degenerat
dictvs ABC: cadascú a la seva
La propietat ABC: una regla, totes les conseqüències
Un arbre binari de cerca és un arbre binari on, per a tot node amb clau k:
- Totes les claus del seu subarbre esquerre són menors que
k. - Totes les claus del seu subarbre dret són majors que
k.
graph TD
A((10)) --> B((6))
A --> C((15))
B --> D((3))
B --> E((8))
C --> F((12))
C --> G((20))
L'arbre de 06-03 era això. Comprova la propietat a l'arrel: a l'esquerra del 10 hi ha {6, 3, 8}, tots menors; a la dreta, {15, 12, 20}, tots majors. I ull a l'adverbi totes: no n'hi ha prou que cada fill respecti el seu pare — el 8 és fill dret del 6 (correcte: 8 > 6), però a més ha de ser menor que 10, perquè viu al subarbre esquerre de l'arrel. Aquesta distinció entre "regla local" i "regla sobre el subarbre sencer" és l'error clàssic en validar un ABC, i el caçarem a 06-08.
La conseqüència operativa: aturat a qualsevol node, una sola comparació et diu en quina meitat continuar — igual que la cerca binària del mòdul 1, però sobre una estructura enllaçada que a més admet insercions i esborrats barats (l'array ordenat cercava en O(log n) però inseria en O(n) desplaçant elements). L'ABC és, conceptualment, la cerca binària feta estructura de dades.
Cercar i inserir: descendir comparant
Construïm la classe completa. Cada node guardarà una clau (per la qual s'ordena) i un valor (la tasca de TaskFlow), com a la TaulaHash:
class NodeABC:
def __init__(self, clau, valor):
self.clau = clau
self.valor = valor
self.esquerre = None
self.dret = None
class ArbreCerca:
"""Índex ordenat: clau -> valor, amb recorreguts i intervals."""
def __init__(self):
self.arrel = None
def cercar(self, clau):
"""Retorna el valor associat a la clau, o None si no hi és. O(altura)."""
node = self.arrel
while node is not None:
if clau == node.clau:
return node.valor
elif clau < node.clau:
node = node.esquerre # el menor només pot ser a l'esquerra
else:
node = node.dret # el major, a la dreta
return Nonecercar és el descens de l'arbre de decisió de 06-02, amb la pregunta "la clau cercada és menor que la meva?". Cada comparació baixa un nivell i descarta l'altre subarbre sencer: per això el cost és O(altura), no O(n). Inserir segueix el mateix camí — la posició d'una clau nova és exactament on la cerca hauria caigut de l'arbre:
def inserir(self, clau, valor):
"""Insereix el parell (o actualitza el valor si la clau ja existeix). O(altura)."""
if self.arrel is None:
self.arrel = NodeABC(clau, valor)
return
node = self.arrel
while True:
if clau == node.clau:
node.valor = valor # clau repetida: actualitzar
return
elif clau < node.clau:
if node.esquerre is None:
node.esquerre = NodeABC(clau, valor)
return
node = node.esquerre
else:
if node.dret is None:
node.dret = NodeABC(clau, valor)
return
node = node.dretMuntem l'índex de tasques per id (fem servir ids enters per comparar còmodament):
index = ArbreCerca()
tasques = [
(10, {"id": 10, "titol": "Desplegar l'API", "prioritat": 2, "estat": "pendent"}),
(6, {"id": 6, "titol": "Revisar el login", "prioritat": 1, "estat": "en curs"}),
(15, {"id": 15, "titol": "Còpia de seguretat", "prioritat": 3, "estat": "pendent"}),
(3, {"id": 3, "titol": "Arreglar el CSS", "prioritat": 2, "estat": "pendent"}),
(8, {"id": 8, "titol": "Migrar les dades", "prioritat": 1, "estat": "bloquejada"}),
(12, {"id": 12, "titol": "Optimitzar les consultes", "prioritat": 2, "estat": "pendent"}),
(20, {"id": 20, "titol": "Documentar l'API", "prioritat": 3, "estat": "pendent"}),
]
for id_tasca, tasca in tasques:
index.inserir(id_tasca, tasca)
print(index.cercar(8)["titol"]) # Migrar les dades
print(index.cercar(99)) # NoneL'ordre d'inserció (el 10 primer, després el 6, el 15...) ha construït exactament l'arbre del diagrama. Guarda aquesta dada: la forma de l'ABC depèn de l'ordre d'arribada de les claus. Ens tornarà a mossegar a la secció 6.
Mínim, màxim i l'inordre que surt ordenat
La propietat ABC regala tres operacions que el hash ni somia:
def minim(self):
"""La clau més petita: tot cap a l'esquerra. O(altura)."""
if self.arrel is None:
return None
node = self.arrel
while node.esquerre is not None:
node = node.esquerre
return node.clau
def maxim(self):
"""La clau més gran: tot cap a la dreta. O(altura)."""
if self.arrel is None:
return None
node = self.arrel
while node.dret is not None:
node = node.dret
return node.clau
def inordre(self):
"""Tots els (clau, valor) en ordre ascendent de clau. O(n)."""
resultat = []
self._inordre(self.arrel, resultat)
return resultat
def _inordre(self, node, resultat):
if node is None:
return
self._inordre(node.esquerre, resultat)
resultat.append((node.clau, node.valor))
self._inordre(node.dret, resultat)print(index.minim(), index.maxim()) # 3 20
print([clau for clau, _ in index.inordre()]) # [3, 6, 8, 10, 12, 15, 20]I aquí es compleix l'anunci de 06-03: l'inordre d'un ABC retorna les claus ordenades, sempre. La demostració cap en dues línies: l'inordre visita esquerra → node → dreta; per la propietat ABC, tot el de l'esquerra és menor que el node i tot el de la dreta major; aplicant el mateix argument recursivament a cada subarbre, la sortida completa queda ascendent. "Dona'm les tasques ordenades per id" — la primera pregunta que va deixar muda la TaulaHash — és ara una crida a inordre(), en O(n) i sense ordenar res: l'arbre manté l'ordre com a invariant permanent, no el calcula sota demanda com faria sorted() (O(n log n) cada vegada).
Esborrar: els tres casos
Esborrar és l'operació delicada: cal treure el node sense trencar la propietat per als altres. Tres casos, de fàcil a difícil:
| Cas | Situació | Solució |
|---|---|---|
| 1 | El node és fulla | Desenganxar-lo del pare, i prou |
| 2 | Té un sol fill | El fill n'ocupa el lloc (com saltar-se un node a la llista enllaçada) |
| 3 | Té dos fills | Substituir-lo pel seu successor i esborrar el successor |
El cas 3 mereix explicació. No podem deixar un forat amb dos subarbres penjant; necessitem un substitut que mantingui "esquerra menor, dreta major". El candidat perfecte és el successor inordre: la menor clau del subarbre dret (el node que sortiria just després a inordre). És major que tot el subarbre esquerre (és a la dreta) i menor que la resta del subarbre dret (n'és el mínim) — encaixa exactament. I esborrar-lo de la seva posició original és fàcil: sent el mínim d'un subarbre, no té fill esquerre (cas 1 o 2 garantit). El simètric (màxim del subarbre esquerre, el predecessor) funciona igual de bé.
graph TD
subgraph "Esborrar el 10 (dos fills)"
A((10)) --> B((6))
A --> C((15))
C --> F((12))
C --> G((20))
end
subgraph "El successor 12 el substitueix"
A2((12)) --> B2((6))
A2 --> C2((15))
C2 -.esborrat.-> F2((12))
C2 --> G2((20))
end
La implementació recursiva és la més neta — cada crida retorna l'arrel (potser nova) del seu subarbre, i el pare la reenganxa; el mateix patró "reconstruir en tornar" que farem servir a l'AVL:
def esborrar(self, clau):
self.arrel = self._esborrar(self.arrel, clau)
def _esborrar(self, node, clau):
if node is None:
return None # clau no trobada: res a fer
if clau < node.clau:
node.esquerre = self._esborrar(node.esquerre, clau)
elif clau > node.clau:
node.dret = self._esborrar(node.dret, clau)
else:
# Trobat. Casos 1 i 2: zero o un fill
if node.esquerre is None:
return node.dret # pot ser None (cas fulla)
if node.dret is None:
return node.esquerre
# Cas 3: dos fills -> successor = mínim del subarbre dret
successor = node.dret
while successor.esquerre is not None:
successor = successor.esquerre
node.clau, node.valor = successor.clau, successor.valor # copiar el successor aquí
node.dret = self._esborrar(node.dret, successor.clau) # i esborrar-lo d'allà
return nodeindex.esborrar(10) # l'arrel, amb dos fills: el cas difícil
print([c for c, _ in index.inordre()]) # [3, 6, 8, 12, 15, 20] — continua ordenat
print(index.arrel.clau) # 12: el successor ha ocupat l'arrelLlegeix-ho dues vegades, que s'ho val: els casos 1 i 2 es resolen retornant el fill (o None) perquè el pare l'adopti; el cas 3 no esborra el node físic sinó que hi copia dins les dades del successor i delega l'esborrat real en un cas fàcil. Cost total: O(altura) — un descens per trobar, un altre de parcial per al successor.
Cerca per interval: el deute del mòdul 5, saldat
La pregunta que el hash no podia ni plantejar-se: "totes les tasques amb clau entre a i b". A l'ABC, la propietat permet podar: si la clau d'un node ja és menor que a, el seu subarbre esquerre sencer queda fora de l'interval — ni el visitem.
def interval(self, des_de, fins_a):
"""Parells (clau, valor) amb des_de <= clau <= fins_a, en ordre. O(altura + k)."""
resultat = []
self._interval(self.arrel, des_de, fins_a, resultat)
return resultat
def _interval(self, node, des_de, fins_a, resultat):
if node is None:
return
if node.clau > des_de: # pot haver-hi candidats a l'esquerra
self._interval(node.esquerre, des_de, fins_a, resultat)
if des_de <= node.clau <= fins_a: # aquest node hi entra?
resultat.append((node.clau, node.valor))
if node.clau < fins_a: # pot haver-hi candidats a la dreta
self._interval(node.dret, des_de, fins_a, resultat)És un inordre amb dos frens: només entra en un subarbre si l'interval el pot abastar. El cost és O(altura + k), amb k resultats: baixar fins a l'interval costa l'altura, i a partir d'aquí només es trepitja el que es retorna (més una franja de frontera). Comparat amb el hash: allà l'única opció era recórrer-ho TOT i filtrar, O(n) sempre.
I "prioritat entre 1 i 3"? La prioritat no és única (moltes tasques comparteixen prioritat) i el nostre ABC vol claus diferents. El truc professional, el mateix del (prioritat, comptador, tasca) de la SafataUrgencies del mòdul 4: una clau composta (prioritat, id) — les tuples es comparen lexicogràficament, així que ordena per prioritat i desempata per id, i cada parell és únic:
per_prioritat = ArbreCerca()
for id_tasca, tasca in tasques:
per_prioritat.inserir((tasca["prioritat"], id_tasca), tasca)
# El deute del mòdul 5: tasques amb prioritat entre 1 i 3
urgents = per_prioritat.interval((1, 0), (3, float("inf")))
for (prio, id_t), tasca in urgents:
print(prio, id_t, tasca["titol"])
# 1 6 Revisar el login
# 1 8 Migrar les dades
# 2 3 Arreglar el CSS
# ...ordenades per (prioritat, id), de la 1 a la 3Els extrems (1, 0) i (3, float("inf")) acoten "qualsevol id amb prioritat 1" per baix i "qualsevol id amb prioritat 3" per dalt. Consulta resposta, ordenada de propina, i podant tot el que no toca. Aquesta és la fila de la taula de 06-01 que quedava en blanc.
El cost és O(altura): la demo de l'arbre degenerat
Tot l'anterior costa O(altura) i hem anat insinuant que l'altura ens pot trair. Toca provocar el desastre. Què passa si les claus arriben ja ordenades — el cas més natural del món: ids autoincrementals, tasques creades en ordre?
Cada clau nova és major que totes les anteriors, així que sempre baixa per la dreta:
graph TD
A((1)) --> B((2))
B --> C((3))
C --> D((4))
D --> E((5))
E --> F((6))
F --> G((7))
L'arbre degenerat de 06-02: una llista enllaçada amb nom d'arbre. Altura n−1, i tota la taula de costos s'enfonsa:
| Operació | ABC equilibrat | ABC degenerat |
|---|---|---|
cercar |
O(log n) | O(n) |
inserir |
O(log n) | O(n) |
esborrar |
O(log n) | O(n) |
minim / maxim |
O(log n) | O(n) |
Mesurem-ho amb timeit, com al mòdul 1 — construir l'índex amb 2 000 ids ordenats davant dels mateixos ids desordenats:
import timeit, random, sys
sys.setrecursionlimit(10000) # l'inordre recursiu sobre arbre degenerat ho necessita
ids = list(range(2000))
desordenats = ids[:]
random.shuffle(desordenats)
def construir(claus):
a = ArbreCerca()
for c in claus:
a.inserir(c, None)
return a
print(timeit.timeit(lambda: construir(ids), number=5)) # ~4 s (O(n²) total!)
print(timeit.timeit(lambda: construir(desordenats), number=5)) # ~0.03 s (O(n log n))(Els temps exactes depenen de la teva màquina; la proporció — dos ordres de magnitud — no.) Amb claus aleatòries, l'ABC queda raonablement equilibrat de mitjana i les 2 000 insercions costen O(n log n) en total; amb claus ordenades, la inserció i-èsima recorre i nodes i el total és O(n²). I la ironia és cruel: el cas d'ús més habitual (ids autoincrementals) és exactament el pitjor cas de l'ABC. Un índex que es degrada just amb les dades que més rebrà no és un índex seriós. La solució — un arbre que es reequilibra sol a cada inserció — és la propera lliçó.
dict vs ABC: cadascú a la seva
Amb els dos índexs construïts, la comparació honesta (assumint ABC equilibrat; n elements, k resultats):
| Operació | dict / TaulaHash |
ABC |
|---|---|---|
| Cercar clau exacta | O(1) | O(log n) |
| Inserir / esborrar | O(1) | O(log n) |
| Recórrer en ordre | O(n log n) (cal ordenar) | O(n) (inordre) |
| Mínim / màxim | O(n) | O(log n) |
| Interval [a, b] | O(n) (recórrer-ho tot i filtrar) | O(altura + k) |
| Requisit sobre claus | hashables | comparables entre si |
La lectura correcta no és "quin guanya" sinó "per a quina pregunta": clau exacta → hash; ordre, extrems i intervals → arbre. Els sistemes reals usen tots dos alhora — TaskFlow també: la TaulaHash de 05-02 per a obtenir(id) en O(1), i aquest ABC per a llistats i intervals. És la mateixa decisió que pren una base de dades en triar entre un índex hash i un índex d'arbre (ho veurem de prop a 06-06).
Errors Comuns i Consells
- Validar la propietat ABC comparant només amb el pare. Un node ha de respectar tots els seus ancestres, no només l'immediat. El validador correcte propaga cotes (mínim, màxim) en descendir — és l'exercici estrella de 06-08.
- Esborrar el cas de dos fills "a la brava". Substituir per un fill qualsevol trenca la propietat. El substitut ha de ser el successor (mínim del subarbre dret) o el predecessor (màxim de l'esquerre) — només ells encaixen entre tots dos subarbres.
- Oblidar reassignar en cridar
_esborrar. El patrónode.esquerre = self._esborrar(node.esquerre, clau)funciona perquè cada crida retorna la nova arrel del subarbre. Cridar sense assignar deixa l'arbre intacte i el bug és silenciós. - Claus no comparables entre si. Barrejar
intistrcom a claus esclata (TypeErroren comparar). I amb claus compostes, vigila l'ordre de la tupla:(prioritat, id)ordena per prioritat primer;(id, prioritat)és un altre índex diferent. - Assumir que "de mitjana s'equilibra". Cert amb claus aleatòries, fals amb claus ordenades o gairebé ordenades — que és el que produeixen els sistemes reals (ids, timestamps). No confiïs en la sort: 06-05 existeix per això.
Exercicis
Exercici 1: conte i el successor d'una clau
Afegeix a ArbreCerca (a) conte(clau) que retorni True/False, i (b) successor(clau) que retorni la menor clau de l'arbre estrictament major que la donada (tant si la donada existeix a l'arbre com si no), o None. Amb l'índex de la lliçó (després d'esborrar el 10): successor(8) → 12, successor(9) → 12, successor(20) → None.
Exercici 2: les tasques pendents de l'interval
Usant per_prioritat i interval, escriu urgents_pendents(arbre, prio_max) que retorni els títols de les tasques amb prioritat entre 1 i prio_max l'estat de les quals sigui "pendent", ordenats per (prioritat, id). El filtre d'estat s'aplica sobre el resultat de l'interval (l'arbre indexa per prioritat, no per estat — un índex per consulta).
Exercici 3: quant fa el meu arbre?
Escriu altura_abc(arbre) (reutilitza l'altura de 06-02 sobre arbre.arrel) i compara-la en dos índexs de 1 023 claus: un construït amb claus ordenades range(1023) i un altre amb les mateixes barrejades. Quina altura mínima era possible? Relaciona els tres números.
Solucions
Solució 1
def conte(self, clau):
# No n'hi ha prou amb "cercar(clau) is not None": un valor podria SER None.
node = self.arrel
while node is not None:
if clau == node.clau:
return True
node = node.esquerre if clau < node.clau else node.dret
return False
def successor(self, clau):
candidat = None
node = self.arrel
while node is not None:
if node.clau > clau:
candidat = node.clau # serveix, però potser n'hi ha un de menor...
node = node.esquerre # ...cerquem-lo a l'esquerra
else:
node = node.dret # massa petit: cap a la dreta
return candidat
print(index.successor(9)) # 12
print(index.successor(20)) # NoneComentari: successor és el patró "millor candidat fins ara": cada node major que la clau s'apunta com a candidat i s'intenta millorar baixant a l'esquerra; els nodes menors o iguals es descarten baixant a la dreta. Un sol descens, O(altura), i sense necessitar que la clau existeixi. La pega subtil de conte és al comentari: a l'índex hem guardat alguna tasca com a None a la demo, i cercar no distingeix "no hi és" de "hi és i val None" — el mateix matís que resolia in davant de get al dict.
Solució 2
def urgents_pendents(arbre, prio_max):
resultat = []
for (prio, id_t), tasca in arbre.interval((1, 0), (prio_max, float("inf"))):
if tasca["estat"] == "pendent":
resultat.append(tasca["titol"])
return resultat
print(urgents_pendents(per_prioritat, 2))
# ["Arreglar el CSS", "Desplegar l'API", "Optimitzar les consultes"]
# (les de prioritat 1 estaven "en curs" i "bloquejada": filtrades)Comentari: l'arbre fa la feina grossa (acotar i ordenar, O(altura + k)) i el filtre fi va després en Python, O(k). És l'arquitectura de qualsevol consulta real: l'índex redueix l'univers, la resta es filtra. Indexar per totes les combinacions possibles no compensa; s'indexa la dimensió més selectiva.
Solució 3
import random
def altura_node(node):
if node is None:
return -1
return 1 + max(altura_node(node.esquerre), altura_node(node.dret))
def altura_abc(arbre):
return altura_node(arbre.arrel)
claus = list(range(1023))
ordenat = construir(claus)
barrejades = claus[:]
random.shuffle(barrejades)
aleatori = construir(barrejades)
print(altura_abc(ordenat)) # 1022 (degenerat: una clau per nivell)
print(altura_abc(aleatori)) # ~20-24 (varia amb la barreja)Comentari: l'altura mínima possible amb 1 023 = 2¹⁰ − 1 nodes és 9 (l'arbre perfecte de 10 nivells, taula de 06-02). L'aleatori queda en ~20: no és perfecte, però continua sent O(log n) — la teoria diu que l'ABC aleatori fa de mitjana ≈ 1,39·log₂ n... però cap garantia no cobreix el cas ordenat, que dona 1022! Tres números, tres mons: l'ideal (9), el probable (≈20) i el catastròfic (1022). La propera lliçó garanteix quedar-se a un pas de l'ideal, arribi el que arribi.
Conclusió
L'ABC ha saldat els comptes pendents: amb una sola regla — menors a l'esquerra, majors a la dreta — TaskFlow té un índex que cerca en O(altura), llista en ordre amb l'inordre (el misteri de 06-03, resolt), troba mínim i successor d'un cop d'ull i respon intervals amb poda, inclosa la consulta "prioritat entre 1 i 3" que va humiliar el hash, via la clau composta (prioritat, id). També saps esborrar sense trencar res (fulla, un fill, i el cas del successor) i coneixes la lletra petita del contracte: tot és O(altura), i l'altura depèn de l'ordre d'arribada. Amb ids autoincrementals — el pa de cada dia — l'ABC degenera en llista i els seus O(log n) s'evaporen, com acabes de mesurar. La propera lliçó ho arregla d'arrel: l'arbre AVL detecta el desequilibri a cada inserció amb un número per node i el corregeix amb rotacions locals, garantint O(log n) passi el que passi. L'índex de TaskFlow és a una lliçó de tornar-se indestructible.
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
