Nous allons voir dans ce cours des techniques d'optimisation.
Optimiser une solution, c'est la rendre plus rapide car plus efficace. Elle vise à utiliser des raccourcis ou à éviter des opérations inutiles ou redondantes.
Dans le cas de la
récursivité, elle permet de simplifier le code et d'éviter certains bugs
(effets de bords).
Optimiser conduit ainsi souvent à améliorer la
complexité d'un algorithme et donc sa rapidité d'exécution.
En première,
vous avez vu le cas de l'algorithme de recherche séquentielle de
complexité linéaire 𝑂(𝑛) et celui de la recherche dichotomique, de
complexité logarithmique 𝑂(log(𝑛)), qui était plus efficace.
Voir l'article sur l'Analyse de la complexité algorithmique (Wikipédia)
Un algorithme récursif est un algorithme
qui fait appel à lui-même.
Ainsi, une fonction 𝑓 est récursive lorsque la déclaration de cette fonction 𝑓 contient un appel à cette fonction 𝑓.
Nous allons voir qu'il existe différents types de récursivité : simple, double/multiple, imbriquée et mutuelle/croisée.
En mathématiques, la factorielle d'un entier 𝑛∊ℕ, notée fact(𝑛) ou 𝑛!, est définie par : fact(𝑛) = 1×2×3×...×(𝑛-1)×𝑛.
Ainsi
fact(𝑛) = 𝑛×fact(𝑛-1)
On peut ainsi donner une définition
récursive de la factorielle :
Pour programmer récursivement, il faut être capable de découper le problème en sous-problèmes plus simples. À partir de là, il faut distinguer deux cas :
le cas récursif qui permet de passer du problème au sous-problème et qui empile les appels récursifs et le cas trivial (ou condition d'arrêt) qui permet d'arrêter l'empilement et de commencer à faire remonter les résultats.
Dans l'exemple de la fonction factorielle :
fact(3)
Pour la fonction factorielle, il est possible de définir un deuxième cas de base : celui ou 𝑛 = 1 :
Remarque : Ici, il n'y a toujours qu'un seul appel récursif à la fois mais qui dépend du contexte. Cela diffère de la récursion multiple que nous allons voir par la suite.
La suite de Syracuse d'un nombre entier 𝑁 > 0 est définie par récurrence de la manière suivante :
syracuse(n)
qui renvoie
la liste des valeurs de la suite de syracuse allant de n
à 1
en
utilisant une boucle while
.
syra_recursif(n)
On parle de récursion double quand le cas récursif fait deux fois appel à la fonction. L'exemple le plus connu est la suite de Fibonacci. Sa définition récursive est :
fibo(n)
permettant
de calculer le n-ième terme de la suite de Fibonacci.
Il s'agit de deux fonctions qui s'appellent mutuellement, c'est-à-dire que la première appelle la deuxième et la deuxième, la première.
pair
et
impair
mutuellement récursives, sans utiliser le modulo
%
ni le not
, telles que, étant donné un entier
n ≥ 0 :
pair(n)
retourne True
si n est
pair et False
sinon.impair(n)
retourne True
si n est impair et
False
sinon.
Un appel récursif est dit imbriqué quand il apparaît en tant qu'argument d'un appel récursif.
La fonction d'Ackermann est définie mathématiquement par :
La seule opération effectuée lors du déroulement de la fonction d'Ackermann est l'ajout de 1 (lorsque 𝑚 est nul). Malgré cela, cette fonction croît extrêmement rapidement : acker(4, 2) a déjà 19 729 chiffres ce qui représente bien plus que le nombre d'atomes estimé dans l'univers.
La fonction d'Ackermann est utilisée comme programme de test d'une implémentation d'un langage de programmation car elle utilise de façon très exigeante la récursivité.
Un programme récursif a une complexité linéaire 𝑂(𝑛) dans le cas ou sa
pile d'exécution est linéaire. C'est le cas de la fonction factorielle où
le nombre d'appels récursifs est proportionnel à la taille du problème
qui est ici le nombre dont on veut calculer la factorielle.
Dans le cas de la suite de Fibonacci, la pile d'exécution n'est pas
linéaire car on a affaire à une récursion double. Chaque appel entraine un
double appel récursif ce qui conduit rapidement à une explosion des appels
de la fonction. La complexité est alors exponentielle 𝑂(2𝑛) . Ici la
taille du problème de rang 𝑛 correspond au rang de la suite que l'on
souhaite atteindre. Nous verrons par la suite que la
programmation dynamique permet d'optimiser l'algorithme
pour le rendre plus efficace.
La récursivité ne permet pas toujours d'apporter une meilleure réponse au
problème. Dans le cas de la suite de Fibonacci, il est possible d'obtenir
une complexité en 𝑂(𝑛) par simple itération (avec une boucle
while
).
Pour comparer deux algorithmes, une bonne méthode est de les soumettre à un même problème (même taille) et de chronométrer leur temps de réponse. Ce temps de calcul est proportionnel au nombre d'opérations effectuées ce qui est une bonne représentation de la complexité relative d'algorithmes.
fibo_it(n)
(avec
une boucle while
qui renvoie le n-ième terme de la suite de
Fibonacci.
Deux propriétés doivent être vérifiées pour s'assurer qu'un algorithme récursif fonctionne correctement :
Exemple : Dans le cas de la fonction factorielle, sa
terminaison est assurée car la fonction prend en argument un entier
positif, l'appel récursif prend en argument l'entier décrémenté de 1 et le
cas d'arrêt est défini quand l'argument devient nul.
Sa correction
partielle consiste à s'assurer qu'un appel récursif renvoie bien :
𝑛 × 𝑓𝑎𝑐𝑡(𝑛-1)
RecursionError
et affiche le message :
Recursion Error: maximum recursion depth exceeded.
setrecursionlimit(5000)
du module sys
. Cette
solution sera de courte durée face à la limite de puissance du processeur
et au temps d'exécution, en particulier avec des algorithmes de complexité
exponentielle.
import sys
sys.setrecursionlimit(5000)
Des langages utilisant le paradigme fonctionnel sont plus adaptés à
l'utilisation de la récursivité. En effet, pour certains cas, ils sont
capables d'éviter de multiplier les environnements d'appels dans la pile
des appels récursifs.
Exemple :
Langage OCaml
La méthode "Diviser pour régner" est composées de trois phases :
La récursivité est tout à fait adaptée à la méthode "Diviser pour Régner" du fait qu'elle divise un problème initial en sous-problèmes.
Résumons pour en déduire l'algorithme :
Pseudo code : L'algorithme du tri fusion.
FONCTION tri_fusion(liste): liste est la liste à trier N ← longueur de la liste Si N est nul ou vaut 1 alors retourner la liste telle quelle. Sinon : M ← Entier(N/2) liste1 ← liste des M premiers éléments de liste liste2 ← les éléments restant de la liste Renvoyer la fusion de tri_fusion(liste1) et tri_fusion(liste2)
fusion
qui à partir de deux listes
triées, les fusionne pour obtenir une seule liste triée.
tri_fusion
qui implémente le
pseudo-code présenté ci-dessus.
Les images bitmap (aussi appelées images raster) sont des images consituées de pixels, c'est-à-dire d'un ensemble de points structurés dans un tableau. Chacun de ces points possède une ou plusieurs valeurs décrivant sa couleur.
Les images vectorielles sont constituées d'entités géométriques telles qu'un cercle, un rectangle, un segment... Ceux-ci sont représentés par des formules mathématiques (un rectangle est défini par deux points, un cercle par un centre et un rayon, une courbe par une équation, ...).
Une image vectorielle sera toujours nette quelle que soit sa taille contrairement à une image bitmap qui finira par se pixeliser.
Pour manipuler une image en python, la bibliothèque PIL
est parfaitement
adaptée. Elle prend en charge une trentaine de formats d'image bitmap.
from PIL import Image
Voici quelques objets et méthodes utiles de la bibliothèque PIL
pour
réaliser l'exercice. La documentation complète est accessible sur :
Pillow (PIL Fork).
img = Image.open("mon-image.jpg") # Crée un objet img à partir d'un fichier
img.show() # Affiche l'image
img.save('mon-image-v2.jpg', 'jpeg') # Sauvegarde une copie de l'image au format JPEG
largeur, hauteur = img.size # Extrait la largeur et la hauteur en pixel
pix = img.load() # Charge la matrice de pixel
img
et pix
sont des instances
d'objets définies dans la bibliothèque PIL
. Il s'agit, respectivement :
load()
appliquée à notre objet image. pix[x, y]
où x
et y
sont les coordonnées du
pixel tel que x in range(largeur)
et y in range(hauteur)
. Le
pixel de coordonnées (0, 0)
est situé en haut à gauche.L'objectif de cet exercice est de programmer la rotation d'une image d'un quart de tour avec une fonction récursive. Cette méthode permet d'avoir un coût en mémoire constant en modifier l'image sur elle-même. Pour simplifier le problème, on considère une image carrée dont la largeur en pixel est une puissance de 2.
L'idée est de diviser l'image en quatre carrés toujours plus petits jusqu'à atteindre un pixel puis de déplacer ces carrés pour effectuer la rotation.
|
|
|
en vidéo (Youtube) |
_rotation(pix, x, y, n)
qui réalise
la rotation de la portion de l'image comprise entre les pixels de
coordonnées (x, y)
et (x+n, y+n)
.rotation(img)
qui réalise la
rotation à 90° de l'image en prenant comme argument l'objet
img
.
La programmation dynamique apparaît comme pratique de résolution de problèmes mathématico-logistiques et de planification dans les années fin 40 - début 50 du XXème siècle ; plus précisément, elle est formalisée par Richard Bellman entre 1950 et 1953 : l'adjectif «dynamique» a été choisi pour décrire les aspects suivants :
Bellman a témoigné l'avoir choisi, en plus du côté descriptif d'actions spécifiques dans le temps, pour son aspect vendeur, lui servant de «parapluie pour ses activités» ; activités fort utiles et fécondes. C'est un exemple historique de «coup marketing» qu'un chercheur a été contraint d'employer pour mener d'autres activités qui s'avèrent indispensables aujourd'hui.
La méthode diviser pour régner scinde un problème en sous-problèmes, souvent indépendants, qu'on résout récursivement, puis la combinaison des solutions obtenues permet de résoudre le problème initial. Cependant, elle perd grandement en efficacité lorsqu'on résout plusieurs fois le même sous-problème.
print
permettant
d'obtenir l'historique des appels de cette même fonction. Tester pour
𝑛 = 5 et 𝑛 = 50.
Si l'on représente les appels récursifs de l'appel fibo(5)
comme un
graphe, on obtient le graphe suivant :
La programmation dynamique consiste alors à
stocker les valeurs des sous-problèmes, dans un tableau, pour éviter
les recalculs (on parle parfois de «cache»).
Selon la direction dans laquelle on lit le graphe, on a deux méthodes
dont voici les algorithmes :
fonction fibonacci(n)
F[0] = 0
F[1] = 1
pour tout i de 2 à n
F[i] = F[i-1] + F[i-2]
retourner F[n]
fonction fibonacci(n)
si F[n] n'est pas défini
si n = 0 ou n = 1
F[n] = n
sinon
F[n] = fibonacci(n-1) + fibonacci(n-2)
retourner F[n]
import time
start = time.time()
# choses à faire
end = time.time()
print("DURÉE = "+str(end-start)+" s")
res
) ; pour modifier cette liste depuis
une fonction, il faut la déclarer variable globale dans la fonction
avec : global res
.
None
et is None
permettent de modéliser et
de tester des emplacements «vides» dans les listes.
Deux chaines de caractères, st1
et st2
étant
données, la distance de Levenshtein, aussi nommée
distance d'édition, est le nombre minimal de modifications, chacune d'un
seul caractère, permettant de transformer st1
en
st2
. Par modification d'un seul caractère, on entend :
Voir la vidéo de Graphikart : Distance de Levenshtein.
On peut calculer la distance de Levenstein de la manière suivante :
def lev(st1, st2) :
if not st1: # si st1 est vide
return len(st2)
elif not st2: # si st2 est vide
return len(st1)
else :
subst = int( st1[-1] != st2[-1] )
# subst vaut 1 si les derniers caractères de st1 et st2 sont
# différents, et vaut 0 si ce sont les mêmes.
return min([lev(st1[:-1], st2 ) + 1, # insertion d'un char st1 -> st2
lev(st1 , st2[:-1]) + 1, # suppression d'un char st1 -> st2
lev(st1[:-1], st2[:-1]) + subst]) # remplacement ou non d'un char st1 -> st2
Pour dynamiser cet algorithme, on va utiliser le fait que
lev(st1,st2) == lev(st1.reverse(),st2.reverse())
: en effet,
en inversant l'ordre (sans les mélanger) des deux chaînes, on obtient la
même distance ; en alliant ce fait à une logique de programmation
dynamique bottom-top, on organise les calculs selon un tableau à double
entrée (st1
sur les lignes et st2
pour les
colonnes) :
∅ | m | a | i | r | e | |
∅ | ||||||
m | ||||||
è | 𝑎 | 𝑏 | ||||
r | 𝑐 | 𝑥 | ||||
e |
L'objectif est, à partir d'un système de monnaie, de trouver comment
rendre une somme de façon optimale, c'est-à-dire avec le moins de
pièces et de billets possible. Le système de monnaie européen est :
[0.01, 0.02, 0.05, 0.10, 0.20, 0.50, 1, 2, 5, 10, 20, 50, 100, 200, 500]
Rappel : En 1ère nous avons vu la stratégie gloutonne pour résoudre ce problème. L'idée de cet algorithme était assez intuitive et consiste, à chaque étape, à faire le choix qui semble le plus optimal. C'est-à-dire, pour le problème du rendu de monnaie, de toujours choisir la plus grande valeur possible pour atteindre la valeur souhaitée. Cette stratégie est effectivement optimale quand le système de monnaie l'est aussi. C'est le cas de la monnaie européenne mais pour d'autre système de monnaie, l'algorithme glouton n'est plus optimal.
[1, 3, 4]
, l'algorithme
glouton ne donne pas le résultat optimal. On pourra essayer de
rendre la monnaie sur une somme de 6.
Pour le rendre plus dynamique, on va stocker les résultats obtenus dans un
tableau de taille somme+1
(de 0
à somme
).
La recherche d'une sous-chaîne de caractères dans une plus grande chaîne de caractère est un grand classique en algorithmique. Ces algorithmes sont natommant utilisés en bioinformatique à propos de la recherche de séquence ADN. L'ADN est une molécule en double hélices qui est constituée d'un enchaînement de quatre nucléotides différents : A, C, G et T pour Adénine, Cytosine, Guanine et Thymine. Cette suite de nucléotides code l'information pour la synthèse des protéines essentielles au fonctionnement de notre organisme. Le séquençage de l'ADN à l'origine de la révolution de la recherche en génétique, consiste à obtenir la suite des nucléotides et à y rechercher des motifs correspondant à différents gènes. L'optimisation des algorithmes de recherche est un enjeu majeur pour la recherche génétique.
L'approche naïve pour la recherche textuelle est d'effectuer une comparaison du motif avec la séquence en commençant par la gauche et en comparant les caractères un par un de gauche à droite puis en décalant le motif vers la droite caractère par caractère. On parle ici de force brute car nous testons ainsi toutes les possibilités, ce qui est la caractéristique des algorithmes par force brute.
L'algorithme de Boyer-Moore, optimise l'algorithme naïf en diminuant le nombre de comparaisons par l'élimination de celles qui sont inutiles.
Considérons le motif : CGGCAG à rechercher dans
la
séquence : CAATGTCTGCACCAAGACGCCGGCAGGTGCAGACCTTCGTTATAGG
Etape 1 : On commence à placer le motif sur la gauche de la séquence.
Première idée de l'algorithme de Boyer-Moore : Comparer le motif à
la séquence en partant de la droite.
Deuxième idée : Sachant que le
motif ne contient pas la lettre T, il est possible de
décaler tout le motif pour le placer après la lettre T de
la séquence.
Etape 2 : On recommence les comparaisons par la droite. Troisième idée : Le caractère C ne correspond pas il est ailleurs dans le motif. On décale le motif jusqu'à faire correspondre le caractère C.
Etape 3 : On recommence les comparaisons par la droite. Encore une fois, le caractère A ne correspond pas mais il est ailleurs dans le motif. On décale le motif pour faire correspondre le caractère A.
Etape 4 : On recommence les comparaisons par la droite. Encore une fois, le caractère A ne correspond pas mais il est ailleurs dans le motif. On décale le motif pour faire correspondre le caractère A.
Etape 5 : On recommence les comparaisons par la droite. Les caractères G et A correspondent mais pas le caractère A suivant. Sachant qu'il n'y a plus de A dans le motif, on peut le décaler au delà de ce caractère A de la séquence.
Etape 6 : Et ainsi de suite jusqu'à trouver une occurrence. On note alors sa position dans la séquence.
Etape 7 : Après avoir trouver une occurrence, on regarde le caractère suivant dans la séquence, ici c'est un G. Sachant qu'il y a un G dans le motif, on décale le motif pour les faire correspondre.
Etape 8 : Et on recommence les comparaisons par la droite ...
Les trois grandes idées de l'algorithme de Boyer-Moore sont donc :
À travers l'exemple précédent, on voit qu'il faut connaître le motif pour
effectuer des sauts et faire correspondre une lettre de la séquence à sa
première occurrence en suivant dans le motif.
Une première étape importante
dans l'algorithme sera alors de créer une
table de décalage.
Cette dernière doit nous dire combien
de sauts effectuer dans le cas ou un caractère ne correspondrait pas. Il
s'agit d'une table à double entrées : le caractère recherché et la
position du caractère du motif qui diffère de la séquence.
Position dans le motif du caractère qui diffère |
Nombre de sauts pour atteindre : | ||
un A | un C | un G | |
0 | 0 | ||
1 | 1 | 0 | |
2 | 2 | 0 | |
3 | 0 | 1 | |
4 | 0 | 1 | 2 |
5 | 1 | 2 | 0 |
Aide :
table_decalage
permettant de générer cette table à partir
du motif.
time
de python.
Pour approfondir ce domaine de l'algorithmique de recherche textuelle, sachez que l'outil «principal» est l'automate. Grossièrement, un automate est un graphe orienté dont les arêtes ont été étiquetées :
Si vous êtes intéressé, vous pouvez rechercher l'algorithme de Knuth-Morris-Pratt, qui est un autre algorithme de recherche d'un motif dans un texte. Il existe en deux versions, une version où la présence des automates est masquée pour permettre une compréhension de l'algorithme au plus grand nombre, y compris à ceux qui n'ont jamais entendu parler d'automates, et une deuxième version dans laquelle les automates sont présents. Tapez "Algorithme de Knuth-Morris-Pratt" ou "Automate des occurrences" dans votre moteur de recherche préféré pour trouver ces deux versions.
en français | en anglais |
---|---|
récursivité | recursion |
fonction factorielle | factorial function |
algorithme diviser pour régner | divide-and-conquer algorithm |
tri fusion | merge sort |
programmation dynamique | dynamic programming |