Blog

TDD sur CPC : le tri par sélection, et ce qu'il coûte

24/10/2026Z80CPCTDDtri

Combien de temps faut-il à un CPC pour trier 255 octets ? Sur PC, on répond « O(n²) » et on passe à autre chose, mais sur CPC, on veut des NOPs. On va donc écrire un tri par sélection en Z80, test après test, jusqu’à une formule exacte de sa durée, vérifiée elle aussi par un test.

En chemin, on tombe sur deux surprises : des tests qui n’échouaient que par chance, et un pire cas qui n’est pas le tableau à l’envers.

Le point de départ est la routine find_min et ses tests, écrits dans l’article précédent, qui présente aussi la méthode.

Ce que fait la routine

HL pointe sur le tableau, B contient sa longueur (de 1 à 255), et la routine trie le tableau sur place, du plus petit au plus grand. Comme pour find_min, le tableau tient dans une page de 256 octets.

Le tri par sélection cherche le plus petit octet du tableau et l’échange avec le premier, qui se trouve alors à sa place. Puis il recommence sur le reste, jusqu’au dernier octet. Vous pouvez lancer l’animation ou avancer étape par étape : la flèche montre l’octet lu, la case pleine le minimum du moment, et les cases au contour coloré les octets déjà à leur place.

Sur le tableau #42 #30 #05 #17 #99, le premier passage trouve #05 et l'échange avec #42. Le deuxième trouve #17 et l'échange avec #30. Le troisième trouve #30 et l'échange avec #42. Le quatrième trouve #42, déjà à sa place. Le tableau trié est #05 #17 #30 #42 #99.

La complexité, en NOPs

La complexité d’un algorithme décrit la façon dont sa durée grandit avec la taille des données. Prenons find_min telle que l’article précédent l’a laissée (intro/11-green), et lançons-la sur des tableaux de plus en plus longs. Au pire, chaque octet est un nouveau minimum (octets décroissants). Au mieux, le premier octet est déjà le plus petit (octets croissants). Voici les durées mesurées, en NOPs :

n pire cas meilleur cas
2 18 17
16 172 157
64 700 637
255 2 801 2 547

Le compte se refait à la main, comme pour le budget de l’article précédent, en partant des 5 NOPs de l’entrée. Un tour où le minimum change en coûte 11 : inc l 1, cp (hl) 2, jr c 2 quand il ne saute pas, ld a,(hl) 2, djnz 4. Un tour où il ne change pas en coûte 10, car jr c saute (3 NOPs) par-dessus ld a,(hl). Avec n − 1 tours, un dernier djnz moins cher d’un NOP et le ret final, on obtient 11n − 4 NOPs au pire, et 10n − 3 au mieux.

Quand n double, la durée double : find_min est linéaire, ce que les informaticiens écrivent O(n), « proportionnel à n, à une constante près ». Le O() jette la constante, alors que sur CPC, c’est souvent elle qui compte (ici, 11 NOPs par octet).

Le tri par sélection appelle find_min sur n octets, puis sur n − 1, et ainsi de suite jusqu’à 2. La somme de ces longueurs vaut à peu près n²/2 : quand n double, la durée est multipliée par quatre. C’est du O(n²). Il reste à écrire le tri, puis à trouver la formule exacte.

Le premier test

Le code part de la fin de l’article précédent : find_min et ses six tests. Dès que selection-sort.asm existe (selection/02-green), le source principal inclut les deux routines :

 org #4000
 include "find-min.asm"
 include "selection-sort.asm"

Pour rappel, un test met des valeurs dans les registres et en mémoire, appelle la routine, puis compare le résultat à ce qu’on attend. Dans tous les tests de l’article, HL vaut #8000 et B la longueur du tableau, si bien que les tableaux de test ne montrent que les octets et le résultat attendu. Les tests appellent selection_sort, sauf deux qui appellent find_min, et leur tableau le précise.

Un tableau d’un octet est déjà trié. Le premier test vérifie seulement que la routine ne touche pas à la mémoire :

Octets Attendu
#42 la mémoire inchangée
Le test en TypeScript

La fonction selectionSort écrit les octets en #8000, met HL et B, puis appelle la routine, comme findMin dans l’article précédent.

// Runs selection_sort on `bytes`, written at ARRAY.
function selectionSort(bytes: number[]) {
  return program.run("selection_sort", {
    registers: { hl: ARRAY, b: bytes.length },
    memory: [{ at: ARRAY, bytes }],
  });
}

it("leaves a one-byte array as it is", () => {
  expect(selectionSort([0x42])).toLeaveMemoryUnchanged();
});

Un octet, puis deux

Le premier test échoue pour la bonne raison : la routine n’existe pas encore (selection/01-red) :

Error: The Program has no label "selection_sort"

Voici le moins de code possible pour passer au vert (selection/02-green) :

selection_sort:
 ret

C’est la même triche que dans l’article précédent : la routine ne trie rien. Deux octets dans le mauvais ordre la démasquent (selection/03-red) :

Octets Attendu
#42, #17 #17, #42 en mémoire
Le test en TypeScript

run.memory(ARRAY, 2) lit les deux octets en #8000 après l’appel.

it("swaps two bytes in the wrong order", () => {
  expect(selectionSort([0x42, 0x17]).memory(ARRAY, 2)).toEqual([0x17, 0x42]);
});

La routine laisse les deux octets dans le mauvais ordre :

AssertionError: expected [ 66, 23 ] to deeply equal [ 23, 66 ]

Ce message vient de Vitest, l’outil qui lance les tests, et non de z80-tdd, d’où les octets en décimal : 66 vaut #42, et 23 vaut #17. z80-tdd ne sait pas encore comparer une zone de mémoire à des octets attendus, et il faut donc lire le décimal. Pour deux octets, une comparaison et un échange suffisent, toujours sans boucle (selection/04-green) :

selection_sort:
 dec b
 ret z
 ld a,(hl)
 inc l
 cp (hl)
 ret c          ; the two bytes are already in order
 ld b,(hl)
 ld (hl),a
 dec l
 ld (hl),b
 ret

find_min doit dire où

À partir de trois octets, il faut échanger le minimum avec le premier octet, donc savoir où il est. Or find_min ne rend que sa valeur. Avant d’écrire la boucle, on ajoute donc un test à find_min (selection/05-red) :

Routine Octets Attendu
find_min #42, #30, #05, #17, #99 DE = #8002, l’adresse de #05
Le test en TypeScript
it("returns in DE the address of the smallest byte", () => {
  expect(findMin([0x42, 0x30, 0x05, 0x17, 0x99])).toHaveRegisters({ de: ARRAY + 2 });
});

La routine ne touche pas encore à DE :

Error: Registers differ:
  DE  expected #8002, received #0000

Comme le tableau tient dans une page, D ne change jamais et prend la page de H dès l’entrée. Seul E suit le minimum (selection/06-green) :

find_min:
 ld d,h
 ld e,l
 ld a,(hl)
 dec b
 ret z
.loop:
 inc l
 cp (hl)
 jr c,.next     ; A is below this byte
 ld a,(hl)
 ld e,l
.next:
 djnz .loop
 ret

Le nouveau test passe, mais un ancien tombe, celui du budget de 172 NOPs sur 16 octets décroissants :

AssertionError: expected 189 to be less than or equal to 172

Le budget de l’article précédent fait son travail. Le compte est vite fait : ld d,h et ld e,l ajoutent 2 NOPs à l’entrée, et ld e,l ajoute 1 NOP à chacun des 15 tours où le minimum change, soit 172 + 2 + 15 = 189. On ne voit pas de version moins chère, puisque A et l’adresse doivent changer tous les deux à chaque nouveau minimum. On relève donc le budget à 189 NOPs, en connaissance de cause, et le test dit pourquoi en commentaire :

Routine Octets Attendu
find_min 16 octets décroissants, de #FF à #F0 189 NOPs au plus
Le test en TypeScript
it("finds the smallest of 16 bytes in 189 NOPs at most", () => {
  // Falling bytes make every turn of the loop take its slower path.
  const falling = Array.from({ length: 16 }, (_, i) => 0xff - i);

  // The address in DE costs 2 NOPs at entry and 1 NOP for each new minimum.
  expect(findMin(falling).nops).toBeLessThanOrEqual(189);
});

La boucle

Le test suivant trie cinq octets mélangés (selection/07-red) :

Octets Attendu
#42, #30, #05, #17, #99 #05, #17, #30, #42, #99 en mémoire
Le test en TypeScript
it("sorts five bytes in any order", () => {
  expect(selectionSort([0x42, 0x30, 0x05, 0x17, 0x99]).memory(ARRAY, 5)).toEqual([
    0x05, 0x17, 0x30, 0x42, 0x99,
  ]);
});

La version à deux octets échoue :

AssertionError: expected [ 48, 66, 5, 23, 153 ] to deeply equal [ 5, 23, 48, 66, 153 ]

En hexadécimal, la routine a laissé #30, #42, #05, #17, #99 : elle a échangé les deux premiers octets, puis elle s’est arrêtée. Il est temps d’écrire la vraie boucle (selection/08-green) :

; Sorts in place, smallest first, the array at HL, B bytes long (1 to 255).
; The array stays within one 256-byte page, as find_min requires.
selection_sort:
 dec b          ; one pass for each byte but the last
 ret z
.loop:
 push bc
 push hl
 inc b          ; find_min scans the bytes not yet sorted: B + 1
 call find_min
 pop hl
 pop bc
 ld c,(hl)      ; swap the smallest byte with the first unsorted one
 ld (hl),a
 ld a,c
 ld (de),a
 inc l
 djnz .loop
 ret

Tous les tests sont maintenant verts. Il y a n − 1 passages, car le dernier octet est trié quand tous les autres le sont. find_min déplace L et vide B, d’où les push et les pop autour de l’appel. Quand le minimum est déjà en place, DE vaut HL et l’octet s’échange avec lui-même : cela ne change rien, et un test pour l’éviter coûterait des NOPs à chaque passage.

Des tests qui échouaient par chance

Un test qui passe dès son écriture doit prouver qu’il sait échouer : on casse la routine exprès, et on vérifie qu’il devient rouge. L’article précédent appelle ces tests des guards (gardes). Ici, c’est en cassant la routine qu’un trou apparaît dans les tests déjà écrits.

Oublions le dec b de l’entrée. Chaque passage demande alors à find_min un octet de trop, celui qui suit le tableau, et le tri fait un passage de plus. Les tests de tri échouent aussitôt, et celui d’un octet montre l’échange de trop :

Error: Memory changed:
  #8000  #42 -> #00
  #8001  #00 -> #42

Mais ils échouent seulement parce que chaque test part d’une mémoire neuve, remplie de #00. Ce zéro, plus petit que tout octet, entre dans le tableau. Avec #FF après le tableau, find_min ne le choisirait jamais, et la version cassée passerait tous les tests.

Les tests dépendaient donc d’une condition qu’ils ne disaient pas, et le guard suivant la dit tout haut, en écrivant lui-même #00 juste après le tableau (selection/09-guard) :

Octets Attendu
#42, #30, #05, #17, #99 (B = 5), et #00 en #8005, juste après le tableau #05, #17, #30, #42, #99 en mémoire, et rien de modifié après #8004
Le test en TypeScript

toChangeOnly vérifie que la routine n’écrit qu’entre #8000 et #8004.

it("ignores a smaller byte right after the array", () => {
  // Fresh memory already holds #00 there: this test says so out loud.
  const run = program.run("selection_sort", {
    registers: { hl: ARRAY, b: 5 },
    memory: [{ at: ARRAY, bytes: [0x42, 0x30, 0x05, 0x17, 0x99, 0x00] }],
  });

  expect(run).toChangeOnly({ address: ARRAY, length: 5 });
  expect(run.memory(ARRAY, 5)).toEqual([0x05, 0x17, 0x30, 0x42, 0x99]);
});

Il passe dès son écriture, et avec la version sans dec b, il donne l’adresse fautive, en hexadécimal cette fois :

Error: Memory changed outside #8000-#8004:
  #8005  #00 -> #99

Le vrai pire cas

Il reste la formule promise au début, et pour l’écrire, il faut le tableau qui fait le plus souffrir le tri. On penserait au tableau à l’envers, mais la mesure dit autre chose (durées en NOPs) :

n octets croissants octets décroissants octets tous égaux
2 56 58 58
16 1 820 1 948 2 060
128 86 492 94 684 102 748
255 334 269 366 781 399 039

Les octets tous égaux sont le pire cas. Avec deux octets égaux, cp (hl) ne met pas la retenue, donc find_min prend un nouveau minimum à chaque octet. Dans un tableau à l’envers, au contraire, les échanges remettent de l’ordre au fil des passages, et certains tours prennent le chemin court : 56 tours sur 120 pour 16 octets.

Voici le compte pour n octets égaux. Sur m octets, find_min coûte 12m − 3 NOPs : 7 à l’entrée, 12 par tour, 1 de moins pour le dernier djnz, 3 pour le ret. Le tri ajoute 32 NOPs autour de chaque appel : push bc 4, push hl 4, inc b 1, call 5, pop hl 3, pop bc 3, ld c,(hl) 2, ld (hl),a 2, ld a,c 1, ld (de),a 2, inc l 1, djnz 4.

Partie NOPs
Entrée : dec b, ret z qui ne sort pas 3
Chaque passage, sur m octets : l’appel et ce qui l’entoure 12m − 3 + 32
Fin : le dernier djnz coûte 1 NOP de moins, puis ret −1 + 3

Les passages portent sur m = n, n − 1, … jusqu’à 2 octets, et la somme donne 6n² + 35n − 36 NOPs : du O(n²), avec ses constantes.

Le dernier guard fixe ce plafond pour trois longueurs (selection/10-guard) :

Octets Attendu
n octets égaux à #42, pour n = 2, 16 et 255 6n² + 35n − 36 NOPs au plus : 58, 2 060 et 399 039
Le test en TypeScript

it.each lance le même test pour chaque valeur de n.

// Equal bytes are the slowest input: find_min takes a new minimum at each byte.
it.each([2, 16, 255])("sorts %i equal bytes in 6n² + 35n − 36 NOPs at most", (n) => {
  const equal = Array.from({ length: n }, () => 0x42);

  expect(selectionSort(equal).nops).toBeLessThanOrEqual(6 * n * n + 35 * n - 36);
});

Ce guard attrape une tentation classique : remettre inc hl dans find_min pour lever la contrainte de page. C’est 1 NOP de plus par tour de find_min, et le tri fait n(n − 1)/2 tours dans ses appels. Les trois cas tombent :

AssertionError: expected 59 to be less than or equal to 58
AssertionError: expected 2180 to be less than or equal to 2060
AssertionError: expected 431424 to be less than or equal to 399039

Le budget de find_min tombe lui aussi (expected 204 to be less than or equal to 189) : il voit le surcoût sur 16 octets. Le guard, lui, dit ce que ce surcoût coûte au tri : 32 385 NOPs de plus sur 255 octets.

Pour 255 octets, le tri prend au pire 399 039 NOPs, soit environ 20 trames de 19 968 NOPs. Voilà la réponse à la question du début : 0,4 seconde.

Conclusion

Avec z80-tdd, la complexité se mesure au NOP près, et un test la fixe. Le tri par sélection garde son O(n²), mais ses constantes sont maintenant écrites, et une optimisation future ne pourra plus les dégrader sans faire tomber un guard.

Le tri garde les limites de find_min : de 1 à 255 octets, dans une page de 256 octets. Il fait aussi un échange inutile quand le minimum est déjà en place.

Le prochain article passe au tri par insertion, avec la même recherche de son pire cas.

Pour essayer chez vous

Il vous faut Node.js 24 ou plus, pnpm, et RASM v3.3.1 dans le PATH. Les étapes de cet article sont sur la branche selection du dépôt z80-tdd-sorts, qui part du dernier tag de l’article précédent. Pour rejouer une étape :

git checkout selection/01-red
pnpm install
pnpm test

Sur les tags selection/*, l’option --no-lockfile n’est plus nécessaire : le premier commit de la branche corrige le lockfile que pnpm 10.34.1 ou plus refusait.

Les tests du tri sont dans src/selection-sort.test.ts, à côté de ceux de find_min. Pour installer z80-tdd dans un projet à vous, voir la section Pour essayer chez vous de l’article précédent.

Liens