Recherche binaire: lower bound, tableau roté, recherche par réponse
1. Vue d’ensemble
La recherche binaire est un algorithme de recherche sur des données triées avec une complexité O(log n). L’idée est très simple, mais l’implémentation est facile à rater : boucle infinie, décalage d’une unité (off-by-one), débordement d’entier lors du calcul de mid.
Cet article progresse de la version basique aux variantes courantes : chercher la première/dernière position d’éléments dupliqués, compter les occurrences, chercher dans un tableau roté, et recherche binaire sur la réponse. Après lecture, vous aurez un ensemble de schémas d’implémentation robustes, accompagnés de tests pour les valider.
2. Recherche binaire basique
Supposons que vous avez un tableau de prix de produits trié en ordre croissant et que vous devez chercher la position d’un prix donné :
public final class PriceSearch {
public static int search(int[] prices, int target) {
int lo = 0;
int hi = prices.length - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (prices[mid] == target) {
return mid;
}
if (prices[mid] < target) {
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return -1;
}
public static void main(String[] args) {
int[] prices = {100, 150, 200, 350, 500};
System.out.println(search(prices, 350));
System.out.println(search(prices, 300));
}
}
Explications :
loethiforment un intervalle fermé[lo, hi]. C’est la région oùtargetpeut se trouver.- La condition
lo <= hisignifie que la boucle s’exécute tant que l’intervalle contient au moins un élément. mid = lo + (hi - lo) / 2évite les débordements. Écrire(lo + hi) / 2déborderait quandlo + hidépasseInteger.MAX_VALUE. Une écriture équivalente est(lo + hi) >>> 1.- Après chaque itération, on exclut
midde l’intervalle (mid + 1oumid - 1), donc l’intervalle se réduit toujours et la boucle se termine.
Résultat :
3
-1
Cette version suffit quand le tableau n’a pas d’éléments dupliqués et que vous voulez juste savoir « existe-t-il ». Avec des éléments dupliqués, elle retourne une position quelconque dans le groupe. Généralement, ce n’est pas ce que vous voulez.
3. Lower bound et upper bound
Les deux variantes les plus importantes sont :
- lower bound : le premier indice où
a[i] >= target. - upper bound : le premier indice où
a[i] > target.
Les deux retournent toujours un indice dans [0, n], même si target n’existe pas dans le tableau. Cet indice est précisément la position d’insertion pour garder le tableau trié.
public final class Bounds {
public static int lowerBound(int[] a, int target) {
int lo = 0;
int hi = a.length; // intervalle semi-ouvert [lo, hi)
while (lo < hi) {
int mid = (lo + hi) >>> 1;
if (a[mid] < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
public static int upperBound(int[] a, int target) {
int lo = 0;
int hi = a.length;
while (lo < hi) {
int mid = (lo + hi) >>> 1;
if (a[mid] <= target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
}
Différences par rapport à la version basique :
- On utilise un intervalle semi-ouvert
[lo, hi), donchiest initialisé àa.lengthet la condition de boucle estlo < hi. - Quand
a[mid]satisfait la condition « assez grand », on posehi = midau lieu demid - 1, carmidpeut être la réponse. - Les deux fonctions ne diffèrent que par les opérateurs
<et<=.
Testons avec un tableau ayant des éléments dupliqués :
int[] prices = {100, 200, 200, 200, 350, 500};
System.out.println(Bounds.lowerBound(prices, 200)); // 1
System.out.println(Bounds.upperBound(prices, 200)); // 4
System.out.println(Bounds.lowerBound(prices, 300)); // 4
System.out.println(Bounds.lowerBound(prices, 900)); // 6
1
4
4
6
Avec 300, le résultat 4 dit que si on insère 300, il viendrait avant 350. Avec 900, le résultat est n, ce qui signifie tous les éléments sont plus petits.
3.1. Première position, dernière position et nombre d’occurrences
À partir de ces deux fonctions, vous déduisez immédiatement les problèmes courants :
public static int firstIndexOf(int[] a, int target) {
int i = Bounds.lowerBound(a, target);
return (i < a.length && a[i] == target) ? i : -1;
}
public static int lastIndexOf(int[] a, int target) {
int i = Bounds.upperBound(a, target) - 1;
return (i >= 0 && a[i] == target) ? i : -1;
}
public static int count(int[] a, int target) {
return Bounds.upperBound(a, target) - Bounds.lowerBound(a, target);
}
Avec le tableau prices ci-dessus et target = 200, on obtient firstIndexOf = 1, lastIndexOf = 3, count = 3. Règle à retenir : toujours vérifier les limites i < a.length (ou i >= 0) avant de lire a[i].
3.2. Compter les éléments dans une plage
Une application pratique : compter le nombre de commandes avec un total dans [min, max] :
public static int countInRange(int[] sortedTotals, int min, int max) {
return Bounds.upperBound(sortedTotals, max) - Bounds.lowerBound(sortedTotals, min);
}
Avec {100, 200, 200, 200, 350, 500} et la plage [150, 400], le résultat est 5 - 1 = 4. Chaque requête ne coûte que O(log n), sans parcourir le tableau entier.
4. Recherche dans un tableau roté
Un tableau roté est un tableau trié qu’on « coupe » à un point et dont on échange les deux moitiés, par exemple {40, 50, 60, 10, 20, 30}. Le tableau n’est plus croissant globalement, mais au moins une moitié autour de mid reste ordonnée. On s’en sert pour décider de la direction :
public static int searchRotated(int[] a, int target) {
int lo = 0;
int hi = a.length - 1;
while (lo <= hi) {
int mid = (lo + hi) >>> 1;
if (a[mid] == target) {
return mid;
}
if (a[lo] <= a[mid]) { // la moitié gauche est ordonnée
if (a[lo] <= target && target < a[mid]) {
hi = mid - 1;
} else {
lo = mid + 1;
}
} else { // la moitié droite est ordonnée
if (a[mid] < target && target <= a[hi]) {
lo = mid + 1;
} else {
hi = mid - 1;
}
}
}
return -1;
}
Explications :
a[lo] <= a[mid]indique que l’intervalle[lo, mid]est croissant. Sitargetest dans la plage de valeurs de cet intervalle, on rétrécit à gauche. Sinon, on va à droite.- Sinon, l’intervalle
[mid, hi]est croissant, et on raisonne de façon symétrique.
System.out.println(searchRotated(new int[]{40, 50, 60, 10, 20, 30}, 20));
System.out.println(searchRotated(new int[]{40, 50, 60, 10, 20, 30}, 45));
4
-1
Attention : Cette implémentation suppose que le tableau n’a pas d’éléments dupliqués. Avec un tableau comme
{2, 2, 2, 3, 2}, la conditiona[lo] <= a[mid]ne peut plus distinguer quelle moitié est ordonnée. Dans ce cas, il faut gérer le casa[lo] == a[mid]en incrémentantlod’une unité, et la complexité dans le pire cas devient O(n).
5. Recherche binaire sur la réponse
C’est la variante la plus puissante. Elle ne cherche pas dans un tableau mais trouve la plus petite valeur satisfaisant une condition. La seule condition pour l’appliquer est que le prédicat soit monotone : si x la satisfait, alors tous les nombres supérieurs à x la satisfont aussi. Autrement dit, la suite de résultats a la forme false, false, ..., true, true.
Écrivons une fonction générique :
import java.util.function.LongPredicate;
public final class AnswerSearch {
/** La plus petite valeur dans [lo, hi] où ok retourne true. Suppose ok(hi) == true. */
public static long firstTrue(long lo, long hi, LongPredicate ok) {
while (lo < hi) {
long mid = lo + (hi - lo) / 2;
if (ok.test(mid)) {
hi = mid;
} else {
lo = mid + 1;
}
}
return lo;
}
}
Cette fonction a la même structure que lowerBound. En fait, lowerBound n’est qu’un cas particulier avec le prédicat a[i] >= target.
Application à un problème réel : l’entrepôt a une liste de colis dans un ordre fixe, qui doivent être livrés en maxDays jours. Chaque jour, le camion transporte des colis consécutifs, avec un poids total ne dépassant pas la capacité du camion. Quelle est la capacité minimale ?
import java.util.Arrays;
public final class ShippingPlanner {
public static long minCapacity(int[] weights, int maxDays) {
long lo = Arrays.stream(weights).max().orElse(0);
long hi = Arrays.stream(weights).asLongStream().sum();
return AnswerSearch.firstTrue(lo, hi, cap -> canShip(weights, maxDays, cap));
}
static boolean canShip(int[] weights, int maxDays, long capacity) {
int days = 1;
long load = 0;
for (int w : weights) {
if (load + w > capacity) {
days++;
load = 0;
}
load += w;
}
return days <= maxDays;
}
public static void main(String[] args) {
System.out.println(minCapacity(new int[]{3, 2, 2, 4, 1, 4}, 3));
}
}
Explications :
loest le colis le plus lourd, car le camion doit pouvoir transporter au moins un colis quelconque.hiest le poids total, car une seule journée suffirait. Doncok(hi)est toujours vrai.canShipsimule une stratégie gloutonne : on charge des colis jusqu’à dépasser la capacité, puis on passe au jour suivant. Plus la capacité est grande, moins de jours il faut, donc le prédicat est monotone.- Le coût total est O(n · log(sum)), au lieu d’essayer chaque capacité.
6
Avec une capacité de 6, l’horaire de livraison est [3, 2], [2, 4], [1, 4], exactement 3 jours. Avec une capacité de 5, il faudrait 4 jours, donc 5 ne convient pas.
Le même schéma résout de nombreux autres problèmes : le nombre minimum de threads pour traiter les batchs à temps, le débit minimum à respecter, la racine carrée entière d’un nombre.
6. Utiliser la bibliothèque standard de Java
Java a déjà Arrays.binarySearch et Collections.binarySearch. Vous devez comprendre la valeur qu’elles retournent :
int[] prices = {100, 200, 200, 200, 350, 500};
System.out.println(Arrays.binarySearch(prices, 350));
System.out.println(Arrays.binarySearch(prices, 300));
4
-5
- Si l’élément est trouvé, la fonction retourne l’indice de l’élément.
- Si pas trouvé, la fonction retourne
-(insertionPoint) - 1. Ici la position d’insertion est4, donc le résultat est-5. Pour récupérer la position d’insertion, calculez-(result) - 1. - Avec des éléments dupliqués, la documentation ne garantit pas de retourner une position particulière du groupe. Si vous avez besoin de la première ou dernière position, utilisez votre propre implémentation de
lowerBound/upperBound.
Avec une liste d’objets, passez un Comparator cohérent avec l’ordre du tri :
record Order(String id, long total) {}
List<Order> orders = List.of(
new Order("A1", 120), new Order("B7", 300), new Order("C3", 450));
int idx = Collections.binarySearch(
orders, new Order("?", 300), Comparator.comparingLong(Order::total));
System.out.println(idx); // 1
Attention :
Collections.binarySearchsurLinkedListn’atteint pas O(log n) car accéder par indice coûte O(n). UtilisezArrayListou un tableau.
7. Tests
Ajoutez les dépendances JUnit 5 et AssertJ. Si votre projet utilise spring-boot-starter-test, les deux sont déjà inclus.
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
Écrivez des tests pour les cas limites. C’est là que la recherche binaire se casse le plus souvent :
import org.junit.jupiter.api.Test;
import java.util.Random;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
class BoundsTest {
private final int[] prices = {100, 200, 200, 200, 350, 500};
@Test
void lowerAndUpperBoundHandleDuplicates() {
assertThat(Bounds.lowerBound(prices, 200)).isEqualTo(1);
assertThat(Bounds.upperBound(prices, 200)).isEqualTo(4);
}
@Test
void boundsReturnInsertionPointAtEdges() {
assertThat(Bounds.lowerBound(prices, 50)).isZero();
assertThat(Bounds.lowerBound(prices, 900)).isEqualTo(prices.length);
assertThat(Bounds.lowerBound(new int[0], 1)).isZero();
}
@Test
void lowerBoundMatchesLinearScan() {
Random random = new Random(42);
for (int round = 0; round < 1_000; round++) {
int[] a = random.ints(random.nextInt(20), 0, 10).sorted().toArray();
int target = random.nextInt(12) - 1;
int expected = (int) IntStream.of(a).filter(x -> x < target).count();
assertThat(Bounds.lowerBound(a, target)).isEqualTo(expected);
}
}
@Test
void minCapacityFindsSmallestFeasibleValue() {
int[] weights = {3, 2, 2, 4, 1, 4};
assertThat(ShippingPlanner.minCapacity(weights, 3)).isEqualTo(6);
assertThat(ShippingPlanner.canShip(weights, 3, 5)).isFalse();
}
}
Le test lowerBoundMatchesLinearScan compare avec le comptage linéaire sur 1000 tableaux aléatoires, y compris les vides. Ce type de test de concordance attrape les erreurs off-by-one bien mieux que quelques exemples écrits à la main.
8. Erreurs courantes et quand ne pas l’utiliser
Erreurs fréquentes :
- Mélanger les conventions d’intervalle. Vous initialisez
hi = n(semi-ouvert) mais bouclezwhile (lo <= hi)et posezhi = mid - 1. Vous lisez alors hors du tableau. Choisissez une convention et restez-y cohérent. - Boucle infinie. Si vous écrivez
lo = midavecmid = (lo + hi) / 2, quandhi = lo + 1alorsmid == loet l’intervalle ne rétrécit pas. Si vous avez vraiment besoin delo = mid, arrondissez vers le haut :mid = lo + (hi - lo + 1) / 2. - Débordement d’entier au calcul de
midou quand on somme dans une recherche binaire sur la réponse. Utilisezlongpour les valeurs qui peuvent être grandes. - Prédicat non monotone. La recherche binaire retourne un nombre, mais ce nombre n’a pas de sens. Prouvez la monotonie avant d’appliquer.
Quand ne pas utiliser la recherche binaire :
- Les données ne sont pas triées et vous cherchez seulement quelques fois. Trier coûte O(n log n), mais une recherche linéaire coûte O(n).
- Vous cherchez juste « oui ou non » avec plusieurs recherches. Un
HashSetdonne O(1) en moyenne. - Les données changent souvent, avec beaucoup d’insertions et suppressions. Garder un tableau trié coûte O(n) par insertion. Envisagez
TreeMap/TreeSet, qui ont déjàceilingKey,floorKey,higherKeyéquivalant aux lower et upper bounds. - Le tableau est très petit, quelques dizaines d’éléments. Un parcours linéaire est souvent plus rapide grâce au cache, et le code est plus simple.
9. Conclusion
La recherche binaire n’est pas juste « trouver un élément dans un tableau ». Quand on maîtrise le lower bound et l’upper bound avec l’intervalle semi-ouvert [lo, hi), on résout les problèmes de première/dernière position, de comptage dans une plage, en quelques lignes. Un tableau roté demande une étape supplémentaire pour identifier la moitié ordonnée. La recherche binaire sur la réponse étend la technique à tous les problèmes avec un prédicat monotone.
Gardez une convention d’intervalle unique, utilisez >>> ou lo + (hi - lo) / 2 pour éviter les débordements, et ayez toujours un test de concordance avec un parcours linéaire. Ces trois habitudes éliminent la plupart des bugs pour lesquels la recherche binaire est réputée difficile.