Salta al contingut
Llegat fiber

Millores d'eficiència

Quan una funció recalcula el mateix moltes vegades, li pots fer retornar més dades (immersió per resultats) o passar-li dades ja calculades (immersió per dades). Als bucles, la idea és la mateixa: no tornar a calcular el que ja saps.

Conceptes clau

  • Detectar càlculs repetits
  • Immersió d'eficiència per resultats
  • Immersió d'eficiència per dades
  • Exemple: arbre equilibrat en Θ(n)\Theta(n)
  • Millores en programes iteratius: sumes acumulades i rècords

Per què?

Hi ha dues maneres d’arribar a un algorisme eficient: tenir una idea brillant des del principi, o bé escriure primer una solució correcta i senzilla i després millorar-la traient-ne els càlculs innecessaris. La segona és la més fiable, i és la que veuràs aquí.

Les eines són les mateixes immersions del disseny recursiu, però amb un objectiu diferent: no per poder fer la recursió, sinó per fer-la més ràpida.

Com es detecta un càlcul repetit

Mira la teva solució i pregunta’t:

  • Es crida dues vegades la mateixa funció amb els mateixos paràmetres? (fib(n-1) i fib(n-2), que a dins torna a cridar fib(n-2)…)
  • Dins d’una funció recursiva, es crida una altra funció que recorre el mateix que la recursió? (alcada dins d’equilibrat.)
  • Dins d’un bucle, hi ha un altre bucle que recalcula una cosa que es podria anar actualitzant? (Sumar tots els elements de la dreta per a cada posició.)

Si la resposta és sí, hi ha marge per millorar.

Immersió per resultats

La idea: que la funció recursiva retorni més coses de les que demana el problema, perquè qui la crida les aprofiti i no les hagi de calcular.

Arbre equilibrat

Un arbre és equilibrat si, a cada node, les alçades dels dos fills difereixen com a molt en 1. La solució directa (la del capítol anterior) crida alcada a cada node i costa Θ(n2)\Theta(n^2) en el cas pitjor.

Però per saber si un subarbre és equilibrat, la recursió ja recorre tot el subarbre. Podria calcular-ne l’alçada al mateix temps i retornar-la:

// Pre: cert. Post: retorna si a és equilibrat; h és l'alçada de a.
bool equilibrat(const BinTree<int>& a, int& h) {
    if (a.empty()) {
        h = 0;
        return true;
    }
    int he, hd;
    bool ee = equilibrat(a.left(), he);
    bool ed = equilibrat(a.right(), hd);
    h = 1 + max(he, hd);
    return ee and ed and he - hd >= -1 and he - hd <= 1;
}
1243
Aquest arbre és equilibrat: al 2, les alçades dels fills són 1 i 0; a l'1, són 2 i 1. Cada crida retorna, a més de la resposta, l'alçada del seu subarbre, i el pare la fa servir sense tornar-la a calcular.

Ara cada node fa una feina constant a més de les seves dues crides: Θ(n)\Theta(n).

La postcondició de la immersió és més forta que la de la funció original: diu més coses (també l’alçada). La funció original s’obté ignorant el resultat extra:

bool equilibrat(const BinTree<int>& a) {
    int h;
    return equilibrat(a, h);
}

Fibonacci

La funció de Fibonacci directa fa dues crides, fib(n-1) i fib(n-2), i és exponencial. Però si fib(n-1) retornés també fib(n-2), n’hi hauria prou amb una crida:

// Pre: n >= 1. Post: f = fib(n), g = fib(n-1).
void fib2(int n, long long& f, long long& g) {
    if (n == 1) {
        f = 1;
        g = 0;
    }
    else {
        long long f1, g1;
        fib2(n - 1, f1, g1);  // HI: f1 = fib(n-1), g1 = fib(n-2)
        f = f1 + g1;
        g = f1;
    }
}

Una crida per cada valor de nn: T(n)=T(n−1)+Θ(1)T(n) = T(n-1) + \Theta(1), és a dir, Θ(n)\Theta(n). De 1,6n1{,}6^n a nn.

La funció escala del tema anterior és la mateixa: escala(n) = fib(n+1). Per tant, fib2(n + 1, f, g) et dona la versió Θ(n)\Theta(n).

Immersió per dades

L’altra direcció: passar a la crida recursiva informació que ja s’ha calculat, perquè no l’hagi de tornar a calcular. És el reforçament de la precondició del disseny recursiu.

Amb Fibonacci, en lloc de baixar fins a 1 i calcular en tornar, es pot anar pujant des del principi, portant els dos últims valors:

// Pre: 1 <= i <= n, a = fib(i-1), b = fib(i). Post: retorna fib(n).
long long fib_dades(int n, int i, long long a, long long b) {
    if (i == n) return b;
    return fib_dades(n, i + 1, b, a + b);
}
// fib(n) = fib_dades(n, 1, 0, 1), per a n >= 1

També és Θ(n)\Theta(n), i és recursiva final: es pot passar a un bucle de manera mecànica (amb aa i bb com a variables).

Una immersió per dades també serveix quan els nodes d’un arbre necessiten informació dels seus avantpassats: la suma del camí des de l’arrel, la profunditat, el màxim que hi ha per sobre… En lloc de recalcular-la per a cada node, es passa cap avall com a paràmetre.

Millores en programes iteratius

Als bucles la idea és la mateixa: no recalculis el que pots anar actualitzant.

Sumes acumulades

Vols comptar quantes posicions d’un vector tenen un valor igual a la suma de tots els elements de la seva dreta. Directament:

for (int i = 0; i < n; ++i) {
    int s = 0;
    for (int j = i + 1; j < n; ++j) s += v[j];   // suma de la dreta, de nou cada vegada
    if (v[i] == s) ++compte;
}

Θ(n2)\Theta(n^2). Però la suma de la dreta de ii és la de i+1i + 1 més v[i+1]v[i+1]: es pot anar actualitzant si es recorre el vector des del final.

704122131405is = 2
Recorrent des del final, s ja val la suma de la dreta de i: per comparar v[2] = 2 n'hi ha prou d'una comparació. Després, s += v[i].
// Pre: cert. Post: retorna quantes posicions i compleixen v[i] = suma de v[i+1..].
int iguals_suma_dreta(const vector<int>& v) {
    int compte = 0;
    int s = 0;  // suma de v[i+1..]
    int i = int(v.size()) - 1;
    // Inv: -1 <= i < v.size(), s = suma de v[i+1..], compte = posicions de v[i+1..] que compleixen
    while (i >= 0) {
        if (v[i] == s) ++compte;
        s += v[i];
        --i;
    }
    return compte;
}

Θ(n)\Theta(n). Fixa’t que la millora apareix a l’invariant: hi hem afegit «ss = suma de v[i+1..]v[i+1..]», una dada que el bucle manté i que estalvia el bucle de dins.

Rècords

Vols comptar quantes posicions d’un vector són un rècord: tenen un valor més gran que tots els de la seva esquerra. Directament, per a cada ii calcularies el màxim de v[0..i−1]v[0..i-1] amb un bucle: Θ(n2)\Theta(n^2). Però el màxim de l’esquerra de i+1i+1 és el de ii comparat amb v[i]v[i]: es pot anar actualitzant.

// Pre: cert. Post: retorna quantes posicions i tenen v[i] més gran que tots els de v[0..i-1].
int records(const vector<int>& v) {
    int n = int(v.size());
    if (n == 0) return 0;
    int compte = 1;     // v[0] sempre és un rècord
    int maxim = v[0];
    int i = 1;
    // Inv: 1 <= i <= n, maxim = màxim de v[0..i-1], compte = rècords de v[0..i-1]
    while (i < n) {
        if (v[i] > maxim) {
            ++compte;
            maxim = v[i];
        }
        ++i;
    }
    return compte;
}

Θ(n)\Theta(n). Amb [3,1,4,1,5,9,2,6][3, 1, 4, 1, 5, 9, 2, 6] en dona 4 (el 3, el 4, el 5 i el 9).

Errors habituals

Exercicis

ExerciciNodes iguals a la mida

Escriu una funció que compti quants nodes d’un arbre binari tenen un valor igual a la mida del seu subarbre (el nombre de nodes que té a sota, ell inclòs). Ha de costar Θ(n)\Theta(n).

4211
Tots quatre nodes compleixen la condició: el 4 té 4 nodes al seu subarbre, el 2 en té 2 i les fulles amb 1, un.
Mostra la solució

Comprovar-ho a cada node cridant mida seria Θ(n2)\Theta(n^2) en el cas pitjor. Fem una immersió per resultats que retorni també la mida:

// Pre: cert. Post: retorna quants nodes de a tenen un valor igual a la mida del seu subarbre;
//      m és la mida de a.
int iguals_mida(const BinTree<int>& a, int& m) {
    if (a.empty()) {
        m = 0;
        return 0;
    }
    int me, md;
    int n = iguals_mida(a.left(), me) + iguals_mida(a.right(), md);
    m = 1 + me + md;
    if (a.value() == m) ++n;
    return n;
}

Feina constant per node: Θ(n)\Theta(n).

ExerciciEl diàmetre

El diàmetre d’un arbre binari és el nombre de nodes del camí més llarg entre dos nodes qualssevol (no cal que passi per l’arrel). Escriu una funció que el calculi en Θ(n)\Theta(n).

1234567
El camí més llarg (en corall) va del 4 al 7 passant pel 2: 6 nodes. No passa per l'arrel: el millor camí que hi passa només té 5 nodes.
Mostra la solució

El camí més llarg d’un arbre o bé passa per l’arrel (i llavors té he+hd+1h_e + h_d + 1 nodes, on heh_e i hdh_d són les alçades dels fills), o bé és tot dins d’un dels fills (i és el diàmetre d’aquell fill). Necessitem, per a cada subarbre, el diàmetre i l’alçada:

// Pre: cert. Post: retorna el nombre de nodes del camí més llarg entre dos nodes de a;
//      h és l'alçada de a.
int diametre(const BinTree<int>& a, int& h) {
    if (a.empty()) {
        h = 0;
        return 0;
    }
    int he, hd;
    int de = diametre(a.left(), he);
    int dd = diametre(a.right(), hd);
    h = 1 + max(he, hd);
    return max(he + hd + 1, max(de, dd));
}

Sense la immersió (calculant les alçades amb alcada a cada node) seria Θ(n2)\Theta(n^2). Amb ella, Θ(n)\Theta(n).

ExerciciIgual a la suma dels avantpassats

Escriu una funció que compti quants nodes d’un arbre binari tenen un valor igual a la suma de tots els seus avantpassats (els nodes del camí des de l’arrel fins al pare; l’arrel no en té cap, i la suma és 0). Ha de costar Θ(n)\Theta(n).

Mostra la solució

La informació ve de dalt: immersió per dades amb la suma del camí fins al pare.

// Pre: s és la suma dels valors del camí des de l'arrel original fins al pare de a.
// Post: retorna quants nodes de a tenen un valor igual a la suma dels seus avantpassats.
int iguals_avantpassats(const BinTree<int>& a, int s) {
    if (a.empty()) return 0;
    int n = iguals_avantpassats(a.left(), s + a.value()) + iguals_avantpassats(a.right(), s + a.value());
    if (a.value() == s) ++n;
    return n;
}

Crida inicial: iguals_avantpassats(a, 0). Sense el paràmetre, cada node hauria de recalcular la suma del seu camí (que no pot, perquè un BinTree no sap qui és el seu pare), o caldria passar-hi el camí sencer.

ExerciciEl punt d'equilibri

Escriu una funció que retorni la primera posició ii d’un vector tal que la suma de v[0..i]v[0..i] és igual a la suma de v[i+1..n−1]v[i+1..n-1], o −1-1 si no n’hi ha cap. Amb [1,2,3,3,2,1][1, 2, 3, 3, 2, 1] és la 2. Ha de costar Θ(n)\Theta(n).

Mostra la solució

Si calculem el total primer, la suma de la dreta és el total menys la de l’esquerra, que es va acumulant. Dos recorreguts lineals.

// Pre: cert. Post: retorna la primera posició i tal que suma de v[0..i] = suma de v[i+1..], o -1.
int punt_equilibri(const vector<int>& v) {
    int total = 0;
    for (int x : v) total += x;
    int esq = 0;
    int i = 0;
    int r = -1;
    // Inv: 0 <= i <= v.size(), esq = suma de v[0..i-1], cap j < i és un punt d'equilibri;
    //      si r != -1, r = i i i és un punt d'equilibri
    while (i < int(v.size()) and r == -1) {
        if (esq + v[i] == total - esq - v[i]) r = i;
        else {
            esq += v[i];
            ++i;
        }
    }
    return r;
}

Sense el total, per a cada ii caldria sumar els dos costats: Θ(n2)\Theta(n^2).

ExerciciFibonacci iteratiu

Passa fib_dades a un bucle. Quin és l’invariant?

Mostra la solució

Aplicant la recepta de recursiu a iteratiu, amb la crida inicial fib_dades(n, 1, 0, 1):

// Pre: n >= 0. Post: retorna fib(n).
long long fib_iter(int n) {
    if (n == 0) return 0;
    long long a = 0, b = 1;
    int i = 1;
    // Inv: 1 <= i <= n, a = fib(i-1), b = fib(i)
    while (i != n) {
        long long c = a + b;
        a = b;
        b = c;
        ++i;
    }
    return b;
}

L’invariant és la Pre de la immersió. Compte amb l’ordre: la crida feia (b, a + b) amb els valors vells, i per això cal la variable c.