INF3105 — Structures de données et algorithmes
Été 2024
UQAM
Département d'informatique

TP1: Tableaux dynamiques

Objectifs

Tâches

Le TP1 consiste à:
  1. Terminer l'implémentation de la classe générique Tableau amorcée au laboratoire 3.
  2. Implémenter une classe Matrice qui utilise la classe Tableau pour représenter une matrice d'éléments.
  3. Effectuer une analyse empirique de la complexité de la multiplication de matrices.
Un squelette de départ est fourni dans tp1.zip.

Interface publique

L'implémentation de votre classe Tableau doit offrir l'interface publique suivante. Tous les constructeurs, fonctions et opérateurs suivants doivent être implémentés.

template <class T>
class Tableau {
  public:
    // Constructeurs
    Tableau(int capacite_initiale=4);
    Tableau(const Tableau<T>&);
    ~Tableau();

    // Fonctions de modifications
    void           ajouter(const T& element); // à la fin
    void           vider();
    void           inserer(const T& element, int index = 0);
    void           enlever(int index = 0);
    void           enlever_dernier();

    // Opérateurs d'accès []
    T&	           operator[] (int index);
    const T&       operator[] (int index) const;

    // Fonctions utilitaires
    bool           vide() const;
    int            taille() const;
    int            chercher(const T& element) const; // retourne indice première occurrence
    bool           contient(const T& element) const;

    // Opérateurs
    Tableau<T>& operator = (const Tableau<T>& autre);
    bool operator == (const Tableau<T>& autre) const;
  private:
    // ...
};

La classe Matrice doit quant-à-elle offrir l'interface publique suivante en faisant usage d'un tableau unidimensionnel pour représenter une matrice.

template <class T>
class Matrice {
  public:
    // Constructeur
    Matrice(int lignes = 1, int colonnes = 1);

    Matrice t() const; // Transposition de la matrice
    void ajouterLigne(int position, const T& element = T());

    // Opérateurs d'accès ()
    T&	           operator() (int i, int j);
    const T&       operator() (int i, int j) const;

    // Opérateurs
    bool operator == (const Matrice<T>& autre) const;
    bool operator != (const Matrice<T>& autre) const;
    Matrice<T> operator + (const Matrice<T>& autre) const;
    Matrice<T> operator * (const Matrice<T>& autre) const;

    friend std::istream& operator >> (std::istream& is, Matrice<T>& matrice);
  private:
    // ...
};

Vous pouvez ajouter d'autres fonctionnalités, mais pas en retirer. Si vous ajoutez d'autres fonctionnalités, ces dernières ne devraient pas nuire au fonctionnement et à la performance de celles demandées. Vous pouvez modifier la représentation autant que vous le souhaitez.

Optimisation du produit matriciel

Vous devez implémenter l'opérateur de produit matriciel (operator*) en utilisant l'algorithme classique de manière efficace. Il est possible de prouver mathématiquement que l'ordre des trois boucles imbriquées permettant le calcul du produit n'influence pas le résultat obtenu. De plus, la complexité temporelle asymptotique reste la même en permutant les boucles. On aimerait cependant avoir une analyse empirique qui permettrait de déterminer si ces résultats théoriques s'observent en pratique. Il vous est donc demandé:

  1. d'implémenter le produit matriciel des six manières possibles (une méthode privée pour chacune des permutations de boucles possible);
  2. de compléter la fonction eval_vitesse.cpp:evalEmpirique() qui génère sur la sortie standard un fichier csv contenant les temps d'exécution de chacune des six méthodes sur les matrices de tailles croissantes fournies (voir format plus bas);
  3. de générer un graphique contenant les six courbes correspondantes à partir des données générées à l'étape précédente (un script utilisant gnuplot pour générer le graphique à partir de votre fichier de données est également fourni (gnuplot est installable en faisant sudo apt install gnuplot) mais vous pouvez utiliser un autre outil pour générer le graphique si vous préférez);
  4. d'utiliser la meilleure des solutions pour l'implémentation de l'operator*.

Format de sortie de eval_vitesse

Le programme eval_vitesse doit générer une table ayant le format suivant:
Taille  IJK  IKJ  JIK  JKI  KIJ  KJI
100       ?    ?    ?    ?    ?    ?
200       ?    ?    ?    ?    ?    ?
300       ?    ?    ?    ?    ?    ?
400       ?    ?    ?    ?    ?    ?
500       ?    ?    ?    ?    ?    ?
600       ?    ?    ?    ?    ?    ?
700       ?    ?    ?    ?    ?    ?
800       ?    ?    ?    ?    ?    ?
900       ?    ?    ?    ?    ?    ?
1000      ?    ?    ?    ?    ?    ?
1100      ?    ?    ?    ?    ?    ?
1200      ?    ?    ?    ?    ?    ?
où les nombres sur une même ligne sont délimités par une tabulation et les '?' sont les temps d'exécution en milli-secondes que vous obtiendrai pour les différentes méthodes de multiplication de matrices. Les temps d'exécution doivent être arrondis à la milliseconde près.

Perfection

Votre implémentation doit viser la perfection ! Lorsque vous avez différents choix possibles, au niveau de la représentation ou des algorithmes, vous devez autant que possible prendre les meilleurs choix possibles. Ces choix peuvent avoir des impacts différents sur l'efficacité (temps et mémoire) et la lisibilité du code.

Par exemple, examinons le constructeur par copie suivant.

template <class T>
Tableau<T>::Tableau(const Tableau& autre)
        : nbElements(0), capacite(0), elements(nullptr) {
    for(int i = 0; i < autre.taille(); i++)
        ajouter(autre[i]);
}

L'implémentation ci-haut fonctionne correctement (bon résultat). Sa simplicité fait en sorte qu'elle est facile à le comprendre et à le vérifier. Toutefois, cette implémentation est imparfaite sur le plan de l'efficacité. Il faut être vigilant au fait que la fonction publique ajouter vérifie systématiquement la capacité du tableau et que dans le cas ci-haut, elle provoquera plusieurs agrandissements, donc plusieurs allocations de mémoire. Lorsque la taille ultime du tableau est inconnue à l'avance, nous avons vu que cela est même inévitable.

Cependant, dans le présent contexte, les réallocations du tableau natif sont évitables. En effet, la taille du tableau autre étant connue, il est possible de construire le nouveau tableau natif directement avec la bonne capacité. C'est ainsi que nous avons obtenu la solution proposée dans les notes de cours, reproduite ci-dessous.

template <class T>
Tableau<T>::Tableau(const Tableau& autre)
        : capacite(autre.nbElements), // ou autre.capacite
          nbElements(autre.nbElements),
          elements(new T[capacite]) {
    for(int i = 0; i < nbElements; i++)
        elements[i] = autre.elements[i];
}

Compromis

Atteindre la perfection dans toutes dimensions (temps, mémoire, simplicité, etc.) simultanément n'est pas toujours possible. Lorsque certaines dimensions sont en opposition, des compromis sont requis. Par exemple, nous avons vu que de doubler la capacité du tableau natif était une politique appropriée. En effet, c'est un compromis entre le temps d'exécution et la mémoire utilisée. Cela permet à la fonction ajouter d'avoir un coût amorti en O(1). Toutefois, le prix à payer est d'accepter une certaine perte de mémoire. En effet, on peut se retrouver avec des situations où un tableau est à moitié plein. Généralement, il s'agit d'une perte raisonnable étant donné l'avantage d'obtenir un ajout en temps constant.

Dans le cadre du TP1, lorsqu'il n'est pas possible d'atteindre la perfection dans toutes les dimensions, vous devez prioriser l'ordre suivant:

  1. fonctionnement correct (quasi toujours non négociable);
  2. efficacité temporelle (en ordre de grandeur, ensuite temps réel);
  3. efficacité en mémoire;
  4. simplicité du code.

Contraintes

Bibliothèques permises

Vous devez implémenter et utiliser votre propre classe générique Tableau selon le squelette dans tableau.h du laboratoire 3. Vous devez implémenter votre propre classe générique Matrice dans un fichier matrice.h. Pour l'instant, l'utilisation des conteneurs de la bibliothèque standard de C++ (ex.: std::vector ou std::list) n'est pas permise. Ce sera permis plus tard dans le cours.

Environnement de développement

Relisez les Politiques et les directives sur les outils informatiques dans le cours INF3105. Votre TP1 doit pouvoir être compilé avec g++ version 11 (version installée sur le serveur java.labunix.uqam.ca).

Taille des équipes

Vous pouvez faire ce travail en équipe de 1 ou 2. Toutefois, tous les membres de l'équipe doivent contribuer à l'ensemble du travail et non à seulement quelques parties. Le travail d'équipe vise à favoriser les discussions et l'entraide. Le travail d'équipe ne vise pas à réduire la tâche. Ainsi, se diviser la tâche en deux n'est pas une méthode de travail d'équipe appropriée dans ce cours. La participation inadéquate des membres de l'équipe peut être considérée comme du plagiat. Le professeur et le correcteur pourront sélectionner quelques équipes au hasard afin de vérifier que tous les membres sont capables d'expliquer l'ensemble du travail.

Tests

Des tests sont inclus avec le squelette de départ dans tp1.zip. Les exécutables test_tableau et test_matrice permettent de tester les classes Tableau et Matrice respectivement. Les tests ne sont cependant pas exhaustifs. Il est suggéré d'effectuer des tests supplémentaires pour valider votre code.

Les matrices à utiliser pour effectuer l'évaluation empirique de vos produits matriciels sont disponibles dans tests.tar.xz. Vous devez extraire les tests à la racine de votre projet. La commande pour extraire est

tar xaf tests.tar.xz

Remise

Vous devez remettre un fichier tp1.zip contenant les fichiers suivants:
  1. tableau.h
  2. matrice.h
  3. data.csv (fichier généré par eval_vitesse)
  4. graphique.(png,jpg,bmp,pdf) (représentation visuelle des données dans data.csv)

Vous devez soumettre votre TP au plus tard le 2 juin 2024 à 23h59m59s EST (heure de Montréal).
Tous les membres de l'équipe doivent:

Vous pouvez soumettre votre TP et les réponses au questionnaire autant de fois que vous voulez. Seule la dernière soumission sera considérée.

Évaluation

Ce travail pratique compte pour 5% de la note finale.

Grille de correction

Critère Description Points
A. Respect des directives pour la remise
  • Fichiers demandés seulement. Aucun fichier binaire (ex.: .o, .obj, .gch, exécutable, etc.). Aucun fichier test. Aucun fichier caché.
  • Remise par le formulaire Web. Pas de remise par courriel.
  • Compilable avec make sans modification.
  • Exécutable sans modification.
10
B. Appréciation générale
  • Structure du programme + Qualité du code :
    • Découpage du programme (tout n'est pas dans la fonction main()); pas de fonction trop longue; éviter la duplication de code.
    • Choix des types de données; identificateurs (noms) significatifs, lisibilité du code, pertinence des commentaires; etc.
    • Justesse de l'usage du mot clé const, des références (&) et des pointeurs (*).
  • Encapsulation :
    • Respect des principes de l'abstraction;
    • Cachez le maximum de la représentation des objets en rendant un maximum d'attributs privés;
    • Évitez autant que possible les bris d'abstraction, comme des getters et setters qui retournent ou affectent directement des attributs d'un type abstrait de donnée. Par exemple, les fonctions getX() et getY() ne devraient pas exister dans une classe Point. Mais, une fonction getNom()dans une classe Region peut être justifiée/tolérée.
    • Utilisation appropriée des modificateurs d'accès public, protected et private, et du mot clé friend, etc.
10
C. Gestion de la mémoire
  • Pas de fuite de mémoire.
  • Pas de double libération de mémoire.
  • Pas d'accès à de la mémoire libérée.
  • Pas d'utilisation de mémoire non initialisée.
10
D. Fonctionnement correct
15 points: les tests du Tableau (fournis et ajoutés) fonctionnent tous.
25 points: les tests de la Matrice (fournis et ajoutés) fonctionnent tous.

40
E. Exactitude de l'auto-évaluation déclarée dans le formulaire de remise
Vous déclarez que votre programme fonctionne
Correctement Partiellement Aucunement (Déclaration absente)
À la correction, votre programme fonctionne correctement (D=40) 1 0.5 0 0
À la correction, votre programme fonctionne partiellement (0<D<40) 0.5 1 0.5 0
À la correction, votre programme ne fonctionne aucunement (D=0) 0 0 1 0

Attention : les tests fournis ne sont pas nécessairement ceux qui seront utilisés pour la correction. De nouveaux tests pourront être utilisés.

10
F. Analyse et complexité
Réponses correctes au questionnaire portant sur le TP1.
20

Total 100

Pénalités