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: en funció de de mides més petites
- Reducció per substracció:
- Reducció per divisió:
- L'arbre de crides
- Cost de les funcions sobre arbres
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 ?
Per respondre-ho, cal saber calcular el cost d’una funció recursiva.
La idea: una recurrència
Sigui el cost de la funció amb una entrada de mida . Una crida fa:
- una feina no recursiva (comparacions, sumes, potser un bucle…), que costa alguna cosa en funció de ;
- crides recursives amb mides més petites, que costen 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 :
És una recurrència. Desplegant-la: Després de passos, , i s’arriba al cas base quan , és a dir, . Per tant, .
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 : . L’arbre de crides és un arbre binari complet: de es baixa a en uns nivells, i encara n’hi ha un més per al cas . Al nivell hi ha crides, i en total n’hi ha unes . Cost : de logarítmic a lineal només per haver repetit una crida. Amb , la primera versió fa 12 crides i la segona, 4.095.
Amb la funció de Fibonacci (la de les escales és la mateixa idea), l’arbre de crides de fib(4) és:
La recurrència és , i el nombre de crides creix com els mateixos nombres de Fibonacci, que són aproximadament . 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 (per exemple, ):
| Cas | Cost | Exemple |
|---|---|---|
xifres, suma_fins (amb ): . Una recursió que a cada pas recorre el que queda (): . |
||
k de l’exercici 1 d), dues crides amb : . |
A la substracció cal . fib no encaixa exactament en el patró de substracció, perquè les crides són amb i . Queda entre i , és a dir, entre i : exponencial (de fet, ).
Reducció per divisió
La crida recursiva és amb :
Es compara amb (que diu com creix el nombre de crides):
| Cas | Cost | Exemple |
|---|---|---|
| la «dicotòmica» sobre una llista de l’exercici 2 (, , ): . La feina de l’arrel domina. | ||
| dicotòmica (, , ): . Mergesort (, , ): . | ||
| potència amb la crida repetida (, , ): . |
La intuïció: l’arbre de crides té nivells, i al nivell de baix hi ha crides. La feina de l’arrel és . Si é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 , surt el factor .
Recorda que el teorema demana i .
É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 . 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 té avantpassats: en total . (En un arbre equilibrat, cada node té avantpassats, i el cost és .)
Es pot fer en : 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ó ( é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) . Substracció, , : .
- b) . Divisió, , , : . (Escriure
2 * g(n / 2)ho deixaria en .) - c) (el bucle recorre el tros). Substracció, , : .
- d) . Substracció, : . Un altre cas on guardar el resultat (
return 2 * k(n - 1);) ho canvia tot: passaria a .
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 (cal avançar posicions), i després es fa una crida amb la meitat:
Divisió amb , , : .
No és millor que una cerca seqüencial, que també és 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:
Divisió amb , , : . Intuïtivament: hi ha nivells de crides, i a cada nivell, entre totes les crides, es fusionen 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 nodes:
mida(a).- Una funció que, a cada node, crida
midadel fill esquerre. - Una funció que només baixa pel fill esquerre fins al final (per trobar el node de més a l’esquerra).
- El
preordredel 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ó
- : una crida per node, cadascuna amb feina constant.
- : en un arbre que és una línia cap a l’esquerra, cada node recorre tot el que té a sota. si l’arbre és equilibrat.
- , on és l’alçada: només es visita un camí. En el cas pitjor (una línia), ; en un arbre equilibrat, .
- 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 .