Blog

TDD sur CPC : le tri par insertion, quatre versions au NOP près

24/10/2026Z80CPCTDDtri

Le tri par sélection de l’article précédent ne tient pas compte de l’ordre des octets : même sur un tableau déjà trié, il cherche le minimum à chaque passage. Le tri par insertion, lui, profite de l’ordre qu’il trouve. Mais que coûte-t-il quand il n’y en a aucun ?

Pour le savoir, on l’écrit en Z80, test après test, on essaie quatre façons de décaler les octets, mesurées au NOP près, et on finit par mettre les deux tris face à face.

La méthode est celle de l’article sur find_min, à lire d’abord si le TDD vous est nouveau.

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. C’est le contrat de selection_sort, et le tableau tient toujours dans une page de 256 octets.

Le tri par insertion est le tri du joueur de cartes : on prend les cartes une à une, et on glisse chacune à sa place parmi celles déjà en main. Le tableau se coupe en deux : à gauche, une suite triée, à droite, les octets qui restent. Au départ, la suite triée ne contient que le premier octet. À chaque passage, on prend l’octet qui la suit : la clé. Tant que l’octet à gauche de la clé est plus grand qu’elle, il se décale d’une place vers la droite. Puis la clé s’écrit dans la place libérée.

Lancez l’animation, ou avancez étape par étape : les cases au contour coloré forment la suite triée, la case pleine montre la clé, et la flèche l’octet comparé à la clé. Le Z80 garde la clé dans A pendant les décalages, et ne l’écrit qu’à la fin. L’animation la montre déjà à la place où elle s’écrira.

Sur le tableau #42 #30 #05 #17 #99, la clé #30 passe devant #42. La clé #05 passe devant #42 et #30. La clé #17 passe devant #42 et #30, et s'arrête après #05. La clé #99 reste à sa place. Le tableau trié est #05 #17 #30 #42 #99.

Sur un tableau déjà trié, chaque clé s’arrête tout de suite : une comparaison par octet, et c’est tout. Sur un tableau à l’envers, chaque clé traverse toute la suite triée. Le meilleur et le pire cas se devinent déjà, et les tests les mesureront.

Le premier test

Le tri par insertion n’a pas besoin de find_min. Le code repart donc de zéro, et le source principal n’inclut que la nouvelle routine :

 org #4000
 include "insertion-sort.asm"

Dans les tests de cet article, HL vaut #8000 et B la longueur du tableau, sauf dans un test, qui le précise. Les tableaux de test ne montrent donc que les octets et le résultat attendu. Ce sont les mêmes octets que pour selection_sort, pour comparer les deux tris à la fin.

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 insertionSort écrit les octets en #8000, met HL et B, puis appelle la routine, comme selectionSort dans l’article précédent.

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

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

Un octet, puis deux

Les premières étapes suivent celles de la sélection. Le test d’un octet échoue faute de label (insertion/01-red), et un simple ret le fait passer (insertion/02-green). Deux octets dans le mauvais ordre démasquent cette triche (insertion/03-red) :

Octets Attendu
#42, #17 #17, #42 en mémoire
Le test en TypeScript
it("swaps two bytes in the wrong order", () => {
  expect(insertionSort([0x42, 0x17]).memory(ARRAY, 2)).toEqual([0x17, 0x42]);
});

Le simple ret laisse les deux octets tels quels :

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

Vitest affiche les octets en décimal : 66 vaut #42, et 23 vaut #17. Le code vert diffère déjà de celui de la sélection. La clé, c’est le deuxième octet, et on la compare à l’octet avant elle (insertion/04-green) :

insertion_sort:
 dec b
 ret z
 inc l
 ld a,(hl)      ; the key: the byte to insert
 dec l
 cp (hl)
 ret nc         ; the key is not below the byte before it
 ld c,(hl)      ; move that byte one place right, and the key into its place
 ld (hl),a
 inc l
 ld (hl),c
 ret

Une seule clé

Le test suivant place une clé tout devant une suite déjà triée (insertion/05-red) :

Octets Attendu
#17, #30, #42, #05 #05, #17, #30, #42 en mémoire
Le test en TypeScript
it("moves the last byte to the front of a sorted run", () => {
  expect(insertionSort([0x17, 0x30, 0x42, 0x05]).memory(ARRAY, 4)).toEqual([
    0x05, 0x17, 0x30, 0x42,
  ]);
});

La version à deux octets ne voit pas la dernière clé :

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

En hexadécimal, la routine a laissé #17, #30, #42, #05, car elle ne regarde que les deux premiers octets. Pour ce test, une seule clé suffit, la dernière, qui recule pas à pas vers le début du tableau, toujours sans boucle externe (insertion/06-green) :

insertion_sort:
 dec b
 ret z
 ld a,l
 add a,b
 ld l,a         ; HL: the last byte
 ld a,(hl)      ; the key: the byte to insert
.shift:
 dec l
 cp (hl)
 jr nc,.place   ; the key is not below this byte
 ld c,(hl)      ; move this byte one place right
 inc l
 ld (hl),c
 dec l
 jr .shift
.place:
 inc l
 ld (hl),a
 ret

Cette boucle n’a pas de borne : elle s’arrête sur le premier octet qui n’est pas plus grand que la clé. Rien ne l’oblige à s’arrêter au début du tableau, et on y reviendra plus loin.

La boucle externe

Le test suivant trie cinq octets mélangés (insertion/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(insertionSort([0x42, 0x30, 0x05, 0x17, 0x99]).memory(ARRAY, 5)).toEqual([
    0x05, 0x17, 0x30, 0x42, 0x99,
  ]);
});

Avec une seule clé, la routine échoue sur ce tableau :

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

En hexadécimal, la routine a laissé #42, #30, #05, #17, #99, puisque la dernière clé, #99, était déjà à sa place. La boucle externe insère maintenant chaque octet à son tour. E garde la position de la clé, pour que le passage suivant reparte de là (insertion/08-green) :

insertion_sort:
 dec b          ; one pass for each byte but the first
 ret z
.next:
 inc l          ; HL: the next byte to insert
 ld e,l         ; the next pass starts from there
 ld a,(hl)      ; the key: the byte to insert
.shift:
 dec l
 cp (hl)
 jr nc,.place   ; the key is not below this byte
 ld c,(hl)      ; move this byte one place right
 inc l
 ld (hl),c
 dec l
 jr .shift
.place:
 inc l
 ld (hl),a
 ld l,e
 djnz .next
 ret

Tous les tests sont verts, mais la boucle interne n’a toujours pas de borne.

Le mur avant le tableau

Quand la clé est plus petite que tous les octets de la suite triée, elle dépasse le premier octet, et la boucle continue de lire avant le tableau. Pour le prouver, il suffit d’y mettre un octet plus grand, #FF : le code sans borne va le décaler dans le tableau.

Le premier essai plaçait le tableau en #8000 et le #FF en #7FFF. Le test est passé alors qu’il devait échouer, parce que dec l ne touche pas H : depuis #8000, il ne recule pas en #7FFF, mais en #80FF, au bout de la même page. Or la mémoire neuve de z80-tdd vaut #00 partout, et ce zéro, plus petit que toute clé, arrête la boucle. C’est encore un test qui passait par chance, comme dans l’article sur la sélection.

Le test place donc le tableau un octet plus loin, en #8001, juste après le #FF (insertion/09-red). C’est le seul test où HL ne vaut pas #8000 :

Octets Attendu
#FF en #8000, puis #42, #30, #05, #17, #99 en #8001 (HL = #8001, B = 5) #05, #17, #30, #42, #99 en #8001, et rien de modifié hors de #8001-#8005
Le test en TypeScript

toChangeOnly vérifie que la routine n’écrit qu’entre #8001 et #8005.

it("ignores a larger byte right before the array", () => {
  // Fresh memory holds #00 next to ARRAY. The array starts one byte into the
  // page, after the #FF, so a step back by DEC L or DEC HL reads the #FF.
  const run = program.run("insertion_sort", {
    registers: { hl: ARRAY + 1, b: 5 },
    memory: [{ at: ARRAY, bytes: [0xff, 0x42, 0x30, 0x05, 0x17, 0x99] }],
  });

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

Cette fois, il échoue pour la bonne raison. Dès le premier passage, la clé #30 pousse le #FF dans le tableau, et prend sa place, hors du tableau. Au passage suivant, la clé #05 l’en chasse :

Error: Memory changed outside #8001-#8005:
  #8000  #FF -> #05

La borne la plus simple, c’est un compteur. D compte les octets de la suite triée, et E en reçoit une copie à chaque passage : la clé ne peut pas dépasser plus de E octets. Comme E est pris, la position de la clé passe sur la pile (insertion/10-green) :

insertion_sort:
 dec b          ; one pass for each byte but the first
 ret z
 ld d,0
.next:
 inc d          ; D: the bytes of the sorted run
 inc l          ; HL: the next byte to insert
 push hl        ; the next pass starts from there
 ld e,d         ; E: the bytes the key can still move past
 ld a,(hl)      ; the key: the byte to insert
.shift:
 dec l
 cp (hl)
 jr nc,.place   ; the key is not below this byte
 ld c,(hl)      ; move this byte one place right
 inc l
 ld (hl),c
 dec l
 dec e
 jr nz,.shift
 dec l          ; the key goes first: .place steps back onto the first byte
.place:
 inc l
 ld (hl),a
 pop hl
 djnz .next
 ret

Le dec l après la boucle est une petite astuce : .place avance d’un octet avant d’écrire, donc on recule d’un octet de plus quand la clé va en tête.

Quatre façons de décaler

Tout est vert, et la routine est correcte, mais rien ne dit encore qu’elle est rapide. Avant de fixer des budgets, on essaie donc trois autres boucles internes :

z80-tdd a mesuré les quatre versions sur des octets triés et sur des octets à l’envers. Pour chacune, le compte des NOPs donne une formule exacte :

Version octets triés octets à l’envers
compteur E (10-green) 25n − 18 7,5n² + 11,5n − 12
sentinelle (Algorithms in C) 37n − 26 7n² + 13n − 9
échanges (Algorithms, 4e éd.) 22n − 15 8,5n² + 6,5n − 8
LDD 33n − 26 6n² + 22n − 21

Les formules de la sentinelle valent à partir de 3 octets. Aucune version ne gagne partout :

LDD gagne le pire cas, et sur un tableau trié elle ne perd que 8 NOPs par passage sur le compteur E. C’est elle que garde la routine. Le nettoyage (refactor) remplace la boucle interne sans toucher un seul test (insertion/11-refactor) :

; Sorts in place, smallest first, the array at HL, B bytes long (1 to 255).
; The array stays within one 256-byte page: INC E keeps D on the page of H.
insertion_sort:
 dec b          ; one pass for each byte but the first
 ret z
 ld c,0
.next:
 inc c          ; C: the bytes of the sorted run
 push bc
 ld d,h         ; DE: the next byte to insert, then the hole it leaves
 ld e,l
 inc e
 push de        ; the next pass starts from there
 ld a,(de)      ; the key: the byte to insert
 ld b,0         ; BC: the bytes the key can still move past
.shift:
 cp (hl)        ; HL: the byte before the hole
 jr nc,.place   ; the key is not below this byte
 ldd            ; move this byte into the hole; P/V: BC is not 0
 jp pe,.shift
.place:
 ld (de),a
 pop hl         ; HL: the last byte of the sorted run
 pop bc
 djnz .next
 ret

jr ne connaît pas la condition pe, d’où le jp. Et l’astuce du dec l disparaît : quelle que soit la sortie de la boucle, DE pointe sur la place libérée.

Les guards

Place aux tests qui passent dès leur écriture, les guards. Chacun doit prouver qu’il sait échouer : on casse la routine exprès, et on vérifie qu’il devient rouge.

Le premier reprend celui de la sélection : un octet plus petit juste après le tableau (insertion/12-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
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("insertion_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]);
});

Sans le dec b de l’entrée, le tri fait un passage de trop, et insère l’octet qui suit le tableau. Les anciens tests tombent eux aussi, mais seulement grâce au #00 de la mémoire neuve. Avec #FF après le tableau, la version cassée les passerait tous. Le guard le dit tout haut :

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

Le meilleur cas

Les trois guards suivants fixent les budgets, pour 2, 16 et 255 octets. D’abord le meilleur cas, les octets triés (insertion/13-guard) :

Octets Attendu
n octets croissants, #00, #01, #02…, pour n = 2, 16 et 255 33n − 26 NOPs au plus : 40, 502 et 8 389
Le test en TypeScript
// Sorted bytes are the fastest input: each key stops at the byte before it.
it.each([2, 16, 255])("sorts %i sorted bytes in 33n − 26 NOPs at most", (n) => {
  const sorted = Array.from({ length: n }, (_, i) => i);

  expect(insertionSort(sorted).nops).toBeLessThanOrEqual(33 * n - 26);
});

Le compte se refait à la main. Un passage où la clé ne bouge pas coûte 33 NOPs : inc c 1, push bc 4, ld d,h 1, ld e,l 1, inc e 1, push de 4, ld a,(de) 2, ld b,0 2, cp (hl) 2, jr nc 3, ld (de),a 2, pop hl 3, pop bc 3, djnz 4.

Partie NOPs
Entrée : dec b 1, ret z qui ne sort pas 2, ld c,0 2 5
Chacun des n − 1 passages 33
Fin : le dernier djnz coûte 1 NOP de moins, puis ret −1 + 3

La somme donne 33n − 26 NOPs. Ce guard attrape la sentinelle, qui paie sa première passe :

AssertionError: expected 47 to be less than or equal to 40
AssertionError: expected 566 to be less than or equal to 502
AssertionError: expected 9409 to be less than or equal to 8389

Les octets égaux

Ensuite les octets égaux, le pire cas de la sélection (insertion/14-guard). Ici, c’est aussi le meilleur cas : cp (hl) met la retenue seulement si la clé est strictement plus petite, donc une clé ne dépasse jamais un octet égal. Le guard reprend le budget des octets triés :

Octets Attendu
n octets égaux à #42, pour n = 2, 16 et 255 33n − 26 NOPs au plus : 40, 502 et 8 389
Le test en TypeScript
// A key never moves past a byte equal to it.
it.each([2, 16, 255])("sorts %i equal bytes in 33n − 26 NOPs at most", (n) => {
  const equal = Array.from({ length: n }, () => 0x42);

  expect(insertionSort(equal).nops).toBeLessThanOrEqual(33 * n - 26);
});

La version cassée qu’il vise compare avec ≤ au lieu de < : un jr z avant le jr nc fait passer la clé devant les octets égaux. Les octets égaux deviennent alors presque aussi lents que les octets à l’envers :

AssertionError: expected 48 to be less than or equal to 40
AssertionError: expected 1987 to be less than or equal to 502
AssertionError: expected 428124 to be less than or equal to 8389

Ce guard n’est pas le seul à l’attraper. Le jr z coûte 2 NOPs à chaque comparaison, même quand il ne saute pas, et le guard des octets triés tombe aussi : 42 NOPs au lieu de 40 pour deux octets. Mais lui la voit de justesse, alors que le guard des octets égaux montre l’ampleur des dégâts.

Le pire cas

Il reste le pire cas, les octets à l’envers (insertion/15-guard) :

Octets Attendu
n octets décroissants, de n − 1 à #00, pour n = 2, 16 et 255 6n² + 22n − 21 NOPs au plus : 47, 1 867 et 395 739
Le test en TypeScript
// Reversed bytes are the slowest input: each key moves past the whole sorted run.
it.each([2, 16, 255])("sorts %i reversed bytes in 6n² + 22n − 21 NOPs at most", (n) => {
  const reversed = Array.from({ length: n }, (_, i) => n - 1 - i);

  expect(insertionSort(reversed).nops).toBeLessThanOrEqual(6 * n * n + 22 * n - 21);
});

Au k-ième passage, la clé dépasse les k octets de la suite triée. Hors de la boucle interne, le passage coûte 28 NOPs : les 33 du meilleur cas, moins le cp (hl) et le jr nc qui sortaient de la boucle. Chaque octet décalé ajoute 12 NOPs.

Partie NOPs
Entrée 5
Passage k, pour k = 1 à n − 1 28 + 12k
Fin : le dernier djnz coûte 1 NOP de moins, puis ret −1 + 3

La somme donne 6n² + 22n − 21 NOPs. Ce guard attrape les échanges :

AssertionError: expected 2272 to be less than or equal to 1867
AssertionError: expected 554362 to be less than or equal to 395739

Il attrape aussi le compteur E :

AssertionError: expected 2092 to be less than or equal to 1867
AssertionError: expected 490608 to be less than or equal to 395739

Pour deux octets, les deux versions passent sous le plafond : l’écart se creuse avec n. La sentinelle, déjà prise par le guard des octets triés, tombe ici aussi : 1 991 NOPs au lieu de 1 867 pour 16 octets.

Sélection contre insertion

Les deux tris ont maintenant leurs formules, sur les trois mêmes entrées :

Entrée sélection insertion
octets triés 5n² + 36n − 36 33n − 26
octets égaux 6n² + 35n − 36 33n − 26
octets à l’envers ⌊11n²/2⌋ + 36n − 36 6n² + 22n − 21

Et pour 255 octets :

Entrée sélection insertion
octets triés 334 269 8 389
octets égaux 399 039 8 389
octets à l’envers 366 781 395 739

Sur un tableau trié ou d’octets égaux, l’insertion est linéaire, et 40 à 48 fois plus rapide que la sélection. Sur un tableau à l’envers, c’est l’inverse, à partir d’une certaine taille. Jusqu’à 26 octets, l’insertion reste devant (4 607 NOPs contre 4 618), mais dès 27 octets, la sélection gagne (4 945 contre 4 947). L’insertion déplace en effet chaque octet d’un pas à la fois, à 12 NOPs le pas, alors que la sélection cherche longtemps, mais n’écrit qu’un échange par passage.

Il reste à comparer les pires cas : 399 039 NOPs pour la sélection, sur des octets égaux, et 395 739 pour l’insertion, sur des octets à l’envers. L’écart fait moins d’un pour cent, et on peut parler de match nul, avec environ 20 trames de 19 968 NOPs de chaque côté pour trier 255 octets.

Conclusion

Les mesures de z80-tdd ont choisi la version LDD, et trois guards gardent ce choix : une modification qui ralentit l’un des trois cas mesurés fait tomber un test. Sur un tableau trié, l’insertion est 40 fois plus rapide que la sélection, et dans le pire cas, les deux tris font jeu égal.

La routine garde des limites :

Le prochain article passe au tri à bulles.

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 insertion du dépôt z80-tdd-sorts. Elle part de main, et non de selection, puisque le tri par insertion n’a pas besoin de find_min. Pour rejouer une étape :

git checkout insertion/01-red
pnpm install
pnpm test

Les tests du tri sont dans src/insertion-sort.test.ts. Pour installer z80-tdd dans un projet à vous, voir la section Pour essayer chez vous de l’article sur find_min.

Liens