Diviser pour régner
Cours complet · informatique (MP2I/MPI), chapitre 15 · MP2I et MPI
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
15.1 Trois temps
Le chapitre précédent énumérait ; celui-ci découpe. La méthode se dit en trois temps :
- Diviser le problème en sous-problèmes de même nature, plus petits ;
- Régner : les résoudre récursivement, jusqu'à un cas de base traité directement ;
- Combiner leurs solutions en une solution du problème initial.
Le gain vient d'un fait arithmétique : diviser la taille par deux à chaque étape ne laisse que étages. Encore faut-il que le troisième temps soit bon marché — c'est là que tout se joue, et c'est ce que la récurrence du chapitre chap:recursivite mesure :
15.2 La dichotomie
Chercher dans un tableau trié : on compare à l'élément médian, ce qui élimine la moitié du tableau.
(* Renvoie Some i tel que t.(i) = v, ou None.
Précondition : t est trié par ordre croissant. *)
let recherche t v =
let rec aux g d =
if g > d then None
else
let m = g + (d - g) / 2 in
if t.(m) = v then Some m
else if t.(m) < v then aux (m + 1) d
else aux g (m - 1)
in
aux 0 (Array.length t - 1)
Ici : un seul sous-problème est résolu, et la combinaison est vide. .
| recherche linéaire | dichotomie | |
|---|---|---|
| 1 000 | 10 | |
| 1 000 000 | 20 | |
| 1 000 000 000 | 30 |
Multiplier la taille par mille n'ajoute que dix comparaisons. C'est le rendement le plus spectaculaire de tout le livre, et il ne coûte qu'une chose : que le tableau soit trié.
15.2.1 La dichotomie là où on ne l'attend pas
Le programme insiste : « on présente un exemple de dichotomie où son recours n'est pas évident : par exemple, la couverture de points de la droite par segments égaux de plus petite longueur ».
Méthode : Dichotomiser sur la RÉPONSE, et non sur les données
Le problème ne parle d'aucun tableau trié. L'idée est ailleurs : on ne cherche pas dans les données, on cherche la valeur de la réponse, et l'on s'appuie sur une propriété de monotonie.
Le problème. points sur une droite, segments de même longueur . Quelle est la plus petite permettant de tout couvrir ?
L'observation décisive. Si une longueur suffit, alors toute longueur suffit aussi. La propriété « suffit » est donc monotone en : fausse jusqu'à un seuil, vraie ensuite. Un seuil dans une suite monotone se cherche par dichotomie.
Le test, glouton et linéaire. Pour une donnée, on couvre au plus vite : le premier segment commence au premier point non couvert, et l'on recommence.
(* Nombre minimal de segments de longueur L couvrant les points triés x. *)
let segments_necessaires x l =
let n = Array.length x in
let compte = ref 0 and i = ref 0 in
while !i < n do
let debut = x.(!i) in
incr compte;
while !i < n && x.(!i) <= debut +. l do incr i done
done;
!compte
(* Plus petite longueur permettant de couvrir x avec k segments, à eps près.
Précondition : x trié, non vide, k >= 1. *)
let plus_petite_longueur x k eps =
let bas = ref 0.0 and haut = ref (x.(Array.length x - 1) -. x.(0)) in
while !haut -. !bas > eps do
let milieu = (!bas +. !haut) /. 2.0 in
if segments_necessaires x milieu <= k then haut := milieu (* ça suffit *)
else bas := milieu (* trop court *)
done;
!haut
Complexité. Chaque test est , et la dichotomie divise l'intervalle par deux : .
Ma réponse est-elle un nombre, et la propriété « telle valeur convient » est-elle monotone ? Si oui, on dichotomise sur la réponse — même si le problème ne ressemble en rien à une recherche.
Le schéma est le même pour « la plus petite capacité de camion permettant livraisons », « la plus grande vitesse permettant d'arriver à l'heure », « le plus petit délai tenable ». Il transforme un problème d'optimisation en une suite de problèmes de décision — un passage que le chapitre chap:decidabilite formalisera sous le nom de transformation par un seuil.
La boucle s'arrête sur haut -. bas > eps, jamais sur bas = haut : deux flottants ne deviennent pas égaux, et la boucle tournerait sans fin. C'est le défaut du chapitre chap:algo-prog, rencontré ici en situation.
15.3 Le tri par partition-fusion
Méthode : Trier en fusionnant
Diviser le tableau en deux moitiés, trier chacune, fusionner les deux moitiés triées.
(* Fusionne deux listes triées en une liste triée. Complexité : O(|a| + |b|). *)
let rec fusion a b =
match a, b with
| [], l | l, [] -> l
| x :: ra, y :: rb ->
if x <= y then x :: fusion ra b else y :: fusion a rb
(* Trie l par ordre croissant. Complexité : Theta(n log n) dans TOUS les cas. *)
let rec tri_fusion = function
| ([] | [_]) as l -> l (* zéro ou un élément : déjà trié *)
| l ->
let rec couper = function
| [] -> ([], [])
| [x] -> ([x], [])
| x :: y :: r -> let (a, b) = couper r in (x :: a, y :: b)
in
let (g, d) = couper l in
fusion (tri_fusion g) (tri_fusion d)
, dans le meilleur comme dans le pire cas.
Démonstration
La coupe est , la fusion aussi : chaque appel à fusion consomme un élément par appel récursif. Donc .
Le calcul est celui du chapitre chap:recursivite, et il se lit sur un dessin : à chaque étage de la récursion, les sous-problèmes totalisent éléments, donc de travail ; et il y a étages. Total : .
Rien dans ce raisonnement ne dépend des données : le tri par partition-fusion ne connaît pas de « mauvais » tableau.
d'espace supplémentaire — la fusion construit une nouvelle liste. C'est son seul défaut face au tri rapide, qui trie en place mais dégénère en sur les pires entrées, et face au tri par tas (chapitre chap:tas), qui est en place et garanti.
15.3.1 Une variante qui compte : les inversions
Une inversion d'un tableau est un couple avec et . Leur nombre mesure le désordre : nul si le tableau est trié, s'il est à l'envers.
La force brute est . Or la fusion les compte gratuitement : quand on prend un élément de la moitié droite alors qu'il reste éléments à gauche, cet élément est plus petit que ces -là, tous situés avant lui — soit inversions d'un coup.
(* Coupe l en ses n/2 premiers et le reste. CONTIGUË, contrairement au
« couper » du tri : le comptage exige que TOUTE la moitié gauche précède
la droite dans la liste d'origine. *)
let couper_contigu l =
let n = List.length l / 2 in
let rec aux k = function
| r when k = 0 -> ([], r)
| x :: r -> let (a, b) = aux (k - 1) r in (x :: a, b)
| [] -> ([], [])
in aux n l
(* Renvoie (liste triée, nombre d'inversions). Complexité : Theta(n log n). *)
let rec tri_compte = function
| ([] | [_]) as l -> (l, 0)
| l ->
let (g, d) = couper_contigu l in
let (g', ig) = tri_compte g and (d', id) = tri_compte d in
let rec fusion a na b =
match a, b with
| [], l -> (l, 0)
| l, [] -> (l, 0)
| x :: ra, y :: rb ->
if x <= y then let (f, k) = fusion ra (na - 1) b in (x :: f, k)
else
(* y passe devant TOUS les éléments restants de a. On transporte
leur nombre en argument : « List.length a » ici rendrait la
fusion quadratique, et donc l'algorithme entier. *)
let (f, k) = fusion a na rb in (y :: f, k + na)
in
let (f, ifus) = fusion g' (List.length g') d' in
(f, ig + id + ifus)
Le même découpage, le même coût, une information de plus. C'est la marque d'une bonne méthode : on l'adapte sans la refaire.
La coupe doit être contiguë. Le couper du tri sépare un élément sur deux : pour trier, n'importe quelle coupe convient, et celle-là est la plus courte à écrire. Mais le comptage repose sur le fait que toute la moitié gauche précède la droite dans la liste d'origine — ce que la coupe alternée détruit. Mesuré sur les permutations de quatre éléments avec la coupe alternée : les sont mal comptées, et la liste déjà triée rend au lieu de .
Et List.length a dans la fusion la rendrait quadratique, donc l'algorithme entier aussi : on transporte la longueur en argument. Une complexité annoncée se vérifie en mesurant, pas en la recopiant du tri dont on est parti.
15.4 La rencontre au milieu
Quand l'exhaustif coûte , on coupe l'ensemble en deux moitiés de taille , on énumère séparément les possibilités de chacune, puis on les apparie. Le coût passe de à — la racine carrée du précédent.
Étant donné entiers et une cible , existe-t-il un sous-ensemble de somme ?
(* Vrai s'il existe un sous-ensemble de t de somme c. Complexité : O(2^(n/2) · n). *)
let somme_sous_ensemble t c =
let n = Array.length t in
let moitie = n / 2 in
(* toutes les sommes atteignables avec les éléments d'indices [d, f[ *)
let sommes d f =
let acc = ref [0] in
for i = d to f - 1 do
acc := !acc @ List.map (fun s -> s + t.(i)) !acc
done;
!acc
in
let a = sommes 0 moitie in
let b = List.sort compare (sommes moitie n) in
let tab = Array.of_list b in
(* pour chaque somme de la première moitié, on CHERCHE son complément
dans la seconde, par DICHOTOMIE : c'est la « rencontre » *)
List.exists (fun s -> recherche tab (c - s) <> None) a
Le gain, chiffré. Pour : l'exhaustif fait opérations — dix-huit minutes. La rencontre au milieu en fait de chaque côté, plus le tri et les recherches : quelques dixièmes de seconde. Le même problème, quatre ordres de grandeur d'écart.
Remarquez ce que fait ce dernier algorithme : il divise pour régner (deux moitiés), puis se sert de la dichotomie pour apparier. Les méthodes de ce chapitre se composent — et c'est ainsi qu'on gagne des ordres de grandeur sur des problèmes que le chapitre chap:exploration déclarait hors de portée.
15.5 Les points les plus proches
Étant donné points du plan, trouver la paire la plus proche. La force brute examine les paires : .
Diviser : on trie les points par abscisse et l'on coupe par une droite verticale médiane. Régner : on résout à gauche et à droite, obtenant . Combiner : il reste à examiner les paires à cheval sur la droite. Seuls comptent les points situés dans la bande de largeur centrée sur la coupure — et, une fois cette bande triée par ordonnée, chaque point n'a besoin d'être comparé qu'à un nombre borné de suivants, car deux points de la bande distants de plus de verticalement ne peuvent pas faire mieux que .
Complexité : , soit — la bande se traite en temps linéaire une fois les points triés par ordonnée.
15.6 Ce qu'il faut retenir
- Diviser, régner, combiner — et c'est le coût de la combinaison qui décide de tout, via , résolue à la main.
- La dichotomie vaut aussi sur la réponse, dès que la propriété « telle valeur convient » est monotone. C'est le cas le plus rentable, et le moins visible.
- Le tri par partition-fusion est dans tous les cas, au prix de de mémoire — et il compte les inversions gratuitement.
- La rencontre au milieu remplace par : sur , dix-huit minutes deviennent un dixième de seconde.