Divideix i venceràs trosseja, greedy aposta, la programació dinàmica recorda. Però hi ha problemes on res d'això no basta: problemes de restriccions, on la solució és una combinació de decisions que han de ser compatibles entre si — assignar conductors a torns, quadrar horaris, generar rutes d'inspecció vàlides — i on no hi ha recurrència ni criteri voraç que hi valgui. Per a aquests problemes existeix la quarta estratègia del mòdul: el backtracking (tornada enrere), una exploració sistemàtica de l'espai de solucions que avança decisió a decisió i, tan bon punt detecta un carreró sense sortida, desfà l'últim pas i prova una altra alternativa. És la més costosa de les quatre estratègies — el seu cost és exponencial en el pitjor cas — però, ben podada, resol problemes davant dels quals les altres arronsen les espatlles. Amb ella tancarem el mòdul comparant cara a cara les quatre estratègies de disseny.
Contingut
- La idea: explorar un arbre de decisions amb marxa enrere
- L'esquema general: triar, explorar, desfer
- Exemple RutaBus: assignar conductors a torns amb incompatibilitats
- La poda: per què importa tant
- N-Reines: el clàssic compacte
- Backtracking vs força bruta vs PD — i les quatre estratègies cara a cara
- El cost exponencial
La idea: explorar un arbre de decisions amb marxa enrere
Imagina que construeixes la solució per etapes: primera decisió, segona decisió, tercera... Cada seqüència possible de decisions és una branca d'un arbre de decisions; les fulles són solucions candidates completes. La força bruta genera totes les fulles i comprova quines valen. El backtracking és més llest:
- Recorre l'arbre en profunditat, prenent una decisió cada vegada (la pila de recursió de 01-02 i 02-02 porta el compte del camí actual).
- A cada node comprova si la solució parcial encara pot dur a una solució vàlida. Si no pot, poda: abandona aquella branca sencera sense generar-la.
- En esgotar les alternatives d'un node, retrocedeix (backtrack) al node anterior i prova l'opció següent.
La diferència amb greedy és radical: greedy pren una decisió i no torna mai; el backtracking es reserva sempre el dret a penedir-se. La diferència amb la força bruta és la poda: no s'examinen combinacions el fracàs de les quals ja està garantit a mitja construcció.
L'esquema general: triar, explorar, desfer
Gairebé tot backtracking s'escriu amb la mateixa plantilla de tres temps. Val la pena memoritzar-la:
def backtracking(solucio_parcial):
# La solució parcial ja és completa?
if es_completa(solucio_parcial):
registra(solucio_parcial) # guardar-la, imprimir-la, comptar-la...
return
# Provar cada alternativa per a la decisió següent
for candidat in candidats_seguent_pas(solucio_parcial):
if es_valid(candidat, solucio_parcial): # PODA: té futur?
solucio_parcial.append(candidat) # 1. TRIAR
backtracking(solucio_parcial) # 2. EXPLORAR (recursió)
solucio_parcial.pop() # 3. DESFER (la tornada enrere!)Comentem les peces clau:
es_completa: l'equivalent al cas base de la recursió (01-02). Aquí una solució completa no talla l'algorisme: es registra i se'n continuen buscant d'altres (llevat que només en vulguem una: llavors es pot retornarTruei propagar el tall).es_valid: la funció de poda. Comprova restriccions sobre la solució parcial; com més aviat detecti la inviabilitat, més arbre s'estalvia.append/pop: la parella sagrada. Tot allò que el pas "triar" modifiqui, el pas "desfer" ho ha de restaurar exactament. Treballem sobre una única estructura compartida (referència, no còpia — recorda 02-02) que es va teixint i desteixint.- L'estat del camí viu a la pila de crides: en retornar de la recursió som, automàticament, un nivell més amunt de l'arbre.
Exemple RutaBus: assignar conductors a torns amb incompatibilitats
El problema. RutaBus ha de cobrir demà els tres torns de la línia L1 — Matí, Tarda i Nit — amb els seus tres conductors de plantilla: Anna, Bruno i Carla. Cada conductor fa exactament un torn, però hi ha restriccions de disponibilitat:
- L'Anna no pot fer el torn de nit (cura familiar).
- En Bruno no pot fer el de matí (acaba avui a les 23:00: descans legal).
- La Carla no pot fer el de tarda (formació obligatòria).
Volem totes les assignacions vàlides (planificació necessita alternatives per si algú es posa malalt).
TORNS = ["Matí", "Tarda", "Nit"]
CONDUCTORS = ["Anna", "Bruno", "Carla"]
INCOMPATIBLES = { # conductor -> torns que NO pot fer
"Anna": {"Nit"},
"Bruno": {"Matí"},
"Carla": {"Tarda"},
}
def assigna_torns(assignacio=None, solucions=None):
"""Assigna un conductor diferent a cada torn respectant INCOMPATIBLES.
assignacio: dict torn -> conductor (solució parcial)."""
if assignacio is None:
assignacio, solucions = {}, []
if len(assignacio) == len(TORNS): # completa?
solucions.append(dict(assignacio)) # còpia: la parcial es reutilitza
return solucions
torn = TORNS[len(assignacio)] # decisió següent: aquest torn
for conductor in CONDUCTORS:
lliure = conductor not in assignacio.values()
compatible = torn not in INCOMPATIBLES[conductor]
if lliure and compatible: # PODA doble
assignacio[torn] = conductor # TRIAR
assigna_torns(assignacio, solucions) # EXPLORAR
del assignacio[torn] # DESFER
return solucions
for s in assigna_torns():
print(s)
# {'Matí': 'Anna', 'Tarda': 'Bruno', 'Nit': 'Carla'}
# {'Matí': 'Carla', 'Tarda': 'Anna', 'Nit': 'Bruno'}Detalls d'implementació que separen un backtracking correcte d'un de traïdor:
solucions.append(dict(assignacio)): guardem una còpia. Si guardéssim la referència, eldelposterior la buidaria — el clàssic error de còpies vs referències de 02-02.- La decisió de cada nivell és "quin conductor fa el torn k": l'arbre té profunditat 3 (una per torn) i fins a 3 branques per node.
- La poda és doble: conductor ja ocupat (
lliure) i restricció de disponibilitat (compatible).
L'arbre de cerca complet, amb les podes marcades:
flowchart TD
R["Matí = ?"] --> A["Anna ✔"]
R -.-> B["Bruno ✘ poda: no fa matins"]
R --> C["Carla ✔"]
A --> AT["Tarda = ?"]
AT --> AB["Bruno ✔"]
AT -.-> AC["Carla ✘ poda: no fa tardes"]
AB --> ABN["Nit = Carla ✔ SOLUCIÓ 1"]
C --> CT["Tarda = ?"]
CT --> CA["Anna ✔"]
CT --> CB["Bruno ✔"]
CA --> CAN["Nit = Bruno ✔ SOLUCIÓ 2"]
CB --> CBN["Nit = Anna ✘ poda: no fa nits → RETROCÉS"]
Segueix la branca dreta amb el dit: després d'assignar Matí=Carla i Tarda=Bruno, l'únic conductor lliure per a Nit és l'Anna — però l'Anna no fa nits. La branca mor, l'algorisme desfà Tarda=Bruno, prova l'alternativa següent (ja no n'hi ha), desfà Matí=Carla... i com que tampoc no queden opcions a l'arrel, acaba amb les 2 solucions trobades. Aquest moviment de retrocés és literalment l'execució del del assignacio[torn] en tornar de la recursió.
Sense podes, hauríem generat les 3! = 6 permutacions completes i descartat 4. Aquí l'estalvi és modest; amb 20 torns i 20 conductors, 20! ≈ 2,4 × 10¹⁸ permutacions fan la diferència entre "impossible" i "a l'instant" — si les podes tallen aviat.
La poda: per què importa tant
La poda és la diferència entre backtracking i suïcidi combinatori. Dos principis:
- Podar com més aviat millor. Una restricció comprovada al nivell 2 elimina subarbres sencers; la mateixa restricció comprovada a la fulla només elimina una fulla. Per això
es_validopera sobre la solució parcial, no sobre la completa. - Ordenar les decisions per fallar aviat. Convé decidir primer la variable més restringida (el torn amb menys conductors possibles): els fracassos afloren a dalt de l'arbre, on podar és barat. És una heurística d'ordre, no canvia la correcció.
| Estratègia de comprovació | Nodes explorats (idea) | Cost |
|---|---|---|
| Generar-ho tot i filtrar al final (força bruta) | Totes les fulles: kⁿ o n! | Inassumible ja amb n ≈ 12-15 |
| Podar a cada node (backtracking) | Només branques "amb futur" | Exponencial en el pitjor cas, útil a la pràctica |
Convé ser honestos: la poda no canvia el pitjor cas. Un adversari pot fabricar instàncies on gairebé res no es poda. El backtracking pertany a la família O(2ⁿ)/O(n!) de la jerarquia de 01-03, i cap poda no el treu d'allà en general; el que fa és tornar-lo practicable en les instàncies reals, que rarament són adversàries.
N-Reines: el clàssic compacte
El problema de les N reines — col·locar N reines en un tauler N×N sense que cap no s'ataqui (mateixa fila, columna o diagonal) — és el "hola món" del backtracking, i ens el podem permetre aquí perquè no pertany a cap altre mòdul. Decisió per nivell: a quina columna va la reina de la fila k.
def n_reines(n):
"""Retorna totes les solucions; cadascuna és una llista on
solucio[fila] = columna de la reina d'aquella fila."""
solucions = []
def valida(posades, col):
fila = len(posades)
for f, c in enumerate(posades):
if c == col: # mateixa columna
return False
if abs(c - col) == abs(f - fila): # mateixa diagonal
return False
return True # mateixa fila: impossible per disseny
def explora(posades):
if len(posades) == n: # completa
solucions.append(posades[:])
return
for col in range(n):
if valida(posades, col): # PODA
posades.append(col) # TRIAR
explora(posades) # EXPLORAR
posades.pop() # DESFER
explora([])
return solucions
print(len(n_reines(4))) # 2
print(n_reines(4)) # [[1, 3, 0, 2], [2, 0, 3, 1]]Fixa't en el truc de representació: guardar només la columna per fila elimina de soca-rel els conflictes de fila (cada fila té exactament una reina) — triar bé l'estructura de la solució parcial és, en si mateix, una forma de poda. Per a n = 8, la força bruta sobre les 8⁸ ≈ 16,7 milions de col·locacions (o les 8! = 40 320 permutacions, amb la representació bona) es queda en uns ~2 000 nodes explorats amb poda: aquest és l'ordre de magnitud de l'estalvi.
Backtracking vs força bruta vs PD — i les quatre estratègies cara a cara
Primer el duel directe:
| Força bruta | Backtracking | Programació dinàmica | |
|---|---|---|---|
| Què explora | Totes les combinacions completes | Només branques viables (poda) | Tots els subproblemes, una vegada cadascun |
| Requisit | Cap | Restriccions comprovables sobre solucions parcials | Subestructura òptima + subproblemes solapats |
| Resultat | Exacte | Exacte | Exacte |
| Cost típic | kⁿ, n! | Exponencial podat | Polinòmic (nre. de subproblemes) |
| Quan usar-lo | Mai, llevat de n minúscul | Restriccions sense estructura de recurrència | Quan la recurrència existeix |
La regla del polze: si pots definir un subproblema i una recurrència, la PD converteix l'arbre exponencial en una taula polinòmica — fes-ho. El backtracking és per a quan els estats no es repeteixen o no es deixen resumir (cada solució parcial és única, com una assignació concreta de conductors): allà no hi ha res a memoïtzar i només queda buscar amb intel·ligència.
I el tancament del mòdul — les quatre estratègies cara a cara:
| Estratègia | Idea en una frase | Pregunta que delata el problema | Cost típic | Òptima garantida |
|---|---|---|---|---|
| Divideix i venceràs (03-01) | Trossejar en subproblemes independents i combinar | "Puc partir-lo en meitats independents?" | O(n log n) | Sí (si el disseny és correcte) |
| Greedy (03-02) | Decidir el millor ara, sense tornada enrere | "Existeix un criteri local que mai no tanca la porta a l'òptim?" | O(n log n) | Només amb demostració |
| Programació dinàmica (03-03) | Explorar totes les decisions memoritzant subproblemes | "Subproblemes solapats amb recurrència?" | O(n·m) polinòmic | Sí |
| Backtracking (03-04) | Construir per etapes, podar i desfer | "Restriccions que es poden comprovar a mitges?" | Exponencial podat | Sí (explora tot allò viable) |
Davant d'un problema nou de RutaBus, aquest és l'ordre d'interrogatori recomanat: greedy demostrable? (el més barat); si no, hi ha recurrència amb solapament? (PD); es parteix en meitats independents? (divideix i venceràs); res de tot això però hi ha restriccions comprovables? (backtracking). I si ni tan sols això, potser toca una heurística assumida com a tal — com el nostre veterà recorregut_supervisor.
El cost exponencial
Analitzem el backtracking amb les eines del Mòdul 2. En el pitjor cas, l'arbre de decisions té ramificació b (alternatives per nivell) i profunditat n (decisions): fins a bⁿ nodes. Per a assignacions tipus permutació, l'arbre sense podar té n! fulles. És el cim de la jerarquia de 01-03, la zona on sumar un element multiplica el temps.
- Temps: O(bⁿ) o O(n!) en el pitjor cas, multiplicat pel cost d'
es_valida cada node (mantén-lo barat: idealment O(1) o O(n) com a N-Reines!). - Espai: sorprenentment modest — O(n) per a la solució parcial més O(n) de pila de recursió (02-02). El backtracking no guarda l'arbre: el recorre. Per això pot atacar espais de cerca astronòmics amb memòria de butxaca.
- El millor cas i el cas mitjà (02-03) depenen brutalment de les podes i de l'ordre d'exploració: dues implementacions correctes del mateix problema poden diferir en factors de milers. En backtracking, l'enginyeria de la poda és el rendiment.
Errors Comuns i Consells
- Desfer a mitges. Si "triar" toca dues estructures (l'assignació i un conjunt d'ocupats), "desfer" ha de restaurar totes dues. Qualsevol asimetria corromp silenciosament les branques següents. Revisa que cada
append/add/assignació tingui el seupop/remove/delmirall. - Guardar referències en lloc de còpies.
solucions.append(assignacio)guarda un objecte que continuarà mutant; al final tindràs una llista de solucions buides o totes iguals. Copia (dict(assignacio),llista[:]) en registrar — el matís de 02-02 atacant de nou. - Podar tard. Comprovar la validesa només a les fulles converteix el backtracking en força bruta amb passos extres. Pregunta't quines restriccions pots avaluar amb la solució a mitges.
- Oblidar el retorn després de registrar. Sense el
returnen completar la solució, el bucle continua "estenent" una solució ja completa, amb errors d'índex o solucions fantasma. - Recalcular
es_validdes de zero. A N-Reines,validaés O(n); mantenir conjunts de columnes i diagonals ocupades ho abaixa a O(1) per consulta a canvi d'una mica de memòria — el trade-off de 02-02, una altra vegada. (I afegeix dues estructures més que triar/desfer ha de mantenir en mirall.) - Usar backtracking on hi ha recurrència. Si detectes subproblemes repetits, estàs pagant preu exponencial per una cosa que la PD resol en polinòmic. Repassa la taula comparativa abans d'escriure ni una línia.
Exercicis
Exercici 1
S'incorpora un quart torn ("Reforç") i un quart conductor ("David"), amb aquestes restriccions addicionals: en David només pot fer Reforç o Nit, i en Bruno tampoc no pot fer Reforç. Adapta assigna_torns (n'hi ha prou d'actualitzar les constants) i calcula a mà, dibuixant l'arbre, quantes solucions vàlides hi ha. Comprova-ho després amb el codi.
Exercici 2
Escriu rutes_inspeccio(xarxa, origen, k) que generi totes les rutes d'inspecció d'exactament k parades que comencin a origen, sense repetir parada, movent-se només entre parades connectades. La xarxa és un diccionari d'adjacència, per exemple:
xarxa = {
"Plaça Major": ["Estació Nord", "Parc del Riu"],
"Estació Nord": ["Plaça Major", "Hospital Central"],
"Hospital Central": ["Estació Nord", "Parc del Riu"],
"Parc del Riu": ["Plaça Major", "Hospital Central"],
}Exercici 3
A n_reines, la funció valida recorre les reines ja col·locades: O(n) per comprovació. Reescriu l'algorisme mantenint tres conjunts — columnes ocupades, diagonals fila − col i diagonals fila + col — perquè la comprovació sigui O(1). No oblidis el mirall triar/desfer sobre els tres conjunts.
Solucions
Solució 1
Constants noves:
TORNS = ["Matí", "Tarda", "Nit", "Reforç"]
CONDUCTORS = ["Anna", "Bruno", "Carla", "David"]
INCOMPATIBLES = {
"Anna": {"Nit"},
"Bruno": {"Matí", "Reforç"},
"Carla": {"Tarda"},
"David": {"Matí", "Tarda"}, # només Reforç o Nit
}Arbre: per a Matí només hi caben l'Anna o la Carla. (a) Matí=Anna → Tarda ∈ {Bruno} (la Carla no fa tardes, en David tampoc) → Nit ∈ {Carla, David}: amb Nit=Carla queda Reforç=David ✔; amb Nit=David queda Reforç=Carla ✔. (b) Matí=Carla → Tarda ∈ {Anna, Bruno}. Amb Tarda=Anna → Nit ∈ {Bruno, David}: Nit=Bruno deixa Reforç=David ✔; Nit=David deixa Reforç=Bruno ✘ (en Bruno no fa reforços: poda i retrocés). Amb Tarda=Bruno → Nit ∈ {David} (l'Anna no fa nits) → Reforç=Anna ✔. Total: 4 solucions. Si el teu recompte manual difereix del codi, guanya el codi — i l'exercici passa a ser trobar la branca que vas comptar malament.
Solució 2
def rutes_inspeccio(xarxa, origen, k):
solucions = []
def explora(ruta):
if len(ruta) == k: # completa
solucions.append(ruta[:]) # còpia
return
for veina in xarxa[ruta[-1]]: # candidates: parades connectades
if veina not in ruta: # PODA: sense repetir parada
ruta.append(veina) # TRIAR
explora(ruta) # EXPLORAR
ruta.pop() # DESFER
explora([origen])
return solucions
print(rutes_inspeccio(xarxa, "Plaça Major", 3))
# [['Plaça Major', 'Estació Nord', 'Hospital Central'],
# ['Plaça Major', 'Parc del Riu', 'Hospital Central']]La plantilla de tres temps, intacta; només canvien els candidats (veïnes a la xarxa) i la poda (no repetir). El veina not in ruta és una cerca en llista O(k) (cost ocult de 02-01); amb un set paral·lel baixaria a O(1). Aquesta funció és, a més, la nostra primera exploració de la xarxa de RutaBus com a graf — una llavor del que ve al Mòdul 4.
Solució 3
def n_reines_rapid(n):
solucions = []
cols, diag1, diag2 = set(), set(), set()
def explora(posades):
fila = len(posades)
if fila == n:
solucions.append(posades[:])
return
for col in range(n):
if col in cols or (fila - col) in diag1 or (fila + col) in diag2:
continue # PODA en O(1)
posades.append(col) # TRIAR (x4 estructures)
cols.add(col); diag1.add(fila - col); diag2.add(fila + col)
explora(posades) # EXPLORAR
posades.pop() # DESFER (x4, en mirall)
cols.remove(col); diag1.remove(fila - col); diag2.remove(fila + col)
explora([])
return solucionsLes diagonals "descendents" comparteixen el valor fila − col i les "ascendents" el valor fila + col; tres consultes a conjunts O(1) substitueixen el bucle O(n). El preu: quatre estructures que triar/desfer han de mantenir perfectament sincronitzades — comprova que cada add té el seu remove.
Conclusió
El backtracking completa el nostre repertori: construir la solució per etapes, podar les branques sense futur i desfer per explorar alternatives, amb la plantilla triar–explorar–desfer com a esquelet universal i la disciplina del mirall (tot el que es fa es desfà) com a regla d'or. L'hem aplicat a l'assignació de conductors de RutaBus, a les rutes d'inspecció sobre la xarxa — el nostre primer passeig per ella com a graf — i al clàssic N-Reines, i n'hem assumit la naturalesa: cost exponencial en el pitjor cas que només la qualitat de les podes torna practicable. Amb això, el Mòdul 3 queda complet: divideix i venceràs per a subproblemes independents, greedy per a decisions locals demostrablement segures, programació dinàmica per a subproblemes solapats amb recurrència, i backtracking per a restriccions que exigeixen buscar amb tornada enrere — quatre motlles, quatre preguntes de diagnòstic, i el judici del Mòdul 2 per sotmetre a anàlisi qualsevol disseny que en surti. Al Mòdul 4 recollirem la collita: els algorismes clàssics que van néixer d'aquestes estratègies — la cerca binària i les ordenacions merge sort i quick sort com a fills de divideix i venceràs, Dijkstra com el greedy que sí que és òptim, Floyd-Warshall com a programació dinàmica sobre grafs — implementats i analitzats peça a peça, començant per la joia de l'eficiència logarítmica: la cerca binària.
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
