Implรฉmentation de l'algorithme de Kruskal randomisรฉ pour gรฉnรฉrer des labyrinthes parfaits, portรฉe en Go, Zig, Rust et TypeScript (Bun).
- Qu'est-ce qu'un labyrinthe parfait ?
- Reprรฉsentation en mรฉmoire
- Union-Find โ la structure clรฉ
- Algorithme de Kruskal randomisรฉ
- Algorithme de Prim randomisรฉ
- Optimisations mรฉmoire
- Implรฉmentation Go
- Implรฉmentation Zig
- Implรฉmentation Rust
- Implรฉmentation TypeScript (Bun)
- Benchmarks
- Lancer le projet
Un labyrinthe parfait respecte deux rรจgles :
- Connexe : il existe un chemin entre n'importe quelle paire de cellules.
- Sans cycle : ce chemin est unique โ pas de boucle possible.
Autrement dit, un labyrinthe parfait est un arbre couvrant du graphe de la grille.
โโโโโฌโโโโฌโโโโฌโโโโฌโโโโ
โ โ โ
โ โโโโ โโ โโ โ
โ โ โ โ
โ โโโโโ โโโโโ โ
โ โ โ
โโโโโดโโโโดโโโโดโโโโดโโโโ
โ Parfait : une seule solution entre chaque paire de points
Une grille m ร n a :
(m+1) ร nmurs horizontaux (murH) โ les plafonds/planchersm ร (n+1)murs verticaux (murV) โ les cรดtรฉs
murH[0][0] murH[0][1] murH[0][2]
โ โ โ
โโโโโโโฌโโโโโโฌโโโโโโ
โ โ โ โ โ murV[0][0..3]
โโโโโโโผโโโโโโผโโโโโโค
โ โ โ โ โ murV[1][0..3]
โโโโโโโผโโโโโโผโโโโโโค
โ โ โ โ โ murV[2][0..3]
โโโโโโโดโโโโโโดโโโโโโ
murH[3][0] ...
Au lieu de stocker true/false par boolรฉen (1 octet), on pack 8 murs par octet :
index : 0 1 2 3 4 5 6 7 8 9 ...
โโโโฌโโโฌโโโฌโโโฌโโโฌโโโฌโโโฌโโโ โโโโฌโโ
octet 0 โ0 โ1 โ0 โ1 โ1 โ0 โ0 โ1 โ โ0 โ1 ...
โโโโดโโโดโโโดโโโดโโโดโโโดโโโดโโโ โโโโดโโ
โ mur ouvert
ouvrirMur(arr, idx):
arr[idx >> 3] |= 1 << (idx & 7)
โโโ octet โโโ bit dans l'octet
Gain : 8ร moins de RAM โ meilleure utilisation du cache CPU.
L'Union-Find (ou Disjoint Set Union) permet de savoir si deux cellules sont dรฉjร connectรฉes en temps quasi-constant.
Au dรฉpart, chaque cellule est son propre groupe :
Cellules : 0 1 2 3 4
parent : [-1] [-1] [-1] [-1] [-1]
โ racine (valeur nรฉgative = taille du groupe)
Quand on fusionne les groupes de 0 et 1 :
Avant : Aprรจs :
0 1 0
[-1] [-1] [-2]
|
1 (parent[1] = 0)
[-1โ0]
Le groupe le plus petit est attachรฉ sous le plus grand โ l'arbre reste plat.
Lors d'un find(x), on fait sauter chaque nลud vers son grand-parent :
Avant find(4) : Aprรจs find(4) :
0 0
| /|\
1 1 3 4
|
2
|
3
|
4
Rรฉsultat : les appels suivants sont quasi O(1).
parent[x] < 0 โ x est une racine, |parent[x]| = taille du groupe
parent[x] >= 0 โ x pointe vers son parent
Cela รฉvite un tableau rang[] sรฉparรฉ โ moitiรฉ moins de mรฉmoire.
// Go
func find(parent []int32, x int32) int32 {
for parent[x] >= 0 {
if parent[parent[x]] >= 0 {
parent[x] = parent[parent[x]] // saute vers le grand-parent
}
x = parent[x]
}
return x
}C'est l'adaptation de l'algorithme de Kruskal (arbre couvrant minimal) oรน les poids des arรชtes sont alรฉatoires.
1. Lister tous les murs intรฉrieurs
2. Mรฉlanger alรฉatoirement (Fisher-Yates)
3. Pour chaque mur :
โโ Les deux cellules sont dans le mรชme groupe ? โ ignorer (รฉvite les cycles)
โโ Sinon โ ouvrir le mur + fusionner les groupes
4. Arrรชter quand mรnโ1 murs sont ouverts
Dรฉpart : 9 cellules, toutes isolรฉes
[0][1][2] Murs mรฉlangรฉs (exemple) :
[3][4][5] โ mur(1,2) โ mur(3,4) โ mur(0,1) โ mur(4,5) โ ...
[6][7][8]
รtape 1 : ouvrir mur entre 1 et 2
โโโโฌโโโฌโโโ groupes : {0} {1,2} {3} {4} {5} {6} {7} {8}
โ โ โ
โโโโผโโโผโโโค
โ โ โ โ
โโโโผโโโผโโโค
โ โ โ โ
โโโโดโโโดโโโ
รtape 2 : ouvrir mur entre 3 et 4
โโโโฌโโโฌโโโ groupes : {0} {1,2} {3,4} {5} {6} {7} {8}
โ โ โ
โโโโผโโโผโโโค
โ โ โ
โโโโผโโโผโโโค
โ โ โ โ
โโโโดโโโดโโโ
... aprรจs mรnโ1 = 8 ouvertures :
โโโโฌโโโฌโโโ Un seul groupe {0..8}
โ โ โ labyrinthe parfait !
โโโโ โโโโค
โ โ โ โ
โโโโดโโโค โ
โ โ
โโโโดโโโดโโโ
Au lieu d'un struct {i1, j1, i2, j2} (32 octets), on encode en 1 entier 32 bits :
code = (i * n + j) * 2 + direction
direction 0 โ mur ร droite : sรฉpare (i,j) et (i, j+1)
direction 1 โ mur en bas : sรฉpare (i,j) et (i+1, j)
Dรฉcodage :
dir = code & 1
cell = code >> 1
i1 = cell / n
j1 = cell % n
i2 = (dir==1) ? i1+1 : i1
j2 = (dir==0) ? j1+1 : j1
Gain : 8ร moins de RAM pour la liste des murs.
Alternative ร Kruskal, orientรฉe frontiรจre plutรดt que liste globale.
| Kruskal | Prim | |
|---|---|---|
| Liste initiale | Tous les murs (O(mรn)) | Vide |
| Mรฉmoire | O(mรn) | O(pรฉrimรจtre rรฉgion visitรฉe) |
| Croissance | Globale | Locale depuis une cellule |
1. Marquer la cellule (0,0) comme visitรฉe
2. Ajouter ses 4 murs dans la frontiรจre
3. Tant que la frontiรจre n'est pas vide :
โโ Tirer un mur au hasard (swap avec le dernier โ O(1))
โโ Les deux cellules visitรฉes ? โ ignorer
โโ Sinon โ ouvrir le mur, marquer la nouvelle cellule,
ajouter ses murs ร la frontiรจre
รtape 1 : รtape 2 : รtape 3 :
โโโโฌโโโฌโโโ โโโโฌโโโฌโโโ โโโโฌโโโฌโโโ
โโ โ โ โ โโ โ โ โโ โ โ
โโโโผโโโผโโโค โ โโโโผโโโผโโโค โ โโโโฌโโโผโโโค
โ โ โ โ โ โ โ โ โโ โ โ โ
โโโโผโโโผโโโค โโโโผโโโผโโโค โโโโผโโโผโโโค
โ โ โ โ โ โ โ โ โ โ โ โ
โโโโดโโโดโโโ โโโโดโโโดโโโ โโโโดโโโดโโโ
โ = visitรฉ frontiรจre = {โ, โ} frontiรจre grandit
Go (optimisรฉ) Zig Rust TypeScript
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
murs (walls) []int32 []u32 Vec<i32> Int32Array
4B/mur 4B/mur 4B/mur 4B/mur
parent (UF) []int32 []i32 Vec<i32> โ
4B/cellule 4B/cellule 4B/cellule
murH/murV []uint8 (bits) []u8 (bits) Vec<u8> Uint8Array
1 bit/mur 1 bit/mur 1 bit/mur 1 bit/mur
Cellules : 100 000 000
Murs int. : ~200 000 000
parent[] : 100 000 000 ร 4B = 381 MB
murs[] : 200 000 000 ร 4B = 762 MB (temporaire)
murH+murV : ~200 000 000 bits = 24 MB
Total approx: ~1.2 GB RAM
Compression de bits :
func ouvrirMur(arr []uint8, idx int) {
arr[idx>>3] |= 1 << uint(idx&7)
}
// โ octet โ bit dans l'octetUnion-Find avec valeurs nรฉgatives (pas de tableau rang sรฉparรฉ) :
func find(parent []int32, x int32) int32 {
for parent[x] >= 0 { // >= 0 โ pas une racine
if parent[parent[x]] >= 0 {
parent[x] = parent[parent[x]] // path halving
}
x = parent[x]
}
return x
}
func union(parent []int32, x, y int32) bool {
rx, ry := find(parent, x), find(parent, y)
if rx == ry { return false }
if parent[rx] > parent[ry] { rx, ry = ry, rx } // plus nรฉgatif = plus grand
parent[rx] += parent[ry] // absorber la taille
parent[ry] = rx // relier
return true
}Encodage compact :
murs = append(murs, (i*n+j)*2) // mur ร droite
murs = append(murs, (i*n+j)*2+1) // mur en basShuffle Fisher-Yates via math/rand/v2 :
rand.Shuffle(len(murs), func(a, b int) {
murs[a], murs[b] = murs[b], murs[a]
})Pas de GC, allocateur explicite :
const parent = try allocator.alloc(i32, g.m * g.n);
defer allocator.free(parent);ArrayList en mode unmanaged (0.16+) โ l'allocateur est passรฉ ร chaque opรฉration :
var murs = try std.ArrayList(u32).initCapacity(allocator, 2 * g.m * g.n);
defer murs.deinit(allocator);
try murs.append(allocator, code);Shuffle :
rng.shuffleWithIndex(u32, murs.items, usize);Temps via std.c.clock_gettime (std.time simplifiรฉ en 0.16) :
fn nowMs() i64 {
var ts: std.c.timespec = undefined;
_ = std.c.clock_gettime(std.c.CLOCK.MONOTONIC, &ts);
return @as(i64, ts.sec) * 1000 + @divTrunc(ts.nsec, 1_000_000);
}Compilation optimisรฉe :
zig build-exe -O ReleaseFast main.zig
Pas de dรฉpendance externe โ RNG xorshift intรฉgrรฉ :
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
}Ownership sans GC โ Rust garantit l'absence de fuite mรฉmoire ร la compilation :
let mut parent = vec![-1i32; taille]; // libรฉrรฉ automatiquement en fin de scope
let mut murs: Vec<i32> = Vec::with_capacity(2 * taille);Compilation optimisรฉe :
rustc -O -o main_rs main.rs
L'option -O active les optimisations LLVM (รฉquivalent ร -O2).
Typed Arrays = pas de GC sur les donnรฉes critiques :
const parent = new Int32Array(taille).fill(-1); // mรฉmoire contiguรซ, pas de GC
const murs = new Int32Array(2 * taille); // idem
const murH = new Uint8Array(...); // bits compressรฉsLes TypedArray sont allouรฉs en dehors du tas JS โ le GC ne les scanne pas โ performance proche du natif.
Shuffle Fisher-Yates inline :
function shuffle(arr: Int32Array): void {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const t = arr[i]; arr[i] = arr[j]; arr[j] = t;
}
}Timing avec performance.now() :
const debut = performance.now();
// ...
const duree = performance.now() - debut; // en millisecondes (flottant)Exรฉcution :
bun run main.ts
Bun compile TypeScript ร la volรฉe sans tsc โ zรฉro configuration.
Grille 10 000 ร 10 000 (100 millions de cellules) sur Apple Silicon M-series.
โโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Langage โ Temps โ Notes โ
โโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ ๐ฅ Rust โ 9.63s โ LLVM -O2, RNG xorshift โ
โ ๐ฅ Zig โ 12.04s โ ReleaseFast, backend LLVM โ
โ ๐ฅ Bun (TypeScript) โ 14.71s โ JSC JIT + TypedArray โ
โ 4 Go โ 16.75s โ math/rand/v2, GC minimal โ
โโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- Mรชme backend LLVM que Zig, mais auto-vectorisation plus agressive sur ce workload.
- Ownership garantit qu'il n'y a pas d'aliasing โ le compilateur peut optimiser plus librement.
- Les
TypedArrayJS sont hors du tas GC โ aucune pause de collecte. - Le JIT de JavaScriptCore (moteur de Bun) reconnaรฎt les patterns sur
Int32Arrayet gรฉnรจre du code natif efficace.
| Outil | Installation |
|---|---|
| Go | https://go.dev/dl |
| Zig | brew install zig |
| Rust | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh |
| Bun | brew install bun |
make all # lance les 4 langages
make go # Go seulement
make zig # Zig seulement
make rust # Rust seulement
make ts # Bun/TypeScript seulement
make clean # supprime les binaires compilรฉsModifier la constante en haut de chaque fichier :
| Fichier | Constante |
|---|---|
main.go |
lignes = 10000 / colonnes = 10000 |
main.zig |
lignes: usize = 10000 / colonnes: usize = 10000 |
main.rs |
LIGNES: usize = 10_000 / COLONNES: usize = 10_000 |
main.ts |
LIGNES = 10_000 / COLONNES = 10_000 |