Sudoku-08 : Resolution par Stratégies Humaines (Python)

Niveau : Techniques d’inference | Duree : ~40 min | Prerequis : Sudoku-00 Environment

Objectifs d’apprentissage

A la fin de ce notebook, vous saurez : 1. Comprendre comment les experts humains resolvent les Sudoku par deduction logique 2. Implementer les techniques de base : Naked Singles, Hidden Singles 3. Implementer les techniques intermediaires : Naked Pairs, Pointing Pairs 4. Implementer la technique avancee X-Wing 5. Combiner ces techniques dans un solveur hybride avec fallback vers le backtracking


Ce notebook implemente un solveur de Sudoku utilisant les techniques de deduction utilisees par les experts humains. C’est l’equivalent Python du notebook C# Sudoku-08-HumanStrategies-CSharp.ipynb.

Introduction

Les algorithmes que nous avons vus dans les notebooks précédents (backtracking, OR-Tools, Dancing Links, etc.) resolvent les Sudoku par force brute ou par satisfaction de contraintes de maniere systématique. Un expert humain procede tout autrement : il applique des techniques de deduction logique de difficulte croissante, en commencant par les plus simples.

Hiérarchie des techniques

  1. Naked Single : Une case ne peut contenir qu’une seule valeur
  2. Hidden Single : Une valeur ne peut aller qu’a une seule case dans une unite (ligne/colonne/bloc)
  3. Naked Pair : Deux cases ne contiennent que les mêmes deux valeurs candidats
  4. Pointing Pair : Une valeur dans un bloc est restreinte a une ligne/colonne
  5. X-Wing : Une valeur forme un rectangle dans deux lignes/colonnes

Difficulte des puzzles

Niveau Techniques necessaires
Easy Naked Singles, Hidden Singles
Medium Naked Pairs, Pointing Pairs
Hard X-Wing, techniques avancees
Expert Swordfish, XY-Wing, etc.
# Imports
import numpy as np
import time
from typing import List, Tuple, Optional, Set, Dict
from dataclasses import dataclass
from enum import Enum

print("Libraries importees avec succes.")
Libraries importees avec succes.

Configuration du chemin vers les fichiers de puzzles.

# Configuration du chemin vers les puzzles
import os
from pathlib import Path

NOTEBOOK_DIR = Path.cwd()
PUZZLES_DIR = NOTEBOOK_DIR / "Puzzles"

if PUZZLES_DIR.exists():
    print(f"Dossier Puzzles: {PUZZLES_DIR.name}")
else:
    print("ATTENTION: Dossier Puzzles non trouve")
    PUZZLES_DIR = Path(os.getcwd()) / "Puzzles"
Dossier Puzzles: Puzzles

1. Classe Sudoku avec Candidats

Pour les stratégies humaines, nous devons suivre non seulement les valeurs placees, mais aussi les candidats possibles pour chaque case.

@dataclass
class CandidateInfo:
    """Informations sur les candidats d'une case."""
    row: int
    col: int
    value: int
    
    def __hash__(self):
        return hash((self.row, self.col, self.value))

class HumanSudokuSolver:
    """Solveur de Sudoku utilisant les strategies humaines."""
    
    def __init__(self, puzzle_str: str):
        # Grille principale (0 = vide)
        self.grid = np.zeros((9, 9), dtype=int)
        
        # Candidats pour chaque case (ensemble de valeurs possibles)
        self.candidates = [[set() for _ in range(9)] for _ in range(9)]
        
        # Historique des placements
        self.placements = []
        
        # Statistiques
        self.strategy_counts = {}
        
        # Initialiser
        self._initialize(puzzle_str)
    
    def _initialize(self, puzzle_str: str):
        """Initialise la grille et les candidats."""
        puzzle_str = puzzle_str.replace('.', '0').replace(' ', '').replace('\n', '')
        
        for i in range(81):
            row, col = i // 9, i % 9
            val = int(puzzle_str[i])
            if val != 0:
                self.grid[row, col] = val
                self.placements.append(CandidateInfo(row, col, val))
        
        # Initialiser les candidats
        self._init_candidates()
    
    def _init_candidates(self):
        """Initialise les candidats pour toutes les cases vides."""
        for row in range(9):
            for col in range(9):
                if self.grid[row, col] == 0:
                    self.candidates[row][col] = self._get_possible_values(row, col)
    
    def _get_possible_values(self, row: int, col: int) -> Set[int]:
        """Retourne les valeurs possibles pour une case."""
        if self.grid[row, col] != 0:
            return set()
        
        values = set(range(1, 10))
        
        # Retirer les valeurs de la ligne
        values -= set(self.grid[row, :])
        
        # Retirer les valeurs de la colonne
        values -= set(self.grid[:, col])
        
        # Retirer les valeurs du bloc
        br, bc = 3 * (row // 3), 3 * (col // 3)
        values -= set(self.grid[br:br+3, bc:bc+3].flatten())
        
        return values
    
    def is_solved(self) -> bool:
        """Verifie si le Sudoku est resolu."""
        return np.all(self.grid != 0) and self._is_valid()
    
    def _is_valid(self) -> bool:
        """Verifie la validite de la grille."""
        # Verifier les lignes
        for r in range(9):
            if len(set(self.grid[r, :])) < 9 and 0 not in self.grid[r, :]:
                return False
        # Verifier les colonnes
        for c in range(9):
            if len(set(self.grid[:, c])) < 9 and 0 not in self.grid[:, c]:
                return False
        return True
    
    def __str__(self) -> str:
        lines = []
        for r in range(9):
            if r > 0 and r % 3 == 0:
                lines.append('-' * 21)
            row_str = ''
            for c in range(9):
                if c > 0 and c % 3 == 0:
                    row_str += '| '
                val = self.grid[r, c]
                row_str += (str(val) if val != 0 else '.') + ' '
            lines.append(row_str)
        return '\n'.join(lines)

def load_puzzles(filepath: str, max_puzzles: int = None) -> List[str]:
    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

# Charger les puzzles
easy_puzzles = load_puzzles(str(PUZZLES_DIR / 'Sudoku_Easy51.txt'), max_puzzles=5)
print(f"Puzzles charges: {len(easy_puzzles)}")

# Test
solver = HumanSudokuSolver(easy_puzzles[0])
print("\nGrille de test:")
print(solver)
Puzzles charges: 5

Grille de test:
9 . 2 | . . 5 | 4 . 3 
1 . . | . 6 3 | . 2 5 
5 . 8 | 4 . 7 | . 6 . 
---------------------
. 2 6 | 3 . 9 | . . 1 
. 5 7 | . 1 . | 2 9 . 
. 9 . | 6 7 . | 5 3 . 
---------------------
2 4 . | 5 3 . | 6 . . 
7 . 5 | 2 . . | 3 . 4 
. 8 . | . 4 1 | 9 5 . 

Interpretation : Structure de la classe HumanSudokuSolver

Composant Rôle Intérêt
self.grid Grille 9x9 des valeurs placees Representation standard du Sudoku
self.candidates Liste des candidats pour chaque case Cle des stratégies humaines
self.placements Historique des placements Permet le backtrack et l’analyse
self.strategy_counts Compteur d’utilisation des techniques Benchmark et analyse

Structure des données : - self.candidates[row][col] = ensemble des valeurs possibles (1-9) pour cette case - Initialisation : Pour chaque case vide, calculer les valeurs possibles en eliminant celles de la ligne, colonne et bloc - Exemple : Case (0, 1) avec grille contenant {9,2,5,4,3,1,6,2,5,5,8,4,7,6} ne peut contenir que {7}

Points cles : 1. La classe maintient deux representations : grille (valeurs placees) et candidats (possibilites) 2. La méthode _get_possible_values implemente la règle de base du Sudoku : une valeur doit etre unique dans sa ligne, colonne et bloc 3. L’affichage avec __str__ utilise des separateurs visuels (- et |) pour delimiter les blocs 3x3 4. Le chargement des puzzles depuis un fichier permet de tester sur de nombreux cas

Note technique : L’utilisation de set pour les candidats permet des opérations efficaces d’ajout et de suppression (O(1)). La méthode discard supprime un élément si present sans lever d’erreur, ce qui est ideal pour la propagation des contraintes.

2. Technique 1 : Naked Single

Un Naked Single (ou “Singleton”) est une case qui ne contient qu’un seul candidat possible. C’est la technique la plus simple.

def find_naked_singles(self) -> List[CandidateInfo]:
    """Trouve les Naked Singles (cases avec un seul candidat)."""
    placements = []
    
    for row in range(9):
        for col in range(9):
            if self.grid[row, col] == 0:
                candidates = self.candidates[row][col]
                if len(candidates) == 1:
                    value = list(candidates)[0]
                    placements.append(CandidateInfo(row, col, value))
    
    return placements

def apply_placement(self, info: CandidateInfo, strategy: str) -> bool:
    """Applique un placement et met a jour les candidats."""
    if self.grid[info.row, info.col] != 0:
        return True  # Deja place
    
    # Verifier que le placement est valide
    if info.value not in self.candidates[info.row][info.col]:
        return False  # Placement invalide
    
    # Placer la valeur
    self.grid[info.row, info.col] = info.value
    self.placements.append(info)
    self.candidates[info.row][info.col] = set()
    
    # Mettre a jour les candidats voisins
    self._remove_candidate_from_peers(info.row, info.col, info.value)
    
    # Statistiques
    self.strategy_counts[strategy] = self.strategy_counts.get(strategy, 0) + 1
    
    return True

def _remove_candidate_from_peers(self, row: int, col: int, value: int):
    """Retire un candidat des cases voisines."""
    # Meme ligne
    for c in range(9):
        if c != col:
            self.candidates[row][c].discard(value)
    
    # Meme colonne
    for r in range(9):
        if r != row:
            self.candidates[r][col].discard(value)
    
    # Meme bloc
    br, bc = 3 * (row // 3), 3 * (col // 3)
    for r in range(br, br + 3):
        for c in range(bc, bc + 3):
            if r != row or c != col:
                self.candidates[r][c].discard(value)

# Ajouter les methodes a la classe
HumanSudokuSolver.find_naked_singles = find_naked_singles
HumanSudokuSolver.apply_placement = apply_placement
HumanSudokuSolver._remove_candidate_from_peers = _remove_candidate_from_peers

print("Methodes Naked Single ajoutees.")

# Test
solver = HumanSudokuSolver(easy_puzzles[0])
naked_singles = solver.find_naked_singles()
print(f"Naked Singles trouves: {len(naked_singles)}")
for ns in naked_singles[:5]:
    print(f"  ({ns.row}, {ns.col}) = {ns.value}")
Methodes Naked Single ajoutees.
Naked Singles trouves: 11
  (0, 4) = 8
  (1, 1) = 7
  (1, 2) = 4
  (2, 1) = 3
  (2, 6) = 1

Exercice : Compter les naked singles par difficulte

Objectif Comptez combien de naked singles peuvent etre trouves dans des puzzles de différentes difficultes (facile, moyen, difficile).

Indice Utilisez find_naked_singles sur plusieurs puzzles de chaque difficulte et calculez la moyenne.

# EXERCICE : Compter les naked singles par difficulte
def count_naked_singles_by_difficulty(puzzles: dict) -> dict:
    # TODO: Pour chaque difficulte, chargez les puzzles et comptez
    # le nombre moyen de naked singles trouves
    result = {}  # TODO etudiant
    return result
print("Exercice a completer")
Exercice a completer

Interpretation : Detection des Naked Singles

Aspect Valeur Signification
Naked Singles trouves 11 Cases avec un seul candidat possible
Exemples (0,4)=8, (1,1)=7, (1,2)=4 Deductions immediates
Impact Reduction de l’espace de recherche Chaque placement elimine des candidats voisins

Exemples de deductions : - Case (0, 4) = 8 : Après elimination des valeurs de la ligne 0, colonne 4 et bloc, seul 8 reste possible - Case (1, 1) = 7 : La case ne peut contenir que 7 après propagation des contraintes - Case (2, 6) = 1 : Un Naked Single typique dans une case contrainte

Points cles : 1. Les Naked Singles sont les deductions les plus simples : une case ne contient qu’un candidat 2. Chaque Naked Single place elimine des candidats dans les 20 cases voisines (8 ligne + 8 colonne + 4 bloc) 3. Cette propagation créé une reaction en chaîne : un placement fait apparaître de nouveaux Naked Singles 4. La méthode _remove_candidate_from_peers est cruciale pour maintenir la cohérence des candidats

Note technique : Les Naked Singles correspondent aux placements “evidents” pour un humain. Quand une case ne peut contenir qu’une seule valeur, on la place immediatement. L’originalite de l’approche humaine est de maintenir explicitement la liste des candidats pour chaque case, ce qui permet de detecter ces singles efficacement.

3. Technique 2 : Hidden Single

Un Hidden Single est une valeur qui ne peut aller qu’a une seule case dans une unite (ligne, colonne ou bloc), même si cette case a d’autres candidats.

def find_hidden_singles(self) -> List[CandidateInfo]:
    """Trouve les Hidden Singles dans les lignes, colonnes et blocs."""
    placements = []
    
    # Chercher dans les lignes
    for row in range(9):
        value_positions = {v: [] for v in range(1, 10)}
        for col in range(9):
            if self.grid[row, col] == 0:
                for value in self.candidates[row][col]:
                    value_positions[value].append(col)
        
        for value, positions in value_positions.items():
            if len(positions) == 1:
                col = positions[0]
                if self.grid[row, col] == 0:
                    placements.append(CandidateInfo(row, col, value))
    
    # Chercher dans les colonnes
    for col in range(9):
        value_positions = {v: [] for v in range(1, 10)}
        for row in range(9):
            if self.grid[row, col] == 0:
                for value in self.candidates[row][col]:
                    value_positions[value].append(row)
        
        for value, positions in value_positions.items():
            if len(positions) == 1:
                row = positions[0]
                if self.grid[row, col] == 0:
                    placements.append(CandidateInfo(row, col, value))
    
    # Chercher dans les blocs
    for block_row in range(3):
        for block_col in range(3):
            value_positions = {v: [] for v in range(1, 10)}
            
            for r in range(block_row * 3, block_row * 3 + 3):
                for c in range(block_col * 3, block_col * 3 + 3):
                    if self.grid[r, c] == 0:
                        for value in self.candidates[r][c]:
                            value_positions[value].append((r, c))
            
            for value, positions in value_positions.items():
                if len(positions) == 1:
                    row, col = positions[0]
                    if self.grid[row, col] == 0:
                        placements.append(CandidateInfo(row, col, value))
    
    return placements

# Ajouter la methode
HumanSudokuSolver.find_hidden_singles = find_hidden_singles

print("Methode Hidden Single ajoutee.")

# Test
solver = HumanSudokuSolver(easy_puzzles[0])
hidden_singles = solver.find_hidden_singles()
print(f"Hidden Singles trouves: {len(hidden_singles)}")
Methode Hidden Single ajoutee.
Hidden Singles trouves: 49

Interpretation : Hidden Singles vs Naked Singles

Aspect Naked Singles Hidden Singles
Definition Case avec 1 seul candidat Valeur avec 1 seule position possible
Detection Locale (par case) Globale (par unite)
Nombre trouve 11 49
Ratio 1x 4.5x

Points cles : 1. Les Hidden Singles sont 4.5x plus frequents que les Naked Singles dans ce puzzle 2. Un Hidden Single est une contrainte plus forte : il indique que même si une case a plusieurs candidats, un d’entre eux est force 3. Les Hidden Singles necessitent de parcourir toute l’unite (ligne/colonne/bloc) pour etre detectes 4. Combiner les deux techniques maximise le nombre de deductions directes

Note technique : Les Hidden Singles sont cruciaux pour la resolution. Par exemple, si la valeur 7 n’apparait comme candidat que dans une seule case d’une ligne, alors 7 DOIT aller dans cette case, même si la case contient d’autres candidats (1, 4, 9). C’est une deduction logique forte.

4. Technique 3 : Naked Pair

Un Naked Pair est deux cases de la même unite qui contiennent exactement les mêmes deux candidats. Ces deux candidats peuvent donc etre elimines des autres cases de l’unite.

def find_naked_pairs(self) -> List[Tuple[int, int, int, Set[int], str]]:
    """Trouve les Naked Pairs dans les lignes, colonnes et blocs.
    
    Returns:
        Liste de (row1, col1, row2, col2, pair_values, unit_type)
        où unit_type est 'row', 'col', ou 'block'
    """
    results = []
    
    # Chercher dans les lignes
    for row in range(9):
        pair_cells = []
        for col in range(9):
            if self.grid[row, col] == 0 and len(self.candidates[row][col]) == 2:
                pair_cells.append((col, self.candidates[row][col]))
        
        # Chercher les paires de cases avec les memes candidats
        for i in range(len(pair_cells)):
            for j in range(i + 1, len(pair_cells)):
                col1, cand1 = pair_cells[i]
                col2, cand2 = pair_cells[j]
                if cand1 == cand2:
                    results.append((row, col1, row, col2, cand1, 'row'))
    
    # Chercher dans les colonnes
    for col in range(9):
        pair_cells = []
        for row in range(9):
            if self.grid[row, col] == 0 and len(self.candidates[row][col]) == 2:
                pair_cells.append((row, self.candidates[row][col]))
        
        for i in range(len(pair_cells)):
            for j in range(i + 1, len(pair_cells)):
                row1, cand1 = pair_cells[i]
                row2, cand2 = pair_cells[j]
                if cand1 == cand2:
                    results.append((row1, col, row2, col, cand1, 'col'))
    
    # Chercher dans les blocs
    for block_row in range(3):
        for block_col in range(3):
            pair_cells = []
            for r in range(block_row * 3, block_row * 3 + 3):
                for c in range(block_col * 3, block_col * 3 + 3):
                    if self.grid[r, c] == 0 and len(self.candidates[r][c]) == 2:
                        pair_cells.append(((r, c), self.candidates[r][c]))
            
            for i in range(len(pair_cells)):
                for j in range(i + 1, len(pair_cells)):
                    (r1, c1), cand1 = pair_cells[i]
                    (r2, c2), cand2 = pair_cells[j]
                    if cand1 == cand2:
                        results.append((r1, c1, r2, c2, cand1, 'block'))
    
    return results

def apply_naked_pair(self, row1: int, col1: int, row2: int, col2: int, 
                   pair_values: Set[int], unit_type: str) -> int:
    """Applique un Naked Pair en eliminant les candidats des autres cases.
    
    Returns:
        Nombre de candidats elimines
    """
    eliminated = 0
    
    if unit_type == 'row':
        row = row1
        for col in range(9):
            if col != col1 and col != col2 and self.grid[row, col] == 0:
                for val in pair_values:
                    if val in self.candidates[row][col]:
                        self.candidates[row][col].discard(val)
                        eliminated += 1
    
    elif unit_type == 'col':
        col = col1
        for row in range(9):
            if row != row1 and row != row2 and self.grid[row, col] == 0:
                for val in pair_values:
                    if val in self.candidates[row][col]:
                        self.candidates[row][col].discard(val)
                        eliminated += 1
    
    elif unit_type == 'block':
        br, bc = 3 * (row1 // 3), 3 * (col1 // 3)
        for r in range(br, br + 3):
            for c in range(bc, bc + 3):
                if (r != row1 or c != col1) and (r != row2 or c != col2):
                    if self.grid[r, c] == 0:
                        for val in pair_values:
                            if val in self.candidates[r][c]:
                                self.candidates[r][c].discard(val)
                                eliminated += 1
    
    return eliminated

# Ajouter les methodes
HumanSudokuSolver.find_naked_pairs = find_naked_pairs
HumanSudokuSolver.apply_naked_pair = apply_naked_pair

print("Methodes Naked Pair ajoutees.")
Methodes Naked Pair ajoutees.

Lecture : le contrat de sortie minimal du Naked Pair

La cellule ne produit qu’une ligne (Methodes Naked Pair ajoutees) : le contrat de cette étape est l’ajout de la méthode, pas son diagnostic. Ce qui rend la lecture non triviale, c’est ce que la ligne ne dit pas et que la suite du notebook commitera : sur les cinq puzzles du benchmark (cellule de la section 8), la technique est appliquée 15 fois — la preuve qu’elle est bien déclenchée, et non un code mort. Le Naked Pair exploite une unité (ligne, colonne ou bloc) où deux cases n’ont exactement que les deux mêmes candidats : ces deux valeurs ne peuvent plus apparaître ailleurs dans l’unité, donc on les retire des autres cases. C’est une déduction du « deuxième niveau » : elle ne place pas une valeur directement, elle réduit le candidat des cases voisines, ce qui ravive la chasse aux singles des sections précédentes.

Exercice : Implementer la technique Hidden Pair

Objectif Implementez la detection des Hidden Pairs : quand deux valeurs n’apparaissent que dans deux cellules d’une même unite (ligne/colonne/bloc), ces deux valeurs peuvent etre eliminees des candidats des autres cellules de l’unite.

Étape 1 Parcourez chaque unite et identifiez les valeurs qui n’apparaissent que 2 fois. Étape 2 Verifiez si ces 2 valeurs partagent les mêmes 2 cellules.

# EXERCICE : Implementer la technique Hidden Pair
def find_hidden_pairs(candidates: np.ndarray) -> list:
    # TODO: Trouvez les hidden pairs dans la grille
    # candidates[i][j] = ensemble des valeurs candidates pour la cellule (i,j)
    # Retournez une liste de (unite_type, position, paire_de_valeurs)
    result = []  # TODO etudiant
    return result
print("Exercice a completer")
Exercice a completer

5. Technique 4 : Pointing Pair

Un Pointing Pair (ou “Locked Candidates”) se produit quand les candidats d’une valeur dans un bloc sont restreints a une seule ligne ou colonne. Cette valeur peut donc etre eliminee des autres cases de cette ligne/colonne en dehors du bloc.

def find_pointing_pairs(self) -> List[Tuple[int, int, int, str]]:
    """Trouve les Pointing Pairs dans les blocs.
    
    Returns:
        Liste de (block_row, block_col, value, direction)
        où direction est 'row' ou 'col'
    """
    results = []
    
    for block_row in range(3):
        for block_col in range(3):
            for value in range(1, 10):
                # Trouver les positions de la valeur dans le bloc
                positions = []
                for r in range(block_row * 3, block_row * 3 + 3):
                    for c in range(block_col * 3, block_col * 3 + 3):
                        if self.grid[r, c] == 0 and value in self.candidates[r][c]:
                            positions.append((r, c))
                
                if len(positions) < 2:
                    continue
                
                # Verifier si tous sur la meme ligne
                rows = set(p[0] for p in positions)
                if len(rows) == 1:
                    results.append((block_row, block_col, value, 'row'))
                
                # Verifier si tous sur la meme colonne
                cols = set(p[1] for p in positions)
                if len(cols) == 1:
                    results.append((block_row, block_col, value, 'col'))
    
    return results

def apply_pointing_pair(self, block_row: int, block_col: int, 
                        value: int, direction: str) -> int:
    """Applique un Pointing Pair.
    
    Returns:
        Nombre de candidats elimines
    """
    eliminated = 0
    
    # Trouver les positions dans le bloc
    positions = []
    for r in range(block_row * 3, block_row * 3 + 3):
        for c in range(block_col * 3, block_col * 3 + 3):
            if self.grid[r, c] == 0 and value in self.candidates[r][c]:
                positions.append((r, c))
    
    if direction == 'row':
        row = positions[0][0]
        for col in range(9):
            if col // 3 != block_col and self.grid[row, col] == 0:
                if value in self.candidates[row][col]:
                    self.candidates[row][col].discard(value)
                    eliminated += 1
    
    elif direction == 'col':
        col = positions[0][1]
        for row in range(9):
            if row // 3 != block_row and self.grid[row, col] == 0:
                if value in self.candidates[row][col]:
                    self.candidates[row][col].discard(value)
                    eliminated += 1
    
    return eliminated

# Ajouter les methodes
HumanSudokuSolver.find_pointing_pairs = find_pointing_pairs
HumanSudokuSolver.apply_pointing_pair = apply_pointing_pair

print("Methodes Pointing Pair ajoutees.")
Methodes Pointing Pair ajoutees.

Lecture : le Pointing Pair, un candidat verrouillé dans une unité

La sortie minimaliste (Methodes Pointing Pair ajoutees) encode une étape d’architecture, pas un calcul : les techniques s’ajoutent par paquet, chacune avec son propre prédicat de recherche, et le solveur complet les compose (section 7). Le Pointing Pair — ou locked candidates — survient quand un candidat n’apparaît que dans une seule ligne (ou colonne) d’un bloc : puisqu’il doit nécessairement occuper cette ligne dans le bloc, il est éliminé des autres blocs de la même ligne. C’est la première technique où l’observation se fait sur deux unités croisées (bloc × ligne), ce qui la rend plus coûteuse à coder que les singles. Son utilité est mesurée plus bas, dans le benchmark : 17 applications sur 5 puzzles faciles — derrière les singles, devant le X-Wing — soit une technique de milieu de gamme, fréquente sans être triviale.

6. Technique 5 : X-Wing

Un X-Wing se produit quand une valeur a exactement deux candidats dans chacune de deux lignes différentes, et ces candidats sont dans les mêmes deux colonnes. La valeur peut donc etre eliminee des autres cases de ces colonnes.

def find_x_wings(self) -> List[Tuple[int, int, int, int, int]]:
    """Trouve les X-Wings dans les lignes.
    
    Returns:
        Liste de (row1, row2, col1, col2, value)
    """
    results = []
    
    for value in range(1, 10):
        # Pour chaque valeur, trouver les lignes avec exactement 2 candidats
        rows_with_two = []
        for row in range(9):
            cols_with_value = []
            for col in range(9):
                if self.grid[row, col] == 0 and value in self.candidates[row][col]:
                    cols_with_value.append(col)
            
            if len(cols_with_value) == 2:
                rows_with_two.append((row, cols_with_value))
        
        # Chercher deux lignes avec les memes colonnes
        for i in range(len(rows_with_two)):
            for j in range(i + 1, len(rows_with_two)):
                row1, cols1 = rows_with_two[i]
                row2, cols2 = rows_with_two[j]
                
                if cols1 == cols2:
                    results.append((row1, row2, cols1[0], cols1[1], value))
    
    return results

def apply_x_wing(self, row1: int, row2: int, col1: int, col2: int, value: int) -> int:
    """Applique un X-Wing.
    
    Returns:
        Nombre de candidats elimines
    """
    eliminated = 0
    
    # Eliminer la valeur des autres cases des colonnes
    for col in [col1, col2]:
        for row in range(9):
            if row != row1 and row != row2 and self.grid[row, col] == 0:
                if value in self.candidates[row][col]:
                    self.candidates[row][col].discard(value)
                    eliminated += 1
    
    return eliminated

# Ajouter les methodes
HumanSudokuSolver.find_x_wings = find_x_wings
HumanSudokuSolver.apply_x_wing = apply_x_wing

print("Methodes X-Wing ajoutees.")
Methodes X-Wing ajoutees.

Lecture : le X-Wing, première structure en croix

Comme pour les deux sections précédentes, la sortie (Methodes X-Wing ajoutees) ne valide que l’ajout ; la mesure d’usage viendra du benchmark : 5 applications sur les cinq puzzles — le X-Wing est la technique la moins utilisée de la section 8, ce qui est cohérent avec sa définition. Un X-Wing se produit quand une valeur n’a que deux positions possibles dans deux lignes distinctes, alignées sur les deux mêmes colonnes : les quatre cases forment un rectangle, et la valeur ne peut occuper que deux coins opposés — elle est donc éliminée des deux colonnes en dehors du rectangle. La structure est symétrique (lignes ↔︎ colonnes), ce qui explique les paires de méthodes row/col dans le code. Le benchmark le montre rarement utile sur des grilles faciles, mais c’est la technique qui devient indispensable sur les puzzles moyens : sa présence dans la panoplie évite au solveur de tomber dans le backtracking de la section 9.

7. Solveur Complet

Le solveur combine toutes les techniques avec un fallback vers le backtracking si necessaire.

def solve(self, max_iterations: int = 1000) -> bool:
    """Resout le Sudoku en utilisant les strategies humaines.
    
    Returns:
        True si resolu, False sinon
    """
    self.strategy_counts = {}
    iteration = 0
    
    while not self.is_solved() and iteration < max_iterations:
        iteration += 1
        progress = False
        
        # 1. Naked Singles
        naked_singles = self.find_naked_singles()
        for ns in naked_singles:
            if self.apply_placement(ns, "Naked Single"):
                progress = True
        
        if self.is_solved():
            return True
        
        # 2. Hidden Singles
        hidden_singles = self.find_hidden_singles()
        for hs in hidden_singles:
            if self.apply_placement(hs, "Hidden Single"):
                progress = True
        
        if self.is_solved():
            return True
        
        # 3. Naked Pairs
        naked_pairs = self.find_naked_pairs()
        for r1, c1, r2, c2, pair_vals, unit_type in naked_pairs:
            eliminated = self.apply_naked_pair(r1, c1, r2, c2, pair_vals, unit_type)
            if eliminated > 0:
                progress = True
                self.strategy_counts["Naked Pair"] = self.strategy_counts.get("Naked Pair", 0) + 1
        
        # 4. Pointing Pairs
        pointing_pairs = self.find_pointing_pairs()
        for br, bc, val, direction in pointing_pairs:
            eliminated = self.apply_pointing_pair(br, bc, val, direction)
            if eliminated > 0:
                progress = True
                self.strategy_counts["Pointing Pair"] = self.strategy_counts.get("Pointing Pair", 0) + 1
        
        # 5. X-Wings
        x_wings = self.find_x_wings()
        for r1, r2, c1, c2, val in x_wings:
            eliminated = self.apply_x_wing(r1, r2, c1, c2, val)
            if eliminated > 0:
                progress = True
                self.strategy_counts["X-Wing"] = self.strategy_counts.get("X-Wing", 0) + 1
        
        if not progress:
            # Plus de progres, essayer le backtracking
            if self._backtrack():
                return True
            break
    
    return self.is_solved()

def _backtrack(self) -> bool:
    """Simple backtracking comme fallback."""
    # Trouver la premiere case vide
    for row in range(9):
        for col in range(9):
            if self.grid[row, col] == 0:
                candidates = list(self.candidates[row][col])
                
                for value in candidates:
                    if self._is_valid_placement(row, col, value):
                        self.grid[row, col] = value
                        self.placements.append(CandidateInfo(row, col, value))
                        
                        if self._backtrack():
                            return True
                        
                        # Annuler
                        self.grid[row, col] = 0
                        self.placements.pop()
                
                return False
    return True

def _is_valid_placement(self, row: int, col: int, value: int) -> bool:
    """Verifie si un placement est valide."""
    # Ligne
    if value in self.grid[row, :]:
        return False
    # Colonne
    if value in self.grid[:, col]:
        return False
    # Bloc
    br, bc = 3 * (row // 3), 3 * (col // 3)
    if value in self.grid[br:br+3, bc:bc+3]:
        return False
    return True

# Ajouter les methodes
HumanSudokuSolver.solve = solve
HumanSudokuSolver._backtrack = _backtrack
HumanSudokuSolver._is_valid_placement = _is_valid_placement

print("Methode solve ajoutee.")
Methode solve ajoutee.

8. Test et Benchmark

print("=== Test : Puzzle Facile ===")
puzzle_str = easy_puzzles[0]

solver = HumanSudokuSolver(puzzle_str)
print("Puzzle original:")
print(solver)

start = time.time()
solved = solver.solve()
elapsed = time.time() - start

print(f"\nResolu: {solved}")
print(f"Temps: {elapsed*1000:.2f}ms")
print(f"\nStatistiques des strategies:")
for strategy, count in solver.strategy_counts.items():
    print(f"  {strategy}: {count}")

print("\nSolution:")
print(solver)
=== Test : Puzzle Facile ===
Puzzle original:
9 . 2 | . . 5 | 4 . 3 
1 . . | . 6 3 | . 2 5 
5 . 8 | 4 . 7 | . 6 . 
---------------------
. 2 6 | 3 . 9 | . . 1 
. 5 7 | . 1 . | 2 9 . 
. 9 . | 6 7 . | 5 3 . 
---------------------
2 4 . | 5 3 . | 6 . . 
7 . 5 | 2 . . | 3 . 4 
. 8 . | . 4 1 | 9 5 . 

Resolu: True
Temps: 1.54ms

Statistiques des strategies:
  Naked Single: 14
  Hidden Single: 22

Solution:
9 6 2 | 1 8 5 | 4 7 3 
1 7 4 | 9 6 3 | 8 2 5 
5 3 8 | 4 2 7 | 1 6 9 
---------------------
8 2 6 | 3 5 9 | 7 4 1 
3 5 7 | 8 1 4 | 2 9 6 
4 9 1 | 6 7 2 | 5 3 8 
---------------------
2 4 9 | 5 3 8 | 6 1 7 
7 1 5 | 2 9 6 | 3 8 4 
6 8 3 | 7 4 1 | 9 5 2 

Lecture du test : la grille facile se résout toute seule, en 1,54 ms

La sortie aligne le puzzle d’origine, sa solution complète, et les statistiques des techniques qui y sont parvenues. Premier fait, en haut : Resolu: True, Temps: 1,54 ms — ce puzzle dit « facile » est résolu sans le moindre appel au backtracking : aucune ligne d’appels récursifs n’apparaît, le solveur a tout déduit. Deuxième fait, le relevé des techniques : Naked Single : 14, Hidden Single : 22 — sur cette grille, la déduction cachée (un chiffre n’a qu’une seule case possible dans son unité) fait plus de travail que la déduction évidente (une case n’a qu’un candidat) ; le ratio 22/14 = 1,6 est l’inverse de l’ordre habituellement présenté dans les tutoriaux (le Naked Single d’abord, le Hidden Single ensuite), d’où l’importance de ces compteurs pour la pédagogie. Troisième fait : la solution elle-même, première ligne 9 6 2 | 1 8 5 | 4 7 3 — les huit autres lignes permutent les mêmes chiffres, chaque ligne/colonne/bloc contenant exactement une fois 1 à 9 ; la vérification visuelle de ces 27 contraintes est l’exercice de lecture le plus direct du notebook.

Point cle : Les stratégies humaines resolvent la plupart des puzzles faciles et moyens sans backtracking — Naked Singles et Hidden Singles y suffisent. Un fallback backtracking reste présent, utilise seulement si les stratégies humaines echouent.

# Benchmark sur plusieurs puzzles
def benchmark_human(puzzles: List[str], num_puzzles: int = 5):
    """Benchmark du solveur humain."""
    print(f"\n=== Benchmark: Strategies Humaines ({num_puzzles} puzzles) ===")
    
    total_time = 0
    solved_count = 0
    all_strategies = {}
    
    for i, puzzle_str in enumerate(puzzles[:num_puzzles]):
        solver = HumanSudokuSolver(puzzle_str)
        
        start = time.time()
        solved = solver.solve()
        elapsed = time.time() - start
        
        total_time += elapsed
        if solved:
            solved_count += 1
        
        status = "OK" if solved else "Echec"
        print(f"  Puzzle {i+1}: {status}, {elapsed*1000:.2f}ms")
        
        for strategy, count in solver.strategy_counts.items():
            all_strategies[strategy] = all_strategies.get(strategy, 0) + count
    
    print(f"\nResume:")
    print(f"  Resolus: {solved_count}/{num_puzzles}")
    print(f"  Temps total: {total_time*1000:.2f}ms")
    print(f"  Temps moyen: {(total_time/num_puzzles)*1000:.2f}ms")
    print(f"\nStrategies utilisees:")
    for strategy, count in sorted(all_strategies.items()):
        print(f"  {strategy}: {count}")

benchmark_human(easy_puzzles, num_puzzles=5)

=== Benchmark: Strategies Humaines (5 puzzles) ===
  Puzzle 1: OK, 0.65ms
  Puzzle 2: OK, 2.35ms
  Puzzle 3: OK, 2.82ms
  Puzzle 4: OK, 3.62ms
  Puzzle 5: OK, 2.50ms

Resume:
  Resolus: 5/5
  Temps total: 11.93ms
  Temps moyen: 2.39ms

Strategies utilisees:
  Hidden Single: 149
  Naked Pair: 15
  Naked Single: 91
  Pointing Pair: 17
  X-Wing: 5

Lire les chronos du benchmark : cinq puzzles, un rythme et des écarts

La sortie donne enfin des chiffres que les interprétations qualitatives de la section 8 n’avaient pas encore cités. Puzzle 1 : 0,65 ms — Puzzle 4 : 3,62 ms : entre le plus rapide et le plus lent des cinq, le rapport atteint 5,6x — même parmi des puzzles « faciles », la structure fait varier les temps d’un ordre de grandeur modeste, exactement le comportement que le notebook 18b quantifie sur 8 grilles comparables. Le bilan cité est cohérent : 5/5 résolus, total 11,93 ms, moyenne 2,39 ms — une moyenne qui a du sens ici parce que tous les puzzles partagent la même fourchette (pas de valeur aberrante au-delà de 3,62 ms). Le dernier bloc donne la « balance » des techniques sur les cinq grilles confondues : Hidden Single : 149, Naked Single : 91, Pointing Pair : 17, Naked Pair : 15, X-Wing : 5 (total 277 déductions). Trois lectures : (1) les singles font ~87 % du travail (240/277), confirmant la hiérarchie des sections 2-3 ; (2) le X-Wing, à 5, est presque de l’ordre du bruit sur des grilles faciles ; (3) la hiérarchie 149 > 91 > 17 > 15 > 5 est la courbe de fréquence à retenir : la rareté d’une technique croît avec sa complexité, de la déduction unitaire à la structure en croix.

Interpretation : Benchmark des Stratégies Humaines

Metrique Valeur Signification
Taux de reussite 5/5 (100%) Tous les puzzles faciles resolus
Temps moyen quasi-instantanee (cf. mesure ci-dessus) Performance excellente
Temps total quasi-instantanee (cf. mesure ci-dessus) Même avec 5 puzzles
Stratégies utilisees 5 différentes Toute la gamme de difficulte

Distribution des stratégies : - Hidden Single : 149 applications (technique la plus frequente) - Naked Single : 91 applications - Pointing Pair : 17 applications - Naked Pair : 15 applications - X-Wing : 5 applications (technique avancee peu frequente)

Points cles : 1. Les techniques de base (Naked/Hidden Singles) representent 87% des deductions (240/277) 2. Les techniques avancees (X-Wing) ne sont necessaires que pour 2% des deductions 3. La resolution est extremement rapide : quasi-instantanee par puzzle (cf. mesure ci-dessus) 4. Le solveur humain est complet : il combine automatiquement les techniques necessaires

Note technique : Les Hidden Singles sont plus nombreux que les Naked Singles car ils detectent des contraintes cachees. Une case peut avoir plusieurs candidats (pas de Naked Single) mais un de ces candidats ne peut aller nulle part ailleurs dans l’unite (Hidden Single).

9. Comparaison avec le Backtracking Simple

Exercice : Mesurer le taux de resolution par stratégies humaines seules

Objectif Determinez quel pourcentage de puzzles peut etre resolu uniquement par stratégies humaines (sans backtracking), par difficulte.

Indice Utilisez le solveur HumanSudokuSolver et comptez les puzzles ou solve() retourne True sans utiliser le backtracking de secours.

# EXERCICE : Mesurer le taux de resolution par strategies humaines seules
def measure_human_solve_rate(puzzles_by_difficulty: dict) -> dict:
    # TODO: Pour chaque difficulte, testez les puzzles et comptez
    # combien peuvent etre resolus uniquement par strategies humaines
    result = {}  # TODO etudiant
    return result
print("Exercice a completer")
Exercice a completer
class SimpleBacktracking:
    """Simple backtracking pour comparaison."""
    
    def __init__(self):
        self.call_count = 0
    
    def solve(self, puzzle_str: str) -> bool:
        grid = np.zeros((9, 9), dtype=int)
        for i in range(81):
            grid[i // 9, i % 9] = int(puzzle_str[i])
        
        self.call_count = 0
        return self._backtrack(grid)
    
    def _backtrack(self, grid: np.ndarray) -> bool:
        self.call_count += 1
        
        for r in range(9):
            for c in range(9):
                if grid[r, c] == 0:
                    for val in range(1, 10):
                        if self._is_valid(grid, r, c, val):
                            grid[r, c] = val
                            if self._backtrack(grid):
                                return True
                            grid[r, c] = 0
                    return False
        return True
    
    def _is_valid(self, grid: np.ndarray, row: int, col: int, val: int) -> bool:
        if val in grid[row, :]:
            return False
        if val in grid[:, col]:
            return False
        br, bc = 3 * (row // 3), 3 * (col // 3)
        if val in grid[br:br+3, bc:bc+3]:
            return False
        return True

print("\n=== Comparaison : Strategies Humaines vs Backtracking ===")

for i, puzzle_str in enumerate(easy_puzzles[:3]):
    print(f"\nPuzzle {i+1}:")
    
    # Strategies humaines
    solver = HumanSudokuSolver(puzzle_str)
    start = time.time()
    solved_h = solver.solve()
    t_h = time.time() - start
    
    # Backtracking
    bt = SimpleBacktracking()
    start = time.time()
    solved_bt = bt.solve(puzzle_str)
    t_bt = time.time() - start
    
    print(f"  Humain: {solved_h}, {t_h*1000:.2f}ms")
    print(f"  Backtracking: {solved_bt}, {t_bt*1000:.2f}ms, {bt.call_count} appels")

=== Comparaison : Strategies Humaines vs Backtracking ===

Puzzle 1:
  Humain: True, 0.75ms
  Backtracking: True, 1.54ms, 49 appels

Puzzle 2:
  Humain: True, 3.14ms
  Backtracking: True, 7.55ms, 201 appels

Puzzle 3:
  Humain: True, 2.16ms
  Backtracking: True, 10.95ms, 295 appels

Lire la comparaison appariée : l’écart se creuse avec la difficulté

La sortie compare les deux solveurs puzzle par puzzle, ce qui permet trois lectures exactes. La première : les chronos appariés — P1 : 0,75 ms contre 1,54 ms, P2 : 3,14 ms contre 7,55 ms, P3 : 2,16 ms contre 10,95 ms. Les ratios par puzzle sont 2,1x, 2,4x puis 5,1x : l’avantage des stratégies humaines grandit avec la difficulté du puzzle, puisque le backtracking paie chaque retour-arrière en appels récursifs pendant que les déductions logiques coûtent un balayage de candidats. La deuxième lecture : la colonne des appels — 49, 201, 295 — croît dans le même ordre : le backtracking « simple » de la section 9 confirme être le point de comparaison honnête du notebook, celui qui justifie les stratégies humaines sans triomphalisme (sur un puzzle qu’il résout aussi, en 10,95 ms quand même). La troisième : les deux solveurs affichent tous True — la comparaison ne porte pas sur la résolubilité (les deux sont complets sur ces grilles), mais sur le chemin : des déductions allant de 0,75 ms (P1) à 3,14 ms (P2), contre 545 appels d’essai-erreur cumulés (49 + 201 + 295).

Interpretation : Comparaison Humain vs Backtracking

Aspect Stratégies Humaines Backtracking Simple
Temps moyen quasi-instantanee (deduction logique) quelques ms (essai-erreur)
Appels recursifs 0 (deduction logique) 49 - 295 appels
Efficacite Resolution directe Essai-erreur
Explicabilite Chaîne de deductions claire Pas de traçabilité

Points cles : 1. Les stratégies humaines sont 2-5x plus rapides que le backtracking simple pour les puzzles faciles 2. Le backtracking effectue des appels recursifs inutiles (49-295) alors que les stratégies humaines resolvent directement par deduction 3. La différence de performance s’accroit avec la difficulte du puzzle (puzzle 3 : cf. mesures ci-dessus) 4. Les stratégies humaines produisent une solution explicable (chaque placement est justifie par une technique logique)

Note technique : Le backtracking simple ne profite pas des contraintes pour reduire l’espace de recherche. Il teste toutes les possibilites de 1 a 9 pour chaque case, même quand une seule est valide. Les stratégies humaines identifient directement cette unique possibilite.

Distinction cle : Naked Pair vs Hidden Pair

Ces deux techniques sont frequemment confondues. Voici la différence fondamentale :

Naked Pair Hidden Pair
Observation Deux cases contiennent exactement les mêmes 2 candidats {X, Y} Deux valeurs {X, Y} n’apparaissent comme candidats que dans les mêmes 2 cases
Condition Les 2 cases ont chacune exactement {X, Y} comme candidats Les 2 cases peuvent avoir plus de candidats, mais X et Y n’apparaissent nulle part ailleurs
Action Eliminer X et Y des autres cases de l’unite Eliminer les autres candidats de ces 2 cases

Exemple concrete dans un bloc avec 4 cases vides :

Case A : candidats {3, 7}
Case B : candidats {3, 7}
Case C : candidats {2, 3, 5}
Case D : candidats {2, 5, 7}
  • Naked Pair : A et B ont exactement {3, 7}. On elimine 3 et 7 de C et D. Résultat : C={2, 5}, D={2, 5}.
  • Hidden Pair : Si les valeurs 3 et 7 n’apparaissent que dans A et B (pas dans C ou D), on elimine les autres candidats de A et B. Résultat : A={3, 7}, B={3, 7}.

Mnemotechnique : “Naked” = les candidats sont visibles a nu (les cases montrent clairement {X,Y}). “Hidden” = les candidats sont ** caches** parmi d’autres, mais le fait qu’ils n’apparaissent qu’a deux endroits les revele.

10. Exemple guide : techniques avancees

Les cellules suivantes contiennent des exemples resolus pour trois techniques avancees (Hidden Pair, Swordfish, XY-Wing). Etudiez le code de chaque exemple, puis implementez la variante proposee dans le stub TODO qui suit.

Exemple guide 1 : Hidden Pair (exemple + variante)

L’exemple ci-dessous montre une implementation complete de la detection de Hidden Pairs dans toutes les unites. La variante vous demande de specialiser cette detection aux blocs uniquement.

Exemple guide 2 : Swordfish (exemple + variante)

L’exemple montre une implementation complete du Swordfish dans les deux orientations (lignes et colonnes). La variante vous demande d’implementer uniquement la detection par colonnes.

Exemple guide 3 : XY-Wing (exemple + variante)

L’exemple montre une implementation complete du XY-Wing avec recherche exhaustive. La variante vous demande d’implementer une fonction ciblee qui, pour un pivot donne, cherche les ailes possibles.

# Exemple resolu : Hidden Pair
from itertools import combinations

def find_hidden_pairs(self) -> list:
    """
    Trouve les Hidden Pairs dans toutes les unites (lignes, colonnes, blocs).

    Principe :
      - Pour chaque unite et chaque paire de valeurs (v1, v2), v1 < v2,
        on verifie si v1 et v2 apparaissent dans EXACTEMENT les 2 memes cases.
      - Si c'est le cas, les autres candidats de ces 2 cases peuvent etre elimines.

    Returns:
        Liste de tuples (row1, col1, row2, col2, {v1, v2}, unit_type)
    """
    results = []

    # Construction des 27 unites : 9 lignes, 9 colonnes, 9 blocs
    units = []
    for r in range(9):
        units.append(([(r, c) for c in range(9)], 'row'))
    for c in range(9):
        units.append(([(r, c) for r in range(9)], 'col'))
    for br in range(3):
        for bc in range(3):
            block = [(br * 3 + dr, bc * 3 + dc) for dr in range(3) for dc in range(3)]
            units.append((block, 'block'))

    # Pour chaque unite, chercher les Hidden Pairs
    for cells, unit_type in units:
        # Pour chaque paire de valeurs possibles (1-9)
        for v1, v2 in combinations(range(1, 10), 2):
            # Cases de l'unite ou v1 est candidat
            cells_v1 = [(r, c) for (r, c) in cells
                        if self.grid[r, c] == 0 and v1 in self.candidates[r][c]]
            # Cases de l'unite ou v2 est candidat
            cells_v2 = [(r, c) for (r, c) in cells
                        if self.grid[r, c] == 0 and v2 in self.candidates[r][c]]

            # Hidden Pair detecte : v1 et v2 confines aux memes 2 cases
            if len(cells_v1) == 2 and cells_v1 == cells_v2:
                (r1, c1), (r2, c2) = cells_v1

                # Filtrer : si les 2 cases ne contiennent deja que {v1, v2},
                # c'est un Naked Pair, pas un Hidden Pair utile
                extras = (self.candidates[r1][c1] | self.candidates[r2][c2]) - {v1, v2}
                if extras:
                    results.append((r1, c1, r2, c2, {v1, v2}, unit_type))

    return results


def apply_hidden_pair(self, row1: int, col1: int, row2: int, col2: int,
                      pair_values: set, unit_type: str) -> int:
    """
    Applique un Hidden Pair : ne garde que pair_values comme candidats
    dans les deux cases concernees.

    Returns:
        Nombre de candidats elimines.
    """
    eliminated = 0

    for (r, c) in [(row1, col1), (row2, col2)]:
        # Tous les candidats hors de la paire sont impossibles ici
        to_remove = self.candidates[r][c] - pair_values
        for val in to_remove:
            self.candidates[r][c].discard(val)
            eliminated += 1

    return eliminated


# Attacher les methodes a la classe
HumanSudokuSolver.find_hidden_pairs = find_hidden_pairs
HumanSudokuSolver.apply_hidden_pair = apply_hidden_pair

print("Methode definie : find_hidden_pairs")
Methode definie : find_hidden_pairs

Lecture de l’exemple résolu : Hidden Pair, la première brique avancée

La sortie (Methode definie : find_hidden_pairs) valide que la cellule d’exemple guide est bien un ajout fonctionnel — l’apport de la lecture vient de la cellule de test de la section 10 : le motif détecté y est imprimé, Hidden Pairs trouves: 1 avec (0,7) et (1,6) = {8, 7} dans block. Cette ligne est la lecture complète de l’exemple : deux cases dans le même bloc (intersection des lignes 0-1 et des colonnes 6-7, coin supérieur droit de la grille) sont les seules positions où les valeurs 8 et 7 peuvent aller dans ce bloc ; elles gardent leurs autres candidats éventuels, mais 8 et 7 sont confisquées — tout autre candidat dans ces deux cases devient éliminable. C’est le miroir exact du Naked Pair (deux cases avec deux candidats seulement → éliminer des voisins) décrit en section 3.

Exercice 1b : Hidden Pair dans les blocs

Enonce : Implementez une fonction find_hidden_pairs_blocks(self) qui detecte les Hidden Pairs uniquement dans les 9 blocs 3x3.

Consignes : 1. Parcourez chaque bloc 3x3 2. Pour chaque paire de valeurs (v1, v2), verifiez si les deux valeurs n’apparaissent comme candidats que dans exactement les 2 mêmes cases du bloc 3. Si c’est le cas et que ces cases ont d’autres candidats, identifiez le Hidden Pair 4. Retournez la liste des Hidden Pairs trouves sous forme de tuples (row1, col1, row2, col2, {v1, v2}) 5. Validez en filtrant les résultats de find_hidden_pairs() avec unit_type='block'

# Exemple guide 1b : Hidden Pair dans les blocs
#
# TODO: Implementez find_hidden_pairs_blocks(self) qui detecte les Hidden Pairs
# uniquement dans les 9 blocs 3x3 (en ignorant lignes et colonnes).
#
# Principe du Hidden Pair :
#   - Dans un bloc, si deux valeurs v1 et v2 n'apparaissent comme candidats
#     que dans exactement les 2 memes cases, alors les autres candidats
#     de ces 2 cases peuvent etre elimines.
#
# Indice : simplifiez la boucle "units" de l'exemple pour ne garder
#          que les 9 blocs (unit_type='block').

# Votre code ici

print("Exercice a completer : Hidden Pairs dans les blocs")
Exercice a completer : Hidden Pairs dans les blocs

Exemple guide 2 : Swordfish

Le Swordfish est une generalisation du X-Wing sur trois lignes (ou colonnes) au lieu de deux : une valeur forme un pattern de 3 lignes x 3 colonnes ou elle est confinee, permettant l’elimination de ses candidats dans les colonnes (ou lignes) correspondantes en dehors du pattern. L’exemple ci-dessous implemente la detection dans les deux orientations.

# Exemple resolu : Swordfish
# Generalisation du X-Wing sur 3 lignes / 3 colonnes.

def find_swordfish(self) -> list:
    """
    Trouve les Swordfish, orientation 'row' et orientation 'col'.

    Returns:
        Liste de (rows_tuple, cols_tuple, value, orientation)
    """
    results = []

    for value in range(1, 10):
        # --- Orientation LIGNES ---
        row_cols = {}
        for r in range(9):
            cols = [c for c in range(9)
                    if self.grid[r, c] == 0 and value in self.candidates[r][c]]
            if 2 <= len(cols) <= 3:
                row_cols[r] = set(cols)

        for rows in combinations(sorted(row_cols.keys()), 3):
            union_cols = row_cols[rows[0]] | row_cols[rows[1]] | row_cols[rows[2]]
            if len(union_cols) == 3:
                results.append((rows, tuple(sorted(union_cols)), value, 'row'))

        # --- Orientation COLONNES ---
        col_rows = {}
        for c in range(9):
            rows_in_c = [r for r in range(9)
                         if self.grid[r, c] == 0 and value in self.candidates[r][c]]
            if 2 <= len(rows_in_c) <= 3:
                col_rows[c] = set(rows_in_c)

        for cols in combinations(sorted(col_rows.keys()), 3):
            union_rows = col_rows[cols[0]] | col_rows[cols[1]] | col_rows[cols[2]]
            if len(union_rows) == 3:
                results.append((tuple(sorted(union_rows)), cols, value, 'col'))

    return results


def apply_swordfish(self, rows, cols, value, orientation) -> int:
    """Applique un Swordfish : elimine value des autres cases des 3 lignes/colonnes."""
    eliminated = 0
    rows_set = set(rows)
    cols_set = set(cols)

    if orientation == 'row':
        for c in cols:
            for r in range(9):
                if r not in rows_set and self.grid[r, c] == 0:
                    if value in self.candidates[r][c]:
                        self.candidates[r][c].discard(value)
                        eliminated += 1
    elif orientation == 'col':
        for r in rows:
            for c in range(9):
                if c not in cols_set and self.grid[r, c] == 0:
                    if value in self.candidates[r][c]:
                        self.candidates[r][c].discard(value)
                        eliminated += 1

    return eliminated


HumanSudokuSolver.find_swordfish = find_swordfish
HumanSudokuSolver.apply_swordfish = apply_swordfish

print("Methode definie : find_swordfish")
Methode definie : find_swordfish

Lecture de l’exemple résolu : Swordfish, un X-Wing à trois arêtes

La sortie (Methode definie : find_swordfish) valide l’ajout, et le test de la section 10 la fait parler : Swordfish trouves: 1 avec Valeur 7, lignes=(0, 1, 3), colonnes=(1, 6, 7), orientation=row. La ligne se lit comme une généralisation du X-Wing : au lieu de deux lignes et deux colonnes, le Swordfish aligne trois lignes dont le candidat 7 n’occupe que trois colonnes (1, 6 et 7) ; la valeur doit donc occuper une colonne différente à chaque ligne, et elle peut être éliminée des trois colonnes en dehors des trois lignes visées. L’orientation row dit le sens du balayage (le motif existe aussi par colonnes — l’exercice 2b fera coder le transposé). Sur la grille de test, la détection d’un seul motif suffit pour la cellule de validation : la rareté du Swordfish (un motif à trois niveaux sur une grille facile) est elle-même l’information — comme le X-Wing du benchmark, ces structures ne paient leur code que sur les puzzles moyens.

Exercice 2b : Swordfish par colonnes

Enonce : Implementez une fonction find_swordfish_cols(self) qui detecte les Swordfish uniquement dans l’orientation colonnes (en ignorant les lignes).

Consignes : 1. Pour chaque valeur de 1 a 9, identifiez les colonnes ou cette valeur apparait dans 2 ou 3 cases 2. Pour chaque combinaison de 3 colonnes, verifiez si l’union des lignes fait exactement 3 3. Retournez la liste des Swordfish trouves sous forme de tuples (rows, cols, value) 4. Validez en comparant les résultats de orientation=‘col’ de find_swordfish()

# Exemple guide 2b : Swordfish par colonnes
#
# TODO: Implementez find_swordfish_cols(self) qui detecte les Swordfish
# uniquement dans l'orientation "colonnes".
#
# Principe du Swordfish (orientation colonnes) :
#   - Pour une valeur v, trouvez 3 colonnes ou v apparait dans 2 ou 3 cases
#   - Si l'union des lignes concernees fait exactement 3 lignes,
#     alors v peut etre elimine des autres cases de ces 3 lignes
#
# Indice : inspirez-vous de la section "Orientation COLONNES" de l'exemple.

# Votre code ici

print("Exercice a completer : Swordfish par colonnes")
Exercice a completer : Swordfish par colonnes

Exemple guide 3 : XY-Wing

Le XY-Wing est une technique basee sur trois cellules bi-valuees (ayant exactement deux candidats). Un pivot avec les candidats {X, Y} est connecte a deux ailes : l’une avec {X, Z} et l’autre avec {Y, Z}. La valeur Z peut alors etre eliminee de toute cellule visible simultanement par les deux ailes. L’exemple ci-dessous implemente la recherche exhaustive de tous les XY-Wings dans la grille.

# Exemple resolu : XY-Wing

def _are_peers(self, r1: int, c1: int, r2: int, c2: int) -> bool:
    """Deux cases sont peers si elles partagent ligne, colonne ou bloc (et sont distinctes)."""
    if (r1, c1) == (r2, c2):
        return False
    if r1 == r2 or c1 == c2:
        return True
    if (r1 // 3 == r2 // 3) and (c1 // 3 == c2 // 3):
        return True
    return False


def find_xy_wings(self) -> list:
    """
    Trouve les XY-Wings.

    Returns:
        Liste de (pivot, wing1, wing2, z)
        - pivot, wing1, wing2 : tuples (row, col)
        - z : valeur a eliminer des cases visibles des deux ailes
    """
    results = []
    seen = set()

    # Collecter toutes les cases bi-valuees
    bivalue = [(r, c) for r in range(9) for c in range(9)
               if self.grid[r, c] == 0 and len(self.candidates[r][c]) == 2]

    for (rp, cp) in bivalue:
        pivot_cands = self.candidates[rp][cp]
        # Notations : candidats du pivot = {X, Y}
        x, y = sorted(pivot_cands)

        # Peers bi-valuees du pivot
        peers = [(r, c) for (r, c) in bivalue
                 if self._are_peers(rp, cp, r, c)]

        for (r1, c1) in peers:
            w1 = self.candidates[r1][c1]
            # Aile 1 doit partager exactement un candidat avec le pivot
            if x in w1 and y not in w1:
                z_set = w1 - {x}
            elif y in w1 and x not in w1:
                z_set = w1 - {y}
            else:
                continue  # w1 = {X,Y} (meme que pivot) ou aucun partage
            z = next(iter(z_set))

            # Si l'aile1 a partage X, on cherche aile2 = {Y, Z}
            # Si l'aile1 a partage Y, on cherche aile2 = {X, Z}
            if x in w1:
                target_w2 = {y, z}
            else:
                target_w2 = {x, z}

            for (r2, c2) in peers:
                if (r2, c2) == (r1, c1):
                    continue
                if self.candidates[r2][c2] != target_w2:
                    continue

                # Cle canonique pour eviter les doublons (ordre des ailes)
                key = (
                    (rp, cp),
                    frozenset([(r1, c1), (r2, c2)]),
                    z
                )
                if key in seen:
                    continue
                seen.add(key)

                results.append(((rp, cp), (r1, c1), (r2, c2), z))

    return results


def apply_xy_wing(self, pivot, wing1, wing2, z) -> int:
    """
    Applique un XY-Wing : elimine z des cases visibles des DEUX ailes
    (sauf pivot, wing1 et wing2 eux-memes).

    Returns:
        Nombre de candidats elimines.
    """
    eliminated = 0
    r1, c1 = wing1
    r2, c2 = wing2

    excluded = {pivot, wing1, wing2}

    for r in range(9):
        for c in range(9):
            if (r, c) in excluded:
                continue
            if self.grid[r, c] != 0:
                continue
            # Doit voir les deux ailes
            if self._are_peers(r, c, r1, c1) and self._are_peers(r, c, r2, c2):
                if z in self.candidates[r][c]:
                    self.candidates[r][c].discard(z)
                    eliminated += 1

    return eliminated


HumanSudokuSolver._are_peers = _are_peers
HumanSudokuSolver.find_xy_wings = find_xy_wings
HumanSudokuSolver.apply_xy_wing = apply_xy_wing

print("Methodes definies : _are_peers, find_xy_wings")
Methodes definies : _are_peers, find_xy_wings

Lecture de l’exemple résolu : trois XY-Wing, un cycle de paires

La sortie ne dit pas seulement « 3 trouvés », elle imprime les pivots : Pivot=(7, 1), Aile1=(7, 5), Aile2=(7, 7), puis les deux permutations symétriques — les trois pivots forment un cycle sur la ligne 7 de la grille, chacun servant d’aile aux deux autres. Le mécanisme XY-Wing se lit dans les valeurs éliminées : elimine=8, puis 1, puis 6 — trois valeurs distinctes, une par pivot. Un XY-Wing fonctionne avec trois cases bivaluées (deux candidats chacune) : un pivot {X, Y} et deux ailes {X, Z} et {Y, Z} ; tout candidat commun aux deux ailes (ici leur valeur partagée) peut être retiré de leurs cases voisines communes. Sur la ligne 7 de la grille de test, les trois cases (7,1), (7,5), (7,7) s’échangent ainsi leurs candidats — une chaîne fermée qui élimine trois chiffres, un par pivot ; c’est la plus coûteuse des trois structures de la section 10, et la seule qui exige de superposer trois objets bivalués pour déduire une élimination.

Exercice 3b : XY-Wing pour un pivot donne

Enonce : Implementez une fonction find_xy_wings_for_pivot(self, pivot_row, pivot_col) qui, pour un pivot donne, cherche toutes les paires d’ailes possibles.

Consignes : 1. Verifiez que la cellule pivot a exactement 2 candidats {X, Y} 2. Parmi les peers bi-valuees du pivot, trouvez les paires (aile1, aile2) ou aile1 = {X, Z} et aile2 = {Y, Z} 3. Retournez la liste des XY-Wings trouves sous forme de tuples (wing1, wing2, z) 4. Comparez votre résultat avec find_xy_wings() de l’exemple pour validation

# Exemple guide 3b : XY-Wing pour un pivot donne
#
# TODO: Implementez find_xy_wings_for_pivot(pivot_row, pivot_col) qui,
# etant donne un pivot (cellule avec exactement 2 candidats),
# cherche toutes les paires d'ailes possibles pour former un XY-Wing.
#
# Rappel de la structure d'un XY-Wing :
#   - Le pivot a pour candidats {X, Y}
#   - L'aile 1 (peer du pivot) a pour candidats {X, Z}
#   - L'aile 2 (peer du pivot) a pour candidats {Y, Z}
#   - Z peut etre elimine de toute cellule peer commune aux deux ailes
#
# Indice : reutilisez self._are_peers() de l'exemple ci-dessus.

# Votre code ici

print("Exercice a completer : XY-Wing pour un pivot donne")
Exercice a completer : XY-Wing pour un pivot donne

Validation des techniques avancees

Les trois exemples précédents (Hidden Pair, Swordfish, XY-Wing) sont maintenant integres a la classe HumanSudokuSolver. La cellule suivante teste leur detection sur le puzzle de reference, puis realise une resolution complete en combinant toutes les techniques avancees avant de finir avec le fallback backtracking.

# Test des 3 exemples resolus : Hidden Pair, Swordfish, XY-Wing
print("=== Test des techniques avancees (Exemples resolus) ===\n")

solver = HumanSudokuSolver(easy_puzzles[0])
print("Grille initiale:")
print(solver)

# Test Hidden Pairs
hidden_pairs = solver.find_hidden_pairs()
print(f"\nHidden Pairs trouves: {len(hidden_pairs)}")
for r1, c1, r2, c2, vals, unit_type in hidden_pairs[:3]:
    print(f"  ({r1},{c1}) et ({r2},{c2}) = {vals} dans {unit_type}")

# Test Swordfish
swordfish = solver.find_swordfish()
print(f"\nSwordfish trouves: {len(swordfish)}")
for rows, cols, val, orient in swordfish[:3]:
    print(f"  Valeur {val}, lignes={rows}, colonnes={cols}, orientation={orient}")

# Test XY-Wing
xy_wings = solver.find_xy_wings()
print(f"\nXY-Wings trouves: {len(xy_wings)}")
for pivot, w1, w2, z in xy_wings[:3]:
    print(f"  Pivot={pivot}, Aile1={w1}, Aile2={w2}, elimine={z}")

# Resolution complete avec les techniques avancees
print("\n--- Resolution d'un puzzle avec techniques avancees ---")
solver2 = HumanSudokuSolver(easy_puzzles[2])
print("Puzzle avant resolution:")
print(solver2)

# Appliquer les techniques avancees puis finir avec solve()
for r1, c1, r2, c2, vals, ut in solver2.find_hidden_pairs():
    solver2.apply_hidden_pair(r1, c1, r2, c2, vals, ut)
for rows, cols, val, orient in solver2.find_swordfish():
    solver2.apply_swordfish(rows, cols, val, orient)
for pivot, w1, w2, z in solver2.find_xy_wings():
    solver2.apply_xy_wing(pivot, w1, w2, z)

solver2.solve()
print(f"\nResolu: {solver2.is_solved()}")
print(solver2)
=== Test des techniques avancees (Exemples resolus) ===

Grille initiale:
9 . 2 | . . 5 | 4 . 3 
1 . . | . 6 3 | . 2 5 
5 . 8 | 4 . 7 | . 6 . 
---------------------
. 2 6 | 3 . 9 | . . 1 
. 5 7 | . 1 . | 2 9 . 
. 9 . | 6 7 . | 5 3 . 
---------------------
2 4 . | 5 3 . | 6 . . 
7 . 5 | 2 . . | 3 . 4 
. 8 . | . 4 1 | 9 5 . 

Hidden Pairs trouves: 1
  (0,7) et (1,6) = {8, 7} dans block

Swordfish trouves: 1
  Valeur 7, lignes=(0, 1, 3), colonnes=(1, 6, 7), orientation=row

XY-Wings trouves: 3
  Pivot=(7, 1), Aile1=(7, 5), Aile2=(7, 7), elimine=8
  Pivot=(7, 5), Aile1=(7, 1), Aile2=(7, 7), elimine=1
  Pivot=(7, 7), Aile1=(7, 1), Aile2=(7, 5), elimine=6

--- Resolution d'un puzzle avec techniques avancees ---
Puzzle avant resolution:
2 . . | . 8 . | 3 . . 
. 6 . | . 7 . | . 8 4 
. 3 . | 5 . . | 2 . 9 
---------------------
. . . | 1 . 5 | 4 . 8 
. . . | . . . | . . . 
4 . 2 | 7 . 6 | . . . 
---------------------
3 . 1 | . . 7 | . 4 . 
7 2 . | . 4 . | . 6 . 
. . 4 | . 1 . | . . 3 

Resolu: True
2 4 5 | 9 8 1 | 3 7 6 
1 6 9 | 2 7 3 | 5 8 4 
8 3 7 | 5 6 4 | 2 1 9 
---------------------
9 7 6 | 1 2 5 | 4 3 8 
5 1 3 | 4 9 8 | 6 2 7 
4 8 2 | 7 3 6 | 9 5 1 
---------------------
3 9 1 | 6 5 7 | 8 4 2 
7 2 8 | 3 4 9 | 1 6 5 
6 5 4 | 8 1 2 | 7 9 3 

Lire le test des techniques avancées : quatre faits, une grille résolue

La cellule de validation répète les trois exemplettes précédentes sur une grille unique et imprime chaque détection — c’est le bilan chiffré de la section 10. Hidden Pairs : 1 (le couple {(0,7), (1,6)} = {8, 7} déjà relevé), Swordfish : 1 (valeur 7, lignes (0, 1, 3) — un seul motif suffit sur la grille de test), XY-Wings : 3 (le cycle de pivots de la ligne 7). La deuxième moitié de la sortie change de grille : Puzzle avant resolution montre une grille vierge (première ligne 2 . . | . 8 . | 3 . .) que le solveur complet attaque — et Resolu: True avec les neuf lignes de solution complètes, dont la première 2 4 5 | 9 8 1 | 3 7 6. La leçon de cette lecture est la composition : les techniques de la section 10 ne servent pas seules — c’est le solveur de la section 7 qui les enchaîne (Hidden Pair d’abord, puis Swordfish, puis les XY-Wing) jusqu’à la résolution complète. Le test prouve ainsi l’objectif du notebook : un solveur « humain » qui remplace l’essai-erreur par une liste ordonnée de déductions.

Resume et perspectives

Ce notebook a explore la resolution de Sudoku par deduction logique progressive, en implementant les techniques utilisees par les experts humains : Naked Singles et Hidden Singles pour les puzzles faciles, Naked Pairs et Pointing Pairs pour le niveau intermediaire, et X-Wing pour les grilles avancees. Les techniques supplementaires (Hidden Pairs, Swordfish, XY-Wing) ont ete presentees en exemples guides pour illustrer les stratégies expert. Le benchmark sur 5 puzzles faciles a montre que les Hidden Singles dominent avec 149 applications (54% des deductions), suivis des Naked Singles (91 occurrences), tandis que les techniques avancees comme X-Wing ne representent que 2% des cas – confirmant que la majorite des puzzles courants se resolvent par inference directe sans recours au backtracking.

La comparaison avec le backtracking simple a mis en evidence un avantage qualificatif majeur : les stratégies humaines produisent une chaîne de deductions explicable, ou chaque placement est justifie par une technique logique identifiable, tandis que le backtracking procede par essai-erreur opaque. En termes de performance, le solveur humain est systematiquement plus rapide sur les puzzles faciles et ne necessite aucun appel récursif, la ou le backtracking accumule 49 a 295 appels. Le mécanisme de fallback vers le backtracking garantit toutefois la completude du solveur sur les instances ou les techniques de deduction atteignent leurs limites.

Le prochain notebook, Sudoku-09-GraphColoring-Python, change de paradigme en modelisant le Sudoku comme un problème de coloration de graphe avec NetworkX, ou les 81 cellules deviennent des sommets et les contraintes d’exclusion des aretes, permettant de comparer des heuristiques de coloration (MRV, LCV, Welsh-Powell) sur cette structure reguliere.

Resume

Techniques implementees

Technique Description Difficulte
Naked Single Case avec un seul candidat Facile
Hidden Single Valeur avec une seule position possible Facile
Naked Pair Deux cases avec les mêmes deux candidats Moyen
Pointing Pair Candidats restreints dans un bloc Moyen
X-Wing Rectangle de candidats Avance

Stratégies par difficulte

Niveau Techniques requises
Easy Naked Singles, Hidden Singles
Medium + Naked Pairs, Pointing Pairs
Hard + X-Wing
Expert + Swordfish, XY-Wing, etc.

Avantages des stratégies humaines

  1. Intuitives : Refletent la facon dont les humains resolvent
  2. Pedagogiques : Chaque technique est comprehensible individuellement
  3. Progressives : Des techniques simples aux avancees
  4. Visuelles : Peuvent etre expliquees avec des diagrammes

Limitations

  1. Pas toujours completes : Certains puzzles necessitent le backtracking
  2. Plus complexes : Plus difficiles a implementer que le backtracking simple
  3. Performance : Plus lentes que les solveurs CSP industriels

References


Navigation : << Sudoku-07-Norvig | Index | Sudoku-09-GraphColoring >>

Retour au sommet