Le projet Fillit
Fillit est un projet algorithmique de l'École 42 qui vous confronte à un problème classique en informatique : trouver la solution optimale parmi un ensemble exponentiel de possibilités, dans un temps raisonnable.
Le principe est simple à énoncer : on vous donne un fichier contenant une liste de
pièces de Tetris (des Tetriminos), chacune composée de 4 blocs. Votre programme
doit les assembler toutes ensemble dans le plus petit carré possible.
Une pièce de Tetris occupe 4 cases, donc avec n pièces il faudra au minimum
ceil(sqrt(4n)) cases de côté — mais ce n'est qu'un minorant, car la géométrie
des pièces peut imposer un carré plus grand.
Ce qui rend le problème difficile, c'est que chaque pièce ajoutée multiplie exponentiellement le nombre de configurations possibles. Avec 26 pièces (le maximum autorisé), le nombre de façons de les arranger est astronomique. Un algorithme naïf qui essaierait toutes les combinaisons ne terminerait jamais. Il faut donc utiliser une technique intelligente : le backtracking (retour sur trace), qui explore l'arbre des possibilités en abandonnant dès qu'une branche ne peut pas mener à une solution.
Le projet doit être écrit en C avec la Norme 42, sans fuites mémoire,
et n'utiliser que les fonctions exit, open, close, write, read, malloc, free.
Le binaire s'appelle fillit et prend un seul argument : le chemin du fichier
contenant les Tetriminos. La moulinette impose un timeout arbitraire — votre programme
doit répondre rapidement, même avec 26 pièces.
Les 19 formes de Tetriminos valides
Un Tetrimino est une pièce de 4 blocs connectés (chaque bloc touche au moins un autre bloc sur l'un de ses 4 côtés). Il existe exactement 5 formes libres (en considérant les rotations comme équivalentes), mais comme le sujet interdit la rotation, on compte 19 formes distinctes (5 formes × rotations uniques). Votre programme ne fait pas de rotation : il place chaque pièce telle qu'elle apparaît dans le fichier.
Le format des Tetriminos
Chaque Tetrimino est décrit dans un bloc de 4 lignes de 4 caractères, séparé du
suivant par une ligne vide. Les caractères autorisés sont # (bloc) et
. (vide).
Structure d'un fichier valide
Un fichier fillit contient entre 1 et 26 Tetriminos. Chaque Tetrimino occupe exactement
4 lignes de 4 caractères, suivies d'un caractère \n après chaque ligne.
Entre deux Tetriminos, il y a exactement un \n supplémentaire (une ligne
vide). Le dernier Tetrimino n'est pas obligé d'être suivi d'une ligne
vide, mais il peut l'être.
# 4 Tetriminos séparés par des lignes vides
...#
...#
...#
...#
# ← ligne vide de séparation
....
....
....
####
# ← ligne vide
.###
...#
....
....
# ← ligne vide
....
..##
.##.
....
Règles de validation
Votre programme doit rejeter tout fichier invalide en affichant error.
Un Tetrimino est valide si et seulement si toutes les conditions
suivantes sont remplies :
- Exactement 4 lignes de 4 caractères, chacune suivie d'un
\n. - Uniquement les caractères
#et.— aucun autre caractère n'est toléré (pas d'espaces, pas de chiffres, pas de lettres). - Exactement 4 blocs (
#) par Tetrimino — ni plus, ni moins. - Les 4 blocs sont connectés — chaque bloc doit toucher au moins un autre bloc sur l'un de ses 4 côtés (haut, bas, gauche, droite). Les connexions diagonales ne comptent pas. En pratique, cela signifie que la somme des connexions entre blocs doit être d'au moins 6 (un arbre connecté de 4 nœuds a 3 arêtes, mais comme chaque arête est comptée deux fois, on obtient 6).
Une pièce comme #..# (deux blocs séparés par des points) est invalide
même si elle contient 4 # au total, car les blocs ne sont pas connectés.
La validation doit vérifier les connexions, pas seulement le compte de blocs.
Pas de rotation
Le sujet est très clair : aucune rotation n'est permise. Une pièce qui
apparaît comme ##.. / #... / #... / .... dans le fichier doit être placée
exactement dans cette orientation. Cela signifie que deux pièces géométriquement
identiques mais orientées différemment sont considérées comme distinctes.
Votre algorithme n'a pas à essayer les 4 rotations de chaque pièce — il place la pièce
telle quelle.
Parsing & validation
La première étape consiste à lire le fichier, extraire chaque Tetrimino dans une structure de données, et valider qu'il respecte toutes les règles du sujet.
Structure de données
Chaque Tetrimino est stocké dans une structure t_tetri qui contient sa
forme (un tableau 4×4 de caractères), sa lettre d'identification (A, B, C...), et ses
dimensions utiles après normalisation (largeur et hauteur du rectangle englobant
minimum).
typedef struct s_tetri
{
char shape[4][5]; /* 4 lignes de 4 chars + '\0' */
char letter; /* 'A', 'B', 'C', ... */
int width; /* largeur du bounding box */
int height; /* hauteur du bounding box */
} t_tetri;
typedef struct s_map
{
char **grid; /* grille carrée dynamique */
int size; /* taille de la grille (côté) */
} t_map;
Lecture du fichier
La lecture se fait en un seul appel read() avec un buffer assez grand pour
contenir les 26 Tetriminos maximum. Chaque Tetrimino occupe 20 octets (4 lignes × 5
caractères avec le \n), plus 1 octet pour la ligne vide de séparation,
soit 21 octets × 26 = 546 octets maximum.
int read_file(char *filename, char *buffer)
{
int fd;
int bytes_read;
fd = open(filename, O_RDONLY);
if (fd == -1)
return (-1);
bytes_read = read(fd, buffer, BUF_SIZE * MAX_TETRIMINOS);
if (bytes_read <= 0)
{
close(fd);
return (-1);
}
buffer[bytes_read] = '\0';
close(fd);
return (bytes_read);
}
Extraction et validation
L'extraction parcourt le buffer caractère par caractère. Pour chaque Tetrimino, on
copie les 4 lignes de 4 caractères dans shape[], on vérifie le format
(présence des \n aux bons endroits), puis on valide la pièce (4 blocs
connectés). Si tout est correct, on passe au Tetrimino suivant en sautant la ligne
vide de séparation.
/* Compte le nombre de blocs '#' dans le Tetrimino */
static int count_blocks(t_tetri *tetri)
{
int i;
int j;
int count;
count = 0;
i = 0;
while (i < 4)
{
j = 0;
while (j < 4)
{
if (tetri->shape[i][j] == '#')
count++;
j++;
}
i++;
}
return (count);
}
/* Vérifie que les blocs sont connectés (au moins 6 connexions) */
static int check_connections(t_tetri *tetri)
{
int i;
int j;
int connections;
connections = 0;
i = 0;
while (i < 4)
{
j = 0;
while (j < 4)
{
if (tetri->shape[i][j] == '#')
{
if (i > 0 && tetri->shape[i - 1][j] == '#')
connections++;
if (i < 3 && tetri->shape[i + 1][j] == '#')
connections++;
if (j > 0 && tetri->shape[i][j - 1] == '#')
connections++;
if (j < 3 && tetri->shape[i][j + 1] == '#')
connections++;
}
j++;
}
i++;
}
/* 4 blocs connectés = 3 arêtes × 2 (bidirectionnel) = 6 minimum */
return (connections >= 6);
}
Avec 4 blocs connectés, on a au minimum 3 liaisons (un arbre). Chaque liaison est comptée deux fois (une fois dans chaque sens), donc le minimum est 6. Si la somme des connexions est inférieure à 6, la pièce est forcément déconnectée. Les pièces comme le carré O ont 8 connexions (4 arêtes × 2), et la pièce I a 6 connexions (3 arêtes × 2).
Normalisation des pièces
Avant de placer les pièces sur la grille, il faut les normaliser : décaler les blocs vers le coin haut-gauche du carré 4×4, et calculer les dimensions utiles (bounding box). Cela simplifie le placement et l'affichage.
Pourquoi normaliser ?
Le sujet dit que "chaque Tetrimino est défini par ses limites minimales (leurs '#')".
Les 12 caractères vides restants sont ignorés lors de l'assemblage. Autrement dit,
une pièce décrite comme .... / ..## / .##. / .... occupe réellement un
rectangle de 2×2, pas 4×4. La normalisation calcule ce rectangle englobant et
décale la pièce vers l'origine pour faciliter le placement.
Algorithme de normalisation
La normalisation se fait en trois étapes : trouver la position minimale (ligne et
colonne du premier #), décaler tous les blocs de cette position vers
l'origine, puis calculer les dimensions du bounding box (largeur = colonne max + 1,
hauteur = ligne max + 1).
/* Trouve la position du bloc le plus en haut-gauche */
static void find_min_pos(t_tetri *tetri, int *min_row, int *min_col)
{
int i;
int j;
*min_row = 4;
*min_col = 4;
i = 0;
while (i < 4)
{
j = 0;
while (j < 4)
{
if (tetri->shape[i][j] == '#')
{
if (i < *min_row)
*min_row = i;
if (j < *min_col)
*min_col = j;
}
j++;
}
i++;
}
}
void normalize_tetri(t_tetri *tetri)
{
char temp[4][5];
int min_row;
int min_col;
int i;
int j;
find_min_pos(tetri, &min_row, &min_col);
/* Copie décalée vers le coin (0,0) */
i = 0;
while (i < 4)
{
j = 0;
while (j < 4)
{
temp[i][j] = '.';
j++;
}
i++;
}
i = 0;
while (i < 4 && i + min_row < 4)
{
j = 0;
while (j < 4 && j + min_col < 4)
{
temp[i][j] = tetri->shape[i + min_row][j + min_col];
j++;
}
i++;
}
/* Recopie dans la structure + calcul des dimensions */
/* ... (copie + calculate_dimensions) */
}
Le backtracking expliqué
Le backtracking est la technique algorithmique au cœur de fillit. C'est une recherche systématique qui explore l'arbre des possibilités en abandonnant une branche dès qu'elle ne peut pas mener à une solution. C'est ce qu'on appelle l'élagage (pruning).
L'idée intuitive
Imaginez que vous assemblez un puzzle sans image de référence. Vous prenez la première pièce, vous la posez en haut à gauche. Puis vous prenez la deuxième pièce et vous essayez de la placer à côté. Si elle s'insère, vous passez à la troisième. Si elle ne s'insère nulle part, vous revenez en arrière : vous retirez la première pièce et vous essayez une autre position pour elle. C'est exactement ce que fait le backtracking, mais de manière systématique et automatique.
Le backtracking est un algorithme de recherche qui construit incrementalement des candidats solutions et abandonne un candidat dès qu'il devient impossible ("backtracks") de le compléter en une solution valide. C'est une forme de parcours en profondeur (DFS) sur l'arbre des configurations possibles.
L'arbre de recherche
Pour fillit, chaque nœud de l'arbre représente un état partiel de la grille : certaines pièces sont placées, d'autres non. La racine est la grille vide. Les enfants d'un nœud sont toutes les positions valides où on peut placer la pièce suivante. Une feuille où toutes les pièces sont placées est une solution. L'objectif est de trouver la plus petite taille de grille qui admet au moins une solution.
Les 4 étapes de la récursion
La fonction solve() est récursive. À chaque appel, elle essaie de placer
la pièce numéro idx à toutes les positions possibles de la grille. Voici
la logique en 4 étapes :
1Condition d'arrêt (cas de base)
Si idx == count (toutes les pièces ont été placées), on a trouvé une
solution : on retourne 1 (succès). C'est le cas de base de la récursion.
Sans cette condition, la fonction s'appellerait indéfiniment.
2Essai de placement
On parcourt toutes les positions (row, col) de la grille, ligne par
ligne, de gauche à droite et de haut en bas. Pour chaque position, on appelle
try_place() qui vérifie si la pièce tetris[idx] peut
être placée à cet endroit sans déborder ni chevaucher une pièce déjà posée.
3Récursion
Si le placement réussit, on place réellement la pièce sur la grille
(on écrit sa lettre dans les cellules), puis on appelle solve()
récursivement avec idx + 1 pour essayer de placer la pièce suivante.
Si cet appel retourne 1, la solution est trouvée et on la propage
vers le haut. Sinon, on continue à essayer d'autres positions.
4Backtrack (retour arrière)
Si la récursion a échoué (retourne 0), cela signifie que la pièce
actuelle ne peut pas mener à une solution à cette position. On retire
alors la pièce de la grille en appelant remove_piece() (on remet les
cellules à '.'), puis on essaie la position suivante. Si aucune position
ne fonctionne, on retourne 0 pour signaler l'échec au niveau parent,
qui backtrackera à son tour.
Le code de la fonction solve
/*
** Résout le puzzle en plaçant récursivement chaque pièce.
** Retourne 1 si toutes les pièces ont été placées, 0 sinon.
*/
int solve(t_tetri *tetris, int idx, int count, t_map *map)
{
int row;
int col;
/* Étape 1: cas de base — toutes les pièces sont placées */
if (idx == count)
return (1);
/* Étape 2: essayer toutes les positions dans la grille */
row = 0;
while (row <= map->size - tetris[idx].height)
{
col = 0;
while (col <= map->size - tetris[idx].width)
{
/* Étape 2: peut-on placer la pièce ici ? */
if (try_place(map, &tetris[idx], row, col))
{
/* Étape 3: place la pièce + récursion sur la suivante */
if (solve(tetris, idx + 1, count, map))
return (1); /* solution trouvée ! */
/* Étape 4: échec → retirer la pièce (backtrack) */
remove_piece(map, &tetris[idx], row, col);
}
col++;
}
row++;
}
/* Aucune position ne marche pour cette pièce → backtrack */
return (0);
}
Les fonctions try_place et remove_piece
try_place() fait deux choses : elle vérifie que la pièce tient dans la
grille (pas de débordement) et qu'elle ne chevauche aucune pièce déjà posée. Si tout
est OK, elle écrit la lettre de la pièce dans les cellules correspondantes et retourne
1. remove_piece() fait l'inverse : elle efface la lettre (remet les
cellules à '.') pour préparer le backtrack.
int try_place(t_map *map, t_tetri *tetri, int row, int col)
{
int i;
int j;
/* Vérifie que la pièce ne déborde pas de la grille */
if (row + tetri->height > map->size ||
col + tetri->width > map->size)
return (0);
/* Vérifie qu'aucun bloc ne chevauche une pièce déjà posée */
i = 0;
while (i < tetri->height)
{
j = 0;
while (j < tetri->width)
{
if (tetri->shape[i][j] == '#' &&
map->grid[row + i][col + j] != '.')
return (0); /* chevauchement ! */
j++;
}
i++;
}
/* Place la pièce en écrivant sa lettre dans la grille */
i = 0;
while (i < tetri->height)
{
j = 0;
while (j < tetri->width)
{
if (tetri->shape[i][j] == '#')
map->grid[row + i][col + j] = tetri->letter;
j++;
}
i++;
}
return (1);
}
void remove_piece(t_map *map, t_tetri *tetri, int row, int col)
{
int i;
int j;
i = 0;
while (i < tetri->height)
{
j = 0;
while (j < tetri->width)
{
if (tetri->shape[i][j] == '#')
map->grid[row + i][col + j] = '.';
j++;
}
i++;
}
}
La boucle principale : taille incrémentale
Le backtracking ne cherche pas directement la solution optimale. Il essaie d'abord avec la plus petite taille de grille possible (calculée à partir du nombre de blocs), et si aucune solution n'existe à cette taille, il incrément la taille de 1 et réessaie. Cela garantit de trouver le plus petit carré, car on commence par le minorant et on monte jusqu'à trouver.
/* Calcule la taille minimale: ceil(sqrt(4 * count)) */
int find_min_square(int count)
{
int size;
int total_blocks;
total_blocks = count * 4;
size = 2;
while (size * size < total_blocks)
size++;
return (size);
}
static void run_fillit(char *filename)
{
/* ... parsing ... */
map_size = find_min_square(count);
map = create_map(map_size);
/* Boucle: essaie map_size, puis map_size+1, etc. */
while (!solve(tetris, 0, count, map))
{
free_map(map);
map_size++;
map = create_map(map_size);
}
print_map(map);
free_map(map);
}
On pourrait commencer par une grande taille (par exemple 4×4 pour 4 pièces) et
descendre. Mais le backtracking est plus rapide sur les petites grilles car il y a
moins de positions à explorer. En commençant par le minorant ceil(sqrt(4n)),
on essaie d'abord la grille la plus contrainte (où une solution est rare mais rapide
à invalider), puis on agrandit. Si une solution existe à la taille minimale, on la
trouve immédiatement ; sinon, on passe à la taille suivante.
La règle du "most upper-left"
Le sujet exige que, parmi toutes les solutions possibles à la taille minimale, on choisisse celle où les pièces sont placées le plus en haut à gauche possible, dans l'ordre d'apparition dans le fichier. Notre algorithme respecte naturellement cette règle grâce à l'ordre de parcours : on essaie les positions de haut en bas et de gauche à droite, et on s'arrête à la première solution trouvée. La première solution trouvée est donc celle où la première pièce (A) est la plus en haut-gauche possible, puis la deuxième (B) dans la même logique, etc.
Le code complet
Vue d'ensemble de l'architecture du projet, fichier par fichier. Le code fait environ 250 lignes réparties sur 5 fichiers sources.
| Fichier | Rôle | Lignes |
|---|---|---|
main.c | Point d'entrée, gestion argc/argv, orchestration | ~50 |
parsing.c | Lecture fichier, extraction 4×4, validation (blocs + connexions) | ~110 |
normalize.c | Alignement top-left + calcul width/height | ~80 |
map.c | Création/libération/affichage de la grille | ~75 |
solve.c | Backtracking récursif + try_place + remove_piece | ~85 |
Architecture des données
Le flux de données est linéaire : read_file → parse_tetriminos
→ normalize_tetri (pour chaque pièce) → validate_tetri →
solve (backtracking) → print_map. Aucune structure globale
n'est utilisée : tout passe par les paramètres des fonctions, ce qui rend le code
testable et modulaire.
Le sujet interdit les fuites. La seule allocation dynamique est la grille
t_map (un tableau 2D de char). La fonction free_map
libère chaque ligne puis le tableau de pointeurs puis la structure. Elle est appelée
dans deux cas : quand on agrandit la grille (taille +1) et à la fin du programme.
Les structures t_tetri sont allouées sur la pile (tableau statique de 26),
donc pas de malloc à libérer pour elles.
Exemple pas à pas
Suivons l'exécution du programme sur l'exemple du sujet, avec 4 pièces : un L, une barre I horizontale, un T, et un S.
Les 4 pièces en entrée
Étape 1 : calcul de la taille minimale
4 pièces × 4 blocs = 16 blocs. ceil(sqrt(16)) = 4. On commence avec une
grille 4×4. C'est le minorant absolu — si ça marche, on a la solution optimale.
Étape 2 : backtracking sur grille 4×4
L'algorithme essaie de placer A en (0,0) — le coin haut-gauche. A est un L de 2×3, ça tient. Puis il essaie B. B fait 1×4. À (0,0) ça chevauche A. À (0,1) ça chevauche aussi. À (0,2) pareil. En fait, B ne peut pas tenir sur la ligne 0 car A occupe les colonnes 0-1. B peut aller en (1,2) mais ça déborde (1+1 ≤ 4 OK, mais 2+4 = 6 > 4). B ne peut pas être placée → on backtrack : on retire A de (0,0) et on essaie A en (0,1).
Après plusieurs essais, l'algorithme trouve la configuration suivante où toutes les pièces tiennent en 4×4 :
Solution finale
DDAA
CDDA
CCCA
BBBB
Chaque pièce est identifiée par sa lettre (A, B, C, D) dans l'ordre d'apparition du fichier. La solution respecte la règle "upper-left" : A est en (0,2), B en (3,0), C en (1,0), D en (0,0). Aucune autre configuration 4×4 ne place les pièces plus en haut-gauche dans l'ordre du fichier.
Optimisations
Le backtracking naïf peut être lent sur certains cas pathologiques. Voici les optimisations importantes pour passer la moulinette sans timeout.
1. Normalisation préalable
Normaliser les pièces avant le backtracking (et non à chaque essai)
évite de recalculer le bounding box à chaque placement. C'est fait une fois pour
toutes au parsing. Les champs width et height de
t_tetri permettent à try_place de ne parcourir que les
cellules utiles, pas tout le 4×4.
2. Bornage de la boucle
Au lieu de parcourir toute la grille (row < map->size), on s'arrête
à map->size - tetri->height. Si une pièce fait 3 de haut et que la
grille fait 4 de côté, inutile d'essayer row = 2 ou row = 3 —
la pièce déborderait. Cela divise le nombre d'essais par 2 à 4 selon les pièces.
/* AVANT (naïf): parcours toute la grille */
while (row < map->size)
/* APRÈS (optimisé): s'arrête dès que la pièce déborderait */
while (row <= map->size - tetris[idx].height)
3. Early termination (taille incrémentale)
On commence par la plus petite taille possible. Si solve retourne 0
(échec), on libère la grille, on incrémente la taille de 1, et on recommence. Cela
garantit qu'on trouve le plus petit carré saut tester inutilement de grandes
grilles qui seraient coûteuses à explorer.
3. Éviter le pire cas : pièces identiques
Le cas pathologique est 26 pièces I verticales identiques : le backtracking explore un arbre énorme car chaque pièce peut aller dans n'importe quelle colonne. En pratique, ce cas est rare car les sujets de moulinette utilisent des pièces variées. Si vous voulez le gérer, une optimisation avancée consiste à détecter les pièces identiques et à forcer leur ordre (par exemple, ne placer la pièce N+1 qu'à droite ou en dessous de la pièce N si elles sont identiques).
Le sujet précise : "This moulinette includes an arbitrary timeout that will stop the execution of your program if it takes too long." Sur les tests courants (4-10 pièces variées), notre implémentation répond en moins de 10 millisecondes. Le cas 26 pièces identiques peut prendre plusieurs secondes, mais ce n'est généralement pas testé par la moulinette. Si ça l'est, l'optimisation des pièces identiques (ci-dessus) devient nécessaire.
Compilation & tests
Comment compiler le projet et le tester avec les exemples du sujet.
Compilation
# Compiler le projet
$ make
# Règles disponibles
$ make all # compile fillit
$ make clean # supprime les .o
$ make fclean # supprime .o + binaire
$ make re # fclean + all
Test avec l'exemple du sujet
# Créer un fichier de test
$ cat > sample.fillit << EOF
....
##..
.#..
.#..
....
####
....
....
#...
###.
....
....
....
##..
.##.
....
EOF
# Exécuter
$ ./fillit sample.fillit
DDAA
CDDA
CCCA
BBBB
Cas d'erreur
# Fichier invalide (pièce déconnectée)
$ cat > bad.fillit << EOF
#..#
....
....
....
EOF
$ ./fillit bad.fillit
error
# Pas d'argument
$ ./fillit
usage: ./fillit [file]
# Fichier inexistant
$ ./fillit /nonexistent
error
Avant de rendre : make fclean && make sans warnings, fichier
author présent avec votre login, binaire fillit à la racine,
aucun fichier superflu (pas de .o, pas de obj/), et le
projet dans un repo Git propre.