Article de reference

Algorithme diviser pour régner

En informatique , le paradigme « diviser pour régner » est une méthode de conception d'algorithmes . Un algorithme de ce type décompose récursivement un problème en deux ou plus...

En informatique , le paradigme « diviser pour régner » est une méthode de conception d'algorithmes . Un algorithme de ce type décompose récursivement un problème en deux ou plusieurs sous-problèmes de même type ou de types apparentés, jusqu'à ce que ceux-ci deviennent suffisamment simples pour être résolus directement. Les solutions de ces sous-problèmes sont ensuite combinées pour obtenir la solution du problème initial.

La technique diviser pour régner est à la base d'algorithmes efficaces pour de nombreux problèmes, tels que le tri (par exemple, le tri rapide , le tri fusion ), la multiplication de grands nombres (par exemple, l' algorithme de Karatsuba ), la recherche de la paire de points la plus proche , l'analyse syntaxique (par exemple, les analyseurs descendants ), la résolution de SAT , et le calcul de la transformée de Fourier discrète ( FFT ).

Concevoir des algorithmes de type « diviser pour régner » efficaces peut s'avérer complexe. Comme en récurrence , il est souvent nécessaire de généraliser le problème pour le rendre récursif . La validité d'un algorithme de ce type est généralement démontrée par récurrence, et son coût de calcul est souvent déterminé par la résolution de relations de récurrence .

Tri de la liste (38, 27, 43, 3, 9, 82, 10) par ordre croissant selon la méthode « diviser pour régner ». Partie supérieure : division en sous-listes ; partie centrale : tri trié d’une liste à un seul élément ; partie inférieure : assemblage des sous-listes triées.

Le paradigme « diviser pour régner » est souvent utilisé pour trouver une solution optimale à un problème. Son principe consiste à décomposer un problème donné en deux ou plusieurs sous-problèmes similaires, mais plus simples, à les résoudre successivement, puis à combiner leurs solutions pour résoudre le problème initial. Les problèmes suffisamment simples sont résolus directement. Par exemple, pour trier une liste de n nombres naturels , on la divise en deux listes d'environ n /2 nombres chacune, on trie chacune d'elles successivement, puis on entrelace les résultats pour obtenir la liste triée (voir l'illustration). Cette approche est connue sous le nom d' algorithme de tri fusion .

L'expression « diviser pour régner » est parfois employée pour désigner des algorithmes qui réduisent chaque problème à un seul sous-problème, comme l' algorithme de recherche dichotomique pour trouver un enregistrement dans une liste triée (ou son équivalent en calcul numérique , l' algorithme de dichotomie pour la recherche de racines ). Ces algorithmes peuvent être implémentés plus efficacement que les algorithmes « diviser pour régner » classiques ; en particulier, s'ils utilisent la récursivité terminale , ils peuvent être convertis en simples boucles . Cependant, selon cette définition large, tout algorithme utilisant la récursivité ou des boucles pourrait être considéré comme un algorithme « diviser pour régner ». Par conséquent, certains auteurs estiment que l'expression « diviser pour régner » ne devrait être utilisée que lorsque chaque problème peut générer deux sous-problèmes ou plus. L'expression « diminuer pour régner » a été proposée à la place pour la classe des problèmes à sous-problème unique.

Une application importante du principe « diviser pour régner » se trouve dans l'optimisation, où si l'espace de recherche est réduit (« élagué ») par un facteur constant à chaque étape, l'algorithme global a la même complexité asymptotique que l'étape d'élagage, la constante dépendant du facteur d'élagage (en sommant la série géométrique ) ; c'est ce qu'on appelle l'élagage et la recherche .

premiers exemples historiques

Les premiers exemples de ces algorithmes sont principalement de type « diminuer et conquérir » – le problème initial est successivement décomposé en sous-problèmes individuels , et peut effectivement être résolu de manière itérative.

La recherche binaire , un algorithme de type « réduire pour régner » où les sous-problèmes sont environ deux fois plus petits que l'original, a une longue histoire. Bien qu'une description claire de l'algorithme sur ordinateur soit apparue en 1946 dans un article de John Mauchly , l'idée d'utiliser une liste triée d'éléments pour faciliter la recherche remonte au moins à Babylone , en 200 av. J.-C. Un autre algorithme ancien de ce type est l' algorithme d'Euclide, qui permet de calculer le plus grand commun diviseur de deux nombres en les divisant en sous-problèmes équivalents de plus en plus petits ; cet algorithme date de plusieurs siècles avant J.-C.

Un exemple précoce d'algorithme diviser pour régner avec plusieurs sous-problèmes est la description par Gauss en 1805 de ce qui est maintenant appelé l' algorithme de transformation de Fourier rapide (FFT) de Cooley-Tukey , bien qu'il n'ait pas analysé son nombre d'opérations de manière quantitative, et que les FFT ne se soient généralisées que lorsqu'elles ont été redécouvertes plus d'un siècle plus tard.

Un des premiers algorithmes D&C à deux sous-problèmes qui a été spécifiquement développé pour les ordinateurs et correctement analysé est l' algorithme de tri fusion , inventé par John von Neumann en 1945.

Un autre exemple notable est l' algorithme inventé par Anatolii A. Karatsuba en 1960 qui permet de multiplier deux nombres à n chiffres en opérations ( notation grand O ). Cet algorithme a réfuté la conjecture d' Andreï Kolmogorov de 1956 selon laquelle cette opération nécessiterait opérations.

À titre d'exemple supplémentaire d'algorithme de type « diviser pour régner » n'impliquant pas initialement d'ordinateurs, Donald Knuth cite la méthode généralement utilisée par les bureaux de poste pour acheminer le courrier : les lettres sont triées dans des sacs distincts selon les zones géographiques, chacun de ces sacs est lui-même trié en lots pour des sous-régions plus petites, et ainsi de suite jusqu'à la distribution. Ce procédé est apparenté au tri par base , décrit pour les machines de tri à cartes perforées dès 1929.

Avantages

Résoudre des problèmes difficiles

Diviser pour régner est un outil puissant pour résoudre des problèmes conceptuellement difficiles : il suffit de décomposer le problème en sous-problèmes, de résoudre les cas triviaux et de combiner ces sous-problèmes au problème initial. De même, réduire pour régner consiste simplement à ramener le problème à un seul sous-problème plus simple, comme le casse-tête classique des tours de Hanoï , où déplacer une tour de hauteur h se réduit à déplacer une tour de hauteur h .

Efficacité de l'algorithme

Le paradigme « diviser pour régner » facilite souvent la découverte d'algorithmes efficaces. Il a été la clé, par exemple, de la méthode de multiplication rapide de Karatsuba , des algorithmes de tri rapide et de tri fusion, de l' algorithme de Strassen pour la multiplication matricielle et des transformées de Fourier rapides.

Dans tous ces exemples, l'approche D&C a permis d'améliorer le coût asymptotique de la solution. Par exemple, si (a) les cas de base ont une taille constante et bornée, le travail de division du problème et de combinaison des solutions partielles est proportionnel à la taille du problème , et (b) il existe un nombre borné de sous-problèmes de taille ~ à chaque étape, alors le coût de l'algorithme diviser pour régner sera de .

Pour d'autres types d'approches de type « diviser pour régner », les temps d'exécution peuvent également être généralisés. Par exemple, lorsque : a) le travail de division du problème et de combinaison des solutions partielles prend un temps O(n), où n est la taille de l'entrée et c une constante ; b) lorsque n ≥ 0 , l'algorithme prend un temps majoré par n ; et c) il existe n sous-problèmes, chacun de taille n ≈ 0. Alors, les temps d'exécution sont les suivants :

  • si le nombre de sous-problèmes , alors le temps d'exécution de l'algorithme diviser pour régner est borné par . 2" 2 q>2{\displaystyle q>2}2
  • si le nombre de sous-problèmes est exactement un, alors le temps d'exécution de l'algorithme diviser pour régner est borné par .

Si, en revanche, le travail de division du problème et de combinaison des solutions partielles prend du temps, et qu'il existe 2 sous-problèmes où chacun a une taille , alors le temps d'exécution de l'algorithme diviser pour régner est borné par .

Parallélisme

Les algorithmes de type diviser pour régner sont naturellement adaptés à l'exécution sur des machines multiprocesseurs , en particulier les systèmes à mémoire partagée où la communication des données entre les processeurs n'a pas besoin d'être planifiée à l'avance car des sous-problèmes distincts peuvent être exécutés sur différents processeurs.

Accès à la mémoire

Les algorithmes de type « diviser pour régner » exploitent naturellement et efficacement les caches mémoire . En effet, lorsqu'un sous-problème est suffisamment petit, il peut, en principe, être résolu, ainsi que tous ses sous-problèmes, dans le cache , sans accéder à la mémoire principale , plus lente . Un algorithme conçu pour exploiter le cache de cette manière est dit « insensible au cache » , car il ne prend pas en compte la taille du cache comme paramètre explicite . De plus, les algorithmes de type « diviser pour régner » peuvent être conçus pour des algorithmes importants (par exemple, le tri, les FFT et la multiplication matricielle) afin d'être des algorithmes insensibles au cache optimaux : ils utilisent le cache de manière probablement optimale, au sens asymptotique, quelle que soit sa taille. À l'inverse, l'approche traditionnelle d'exploitation du cache consiste à utiliser le blocage , comme dans l'optimisation par imbrication de boucles , où le problème est explicitement divisé en segments de taille appropriée. Cette approche peut également exploiter le cache de manière optimale, mais uniquement si l'algorithme est adapté aux tailles de cache spécifiques d'une machine donnée.

Le même avantage existe en ce qui concerne d'autres systèmes de stockage hiérarchiques, tels que NUMA ou la mémoire virtuelle , ainsi que pour plusieurs niveaux de cache : une fois qu'un sous-problème est suffisamment petit, il peut être résolu au sein d'un niveau donné de la hiérarchie, sans accéder aux niveaux supérieurs (plus lents).

Contrôle de l'arrondi

Dans les calculs utilisant l'arithmétique arrondie, par exemple avec des nombres à virgule flottante , un algorithme de type « diviser pour régner » peut donner des résultats plus précis qu'une méthode itérative en apparence équivalente. Par exemple, on peut additionner N nombres soit par une simple boucle qui ajoute chaque donnée à une variable, soit par un algorithme de type « diviser pour régner » appelé sommation par paires, qui divise l'ensemble de données en deux moitiés, calcule récursivement la somme de chaque moitié, puis additionne les deux sommes. Bien que la seconde méthode effectue le même nombre d'additions que la première et présente le surcoût des appels récursifs, elle est généralement plus précise.

Problèmes de mise en œuvre

Récursivité

Les algorithmes de type « diviser pour régner » sont naturellement implémentés sous forme de procédures récursives . Dans ce cas, les sous-problèmes partiels ayant permis d'aboutir au problème en cours de résolution sont automatiquement stockés dans la pile d'appels de la procédure . Une fonction récursive est une fonction qui s'appelle elle-même au sein de sa définition.

Pile explicite

Les algorithmes de type « diviser pour régner » peuvent également être implémentés par un programme non récursif qui stocke les sous-problèmes partiels dans une structure de données explicite, telle qu'une pile , une file ou une file de priorité . Cette approche offre une plus grande liberté dans le choix du sous-problème à résoudre ensuite, une caractéristique importante dans certaines applications, comme la récursivité en largeur et la méthode de séparation et d'évaluation pour l'optimisation de fonctions. Cette approche est également la solution standard dans les langages de programmation qui ne prennent pas en charge les procédures récursives.

Taille de la pile

Dans les implémentations récursives d'algorithmes de tri par objets (D&C), il est impératif de veiller à allouer suffisamment de mémoire à la pile de récursion ; à défaut, l'exécution risque d'échouer en raison d' un dépassement de capacité de la pile . Les algorithmes D&C performants en termes de temps d'exécution présentent souvent une faible profondeur de récursion. Par exemple, l'algorithme de tri rapide peut être implémenté de manière à ne jamais nécessiter plus de 2 appels récursifs imbriqués pour trier les éléments.

Il peut être difficile de prévenir les débordements de pile lors de l'utilisation de procédures récursives, car de nombreux compilateurs traitent la pile de récursion comme un bloc de mémoire contigu, et certains lui réservent même un espace fixe. De plus, les compilateurs peuvent y stocker plus d'informations que nécessaire, notamment l'adresse de retour, les paramètres inchangés et les variables locales de la procédure. Par conséquent, le risque de débordement de pile peut être réduit en limitant le nombre de paramètres et de variables locales dans la procédure récursive, ou en remplaçant la récursivité par une structure de données de pile explicite.

Choisir les cas de base

Dans tout algorithme récursif, il existe une grande liberté dans le choix des cas de base , les petits sous-problèmes qui sont résolus directement afin de terminer la récursion.

Choisir les cas de base les plus simples ou les plus petits possibles est plus élégant et conduit généralement à des programmes plus simples, car il y a moins de cas à considérer et ils sont plus faciles à résoudre. Par exemple, un algorithme de transformée de Fourier rapide peut interrompre la récursion lorsque l'entrée est un seul échantillon, et l'algorithme de tri rapide peut s'arrêter lorsque l'entrée est la liste vide ; dans les deux cas, il n'y a qu'un seul cas de base à considérer, et il ne nécessite aucun traitement.

En revanche, l'efficacité s'améliore souvent si la récursivité est interrompue à des cas de base relativement importants, et que ceux-ci sont résolus de manière non récursive, aboutissant à un algorithme hybride . Cette stratégie évite la surcharge des appels récursifs peu ou pas productifs et peut également permettre l'utilisation d'algorithmes non récursifs spécialisés qui, pour ces cas de base, sont plus efficaces qu'une récursivité explicite. Une procédure générale pour un algorithme récursif hybride simple consiste à court-circuiter le cas de base , également appelé récursivité à distance . Dans ce cas, on vérifie si l'étape suivante aboutira au cas de base avant l'appel de fonction, évitant ainsi un appel inutile. Par exemple, dans un arbre, plutôt que d'effectuer une récursion vers un nœud enfant puis de vérifier s'il est nul, vérifier la valeur nulle avant la récursion permet d'éviter la moitié des appels de fonction dans certains algorithmes sur les arbres binaires. Étant donné qu'un algorithme de division et de regroupement (D&C) réduit finalement chaque instance de problème ou de sous-problème à un grand nombre d'instances de base, ces dernières dominent souvent le coût global de l'algorithme, en particulier lorsque la surcharge liée à la division/regroupement est faible. Notez que ces considérations ne dépendent pas du fait que la récursivité soit implémentée par le compilateur ou par une pile explicite.

Ainsi, par exemple, de nombreuses implémentations de l'algorithme quicksort en bibliothèque basculent vers un simple tri par insertion basé sur une boucle (ou un algorithme similaire) dès que le nombre d'éléments à trier est suffisamment petit. Il est important de noter que si la liste vide était le seul cas de base, le tri d'une liste contenant des éléments impliquerait un nombre maximal d'appels à quicksort qui ne feraient rien d'autre que retourner immédiatement. Augmenter les cas de base à des listes de taille 2 ou moins permet d'éliminer la plupart de ces appels inutiles. Plus généralement, un cas de base supérieur à 2 est généralement utilisé pour réduire le temps passé en surcharge liée aux appels de fonction ou à la manipulation de la pile.

On peut également utiliser de grands ensembles de cas de base qui emploient toujours un algorithme de type « diviser pour régner », mais implémentés pour un ensemble prédéterminé de tailles fixes. Dans ce cas, l'algorithme peut être entièrement déroulé en un code sans récursivité, boucles ni conditions (technique liée à l' évaluation partielle ). Par exemple, cette approche est utilisée dans certaines implémentations efficaces de la FFT, où les cas de base sont des implémentations déroulées d'algorithmes FFT de type « diviser pour régner » pour un ensemble de tailles fixes. Des méthodes de génération de code source peuvent être utilisées pour produire le grand nombre de cas de base distincts nécessaires à la mise en œuvre efficace de cette stratégie.

La version généralisée de cette idée est connue sous le nom de « déroulement » ou « grossissement » de la récursion, et diverses techniques ont été proposées pour automatiser la procédure d'élargissement du cas de base.

Programmation dynamique pour les sous-problèmes qui se chevauchent

Pour certains problèmes, la récursion ramifiée peut aboutir à l'évaluation répétée du même sous-problème. Dans ce cas, il peut être judicieux d'identifier et de sauvegarder les solutions de ces sous-problèmes redondants, une technique communément appelée mémoïsation . Poussée à l'extrême, elle conduit à des algorithmes ascendants de type « diviser pour régner », tels que la programmation dynamique .