# Imports
import time
from typing import Dict, Set, Tuple, Optional, List
print('Solveur Sudoku - Approche Norvig')Solveur Sudoku - Approche Norvig
Niveau : Programmation par contraintes | Durée : ~20 min | Prérequis : Sudoku-06 AIMA-CSP (Python)
| << Précédent | Sommaire | Suivant >> |
|---|---|---|
| Sudoku-06-AIMA-CSP-Python | Index | Sudoku-08-HumanStrategies-Python |
Peter Norvig a proposé en 2006 une solution élégante en ~80 lignes de Python. L’idée clé :
Lien : Solving Every Sudoku Puzzle par Peter Norvig
Nous définissons la structure du Sudoku : 81 cases, 27 unités (9 lignes, 9 colonnes, 9 blocs).
# Définition des constantes
digits = '123456789'
rows = 'ABCDEFGHI'
cols = digits
squares = [r + c for r in rows for c in cols]
# Unites : lignes, colonnes, blocs
unitlist = (
[['A'+c, 'B'+c, 'C'+c, 'D'+c, 'E'+c, 'F'+c, 'G'+c, 'H'+c, 'I'+c] for c in cols] +
[[r+'1', r+'2', r+'3', r+'4', r+'5', r+'6', r+'7', r+'8', r+'9'] for r in rows] +
[[r+c for r in rs for c in cs] for rs in ('ABC','DEF','GHI') for cs in ('123','456','789')]
)
# Dictionnaire : square -> liste des 3 unités
units = {s: [u for u in unitlist if s in u] for s in squares}
# Dictionnaire : square -> ensemble des 20 pairs
peers = {s: set(sum(units[s], [])) - {s} for s in squares}
print(f'Carres: {len(squares)}')
print(f'Unites: {len(unitlist)}')
print(f'Pairs de A1: {len(peers["A1"])}')
print(f'Unites de A1: {units["A1"]}')Carres: 81
Unites: 27
Pairs de A1: 20
Unites de A1: [['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1', 'I1'], ['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9'], ['A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3']]
La sortie donne les trois nombres qui structurent tout l’algorithme. 81 carrés : les cellules A1-I9. 27 unités : 9 lignes + 9 colonnes + 9 blocs — la contrainte du jeu dit qu’une unité est une permutation de 1-9. 20 pairs de A1 : le voisinage d’une cellule — lisez la décomposition : 8 camarades de colonne + 8 camarades de ligne + 4 camarades de bloc restants = 20, et les trois unités de A1 sont listées explicitement (colonne, ligne, bloc 3x3). C’est ce voisinage que eliminate parcourt : quand une valeur est fixée dans une cellule, ses 20 pairs la perdent immédiatement. Toute la puissance de l’approche Norvig tient dans cette table précalculée — construite une fois, interrogée des millions de fois.
Un dernier détail de lecture : les trois unités de A1 sont listées colonne d’abord — c’est l’ordre d’itération du dictionnaire des unités par carré, aucun ordre n’étant sémantiquement privilégié pour l’algorithme ; seul compte le contenu des 27 ensembles.
Les deux opérations fondamentales :
| Règle | Description |
|---|---|
| Peer Elimination | Si A1 = 1, alors 1 est éliminé de tous les pairs de A1 |
| Singleton Unit | Si seule la case A1 peut contenir 1 dans son bloc, alors A1 = 1 |
Le cœur de Norvig tient en une inversion : assign(values, s, d) n’écrit pas « la cellule s vaut d » — il appelle eliminate pour retirer toutes les autres valeurs de s. Et eliminate(values, s, d) fait le vrai travail, en trois temps : (a) retirer d des candidats de s ; (b) si s est réduit à un seul candidat, éliminer cette valeur de tous ses 20 pairs (récursion en cascade) ; (c) si une unité de s ne peut plus placer d que dans une seule cellule, y assigner d (le singleton caché). Chaque elimination déclenche donc potentiellement d’autres assignations — c’est cette propagation mutuellement récursive qui résout l’essentiel des grilles avant même que search ne branche.
Et le lien avec search est d’une élégance minimaliste : MRV (choisir la cellule au domaine minimal) s’y écrit min(values, key=lambda s: len(values[s])) — le domaine étant la chaîne de candidats, l’heuristique entière tient en un tri par longueur de chaîne.
def assign(values: Dict[str, str], s: str, d: str) -> Optional[Dict[str, str]]:
"""Assigne d à s en éliminant toutes les autres valeurs.
Renvoie values si succès, None si contradiction.
"""
other_values = values[s].replace(d, '')
if all(eliminate(values, s, d2) for d2 in other_values):
return values
else:
return None
def eliminate(values: Dict[str, str], s: str, d: str) -> bool:
"""Élimine d de values[s]; propage les conséquences."""
if d not in values[s]:
return True # Déjà éliminé
values[s] = values[s].replace(d, '')
# (1) Si une case n'a qu'une valeur, l'éliminer des pairs
if len(values[s]) == 0:
return False # Contradiction: plus de valeurs
elif len(values[s]) == 1:
d2 = values[s]
if not all(eliminate(values, s2, d2) for s2 in peers[s]):
return False
# (2) Si une unité n'a qu'une place pour d, l'y assigner
for u in units[s]:
dplaces = [s2 for s2 in u if d in values[s2]]
if len(dplaces) == 0:
return False # Contradiction: pas de place pour d
elif len(dplaces) == 1:
if not assign(values, dplaces[0], d):
return False
return True
print("Fonctions definies : assign, eliminate")Fonctions definies : assign, eliminate
Une grille est représentée comme un dictionnaire {square: digits} où digits est : - Un chiffre (‘1’-‘9’) si la case est résolue - Une chaîne de chiffres possibles si la case n’est pas résolue
Format d’entrée : chaîne de 81 caractères (ou avec . et 0 pour les cases vides).
def parse_grid(grid: str) -> Optional[Dict[str, str]]:
"""Convertit une grille string en dictionnaire {square: digits}."""
values = {s: digits for s in squares} # Toutes les valeurs possibles
for s, d in grid_values(grid).items():
if d in digits and not assign(values, s, d):
return None # Contradiction
return values
def grid_values(grid: str) -> Dict[str, str]:
"""Convertit une grille en dictionnaire {square: char}."""
chars = [c for c in grid if c in digits or c in '0.']
assert len(chars) == 81
return dict(zip(squares, chars))
# Test
test_grid = '..3.2.6..9..3.5..1..18.64....81.29..7.......8..67.82....26.95..8..2.3..9..5.1.3..'
print(f'Grille test: {len(test_grid)} caracteres')Grille test: 81 caracteres
La grille est une chaîne de 81 caractères lue ligne par ligne : un chiffre = un indice fixé, un point ou un zéro = une cellule libre. C’est l’encodage canonique de l’essai de Norvig — compact, insensible aux espaces, facile à copier-coller dans un test. Au parsing, chaque point devient le domaine complet 123456789 et chaque indice un domaine singleton : le solveur ne verra plus jamais la différence entre les deux — tout son travail consiste à réduire des ensembles de candidats jusqu’à ce que chacun soit un singleton.
Si la propagation ne suffit pas, on utilise le backtracking avec l’heuristique MRV :
def solve(grid: str) -> Optional[Dict[str, str]]:
"""Résout une grille Sudoku."""
return search(parse_grid(grid))
def search(values: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
"""Backtracking avec heuristique MRV."""
if values is None:
return None # Échec
if all(len(values[s]) == 1 for s in squares):
return values # Résolu !
# Choisir la case avec le moins de valeurs (MRV)
n, s = min((len(values[s]), s) for s in squares if len(values[s]) > 1)
# Essayer chaque valeur
for d in values[s]:
result = search(assign(values.copy(), s, d))
if result:
return result
return None
print("Fonctions definies : solve, search (backtracking MRV)")Fonctions definies : solve, search (backtracking MRV)
La fonction display() formate une grille de manière lisible.
def display(values: Dict[str, str]) -> str:
"""Affiche une grille."""
width = 1 + max(len(values[s]) for s in squares)
line = '+'.join(['-' * (width * 3)] * 3)
result = []
for r in rows:
result.append(''.join(values[r + c].center(width) + ('|' if c in '36' else '')
for c in cols))
if r in 'CF':
result.append(line)
return '\n'.join(result)
print("Fonction definie : display")Fonction definie : display
Testons le solveur sur une grille facile.
# Test avec une grille facile
easy = '..3.2.6..9..3.5..1..18.64....81.29..7.......8..67.82....26.95..8..2.3..9..5.1.3..'
print('Puzzle:')
values = parse_grid(easy)
print(display(values))
print('\nResolution...')
start = time.time()
solution = solve(easy)
elapsed = (time.time() - start) * 1000
if solution:
print(f'\nResolu en {elapsed:.2f} ms')
print(display(solution))
else:
print('Pas de solution')Puzzle:
4 8 3 |9 2 1 |6 5 7
9 6 7 |3 4 5 |8 2 1
2 5 1 |8 7 6 |4 9 3
------+------+------
5 4 8 |1 3 2 |9 7 6
7 2 9 |5 6 4 |1 3 8
1 3 6 |7 9 8 |2 4 5
------+------+------
3 7 2 |6 8 9 |5 1 4
8 1 4 |2 5 3 |7 6 9
6 9 5 |4 1 7 |3 8 2
Resolution...
Resolu en 2.67 ms
4 8 3 |9 2 1 |6 5 7
9 6 7 |3 4 5 |8 2 1
2 5 1 |8 7 6 |4 9 3
------+------+------
5 4 8 |1 3 2 |9 7 6
7 2 9 |5 6 4 |1 3 8
1 3 6 |7 9 8 |2 4 5
------+------+------
3 7 2 |6 8 9 |5 1 4
8 1 4 |2 5 3 |7 6 9
6 9 5 |4 1 7 |3 8 2
Regardez bien la sortie : la grille affichée avant « Resolution… » ne contient aucun point — le puzzle de test est fourni déjà résolu (c’est une grille complète, chaque ligne une permutation de 1-9). Le solveur la re-démontre donc en 2,67 ms : parsing en domaines, propagation assign/eliminate jusqu’au point fixe — sur une grille complète, la propagation seule referme tout, sans qu’un seul embranchement de recherche soit nécessaire. Le temps mesuré couvre l’aller-retour complet chaîne → domaines → solution. Pour situer dans la série : le jumeau C# de ce notebook résout la grille témoin à 45 indices par propagation + recherche, et Sudoku-06 mesure le même profil (81 assignations, 0 backtrack) — trois langues, une même hiérarchie : la propagation fait le travail.
Testons le solveur sur des puzzles de différentes difficultés.
Objectif Implementez le solveur Norvig sans propagation : la fonction separee solve_without_propagation du squelette ci-dessous doit resoudre une grille avec assign + backtracking MRV uniquement, sans jamais appeler eliminate. Comparez le nombre d’appels recursifs et le temps de resolution avec le solveur complet solve sur les memes grilles.
Indice Travaillez uniquement dans la fonction du squelette — ne modifiez pas solve lui-meme : le benchmark juste apres cet exercice l’appelle tel quel. Pour compter les appels, un simple compteur global incremente dans la boucle de backtracking suffit ; testez d’abord sur les grilles difficiles (Sudoku_hardest.txt), la ou la propagation fait la plus grosse difference.
# EXERCICE : Analyser l'impact de la propagation des contraintes
def solve_without_propagation(grid: str) -> Optional[Dict[str, str]]:
# TODO: Implementez un solveur Norvig sans propagation de contraintes
# (uniquement assign + backtracking, pas d'eliminate)
# Comparez le nombre d'appels recursifs avec le solveur complet
result = None # TODO etudiant
return result
print("Exercice a completer")Exercice a completer
from pathlib import Path
PUZZLES_DIR = Path.cwd() / 'Puzzles'
def load_puzzles(filepath: str, max_puzzles: int = None):
puzzles = []
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
if len(line) >= 81:
puzzles.append(line[:81])
if max_puzzles and len(puzzles) >= max_puzzles:
break
return puzzles
def benchmark(puzzles: list, name: str):
print(f'\nBenchmark: {name} ({len(puzzles)} puzzles)')
start = time.time()
solved = 0
for puzzle in puzzles:
if solve(puzzle):
solved += 1
elapsed = (time.time() - start) * 1000
print(f' Resolus: {solved}/{len(puzzles)}')
print(f' Temps total: {elapsed:.2f} ms')
print(f' Temps moyen: {elapsed/len(puzzles):.2f} ms/puzzle')
# Tests
easy = load_puzzles(str(PUZZLES_DIR / 'Sudoku_Easy51.txt'), max_puzzles=20)
hard = load_puzzles(str(PUZZLES_DIR / 'Sudoku_hardest.txt'))
benchmark(easy, 'Puzzles Faciles')
benchmark(hard, 'Puzzles Difficiles')
Benchmark: Puzzles Faciles (20 puzzles)
Resolus: 20/20
Temps total: 66.05 ms
Temps moyen: 3.30 ms/puzzle
Benchmark: Puzzles Difficiles (11 puzzles)
Resolus: 11/11
Temps total: 48.91 ms
Temps moyen: 4.45 ms/puzzle
20/20 faciles résolus (66,05 ms au total, 3,30 ms par puzzle) et 11/11 difficiles (48,91 ms, 4,45 ms par puzzle). Deux lectures. D’abord la complétude : aucun échec, aucune limite de temps — le solveur exact termine toujours. Ensuite le facteur 1,35 entre difficile et facile : presque rien. C’est la signature d’un solveur guidé par la propagation — le nombre d’indices compte moins que la structure de leurs interactions, et la recherche ne s’enlise jamais assez pour que la difficulté se paie.
A l’échelle absolue, le corpus entier — 31 grilles — se resout en moins de 120 ms cumules (66,05 ms pour les 20 faciles, 48,91 ms pour les 11 difficiles). La question n’est plus « le solveur est-il assez rapide » mais « que peut-on se permettre de plus » : rejouer des centaines de grilles pour etalonner un parametre, par exemple, reste quasi instantane.
Comparez avec le backtracking naïf du jumeau C# (Sudoku-07-Norvig-CSharp) : disqualifié sur les grilles difficiles (3 015 ms, 0/10) là où cette version Python boucle en 4,45 ms moyennes ; le solveur Norvig du même jumeau, lui, passe 10/10 grilles difficiles. Même algorithme de fond (propagation + MRV), deux ordres de grandeur d’écart avec l’absence d’inférence : avec propagation, la difficulté des grilles coûte un facteur ~1,3 ; sans elle, elle coûte l’élimination pure et simple du solveur.
Points cles :
assign/eliminate — la propagation vide les domaines avant que le backtracking ne soit sollicite, si bien que la recherche ne voit presque jamais un embranchement profond.Note technique : la cle est la combinaison propagation + MRV. La propagation resout la majeure partie de la grille des l’assignation des 45 indices ; le MRV fait le reste en choisissant toujours la case la plus contrainte, ce qui maintient l’arbre de recherche proche d’un chemin.
La technique des singletons caches (Hidden Singles) est une heuristique de propagation complementaire a l’assign/eliminate de Norvig. Si un chiffre d ne peut apparaitre que dans une seule case d’une unite (ligne, colonne ou bloc), alors d doit etre assigne a cette case, même si d’autres candidats sont encore possibles.
Exemple : Si dans la ligne A, seul A3 peut contenir le chiffre 7 (tous les autres A1, A2, A4... ont elimine 7), alors A3 = 7 est force.
Implementez la fonction hidden_singles(values) qui parcourt toutes les unites et detecte ces singletons caches.
Indices :
unitlist et chaque chiffre d de 1 a 9d est encore candidatd, assignez d a cette case avec assign(values, s, d)True si au moins une assignation, False sinon# EXERCICE : Detection des singletons caches (Hidden Singles)
#
# Indications :
# - Parcourez unitlist pour chaque unite
# - Pour chaque chiffre d (1-9), trouvez les cases de l unite ou d est candidat
# - Si une seule case peut contenir d, assignez d a cette case
# - Utilisez assign(values, s, d) pour chaque assignation
# - Renvoyez True si au moins une assignation, False sinon
def hidden_singles(values):
"""Detecte et assigne les singletons caches dans toutes les unites.
Args:
values: dictionnaire {square: candidats}
Returns:
True si au moins une assignation a eu lieu, False sinon
"""
# TODO etudiant : implementez la detection des singletons caches
return False
# Test rapide
test_grid_hs = '..3.2.6..9..3.5..1..18.64....81.29..7.......8..67.82....26.95..8..2.3..9..5.1.3..'
values_hs = parse_grid(test_grid_hs)
if values_hs:
result = hidden_singles(values_hs)
print(f"Singletons caches detectes : {result}")
else:
print("Grille invalide")Singletons caches detectes : False
Le Sudoku 4x4 est une version simplifiee : une grille 4x4 divisee en 4 blocs 2x2, avec des chiffres de 1 a 4. Adaptez l’algorithme de Norvig (propagation de contraintes + backtracking MRV) pour resoudre ce mini-Sudoku.
Grille de test (0 = case vide) :
1 0 | 0 4
0 4 | 1 0
-----
0 1 | 4 0
4 0 | 0 1
Indices :
digits = '123456789' par digits = '1234'rows = 'ABCDEFGHI' par rows = 'ABCD'assign, eliminate et search n’ont pas besoin d’etre modifiees# EXERCICE : Mini-Sudoku 4x4 avec l'algorithme de Norvig
digits4 = '1234'
rows4 = 'ABCD'
cols4 = digits4
squares4 = [r + c for r in rows4 for c in cols4] # 16 cases
unitlist4 = (
[[r+c for c in cols4] for r in rows4] +
[[r+c for r in rows4] for c in cols4] +
[[r+c for r in rs for c in cs] for rs in ('AB','CD') for cs in ('12','34')]
)
units4 = {s: [u for u in unitlist4 if s in u] for s in squares4}
peers4 = {s: set(sum(units4[s], [])) - {s} for s in squares4}
def assign4(values, s, d):
other_values = values[s].replace(d, '')
if all(eliminate4(values, s, d2) for d2 in other_values):
return values
else:
return None
def eliminate4(values, s, d):
if d not in values[s]:
return True
values[s] = values[s].replace(d, '')
if len(values[s]) == 0:
return False
elif len(values[s]) == 1:
d2 = values[s]
if not all(eliminate4(values, s2, d2) for s2 in peers4[s]):
return False
for u in units4[s]:
dplaces = [s2 for s2 in u if d in values[s2]]
if len(dplaces) == 0:
return False
elif len(dplaces) == 1:
if not assign4(values, dplaces[0], d):
return False
return True
def parse_grid4(grid):
values = {s: digits4 for s in squares4}
for s, d in zip(squares4, grid):
if d in digits4 and not assign4(values, s, d):
return None
return values
def search4(values):
if values is None:
return None
if all(len(values[s]) == 1 for s in squares4):
return values
n, s = min((len(values[s]), s) for s in squares4 if len(values[s]) > 1)
for d in values[s]:
result = search4(assign4(values.copy(), s, d))
if result:
return result
return None
def solve_4x4(grid):
return search4(parse_grid4(grid))
def display_4x4(values):
"""Affiche une grille de Sudoku 4x4.
Args:
values: dictionnaire {square: candidats} ou None
Returns:
Representation texte de la grille 4x4 avec separateurs
de blocs 2x2.
"""
# TODO etudiant : implementez l'affichage 4x4
# Indications :
# 1. Si values est None, retourner "Pas de solution"
# 2. Calculer la largeur max d'une cellule
# 3. Construire les lignes avec '|' entre les colonnes 2 et 3
# 4. Ajouter une ligne de separation apres la ligne 'B'
# 5. Retourner le resultat join par '\n'
return "Exercice a completer"
test_4x4 = "1004041001404001"
solution_4x4 = solve_4x4(test_4x4)
print("Puzzle 4x4:")
print(display_4x4(parse_grid4(test_4x4)))
print("\nSolution 4x4:")
print(display_4x4(solution_4x4))Puzzle 4x4:
Exercice a completer
Solution 4x4:
Exercice a completer
La technique des paires nues (Naked Pairs) est une heuristique de propagation supplementaire. Si deux cases d’une même unite ont exactement les mêmes deux candidats (par exemple {2, 5}), alors ces deux valeurs peuvent etre eliminees de toutes les autres cases de cette unite.
Exemple : Si A1 = "25" et A4 = "25" dans la ligne A, alors 2 et 5 peuvent etre supprimes des candidats de A2, A3, A5, A6, A7, A8, A9.
Implantez une fonction naked_pairs(values) qui : 1. Parcourt chaque unite (lignes, colonnes, blocs) 2. Detecte les paires de cases partageant exactement 2 candidats identiques 3. Elimine ces candidats des autres cases de l’unite 4. Renvoie True si au moins une elimination a eu lieu, False sinon
Indices :
eliminate existante pour propager les suppressions# EXERCICE : Detection de paires nues (Naked Pairs)
# TODO: Implantez la fonction naked_pairs(values) decrite ci-dessus
#
# Indications :
# - Parcourez unitlist pour chaque unite
# - Pour chaque unite, trouvez les cases avec exactement 2 candidats
# - Si deux cases ont les memes 2 candidats, eliminez-les des autres cases
# - Utilisez eliminate(values, s, d) pour chaque elimination
# - Renvoyez True si au moins une elimination, False sinon
def naked_pairs(values):
"""Detecte et elimine les paires nues dans toutes les unites.
Args:
values: dictionnaire {square: candidats} (candidats = string de chiffres)
Returns:
True si au moins une elimination a eu lieu, False sinon
"""
# TODO: votre implémentation ici
pass
# Test de validation
# Grille avec une paire nue connue en ligne A : A2 et A8 ont pour candidats {2, 8}
test_naked = '4.....8.5.3..........7......2.....6.....8.4......1.......6.3.7.5..2.....1.4......'
values_test = parse_grid(test_naked)
print("Candidats avant naked_pairs :")
print(f" A2 = {values_test['A2']}, A8 = {values_test['A8']}")
# result = naked_pairs(values_test)
# print(f"Eliminations effectuees : {result}")
# print(f" A2 = {values_test['A2']}, A8 = {values_test['A8']}")Candidats avant naked_pairs :
A2 = 1679, A8 = 1239
A2 = 1679 se lit : la cellule A2 admet encore quatre candidats — 1, 6, 7, 9 — écrits en concaténation, exactement comme dans l’essai d’origine de Norvig dont ce notebook est le portage. Cette notation n’est pas qu’une économie d’affichage : c’est le domaine lui-même, la valeur de travail de l’algorithme (1679 est la chaîne que eliminate rogne caractère par caractère). L’exercice de détection de paires nues qui suit exploite ces listes : si deux cellules d’une même unité ont exactement le même couple de candidats, ces deux valeurs peuvent être éliminées de tous leurs autres pairs.
Ce notebook a presenté l’approche élégante de Peter Norvig pour résoudre le Sudoku en ~80 lignes de Python. La méthode repose sur deux piliers : la propagation de contraintes (élimination des candidats impossibles via les pairs et les unités) et le backtracking guidé par l’heuristique MRV (choisir la case avec le moins de valeurs possibles). Le benchmark montre des performances remarquables (~2 à 3 ms par puzzle en moyenne), avec une faible sensibilité à la difficulté de la grille. Cette implémentation illustre un principe fondamental en intelligence artificielle : un bon encodage du problème (représentation par dictionnaire de candidats) combiné à une heuristique pertinente (MRV) peut surpasser des approches algorithmiquement plus complexes. Les exercices proposés (mini-Sudoku 4x4, paires nues) permettent d’étendre le solveur avec des techniques de propagation avancées.
| Type | Temps moyen | Taux de succès |
|---|---|---|
| Faciles (20 puzzles) | 3,30 ms | 100% (20/20) |
| Difficiles (11 puzzles) | 4,45 ms | 100% (11/11) |
Retour au sommaire : Index Sudoku