Blog

TDD sur CPC : le tri par comptage, 256 compteurs et pas une comparaison

24/10/2026Z80CPCTDDtri

Les trois tris des articles précédents font la même chose : ils comparent des octets, deux par deux. Le tri de cet article ne compare rien du tout. C’est un algorithme connu, le tri par comptage : on compte les octets, puis on les réécrit dans l’ordre. Simple, mais combien de NOPs ?

La réponse se construit en Z80, test après test, et c’est le compte des NOPs qui choisira entre deux boucles de copie. Pour finir, les quatre tris passent sur les mêmes entrées, côte à côte.

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 des trois tris précédents, et le tableau tient toujours dans une page de 256 octets.

Un octet ne prend que 256 valeurs. On réserve donc 256 compteurs, un par valeur. On parcourt le tableau une fois, et chaque octet ajoute 1 à son compteur. Ensuite, on parcourt les compteurs dans l’ordre, de #00 à #FF, et on réécrit chaque valeur autant de fois qu’on l’a comptée. Le tableau sort trié, sans une seule comparaison entre deux octets.

Lancez l’animation, ou avancez étape par étape. La première rangée est le tableau, et la flèche montre l’octet compté. La seconde rangée montre les compteurs des valeurs du tableau, et « … » les compteurs vides entre elles. La case pleine montre le compteur lu ou modifié, et les cases au contour coloré les octets déjà réécrits. L’animation montre la version finale de l’article : chaque copie retire 1 au compteur, qui revient à zéro avec la dernière. La section sur le décompte explique pourquoi.

Sur le tableau #42 #17 #05 #42 #17, le comptage donne 1 pour #05, 2 pour #17 et 2 pour #42. Le parcours des compteurs réécrit alors #05 une fois, #17 deux fois et #42 deux fois : le tableau trié est #05 #17 #17 #42 #42.

Le coût change de forme : il n’a plus de terme en n², mais deux termes, n pour le comptage et la réécriture, et 256 pour le parcours des compteurs, quelle que soit la taille du tableau. Sur un petit tableau, ces 256 compteurs coûtent cher. Sur un grand, ils ne comptent presque plus. Les tests mesureront où passe la frontière.

La table des compteurs

Avec 255 octets au plus, un compteur ne dépasse jamais 255 : un octet suffit. Les compteurs vivent dans une table counts de 256 octets, alignée sur une page. H pointe alors sur la table, et L choisit le compteur à lui seul : ld l,a puis inc (hl), et le compteur de la valeur A gagne 1.

Il faut aussi remettre la table à zéro. On pourrait l’effacer au début de chaque appel, mais on fait autrement : la table est à zéro en entrée, et la routine la rend à zéro en sortie. Le defs 256 de l’assembleur la crée à zéro, et chaque appel la laisse comme il l’a trouvée, ce qu’un test vérifiera plus loin.

Le premier test

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

 org #4000
 include "counting-sort.asm"

Dans les tests de cet article, HL vaut #8000 et B la longueur du tableau. Les tableaux de test ne montrent donc que les octets et le résultat attendu.

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

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

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

Un octet, puis deux

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

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

Vitest affiche les octets en décimal : 66 vaut #42, et 23 vaut #17. Avec les tris précédents, le code vert de cette étape comparait deux octets. Ici, on compte dès le deuxième test. Le code vert compte chaque octet, puis parcourt les compteurs et réécrit chaque valeur comptée, une fois. Il remet aussi chaque compteur à zéro au passage (counting/04-green) :

; 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 never carries into D.
; counts must hold zeros on entry, and holds zeros again on exit.
counting_sort:
 ex de,hl       ; DE: the array
 push de
 ld hl,counts
.count:
 ld a,(de)
 ld l,a
 inc (hl)       ; one more copy of this byte
 inc e
 djnz .count
 pop de         ; DE: back to the first byte
.scan:          ; L runs over the counts, up to #FF
 ld a,(hl)
 or a
 jr z,.next
 ld (hl),0      ; the count goes back to zero for the next call
 ld a,l
 ld (de),a
 inc e
.next:
 inc l
 jr nz,.scan
 ret

 align 256
counts:         ; one count for each byte value: L indexes it alone
 defs 256

DE pointe sur le tableau, HL sur la table. Le parcours s’arrête quand inc l repasse à zéro, après le compteur de #FF. Ce code a un défaut que les tests ne voient pas encore, et on y reviendra.

Les doublons

Une valeur peut apparaître plusieurs fois. Le test suivant en met deux paires (counting/05-red) :

Octets Attendu
#42, #17, #42, #17 #17, #17, #42, #42 en mémoire
Le test en TypeScript
it("keeps every copy of a repeated byte", () => {
  expect(countingSort([0x42, 0x17, 0x42, 0x17]).memory(ARRAY, 4)).toEqual([
    0x17, 0x17, 0x42, 0x42,
  ]);
});
AssertionError: expected [ 23, 66, 66, 23 ] to deeply equal [ 23, 23, 66, 66 ]

En hexadécimal, la routine a laissé #17, #42, #42, #17. #17 et #42 sont écrits une fois chacun, et la fin du tableau garde ses anciens octets. Le compteur sert maintenant de longueur : B le reçoit, et une boucle écrit la valeur B fois (counting/06-green) :

.scan:          ; L runs over the counts, up to #FF
 ld a,(hl)
 or a
 jr z,.next
 ld b,a         ; B: the copies of this byte to write
 ld (hl),0      ; the count goes back to zero for the next call
 ld a,l
.copy:
 ld (de),a
 inc e
 djnz .copy
.next:
 inc l
 jr nz,.scan
 ret

B est libre, puisque la boucle de comptage l’a laissé à zéro.

Un parcours qui part de travers

Le test suivant trie cinq octets mélangés (counting/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(countingSort([0x42, 0x30, 0x05, 0x17, 0x99]).memory(ARRAY, 5)).toEqual([
    0x05, 0x17, 0x30, 0x42, 0x99,
  ]);
});
AssertionError: expected [ 153, 48, 5, 23, 153 ] to deeply equal [ 5, 23, 48, 66, 153 ]

En hexadécimal, la routine a laissé #99, #30, #05, #17, #99. Seul le premier octet a changé, et il vaut #99, parce que le parcours ne part pas de #00. Après la boucle de comptage, L garde le dernier octet compté, ici #99. Le parcours commence donc au compteur de #99, écrit #99, puis lit des compteurs vides jusqu’à #FF et s’arrête. Les compteurs de #05 à #42 n’ont jamais été lus.

Le défaut était là depuis 04-green. Les tests d’avant passaient par chance : dans #42, #17 comme dans #42, #17, #42, #17, le dernier octet est aussi le plus petit. Le parcours partait de #17, et ne ratait rien.

Le correctif tient en une instruction. Après djnz, B vaut zéro, et ld l,b met L à #00 pour 1 NOP, là où ld l,0 en coûte 2 (counting/08-green) :

 pop de         ; DE: back to the first byte
 ld l,b         ; L: #00, as DJNZ leaves B at zero
.scan:          ; L runs over the 256 counts

Les deux bouts de la table

Le test suivant passe dès son écriture : c’est un guard. Un guard doit prouver qu’il sait échouer. On casse donc la routine exprès, et on vérifie qu’il devient rouge. Ce guard vise les deux bouts de la table, les compteurs de #00 et de #FF (counting/09-guard) :

Octets Attendu
#FF, #42, #00 #00, #42, #FF en mémoire
Le test en TypeScript
// #00 and #FF sit in the first and last counts: the scan must read all 256.
it("sorts #FF and #00, the two ends of the counts", () => {
  expect(countingSort([0xff, 0x42, 0x00]).memory(ARRAY, 3)).toEqual([0x00, 0x42, 0xff]);
});

La version cassée qu’il vise arrête le parcours un compteur trop tôt. Il suffit d’un ld a,l et d’un inc a avant le jr nz,.scan : la boucle s’arrête dès que L atteint #FF, avant de lire son compteur. Aucun autre test ne contient #FF, et seul ce guard tombe :

AssertionError: expected [ +0, 66, +0 ] to deeply equal [ +0, 66, 255 ]

Vitest écrit le zéro +0. En hexadécimal, le guard attendait #00, #42, #FF et trouve #00, #42, #00 : le #FF n’a jamais été réécrit.

Le tri à bulles avait un guard pour une paire de trop, qui lisait l’octet après le tableau. Ici, la version qui compte un octet de trop, avec un inc b en entrée, ne passe aucun des tests existants. Elle compte le #00 qui suit le tableau, puis réécrit un octet de trop, et les tests existants suffisent à l’attraper.

Une table rendue propre

Le contrat promet une table à zéro en sortie. Le guard suivant le vérifie : seul le tableau doit changer (counting/10-guard) :

Octets Attendu
#42, #17, #42, #17 rien de modifié hors du tableau, de #8000 à #8003
Le test en TypeScript

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

// A count of 2 or more must also go back to zero: the next call counts on top of it.
it("changes only the array, even with repeated bytes", () => {
  expect(countingSort([0x42, 0x17, 0x42, 0x17])).toChangeOnly({ address: ARRAY, length: 4 });
});

Le tableau a des octets répétés parce que la version cassée qu’il vise met dec (hl) à la place de ld (hl),0. Un compteur de 1 revient bien à zéro, mais un compteur de 2 reste à 1. Le tri reste juste, car B garde le bon nombre de copies, et tous les autres tests passent. Le guard, lui, voit les restes :

Error: Memory changed outside #8000-#8003:
  #4117  #00 -> #01
  #4142  #00 -> #01

La table est en #4100, juste après le code du programme. Les compteurs de #17 et #42 y sont restés à 1. Le prochain appel compterait par-dessus, et écrirait un #17 et un #42 de trop.

Décompter dans la table

Tout est vert, et le code peut maigrir. La boucle de copie compte avec B, puis le compteur est remis à zéro. Il est plus simple de décompter directement dans la table : dec (hl) arrive à zéro avec la dernière copie, et la table est propre sans rien de plus. ld b,a et ld (hl),0 disparaissent (counting/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 never carries into D.
; counts must hold zeros on entry, and holds zeros again on exit.
counting_sort:
 ex de,hl       ; DE: the array
 push de
 ld hl,counts
.count:
 ld a,(de)
 ld l,a
 inc (hl)       ; one more copy of this byte
 inc e
 djnz .count
 pop de         ; DE: back to the first byte
 ld l,b         ; L: #00, as DJNZ leaves B at zero
.scan:          ; L runs over the 256 counts
 ld a,(hl)
 or a
 jr z,.next
 ld a,l
.copy:
 ld (de),a
 inc e
 dec (hl)       ; the count reaches zero with the last copy, ready for the next call
 jr nz,.copy
.next:
 inc l
 jr nz,.scan
 ret

 align 256
counts:         ; one count for each byte value: L indexes it alone
 defs 256

Mais une copie coûte maintenant 9 NOPs au lieu de 7 : dec (hl) et jr nz prennent 6 NOPs, là où djnz en prenait 4. Le bilan dépend donc de l’entrée, et il faut compter.

Le compte à la main

Le compte dépend de n, la longueur du tableau, et de d, le nombre de valeurs différentes qu’il contient. Chaque boucle coûte un NOP de moins à son dernier tour, quand son saut ne saute pas.

Partie NOPs
Entrée et sortie : ex de,hl 1, push de 4, ld hl,counts 3, pop de 3, ld l,b 1, ret 3 15
Chaque octet compté : ld a,(de) 2, ld l,a 1, inc (hl) 3, inc e 1, djnz 4 ; le dernier, 1 de moins 11n − 1
Chaque compteur, 10 NOPs. Vide : ld a,(hl) 2, or a 1, jr z 3, inc l 1, jr nz 3. Plein, hors copies : jr z qui ne saute pas 2 au lieu de 3, plus ld a,l 1. Le dernier jr nz, 1 de moins 256 × 10 − 1 = 2 559
Chaque copie : ld (de),a 2, inc e 1, dec (hl) 3, jr nz 3 ; la dernière de chaque compteur, 1 de moins 9n − d

Le parcours des 256 compteurs pèse 2 559 NOPs, quoi qu’il arrive. La somme donne 2 573 + 20n − d NOPs.

Le même compte sur la boucle djnz de 08-green donne 2 573 + 18n + 3d NOPs. La version qui décompte gagne quand d dépasse n/2. Le pire cas compte plus :

Boucle de copie Pire cas NOPs
djnz (08-green) tous les octets différents, d = n 21n + 2 573
dec (hl) (11-refactor) tous les octets égaux, d = 1 20n + 2 572

Le pire cas baisse d’un NOP par octet, et c’est ce qui a décidé.

Les budgets

Les deux derniers tests sont des guards de budget : un nombre de NOPs à ne pas dépasser. D’abord le pire cas, les octets égaux (counting/12-guard) :

Octets Attendu
n octets égaux à #42, pour n = 2, 16 et 255 20n + 2 572 NOPs au plus : 2 612, 2 892 et 7 672
Le test en TypeScript
// Equal bytes are the slowest input: one count, then n turns of the copy loop.
it.each([2, 16, 255])("sorts %i equal bytes in 20n + 2572 NOPs at most", (n) => {
  const equal = Array.from({ length: n }, () => 0x42);

  expect(countingSort(equal).nops).toBeLessThanOrEqual(20 * n + 2572);
});

La version cassée qu’il vise met le ld a,l dans la boucle de copie, après le label. Le tri reste juste, mais chaque copie coûte un NOP de plus, et chaque compteur plein un NOP de moins. Sur des octets tous différents, chaque compteur vaut 1, et la version cassée coûte exactement autant que la bonne. Les octets égaux, eux, font n copies d’un seul compteur :

AssertionError: expected 2613 to be less than or equal to 2612
AssertionError: expected 2907 to be less than or equal to 2892
AssertionError: expected 7926 to be less than or equal to 7672

Puis les octets triés et à l’envers, ensemble (counting/13-guard) :

Octets Attendu
n octets croissants, #00, #01, #02…, puis les mêmes à l’envers, pour n = 2, 16 et 255 19n + 2 573 NOPs au plus : 2 611, 2 877 et 7 418
Le test en TypeScript
// The order changes nothing: sorted and reversed bytes both mean n counts of 1.
it.each([2, 16, 255])("sorts %i sorted or reversed bytes in 19n + 2573 NOPs at most", (n) => {
  const sorted = Array.from({ length: n }, (_, i) => i);
  const reversed = [...sorted].reverse();

  expect(countingSort(sorted).nops).toBeLessThanOrEqual(19 * n + 2573);
  expect(countingSort(reversed).nops).toBeLessThanOrEqual(19 * n + 2573);
});

Pour la première fois de la série, l’ordre ne change rien. Trié ou à l’envers, chaque valeur a un compteur de 1, et la routine fait exactement le même travail. La version cassée que ce guard attrape est la boucle djnz de 08-green, à 21n + 2 573. Elle passe le budget des octets égaux, où elle est moins chère, et tombe ici :

AssertionError: expected 2615 to be less than or equal to 2611
AssertionError: expected 2909 to be less than or equal to 2877
AssertionError: expected 7928 to be less than or equal to 7418

Quatre tris face à face

Voici les formules des quatre tris, sur les trois mêmes entrées :

Entrée sélection insertion bulles comptage
octets triés 5n² + 36n − 36 33n − 26 12n + 6 19n + 2 573
octets égaux 6n² + 35n − 36 33n − 26 12n + 6 20n + 2 572
octets à l’envers ⌊11n²/2⌋ + 36n − 36 6n² + 22n − 21 8,5n² + 11,5n − 19 19n + 2 573

Et pour 255 octets :

Entrée sélection insertion bulles comptage
octets triés 334 269 8 389 3 066 7 418
octets égaux 399 039 8 389 3 066 7 672
octets à l’envers 366 781 395 739 555 626 7 418

Le graphique montre les octets à l’envers. En haut, de 2 à 40 octets, à l’échelle linéaire. En bas, de 2 à 255 octets, à l’échelle logarithmique :

Coût en NOPs des quatre tris sur des octets à l’envers. Le comptage part de 2 611 NOPs et croît à peine. Il passe sous les bulles à 18 octets, sous la sélection et l’insertion à 21 octets. À 255 octets, il coûte 7 418 NOPs, contre 366 781 à 555 626 pour les trois autres.

Sur de petits tableaux, le comptage est le plus lent, et de loin : 2 611 NOPs pour deux octets, quand les trois autres en prennent moins de 60. Mais sa droite monte de 19 NOPs par octet, et les paraboles finissent par la croiser. À l’envers, il passe devant les bulles dès 18 octets, puis devant la sélection et l’insertion dès 21 octets. À 255 octets, son pire cas, 7 672 NOPs, tient en 38 % d’une trame de 19 968 NOPs. Face au pire cas de chacun, c’est 52 fois moins que la sélection et l’insertion, et 72 fois moins que les bulles.

Le comptage ne gagne pas partout, pourtant. Sur un tableau déjà trié ou d’octets égaux, les bulles restent devant à toutes les tailles : 3 066 NOPs pour 255 octets, contre 7 418. L’insertion aussi résiste, jusqu’à 185 octets triés et 199 octets égaux. Le comptage, lui, ne sait pas que le tableau est déjà trié : il paie ses 256 compteurs à chaque appel.

Conclusion

Le tri par comptage ne compare rien, et il y gagne un coût linéaire, le même pour toutes les entrées à un NOP par octet près. Les tests ont trouvé un parcours qui partait de travers, puis ont tenu la table propre d’un appel à l’autre. Le compte des NOPs, lui, a choisi la boucle de copie, pour un NOP par octet gagné sur le pire cas.

Ce tri a pourtant ses limites :

Le prochain article passe au tri par base, le radix sort, sur des clés de 16 bits. Le comptage y revient, sous une forme stable, comme passe interne.

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 counting du dépôt z80-tdd-sorts. Elle part de main, comme la branche bubble. Pour rejouer une étape :

git checkout counting/01-red
pnpm install
pnpm test

Les tests du tri sont dans src/counting-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