Salta al contenuto
Note per Studenti Esercizio - Array dinamico ordinato come classe template (quarto appello 2022-23, programmazione)

Esercizio - Array dinamico ordinato come classe template (quarto appello 2022-23, programmazione)

Esame

Prova di programmazione (60 minuti) del quarto appello 2022-23 del corso di Laboratorio di Programmazione, Ing. Informatica UniPD. Teoria: C++ - template e programmazione genericaun template permette di usare un tipo (o un intero noto in compilazione) come parametro di una classe o di una funzione; il compilatore genera il codice per ogni tipo per cui il template è specializzato (template instantiation, a tempo di compilazione): il codice è efficiente ma gli errori sono lunghi e oscuri, e il compilatore controlla sia il template sia il suo argomento; la notazione è template (o class T, equivalenti) davanti alla classe o alla funzione, e le funzioni membro fuori dalla classe si scrivono template tipo Classe::nome(...); nelle function template il compilatore deduce gli argomenti dai parametri della chiamata (myMax(i, j) invece di myMax(i, j)), ma due argomenti di tipo diverso per lo stesso T sono un errore; una specializzazione esplicita (template<> ...) fornisce il codice per un tipo particolare; i parametri interi (template<typename T, int N> struct array) danno array di dimensione fissa senza free store, più efficienti e che esprimono concetti come la tripletta RGB; poiché il compilatore deve vedere la definizione completa al punto d'uso, le definizioni si mettono nell'header (spesso in un file .hpp incluso in coda al .h).C++ - template e programmazione generica →, C++ - costruttori, inizializzazione, copia e spostamentouna buona interfaccia è completa e minimale, ha i costruttori giusti, supporta o proibisce la copia, usa tipi adeguati, dichiara const ciò che non modifica e libera le risorse nel distruttore; il costruttore di default (senza argomenti) esiste quando c'è un valore iniziale sensato, si ottiene con valori di default nei membri (in-class initializer) o argomenti di default, e explicit vieta la conversione implicita da un argomento; initializer_list permette la lista {a, b, c} e con le graffe ha la precedenza sul costruttore a un intero (vector{3} è un elemento, vector(3) sono tre); la copia di default è membro a membro (shallow copy) e con un puntatore a un buffer duplica il puntatore invece del contenuto (due oggetti che condividono i dati e una doppia delete), quindi servono il costruttore di copia e l'assegnamento di copia che fanno la deep copy (l'assegnamento prima alloca e copia, poi libera i vecchi dati, e deve tollerare l'auto-assegnamento); this è il puntatore all'oggetto corrente, parametro implicito delle funzioni membro; il costruttore e l'assegnamento di spostamento (T&&, rvalue reference) rubano il buffer della sorgente lasciandola vuota ma valida, il compilatore li sceglie da solo (con la copy elision a volte non servono nemmeno); i cinque (distruttore, copia, spostamento) vanno definiti insieme.C++ - costruttori, inizializzazione, copia e spostamento →, C++ - allocazione dinamica della memoria con new e deletela memoria dinamica si prende dal free store (heap) con new, che restituisce un puntatore all'oggetto o al primo elemento dell'array (new T, new T[n], con inizializzatori {}), e si restituisce con delete (per new) e delete[] (per new[]); il puntatore non sa quanti elementi ci sono, la memoria non ha nome e non ha controllo dei limiti; in C++ non c'è garbage collector (scelta per efficienza e controllo), quindi un'area che perde ogni riferimento diventa garbage e produce un memory leak, e gli errori tipici sono la doppia cancellazione e il dangling pointer (si mette il puntatore a nullptr dopo delete); il distruttore ~T() è chiamato dal compilatore quando l'oggetto esce dallo scope o con delete e libera le risorse, per cui una classe come vector acquisisce la memoria nel costruttore e la rilascia nel distruttore; new alloca e chiama il costruttore, delete chiama il distruttore e libera; operator[] con due versioni (T& e const) dà accesso in lettura e scrittura al buffer; si alloca dinamicamente solo se serve davvero (memoria grande, dimensione nota a run-time, durata controllata).C++ - allocazione dinamica della memoria con new e delete →. Esercizi affini: Esercizio - La classe template DynamicStorage (secondo appello 2022-23, programmazione), Esercizio - MyVector 2.0 con template (laboratorio 5).

Testo. Implementare una classe template che gestisce un array dinamico in cui gli elementi sono sempre ordinati. La classe non può fare uso di contenitori standard STL. La classe deve avere: una funzione che inserisce un nuovo elemento nell'array; una funzione che cerca un elemento nell'array e, se è presente, ritorna l'indice; le altre funzioni necessarie per inizializzazione, copia e spostamento.

Progetto

Dati e invariante. T* data_, size_, capacity_ (come in un vettore con buffer sovradimensionato). Invariante: data_[0] <= data_[1] <= ... <= data_[size_-1] (ordine crescente rispetto a operator<). Ogni funzione che modifica il contenuto deve conservarlo; per questo l'accesso agli elementi è in sola lettura (const T& operator[](size_t) const): una scrittura diretta potrebbe rompere l'ordine.

Funzioni.

funzione scelta
insert(const T&) trova con una ricerca binaria la posizione del primo elemento maggiore del valore, sposta a destra gli elementi successivi, scrive; se il buffer è pieno reserve(2 * capacity_) prima. I duplicati sono ammessi (il nuovo va dopo gli uguali: inserimento stabile). Costo: O(log⁡n)O(\log n) per la posizione + O(n)O(n) per lo spostamento
find(const T&) const ricerca binaria (sfrutta l'ordine): O(log⁡n)O(\log n); restituisce l'indice di un elemento uguale, oppure npos (costante static constexpr size_t npos = -1). L'uguaglianza si ricava solo da < (!(a<b) && !(b<a)): così T deve fornire solo operator<
costruttore di default vuoto, nessuna allocazione
distruttore delete[] data_
copia (costruttore e assegnamento) profonda, con capacity_ = size_; l'assegnamento alloca prima, libera dopo, controlla this != &o
spostamento (costruttore e assegnamento) noexcept, ruba il buffer e svuota la sorgente
size(), operator[] const, reserve privato accessori e ingrandimento (raddoppio da 4)

Requisiti su T: costruttore di default (per new T[n]), assegnamento di copia e operator<.

Soluzione

sorted_array.cppcpp
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <string>
#include <utility>
using namespace std;

// Array dinamico template i cui elementi sono SEMPRE ordinati (crescenti). Niente contenitori STL.
// Invarianti: data_[0..size_-1] e' ordinato; 0 <= size_ <= capacity_; data_ == nullptr se capacity_ == 0.
// Requisiti su T: costruttore di default, assegnamento di copia, operator<  (e operator== non serve).
template <typename T>
class SortedArray {
public:
    static constexpr size_t npos = static_cast<size_t>(-1);    // "non trovato"

    SortedArray() : size_{0}, capacity_{0}, data_{nullptr} {}
    ~SortedArray() { delete[] data_; }

    SortedArray(const SortedArray& o) : size_{o.size_}, capacity_{o.size_}, data_{o.size_ ? new T[o.size_] : nullptr}
    {
        copy(o.data_, o.data_ + size_, data_);
    }
    SortedArray& operator=(const SortedArray& o)
    {
        if (this != &o) {
            T* nuovo = o.size_ ? new T[o.size_] : nullptr;
            copy(o.data_, o.data_ + o.size_, nuovo);
            delete[] data_;
            data_ = nuovo;
            size_ = capacity_ = o.size_;
        }
        return *this;
    }
    SortedArray(SortedArray&& o) noexcept : size_{o.size_}, capacity_{o.capacity_}, data_{o.data_}
    {
        o.size_ = o.capacity_ = 0;
        o.data_ = nullptr;
    }
    SortedArray& operator=(SortedArray&& o) noexcept
    {
        if (this != &o) {
            delete[] data_;
            size_ = o.size_; capacity_ = o.capacity_; data_ = o.data_;
            o.size_ = o.capacity_ = 0;
            o.data_ = nullptr;
        }
        return *this;
    }

    // inserisce mantenendo l'ordine: cerca la posizione con una ricerca binaria, sposta a destra, scrive
    void insert(const T& value)
    {
        if (size_ == capacity_) reserve(capacity_ == 0 ? 4 : 2 * capacity_);
        size_t pos = primo_maggiore(value);                    // posizione del primo elemento > value
        for (size_t i = size_; i > pos; --i) data_[i] = data_[i - 1];   // fa posto
        data_[pos] = value;
        ++size_;
    }

    // ricerca binaria: O(log n). Restituisce l'indice di un elemento uguale a value, oppure npos
    size_t find(const T& value) const
    {
        size_t lo = 0, hi = size_;                             // intervallo [lo, hi)
        while (lo < hi) {
            size_t mid = lo + (hi - lo) / 2;
            if (data_[mid] < value) lo = mid + 1;
            else if (value < data_[mid]) hi = mid;
            else return mid;
        }
        return npos;
    }

    size_t size() const { return size_; }
    const T& operator[](size_t i) const { return data_[i]; }   // SOLA lettura: scrivere romperebbe l'ordine

private:
    size_t primo_maggiore(const T& value) const                // indice del primo elemento > value (o size_)
    {
        size_t lo = 0, hi = size_;
        while (lo < hi) {
            size_t mid = lo + (hi - lo) / 2;
            if (value < data_[mid]) hi = mid;
            else lo = mid + 1;
        }
        return lo;
    }
    void reserve(size_t nuova)
    {
        T* nuovo = new T[nuova];
        copy(data_, data_ + size_, nuovo);
        delete[] data_;
        data_ = nuovo;
        capacity_ = nuova;
    }

    size_t size_;
    size_t capacity_;
    T* data_;
};

template <typename T>
void stampa(const SortedArray<T>& a)
{
    for (size_t i = 0; i < a.size(); ++i) cout << a[i] << ' ';
    cout << '\n';
}

int main()
{
    SortedArray<int> a;
    for (int x : {42, 7, 19, 7, 88, 3, 56})                    // inseriti disordinati
        a.insert(x);
    cout << "ordinato: ";
    stampa(a);
    cout << "find(19) = " << a.find(19) << ", find(88) = " << a.find(88)
         << ", find(5) e' npos? " << (a.find(5) == SortedArray<int>::npos) << '\n';

    SortedArray<int> b = a;                                    // copia profonda
    b.insert(1);
    cout << "a ha " << a.size() << " elementi, b ne ha " << b.size() << ": ";
    stampa(b);

    SortedArray<int> c = std::move(a);                         // spostamento
    cout << "dopo lo spostamento: a ha " << a.size() << " elementi, c ne ha " << c.size() << '\n';

    SortedArray<string> s;                                     // anche con le stringhe (ordine alfabetico)
    for (string w : {"pera", "mela", "uva", "banana"}) s.insert(w);
    stampa(s);
    cout << "find(\"uva\") = " << s.find("uva") << '\n';
    return 0;
}
ordinato: 3 7 7 19 42 56 88 
find(19) = 3, find(88) = 6, find(5) e' npos? 1
a ha 7 elementi, b ne ha 8: 1 3 7 7 19 42 56 88 
dopo lo spostamento: a ha 0 elementi, c ne ha 7
banana mela pera uva 
find("uva") = 3

Lettura: gli inserimenti disordinati (42, 7, 19, 7, 88, 3, 56) producono 3 7 7 19 42 56 88; find(19) →\to indice 3 (4º elemento), find(88) →\to 6 (l'ultimo), find(5) →\to npos. La copia b è indipendente (b.insert(1) dà 8 elementi e lascia a a 7); dopo std::move(a), a è vuoto e c ha i 7 elementi. Con string l'ordine è alfabetico (banana mela pera uva) e find("uva") dà 3.

Passi di insert(19) in {3, 7, 7, 42, ...} (esempio): primo_maggiore(19) fa una ricerca binaria dell'indice del primo elemento >19> 19 (qui la posizione del 42); gli elementi da quella posizione in poi si spostano di una posizione a destra, partendo dall'ultimo (altrimenti si sovrascrivono: ciclo for (i = size_; i > pos; --i) data_[i] = data_[i-1];); si scrive 19 in pos.

Versione ripasso

  • Invariante: data_[0] <= ... <= data_[size_-1] (crescente con <); accesso agli elementi solo in lettura (const T& operator[](size_t) const) per non romperlo. Dati: T* data_, size_, capacity_; nessun contenitore STL.
  • insert(v): reserve (raddoppio da 4) se pieno; pos = primo_maggiore(v) con ricerca binaria; sposta a destra dall'ultimo (for (i = size_; i > pos; --i) data_[i] = data_[i-1];); data_[pos] = v; ++size_. Duplicati ammessi. Costo O(log⁡n)+O(n)O(\log n) + O(n).
  • find(v) const: ricerca binaria su [lo,hi)[lo, hi): data_[mid] < v →\to lo = mid + 1; v < data_[mid] →\to hi = mid; altrimenti mid; npos se assente. O(log⁡n)O(\log n); serve solo operator<.
cpp
size_t find(const T& value) const
{ size_t lo = 0, hi = size_;
  while (lo < hi) { size_t mid = lo + (hi - lo) / 2;
                    if (data_[mid] < value) lo = mid + 1;
                    else if (value < data_[mid]) hi = mid;
                    else return mid; }
  return npos; }
  • Inizializzazione, copia, spostamento: default vuoto; distruttore delete[] data_; copia profonda (capacity_ = size_; assegnamento: alloca, copia, libera, this != &o); spostamento noexcept, sorgente svuotata. Requisiti su T: default, assegnamento, operator<.
  • Prova: inserendo 42, 7, 19, 7, 88, 3, 56 →\to 3 7 7 19 42 56 88; find(19) = 3, find(88) = 6, find(5) = npos; copia indipendente (b 8 elementi, a 7); dopo move a vuoto, c 7; string: banana mela pera uva, find("uva") = 3.
  • Errori tipici: spostare gli elementi dall'inizio (si sovrascrivono); ricerca lineare (O(n)O(n)) invece di binaria; operator[] che restituisce T& e rompe l'ordine; mid = (lo + hi) / 2 (overflow); find con == (impone un requisito in più); dimenticare reserve.
  • primo_maggiore(v) (ricerca binaria del punto di inserimento): lo = 0, hi = size_; while (lo < hi) { mid = lo + (hi - lo) / 2; if (value < data_[mid]) hi = mid; else lo = mid + 1; } return lo; →\to il nuovo elemento va dopo gli uguali (inserimento stabile).
  • Costo: insert O(log⁡n)O(\log n) per trovare la posizione + O(n)O(n) per spostare; find O(log⁡n)O(\log n); una ricerca lineare sarebbe O(n)O(n).

Esercizi su questo argomento

Teoria collegata