TL;DR
- Le Problème: Optimisation de la complexité temporelle et spatiale des structures de données.
- L'Approche: Problème style CTCI 8.3 pour débutants: dans un tableau trié, trouver un indice i avec A[i] == i. Valeurs distinctes: recherche binaire. Doublons: les deux côtés avec bornes resserrées.
- Complexité: Compromis optimal entre temps et mémoire avec gestion des cas limites.
Les chambres d'un hôtel s'alignent, numérotées 0, 1, 2, ... La liste des clients est triée par numéro de préférence de chambre. Un indice magique est une chambre où le numéro du client égale le numéro de la chambre: A[i] == i. Tu veux une telle chambre, ou la preuve qu'aucune n'existe, sans ouvrir chaque porte quand tu peux l'éviter.
Ce billet est un enseignement original pour débutants en Java. Même famille de problèmes que les entretiens classiques de "point fixe dans un tableau trié", pas une copie du livre. Fait partie de la série CTCI en Java. Le chapitre 8 (récursion et programmation dynamique) continue ici après la marche sur la grille.
1. Analogie du quotidien
Imagine des casiers peints de 0 à 6. Dans chacun tu glisses un billet avec un entier. Les billets sont déjà en ordre croissant de gauche à droite.
| Indice (casier) | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| Valeur (billet) | -1 | 0 | 1 | 3 | 5 | 7 | 9 |
Le casier 3 contient le billet 3. C'est un indice magique. Le 4 contient 5, pas 4.
Si chaque billet est unique, la ligne triée a une forme nette: une fois que les valeurs dépassent leur indice et montent au moins aussi vite que les indices, l'égalité ne peut plus se cacher plus à droite. D'où la recherche binaire.
Si les billets peuvent se répéter, la ligne peut vaciller. La valeur 2 peut être à l'indice 1 et encore plus loin. Tu ne peux pas toujours jeter une moitié entière, mais tu peux encore sauter des plages impossibles pour un point fixe.
2. Énoncé clair
Entrée: un tableau trié d'entiers A (non décroissant). L'échauffement classique suppose des valeurs distinctes. Le follow-up autorise les doublons.
Sortie: un indice i avec A[i] == i, ou une sentinelle (ici -1) s'il n'y en a pas.
Exemples (distincts):
| Tableau | Indice magique | Pourquoi |
|---|---|---|
{-1, 0, 1, 3, 5, 7, 9} |
3 |
A[3] == 3 |
{0, 2, 3, 4, 5} |
0 |
la première case matche |
{1, 2, 3, 4} |
aucun | chaque valeur est strictement au-dessus de son indice |
{-10, -5, 2, 5} |
2 |
seul le milieu matche |
Exemple avec doublons:
A = {-10, -5, 2, 2, 2, 3, 4, 7, 9, 12, 13}
L'indice 7 marche (A[7] == 7). Selon le mid tu peux aussi tomber sur d'autres points fixes s'ils existent; en renvoyer un seul suffit pour ce problème.
À clarifier en entretien:
- Trié croissant? (Oui.)
- Distincts ou non? (Demande. Commence distinct, puis doublons.)
- N'importe quel indice magique ou le plus à gauche? (N'importe lequel sauf consigne contraire.)
- Tableau vide? Renvoie
-1. - Valeurs négatives autorisées? Oui. Les indices restent non négatifs, donc une valeur négative ne peut jamais égaler son indice.
3. Réfléchis d'abord
Force brute
Parcours i de 0 à n - 1. Si A[i] == i, renvoie i. Temps O(n), espace O(1). Correct pour un petit n. En entretien on veut exploiter le tri.
Valeurs distinctes: recherche binaire sur l'écart du point fixe
Regarde mid. Compare A[mid] et mid.
- Égal: terminé. Renvoie
mid. A[mid] > mid: pour toutj > mid, tri + distincts impliqueA[j] >= A[mid] + (j - mid) > mid + (j - mid) = j. DoncA[j] > jpour toujours à droite. Cherche seulement à gauche:0 .. mid - 1.A[mid] < mid: pour toutj < mid,A[j] <= A[mid] - (mid - j) < mid - (mid - j) = j. DoncA[j] < jpour toujours à gauche. Cherche seulement à droite:mid + 1 .. n - 1.
C'est une recherche binaire classique avec une comparaison maison (valeur - indice traverse zéro). Profondeur de récursion O(log n).
Doublons: les deux côtés, mais resserrés
Le saut "distincts" casse quand les valeurs peuvent rester plates. Exemple:
index: 0 1 2 3 4 5
value: 1 1 1 3 5 6
À mid 2, A[2] == 1 < 2. Avec la règle distincts tu ne chercherais qu'à droite; d'autres formes cassent l'abandon d'un seul côté. Règle sûre avec doublons:
- Vérifie mid. Si match, renvoie-le.
- Cherche à gauche sur une plage serrée: de
startàMath.min(mid - 1, A[mid]). - Si la gauche échoue, cherche à droite de
Math.max(mid + 1, A[mid])àend.
Pourquoi le min/max?
- Un indice magique
kà gauche doit vérifierk <= mid - 1etA[k] == k. Le tri forceA[k] <= A[mid], donck <= A[mid]. Borne haute gauche:min(mid - 1, A[mid]). - À droite,
k >= mid + 1etk == A[k] >= A[mid], donc borne basse:max(mid + 1, A[mid]).
Pire cas encore O(n) si beaucoup de doublons ouvrent souvent les deux branches. En moyenne bien mieux qu'un scan pur quand le tableau est surtout strict. Tu utilises encore l'ordre au lieu de l'ignorer.
Récursion vs itération
Le cas distinct se mappe proprement sur une boucle (comme binary search). Le cas doublons est plus simple en récursif: essaie gauche, puis droite. Pile O(log n) en partitions équilibrées, jusqu'à O(n) dans les cas moches. Les interviewers acceptent en général la forme récursive.
4. Solution Java
Entiers distincts
/**
* Magic index for a sorted array of distinct ints.
* Returns some i with A[i] == i, or -1 if none.
*/
public static int magicIndexDistinct(int[] a) {
if (a == null || a.length == 0) {
return -1;
}
return magicIndexDistinct(a, 0, a.length - 1);
}
private static int magicIndexDistinct(int[] a, int lo, int hi) {
if (lo > hi) {
return -1;
}
int mid = lo + (hi - lo) / 2;
int val = a[mid];
if (val == mid) {
return mid;
}
if (val > mid) {
// fixed point, if any, is strictly left
return magicIndexDistinct(a, lo, mid - 1);
}
// val < mid: search right
return magicIndexDistinct(a, mid + 1, hi);
}
Jumeau itératif (même logique):
public static int magicIndexDistinctIter(int[] a) {
if (a == null || a.length == 0) {
return -1;
}
int lo = 0;
int hi = a.length - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
int val = a[mid];
if (val == mid) {
return mid;
}
if (val > mid) {
hi = mid - 1;
} else {
lo = mid + 1;
}
}
return -1;
}
Avec doublons (plages resserrées)
/**
* Magic index when the sorted array may contain duplicates.
* Still returns any match, or -1.
*/
public static int magicIndex(int[] a) {
if (a == null || a.length == 0) {
return -1;
}
return magicIndex(a, 0, a.length - 1);
}
private static int magicIndex(int[] a, int lo, int hi) {
if (lo > hi) {
return -1;
}
int mid = lo + (hi - lo) / 2;
int val = a[mid];
if (val == mid) {
return mid;
}
// Left: only indices that can still equal their value
int leftHi = Math.min(mid - 1, val);
int left = magicIndex(a, lo, leftHi);
if (left >= 0) {
return left;
}
// Right: skip indices that cannot match
int rightLo = Math.max(mid + 1, val);
return magicIndex(a, rightLo, hi);
}
Préfère la méthode distinct quand l'interviewer garantit l'unicité (histoire plus claire, vrai O(log n)). Passe à la version générale dès qu'on parle de doublons ou de "non décroissant".
5. Parcours pas à pas
Distincts: {-1, 0, 1, 3, 5, 7, 9}
| lo | hi | mid | A[mid] | Action |
|---|---|---|---|---|
| 0 | 6 | 3 | 3 | égal, renvoie 3 |
Un coup. Mid chanceux, mais les mêmes règles le trouvent depuis d'autres départs.
Échec distincts: {1, 2, 3, 4}
| lo | hi | mid | A[mid] | Action |
|---|---|---|---|---|
| 0 | 3 | 1 | 2 | 2 > 1, va à gauche |
| 0 | 0 | 0 | 1 | 1 > 0, va à gauche |
| 0 | -1 | vide, renvoie -1 |
Chaque valeur est au-dessus de son indice; la recherche se vide correctement.
Doublons: {-10, -5, 2, 2, 2, 3, 4, 7, 9, 12, 13}
Supposons que mid tombe sur l'indice 5 (A[5] == 3).
- Pas égal.
- Haut gauche =
min(4, 3) = 3. Cherche0..3. - Dans cette plage tu ne touches peut-être pas 7; gauche renvoie -1.
- Bas droit =
max(6, 3) = 6. Cherche6..10. - Le mid de là peut être 8 (
A[8] == 9 > 8) ou 7 (A[7] == 7). Quand mid vaut 7, renvoie 7.
Les bornes resserrées sautent l'indice 5 lui-même (déjà testé) et peuvent sauter des cases mortes quand val et mid divergent beaucoup.
Checks rapides dans le code
int[] distinct = {-1, 0, 1, 3, 5, 7, 9};
assert magicIndexDistinct(distinct) == 3;
int[] none = {1, 2, 3, 4};
assert magicIndexDistinct(none) == -1;
int[] dups = {-10, -5, 2, 2, 2, 3, 4, 7, 9, 12, 13};
int m = magicIndex(dups);
assert m >= 0 && dups[m] == m;
assert magicIndex(new int[]{}) == -1;
assert magicIndex(null) == -1;
assert magicIndex(new int[]{0}) == 0;
assert magicIndex(new int[]{1}) == -1;
6. Complexité, bords, conseils d'entretien
| Sujet | Distincts | Avec doublons |
|---|---|---|
| Temps | O(log n) | O(log n) meilleur, O(n) pire |
| Espace extra | O(log n) récursion ou O(1) itératif | O(log n) à O(n) de pile |
| Tri requis | oui | oui (non décroissant) |
| Négatifs | ok; seuls les indices non négatifs peuvent matcher | idem |
Bords:
- Vide / null →
-1. - Un élément
{0}→0;{5}→-1. - Magique aux extrémités: indice 0 ou
n - 1. - Tout négatif: pas d'indice magique (les valeurs n'attrapent jamais un indice non négatif).
- Tableau plat de la même valeur
v: seul l'indicevpeut marcher, et seulement si0 <= v < netA[v] == v.
Bugs fréquents:
- Utiliser la règle d'un seul côté (distincts) après que les doublons sont autorisés.
- Oublier de tester
A[mid] == midavant de brancher. - Off-by-one sur
lo/hi(mid - 1/mid + 1). - Sur doublons, chercher tout
0..mid-1etmid+1..n-1sans le skipmin/max(toujours correct, juste plus lent; mentionne l'optimisation). - Renvoyer seulement un booléen alors qu'on demandait l'indice.
- Overflow sur
(lo + hi) / 2en entiers de largeur fixe; préfèrelo + (hi - lo) / 2.
Comment le raconter:
- Reformule: "Trouver i avec A[i] == i dans un tableau trié."
- Brute O(n), puis "trié + distincts implique binary search d'un seul côté."
- Prouve l'abandon de côté avec l'argument distincts + tri en une phrase chacun.
- Code proprement la version distinct.
- Follow-up: "Avec doublons, cherche les deux côtés mais clip avec min(mid-1, A[mid]) et max(mid+1, A[mid])."
7. Récap à raconter à un ami
Magic Index demande un point fixe dans un tableau trié: l'indice égale la valeur.
- La force brute est une boucle droite. Utilise-la seulement si n est petit ou si le tableau n'est pas trié.
- Distincts + trié: compare mid à
A[mid]. Trop haut: seul la gauche peut marcher. Trop bas: seule la droite. C'est binary search sur l'écart. - Doublons: vérifie mid, puis récure à gauche jusqu'à
min(mid - 1, A[mid]), puis à droite depuismax(mid + 1, A[mid]). L'ordre tue encore les bandes d'indices impossibles. - Renvoie n'importe quel indice qui matche, ou
-1. Les négatifs ne matchent jamais un indice valide. - Le chemin distinct est O(log n). Le chemin doublons peut dégrader en O(n); dis-le à voix haute.
Si tu peux marcher {-1,0,1,3,5,7,9} jusqu'à l'indice 3 et expliquer pourquoi les doublons demandent les deux côtés avec bornes coupées, tu maîtrises le problème 8.3. Ensuite: construire chaque sous-ensemble d'un ensemble.
Série
- Guide: Guide de la série CTCI
- Précédent: Robot in a Grid
- Suivant: Power Set
![Magic Index: Trouver i tel que A[i] égale i (Java) cover image](/assets/images/ctci-8-3-magic-index.webp)