Algorithmes de résolution de requête

1 Objectifs

Ce TP vous propose d'explorer les algorithmes utilisés par les systèmes de bases de données pour répondre aux requêtes qui leur sont envoyées. Cette exploration s'effectue dans un cadre simplifié :

  1. nous n'irons pas jusqu'aux détails de la gestion de la mémoire faite par ces algorithmes,
  2. nous oublierons les subtilités de la gestion des accès disque,
  3. enfin, nous ne verrons pas comment utiliser le disque dur pour stocker des résultats intermédiaires de requête trop volumineux pour tenir en mémoire.

Néanmoins, nous nous efforcerons au cours du TP d'évoquer ces détails afin de comprendre les difficultés techniques qu'ils soulèvent.

Le TP s'effectuera en python.

2 Concept préalable : les flux

2.1 Le principe

La résolution d'une requête dans une base de données requiert la combinaison de nombreuses opérations. Il s'agit :

  • de lire les tables sur le disque,
  • de supprimer les colonnes inutiles,
  • de filtrer les lignes inutiles,
  • de calculer des jointures,
  • de calculer des agrégats
  • etc.

Pour faire en sorte que ces calculs n'utilisent pas toute la mémoire de la machine, ceux-ci sont organisés en flux : les opérations produisent des résultats qui sont consommés au fur et à mesure par d'autres opérations. Cela évite ainsi de stocker l'intégralité des tables en mémoire. D'ailleurs, il est en général impossible de stocker en mémoire vive l'intégralité d'une table d'une bases données de taille industrielle.

2.2 Les flux en python

Python permet assez naturellement de manipuler des flux. Pour cela, il suffit d'utiliser dans une fonction le mot clé yield. Par exemple le code suivant génère le flux des carrés des nombres de 0 à n-1:

def flux_carres(n):
    """Flux des carrés de 0 à n-1"""
    for i in range(n):
        yield i*i

Dans ce programme, la ligne commençant par yield renvoie le carré courant et le calcul est suspendu jusqu'à une nouvelle demande. Les fonctions de ce type s'appellent générateurs (generators en anglais) en python et peuvent être utilisés dans des boucles. Par exemple, exécutez le code suivant et observez ce qu'il produit :

def carres(n):
  res = []
  for i in range(n):
    print("foo")
    res.append(i*i)
  return res

def flux_carres_bis(n):
  for i in range(n):
    print("bar")
    yield i*i

for c in carres(10):
  print(c)

for c in flux_carres_bis(10):
  print(c)

Ainsi nous pouvons écrire une fonction qui calcule la somme des carrés de 0 à n-1 de la manière suivante en nous servant de notre générateur flux_carres:

def somme_carres(n):
    """Calcule la somme des carrés de 0 à n-1."""
    res = 0
    for i in flux_carres(n):
        res += i
    return res

L'avantage de cette approche est double : d'une part, elle évite de consommer trop de mémoire (en ne stockant pas en mémoire la liste des carrés), et d'autre part elle évite de devoir attendre que tous les carrés soient générés avant pouvoir les utiliser. En écrivant la fonction somme_carres de la manière suivante:

def somme_carres_bis(n):
    return sum([i*i for i in range(n)])

la liste de tous les carrés entre 0 et n-1 va être générée et stockée en mémoire, ce qui peut être problématique pour les grandes valeurs de n. À l'inverse l'utilisation du générateur flux_carres évite ce problème et somme_carres a une faible empreinte mémoire.

Les générateurs peuvent être écrits et composés de façon très similaire aux listes en compréhension. Il suffit pour cela de remplacer les crochets [ ] par des parenthèses ( ). Nous pouvons réécrire la fonction somme_carres avec un générateur de la manière suivante (même si cela peut sembler artificiel) :

def somme_carres_ter(n):
    res = 0
    for k in (i*i for i in range(n)):
        res+=k
    return res

La fonction sum utilise à son avantage les générateurs et permet de sommer sans empreinte mémoire. Nous pouvons ainsi écrire somme_carres comme ceci :

def somme_carres_quad(n):
    return sum(i*i for i in range(n))

Tout comme pour les listes en compréhension, les générateurs peuvent être composés, utiliser des filtres, etc.

Nous essaierons autant que possible de faire en sorte que les algorithmes de résolutions de requête que nous implémenterons soient des générateurs.

Vous pourrez trouver plus de détails sur les générateurs sur le wiki de python.

3 Implémentation des opérateurs de l'algèbre relationnelle

Nous allons représenter les tables comme des flux de dictionnaires. Les dictionnaires représenteront les tuples de la table :

  • chaque clé du dictionnaire représente un attribut de la table,
  • la valeur associée à la clé dans un dictionnaire représente la valeur qui est stockée par cet attribut dans le tuple. En général, nous nous contenterons de valeurs entières pour ce TP.

Afin de simuler des tables de tailles variées, nous allons les générer aléatoirement à l'aide de la fonction suivante :

def table(descr, nb=10000):
    """Cette fonction génère une séquence de tuples décrits par le dictionnaire
    descr. Le dictionnaire associe à une clé une paire (k,l). La fonction
    génère nb dictionnaires de la manière suivante :
    - chaque clé de descr est une clé de ces dictionnaires
    - à chacune de ces clés x, ces dictionnaires associent un nombre tiré au hasard
      entre k et l lorsque la paire (k,l) est associée à x dans descr.
    NB : cette fonction requiert d'importer le module random.
    """
    for _ in range(nb):
        tuple_res = {}
        for a, (k,l) in descr.items():
            tuple_res[a] = random.randint(min(k, l), max(k, l))
        yield tuple_res

Voici un exemple d'utilisation de cette fonction qui affiche tous les tuples générés (ici on choisit de générer une table qui ne contient que 10 éléments) :

def exemple_table():
    """Exemple d'utilisation de la fonction table. Génère une table de 10 éléments
comportant les attributs 'a' et 'b' et les affiche en flux."""
    schema = {'a': (0,10), 'b': (100,100000)}
    for tuple_tbl in table(schema,nb=10):
        print(tuple_tbl)

3.1 Projection, concaténation (union), sélection

Nous allons maintenant implémenter les opérations les plus simples de l'algèbre relationnelle. Pour ces opérations, il faut garder à l'esprit que l'on souhaite autant que possible maintenir ces opérations en flux, dans le but d'éviter de mémoriser les tuples traités.

3.1.1 La projection

Nous allons commencer par la projection. Il s'agit, étant données une table et une liste d'attributs, de renvoyer une table (sous forme de flux) qui ne contient que les attributs présents dans la liste d'attributs.

Cela correspond aux requêtes sql de la forme suivante :

SELECT id, nom FROM etudiants;

où on ne sélectionne qu'une partie des attributs d'une table.

Vous compléterez la fonction ci-dessous :

def projection(table, champs):
    """
    Renvoie la table (sous forme de flux) obtenue à partir des tuples contenus
    dans ~table~ en n'y conservant que les attributs (les clés) qui sont
    contenus dans ~champs~.

    Renvoie une exception si un attribut de ~champs~ n'est pas un attribut des
    tuples de ~table~.
    """
    yield {}

Voici un exemple d'utilisation de cette fonction :

def exemple_projection():
    """Exemple d'utilisation de la projection."""
    schema = {'a': (1, 10), 'b': (40, 100), 'c': (20,30)}
    for tuple_res in projection(table(schema ,nb=100),
                                ['a', 'c']):
        print(tuple_res)

Un point de vue plus général sur la projection consiste à la voir comme l'application d'une fonction à chacun des tuples de la table. Par exemple, la requête suivante effectue une telle transformation :

SELECT id, (note1 + note2)/2 AS moyenne FROM etudiants;

Pour implémenter ce type d'opération, complétez cette fonction (vous pouvez penser à utiliser la définition des flux en extension) :

def transformation(table, f):
    """Renvoie un flux obtenu en appliquant ~f~ à chacun des tuples composant
~table~."""
    yield {}

Voici un exemple d'utilisation de cette fonction :

def exemple_transformation():
    schema = {'a': (1, 10), 'b': (40, 100), 'c': (20,30)}
    f = lambda tp: {'a': tp['a'], 'm': (tp['b']+tp['c'])//2}
    for tuple_res in transformation(table(schema,nb=100), f):
        print(tuple_res)

Réécrire la fonction projection à l'aide de la fonction transformation (penser à utiliser une définition de fonction dans la fonction, voir ici par exemple):

def projection2(table, champs):

    """
    Renvoie la table (sous forme de flux) obtenue à partir des tuples contenus
    dans ~table~ en n'y conservant que les attributs (les clés) qui sont
    contenus dans ~champs~.

    Renvoie une erreur si un attribut de ~champs~ n'est pas un attribut des
    tuples de ~table~.
    """
    yield {}

3.1.2 L'union

Une opération relativement simple est l'union de deux tables. Il s'agit de construire un flux de données (de tuples) en énumérant les données d'un premier flux, puis, lorsque ce premier flux est épuisé, d'énumérer le second. L'opérateur équivalent en SQL s'appelle UNION ALL, alors que l'opérateur UNION enlève les doublons.

SELECT * FROM table1 UNION ALL SELECT * FROM table2 ;

Complétez la fonction suivante :

def union(t1, t2):
    """Construit un flux qui énumère les éléments de ~t1~ puis ceux de ~t2~."""
    yield {}

Un exemple d'utilisation de cette fonction:

def exemple_union():
    """Exemple d'utilisation de la fonction union."""
    schema1 = {'a':(30, 100), 'b': (10, 50)}
    schema2 = {'a': (40, 50), 'n': (100, 200), 'm': (0,10)}
    f = lambda tp: {'a': tp['a']//2, 'b': (tp['m']*tp['m'])//4}
    for tp in union(table(schema1, nb = 10),
                    transformation(table(schema2,nb=10),f)):
        print(tp)

3.1.3 La sélection

L'opération de sélection consiste à ne conserver dans un flux que les éléments qui vérifient une certaine propriété.

Typiquement, c'est l'opération réalisée par ce type de requêtes :

SELECT * FROM etudiants WHERE date_inscription <= 2020 AND date_inscription >= 2018;

Nous allons implémenter la sélection par un prédicat, c'est-à-dire une fonction qui à un tuple associe un booléen. Pour cela, complétez la définition suivante :

def selection(table, pred):
    """Construit le flux des éléments de ~table~ qui satisfont le prédicat
       ~pred~ (fonction des tuples dans les booléens)."""
    yield {}

Exemple d'utilisation de selection :

def exemple_selection():
    for un_tuple in selection(table({'a': (30, 100), 'b': (10, 50)}, nb=10),
               lambda tp: tp['a'] > 50 and tp['b'] < 45):
        print(un_tuple)

3.1.4 La sélection en présence d'index

Lorsqu'une table est très grande, par exemple lorsqu'elle contient plus de 109 tuples, la parcourir intégralement a un coût très important. Si le nombre de tuples sélectionnés par une requête est faible par rapport au nombre total de tuples de la table, on utilise un index pour les retrouver directement.

Afin de simuler les index, nous vous fournissons un ensemble de fonctions qui permettent de sauver les tables sur le disque, puis de créer un index de ces tables en mémoire. Cet index va associer à chaque valeur indexée la liste des adresses physiques sur le disque des tuples dont le champ indexé contient cette valeur. Ainsi pour une valeur donnée, on pourra récupérer ces tuples sans devoir lire toute la table sur le disque.

  1. Quelques fonctions pour créer et utiliser des tables sur disque

    Les fonctions utiles pour le TP sont les suivantes (elles se trouvent dans le module disque) :

    • mem_sur_disque(table, fichier) : sauvegarde table dans fichier,
    • lire_sur_disque(fichier) : renvoie le flux des tuples de la table contenue dans fichier,
    • index_fichier(fichier, attribut) : renvoie un dictionnaire qui à chaque valeur v associe la liste des adresses dans fichier des tuples associant v à attribut.
    • trouver_sur_disque(fichier, adresses) : renvoie le flux des tuples dans fichier ayant des adresses physiques dans adresses.

    Typiquement nous utiliserons ces fonctions de la manière suivante :

    schema = {'a' : (0,10), 'b' : (1,10000000)}
    tbl = table(schema, nb=1000000)
    fichier = "/tmp/tbl.table"
    mem_sur_disque(tbl, fichier)
    idx = index_fichier(fichier, 'a')
    b0 = trouve_sur_disque("/tmp/tbl.table",idx[0])
    print(sum(t['b'] for t in b0))
    
  2. Calcul d'une sélection à partir d'un index

    Vous allez maintenant écrire la fonction selection_index(fichier, idx, valeurs) suivante :

    def selection_index(fichier, idx, valeurs) :
        """
        On suppose que ~fichier~ contient des tuples dont l'une des colonnes est
        indexée par ~idx~. La fonction renvoie le flux des tuples qui associe a la
        colonne indexée une valeur dans la séquence ~valeurs~.
    
        Attention : si un élément de ~valeurs~ n'est pas référencé dans ~idx~, on
        souhaite qu'il n'y ait pas d'erreur.
        """
        yield {}
    

    Voici un exemple d'utilisation de cette fonction où on sélectionne dans une table (générée pour l'occasion et indexée suivant l'attribut a) les tuples qui associent une valeur entre 2 et 4 à l'attribut a :

    def exemple_selection_index():
        schema = {'a' : (0,10), 'b' : (1,10000000)}
        tbl = table(schema, nb=1000000)
        fichier = "/tmp/tbl.table"
        mem_sur_disque(tbl, fichier)
        idx = index_fichier(fichier, 'a')
        for tp in selection_index(fichier, idx, range(2,5)):
            print(tp)
    
    

3.2 Les algorithmes de jointure

Nous allons maintenant voir plusieurs algorithmes de calcul de jointure. Il s'agit de l'une des opérations les plus coûteuses lors de la résolution de requête pour un système de bases de données. Ainsi, il y a un grand nombre d'algorithmes qui cherchent à tirer partie de propriétés particulières des tables à joindre pour gagner en efficacité.

3.2.1 Produit cartésien

Avant de calculer la jointure entre deux tables, nous allons nous occuper d'une opération plus élémentaire : le calcul du produit cartésien de deux tables. Le produit cartésien est calculé par les requêtes sql suivantes :

SELECT * FROM table1 CROSS JOIN table2;

ou encore

SELECT * FROM table1, table2;

Cependant avant même d'implémenter le produit cartésien, comme nous ne souhaitons manipuler que des tuples, nous allons écrire une fonction permettant d'assembler deux tuples en un seul.

  1. Appariement de tuples

    Nous allons tout d'abord voir comment nous allons réaliser l'appariement de deux tuples. Idéalement nous souhaiterions que cet appariement consiste à faire l'union des dictionnaires : chaque clé de l'un des dictionnaires est dans le dictionnaire résultat et celui-ci associe à la clé la valeur qui lui est associée par l'un des tuples de départ. Si les deux tuples n'ont aucun attribut en commun, alors il n'y a pas de difficulté. Dans le cas contraire, nous décidons (un peu arbitrairement) de donner la priorité au second tuple : nous allons associer, dans le résultat, les valeurs du second tuple aux clés communes aux deux tuples.

    Complétez la fonction ci-dessous.

    def appariement(t1, t2):
        """Renvoie un tuple ayant pour clé les clés de ~t1~ et de ~t2~.
    
        Lorsqu'une clé n'apparaît que dans un tuple la valeur que lui associe ce
        tuple est celle associée à la clé dans le résultat.
    
        À une clé qui apparaît dans les deux tuples, le résultat associe la valeur
        que lui associe ~t2~.
        """
        return {}
    
  2. L'algorithme de double boucle

    Pour générer le produit cartésien nous allons utiliser un algorithme qui utilise deux boucles imbriquées :

    1. Une boucle parcourt la première table en sélectionant un tuple tp à chaque tour.
    2. La seconde boucle, à l'intérieur de la première, parcourt la seconde table et renvoie le flux des tuples de cette table, appariés avec tp.

    Écrivez cet algorithme dans le squelette de code suivant :

    def produit_cartesien(table1, table2):
        """Construit le flux de tuples obtenus en appariant tous les tuples de
        ~table1~ et de ~table2~.
    
        Ce flux correspond au produit cartésien des deux tables produit par l'algorithme double boucle :
        - ~table1~ est la table utilisée dans le boucle extérieure,
        - ~table2~ est la table utilisée dans la boucle intérieure.
        """
        yield {}
    
  3. Un problème avec les flux en mémoire

    Exécutez le bloc de code suivant :

    tb1 = table({'a': (1,1)}, nb=2)
    tb2 = table({'b': (2,2)}, nb=2)
    for tp in produit_cartesien(tb1,tb2):
        print(tp)
    
    • Combien de tuples auriez-vous du obtenir ?
    • À votre avis, quel est le problème ?
  4. Implémentation du produit cartésien à partir de tables contenues dans des fichiers

    Implémentez la fonction suivante pour des tables contenues dans des fichiers en évitant le problème observé dans la partie précédente.

    def produit_cartesien_fichier(fichier1, fichier2):
        """Construit le flux de tuples obtenus en appariant tous les tuples contenus
        dans les fichiers ~fichier1~ et ~fichier2~.
    
        Ce flux correspond au produit cartésien des deux tables contenues dans les
        fichiers produit par l'algorithme double boucle :
        - la table contenue dans ~fichier1~ est utilisée dans la boucle extérieure,
        - la table contenue dans ~fichier2~ est la table utilisée dans la boucle intérieure.
    
        """
        yield {}
    

    Exécutez le code ci-dessous pour vérifier que vous obtenez le bon comportement.

    tb = [table({'a': (1,1)}, nb=2), table({'b': (2,2)}, nb=2)]
    fichiers = ["/tmp/tb1.table", "/tmp/tb2.table"]
    for i in range(2): mem_sur_disque(tb[i], fichiers[i])
    for tp in produit_cartesien_fichier(fichiers[0],fichiers[1]): print(tp)
    

3.2.2 La jointure

Nous allons maintenant implémenter quelques-uns des nombreux algorithmes de jointure. Après notre expérience avec les produits cartésiens, nous allons systématiquement utiliser des tables stockées sur disque pour calculer les jointures.

  1. Jointure theta

    Nous allons ici utiliser les fonctions selection et produit_cartesien_fichier pour calculer la jointure theta. Il s'agit de récupérer le flux des tuples de produit cartésien satisfaisant une certaine propriété.

    Complétez le code ci-dessous :

    def jointure_theta(fichier1, fichier2, pred):
        """Renvoie le flux des appariements de tuples contenus dans les tables des
        fichiers ~fichier1~ et ~fichier2~" qui satisfont la propriété du prédicat
        ~pred~ (fonction des tuples dans les booléens)."""
        yield {}
    
  2. Jointure naturelle double boucle

    La jointure naturelle consiste à n'apparier que les tuples qui associent les mêmes valeurs à leurs attributs communs. En sql, cela correspond aux requêtes de la forme :

    SELECT * FROM table1 NATURAL JOIN table2;
    

    Complétez le code-ci-dessous :

    def jointure_naturelle(fichier1, fichier2):
        """Renvoie le flux des tuples de la jointure naturelle des tables contenues
        dans ~fichier1~ et ~fichier2~.
    
        Il s'agit des appariements des tuples provenant des tables contenues dans
        ~fichier1~et ~fichier2~ qui associent les mêmes valeurs à leurs attributs
        communs.
        """
        yield {}
    
  3. Jointure naturelle/theta double boucle avec une (petite) table en mémoire

    Lors de l'exécution de l'algorithme double boucle, l'une des tables n'est lue qu'une seule fois alors que la seconde est lue plusieurs fois. Quelle est la table qui est lue plusieurs fois :

    1. La table parcourue dans la boucle extérieure ?
    2. La table parcourue dans la boucle intérieure ?

    Lorsque l'une des deux tables est suffisamment petite pour tenir en mémoire, il peut être plus efficace de la charger en mémoire. À votre avis, pour limiter le nombre d'accès au disque dur, quelle table vaut-il mieux charger en mémoire ?

    1. La table parcourue par la boucle extérieure ?
    2. La table parcourue par la boucle intérieure ?

    Nous allons utiliser cette idée pour implémenter l'algorithme double boucle en chargeant l'une des deux tables en mémoire. Complétez le code ci-dessous. Vous modifierez également la documentation pour préciser quel fichier est chargé en mémoire.

    def jointure_naturelle_mem(fichier1, fichier2):
        """Renvoie le flux des tuples de la jointure naturelle des tables contenues
        dans ~fichier1~ et ~fichier2~.
    
        L'un des deux fichiers est chargé en mémoire afin qu'il ne soit lu qu'une
        seule fois.
    
        Il s'agit des appariements des tuples provenant des tables contenues dans
        ~fichier1~et ~fichier2~ qui associent les mêmes valeurs à leurs attributs
        communs.
    
        """
        yield {}
    
  4. La jointure avec une table indexée

    Nous allons maintenant nous intéresser à un type particulier de jointure. Des jointures qui se font avec la condition que deux attributs des tables à joindre aient la même valeur. Il s'agit de requêtes de la forme :

    SELECT * FROM table1 JOIN table2 ON table1.A = table2.B;
    

    Pour cette partie nous adoptons les hypothèses suivantes :

    1. pour simplifier, les deux tables utilisées n'ont pas d'attributs en commun.
    2. la colonne B de table2 est indexée.

    Afin de profiter de l'indexation de table2, nous pouvons parcourir table1 et pour chaque tuple chercher dans l'index les adresses physiques des tuples de table2 qui peuvent s'apparier avec ce tuple.

    En utilisant l'idée que nous venons d'esquisser, compléter la fonction ci-dessous. Pour rappel :

    • si index est un index pour la colonne col de la table contenue dans fichier et k est une clé de l'index, alors index[k] est la liste des adresses dans fichier des tuples qui à col associent la valeur k.
    • trouve_sur_disque(fichier, adr) renvoie le flux des tuples de fichier aux adresses contenues dans adr.
    def jointure_index(table1, col1, fichier2, index):
        """Renvoie le flux des tuples de la jointure de la ~table1~ et de la table
        contenue dans ~fichier2~ sous la condition que les valeurs de l'attribut
        ~col1~ de ~table1~ soient identiques aux valeurs de l'attribut ~col2~ la
        table de ~fichier2~.
    
        ~index~ est un index de l'attribut ~col2~ dans ~fichier2~.
        """
        yield {}
    

    NB : cet algorithme pourrait assez facilement se généraliser pour les jointure theta dans lesquelles l'une des conditions est l'égalité des valeurs des attributs A et B.

  5. La jointure avec deux tables indexées

    Ici nous cherchons à résoudre la même requête que dans la question précédente avec l'hypothèse supplémentaire que nous disposons d'un index pour l'attribut A de table1. Au lieu de parcourir les tables, nous allons parcourir les deux index pour calculer la jointure.

    Compléter la fonction ci-dessous :

    def jointure_double_index(fichier1, index1, fichier2, index2):
        """Renvoie le flux de tuples obtenue par la jointure des tables contenues dans
         ~fichier1~ et ~fichier2~ avec la condition que les valeurs indexées par
         ~index1~ pour ~table1~ soient égaux aux valeurs indexées par ~index2~ pour
         ~table2~."""
        yield {}
    
  6. La jointure par fusion de flux triés

    Nous poursuivons encore avec la même requête, mais cette fois, au lieu de supposer que nous disposons d'index pour les attributs sur lesquels porte la condition d'égalité, nous supposons que les tables sont triées dans l'ordre croissant selon la valeur des attributs concernés.

    Lorsque deux tables sont triées par rapport aux attributs sur lesquels on effectue la jointure, celle-ci est plus simple à calculer. On peut en effet parcourir chacune des tables en progressant suivant les valeurs des tuples. En particulier, cela permet de ne parcourir chaque table qu'une seule fois. Cela nous permet d'implémenter cette méthode de jointure directement sur des flux et non plus depuis des fichiers.

    Voici une description de l'algorithme que nous allons implémenter :

    1. nous allons charger en mémoire tous les tuples en tête de table1 qui associent la même valeur à l'attribut A,
    2. nous faisons de même pour les tuples en tête de table2 qui associent la même valeur à l'attribut B,
    3. suivant la façon dont se comparent la valeur de l'attribut A commune aux tuples de table1 en mémoire à celle de l'attribut B des tuples de table2 en mémoire, nous produisons les appariements, ou nous continuons d'avancer dans l'une des tables.

    NB : faites bien attention à ne pas oublier de tuples pour calculer la jointure. En particulier, il faut que les premiers tuples ayant une valeur différente de celle des tuples mémorisés lors des étapes 1. et 2. participent bien à la jointure. Pensez bien à les conserver en mémoire.

    Complétez le code ci-dessous :

    def jointure_triee(table1, col1, table2, col2):
        """Implémente la jointure de ~table1~ et ~table2~ sous la condition que les
        valeurs de l'attribut ~col1~ de ~table1~ soient égales aux valeurs de
        l'attribut ~col2~ de ~table2~.
    
        On suppose que ~table1~ est triée suivant les valeurs croissantes de ~col1
        et que ~table2~ est triée suivant les valeurs croissantes de ~col2~.
        """
        yield {}
    

3.3 L'agrégation

Dans cette partie, nous allons implémenter des fonctions qui permettent de calculer des agrégats simples et fréquemment utilisés dans des requêtes.

3.3.1 Calcul du minimum

Nous allons commencer par le calcul de la valeur minimum associée à un attribut dans une table.

Attention : pour calculer ce minimum, vous devez utiliser une mémoire constante. Il ne faut pas que vous stockiez toutes les valeurs dans une liste puis que vous utilisiez une fonction python qui calcule le minimum de cette liste.

Complétez la fonction suivante.

def minimum_table(table, col):
    """Renvoie la plus petite des valeurs associées à l'attribut ~col~ dans ~table~.
    Si ~table~ est vide, renvoie None.
    """
    return None

3.3.2 Calcul de moyenne

Nous allons calculer la moyenne des valeurs associées à un attribut d'une table.

Attention : de nouveau, nous allons chercher à avoir une empreinte mémoire qui est indépendante de la taille de la table considérée. Pour cela, on pourra stocker les informations suivantes :

  • la somme des valeurs des attributs des tuples déjà traités,
  • le nombre des tuples déjà traités.
def moyenne_table(table, col):
    """Renvoie la moyenne des valeurs que les tuples de ~table~ associent à
    l'attribut ~col~.
    Si ~table~ est vide, renvoie None.
    """
    return None

3.3.3 Calcul d'écart type

Nous allons essayer d'appliquer la même méthode pour le calcul de l'écart-type des valeurs associées à un attribut d'une table.

Pour rappel, si v1, …, vn sont les valeurs dont on souhaite calculer l'écart-type, celui-ci vaut :

\[\sqrt{\frac{1}{n} \sum_{i=1}^n (v_i - m)^2}\]

si m est la moyenne des valeurs.

Nous remarquons que: \[\sum_{i=1}^n (v_i - m)^2 = \sum_{i=1}^n (v_i^2 - 2v_i m + m^2) = \left(\sum_{i=1}^n v_i^2\right) -2 m\left(\sum_{i=1}^n v_i\right) + nm^2 = \left(\sum_{i=1}^n v_i^2\right) - nm^2\]

Ainsi en maintenant la somme des carrés des valeurs en plus des informations utilisées pour le calcul de la moyenne, il est possible de calculer l'écart type en utilisant une mémoire bornée.

Complétez la fonction suivante :

def ecart_type_table(table, col):
    """Renvoie l'écart type des valeurs que les tuples de ~table~ associent à
    l'attribut ~col~.
    Si ~table~ est vide, renvoie None.
    """
    return None