Partie II - Structures de donnees · Chapitre 5 · Fiche d'exercices

Structures de données

8 exercices corriges · difficulte croissante

Ces exercices portent sur le choix de la structure autant que sur le code. Justifier systematiquement la structure retenue par un argument de complexite.

Exercice 1Choisir la bonne structureApplication

Pour chaque situation, indiquer la structure la plus adaptee et justifier en une phrase.

  1. Les coordonnees \((x, y)\) d'un point, utilisees comme cle d'un dictionnaire.
  2. La liste des etudiants inscrits, ou l'on verifie tres souvent si un nom est present.
  3. L'historique des pages visitees dans un navigateur, avec un bouton « precedent ».
  4. Les tickets d'un guichet, servis dans l'ordre d'arrivee.
  5. Le nombre d'occurrences de chaque lettre d'un texte.
  6. Les notes d'un devoir, que l'on doit trier puis afficher.
Afficher le corrige
StructureJustification
1tupleimmuable donc hachable ; une liste serait refusee comme cle
2setle test d'appartenance passe de \(\Theta(n)\) a \(\Theta(1)\)
3pile (list)« precedent » retire le dernier ajoute : discipline LIFO
4file (deque)service dans l'ordre d'arrivee : discipline FIFO
5dictassociation lettre → compteur, acces \(\Theta(1)\)
6listl'ordre importe et les doublons sont possibles
Exercice 2Maitriser les tranchesApplication

Soit t = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]. Donner la valeur de :

a) t[2:5]      b) t[:4]       c) t[6:]      d) t[::3]
e) t[::-1]     f) t[-3:]      g) t[8:2]     h) t[2:8:-1]
i) t[-1:-4:-1] j) t[100:]     k) len(t[3:3]) l) t[:-1]
Afficher le corrige
a) [2, 3, 4]              b) [0, 1, 2, 3]
c) [6, 7, 8, 9]           d) [0, 3, 6, 9]
e) [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
f) [7, 8, 9]              g) []      (debut apres la fin, pas negatif)
h) []                     (pas negatif mais debut < fin)
i) [9, 8, 7]              j) []      (aucune erreur !)
k) 0                      l) [0, 1, 2, ..., 8]
Regle

Avec un pas positif on parcourt de gauche a droite : il faut debut < fin. Avec un pas negatif, de droite a gauche : il faut debut > fin. Sinon la tranche est vide, sans erreur.

Exercice 3Copies, alias et referencesEntrainement

Sans machine, donner l'etat final de a et de b.

a) a = [1, 2]; b = a;    b.append(3)
b) a = [1, 2]; b = a[:]; b.append(3)
c) a = [[1], [2]]; b = a[:]; b[0].append(9)
d) a = [[1], [2]]; b = [l[:] for l in a]; b[0].append(9)
e) a = [[0]*2]*2; a[0][0] = 7
f) a = (1, [2]); a[1].append(3)
Afficher le corrige
a) a = [1, 2, 3]        b = [1, 2, 3]     (meme objet)
b) a = [1, 2]           b = [1, 2, 3]
c) a = [[1, 9], [2]]    b = [[1, 9], [2]]  (sous-listes partagees)
d) a = [[1], [2]]       b = [[1, 9], [2]]  (copie profonde a la main)
e) a = [[7, 0], [7, 0]]                    (la ligne est repetee par reference)
f) a = (1, [2, 3])                         (le tuple est immuable, sa liste ne l'est pas)

Le cas (f) merite un commentaire : un tuple garantit que ses references ne changent pas, pas que les objets references soient immuables. Un tel tuple n'est d'ailleurs pas hachable : hash((1, [2])) leve TypeError.

Exercice 4Doublons, intersections, anagrammesEntrainement

Ecrire, en choisissant a chaque fois la structure la plus efficace :

  1. doublons(t) : True si la liste t contient au moins une valeur repetee. Quelle complexite ?
  2. communs(t, u) : liste triee des valeurs presentes dans les deux listes, sans repetition.
  3. uniques_en_ordre(t) : liste des elements de t sans doublon, en conservant l'ordre de premiere apparition.
  4. anagrammes(m1, m2) : les deux mots sont-ils anagrammes ?
Afficher le corrige
def doublons(t):
    """t contient-elle une valeur repetee ? Theta(n) en moyenne."""
    return len(set(t)) != len(t)

def doublons_precoce(t):
    """Variante s'arretant des le premier doublon trouve."""
    vus = set()
    for x in t:
        if x in vus:          # Theta(1)
            return True
        vus.add(x)
    return False

def communs(t, u):
    """Valeurs communes, triees, sans repetition."""
    return sorted(set(t) & set(u))

def uniques_en_ordre(t):
    """Elements de t sans doublon, dans l'ordre de premiere apparition."""
    vus, res = set(), []
    for x in t:
        if x not in vus:
            vus.add(x)
            res.append(x)
    return res

def anagrammes(m1, m2):
    """Les deux mots ont-ils les memes lettres avec les memes multiplicites ?"""
    return sorted(m1.lower()) == sorted(m2.lower())

assert doublons([1, 2, 3, 2])
assert not doublons([1, 2, 3])
assert communs([1, 2, 3, 3], [3, 4, 2]) == [2, 3]
assert uniques_en_ordre([3, 1, 3, 2, 1]) == [3, 1, 2]
assert anagrammes("chien", "niche")

Complexites. doublons construit un ensemble : \(\Theta(n)\) en moyenne. La version naive a deux boucles imbriquees serait en \(\Theta(n^2)\) — pour \(n = 10^5\), c'est la difference entre 0,01 s et plusieurs minutes. uniques_en_ordre utilise deux structures a la fois : l'ensemble pour la rapidite du test, la liste pour conserver l'ordre. C'est un motif tres frequent.

anagrammes coute \(\Theta(n\log n)\) a cause des tris ; une version en \(\Theta(n)\) compte les lettres dans deux dictionnaires et les compare.

Exercice 5Regroupement et classementEntrainement

On dispose d'une liste de couples (nom, note).

notes = [("Amine", 15), ("Sara", 18), ("Youssef", 12),
         ("Amine", 11), ("Sara", 16), ("Youssef", 14)]
  1. Construire un dictionnaire nom -> liste des notes.
  2. Calculer la moyenne de chacun.
  3. Etablir le classement par moyenne decroissante.
  4. Trouver le ou les etudiants ayant obtenu la meilleure note individuelle.
Afficher le corrige
notes = [("Amine", 15), ("Sara", 18), ("Youssef", 12),
         ("Amine", 11), ("Sara", 16), ("Youssef", 14)]

# 1. regroupement
par_nom = {}
for nom, note in notes:
    if nom not in par_nom:
        par_nom[nom] = []
    par_nom[nom].append(note)
# equivalent plus court :
# par_nom.setdefault(nom, []).append(note)

# 2. moyennes
moyennes = {nom: sum(v) / len(v) for nom, v in par_nom.items()}
print(moyennes)      # {'Amine': 13.0, 'Sara': 17.0, 'Youssef': 13.0}

# 3. classement : moyenne decroissante, puis nom croissant a egalite
classement = sorted(moyennes.items(), key=lambda c: (-c[1], c[0]))
for rang, (nom, m) in enumerate(classement, 1):
    print(f"{rang}. {nom:<10} {m:.2f}")

# 4. meilleure note individuelle (il peut y avoir des ex aequo)
meilleure = max(note for _, note in notes)
gagnants = [nom for nom, note in notes if note == meilleure]
print(meilleure, gagnants)      # 18 ['Sara']
1. Sara       17.00
2. Amine      13.00
3. Youssef    13.00

La cle de tri (-c[1], c[0]) realise un tri lexicographique : on compare d'abord l'oppose de la moyenne (donc decroissant), et a egalite le nom (croissant). Ce tri « a plusieurs criteres » est une technique a maitriser.

Exercice 6Mesurer une complexiteApprofondissement

On veut mesurer experimentalement les complexites annoncees dans le cours.

  1. Ecrire une fonction chronometrant le test v in x pour une liste et pour un ensemble, en fonction de \(n\).
  2. Tracer (ou tabuler) les temps pour \(n = 10^3, 10^4, 10^5\). Verifier que le temps de la liste est proportionnel a \(n\).
  3. Mesurer le cout de t.insert(0, x) et de t.append(x). Expliquer la difference.
Afficher le corrige
import time

def chrono(action, repetitions=1000):
    """Temps moyen d'une action, en microsecondes."""
    t0 = time.perf_counter()
    for _ in range(repetitions):
        action()
    return (time.perf_counter() - t0) / repetitions * 1e6

print(f"{'n':>8} {'liste (us)':>12} {'ensemble (us)':>14} {'rapport':>9}")
for n in (10**3, 10**4, 10**5):
    L = list(range(n))
    S = set(L)
    tl = chrono(lambda: (-1) in L, 200)
    ts = chrono(lambda: (-1) in S, 200)
    print(f"{n:>8} {tl:>12.2f} {ts:>14.4f} {tl/ts:>9.0f}")
       n   liste (us)  ensemble (us)   rapport
    1000         4.10         0.0400       102
   10000        41.30         0.0400      1033
  100000       412.00         0.0410     10049

Le temps de la liste est multiplie par 10 quand \(n\) l'est : c'est bien un comportement lineaire. Celui de l'ensemble ne bouge pas : constant.

for n in (10**4, 10**5):
    t = list(range(n))
    print(n, "append :", f"{chrono(lambda: t.append(0), 10000):.3f} us")
    u = list(range(n))
    print(n, "insert(0):", f"{chrono(lambda: u.insert(0, 0), 300):.3f} us")

3. Une liste Python est un tableau dynamique : les elements occupent des cases contigues. append ecrit dans la case libre suivante, en \(\Theta(1)\) amorti (avec de temps en temps un reagrandissement du tableau, dont le cout est reparti sur les appels precedents). insert(0, x) doit en revanche decaler les \(n\) elements d'une case vers la droite : \(\Theta(n)\). Pour des insertions en tete frequentes, on utilise collections.deque, qui les realise en \(\Theta(1)\).

Exercice 7Matrices creusesApprofondissement

Une matrice creuse est une matrice dont la plupart des coefficients sont nuls. La stocker sous forme de liste de listes gaspille de la memoire.

  1. Proposer une representation par dictionnaire dont les cles sont les couples \((i,j)\) des coefficients non nuls.
  2. Ecrire creuse(m) convertissant une matrice pleine, et pleine(d, n, p) la conversion inverse.
  3. Ecrire l'addition et le produit matrice-vecteur en representation creuse.
  4. Comparer la memoire et le temps du produit pour une matrice \(1000\times1000\) ayant 1 % de coefficients non nuls.
Afficher le corrige
def creuse(m):
    """Matrice pleine (liste de listes) -> dictionnaire {(i,j): valeur}."""
    return {(i, j): m[i][j]
            for i in range(len(m))
            for j in range(len(m[0]))
            if m[i][j] != 0}

def pleine(d, n, p):
    """Dictionnaire creux -> matrice pleine n x p."""
    m = [[0] * p for _ in range(n)]
    for (i, j), v in d.items():
        m[i][j] = v
    return m

def somme_creuse(d1, d2):
    """Somme de deux matrices creuses."""
    r = dict(d1)
    for cle, v in d2.items():
        r[cle] = r.get(cle, 0) + v
        if r[cle] == 0:
            del r[cle]          # on ne stocke jamais un zero
    return r

def produit_vecteur(d, x, n):
    """Produit d'une matrice creuse n x p par le vecteur x."""
    y = [0] * n
    for (i, j), v in d.items():
        y[i] += v * x[j]        # une seule multiplication par coefficient non nul
    return y

m = [[1, 0, 0], [0, 0, 2], [3, 0, 0]]
d = creuse(m)
assert d == {(0, 0): 1, (1, 2): 2, (2, 0): 3}
assert pleine(d, 3, 3) == m
assert produit_vecteur(d, [1, 1, 1], 3) == [1, 2, 3]

4. Pour \(n = 1000\) avec 1 % de coefficients non nuls :

PleineCreuse
Coefficients stockes\(10^6\)\(10^4\)
Multiplications (produit par un vecteur)\(10^6\)\(10^4\)
Facteur de gain100 en memoire comme en temps
import random, time
n, densite = 1000, 0.01
d = {(random.randrange(n), random.randrange(n)): random.random()
     for _ in range(int(n*n*densite))}
x = [1.0] * n

t0 = time.perf_counter(); produit_vecteur(d, x, n)
print("creuse :", time.perf_counter() - t0, "s")     # ~0.005 s

M = pleine(d, n, n)
t0 = time.perf_counter()
y = [sum(M[i][j]*x[j] for j in range(n)) for i in range(n)]
print("pleine :", time.perf_counter() - t0, "s")     # ~0.4 s
Portee

Les matrices creuses sont omnipresentes : discretisation d'equations aux derivees partielles, graphes peu denses, systemes lineaires de tres grande taille. Le module scipy.sparse propose des formats optimises (CSR, COO) fondes sur cette meme idee.

Exercice 8Implementer un ensemble : table de hachageConcours

Type concours. On souhaite implementer un type abstrait ensemble supportant ajouter, contient et supprimer, sans utiliser le type set de Python.

  1. Implementation par liste non triee : donner les complexites des trois operations.
  2. Implementation par liste triee : idem. Laquelle preferer, et selon quel usage ?
  3. Implementation par table de hachage avec resolution des collisions par chainage : decrire la structure, puis la programmer.
  4. Sous l'hypothese d'un hachage uniforme et d'un facteur de charge \(\alpha = n/m\) borne, montrer que le cout moyen d'une recherche est \(\Theta(1+\alpha)\).
Afficher le corrige

1-2. Comparaison.

Implementationcontientajouter supprimer
liste non triee\(\Theta(n)\)\(\Theta(n)\) (verifier l'absence) \(\Theta(n)\)
liste triee\(\Theta(\log n)\) par dichotomie\(\Theta(n)\) (decalage) \(\Theta(n)\)
table de hachage\(\Theta(1)\) en moyenne\(\Theta(1)\) en moyenne \(\Theta(1)\) en moyenne

La liste triee n'est interessante que si les recherches dominent largement les modifications, ou si l'on a besoin de parcourir les elements dans l'ordre — ce que la table de hachage ne permet pas.

3. Une table de hachage est un tableau de \(m\) alveoles. Une fonction de hachage \(h\) envoie chaque cle sur un indice de \(0\) a \(m-1\). Deux cles distinctes peuvent tomber dans la meme alveole (collision) : on stocke alors dans chaque alveole la liste des elements qui s'y trouvent.

class EnsembleHache:
    """Ensemble implemente par table de hachage a chainage separe."""

    def __init__(self, m=16):
        self.m = m                       # nombre d'alveoles
        self.n = 0                       # nombre d'elements
        self.table = [[] for _ in range(m)]

    def _indice(self, x):
        return hash(x) % self.m

    def contient(self, x):
        return x in self.table[self._indice(x)]

    def ajouter(self, x):
        alveole = self.table[self._indice(x)]
        if x not in alveole:
            alveole.append(x)
            self.n += 1
            if self.n > 2 * self.m:      # facteur de charge > 2 : on agrandit
                self._agrandir()

    def supprimer(self, x):
        alveole = self.table[self._indice(x)]
        if x in alveole:
            alveole.remove(x)
            self.n -= 1

    def _agrandir(self):
        """Double le nombre d'alveoles et rehache tout."""
        anciens = [x for alveole in self.table for x in alveole]
        self.m *= 2
        self.table = [[] for _ in range(self.m)]
        for x in anciens:
            self.table[self._indice(x)].append(x)

    def __len__(self):
        return self.n

E = EnsembleHache()
for k in range(1000):
    E.ajouter(k % 400)               # avec doublons
assert len(E) == 400
assert E.contient(399) and not E.contient(400)
E.supprimer(399)
assert not E.contient(399) and len(E) == 399
print("table de hachage validee")

4. Analyse. Notons \(m\) le nombre d'alveoles, \(n\) le nombre d'elements et \(\alpha = n/m\) le facteur de charge. Sous l'hypothese de hachage uniforme simple (chaque cle a la meme probabilite \(1/m\) de tomber dans chaque alveole, independamment des autres), la longueur \(L_i\) de l'alveole \(i\) suit une loi binomiale \(\mathcal{B}(n, 1/m)\), d'esperance \(\mathbb{E}[L_i] = n/m = \alpha\).

Une recherche infructueuse parcourt entierement l'alveole visee : son cout moyen est \(\Theta(1 + \alpha)\), le \(1\) representant le calcul du hachage et l'acces au tableau.

Pour une recherche fructueuse, l'element cherche est l'un des \(n\) presents ; on montre de meme que le nombre moyen de comparaisons vaut \(1 + \frac{\alpha}{2} - \frac{\alpha}{2n}\), soit encore \(\Theta(1+\alpha)\).

En maintenant \(\alpha\) borne (ici \(\alpha \le 2\) grace au doublement), le cout moyen devient \(\Theta(1)\). Le redimensionnement coute \(\Theta(n)\) mais n'intervient que tous les \(n/2\) ajouts : son cout amorti est \(\Theta(1)\) par ajout.

Le pire des cas

Si toutes les cles tombent dans la meme alveole, la recherche redevient \(\Theta(n)\). C'est pourquoi la qualite de la fonction de hachage est critique, et pourquoi Python randomise le hachage des chaines depuis la version 3.3 (protection contre les attaques par collisions).

Retour au coursChapitre 5 — Structures de données
Relire le cours →