Tabla de contenidos
Los algoritmos son la base de la computación. Ser capaz de escribir un algoritmo que funcione con cualquier tipo de se secuencia hace que sus programas sean simples y seguros. La habilidad para adaptar algoritmos en tiempo de ejecución a revolucionado el desarrollo de software.
El subconjunto de la Librería Estándar de C++ conocido como Standard Template Library (STL)[17] fue diseñado entorno a algoritmos genéricos —código que procesa secuencias de cualquier tipo de valores de un modo seguro. El objetivo era usar algoritmos predefinidos para casi cualquier tarea, en lugar de codificar a mano cada vez que se necesitara procesar una colección de datos. Sin embargo, ese potencial requiere cierto aprendizaje. Para cuando llegue al final de este capítulo, debería ser capaz de decidir por sí mismo si los algoritmos le resultan útiles o demasiado confusos de recordar. Si es como la mayoría de la gente, se resistirá al principio pero entonces tenderá a usarlos más y más con el tiempo.
Entre otras cosas, los algoritmos genéricos de la librería estándar proporcionan un
vocabulario con el que desribir soluciones. Una vez que los algoritmos le sean
familiares, tendrá un nuevo conjunto de palabras con el que discutir que está
haciendo, y esas palabras son de un nivel mayor que las que tenía antes. No
necesitará decir «Este bucle recorre y asigna de aquí a ahí... oh, ya veo, ¡está
copiando!» En su lugar dirá simplemente copy()
. Esto es lo que
hemos estado haciendo desde el principio de la programación de computadores
—creando abstracciones de alto nivel para expresar lo que está haciendo y
perder menos tiempo diciendo cómo hacerlo. El «cómo» se ha resuelto una vez y para
todo y está oculto en el código del algoritmo, listo para ser reutilizado cuando se
necesite.
Vea aquí un ejemplo de cómo utilizar el algoritmo copy
:
//: C06:CopyInts.cpp // Copies ints without an explicit loop. #include <algorithm> #include <cassert> #include <cstddef> // For size_t using namespace std; int main() { int a[] = { 10, 20, 30 }; const size_t SIZE = sizeof a / sizeof a[0]; int b[SIZE]; copy(a, a + SIZE, b); for(size_t i = 0; i < SIZE; ++i) assert(a[i] == b[i]); } ///:~
Listado 7.1. C06/CopyInts.cpp
Los dos primeros parámetros de copy
representan el rango de la
secuencia de entrada —en este caso del array a
. Los rangos
se especifican con un par de punteros. El primero apunta al primer elemento de la
secuencia, y el segungo apunta una posición después del final del array (justo
después del último elemento). Esto puede parecer extraño al principio, pero es una
antigua expresión idiomática de C que resulta bastante práctica. Por ejemplo, la
diferencia entre esos dos punteros devuelve el número de elementos de la
secuencia. Más importante, en la implementación de copy()
, el
segundo puntero puede actual como un centinela para para la iteración a través de la
secuencia. El tercer argumento hace referencia al comienzo de la secuencia de
salida, que es el array b
en el ejemplo. Se asume que el array
b
tiene suficiente espacio para recibir los elementos copiados.
El algotirmo copy()
no parece muy excitante if solo puediera
procesar enteros. Puede copiar cualquier tipo de secuencia. El siguiente ejemplo
copia objetos string
.
//: C06:CopyStrings.cpp // Copies strings. #include <algorithm> #include <cassert> #include <cstddef> #include <string> using namespace std; int main() { string a[] = {"read", "my", "lips"}; const size_t SIZE = sizeof a / sizeof a[0]; string b[SIZE]; copy(a, a + SIZE, b); assert(equal(a, a + SIZE, b)); } ///:~
Listado 7.2. C06/CopyStrings.cpp
Este ejmeplo presenta otro algoritmo, equal()
, que devuelve
cierto solo si cada elemento de la primera secuencia es igual (usando su
operator==()
) a su elemento correspondiente en la segunda
secuencia. Este ejemplo recorre cada secuencia 2 veces, una para copiar, y otra para
comparar, sin ningún bucle explícito.
Los algoritmos genéricos consiguen esta flexibilidad porque son funciones
parametrizadas (plantillas). Si piensa en la implementación de
copy()
verá que es algo como lo siguiente, que es «casi»
correcto:
template<typename T> void copy(T* begin, T* end, T* dest) { while (begin != end) *dest++ = *begin++; }
Decimos «casi» porque copy()
puede procesar secuencias
delimitadas por cualquier cosa que actúe como un puntero, tal como un iterador. De
ese modo, copy()
se puede utilizar para duplicar un
vector
, como en el siguiente ejemplo.
//: C06:CopyVector.cpp // Copies the contents of a vector. #include <algorithm> #include <cassert> #include <cstddef> #include <vector> using namespace std; int main() { int a[] = { 10, 20, 30 }; const size_t SIZE = sizeof a / sizeof a[0]; vector<int> v1(a, a + SIZE); vector<int> v2(SIZE); copy(v1.begin(), v1.end(), v2.begin()); assert(equal(v1.begin(), v1.end(), v2.begin())); } ///:~
Listado 7.3. C06/CopyVector.cpp
El primer vector, v1
, es inicializado a partir de una secuencia
de enteros en el array a
. La definición del vector
v2
usa un contructor diferente de vector
que reserva sitio para SIZE
elementos, inicializados a cero (el
valor por defecto para enteros).
Igual que con el ejemplo anterior con el array, es importante que
v2
tenga suficiente espacio para recibir una copia de los
contenidos de v1
. Por conveniencia, una función de librería
especial, back_inserter()
, retorna un tipo especial de iterador
que inserta elementos en lugar de sobre-escribirlos, de modo que la memoria del
contenedor se expande conforme se necesita. El siguiente ejemplo usa
back_inserter()
, y por eso no hay que establecer el tamaño del
vector de salida, v2
, antes de tiempo.
//: C06:InsertVector.cpp // Appends the contents of a vector to another. #include <algorithm> #include <cassert> #include <cstddef> #include <iterator> #include <vector> using namespace std; int main() { int a[] = { 10, 20, 30 }; const size_t SIZE = sizeof a / sizeof a[0]; vector<int> v1(a, a + SIZE); vector<int> v2; // v2 is empty here copy(v1.begin(), v1.end(), back_inserter(v2)); assert(equal(v1.begin(), v1.end(), v2.begin())); } ///:~
Listado 7.4. C06/InsertVector.cpp
La función back_inserter()
está definida en el fichero de
cabecera <iterator>
. Explicaremos los iteradores de
inserción en profundidad en el próximo capítulo.
Dado que los iteradores son idénticos a punteros en todos los sentidos importantes,
puede escribir los algoritmos de la librería estándar de modo que los argumentos
puedan ser tanto punteros como iteradores. Por esta razón, la implementación de
copy()
se parece más al siguiente código:
template<typename Iterator> void copy(Iterator begin, Iterator end, Iterator dest) { while (begin != end) *begin++ = *dest++; }
Para cualquier tipo de argumento que use en la llamada, copy()
asume que implementa adecuadamente la indirección y los operadores de incremento. Si
no lo hace, obtendrás un error de compilación.
A veces, podría querer copiar solo un subconjunto bien definido de una secuencia a
otra; solo aquellos elementos que satisfagan una condición particular. Para
conseguir esta flexibilidad, muchos algoritmos tienen una forma alternativa de
llamada que permite proporcionar un predicado, que es simplemente una función que
retorna un valor booleano basado en algún criterio. Suponga por ejemplo, que solo
quiere extraer de una secuencia de enteros, aquellos que son menores o iguales de
15. Una versión de copy()
llamada
remove_copy_if()
puede hacer el trabajo, tal que así:
//: C06:CopyInts2.cpp // Ignores ints that satisfy a predicate. #include <algorithm> #include <cstddef> #include <iostream> using namespace std; // You supply this predicate bool gt15(int x) { return 15 < x; } int main() { int a[] = { 10, 20, 30 }; const size_t SIZE = sizeof a / sizeof a[0]; int b[SIZE]; int* endb = remove_copy_if(a, a+SIZE, b, gt15); int* beginb = b; while(beginb != endb) cout << *beginb++ << endl; // Prints 10 only } ///:~
Listado 7.5. C06/CopyInts2.cpp
La función remove_copy_if()
acepta los rangos definidos por
punteros habituales, seguidos de un predicado de su elección. El predicado debe
ser un puntero a función[FIXME] que toma un argumento simple del mismo tipo que
los elementos de la secuencia, y que debe retornar un booleano. Aquí, la función
gt15
returna verdadero si su argumento es mayor que 15. El
algoritmo remove_copy_if()
aplica gt15()
a cada elemento en la secuencia de entrada e ignora aquellos elementos para los
cuales el predicado devuelve verdad cuando escribe la secuencia de salida.
El siguiente programa ilustra otra variación más del algoritmo de copia.
//: C06:CopyStrings2.cpp // Replaces strings that satisfy a predicate. #include <algorithm> #include <cstddef> #include <iostream> #include <string> using namespace std; // The predicate bool contains_e(const string& s) { return s.find('e') != string::npos; } int main() { string a[] = {"read", "my", "lips"}; const size_t SIZE = sizeof a / sizeof a[0]; string b[SIZE]; string* endb = replace_copy_if(a, a + SIZE, b, contains_e, string("kiss")); string* beginb = b; while(beginb != endb) cout << *beginb++ << endl; } ///:~
Listado 7.6. C06/CopyStrings2.cpp
En lugar de simplemente ignorar elementos que no satisfagan el predicado,
replace_copy_if()
substituye un valor fijo para esos
elementos cuando escribe la secuencia de salida. La salida es:
kiss my lips
como la ocurrencia original de «read», la única cadena de entrada que
contiene la letra «e», es reemplazada por la palabra
«kiss», como se especificó en el último argumento en la llamada a
replace_copy_if()
.
El algoritmo replace_if()
cambia la secuencia original in
situ, en lugar de escribir en una secuencia de salida separada, tal como muestra
el siguiente programa:
//: C06:ReplaceStrings.cpp // Replaces strings in-place. #include <algorithm> #include <cstddef> #include <iostream> #include <string> using namespace std; bool contains_e(const string& s) { return s.find('e') != string::npos; } int main() { string a[] = {"read", "my", "lips"}; const size_t SIZE = sizeof a / sizeof a[0]; replace_if(a, a + SIZE, contains_e, string("kiss")); string* p = a; while(p != a + SIZE) cout << *p++ << endl; } ///:~
Listado 7.7. C06/ReplaceStrings.cpp
Como cualquier otra buena librería, la Librería Estándar de C++ intenta proporcionar modos convenientes de automatizar tareas comunes. Mencionamos al principio de este capítulo puede usar algoritmos genéricos en lugar de bucles. Hasta el momento, sin embargo, nuestros ejemplos siguen usando un bucle explícito para imprimir su salida. Dado que imprimir la salida es una de las tareas más comunes, es de esperar que haya una forma de automatizar eso también.
Ahí es donde los iteradores de flujo entran en juego. Un iterador de flujo usa un
flujo como secuencia de entrada o salida. Para eliminar el bucle de salida en el
programa CopyInts2.cpp
, puede hacer algo como lo siguiente:
//: C06:CopyInts3.cpp // Uses an output stream iterator. #include <algorithm> #include <cstddef> #include <iostream> #include <iterator> using namespace std; bool gt15(int x) { return 15 < x; } int main() { int a[] = { 10, 20, 30 }; const size_t SIZE = sizeof a / sizeof a[0]; remove_copy_if(a, a + SIZE, ostream_iterator<int>(cout, "\n"), gt15); } ///:~
Listado 7.8. C06/CopyInts3.cpp
En este ejemplo, reemplazaremos la secuencia de salida b
en el
tercer argumento de remove_copy_if()
con un iterador de flujo
de salida, que es una instancia de la clase
ostream_iterator
declarada en el fichero
<iterator>
. Los iteradores de flujo de salida sobrecargan
sus operadores de copia-asignación para escribir a sus flujos. Esta instancia en
particular de ostream_iterator
está vinculada al flujo de
salida cout
. Cada vez que remove_copy_if()
asigna un entero de la secuencia a
a cout
a
través de este iterador, el iterador escribe el entero a cout
y
automáticamente escribe también una instancia de la cada de separador indicada en
su segundo argumento, que en este caso contiene el carácter de nueva linea.
Es igual de fácil escribir en un fichero proporcionando un flujo de salida
asociado a un fichero en lugar de cout
.
//: C06:CopyIntsToFile.cpp // Uses an output file stream iterator. #include <algorithm> #include <cstddef> #include <fstream> #include <iterator> using namespace std; bool gt15(int x) { return 15 < x; } int main() { int a[] = { 10, 20, 30 }; const size_t SIZE = sizeof a / sizeof a[0]; ofstream outf("ints.out"); remove_copy_if(a, a + SIZE, ostream_iterator<int>(outf, "\n"), gt15); } ///:~
Listado 7.9. C06/CopyIntsToFile.cpp
Un iterador de flujo de entrada permite a un algoritmo leer su secuencia de
entrada desde un flujo de entrada. Esto se consigue haciendo que tanto el
constructor como operator++()
lean el siguiente elemento del
flujo subyacente y sobrecargando operator*()
para conseguir
el valor leído previamente. Dado que los algoritmos requieren dos punteros para
delimitar la secuencia de entrada, puede construir un
istream_iterator
de dos formas, como puede ver en el
siguiente programa.
//: C06:CopyIntsFromFile.cpp // Uses an input stream iterator. #include <algorithm> #include <fstream> #include <iostream> #include <iterator> #include "../require.h" using namespace std; bool gt15(int x) { return 15 < x; } int main() { ofstream ints("someInts.dat"); ints << "1 3 47 5 84 9"; ints.close(); ifstream inf("someInts.dat"); assure(inf, "someInts.dat"); remove_copy_if(istream_iterator<int>(inf), istream_iterator<int>(), ostream_iterator<int>(cout, "\n"), gt15); } ///:~
Listado 7.10. C06/CopyIntsFromFile.cpp
El primer argumento de replace_copy_if()
en este programa
asocia un objeto istream_iterator
al fichero de entrada que
contiene enteros. El segundo argumento usa el constructor por defecto de la clase
istream_iterator
. Esta llamada construye un valor especial
de istream_iterator
que indica el fin de fichero, de modo que
cuando el primer iterador encuentra el final del fichero físico, se compara con el
valor de istream_iterator<int>()
, permitiendo al algoritmo terminar
correctamente. Fíjese que este ejemplo evita usar un array explícito.
Usar una librería es una cuestión de confianza. Debe confiar en que los desarrolladores no solo proporcionan la funcionalidad correcta, sino también esperar que las funciones se ejecutan tan eficientemente como sea posible. Es mejor escribir sus propios bucles que usar algoritmos que degradan el rendimiento.
Para garantizar la calidad de las implementaciones de la librería, la estándar de C++ no solo especifica lo que debería hacer un algoritmo, también cómo de rápido debería hacerlo y a veces cuánto espacio debería usar. Cualquier algoritmo que no cumpla con los requisitos de rendimiento no es conforma al estándar. La medida de la eficiencia operacional de un algoritmo se llama complejidad.
Cuando es posible, el estándar especifica el número exacto de operaciones que un
algoritmo debería usar. El algoritmo count_if()
, por ejemplo,
retorna el número de elementos de una secuencia que cumplan el predicado
especificado. La siguiente llamada a count_if()
, si se aplica
a una secuencia de enteros similar a los ejemplos anteriores de este capítulo,
devuelve el número de elementos mayores que 15:
size_t n = count_if(a, a + SIZE, gt15);
Dado que count_if()
debe comprobar cada elemento exactamente
una vez, se especificó hacer un número de comprobaciones que sea exactamente igual
que el número de elementos en la secuencia. El algoritmo
copy()
tiene la misma especificación.
Otros algoritmos pueden estar especificados para realizar cierto número máximo de
operaciones. El algoritmo find()
busca a través de una
secuencia hasta encontrar un elemento igual a su tercer argumento.
int* p = find(a, a + SIZE, 20);
Para tan pronto como encuentre el elemento y devuelve un puntero a la primera
ocurrencia. Si no encuentra ninguno, retorna un puntero a una posición pasado el
final de la secuencia (a+SIZE
en este ejemplo). De modo que
find()
realiza como máximo tantas comparaciones como
elementos tenga la secuencia.
A veces el número de operaciones que realiza un algoritmo no se puede medir con
tanta precisión. En esos casos, el estándar especifica la complejidad asintótica
del algoritmo, que es una medida de cómo se comportará el algoritmo con secuencias
largas comparadas con formulas bien conocidas. Un buen ejemplo es el algoritmo
sort()
, del que el estándar dice que requiere
«aproximadamente n log n comparaciones de media» (n es el número de
elementos de la secuencia). [FIXME]. Esta medida de complejidad da una idea del
coste de un algoritmo y al menos le da una base fiable para comparar
algoritmos. Como verá en el siguiente capítulo, el método
find()
para el contendor set
tiene
complejidad logarítmica, que implica que el coste de una búsqueda de un elemento
en un set
será, para conjuntos grandes, proporcional al
logaritmo del número de elementos. Eso es mucho menor que el número de elementos
para un n grande, de modo que siempre es mejor buscar en un
set
utilizando el método en lugar del algoritmo genérico.