Salta al contingut
Llegat fiber

Cost de programes recursius

El cost d'una funció recursiva es planteja com una recurrència: el que costa una crida en funció del que costen les crides de dins. Amb dos patrons, la reducció per substracció i la reducció per divisió, resols gairebé totes les de PRO2.

Conceptes clau

  • Recurrències: T(n)T(n) en funció de TT de mides més petites
  • Reducció per substracció: T(n)=a T(n−c)+…T(n) = a\,T(n - c) + \dots
  • Reducció per divisió: T(n)=a T(n/b)+…T(n) = a\,T(n / b) + \dots
  • L'arbre de crides
  • Cost de les funcions sobre arbres
Prova-hoLa pila de crides

0crides fetes
0fondària màxima de la pila
—resultat

Prova-hoTeorema mestre

α = logb a =

Si k < α: domina la recursió, Θ(nα). Si k = α: hi ha un factor log n de més, Θ(nk log n). Si k > α: domina el treball de cada crida, Θ(nk).

Per què?

Amb un bucle, el cost és «voltes × cost d’una volta». Amb una funció recursiva no hi ha voltes: hi ha crides, que en fan d’altres, que en fan d’altres… Al capítol de correctesa recursiva vas veure que potencia(x, n/2) * potencia(x, n/2) és correcte però molt més lent que guardar el resultat en una variable. Quant més lent? I la funció de les escales, per què triga tant amb n=50n = 50?

Per respondre-ho, cal saber calcular el cost d’una funció recursiva.

La idea: una recurrència

Sigui T(n)T(n) el cost de la funció amb una entrada de mida nn. Una crida fa:

  • una feina no recursiva (comparacions, sumes, potser un bucle…), que costa alguna cosa en funció de nn;
  • crides recursives amb mides més petites, que costen TT d’aquelles mides.

Escrivim-ho com una equació. Per exemple, la potència ràpida:

long long potencia(long long x, int n) {
    if (n == 0) return 1;
    long long p = potencia(x, n / 2);
    if (n % 2 == 0) return p * p;
    return p * p * x;
}

fa una feina constant i una crida amb n/2n/2:

T(n)=T(n/2)+Θ(1),T(0)=Θ(1)T(n) = T(n/2) + \Theta(1), \qquad T(0) = \Theta(1)

És una recurrència. Desplegant-la: T(n)=T(n/2)+c=T(n/4)+2c=T(n/8)+3c=…T(n) = T(n/2) + c = T(n/4) + 2c = T(n/8) + 3c = \dots Després de kk passos, T(n/2k)+kcT(n/2^k) + kc, i s’arriba al cas base quan 2k≈n2^k \approx n, és a dir, k≈log⁡2nk \approx \log_2 n. Per tant, T(n)=Θ(log⁡n)T(n) = \Theta(\log n).

L’arbre de crides

Una altra manera de veure-ho: dibuixa totes les crides que es fan. Cada crida és un node, i els seus fills són les crides que fa. El cost total és la suma de la feina no recursiva de tots els nodes.

Amb la versió que repeteix les crides:

long long potencia(long long x, int n) {
    if (n == 0) return 1;
    if (n % 2 == 0) return potencia(x, n / 2) * potencia(x, n / 2);
    return potencia(x, n / 2) * potencia(x, n / 2) * x;
}

cada crida en fa dues amb n/2n/2: T(n)=2T(n/2)+Θ(1)T(n) = 2T(n/2) + \Theta(1). L’arbre de crides és un arbre binari complet: de nn es baixa a 11 en uns log⁡2n\log_2 n nivells, i encara n’hi ha un més per al cas n=0n = 0. Al nivell kk hi ha 2k2^k crides, i en total n’hi ha unes 4n4n. Cost Θ(n)\Theta(n): de logarítmic a lineal només per haver repetit una crida. Amb n=1024n = 1024, la primera versió fa 12 crides i la segona, 4.095.

842112114211211
Arbre de crides de la potència amb la crida repetida, amb n = 8 (sense els casos n = 0). Cada nivell té el doble de crides que l'anterior: 1, 2, 4, 8. Sumen unes 2n, i amb els casos n = 0, unes 4n.

Amb la funció de Fibonacci (la de les escales és la mateixa idea), l’arbre de crides de fib(4) és:

432101210
L'arbre de crides de fib(4). Cada node és una crida amb aquest valor de n. La crida amb n = 2 (en corall) es fa dues vegades; amb n més grans, les repeticions es multipliquen.

La recurrència és T(n)=T(n−1)+T(n−2)+Θ(1)T(n) = T(n-1) + T(n-2) + \Theta(1), i el nombre de crides creix com els mateixos nombres de Fibonacci, que són aproximadament 1,618n1{,}618^n. Exponencial. Comptant crides de debò: fib(10) en fa 177, fib(20) 21.891 i fib(30) 2.692.537.

Pots veure com creix l’arbre de crides a l’eina «La pila de crides» de dalt de tot.

Els patrons

No cal desplegar cada recurrència. Gairebé totes les de PRO2 encaixen en un d’aquests dos patrons.

Reducció per substracció

La crida recursiva és amb n−cn - c (per exemple, n−1n - 1):

T(n)=a T(n−c)+Θ(nk)T(n) = a,T(n - c) + \Theta(n^k)
Cas Cost Exemple
a=1a = 1 Θ(nk+1)\Theta(n^{k+1}) xifres, suma_fins (amb k=0k = 0): Θ(n)\Theta(n). Una recursió que a cada pas recorre el que queda (k=1k = 1): Θ(n2)\Theta(n^2).
a>1a > 1 Θ(an/c)\Theta(a^{n/c}) k de l’exercici 1 d), dues crides amb n−1n - 1: Θ(2n)\Theta(2^n).

A la substracció cal c≥1c \ge 1. fib no encaixa exactament en el patró de substracció, perquè les crides són amb n−1n-1 i n−2n-2. Queda entre 2T(n−2)2T(n-2) i 2T(n−1)2T(n-1), és a dir, entre Θ(2 n)\Theta(\sqrt2^{,n}) i Θ(2n)\Theta(2^n): exponencial (de fet, Θ(1,618n)\Theta(1{,}618^n)).

Reducció per divisió

La crida recursiva és amb n/bn / b:

T(n)=a T(n/b)+Θ(nk)T(n) = a,T(n / b) + \Theta(n^k)

Es compara kk amb log⁡ba\log_b a (que diu com creix el nombre de crides):

Cas Cost Exemple
k>log⁡bak > \log_b a Θ(nk)\Theta(n^k) la «dicotòmica» sobre una llista de l’exercici 2 (a=1a = 1, b=2b = 2, k=1k = 1): Θ(n)\Theta(n). La feina de l’arrel domina.
k=log⁡bak = \log_b a Θ(nklog⁡n)\Theta(n^k \log n) dicotòmica (a=1a = 1, b=2b = 2, k=0k = 0): Θ(log⁡n)\Theta(\log n). Mergesort (a=2a = 2, b=2b = 2, k=1k = 1): Θ(nlog⁡n)\Theta(n \log n).
k<log⁡bak < \log_b a Θ(nlog⁡ba)\Theta(n^{\log_b a}) potència amb la crida repetida (a=2a = 2, b=2b = 2, k=0k = 0): Θ(n)\Theta(n).

La intuïció: l’arbre de crides té log⁡bn\log_b n nivells, i al nivell de baix hi ha alog⁡bn=nlog⁡baa^{\log_b n} = n^{\log_b a} crides. La feina de l’arrel és nkn^k. Si nkn^k és més gran, guanya l’arrel. Si és més petit, guanyen les fulles. Si són iguals, tots els nivells costen el mateix, i com que n’hi ha log⁡n\log n, surt el factor log⁡n\log n.

Recorda que el teorema demana a≥1a \ge 1 i b>1b > 1.

És el teorema mestre (a EDA el veuràs amb més detall i demostrat). Pots provar-lo a l’eina «Teorema mestre» de dalt de tot.

Funcions sobre arbres

Amb arbres, la mida dels fills depèn de la forma de l’arbre, i la recurrència és més incòmoda. Però sovint hi ha una manera més fàcil: comptar per nodes. Si la funció fa una crida per node i a cada crida fa una feina constant (sense comptar les crides de dins), el cost és Θ(n)\Theta(n). Així són mida, alcada, suma i tots els recorreguts.

El problema ve quan a cada node es fa una feina que no és constant. Per exemple, per saber si un arbre és equilibrat (a cada node, les alçades dels dos fills difereixen com a molt en 1):

bool equilibrat(const BinTree<int>& a) {
    if (a.empty()) return true;
    bool ee = equilibrat(a.left());
    bool ed = equilibrat(a.right());
    int dif = alcada(a.left()) - alcada(a.right());
    return ee and ed and dif >= -1 and dif <= 1;
}

A cada node es crida alcada sobre els dos fills, i alcada recorre tot el subarbre. Per tant, cada node és recorregut per alcada una vegada per cada avantpassat que té. En un arbre que és una línia (el cas pitjor), el node de profunditat pp té pp avantpassats: en total 0+1+⋯+(n−1)=Θ(n2)0 + 1 + \dots + (n-1) = \Theta(n^2). (En un arbre equilibrat, cada node té Θ(log⁡n)\Theta(\log n) avantpassats, i el cost és Θ(nlog⁡n)\Theta(n \log n).)

Es pot fer en Θ(n)\Theta(n): ho veuràs al capítol següent.

Errors habituals

Exercicis

ExerciciEscriu la recurrència

Escriu la recurrència i digues el cost de cada funció (nn és el paràmetre o la mida del tros):

// a)
int f(int n) { if (n <= 1) return 1; return f(n - 1) + 1; }

// b)
int g(int n) { if (n <= 1) return 1; return g(n / 2) + g(n / 2); }

// c) (v té n elements entre esq i dre)
int h(const vector<int>& v, int esq, int dre) {
    if (esq > dre) return 0;
    int s = 0;
    for (int i = esq; i <= dre; ++i) s += v[i];
    return s + h(v, esq + 1, dre);
}

// d)
int k(int n) { if (n <= 1) return 1; return k(n - 1) + k(n - 1); }
Mostra la solució
  • a) T(n)=T(n−1)+Θ(1)T(n) = T(n-1) + \Theta(1). Substracció, a=1a = 1, k=0k = 0: Θ(n)\Theta(n).
  • b) T(n)=2T(n/2)+Θ(1)T(n) = 2T(n/2) + \Theta(1). Divisió, a=2a = 2, b=2b = 2, k=0<log⁡22=1k = 0 < \log_2 2 = 1: Θ(n)\Theta(n). (Escriure 2 * g(n / 2) ho deixaria en Θ(log⁡n)\Theta(\log n).)
  • c) T(n)=T(n−1)+Θ(n)T(n) = T(n-1) + \Theta(n) (el bucle recorre el tros). Substracció, a=1a = 1, k=1k = 1: Θ(n2)\Theta(n^2).
  • d) T(n)=2T(n−1)+Θ(1)T(n) = 2T(n-1) + \Theta(1). Substracció, a=2a = 2: Θ(2n)\Theta(2^n). Un altre cas on guardar el resultat (return 2 * k(n - 1);) ho canvia tot: passaria a Θ(n)\Theta(n).

ExerciciUna dicotòmica sobre una llista

Un company fa una «cerca dicotòmica» sobre una list ordenada: a cada pas, avança un iterador fins a l’element del mig del tros (amb ++it) i continua per la meitat que toca. Escriu-ne la recurrència. És millor que una cerca seqüencial?

Mostra la solució

A cada crida, arribar al mig costa Θ(n)\Theta(n) (cal avançar n/2n/2 posicions), i després es fa una crida amb la meitat:

T(n)=T(n/2)+Θ(n)T(n) = T(n/2) + \Theta(n)

Divisió amb a=1a = 1, b=2b = 2, k=1>log⁡21=0k = 1 > \log_2 1 = 0: Θ(n)\Theta(n).

No és millor que una cerca seqüencial, que també és Θ(n)\Theta(n) i molt més senzilla. La cerca dicotòmica només val la pena quan es pot anar al mig en temps constant, com en un vector. A una llista no hi ha accés directe.

ExerciciOrdenació per fusió

L’ordenació per fusió (mergesort) ordena un vector així: si té 0 o 1 elements, ja està ordenat; si no, ordena recursivament cada meitat i després les fusiona (com a la fusió de cues). Escriu-ne la recurrència i el cost. Implementa-la per a un tros v[e..d-1].

Mostra la solució

Dues crides amb la meitat i una fusió lineal:

T(n)=2T(n/2)+Θ(n)T(n) = 2T(n/2) + \Theta(n)

Divisió amb a=2a = 2, b=2b = 2, k=1=log⁡22k = 1 = \log_2 2: Θ(nlog⁡n)\Theta(n \log n). Intuïtivament: hi ha log⁡2n\log_2 n nivells de crides, i a cada nivell, entre totes les crides, es fusionen nn elements.

// Pre: 0 <= e <= d <= v.size(). Post: v[e..d-1] està ordenat (amb els mateixos elements).
void ordena(vector<int>& v, int e, int d) {
    if (d - e <= 1) return;
    int m = (e + d) / 2;
    ordena(v, e, m);
    ordena(v, m, d);
    vector<int> aux;
    int i = e, j = m;
    while (i < m and j < d) {
        if (v[i] <= v[j]) aux.push_back(v[i++]);
        else aux.push_back(v[j++]);
    }
    while (i < m) aux.push_back(v[i++]);
    while (j < d) aux.push_back(v[j++]);
    for (int k = 0; k < int(aux.size()); ++k) v[e + k] = aux[k];
}

Crida inicial: ordena(v, 0, v.size()).

ExerciciArbres: comptar o recurrència?

Digues el cost, en el cas pitjor, de cada funció sobre un arbre binari de nn nodes:

  1. mida(a).
  2. Una funció que, a cada node, crida mida del fill esquerre.
  3. Una funció que només baixa pel fill esquerre fins al final (per trobar el node de més a l’esquerra).
  4. El preordre del capítol de recorreguts, però retornant una llista i concatenant-les amb un bucle que copia la llista del fill esquerre i la del dret.
Mostra la solució
  1. Θ(n)\Theta(n): una crida per node, cadascuna amb feina constant.
  2. Θ(n2)\Theta(n^2): en un arbre que és una línia cap a l’esquerra, cada node recorre tot el que té a sota. Θ(nlog⁡n)\Theta(n \log n) si l’arbre és equilibrat.
  3. Θ(h)\Theta(h), on hh és l’alçada: només es visita un camí. En el cas pitjor (una línia), Θ(n)\Theta(n); en un arbre equilibrat, Θ(log⁡n)\Theta(\log n).
  4. Θ(n2)\Theta(n^2) en el cas pitjor: cada element es copia una vegada per cada avantpassat. Per això la versió del capítol de recorreguts afegeix a una sola llista passada per referència, i és Θ(n)\Theta(n).