CHAPITRE 9 : LA MÉMOIRE AUSSI SE COMPTE

Un algorithme consomme deux ressources. On s'intéresse beaucoup au temps ; la mémoire est tout aussi capable de faire tomber un programme : plus brutalement, même.

Espace total, espace auxiliaire

Deux mesures, à ne pas confondre.

  • Espace total : tout ce que le programme occupe, les données d'entrée comprises.
  • Espace auxiliaire : ce que l'algorithme réclame en plus de l'entrée.

C'est le second qui intéresse, parce que l'entrée, on ne la choisit pas.

def somme(tab):
    total = 0            # une variable
    for x in tab:        # pas de nouvelle structure
        total += x
    return total
# espace total     : O(n)  : le tableau est là
# espace auxiliaire: O(1)  : une seule variable

On dira donc que cette fonction est « en espace constant », ou qu'elle travaille sur place.

Quand l'algorithme réclame de la place

def doubler(tab):
    sortie = []              # nouvelle liste
    for x in tab:
        sortie.append(x * 2) # elle grandit jusqu'à n
    return sortie
# espace auxiliaire : O(n)

La version sur place évite ce coût :

def doubler_sur_place(tab):
    for i in range(len(tab)):
        tab[i] *= 2
# espace auxiliaire : O(1)

Ce n'est pas toujours souhaitable : modifier l'entrée du client est parfois inacceptable. Mais quand c'est permis, le gain est franc.

La récursion coûte de la mémoire

Voilà ce qu'on oublie le plus souvent. Chaque appel en attente occupe un cadre sur la pile d'appels : ses paramètres, ses variables locales, l'adresse de retour.

def factorielle(n):
    if n <= 1:
        return 1
    return n * factorielle(n - 1)

Au moment le plus profond, n appels attendent simultanément. L'espace auxiliaire est donc O(n), alors qu'aucune structure de données n'apparaît dans le code.

factorielle(5) attend factorielle(4)
               qui attend factorielle(3)
                          qui attend factorielle(2)
                                     qui attend factorielle(1)
→ 5 cadres empilés en même temps

La version itérative, elle, est en O(1) :

def factorielle(n):
    r = 1
    for i in range(2, n + 1):
        r *= i
    return r
# espace auxiliaire : O(1)

Le débordement de pile. La pile a une taille limitée. En Python, la limite est à 1 000 appels imbriqués par défaut, et on obtient un RecursionError au-delà. En C, le dépassement n'est pas signalé proprement : le programme s'effondre sur une erreur de segmentation. C'est la raison pour laquelle on dérécursive les fonctions appelées sur de grandes entrées.

Récursivité terminale

Quand l'appel récursif est la toute dernière chose que fait la fonction, le cadre courant ne sert plus à rien : certains compilateurs le réutilisent, et la pile reste constante.

# non terminal : il reste la multiplication à faire après l'appel
return n * factorielle(n - 1)

# terminal : l'appel est la dernière action
def factorielle(n, acc=1):
    if n <= 1:
        return acc
    return factorielle(n - 1, n * acc)

Attention : cette optimisation existe en C, en Scala, en OCaml, et dans la plupart des langages fonctionnels, mais pas en Python, dont l'auteur l'a refusée délibérément pour préserver la lisibilité des traces d'erreur. Écrire une récursion terminale en Python ne fait rien gagner.

Quelques espaces de référence

AlgorithmeTempsEspace auxiliaire
Recherche linéaireO(n)O(1)
Dichotomie itérativeO(log n)O(1)
Dichotomie récursiveO(log n)O(log n) : la pile
Tri à bulles, insertion, sélectionO(n²)O(1)
Tri fusionO(n log n)O(n)
Tri rapideO(n log n) moyenO(log n) : la pile
Tri par tasO(n log n)O(1)

Cette colonne de droite explique un choix d'ingénierie réel : le tri par tas et le tri fusion ont le même temps, mais le tri fusion réclame un tableau auxiliaire de taille n. Sur un système contraint en mémoire, le tri par tas gagne. C'est aussi pourquoi le sort() de C++ n'est pas un tri fusion.

L'échange temps contre mémoire

Les deux ressources se négocient presque toujours l'une contre l'autre.

# peu de mémoire, beaucoup de temps : on recalcule
def est_premier(n):
    if n < 2: return False
    i = 2
    while i * i <= n:
        if n % i == 0: return False
        i += 1
    return True
# O(√n) par appel, O(1) de mémoire

# beaucoup de mémoire, peu de temps : on précalcule tout
def crible(limite):
    p = [True] * (limite + 1)
    p[0] = p[1] = False
    for i in range(2, int(limite ** 0.5) + 1):
        if p[i]:
            for j in range(i * i, limite + 1, i):
                p[j] = False
    return p
# O(limite log log limite) une fois, puis O(1) par question
# mais O(limite) de mémoire

Pour une question isolée, la première. Pour un million de questions, la seconde. Il n'y a pas de bonne réponse dans l'abstrait : il y a un usage.

À retenir

  • L'espace auxiliaire, ce qu'on réclame en plus de l'entrée, est la mesure utile.
  • Chaque appel récursif en attente occupe la pile : une récursion de profondeur n coûte O(n) de mémoire, sans qu'aucune structure n'apparaisse.
  • Python n'optimise pas la récursivité terminale, contrairement à C et aux langages fonctionnels.
  • On peut presque toujours échanger du temps contre de la mémoire, et réciproquement. Le bon compromis dépend du nombre de fois où l'on pose la question.

Exercices d'application

Exercice 1
Quelle est la complexité en espace de cette fonction, et comment la ramener à O(1) ?
def inverser(tab):
    r = []
    for i in range(len(tab)-1, -1, -1):
        r.append(tab[i])
    return r
Voir le corrigeMasquer le corrige

La liste r finit par contenir n éléments : espace auxiliaire O(n), temps O(n).

En échangeant les éléments sur place, par paires, on n'a plus besoin de la seconde liste :

def inverser_sur_place(tab):
    g, d = 0, len(tab) - 1
    while g < d:
        tab[g], tab[d] = tab[d], tab[g]
        g += 1
        d -= 1

# temps O(n), espace auxiliaire O(1)

On ne fait que n/2 échanges, donc c'est aussi deux fois moins d'écritures. Contrepartie : le tableau d'origine est modifié, à n'employer que si l'appelant l'accepte.

Exercice 2
Pourquoi la dichotomie récursive est-elle en O(log n) d'espace alors que la version itérative est en O(1) ?
Voir le corrigeMasquer le corrige

Parce que la version récursive empile un cadre par appel, et qu'elle divise le tableau par deux à chaque fois : la profondeur d'appels atteint log₂ n. Chaque cadre garde ses bornes et son adresse de retour.

La version itérative fait exactement les mêmes comparaisons, mais réutilise deux variables :

while debut <= fin:
    m = (debut + fin) // 2
    ...
    debut = m + 1   # ou fin = m - 1

Même temps, mémoire constante. Cela dit, log n reste très petit, 30 cadres pour un milliard d'éléments, donc l'écart est ici théorique. Il devient réel pour une récursion de profondeur n, où la pile peut déborder.

Cherchez avant d'ouvrir le corrige : c'est en butant sur les difficultes que l'on progresse.

Questions / Réponses

Aucune question. Soyez le premier à poser une question.
★ ★ ★ ★ ★
2 votes. Moyenne 5 sur 5.

Ajouter un commentaire

Anti-spam