TL;DR
- Le Problème: Optimisation de la complexité temporelle et spatiale des structures de données.
- L'Approche: Problème style CTCI 8.2 pour débutants: le robot va du haut-gauche au bas-droit avec seulement droite et bas. Certaines cases sont interdites. DFS mémorisé (ou DP) trouve un chemin en Java.
- Complexité: Compromis optimal entre temps et mémoire avec gestion des cas limites.
Tu te tiens au coin nord-ouest d'une grille de carrefours. Tu ne peux marcher que vers l'est ou le sud. Certains carrefours sont fermés pour travaux. Peux-tu atteindre le coin sud-est, et si oui, par quelle suite de carrefours?
C'est le robot dans une grille: un labyrinthe avec deux mouvements légaux, des cases bloquées optionnelles, et un chemin (pas tous) comme réponse. La récursion dessine l'arbre de recherche. La mémoïsation (ou le DP) t'empêche de re-résoudre la même case morte encore et encore.
Ce billet est un enseignement original pour débutants en Java. Même famille de questions de chemin sur grille en entretien, pas une copie de livre. Fait partie de la série CTCI en Java. Le chapitre 8 (récursion et programmation dynamique) continue après Triple Step.
1. Analogie du quotidien
Imagine un petit plan de ville en lignes et colonnes de carrefours:
- Tu commences au carrefour haut-gauche
(0, 0). - Le but est le bas-droit
(r - 1, c - 1). - Depuis un carrefour ouvert tu peux aller à droite d'un pâté ou en bas d'un pâté. Pas de gauche, pas de haut, pas de diagonale.
- Certains carrefours sont grillagés. Tu ne peux pas t'y poser.
- Il te faut n'importe quel trajet légal du départ à l'arrivée, listé comme suite de carrefours. Pas tous les trajets, ni le plus court (avec seulement droite et bas, chaque chemin a la même longueur: exactement
(r - 1) + (c - 1)mouvements).
Essaie une grille 3x3 avec le centre bloqué:
S . .
. X .
. . E
Un chemin: droite, droite, bas, bas (bord supérieur puis droit). Un autre: bas, bas, droite, droite (bord gauche puis inférieur). Les deux évitent le centre.
Si la première ligne et la première colonne sont bloquées juste après le départ, tu peux être coincé même si l'arrivée est libre. L'atteignabilité n'est pas "l'arrivée est-elle libre?"; c'est "existe-t-il une chaîne de cases libres reliées par droite/bas depuis le départ?"
2. Énoncé simple
Entrée: une grille de r lignes et c colonnes. Chaque case est libre ou interdite. En code: true = on peut marcher, false = bloqué. Départ (0, 0). But (r - 1, c - 1).
Sortie: une liste de points du départ au but formant un chemin valide, ou null / vide s'il n'y a pas de chemin.
Mouvements: depuis (row, col) seulement vers (row, col + 1) (droite) ou (row + 1, col) (bas), et seulement si la cible est dans les bornes et libre.
Forme du point:
class Point {
final int row;
final int col;
Point(int row, int col) {
this.row = row;
this.col = col;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return row == p.row && col == p.col;
}
@Override
public int hashCode() {
return 31 * row + col;
}
@Override
public String toString() {
return "(" + row + "," + col + ")";
}
}
Petits exemples:
| Idée de grille | Chemin? | Notes |
|---|---|---|
| 1x1 libre | oui: (0,0) |
départ = but |
| 1x1 bloquée | non | on ne peut pas se poser sur le départ |
| 2x2 tout libre | oui | ex. droite puis bas, ou bas puis droite |
2x2 seule (0,1) bloquée |
oui | il faut bas puis droite |
2x2 avec (0,1) et (1,0) bloquées |
non | les deux sorties du départ fermées |
| départ ou but bloqué | non | le chemin inclut les deux extrémités |
À clarifier avant de coder:
- Indexation: lignes d'abord, puis colonnes. Dis
maze[row][col], pas "x/y" sans définition claire. - Le départ est-il garanti libre? Vérifie quand même.
- Un chemin ou tous les chemins? Un chemin pour ce problème.
- Représentation des cases bloquées? Grille booléenne, entiers
0/1, ou un set de points: choisis. - Grille vide ou null? Renvoie null.
3. Réfléchir d'abord
La récursion colle aux mouvements
Depuis la case (r, c), un chemin existe si la case est libre et:
- tu es au but, ou
- il y a un chemin depuis le voisin de droite, ou
- il y a un chemin depuis le voisin du bas.
Tu peux aussi chercher en arrière depuis le but: une case est atteignable si elle est libre et qu'on y arrive depuis celle du dessus ou de gauche (en travaillant du but vers l'origine). Mêmes ordres de grandeur. L'avant depuis l'origine reste naturel pour construire le chemin.
La force brute est exponentielle
À chaque pas tu peux essayer deux branches. Un chemin fait environ r + c pas, donc l'arbre naïf est de l'ordre de O(2^(r+c)) au pire. Pire: beaucoup d'itinéraires visitent la même case. Si c'est une impasse, tu redécouvres l'échec encore et encore.
Mémoïser les échecs (et les succès)
L'optimisation clé: pour chaque case, pose une seule fois "y a-t-il un chemin d'ici au but?" Mets les non en cache dans un set de points échoués (ou un memo booléen 2D). Si tu as déjà prouvé qu'une case n'atteint pas le but, ne l'explore plus.
Avec ce cache, chaque case est explorée un nombre constant de fois. Le temps tombe à O(r * c). L'espace est O(r * c) pour le memo plus O(r + c) pour le chemin et la profondeur de pile.
Tu peux aussi remplir une table DP canReach[row][col] de bas en haut depuis le but, puis marcher depuis le départ en choisissant droite ou bas quand la case suivante peut encore atteindre. Même O(r * c).
Construire le chemin
Deux styles propres:
- En descendant: quand l'appel récursif depuis ici réussit, insère ce point devant le suffixe (ou à la fin puis inverse).
- Depuis le but vers l'origine: démarre au but, essaie gauche et haut; quand un sous-chemin vers l'origine existe, ajoute le point courant.
Les deux conviennent. Ci-dessous, recherche vers l'avant depuis l'origine avec un set de cases échouées.
Esquisse au tableau
- Dessine un 3x3, bloque le centre.
- DFS depuis
(0,0): essaie droite, récursion; essaie bas, récursion. - Marque une case échouée seulement après l'échec des deux directions.
- Quand tu touches
(2,2), le succès remonte et chaque cadre ajoute son point à la liste.
4. Solution Java
DFS mémorisé depuis le départ. Cases libres = true.
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Find one path from top-left to bottom-right.
* Moves: right or down only. maze[r][c] == true means free.
*/
public class RobotInAGrid {
public List<Point> getPath(boolean[][] maze) {
if (maze == null || maze.length == 0 || maze[0].length == 0) {
return null;
}
List<Point> path = new ArrayList<>();
Set<Point> failed = new HashSet<>();
if (findPath(maze, 0, 0, path, failed)) {
return path;
}
return null;
}
/**
* Returns true if there is a path from (row, col) to the goal.
* On success, path contains points from (row, col) through the goal in order.
*/
private boolean findPath(
boolean[][] maze,
int row,
int col,
List<Point> path,
Set<Point> failed) {
int rows = maze.length;
int cols = maze[0].length;
if (row < 0 || col < 0 || row >= rows || col >= cols || !maze[row][col]) {
return false;
}
Point here = new Point(row, col);
if (failed.contains(here)) {
return false;
}
boolean atGoal = (row == rows - 1) && (col == cols - 1);
if (atGoal
|| findPath(maze, row, col + 1, path, failed)
|| findPath(maze, row + 1, col, path, failed)) {
// Recursion filled the suffix (right or down branch).
// Add this cell at the front so the full list is start -> goal.
path.add(0, here);
return true;
}
failed.add(here);
return false;
}
}
path.add(0, here) garde l'ordre départ → but. Si tu préfères des append O(1), pousse en remontant et inverse à la fin, ou collecte depuis le but vers l'arrière et inverse une fois.
Variante: DP bottom-up puis reconstruction
public List<Point> getPathDp(boolean[][] maze) {
if (maze == null || maze.length == 0 || maze[0].length == 0) {
return null;
}
int rows = maze.length;
int cols = maze[0].length;
if (!maze[0][0] || !maze[rows - 1][cols - 1]) {
return null;
}
// canReach[r][c]: can we reach the goal from (r, c)?
boolean[][] canReach = new boolean[rows][cols];
canReach[rows - 1][cols - 1] = true;
for (int r = rows - 1; r >= 0; r--) {
for (int c = cols - 1; c >= 0; c--) {
if (!maze[r][c]) {
canReach[r][c] = false;
continue;
}
if (r == rows - 1 && c == cols - 1) {
continue;
}
boolean right = (c + 1 < cols) && canReach[r][c + 1];
boolean down = (r + 1 < rows) && canReach[r + 1][c];
canReach[r][c] = right || down;
}
}
if (!canReach[0][0]) {
return null;
}
List<Point> path = new ArrayList<>();
int r = 0;
int c = 0;
path.add(new Point(0, 0));
while (r != rows - 1 || c != cols - 1) {
if (c + 1 < cols && canReach[r][c + 1]) {
c++;
} else if (r + 1 < rows && canReach[r + 1][c]) {
r++;
} else {
return null; // should not happen if table is correct
}
path.add(new Point(r, c));
}
return path;
}
Même big-O. Pratique pour une histoire itérative sans pile de récursion.
Checks minimaux
boolean[][] open2 = {
{true, true},
{true, true}
};
// path length 3, e.g. (0,0)-(0,1)-(1,1) or (0,0)-(1,0)-(1,1)
boolean[][] blockedCenter = {
{true, true, true},
{true, false, true},
{true, true, true}
};
// still possible via top-right or bottom-left corridor
boolean[][] wall = {
{true, false},
{false, true}
};
// null path: both exits from start blocked
5. Tableau de complexité
| Approche | Temps | Espace extra | Notes |
|---|---|---|---|
| DFS naïf, sans mémo | O(2^(r+c)) pire | O(r + c) pile + chemin | Revisite les cases mortes |
| DFS mémo (set d'échecs) | O(r * c) | O(r * c) mémo + O(r + c) chemin/pile | Chaque case une fois |
| DP bottom-up + marche | O(r * c) | O(r * c) table | Pas de récursion; un chemin reconstruit |
| Longueur du chemin (si trouvé) | - | O(r + c) points | Toujours (r - 1) + (c - 1) + 1 cases |
En entretien, il faut nommer le piège exponentiel, puis montrer le set de mémo (ou la table DP) qui ramène à du linéaire en nombre de cases.
6. Cas limites et erreurs fréquentes
Les interviewers testent:
- Départ ou but bloqué: échec immédiat.
- 1x1 libre: le chemin est la seule case.
- Une seule ligne ou colonne: un seul couloir; tout blocage le coupe.
- Null ou grille de taille zéro: renvoie null; n'indexe pas
maze[0]. - Lignes de longueurs différentes: suppose rectangulaire; sinon valide
maze[i].length. - x/y sans définir la ligne: préfère
rowetcol.
Erreurs courantes:
- Oublier la mémoïsation. Le code a l'air juste et s'écroule sur de grandes grilles avec beaucoup de blocs près de la fin.
- Mémoïser seulement "visité" pour les cycles. Avec seulement droite/bas il n'y a pas de cycles, mais les cases échouées ont toujours besoin d'un cache car plusieurs parents partagent un enfant.
- Marquer échoué trop tôt (avant d'essayer les deux directions).
- Off-by-one sur le but (
rowsvsrows - 1). - Muter la grille comme visitée sans restaurer, puis rater un second appel.
- Renvoyer les cases dans le mauvais ordre (but → départ) sans inverser.
- Traiter bloqué comme libre en mélangeant les conventions
true/false.
7. Récap pour un ami
Robot sur une grille, version entretien:
- Départ haut-gauche, but bas-droit. Mouvements: droite ou bas seulement. Certaines cases interdites.
- Récursion: depuis une case libre, essaie droite, essaie bas; succès si tu atteins le but.
- Sans cache, la même case morte est explorée via beaucoup de parents: temps exponentiel.
- Mémo: retiens les cases qui n'atteignent pas le but. Chaque case une fois → O(r * c).
- Construis un chemin en enregistrant les points aux retours réussis (ou table DP + marche gloutonne).
- Vérifie départ/but libres, bornes, et entrée vide.
Si tu peux dessiner un petit labyrinthe, marquer une case échouée pour qu'un second parent la saute, et écrire la méthode récursive mémorisée sans bugs d'index, tu maîtrises le 8.2. Suite du chapitre: Magic Index.
Série
- Guide: Guide de la série CTCI
- Précédent: Triple Step
- Suivant: Magic Index
