Algorithmique : Programmation dynamique
par Pascal Vanier

Téléchargez l'archive progdyn.zip qui contient le code à compléter.

Programmation dynamique

Dans de nombreux problèmes, pour trouver la solution, il faut calculer des valeurs pour des sous-problèmes. La "programmation dynamique" est un terme extrèmement mal choisi pour pour désigner les algorithmes où l'on a décelé les sous-problèmes qui sont recalculés inutilement et où l'on réutilise les résultats des calculs déjà effectués.

Plusieurs manières de calculer Fibonacci

Il est plus facile de comprendre avec un exemple, prenons en un que beaucoup d'entre vous connaissent, la suite de Fibonacci, dont la définition récursive est la suivante : \[ F_0 = 0, \quad F_1 = 1\] \[ F_{n+2} = F_{n+1} + F_{n} \]

Les valeurs de la suite sont donc : $0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...$

Écrivez la fonction de Fibonacci sous forme récursive, avec comme nom def fibo(n).

Nous vous fournissons une fonction timeit qui va vous permettre de calculer le temps mis par vos différentes implémentations, son fonctionnement est le suivant : plutôt que d'appeler une fonction directement, par exemple f(arg1,arg2,arg3=toto,argblah=blih), vous la passez en premier argument à timeit et ses arguments ensuite, pour notre exemple cela donne timeit(f,arg1,arg2,arg3=toto,argblah=blih).

Exécutez-la pour n=35 en calculant le temps d'exécution.

Comme vous pouvez le constater l'execution est relativement lente : elle prend plusieurs secondes. Cette manière de faire n'est pas efficace car lors du calcul de fibo(n), fibo(n-1) et fibo(n-2) sont appelées, mais fibo(n-1) appelle elle-même fibo(n-2) qui est donc recalculée. Vous pouvez voir ci-dessous l'arbre des appels récursifs (image tirée de pixees.fr) :

Pour éviter cela, on peut commencer par calculer les petites valeurs de la suite et remonter petit à petit vers les plus grandes, de manière à ce que chaque valeur ne soit calculée qu'une seule fois.

Écrivez la fonction def fibo_seq(n) qui calcule $F_n$ en stockant les valeurs calculées au fur et à mesure dans un tableau. Calculez son temps d'exécution avec timeit, toujours pour $n=35$.
On pourra remarquer qu'il n'est en réalité pas nécessaire de conserver en mémoire toutes les valeurs, une fois que les valeurs pour $n-1$ et $n-2$ sont calculées, on peut oublier toutes celles d'avant.

Principe général : la mémoization

Reprenons notre implémentation récursive fibo de la fonction de Fibonacci, idéalement, on souhaiterait pouvoir la conserver telle quelle et trouver un moyen de ne pas faire les appels récursifs inutiles.

Un moyen de faire ça est d'avoir un dictionnaire dans lequel on stockerait après chaque calcul le résultat de ce calcul.

Ecrivez une fonction def memoize(f): qui prend en argument une fonction f et qui renvoie une nouvelle fonction f_memoizee construite à partir de f : celle-ci vérifiera si elle a déjà été appelée avec ces arguments grâce à un dictionnaire, si c'est le cas, elle utilise le resultat qui y a été stocké, sinon elle appelle f afin d'effectuer le calcul.
Testez le temps pris par la fonction fibo, définie ci-dessous, pour $n=35$ :

fibo = memoize(fibo)
				
Attention, il est important que la fonction memoizee aie le même nom que la fonction à mémoiser, sinon seule le premier appel sera mémoizé et pas les suivants.

On notera que dans le cas de Fibonacci, la mémoization utilise plus de mémoire que la version la plus "optimisée" de calcul qui ne retient que les deux dernières valeurs calculées : la mémoization retient toutes les valeurs calculées, ce qui n'est pas toujours nécessaire.

Le principe de mémorisation des données souvent réutilisées est fondamental en informatique. On appelle mémoire cache la mémoire qui y est dédiée et le fait d'y stocker des données les cacher. La mémoire cache est utilisée par exemple :

Tous les chemins les plus courts dans un graphe

Notre première "vraie" application de la programmation dynamique va consister à trouver un algorithme en $O(n^3)$ qui calcule les chemins les plus courts entre tous les sommets d'un graphe ne contenant pas de cycle négatif.

Quelle est la complexité de l'algorithme naïf pour calculer tous les plus courts chemins : celui qui consiste à utiliser Dijkstra pour chaque couple de sommets ?

Dans le cas de Fibonacci, on pouvait déduire les sous-problèmes à ne pas recalculer de la relation de récurrence, qui était facile à trouver, étant donné qu'il s'agit de la définition même. En règle générale trouver une relation entre un problème et ses sous-problèmes n'est pas forcément évident, et il faut trouver la "bonne" relation. Il n'y a pas de recette miracle pour trouver celle-ci malheureusement. Celle-ci découle souvent d'une observation sur comment passer d'un problème plus petit à un problème plus grand.

Pour le calcul de tous les chemins les plus courts dans un graphe, nous allons nous baser sur l'observation suivante : prenons un graphe à $k$ sommets $\{1,\dots,k\}$, et $d^k(i,j)$ le poids du chemin minimal pour aller de $i$ à $j$ (et $\infty$ sinon). On notera $p_{i,j}$ le poids de l'arête $(i,j)$ (qui vaudra $\infty$ si elle n'existe pas).
Si on ajoute un sommet $(k+1)$ au graphe et des arêtes de $(k+1)$ vers les autres sommets, alors il faut recalculer les chemins les plus courts :

Implémentez le calcul de tous les plus courts chemins dans la classe Graphe du fichier graphe.py dans la methode tous_plus_courts(self). Cette méthode renverra un tableau bidimensionnel m de taille $n\times n$ contenant en m[i][j] le poids du plus court chemin de $i$ vers $j$ (ou $\infty$ si $j$ n'est pas accessible à partir de $i$).

Vous pourrez tester le résultat de votre code en comparant certains poids m[i][j] avec le résultat de pcc(i,j), qui calcule le plus court chemin de $i$ à $j$ avec l'algorithme de Dijkstra. Nous vous avons également fourni une fonction permettant de générer un graphe aléatoire : Graphe.GrapheAleatoire.

L'algorithme présenté ici est une légère variante de l'algorithme de Floyd-Warshall.

Plus grand sous tableau

On a en entrée un tableau $T$ de $n$ entiers (positifs et négatifs), et on souhaite trouver le sous-tableau (non-vide) dont la somme des éléments est maximale. Par exemple, si le tableau est [-2,2,-1,3,-4,1], le plus grand sous tableau est [2,-1,3] dont la somme est 4.

Un algorithme naïf pour trouver le sous-tableau maximal est de parcourir tous les $0\leq i\leq j\lt n$ et de trouver le couple qui définit le sous-tableau de somme maximale. Un tel algorithme aurait une complexité $\mathcal{O}(n^2)$.

Trouvez un algorithme de complexité linéaire qui trouve le sous-tableau de somme maximale et renvoie la valeur de la somme de ses éléments.

Seam Carving

Un exemple intéressant et visuel de programmation dynamique est le Seam Carving, une technique de redimensionnement d'images où l'on essaye de conserver les parties intéressantes. Voici un exemple d'image original et d'image redimensionnée en largeur :


Le seam carving peut faire l'objet d'un devoir maison ou TP intéressant.

Recherche de mots dans un texte

Un problème que nous rencontrons quasiment tous les jours est celui de la recherche de mots dans un texte. Nous allons voir ici un algorithme très simple qui permet de chercher un (ou plusieurs) mot dans un texte de manière efficace en moyenne.

Pour cela nous allons utiliser une fonction de hachage déroulant : une fonction de hachage qui à partir du hachage d'un mot $w_0\dots w_k$ permette facilement de calculer le hachage du mot $w_1\dots w_{k+1}$. L'idée étant que le hachage a une partie en commun, celle correspondant à $w_1\dots w_k$. Le principe est illustré par la figure suivante (tirée de ) :

Rolling Hash

Un exemple de telle fonction peut être le suivant : \[ h(w_0\dots w_k) = \sum_{i=0}^k w_i b^{k-i} \mod p \] où $b$ et $p$ sont des entiers bien choisis (de manière à éviter que trop de collisions), on a alors la relation suivante : \[ h(w_1\dots w_{k+1}) = \left(h(w_0\dots w_k)-b^k w_0\right)\cdot b+w_{k+1} \mod p \]

L'idée de l'algorithme de Rabin-Karp est alors de calculer le hachage du mot que l'on cherche $w$ et de le comparer au hachage des mots à chaque position dans le texte. Si les hachages correspondent, on doit alors comparer les mots pour être sûrs qu'ils sont égaux.

Si la fonction de hachage est bien choisie, les faux positifs sont rares et on fait très peu de comparaisons de mots. Les comparaisons de mots étant coûteuses, contrairement aux comparaisons d'entiers, l'algorithme est en général efficace.

Un autre avantage de cet algorithme est qu'il permet facilement de chercher plus d'un seul mot dans un texte, sans perte d'efficacité.

Implémentez la fonction cherche_mot(mot,texte) du fichier texte.py qui cherche le mot mot dans le texte texte en utilisant $b=11$ et $p=2^{16}$. Nous vous avons fourni un exemple de texte et deux mots.
Cet algorithme n'est pas le plus efficace dans le pire des cas, mais sa complexité en moyenne est optimale. En pratique, lorsque l'on souhaite chercher un seul mot d'autres algorithmes sont privilégiés : Knuth-Morris-Pratt, Boyer-Moore,Boyer-Moore-Horspool entre autres.