Spécification : l'argument
M est une liste de
n listes de
p nombres, avec
n≥1 et
p≥1. La valeur renvoyée est une liste nouvelle de longueur
np, dont l'élément d'indice
ip+j vaut le coefficient d'indices
(i,j) de
M, pour
0≤i≤n−1 et
0≤j≤p−1. La matrice
M n'est pas modifiée, et aucune de ses lignes n'est partagée avec le résultat, puisque le résultat ne contient que des nombres.
def aplatir(M):
"""M est une liste de n listes de p nombres, avec n >= 1 et p >= 1. Renvoie la liste
des np coefficients de M, lus ligne par ligne et de gauche à droite. Ne modifie pas M."""
resultat = []
for ligne in M:
for coefficient in ligne:
resultat.append(coefficient)
return resultat
Correction. Notons
Mi,j le coefficient d'indices
(i,j), et montrons l'invariant suivant de la boucle extérieure : avant le tour qui traite la ligne d'indice
i, la liste
resultat contient exactement les coefficients des lignes d'indices
0 à
i−1, rangés par indice de ligne croissant puis par indice de colonne croissant, et sa longueur vaut
ip.
Avant le premier tour, on a
i=0 : la liste
resultat est vide, et aucune ligne ne précède la ligne d'indice
0, donc l'invariant est vrai.
Supposons-le vrai avant le tour d'indice
i, avec
0≤i≤n−1. La boucle intérieure parcourt les coefficients de la ligne
M[i] dans l'ordre des colonnes croissantes, et chaque tour ajoute le coefficient courant à la fin de
resultat par
append, sans rien retirer. À la fin de la boucle intérieure, la liste
resultat s'est donc allongée, dans l'ordre, des
p coefficients
Mi,0,Mi,1,…,Mi,p−1 : elle contient les coefficients des lignes d'indices
0 à
i, rangés comme annoncé, et sa longueur vaut
ip+p=(i+1)p. L'invariant est donc vrai avant le tour d'indice
i+1.
La boucle extérieure parcourt les
n lignes de
M, en nombre fini, donc elle s'arrête. Après le dernier tour, l'invariant appliqué à
i=n donne que
resultat contient exactement les coefficients des
n lignes, rangés ligne par ligne et de gauche à droite, et que sa longueur vaut
np. C'est la liste demandée, et c'est elle qui est renvoyée.
La matrice n'est pas modifiée, puisque
M et ses lignes n'apparaissent qu'en lecture : aucune écriture
M[i][j] = ... ni aucun appel de méthode modifiant une ligne ne figure dans le code.
Vérification :
M = [[1, 2, 3], [4, 5, 6]]
print(aplatir(M))
print(M)
print(aplatir([[7]]))
Sortie, les points de suspension tenant la place des trois lignes de mesures affichées plus bas, dont les durées dépendent de la machine :
[1, 2, 3, 4, 5, 6]
[[1, 2, 3], [4, 5, 6]]
[7]
...
Le cas
n=p=1 est le plus petit autorisé par la spécification, et il se traite sans clause particulière.
Coût : la boucle intérieure tourne
p fois pour chacune des
n lignes, soit
np tours, chacun formé d'un
append, de coût constant amorti. Le coût est donc
O(np), et c'est le coût minimal de toute fonction qui lit tous les coefficients. La mémoire occupée en plus est celle du résultat, soit
np cases.
Comparaison avec la concaténation. On remplace la boucle intérieure par une concaténation de la ligne entière :
def aplatir_par_concatenation(M):
"""Même résultat que aplatir, mais par concaténation au lieu d'accumulation."""
resultat = []
for ligne in M:
resultat = resultat + ligne
return resultat
Le résultat est le même, mais le coût ne l'est pas. La concaténation ne modifie aucune liste : elle en construit une nouvelle et recopie tout ce qu'elle contient. Au tour d'indice
i, la liste
resultat compte
ip éléments et la ligne en compte
p, donc ce tour recopie
ip+p éléments. Le nombre total de recopies vaut
∑i=0n−1(ip+p)=p∑i=0n−1(i+1)=p⋅2n(n+1),
soit un coût en
Θ(pn2), quadratique en le nombre de lignes, là où la première version est linéaire en le nombre de coefficients.
Mesure, sur une machine donnée, pour
p=10 et trois valeurs de
n. Chaque durée est une moyenne sur plusieurs appels, car un appel isolé est trop bref pour être mesuré proprement.
import time
def duree_moyenne(f, M, repetitions):
"""Renvoie la durée moyenne, en secondes, d'un appel de f sur M."""
debut = time.perf_counter()
for _ in range(repetitions):
f(M)
return (time.perf_counter() - debut) / repetitions
for n in (1000, 2000, 4000):
grande = [[0] * 10 for _ in range(n)]
rapide = duree_moyenne(aplatir, grande, 20)
lente = duree_moyenne(aplatir_par_concatenation, grande, 10)
print(n, round(rapide, 5), round(lente, 4))
Les durées relevées au cours d'une exécution ont été d'environ
0,0004,
0,0005 et
0,0011 seconde pour la première fonction, contre
0,0058,
0,023 et
0,092 seconde pour la seconde. À chaque doublement de
n, la seconde colonne est multipliée par environ quatre, ce qui est la signature d'un coût quadratique, alors que la première croît à peu près comme
n et reste du même ordre de grandeur. Pour
n=4000, le rapport entre les deux temps dépasse déjà quatre-vingts.
À retenir : on initialise la liste avant la boucle, on ajoute avec
append à l'intérieur, on renvoie à l'extérieur ; l'invariant s'écrit avant la preuve et dit ce que contient le résultat après
i lignes ; et une concaténation placée dans une boucle change la classe de complexité sans changer le résultat.