7: Algoritmos genéricos

Tabla de contenidos

7.1. Un primer vistazo
7.2. Objetos-función
7.3. Un catálogo de algoritmos STL
7.4. Creando sus propios algoritmos tipo STL
7.5. Resumen
7.6. Ejercicios

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.

7.1. Un primer vistazo

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.

7.1.1. Predicados

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


7.1.2. Iteradores de flujo

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.

7.1.3. Complejidad algorítmica

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.



[17] N. de T.: Librería Estándar de Plantillas.