Analyse et critique du code
Le code ci-dessus est-il parfait ? Non, car on pourrait lui reprocher plusieurs points :
Pire des cas
Même si le code que nous avons écrit fournira en général la solution assez rapidement (moins d'une demi seconde chez moi), les performances dépendent totalement de la grille passée en entrée. Certaines grilles spécialement conçues peuvent lui demander beaucoup plus de travail, ce qui montre bien que la complexité dans le pire des cas n'est pas si bonne que cela.
L'image ci-contre (source -
Wikipédia) est un exemple d'une telle grille. Le programme ci-dessus met chez moi environ 20 secondes pour la résoudre. La raison est que la bonne solution vient vers la fin de la descente récursive, et le nombre d'essais/erreurs est ainsi multiplié.
Pour pallier à cet inconvénient, essayons de réfléchir si l'on peut améliorer l'exploration des possibilités de manière à ce que la bonne solution sorte au plus tôt. La réponse est oui

!
Optimisation
Considérons quelques points :
- On peut ici effectuer le backtracking dans n'importe quel ordre, le choix que nous avons fait de parcourir linéairement la grille est arbitraire.
- Le nombre de valeurs possibles pour chaque case vide n'est pas identique.
- Plus on remplit de cases (autrement dit : plus on s'enfonce dans la récursion), plus augmentent les chances de créer un blocage.
Il apparait que si nous effectuons le backtracking depuis les cases avec un minimum de solutions vers les cases avec un maximum de solutions, nous minimisons sensiblement l'exploration des possibilités. Cette méthode garantit aussi que le backtracking sera toujours effectué de façon optimale, et nous évitons ainsi le pire des cas mentionné plus haut.
Concrètement, on peut implémenter cette méthode en créant une liste de cases vides, qui enregistre les coordonnées et le nombre de valeurs possibles de chaque case. On triera la liste en ordre croissant, puis on la passe en argument de notre fonction de backtracking.
Cette optimisation est en réalité incomplète. En effet, ici on se contente de trier la liste des cases à parcourir avant de procéder au backtracking. Une optimisation plus efficace serait de réorganiser l'ordre des cases à parcourir au cours de la descente récursive : on augmente encore considérablement les chances de tirer la bonne solution au plus tôt. Mais en pratique, ce n'est pas évident à réaliser correctement, c'est à dire en évitant que le coût des opérations ajoutées ne soit supérieur au gain escompté. C'est pourquoi nous n'aborderons pas ce point ici.
Voici un code possible :
Implémentation des listes "maison"
Secret (cliquez pour afficher)Code : C++ 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58 | ////////////////////////////////////////////
// implémentation de la liste
////////////////////////////////////////////
typedef struct _list
{
int i, j;
int nbValeursPossibles;
struct _list *next;
} LIST;
// retourne un nouvel élément initialisé
static LIST* new_elem (int i, int j, int n)
{
LIST* ret = (LIST*) malloc(sizeof* ret);
if (ret != NULL)
ret->i = i, ret->j = j, ret->nbValeursPossibles = n, ret->next = NULL;
return ret;
}
// supprime intégralement une liste chainée
void liste_delete (LIST** list)
{
LIST* tmp;
while ( (tmp = *list) != NULL)
*list = (*list)->next, free(tmp);
}
// ajoute en tête
void liste_cons (LIST** list, int i, int j, int n)
{
LIST* elem = new_elem (i, j, n);
if (elem != NULL)
elem->next = *list, *list = elem;
}
// insertion dans une liste triée
void insertion (LIST** list, LIST* elem)
{
if (*list == NULL)
*list = elem, elem->next = NULL;
else if ((*list)->nbValeursPossibles < elem->nbValeursPossibles)
insertion (&(*list)->next, elem);
else
elem->next = *list, *list = elem;
}
// tri insertion sur liste chainée
LIST* list_sort (LIST* list)
{
LIST *curr, *list2 = NULL, *tmp;
for (curr = list; curr != NULL; curr = tmp)
{
tmp = curr->next;
insertion (&list2, curr);
}
return list2;
}
|
Code optimisé
Code : C++ 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78 | bool absentDeLigne (int k, int grille[9][9], int i)
{
for (int j=0; j < 9; j++)
if (grille[i][j] == k)
return false;
return true;
}
bool absentDeColonne (int k, int grille[9][9], int j)
{
for (int i=0; i < 9; i++)
if (grille[i][j] == k)
return false;
return true;
}
bool absentDeBloc (int k, int grille[9][9], int i, int j)
{
int _i = i-(i%3), _j = j-(j%3); // ou encore : _i = 3*(i/3), _j = 3*(j/3);
for (i=_i; i < _i+3; i++)
for (j=_j; j < _j+3; j++)
if (grille[i][j] == k)
return false;
return true;
}
bool estValide (int grille[9][9], LIST* position)
{
// Si la liste est vide (fin de liste)
if (position == NULL)
return true;
int i = position->i, j = position->j;
for (int k=1; k <= 9; k++)
{
if ( absentDeLigne(k,grille,i) && absentDeColonne(k,grille,j) && absentDeBloc(k,grille,i,j) )
{
grille[i][j] = k;
if ( estValide(grille, position->next) )
return true;
}
}
grille[i][j] = 0;
return false;
}
// Calcule le nombre de valeurs possibles pour une case vide
int nb_possibles (int grille[9][9], int i, int j)
{
int ret = 0;
for (int k=0; k < 9; k++)
if ( absentDeLigne(k,grille,i) && absentDeColonne(k,grille,j) && absentDeBloc(k,grille,i,j) )
ret++;
return ret;
}
bool resolution (int grille[9][9])
{
// crée et remplit une liste pour les cases vides à visiter
LIST* positions = NULL;
for (int i=0; i < 9; i++)
for (int j=0; j < 9; j++)
if ( grille[i][j] == 0 )
liste_cons ( &positions, i, j, nb_possibles2(grille, i, j) );
// Trie la liste (ordre croissant)
positions = list_sort (positions);
// Appelle la fonction de backtracking récursive estValide()
bool ret = estValide (grille, positions);
// Détruit la liste
liste_delete (&positions);
// retourne le resultat
return ret;
}
|
2eme optimisation
Un autre point que l'on pourrait reprocher à notre code, c'est sa méthode couteuse pour tester si le nombre à insérer est absent de la ligne/colonne/bloc. En effet, cette fonction est dite "critique", car étant au coeur de la récursion, elle est appelée de très nombreuses fois. On a donc fort intérêt à l'optimiser...
Dans notre code, la méthode employée requiert au pire des cas un parcours de toute les cases de ces dernières, ce qui représente tout de même 27 cases. On peut améliorer sensiblement en mémorisant les valeurs présentes dans chaque ligne/colonne/bloc.
Si nous construisons au départ une liste des valeurs possibles pour chaque ligne/colonne/bloc, en utilisant des tableaux pour matérialiser ces listes, nous bénéficions d'un temps de lecture/écriture en O(1). Le test de possibilité d'insertion d'une valeur se fait alors en 3 opérations, ce qui est un gain considérable. Même en prenant en compte l'actualisation des listes au cours de la descente récursive, sachant que l'on fera bien plus de tests que d'actualisations, cette solution sera meilleure que celle proposée plus haut.
Exemple :
Code avec optimisation 1 et 2
Code : C++ 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76 | // Variables globales (tableaux) pour la mémorisation
bool existeSurLigne[9][9];
bool existeSurColonne[9][9];
bool existeSurBloc[9][9];
bool estValide (int grille[9][9], LIST* position)
{
if (position == NULL)
return true;
int i = position->i, j = position->j;
for (int k=0; k < 9; k++)
{
// Vérifie dans les tableaux si la valeur est déjà présente
if ( !existeSurLigne[i][k] && !existeSurColonne[j][k] && !existeSurBloc[3*(i/3)+(j/3)][k] )
{
// Ajoute k aux valeurs enregistrées
existeSurLigne[i][k] = existeSurColonne[j][k] = existeSurBloc[3*(i/3)+(j/3)][k] = true;
if ( estValide(grille, position->next) )
{
// Ecrit le choix valide dans la grille
grille[i][j] = k+1;
return true;
}
// Supprime k des valeurs enregistrées
existeSurLigne[i][k] = existeSurColonne[j][k] = existeSurBloc[3*(i/3)+(j/3)][k] = false;
}
}
return false;
}
// Calcule le nombre de valeurs possibles pour une case vide
int nb_possibles (int grille[9][9], int i, int j)
{
int ret = 0;
for (int k=0; k < 9; k++)
if ( !existeSurLigne[i][k] && !existeSurColonne[j][k] && !existeSurBloc[3*(i/3)+(j/3)][k] )
ret++;
return ret;
}
bool resolution (int grille[9][9])
{
// Initialise les tableaux
for (int i=0; i < 9; i++)
for (int j=0; j < 9; j++)
existeSurLigne[i][j] = existeSurColonne[i][j] = existeSurBloc[i][j] = false;
// Enregistre dans les tableaux toutes les valeurs déjà présentes
int k;
for (int i=0; i < 9; i++)
for (int j=0; j < 9; j++)
if ( (k = grille[i][j]) != 0)
existeSurLigne[i][k-1] = existeSurColonne[j][k-1] = existeSurBloc[3*(i/3)+(j/3)][k-1] = true;
// crée et remplit une liste pour les cases vides à visiter
LIST* positions = NULL;
for (int i=0; i < 9; i++)
for (int j=0; j < 9; j++)
if ( grille[i][j] == 0 )
liste_cons ( &positions, i, j, nb_possibles(grille, i, j) );
// Trie la liste (ordre croissant)
positions = list_sort (positions);
// Appelle la fonction de backtracking récursive estValide()
bool ret = estValide (grille, positions);
// Détruit la liste
liste_delete (&positions);
// retourne le resultat
return ret;
}
|
Concernant la résolution logicielle de sudokus
Le code fourni ici pourrait, avec de légères modification, servir à vérifier que le nombre de solutions d'un sudoku ne dépasse pas 1. Pour cela, il suffit de retourner non pas si la grille est valide ou non, mais plutôt le nombre de solutions possibles. Je vous laisse trouver tout seul comment faire

.
Aussi, pensez qu'un backtracking appliqué à une grille vide fournit une grille remplie (la première possible). En assignant à chaque case une liste de valeurs ordonnée aléatoirement pour l'énumération des possibilités, on peut obtenir une grille remplie aléatoirement. Mais cela n'est pas l'objet de ce tuto

...
Bien entendu, il est possible de mettre en place des algorithmes plus sophistiqués pour résoudre un sudoku. Vous pouvez évidement vous inspirer des méthodes de résolution utilisées par les vrais joueurs pour cela (exemples :
ici et
là).
Enfin, pour les curieux, sachez que le problème de savoir s'il existe une solution pour une grille de sudoku est classé
NP-complet, ce qui revient à dire qu'aucune méthode n'est connue à ce jour pour trouver efficacement la réponse pour une grille N
2*N
2 avec N un peu grand (même s'il n'existe pas non plus de preuve que ce soit impossible

).