Les 25 immeubles : une case de plus, et le programme ne finit plus

Partage
Un carnet à petits carreaux portant une grille 5×5 vide, un crayon, une gomme et cinq blocs de bois de hauteurs décroissantes alignés comme des immeubles.

Il y a quelques années, j’ai mis en ligne Les 16 immeubles : une grille 4×4 à remplir avec des immeubles de 1 à 4 étages, où des observateurs postés autour annoncent combien d’immeubles ils aperçoivent — un immeuble plus haut cache ceux qui sont derrière. La page propose 372 grilles, et le petit script Python qui les a fabriquées tourne en quelques secondes. Je n’y avais plus touché.

Passer en 5×5 me semblait une formalité : le même programme, une constante à changer. J’ai essayé plusieurs fois, au fil des mois. Mes versions de test n’ont jamais terminé. Le plus long était la vérification de l’unicité des solutions — je lançais, j’attendais, j’abandonnais.

La page existe désormais : Les 25 immeubles, 400 grilles. La chaîne complète se calcule en 28 secondes. Voici ce qui coinçait, parce que je crois que le cas est instructif bien au-delà de ce petit casse-tête.

Une case de plus, et tout change d’échelle

Une grille valide, c’est un carré latin : chaque hauteur une fois par ligne et une fois par colonne. Il y en a 576 en 4×4, et 161 280 en 5×5. Un facteur 280 : rien d’effrayant.

Sauf que mon programme ne construisait pas 280 fois plus de choses. Il en construisait 90 000 fois plus. Parce qu’il faisait ceci :

# toutes les grilles imaginables, puis on jette celles qui ne vont pas
a = list(itertools.permutations(resultat, cote))
bonnes = [x for x in a if accept_list(x)]

Engendrer tout, puis filtrer. C’est la façon la plus naturelle d’écrire ce genre de chose, et elle est parfaitement raisonnable… tant que le tas de départ reste petit.

grilles à construiregrilles validesdéchet
4×4255 024576443 : 1
5×522 869 362 880161 280141 800 : 1

Vingt-deux milliards. Et ce n’est même pas une question de patience : ce list(...) demande à Python de tenir les vingt-deux milliards de grilles en mémoire d’un seul coup. Le programme ne ramait pas, il ne pouvait pas démarrer.

Voilà le vrai piège, et il n’a rien d’évident : mon code de 2023 n’était pas mauvais. En 4×4 il gaspille déjà 443 grilles sur 444, et personne ne s’en aperçoit. Le gaspillage ne devient visible qu’en changeant d’échelle.

Premier levier : ne pas construire ce qu’on va jeter

Plutôt que d’empiler cinq lignes au hasard pour vérifier ensuite si l’empilement tient, on pose les lignes une par une, et on vérifie la compatibilité dès qu’une ligne est posée. Une deuxième ligne qui heurte la première est abandonnée sur-le-champ — et avec elle, d’un seul geste, le million et demi de grilles qui commençaient ainsi.

def poser(lignes):
    if len(lignes) == n:
        grilles.append(tuple(lignes))
        return
    for p in perms:
        # le test a lieu AVANT de descendre, pas apres
        if all(all(a != b for a, b in zip(l, p)) for l in lignes):
            lignes.append(p)
            poser(lignes)
            lignes.pop()

Neuf lignes. Le résultat est le même — exactement les mêmes 161 280 grilles — mais le chemin pour y arriver n’a plus rien à voir :

approche directeen élaguantrapport
4×4255 02419 60813 ×
5×522 869 362 88027 950 520818 ×

Treize fois en 4×4 : indolore, invisible. Huit cent dix-huit fois en 5×5 : c’est toute la différence entre un programme qui finit et un programme qui ne finit pas.

Deuxième levier : ne pas chercher ce qu’on peut ranger

Mon autre goulot, celui que j’avais identifié — la vérification de l’unicité. Une grille n’est publiable que si ses indices ne désignent qu’elle : deux grilles différentes portant les mêmes nombres autour donneraient un énoncé sans réponse unique. Je testais cela ainsi :

for i in dico:
    if list(dico.values()).count(dico[i]) == 1:
        dico2[i] = dico[i]

Pour chaque grille, je reconstruis la liste de toutes les valeurs, et je la parcours entièrement pour compter. Sur 576 grilles, c’est instantané. Sur 161 280, chaque tour de boucle repasse sur les 161 280 autres — et il y a 161 280 tours.

La bonne façon tient en deux lignes : on range chaque empreinte dans un casier, une seule fois, puis on relit les casiers qui n’en contiennent qu’un.

combien = Counter(empreintes)
uniques = [g for g, e in zip(grilles, empreintes) if combien[e] == 1]
grillesen balayanten rangeant
8 0001,06 s0,0016 s
161 280≈ 430 s (7 minutes)0,045 s

Et le point qui m’a le plus frappé, une fois dit : on ne résout jamais aucune grille pour vérifier l’unicité. On calcule l’empreinte de chacune — ses vingt indices — et on cherche les empreintes en double. C’est exactement ce que fait un index de base de données, et je cherchais à la main ce qu’il suffisait de ranger.

Le vrai problème n’était pas la vitesse

C’est ici que l’histoire devient intéressante, parce qu’elle cesse de parler d’informatique.

Une fois le calcul en place, j’avais 68 368 grilles 5×5 à solution unique. De quoi publier. Sauf que « solution unique » et « on peut la résoudre » ne veulent pas dire la même chose.

En 4×4, les deux se confondent : les 372 grilles de la page se résolvent toutes par déduction pure. On peut publier les yeux fermés. En 5×5, c’est faux. Près de la moitié des grilles à solution unique n’admettent aucun chemin de déduction : pour les finir, il faut poser une hauteur au hasard, dérouler les conséquences, et revenir en arrière si l’on se contredit. Elles sont mathématiquement irréprochables et humainement injouables — et rien, dans l’énoncé, ne les distingue des autres.

J’ai eu la démonstration en direct. On m’avait préparé un échantillon gradué, et j’ai attaqué celle qui était classée la plus facile. Je n’ai réussi à placer que trois 5. Puis plus rien. Vérification faite après coup : les règles de déduction usuelles donnent sur cette grille exactement trois cases, et ce sont précisément ces trois-là. Je n’avais pas calé — j’avais atteint le fond de ce que la grille pouvait donner. La suite demandait d’énumérer les 120 dispositions possibles d’une ligne. À la main, non.

À gauche la grille vide avec ses indices ; à droite, les trois seuls 5 que la déduction permet de placer.
L’indice 1 en haut de la quatrième colonne force le 5 au bord : un seul immeuble visible, c’est que le plus haut est devant. Ce 5 verrouille sa colonne, et de proche en proche deux autres 5 n’ont plus qu’une case possible. Ensuite, la grille se tait.

Le critère a donc changé. Une grille n’est plus retenue parce qu’elle a une solution unique, mais parce qu’elle se résout avec les règles qu’un joueur applique de tête : l’indice 1 impose le plus haut immeuble au bord, l’indice 5 impose la file croissante, un indice c plafonne les premières cases, on élimine ce qui est déjà posé sur la ligne et la colonne, et une hauteur qui n’a plus qu’une case possible y va.

Ce répertoire est volontairement pauvre : ni énumération, ni hypothèse, ni retour en arrière. Il ne retient que 1 448 grilles sur 161 280 — mais celles-là se terminent sans jamais tâtonner.

Dernier tri : une grille et ses sept images par rotation et symétrie posent le même problème. Qui vient d’en résoudre une refera la suivante sans y penser. En groupant, les 1 448 grilles se ramènent à 188 réellement distinctes, dans lesquelles les 400 fiches sont puisées à tour de rôle. Au passage, cela m’a appris quelque chose sur ma vieille page : ses 372 grilles 4×4 ne sont, elles, que 55 grilles distinctes. Chacune revient en moyenne près de sept fois, tournée d’un quart de tour. Personne ne s’en est jamais plaint, mais c’est bon à savoir.

Le compte final

énumération des 161 280 grilles     14,2 s
calcul des indices                   0,9 s
repérage des empreintes uniques      0,03 s     →  68 368
résolubles sans tâtonner            13,0 s     →   1 448   (188 classes)
                                    ───────
total                               28,1 s

Vingt-huit secondes, dont zéro virgule zéro trois pour l’étape qui me bloquait depuis des mois.

Le code est en libre accès

J’ai sorti le cœur du générateur dans un dépôt autonome, sans rien qui dépende de mon site, sous licence AGPL-3.0 et sans aucune dépendance hors bibliothèque standard. Il vit sur deux dépôts miroirs : la Forge des communs numériques éducatifs et GitHub, et il est archivé et citable : DOI 10.5281/zenodo.22065758.

Il contient la bibliothèque, un utilitaire en ligne de commande qui sort des grilles en texte ou en HTML, une série de contrôles — dont la vérification du solveur par force brute — et surtout bench.py, qui reproduit tous les chiffres de cet article sur votre machine. Le code est générique : il marche en 4×4 comme en 5×5. Au-delà, l’énumération exhaustive n’a plus de sens et il faudrait échantillonner.

Pour un usage en classe, une seule commande suffit : python3 generate.py -c 20 -o html sort une feuille prête à photocopier, grilles puis solutions. En 4×4 c’est jouable dès le cycle 3, en 5×5 plutôt au cycle 4.

Un mot sur la méthode

J’ai mené ce travail avec Claude (l’assistant d’Anthropic, en version ligne de commande). Ce qui m’a le plus servi n’est pas la production de code — les neuf lignes de l’élagage, je les aurais écrites. C’est d’abord d’avoir mesuré : sans les chiffres du tableau, j’aurais continué à croire que mon goulot était l’unicité, alors que le vrai mur était l’énumération.

Et c’est surtout d’avoir vu que mon critère de 2023 ne voulait plus dire la même chose en 5×5. Sans cela, j’aurais publié 400 grilles dont la moitié auraient été infaisables, et je n’aurais compris pourquoi qu’en lisant les courriels de lecteurs bloqués. C’est le genre de piège qu’on ne repère pas en relisant son code : il faut le chercher.

Une dernière remarque, honnête : la première graduation de difficulté qu’on m’a proposée était fausse, et c’est mon essai raté — les fameux trois 5 — qui l’a révélé. La machine mesurait la puissance de son propre solveur, pas la difficulté pour un humain. Il a fallu changer d’unité de mesure : non plus « en combien d’étapes la machine finit », mais « combien de cases un joueur peut-il poser d’entrée ». Ce recalibrage-là, aucune mesure automatique ne l’aurait déclenché. Il fallait quelqu’un pour s’asseoir devant la grille avec un crayon et constater que ça ne passait pas.


Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *