Utilisation des listes

Triangle de Pascal

  • Utilité : à connaître car exercice classique réduction de complexité spatiale.
  • Difficulté : facile

Formule du coefficient binomial dit du triangle de Pascal, avec $1\leq k \leq n$ :

$$ \binom{n}{k} = \binom{n-1}{k-1} \mathrel{+} \binom{n-1}{k} $$

et :

$$ \binom{n}{0} = \binom{n}{n} = 1 \text{ pour tout } n\geq 0 $$

On a déjà vu l'approche récursive. Passons à l'approche itérative.

Calcul de la matrice

Le type matrice ne spécifie pas la longueur de chaque tableau, ceci permet de créer des matrices triangulaires inférieures, dans notre cas, une partie du triangle de Pascal :

$$ \begin{align*} &\binom{0}{0} \\ &\binom{1}{0} \binom{1}{1} \\ &\binom{2}{0} \binom{2}{1} \binom{2}{2} \\ &\binom{3}{0} \binom{3}{1} \binom{3}{2} \binom{3}{3} \\ &\binom{4}{0} \binom{4}{1} \binom{4}{2} \binom{4}{3} \binom{4}{4} \\ &\binom{5}{0} \binom{5}{1} \binom{5}{2} \binom{5}{3} \binom{5}{4} \binom{5}{5} \\ \end{align*} $$

Créez un algorithme rendant une matrice triangulaire inférieure $B$ telle que $B[n][k] = \binom{n}{k}$.

Sa signature devra être :

algorithme binom_matrice(n: entier)  [[entier]]:

Vous pourrez créer itérativement chaque ligne en utilisant la ligne créé à l'itération précédente.

corrigé

Première version qui calcule toute la matrice triangulaire inférieure :

algorithme binom_matrice(n: entier)  [[entier]]:
    matrice := [[entier]]
    matrice  [[entier]]{longueur: n+1}

    ligne := [entier]
    pour chaque i de [0 .. n]:
        ligne  [entier]{longueur: i+1} 

        matrice[i]  ligne
        pour chaque j de [0 .. i]:
            si (j == i) ou (j == 0):
                ligne[j]  1
            sinon:
                précédent  matrice[i-1]
                ligne[j]  précédent[j-1] + précédent[j]

    rendre matrice

Il y a deux boucles imbriquées, donc deux invariants à trouver !

L'invariant de la boucle 4-13 peut être :

Invariant de la boucle 4-13 : matrice[i-1] contient la $i$ème ligne de la matrice triangulaire inférieure de Pascal.

Pour le prouver, il faut trouver un invariant à la boucle 8-13. Par exemple :

Invariant de la boucle 8-13 : si matrice[i-2] contient la $i-1$ème ligne de la matrice triangulaire inférieure de Pascal, alors ligne contient la $i$ème ligne de la matrice triangulaire inférieure de Pascal.

Ce dernier invariant est évidemment vrai par construction de la boucle (c'est la relation de récurrence). Une fois la boucle 8-13 prouvée, cela prouve l'invariant de la boucle 4-13.

Utilisez l'algorithme binom_matrice(n: entier) → [[entier]] pour créer l'algorithme itératif de signature :

algorithme binom(n: entier, k:entier)  entier

calculant $\binom{n}{k}$.

Donnez-en sa complexité spatiale et temporelle.

corrigé

On utilise alors l'algorithme précédent pour déterminer la binomiale :

algorithme binom(n: entier, k:entier)  entier:
    matrice  binom_matrice(n)

    rendre matrice[n][k]

La complexité est en $\mathcal{O}(1)$ plus la complexité de la fonction binom_matrice(n: entier) → [[entier]].

En utilisant la règle de calcul de complexité sur les boucles dépendantes mais croissantes, cette complexité est en $\mathcal{O}(n^2)$.

On en déduit que la complexité de l'algorithme binom est en $\mathcal{O}(nk)$. Comme il faut de plus stocker toute la matrice triangulaire inférieure, sa complexité spatiale est en $\mathcal{O}(nk)$

Deux lignes suffisent

Comme l'algorithme binom_matrice(n: entier) → [[entier]] n'a besoin que de la ligne précédente pour créer la ligne de l'itération actuelle, on peut améliorer la complexité spatiale de l'algorithme binom(n: entier, k:entier) → entier :

Créez l'algorithme ligne_suivante(l: [entier]) → [entier] qui à partir de la liste $l= [\binom{n}{0}, \dots, \binom{n}{n}]$ rend la liste $[\binom{n+1}{0}, \dots, \binom{n+1}{n+1}]$

Donnez-en sa complexité spatiale et temporelle.

corrigé

On va créer un algorithme qui rend uniquement la dernière ligne de la matrice :

algorithme ligne_suivante(l: [entier])  [entier]:
    (l2 := [entier])  [entier]{longueur: l.longueur +1}

    l2[0]  1
    l2[-1]  1

    pour chaque i de [1 .. l2.longueur - 1[:
        l2[i]  l[i] + l[i-1]

    rendre l2

L'algorithme ligne_suivante(l: [entier]) → [entier] est correct si $l= [\binom{n}{0}, \dots, \binom{n}{n}]$ puisque on applique directement l'équation de récursion. Sa complexité spatiale et temporelle est en $\mathcal{O}(l.\text{\small longueur})$.

Utilisez l'algorithme ligne_suivante(l: [entier]) → [entier] pour améliorer la complexité spatiale de l'algorithme binom(n: entier, k:entier) → entier.

corrigé

On a alors la version améliorée de binom(n: entier, k:entier) → entier :

algorithme binom(n: entier, k:entier)  entier:
    (l := [entier])  [0]
    répéter n fois:
       l  ligne_suivante(l)

    rendre l[k]

Si la complexité temporelle ne change pas, la complexité spatiale est maintenant de $\mathcal{O}(l.\text{\small longueur})$.

Enfin, pour calculer binom(n: entier, k:entier) → entier on a pas besoin de toute la ligne de la matrice :

Modifiez l'algorithme ligne_suivante pour le rendre de signature ligne_suivante(l: [entier], k: entier) → [entier] tel que si $l= [\binom{n}{0}, \dots, \binom{n}{\min(k, n)}]$ rend le tableau $[\binom{n+1}{0}, \dots, \binom{n+1}{\min(k, n+1)}]$.

Utilisez le dans binom(n: entier, k:entier) → entier pour que sa complexité spatiale soit en $\mathcal{O}(k)$.

corrigé

On peut faire mieux en ne calculant que les $k+1$ premiers éléments de chaque ligne. Il faut pour cela modifier l'algorithme en créant une version alternative de ligne_suivante : ligne_suivante(l: [entier]) → [entier]

fonction ligne_suivante(l: [entier], k: entier)  [entier]:
    (l2 := [entier])  [entier]{longueur: min(k + 1, l.longueur + 1)}

    l2[0]  1
    l2[-1]  1

    pour chaque i de [1 .. l.longueur[:
        l2[i]  l[i] + l[i-1]

    rendre l2

algorithme binom(n: entier, k:entier)  entier:
    (l := [entier])  [0]
    répéter n fois:
       l  ligne_suivante(l, k)

    rendre l[k]

Une ligne suffit

La partie précédente montre que l'on peut se passer de matrices et n'utiliser que 2 lignes que l'on échange itérativement. On peut encore faire mieux en n'utilisant qu'une seule ligne !

On va ici utiliser un tableau de taille $k + 1$ qui va contenir tout ce qu'on a besoin, l'astuce étant de commencer à remplir la ligne à la fin.

En remplissant la ligne courante de droite à gauche montrez que l'on peut modifier ligne_suivante pour le rendre de signature ligne_suivante(l: [entier], n:entier k: entier) → [entier] telle que si $l= [\binom{n}{0}, \dots, \binom{n}{\min(k, n)}]$ en entrée, elle est modifiée en la liste $[\binom{n+1}{0}, \dots, \binom{n+1}{\min(k, n+1)}]$.

Utilisez le dans binom(n: entier, k:entier) → entier.

corrigé

fonction ligne_suivante(l: [entier], n: entier, k: entier) :
    de j=min(n, k) à j=1 par pas de -1:
        l[j]  l[j] + l[j-1]

algorithme binom(n: entier, k:entier)  entier:
    (l := [entier])  [entier]{longueur: k+1}
    l[:]  1

    pour j dans [0 .. n-1] fois:
       ligne_suivante(l, j, k)

    rendre l[k]

Cette dernière optimisation ne change pas la complexité spatiale en $\mathcal{O}(k)$, elle ne fait que la diminuer par 2. Cette optimisation est cependant significative si $k$ est grand et, surtout, rend l'algorithme très élégant, avec une seule boucle et un seul tableau.

Code

Codez les différents exercices en python. Vous pourrez vérifier vos algorithmes en testant que $\binom{10}{4} = 210$

corrigé

8 reines

  • Utilité : à connaître car problème historique
  • Difficulté : moyen

Anecdote cocasse

Quand le CEA, qui avait financé les travaux, a demandé à Colmerauer un exemple de problème pratique que pouvait résoudre son nouveau langage Prolog, il a répondu le problème des 8 reines.

Pas sûr que les deux interlocuteurs aient la même notion de ce qu'était un "problème pratique".

Ce problème initial consiste à placer 8 reines sur un échiquier (possédant 8 lignes et 8 colonnes) sans qu'aucune reine ne puisse en prendre une autre. Une reine peut prendre toute pièce qui est sur sa ligne, sa colonne ou sur ses diagonales.

Nous verrons ici un cas un peu plus général consistant à placer $n$ reines sur un échiquier à $n$ lignes et $n$ colonnes. On vous demande de donner la complexité de vos algorithmes en fonction de cette taille $n$.

Modélisation

On modélise l'échiquier par une matrice E à $n$ lignes et $n$ colonnes ($n=8$ pour un échiquier traditionnel): E[i][j] correspond à la case à l'intersection de la ligne i et de la colonne j. Cette case est vraie si une reine y est placée et fausse sinon.

Créez un algorithme de signature échiquier(n: entier) → [[booléen]] permettant de créer un échiquier $n \times n$ vide.

corrigé

algorithme échiquier(n: entier)  [[booléen]]:
    (E := [[booléen]])  [[booléen]]{longueur: n}

    pour chaque i de [0 .. n[:
        E[i]  [booléen]{longueur: n}
        E[:]  Faux

    rendre E

Écrivez un algorithme de signature position_correcte(E: [[booléen]], i: entier, j: entier) → booléen permettant de savoir si on peut placer une reine à la ligne i et la colonne j pour un échiquier donné (de taille quelconque). Elle rendra Vrai si la reine peut être placée et Faux sinon.

corrigé

On regarde les lignes, les colonnes et les diagonales. Il faut faire attention à :

  • ne pas regarder la case (i, j)
  • rester dans les bornes de l'échiquier
algorithme position_correcte(E: [[booléen]], i: entier, j: entier)  booléen:
    (n := entier)  E.longueur
    k := entier

    pour k dans [0 .. n[:
        si (k i) ET E[k][j]:
            rendre Faux

    pour k dans [0 .. n[:
        si (k  j) ET E[i][k]:
            rendre Faux

    pour k dans [1 .. n[:
        si (i + k < n) ET (j + k < n) ET E[i + k][j + k]:
            rendre Faux
        si (i + k < n) ET (j - k > -1) ET E[i + k][j - k]:
            rendre Faux
        si (i - k > -1) ET (j + k < n) ET E[i - k][j + k]:
            rendre Faux
        si (i - k > -1) ET (j - k > -1) ET E[i - k][j - k]:
            rendre Faux
        
    rendre Vrai

Quelle est la complexité de votre algorithme position_correcte(E: [[booléen]], i: entier, j: entier) → booléen ?

corrigé

Elle est en $\mathcal{O}(n)$ avec $n$ le nombre de lignes de l'échiquier.

En déduire un algorithme de signature échiquier_correct(E: [[booléen]]) → booléen qui, à partir d'un échiquier de taille quelconque $n$, rend Vrai si l'échiquier résout le problème des 8 reines et Faux sinon.

corrigé

algorithme échiquier_correct(E: [[booléen]])  booléen:
    (n := entier)  E.longueur

    pour (i:= entier) de [0 .. n[:
        pour (j:= entier) de [0 .. n[:
            si E[i][j] ET (NON position_correcte(E, i, j)):
                rendre Faux
        
    rendre Vrai

Quelle est la complexité de votre algorithme échiquier_correct(E: [[booléen]]) → booléen ?

corrigé

Elle est en $\mathcal{O}(n^2)$ avec $n$ le nombre de lignes de l'échiquier.

Solution par énumération naïve

Comme toute solution correcte ne peut contenir plus d'une reine par ligne, on peut se contenter de ne générer que toutes les positions avec exactement 1 reine par ligne.

Créez un algorithme récursif génère récursivement tous les échiquiers de taille $n$ avec exactement 1 reine par ligne et affiche à l'écran ceux qui sont corrects.

Vous pourrez créer un algorithme de signature tous_les_échiquiers_rec(E: [[booléen]], i: entier) qui prend en entrée un échiquier E donné de taille $n$ ayant une reine sur chaque ligne d'indice strictement inférieur à $i$. Cet algorithme vérifie que $E$ est correct si $i\geq n$ et sinon génère les reines de la ligne $i$ et appelle tous_les_échiquiers_rec(E, i + 1) pour générer les autres

corrigé

algorithme tous_les_échiquiers_rec(E: [[booléen]], i: entier):
    (n := entier)  E.longueur

    si i  n:
        si échiquier_correct(E):
            affiche E à l'écran
        rendrepour (j := entier) de [0 .. n[:
        E[i][j]  Vrai
        tous_les_échiquiers_rec(E, i+1)
        E[i][j]  Faux

tous_les_échiquiers_rec(échiquier(n), 0)

Quelles sont les échiquiers corrects à 4 lignes ?

corrigé

▢ ♕ ▢ ▢     ▢ ▢ ♕ ▢
▢ ▢ ▢ ♕     ♕ ▢ ▢ ▢
♕ ▢ ▢ ▢     ▢ ▢ ▢ ♕
▢ ▢ ♕ ▢     ▢ ♕ ▢ ▢

Quelle est la complexité de votre algorithme tous_les_échiquiers_rec(E: [[booléen]], i: entier) en supposant que l'affichage se fait en $\mathcal{O}(1)$ ?

corrigé

Il génère tous les échiquiers possibles avec 1 reine par ligne, il y en a $n^n$ et les vérifient en $\mathcal{O}(n^2)$. Sa complexité est donc égale à $\mathcal{O}(n^2 \cdot n^n)$.

On peut le démontrer rigoureusement en résolvant l'équation de complexité satisfaite par l'algorithme, à savoir :

$$ \begin{cases} C(n) = \mathcal{O}(n^2)\\ C(i) = n \cdot C(i+1) \end{cases} $$

Solution par énumération V2

Il n'est souvent pas nécessaire de générer toutes les $n$ reines avant de savoir si la position est correcte ou non (par exemple dès que l'on a deux reines sur une même colonne) :

En utilisant l'algorithme position_correcte(E: [[booléen]], i: entier, j: entier) → booléen la partie précédente, améliorez l'algorithme tous_les_échiquiers_rec(E: [[booléen]], i: entier) en arrêtant l'ajout de reines dès que la position est incorrecte.

corrigé

algorithme tous_les_échiquiers_rec(E: [[booléen]], i: entier):
    (n := entier)  E.longueur

    si i  n:
        affiche E à l'écran
        rendrepour (j := entier) de [0 .. n[:
        
        si position_correcte(E, i, j):
          E[i][j]  Vrai
          tous_les_échiquiers_rec(E, i+1)
          E[i][j]  Faux

tous_les_échiquiers_rec(échiquier(n), 0)

Quelle est la complexité de votre algorithme tous_les_échiquiers_rec(E: [[booléen]], i: entier) en supposant que l'affichage se fait en $\mathcal{O}(1)$ ?

corrigé

On vérifie à chaque positionnement de reines en $\mathcal{O}(i)$, l'équation de complexité satisfaite par l'algorithme est alors :

$$ \begin{cases} C(n) = \mathcal{O}(1)\\ C(i) = n \mathcal{O}(i) \cdot \cdot C(i+1) \end{cases} $$

On retrouve notre complexité de $\mathcal{O}(n^2 \cdot n^n)$.

Solution par permutation

On peut cependant faire mieux que juste créer des échiquiers

Montrez que l'on peut représenter le problème des $n$ reines par une permutation $P$ du tableau [0, ..., n-1].

corrigé

Comme deux reines ne peuvent partager une même ligne ou une même colonne : chaque ligne et chaque colonne possèdent exactement une reine. Le tableau $P$ de longueur $n$ tel que $P[i]$ corresponde à l'indice de la colonne de la reine de la ligne $i$ est une permutation de [0, ..., n-1].

Créez l'algorithme permutation_correcte(P: [entier], i: entier) → booléen qui prend en paramètre une permutation $P$ et un indice de ligne $i$ et qui rend Vrai si la reine à la ligne d'indice $i$ n'est sur aucune diagonales des reine d'indice $j < i$.

corrigé

algorithme permutation_correcte(P: [entier], i:entier)  booléen:
    (n := entier)  P.longueur

    pour (k:= entier) de [0 .. i[:
        si (0  P[i] - i + k < n) ET (P[i] - i + k == P[k]):
            rendre Faux
        si (0  P[i] + i - k < n) ET (P[i] + i - k == P[k]):
            rendre Faux
        
    rendre Vrai

Quelle est la complexité de votre algorithme permutation_correcte(P: [entier], i:entier) → booléen ?

corrigé

Elle est clairement en $\mathcal{O}(n^2)$.

Montrez que l'algorithme suivant permet de résoudre le problème des $n$ reine. Quelle est sa complexité ?

algorithme toutes_les_permutations_rec(P: [entier], i: entier)  booléen:
    (n := entier)  P.longueur

    si i == n:
        affiche_permutation(P)
        return
    pour (j:= entier) dans [i .. n[:
        P[i], P[j]  P[j], P[i]
        Si permutation_correcte(P, i):
            toutes_les_permutations_rec(P, i+1)
        P[i], P[j]  P[j], P[i]

toutes_les_permutations_rec([0 .. n-1])

corrigé

Si $P$ est une permutation et si les $i-1$ premières reines sont placées dans la permutation, les valeurs de $P[i:]$ contiennent toutes les positions restantes possible pour la reine que l'on doit placer à la ligne d'indice $i$. L'algorithme teste alors toutes les possibilités et ne continue que lorsqu'il a trouvé une position cohérente pour la reine de la ligne d'indice $i$.

Quelle est la complexité de votre algorithme toutes_les_permutations_rec(E: [[booléen]], i: entier) en supposant que l'affichage se fait en $\mathcal{O}(1)$ ?

Vous pourrez utiliser le fait que :

$$ \sum_{0 \leq i \leq n} i! \leq \sum_{0 \leq i \leq n} n! \leq (n+1)! $$

corrigé

On vérifie à chaque positionnement de reines en $\mathcal{O}(i)$, l'équation de complexité satisfaite par l'algorithme est alors :

$$ \begin{cases} C(n) = \mathcal{O}(1)\\ C(i) = (n-i) \mathcal{O}(i) \cdot C(i+1) \end{cases} $$

On à alors $C(0) = \mathcal{O}(\sum_{0 \leq i \leq n} i!)$, donc

$$ C(0) = \mathcal{O}((n+1)!) $$

Ce résultat est très bon puisqu'a priori il faut considérer toutes les permutations et les vérifier, ce qui nous prendrait $\mathcal{O}(n^2\cdot n!)$ si on vérifiait uniquement à la fin !

Code

Codez les différents exercices en python. Vérifiez expérimentalement que :

  • l'optimisation de l'algorithme tous_les_échiquiers_rec(E: [[booléen]], i: entier) est pertinente en pratique
  • l'algorithme toutes_les_permutations_rec(E: [[booléen]], i: entier) est plus rapide

Vous pourrez vérifier vos algorithmes en testant qu'il n'y a que 10 échiquiers corrects pour 5 lignes :

♕ ▢ ▢ ▢ ▢     ♕ ▢ ▢ ▢ ▢     ▢ ♕ ▢ ▢ ▢     ▢ ♕ ▢ ▢ ▢     ▢ ▢ ♕ ▢ ▢
▢ ▢ ♕ ▢ ▢     ▢ ▢ ▢ ♕ ▢     ▢ ▢ ▢ ♕ ▢     ▢ ▢ ▢ ▢ ♕     ♕ ▢ ▢ ▢ ▢
▢ ▢ ▢ ▢ ♕     ▢ ♕ ▢ ▢ ▢     ♕ ▢ ▢ ▢ ▢     ▢ ▢ ♕ ▢ ▢     ▢ ▢ ▢ ♕ ▢
▢ ♕ ▢ ▢ ▢     ▢ ▢ ▢ ▢ ♕     ▢ ▢ ♕ ▢ ▢     ♕ ▢ ▢ ▢ ▢     ▢ ♕ ▢ ▢ ▢
▢ ▢ ▢ ♕ ▢     ▢ ▢ ♕ ▢ ▢     ▢ ▢ ▢ ▢ ♕     ▢ ▢ ▢ ♕ ▢     ▢ ▢ ▢ ▢ ♕


▢ ▢ ♕ ▢ ▢     ▢ ▢ ▢ ♕ ▢     ▢ ▢ ▢ ♕ ▢     ▢ ▢ ▢ ▢ ♕     ▢ ▢ ▢ ▢ ♕
▢ ▢ ▢ ▢ ♕     ♕ ▢ ▢ ▢ ▢     ▢ ♕ ▢ ▢ ▢     ▢ ♕ ▢ ▢ ▢     ▢ ▢ ♕ ▢ ▢
▢ ♕ ▢ ▢ ▢     ▢ ▢ ♕ ▢ ▢     ▢ ▢ ▢ ▢ ♕     ▢ ▢ ▢ ♕ ▢     ♕ ▢ ▢ ▢ ▢
▢ ▢ ▢ ♕ ▢     ▢ ▢ ▢ ▢ ♕     ▢ ▢ ♕ ▢ ▢     ♕ ▢ ▢ ▢ ▢     ▢ ▢ ▢ ♕ ▢
♕ ▢ ▢ ▢ ▢     ▢ ♕ ▢ ▢ ▢     ♕ ▢ ▢ ▢ ▢     ▢ ▢ ♕ ▢ ▢     ▢ ♕ ▢ ▢ ▢

Et seulement 4 pour 6 lignes :

▢ ♕ ▢ ▢ ▢ ▢      ▢ ▢ ♕ ▢ ▢ ▢      ▢ ▢ ▢ ♕ ▢ ▢      ▢ ▢ ▢ ▢ ♕ ▢
▢ ▢ ▢ ♕ ▢ ▢      ▢ ▢ ▢ ▢ ▢ ♕      ♕ ▢ ▢ ▢ ▢ ▢      ▢ ▢ ♕ ▢ ▢ ▢
▢ ▢ ▢ ▢ ▢ ♕      ▢ ♕ ▢ ▢ ▢ ▢      ▢ ▢ ▢ ▢ ♕ ▢      ♕ ▢ ▢ ▢ ▢ ▢
♕ ▢ ▢ ▢ ▢ ▢      ▢ ▢ ▢ ▢ ♕ ▢      ▢ ♕ ▢ ▢ ▢ ▢      ▢ ▢ ▢ ▢ ▢ ♕
▢ ▢ ♕ ▢ ▢ ▢      ♕ ▢ ▢ ▢ ▢ ▢      ▢ ▢ ▢ ▢ ▢ ♕      ▢ ▢ ▢ ♕ ▢ ▢
▢ ▢ ▢ ▢ ♕ ▢      ▢ ▢ ▢ ♕ ▢ ▢      ▢ ▢ ♕ ▢ ▢ ▢      ▢ ♕ ▢ ▢ ▢ ▢

corrigé

Généralisation

Multiplication de matrices

Le problème de la multiplication de matrice est un problème intéressant avec une grande histoire. Nous allons montrer ici la méthode naïve et le plus célèbre des algorithmes, celui de Strassen. Notez qu'actuellement on peut faire mieux.

Création

Écrivez un algorithme de signature création_matrice(n: entier, m: entier) → [[entier]] qui crée une matrice à $n$ lignes et $m$ colonnes dont les valeurs valent toutes 0.

corrigé

algorithme création_matrice(n: entier, m: entier)   [[entier]]:
    M := [[entier]]
    M   [[entier]]{longueur: n}
    pour i dans [0 .. m[:
        M[i]  [entier]{longueur: m}
        M[i][:]  0

    rendre M

Écrivez un algorithme de signature extraction_matrice(M: [[entier]], n1: entier, n2: entier, m1: entier, m2: entier) → [[entier]] qui rend une nouvelle matrice valant M[n1:n2][m1:m2]

corrigé

algorithme extraction_matrice(M: [[entier]], n1: entier, n2: entier, m1: entier, m2: entier)   [[entier]]:
    C = création_matrice(n2-n1, m2-m1)
    pour chaque i de [n1, n2[:
        pour chaque j de [m1, m2[:
            C[i][j] = M[i][j]
    rendre C

Somme

Écrivez un algorithme de signature somme(A: [[entier]], B: [[entier]]) → [[entier]] qui rend une nouvelle matrice valant A + B

corrigé

algorithme somme(A: [[entier]], B: [[entier]])   [[entier]]:
    C  création_matrice(A.longueur, A[0].longueur)

    pour (i:= entier) dans [0 .. C.longueur[:
        pour (j:= entier) dans [0 .. C[0].longueur[:
            C[i][j]  A[i][j] + B[i][j]
    rendre C

Multiplication Naive

Écrivez l'algorithme naïf de la multiplication de deux matrices (sa complexité sera en $\mathcal{O}(n^3)$ avec $n$ le maximum du nombre de ligne et de colonnes dex deux matrices à multiplier). Il devra être de signature Écrivez un algorithme de signature multiplication(A: [[entier]], B: [[entier]]) → [[entier]]

corrigé

algorithme multiplication(A: [[entier]], B: [[entier]])  [[entier]]:
    C  création_matrice(A.longueur, B[0].longueur)

    pour (i:= entier) dans [0 .. C.longueur[:
        pour (j:= entier) dans [0 .. C[0].longueur[:
            pour k dans [0 .. B.longueur[:
                C[i][j]  C[i][j] + A[i][k] * B[k][j]
    rendre C

Multiplication de matrices carrées

Montrer que pour le cas d'une multiplication de 2 matrices carrées à $n$ lignes, on peut toujours se ramener au cas où les deux matrices on un nombre de lignes égale à une puissance de 2 sans changer la complexité.

corrigé

Si $n$ est un entier, il existe $p$ tel que $ 2^{p-1} < n \leq 2^p$, donc $2^p\leq 2n$. Si $A$ est une matrice carrée à $n$ on peut alors créer la matrice $A'$ à $2^p$ lignes telles que :

$$ A'=\begin{pmatrix} A & 0\\ 0 & 0 \end{pmatrix} $$

Et on aura clairement $A \cdot B = (A' \cdot B')[:n][:n]$. Enfin, comme $A'$ a au pire 4 fois plus d'éléments que $A$, les complexités vont rester les même en $\Theta$.

On va donc considérer pour la suite de ces exercices que les matrices carrées ont un nombre de lignes égale à une puissance de 2.

Avant de décrire d'algorithme de Strassen (pour les matrices carrées avec un nombre de lignes égale à une puissance de 2), commençons par en voir le principe en remarquant que l'on peut écrire la multiplication de 2 matrices carrées $A$ et $B$ ainsi :

$$ \begin{matrix} & \begin{pmatrix} B_{11} & B_{12}\\ B_{21} & B_{22}\\ \end{pmatrix} \\ \begin{pmatrix} A_{11} & A_{12}\\ A_{21} & A_{22}\\ \end{pmatrix} & \begin{pmatrix} C_{11} & C_{12}\\ C_{21} & C_{22}\\ \end{pmatrix} \\ \end{matrix} $$

Avec $A_{ij}$, $B_{ij}$ et $C_{ij}$ des matrices carrées telles que pour $1\leq i, j \leq 2$ : $C_{ij} = A_{i1} \cdot B_{1j} + A_{i2} \cdot B_{2j}$

Écrivez un algorithme récursif de multiplication de matrices carrées (avec un nombre de lignes égale à une puissance de 2) de signature multiplication_rec(A: [[entier]], B: [[entier]]) → [[entier]] utilisant les matrices $A_{ij}$, $B_{ij}$ et $C_{ij}$.

corrigé

algorithme multiplication_rec(A: [[entier]], B: [[entier]])  [[entier]]:
    (n := entier)  A.longueur

    Si n  1:
        rendre [[A[0][0] * B[0][0]]]
    
    (A11 := [[entier]])  [[entier]{longueur: n // 2}]{longueur: n // 2}
    A11[:][:]  A[:n //2][:n //2]
    (A12 := [[entier]])  [[entier]{longueur: n // 2}]{longueur: n // 2}
    A12[:][:]  A[:n //2][n //2:]
    (A21 := [[entier]])  [[entier]{longueur: n // 2}]{longueur: n // 2}
    A21[:][:]  A[n //2:][:n //2]
    (A22 := [[entier]])  [[entier]{longueur: n // 2}]{longueur: n // 2}
    A22[:][:]  A[n //2:][n //2:]

    (B11 := [[entier]])  [[entier]{longueur: n // 2}]{longueur: n // 2}
    B11[:][:]  B[:n //2][:n //2]
    (B12 := [[entier]])  [[entier]{longueur: n // 2}]{longueur: n // 2}
    B12[:][:]  B[:n //2][n //2:]
    (B21 := [[entier]])  [[entier]{longueur: n // 2}]{longueur: n // 2}
    B21[:][:]  B[n //2:][:n //2]
    (B22 := [[entier]])  [[entier]{longueur: n // 2}]{longueur: n // 2}
    B22[:][:]  B[n //2:][n //2:]

    (C11 := [[entier]])  somme(multiplication_rec(A11, B11), multiplication_rec(A12, B21))
    (C12 := [[entier]])  somme(multiplication_rec(A11, B12), multiplication_rec(A12, B22))
    (C21 := [[entier]])  somme(multiplication_rec(A21, B11), multiplication_rec(A22, B21))
    (C22 := [[entier]])  somme(multiplication_rec(A21, B12), multiplication_rec(A22, B22))

    (C := [[entier]])  [[entier]{longueur: n}]{longueur: n}
    C[:n // 2][:n // 2]  C11[:][:]
    C[:n // 2][n // 2:]  C12[:][:]
    C[n // 2]:[:n // 2]  C21[:][:]
    C[n // 2:][n // 2:]  C22[:][:]

    rendre C

Montrez que la complexité de cet algorithme satisfait l'équation de recurrence :

$$ \begin{cases} C(1) = \Theta(1)\\ C(n) = \Theta(n^2) + 8 C(n // 2) \end{cases} $$

En conclure que sa complexité est en $Theta(n^3)$ où $n$ est le nombre de lignes des matrices.

corrigé

L'équation de complexité est claire car toutes les opérations de création et de some sont en $\Theta(n^2)$ et qu'il y a 8 appels récursifs. En triturant cette équation on trouve que pour tout $i$ on a :

$$ \begin{cases} C(1) = \Theta(1)\\ C(n) = i \cdot \Theta(n^2) + 8^i C(n // 2^i) \end{cases} $$

Et donc au final

$$ \begin{array}{lcl} C(n) &=& \Theta(\ln(n)n^2) + 8^{\log_2(n)}\Theta(1)\\ &=& \Theta(\ln(n)n^2) + 2^{3\log_2(n)}\Theta(1)\\ &=& \Theta(\ln(n)n^2) + (2^{\log_2(n)})^3\Theta(1)\\ &=& \Theta(\ln(n)n^2) + n^3\Theta(1)\\ &=& \Theta(n^3)\\ \end{array} $$

Pour gagner en complexité il faut diminuer le nombre d'appels récursif et c'est exactement ce que fait l'algorithme de Strassen avec une astuce de calcul comme on les aime.

Strassen

L'astuce qu'a utilisé Strassen est peu ou prou la même que celle utilisée par Karastuba pour multiplier des entiers. En reprenant les matrices $A_{ij}$ et $B_{ij}$ précédente, il montre que l'on peut créer les matrices $C_{ij}$ en passant par 7 matrices $M_k$ :

$$ \begin{array}{lcl} M_1 & =& (A_{11} + A_{22}) \cdot (B_{11} + B_{22}) \\ M_2 & =& (A_{21} + A_{22}) \cdot B_{11} \\ M_3 & =& A_{11} \cdot (B_{12} - B_{22}) \\ M_4 & =& A_{22} \cdot (B_{21} - B_{11}) \\ M_5 & =& (A_{11} + A_{12}) \cdot B_{22} \\ M_6 & =& (A_{21} - A_{11}) \cdot (B_{11} + B_{12}) \\ M_7 & =& (A_{12} - A_{22}) \cdot (B_{21} + B_{22}) \end{array} $$

Qui permettent d'écrire les matrices $C_{ij}$ :

$$ \begin{array}{lcl} C_{11} & =& M_1 + M_4 - M_5 + M_7\\ C_{12} & =& M_3 + M_5\\ C_{21} & =& M_2 + M_4\\ C_{22} & =& M_1 - M_2 - M_3 + M_6\\ \end{array} $$

Montrer qu'il suffit de 7 récursion pour écrire l'algorithme de Strassen. En déduire l'équation de complexité que satisfait l'algorithme de Strassen.

corrigé

Il faut une récursion pur chaque matrice M. Comme le reste des opérations est toujours en $\Theta(n^2)$, on obtient l'équation de récursion :

$$ \begin{cases} C(1) = \Theta(1)\\ C(n) = \Theta(n^2) + & \cdot C(n // 2) \end{cases} $$

Montrer que la complexité de l'algorithme de Strassen est en $\mathcal{O}(n^{2.807})$.

corrigé

En triturant l'équation de récursion on obtient :

$$ \begin{cases} C(1) = \Theta(1)\\ C(n) = i \cdot \Theta(n^2) + 7^i C(n // 2^i) \end{cases} $$

Et donc au final

$$ \begin{array}{lcl} C(n) &=& \Theta(\ln(n)n^2) + 7^{\log_2(n)}\Theta(1)\\ &=& \Theta(\ln(n)n^2) + e^{\ln(7)\log_2(n)}\Theta(1)\\ &=& \Theta(\ln(n)n^2) + e^{\ln(7)\ln(n)/\ln(2)}\Theta(1)\\ &=& \Theta(\ln(n)n^2) + e^{\log_2(7)\ln(n)}\Theta(1)\\ &=& \Theta(\ln(n)n^2) + n^{\log_2(7)}\Theta(1)\\ &=& \mathcal{O}(n^{2.807})\\ \end{array} $$

L'astuce qui consiste à remarquer que $p^{\log_k(q)} = q^{\log_k(p)}$ est à garder sous le coude. Cela ne sert pas tout le temps, mais quand ça sert, ça sert.

Code

Implémentez l'algorithme de Strassen et comparez son exécution à une multiplication naive pour des puissances de 2 allant jusqu'à 512. Conclusion ?

corrigé

L'algorithme de Strassen possède bien plus d'additions et de créations des nouvelles matrices que l'algorithme naïf. Asymptotiquement il est meilleur mais il faut que $n$ soit grand pour ˆ´tre effectivement plus rapide : on voit en effet que le rapport de temps entre les 2 temps algorithme augmente (mais très doucement).

C'est pourquoi l'algorithme de Strassen n'est utilisé que pour de grandes matrices et avec d'autres optimisation que ce que l'on a pu faire (notre code python est loin d'être optimal)

Généralisation

Comment généraliseriez vous l'algorithme de Strassen à des matrices non carrées ?

corrigé

  1. On englobe les matrices à multiplier dans des matrices ayant comme dimensions des puissances de 2 en ligne et en colonne.
  2. on stoppe la récursion lorsqu'une des dimensions vaut $2^0 = 1$ et on multiplie des vecteurs ligne ou colonne.

On peut montrer que le calcul de l'inverse d'une matrice ou de son déterminant est de même complexité que celle de la multiplication de matrices. Cela passe par le complément de Schur d'une matrice. Les deux liens ci-après expliquent tout ça très bien :

On peut faire mieux !

La limite théorique de la multiplication de deux matrices carrées à $n$ lignes est en $\mathcal{O}(n^2)$ et l'algorithme de Strassen est en $\mathcal{O}(n^{2.804})$. Il en existe d'autres meilleures, voir les lien ci-après :

Cependant, comme on l'a vu, ces résultats sont asymptotiques et on ne voit la différence que pour des matrices très très grandes. À l'heure où je tape ces lignes, le meilleur algorithme connu date de 2025 et est en $\mathcal{O}(n^{2.271})$.