Cerca amb adversari: Minimax i poda alfa-beta
En jocs de dos jugadors amb informació completa (escacs, dames, tic-tac-toe) cal decidir el moviment òptim tenint en compte que l'adversari també juga de manera òptima en contra nostra.
Minimax
Minimax: explora l'arbre de jugades possibles fins a un cert nivell, avaluant els estats finals com a positius si guanya el jugador MAX i negatius si guanya MIN. El valor es propaga cap amunt: als nivells MAX es tria el fill amb valor més alt, als nivells MIN el de valor més baix.
def minimax(estat, profunditat, es_torn_max, obtenir_fills, es_terminal, avaluar):
"""Minimax generic. es_torn_max=True al node arrel si l-IA mou primer."""
if es_terminal(estat) or profunditat == 0:
return avaluar(estat)
fills = obtenir_fills(estat)
if es_torn_max:
return max(
minimax(f, profunditat - 1, False, obtenir_fills, es_terminal, avaluar)
for f in fills
)
else:
return min(
minimax(f, profunditat - 1, True, obtenir_fills, es_terminal, avaluar)
for f in fills
)
Poda alfa-beta: optimització de Minimax que talla branques que no poden canviar el resultat final, sense sacrificar l'optimalitat de la decisió. Manté dos valors:
- α (alfa): la millor puntuació que MAX té garantida fins ara en el camí explorat (un mínim que MAX ja assegura).
- β (beta): la millor puntuació que MIN té garantida fins ara en el camí explorat (un màxim que MIN ja assegura).
Mentre es baixa per l'arbre, cada node hereta i actualitza α i β dels seus avantpassats. La poda es produeix quan α ≥ β en un node: en aquest punt, el jugador del nivell superior ja té una alternativa almenys tan bona en una altra branca, de manera que no cal seguir explorant els fills restants d'aquest node — per bo que sigui el resultat, el jugador contrari mai el deixarà escollir aquest camí. La poda no canvia mai la jugada finalment escollida: només estalvia exploració de branques que ja sabem que no influiran en el resultat.
L'ordre en què s'exploren els nodes afecta molt l'eficiència de la poda: com millor ordenats estiguin (millors moviments primer), més branques es podran descartar — en el cas ideal, la poda alfa-beta redueix la complexitat de O(r^p) a O(r^(p/2)), és a dir, permet explorar el doble de profunditat amb el mateix cost computacional.
Cas real: Minimax i poda alfa-beta al Connecta 4
El Connecta 4 (Connect Four) és un dels exemples més utilitzats per ensenyar Minimax i poda alfa-beta a la pràctica, perquè és prou senzill de programar però prou complex perquè calgui optimitzar-lo de debò.
Per què el Connecta 4 necessita poda alfa-beta
- El tauler té 6 files × 7 columnes = 42 caselles, i cada jugada només pot "deixar caure" una fitxa en una de les 7 columnes (si no és plena) — la gravetat fa que la fitxa caigui fins a la primera posició lliure.
- El factor de ramificació és com a màxim 7 (7 columnes possibles a cada torn), i la partida pot durar fins a 42 jugades.
- L'espai d'estats complet té més de 4,5 bilions de posicions possibles. Explorar-lo sencer amb Minimax pur és inviable en temps real; calen poda alfa-beta, una profunditat d'exploració limitada i una funció heurística que avaluï posicions no terminals.
- El joc va ser "resolt" matemàticament per Victor Allis el 1988: amb joc perfecte, el primer jugador sempre guanya si comença jugant la columna central.
Representació del tauler
Es representa com una graella de 6×7 (llista de llistes), on cada casella val 0 (buida), 1 (fitxa del jugador 1) o 2 (fitxa del jugador 2). Les files s'indexen de baix (fila 0) cap amunt, seguint la gravetat.
FILES, COLUMNES = 6, 7
JUGADOR, IA = 1, 2
def crear_tauler():
return [[0] * COLUMNES for _ in range(FILES)]
def columna_valida(tauler, col):
"""Una columna admet una fitxa si la fila superior encara està buida."""
return tauler[FILES - 1][col] == 0
def fila_disponible(tauler, col):
"""Retorna la fila més baixa lliure d'aquella columna (efecte de la gravetat)."""
for fila in range(FILES):
if tauler[fila][col] == 0:
return fila
def posar_fitxa(tauler, fila, col, peça):
tauler[fila][col] = peça
Detecció de la victòria (test terminal)
Cal comprovar, després de cada jugada, si s'han alineat 4 fitxes iguals en horitzontal, vertical o en qualsevol de les dues diagonals:
def hi_ha_victoria(tauler, peça):
# Horitzontal
for fila in range(FILES):
for col in range(COLUMNES - 3):
if all(tauler[fila][col + i] == peça for i in range(4)):
return True
# Vertical
for col in range(COLUMNES):
for fila in range(FILES - 3):
if all(tauler[fila + i][col] == peça for i in range(4)):
return True
# Diagonal ascendent (/)
for fila in range(FILES - 3):
for col in range(COLUMNES - 3):
if all(tauler[fila + i][col + i] == peça for i in range(4)):
return True
# Diagonal descendent (\)
for fila in range(3, FILES):
for col in range(COLUMNES - 3):
if all(tauler[fila - i][col + i] == peça for i in range(4)):
return True
return False
Funció heurística per a posicions no terminals
Com que no es pot explorar tot l'arbre, cal aturar la cerca a una profunditat màxima (per exemple, 5-7 jugades endavant) i estimar la qualitat de la posició amb una heurística. La tècnica habitual és recórrer totes les finestres de 4 caselles consecutives (horitzontals, verticals i diagonals) i puntuar-les segons quantes fitxes de cada jugador contenen:
| Contingut de la finestra (4 caselles) | Puntuació |
|---|---|
| 4 fitxes pròpies | +100 (victòria) |
| 3 fitxes pròpies + 1 buida | +5 |
| 2 fitxes pròpies + 2 buides | +2 |
| 3 fitxes de l'adversari + 1 buida | −4 (cal blocar-ho com a prioritat) |
| Fitxa pròpia a la columna central | +3 (bonificació: la columna central participa en més línies guanyadores possibles) |
def puntuar_finestra(finestra, peça):
puntuacio = 0
adversari = JUGADOR if peça == IA else IA
if finestra.count(peça) == 4:
puntuacio += 100
elif finestra.count(peça) == 3 and finestra.count(0) == 1:
puntuacio += 5
elif finestra.count(peça) == 2 and finestra.count(0) == 2:
puntuacio += 2
if finestra.count(adversari) == 3 and finestra.count(0) == 1:
puntuacio -= 4 # Bloquejar l'amenaça de l'adversari és prioritari
return puntuacio
def avaluar_posicio(tauler, peça):
puntuacio = 0
# Bonificació per fitxes a la columna central (columna 3)
columna_central = [tauler[fila][COLUMNES // 2] for fila in range(FILES)]
puntuacio += columna_central.count(peça) * 3
# Totes les finestres horitzontals, verticals i diagonals
for fila in range(FILES):
for col in range(COLUMNES - 3):
puntuacio += puntuar_finestra(
[tauler[fila][col + i] for i in range(4)], peça
)
for col in range(COLUMNES):
for fila in range(FILES - 3):
puntuacio += puntuar_finestra(
[tauler[fila + i][col] for i in range(4)], peça
)
for fila in range(FILES - 3):
for col in range(COLUMNES - 3):
puntuacio += puntuar_finestra(
[tauler[fila + i][col + i] for i in range(4)], peça
)
puntuacio += puntuar_finestra(
[tauler[fila + 3 - i][col + i] for i in range(4)], peça
)
return puntuacio
Minimax amb poda alfa-beta i ordenació de moviments
L'últim ingredient clau és explorar primer les columnes centrals: com que solen ser les millors jugades, examinar-les abans permet que la poda alfa-beta descarti moltes més branques que si s'exploressin les columnes en ordre 0,1,2...6.
import math
ORDRE_COLUMNES = [3, 2, 4, 1, 5, 0, 6] # Centre primer: millora molt la poda
def minimax_connecta4(tauler, profunditat, alfa, beta, es_torn_ia):
columnes_valides = [c for c in ORDRE_COLUMNES if columna_valida(tauler, c)]
es_terminal = (
hi_ha_victoria(tauler, JUGADOR)
or hi_ha_victoria(tauler, IA)
or len(columnes_valides) == 0
)
if profunditat == 0 or es_terminal:
if es_terminal:
if hi_ha_victoria(tauler, IA):
return None, math.inf
elif hi_ha_victoria(tauler, JUGADOR):
return None, -math.inf
else:
return None, 0 # Empat: tauler ple
return None, avaluar_posicio(tauler, IA) # Profunditat maxima: heuristica
if es_torn_ia:
millor_valor = -math.inf
millor_columna = columnes_valides[0]
for col in columnes_valides:
fila = fila_disponible(tauler, col)
copia = [f[:] for f in tauler]
posar_fitxa(copia, fila, col, IA)
_, valor = minimax_connecta4(copia, profunditat - 1, alfa, beta, False)
if valor > millor_valor:
millor_valor = valor
millor_columna = col
alfa = max(alfa, millor_valor)
if alfa >= beta:
break # Poda: MIN mai deixaria arribar aquí
return millor_columna, millor_valor
else:
millor_valor = math.inf
millor_columna = columnes_valides[0]
for col in columnes_valides:
fila = fila_disponible(tauler, col)
copia = [f[:] for f in tauler]
posar_fitxa(copia, fila, col, JUGADOR)
_, valor = minimax_connecta4(copia, profunditat - 1, alfa, beta, True)
if valor < millor_valor:
millor_valor = valor
millor_columna = col
beta = min(beta, millor_valor)
if alfa >= beta:
break # Poda: MAX mai deixaria arribar aquí
return millor_columna, millor_valor
# Ús: la IA calcula la seva jugada explorant 6 torns (3 seus + 3 de l'adversari) endavant
columna_escollida, valor = minimax_connecta4(
tauler, profunditat=6, alfa=-math.inf, beta=math.inf, es_torn_ia=True
)
Amb aquesta implementació, augmentar la profunditat fa que la IA jugui millor però trigui més a decidir; la poda alfa-beta combinada amb l'ordenació centre-primer és el que fa que, a la pràctica, es puguin explorar 6-8 torns endavant en temps real en lloc dels 2-3 que permetria un Minimax sense optimitzar.
Miniactivitat — AC5071/01/06 — Minimax al tres en ratlla (o al Connecta 4)
Implementa el tres en ratlla (tic-tac-toe) complet amb un motor Minimax que mai perdi (com a màxim empata). Compara el nombre de nodes explorats amb i sense poda alfa-beta per al mateix tauler inicial, i explica per quin motiu la poda no canvia mai la jugada escollida per l'algorisme.
Ampliació opcional: adapta el codi de Connecta 4 d'aquesta pàgina perquè es pugui jugar (per consola) contra la IA. Prova diferents profunditats d'exploració (2, 4, 6) i mesura el temps que triga a decidir cada jugada. Explica per què cal limitar la profunditat en aquest joc i no en el tres en ratlla.