Cómo funcionan los algoritmos
Chapter 8 Algorithm Analysis Techniques

Capítulo 8: Técnicas de Análisis de Algoritmos

En los capítulos anteriores, exploramos una amplia gama de algoritmos y estructuras de datos fundamentales, cubriendo temas como ordenamiento, búsqueda, gráficos, cadenas de texto y diversas aplicaciones. Si bien implementar y comprender estos algoritmos es crucial, también es igualmente importante analizar sus características de rendimiento para tomar decisiones informadas al seleccionar algoritmos para problemas específicos. En este capítulo, nos adentramos en las técnicas utilizadas para analizar y evaluar algoritmos, centrándonos en modelos matemáticos, estudios empíricos y visualización de algoritmos.

Modelos Matemáticos y Análisis

El análisis matemático es una herramienta poderosa para comprender el comportamiento y el rendimiento de los algoritmos. Al desarrollar modelos matemáticos que capturan las características esenciales de un algoritmo, podemos razonar sobre su eficiencia y escalabilidad. Estos modelos nos permiten hacer predicciones sobre el tiempo de ejecución, el uso de memoria y otras métricas de rendimiento de un algoritmo, lo que nos permite comparar diferentes algoritmos y elegir el más adecuado para una tarea determinada.

Notación Big-O

Una de las notaciones más utilizadas para describir el rendimiento de un algoritmo es la notación big-O. La notación big-O proporciona una cota superior en la tasa de crecimiento de una función, lo que nos permite caracterizar el peor caso de un algoritmo en términos de tiempo de ejecución o complejidad de espacio.

En la notación big-O, expresamos el rendimiento de un algoritmo como una función del tamaño de la entrada, generalmente denotada como n. Por ejemplo, considera la siguiente función simple que calcula la suma de los primeros n enteros positivos:

public static int sum(int n) {
    // Inicializar la suma a 0
    int sum = 0;
    // Iterar desde 1 hasta n y sumar los valores
    for (int i = 1; i <= n; i++) {
        sum += i;
    }
    // Devolver la suma
    return sum;
}

El tiempo de ejecución de esta función crece linealmente con el tamaño de la entrada n. Podemos expresar esto usando la notación big-O como O(n), lo que indica que el tiempo de ejecución es proporcional al tamaño de la entrada. Esto significa que a medida que elAquí está la traducción al español del archivo Markdown, con los comentarios traducidos al español y el código sin traducir:

A medida que el tamaño de la entrada aumenta, el tiempo de ejecución del algoritmo aumenta como máximo de forma lineal.

La notación Big-O nos permite ignorar los factores constantes y los términos de orden inferior, centrándonos en el término dominante que determina la tasa de crecimiento de la función. Por ejemplo, considera la siguiente función:

public static int sumOfSquares(int n) {
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= i; j++) {
            sum += j;
        }
    }
    return sum;
}

El tiempo de ejecución de esta función es proporcional al cuadrado de N. Para ser precisos, el número de veces que se ejecuta la instrucción sum += j es 1 + 2 + ... + N ~ N^2/2.

En general, podemos expresar el tiempo de ejecución de un programa en función del tamaño de la entrada utilizando la notación Big-O. Esto nos permite suprimir los coeficientes principales y los términos de orden inferior, y centrarnos en la parte importante: el orden de crecimiento del tiempo de ejecución.

Algoritmo de Knuth-Morris-Pratt

El algoritmo de Knuth-Morris-Pratt (KMP) es un algoritmo eficiente de búsqueda de subcadenas que utiliza una "función de fallo" precalculada para evitar comparaciones innecesarias.

La función de fallo nos dice la longitud del prefijo propio más largo del patrón que también es un sufijo del subcadena del patrón que hemos emparejado hasta ahora. Esto nos permite "saltar hacia adelante" en el texto cuando encontramos un desajuste, en lugar de retroceder.

Aquí hay un ejemplo del algoritmo KMP:

Texto:    "abacadabrabracabracadabrabrabracad"
Patrón: "abracadabra"
Salida:  [13]

El algoritmo KMP tiene un tiempo de ejecución de O(n + m), lo que lo hace mucho más eficiente que el enfoque de fuerza bruta para textos y patrones grandes.

Algoritmo de Boyer-Moore

El algoritmo de Boyer-Moore es otro algoritmo eficiente de búsqueda de subcadenas que funciona preprocesando la cadena de patrones. A diferencia de KMP, que comienza las comparaciones desde el principio del patrón, Boyer-Moore comienza desde el final y trabaja hacia atrás.

La idea clave detrás de Boyer-Moore es utilizar dos funciones precalculadas: el "buen sufijo"Aquí está la traducción al español del archivo markdown:

Función y la función de "mal carácter". Estas funciones nos permiten avanzar en el texto cuando encontramos un desajuste, similar a KMP.

Aquí hay un ejemplo del algoritmo de Boyer-Moore:

Texto:    "abacadabrabracabracadabrabrabracad"
Patrón: "abracadabra"
Salida:  [13]

Boyer-Moore tiene un tiempo de ejecución en el mejor de los casos de O(n/m) y un tiempo de ejecución en el peor de los casos de O(n * m), pero en la práctica, a menudo es el algoritmo de búsqueda de subcadenas más rápido para alfabetos grandes.

Expresiones Regulares

Las expresiones regulares son una herramienta poderosa para describir patrones en cadenas. Proporcionan una notación concisa y flexible para hacer coincidir, buscar y manipular texto.

Una expresión regular es una secuencia de caracteres que define un patrón de búsqueda. La forma más simple de una expresión regular es una cadena literal, como "hola", que coincide exactamente con la cadena "hola". Sin embargo, las expresiones regulares también incluyen caracteres y construcciones especiales que permiten patrones más complejos:

  • . (punto) coincide con cualquier carácter individual.
  • * (asterisco) coincide con cero o más ocurrencias del carácter o grupo anterior.
  • + (más) coincide con una o más ocurrencias del carácter o grupo anterior.
  • ? (signo de interrogación) coincide con cero o una ocurrencia del carácter o grupo anterior.
  • ^ (acento circunflejo) coincide con el inicio de una línea.
  • $ (signo de dólar) coincide con el final de una línea.
  • [] (corchetes) definen una clase de caracteres, coincidiendo con cualquier carácter individual dentro de los corchetes.

Aquí hay algunos ejemplos de expresiones regulares y los patrones que coinciden:

  • a.b coincide con cualquier cadena de tres caracteres que comience con "a" y termine con "b", como "acb", "a5b" o "a b".

  • a*b coincide con cualquier cadena que consista en cero o más caracteres "a" seguidos de un solo "b", como "b", "ab", "aab" o "aaab".

  • a+b coincide con cualquier cadena que consista en uno o más caracteres "a" seguidos de un solo "b", como "ab", "aab" o "aaab", pero no "b".

  • a?b coincide con "ab" o "b".

  • ^a coincide con cualquier cadena que comience con "a".Aquí está la traducción al español del archivo markdown con "a".

  • a$ coincide con cualquier cadena que termine con "a".

  • [aeiou] coincide con cualquier carácter vocal.

Las expresiones regulares son compatibles con muchos lenguajes de programación y herramientas, incluyendo Java, Python y utilidades de Unix como grep y sed. Se utilizan ampliamente para tareas como validación de datos, procesamiento de texto y análisis de registros.

Compresión de datos

La compresión de datos es el proceso de codificar información utilizando menos bits que la representación original. Esto es útil para reducir los requisitos de almacenamiento y los tiempos de transmisión. Hay dos tipos principales de compresión: sin pérdida y con pérdida. La compresión sin pérdida permite reconstruir perfectamente los datos originales a partir de los datos comprimidos, mientras que la compresión con pérdida permite cierta pérdida de información a cambio de una mayor relación de compresión.

Codificación de longitud de ejecución

La codificación de longitud de ejecución (RLE) es una técnica de compresión sin pérdida simple que reemplaza las secuencias repetidas de símbolos idénticos (ejecuciones) con una sola instancia del símbolo y un recuento del número de veces que se repite.

Aquí hay un ejemplo de RLE:

Entrada:  "AAAABBBCCCDDDEEE"
Salida: "4A3B3C3D3E"

RLE es efectivo para datos con muchas ejecuciones largas, como imágenes gráficas simples. Sin embargo, puede aumentar el tamaño de los datos si hay pocas o ninguna ejecución.

Codificación de Huffman

La codificación de Huffman es un algoritmo de compresión sin pérdida que asigna códigos de longitud variable a los símbolos en función de sus frecuencias en los datos de entrada. Los símbolos más frecuentes se asignan a códigos más cortos, mientras que los símbolos menos frecuentes se asignan a códigos más largos.

El algoritmo funciona construyendo un árbol binario, llamado árbol de Huffman, desde abajo hacia arriba. Cada nodo hoja representa un símbolo y su frecuencia, mientras que cada nodo interno representa la suma de las frecuencias de sus hijos. El árbol se construye fusionando repetidamente los dos nodos con las frecuencias más bajas hasta que solo quede un nodo.

Una vez que se construye el árbol, el código de cada símbolo se determina por la ruta desde la raíz hastaAquí está la traducción al español del archivo Markdown, con los comentarios traducidos, pero sin traducir el código:

El nodo hoja correspondiente, con una rama izquierda que representa un "0" y una rama derecha que representa un "1".

Aquí hay un ejemplo de codificación de Huffman:

Entrada:  "AAAABBBCCCDDDEEE"
Salida: "000010011001011100101"

Árbol de Huffman:
      (15)
     /    \
   (7)    (8)
   / \    / \
 (4) (3) (3) (5)
 /\  /\  /\  /\
A B  C D E

En este ejemplo, "A" se asigna el código "00", "B" se asigna "01", "C" se asigna "10", "D" se asigna "110" y "E" se asigna "111". La salida comprimida se obtiene reemplazando cada símbolo de la entrada con su código correspondiente.

La codificación de Huffman es un código prefijo óptimo, lo que significa que ningún código es un prefijo de cualquier otro código. Esto permite la decodificación inequívoca de los datos comprimidos. La codificación de Huffman se utiliza ampliamente en varios formatos de archivo y herramientas de compresión, como JPEG, MP3 y ZIP.

Compresión Lempel-Ziv-Welch (LZW)

La compresión Lempel-Ziv-Welch (LZW) es un algoritmo de compresión basado en diccionario que construye un diccionario (o libro de códigos) de cadenas mientras comprime la entrada. LZW se utiliza ampliamente en utilidades de compresión de archivos y se utilizó en el formato de imagen GIF.

La idea clave detrás de LZW es reemplazar las cadenas de caracteres con códigos individuales. Lee la cadena de entrada carácter por carácter y codifica la cadena en una representación compacta reemplazando cada código de longitud fija por un código de longitud variable. Cuanto más larga sea la cadena, más espacio se ahorrará al codificarla como un solo número.

Aquí hay una descripción paso a paso de cómo funciona la compresión LZW:

  1. Inicializar el diccionario para que contenga todas las cadenas de un solo carácter.
  2. Encuentra la cadena más larga W en el diccionario que coincida con la entrada actual.
  3. Emitir el índice del diccionario para W a la salida y eliminar W de la entrada.
  4. Agregar W seguido del siguiente símbolo de la entrada al diccionario.
  5. Ir al paso 2.

Consideremos un ejemplo. Supongamos que queremos comprimir la cadena "ABABABABA" usando LZW.

  1. Inicializar el diccionario para que contenga "A" y "B".
  2. La coincidencia más larga es "A". Emitir su índice...Aquí está la traducción al español del archivo Markdown, con los comentarios del código traducidos al español:

Índice (0) y elimínalo de la entrada. El diccionario ahora contiene "A", "B" y "AB". 3. La coincidencia más larga es "B". Emite su índice (1) y elimínalo de la entrada. El diccionario ahora contiene "A", "B", "AB" y "BA".

  1. La coincidencia más larga es "AB". Emite su índice (2) y elimínalo de la entrada. El diccionario ahora contiene "A", "B", "AB", "BA" y "ABA".
  2. La coincidencia más larga es "ABA". Emite su índice (4) y elimínalo de la entrada. El diccionario ahora contiene "A", "B", "AB", "BA", "ABA" y "ABAB".
  3. La coincidencia más larga es "BA". Emite su índice (3). La entrada ahora está vacía.

La representación comprimida de "ABABABABA" es así la secuencia de índices [1], lo que requiere menos bits para representarla que la representación ASCII original.

La descompresión funciona de manera similar, pero a la inversa:

  1. Inicializa el diccionario para que contenga todas las cadenas de un solo carácter.
  2. Lee un código X de la entrada.
  3. Emite la cadena para X del diccionario.
  4. Si el código anterior existe, agrega la cadena anterior concatenada con el primer carácter de la cadena para X al diccionario.
  5. Ir al Paso 2.

La compresión LZW es simple y rápida, lo que la convierte en una buena opción para muchas aplicaciones. Sin embargo, tiene algunas limitaciones. El tamaño del diccionario puede crecer bastante, consumiendo una cantidad significativa de memoria. Además, el diccionario se restablece después de cada bloque de entrada, lo que puede reducir la relación de compresión para archivos pequeños.

A pesar de estas limitaciones, LZW sigue siendo un algoritmo de compresión popular y eficaz, particularmente para aplicaciones donde la velocidad es más importante que lograr la mayor relación de compresión posible.

Conclusión

En este capítulo, exploramos varios algoritmos importantes de procesamiento de cadenas, incluyendo ordenamiento de cadenas, tries, búsqueda de subcadenas, expresiones regulares y compresión de datos. Estos algoritmos forman la base de muchas aplicaciones del mundo real y son herramientas esenciales para cualquier programador que trabaje con datos textuales.

Comenzamos discutiendo los ordenamientos de cadenas, que son opAquí está la traducción al español del archivo markdown, con los comentarios de código traducidos:

Algoritmos de ordenamiento optimizados que aprovechan las propiedades especiales de las cadenas de caracteres. El conteo por índice clave, el ordenamiento radix LSD y el ordenamiento radix MSD proporcionan métodos eficientes para ordenar cadenas de caracteres en función de sus caracteres individuales.

A continuación, examinamos los tries, una estructura de datos en forma de árbol para almacenar y recuperar cadenas de caracteres. Los tries permiten un emparejamiento de prefijos rápido y se utilizan comúnmente en aplicaciones como el autocompletado y las tablas de enrutamiento IP.

Los algoritmos de búsqueda de subcadenas, como los algoritmos de Knuth-Morris-Pratt y Boyer-Moore, nos permiten buscar eficientemente patrones dentro de cadenas más grandes. Estos algoritmos tienen numerosas aplicaciones en la edición de texto, la biología computacional y la recuperación de información.

Las expresiones regulares proporcionan una forma poderosa y flexible de describir patrones de cadenas de caracteres. Discutimos la sintaxis básica de las expresiones regulares y cómo se pueden usar para el emparejamiento de patrones y la manipulación de cadenas en varios lenguajes de programación y herramientas.

Finalmente, exploramos los algoritmos de compresión de datos, que reducen el tamaño de los datos explotando la redundancia y los patrones dentro de la entrada. Cubrimos la codificación de longitud de repetición, la codificación de Huffman y la compresión de Lempel-Ziv-Welch, cada una de las cuales tiene sus propias fortalezas y aplicaciones.

Comprender estos algoritmos y estructuras de datos de procesamiento de cadenas de caracteres es crucial para cualquiera que trabaje con datos textuales. A medida que la cantidad de datos no estructurados continúa creciendo, la capacidad de manipular, buscar y comprimir cadenas de caracteres de manera eficiente se volverá cada vez más valiosa. Al dominar las técnicas cubiertas en este capítulo, estarás bien equipado para abordar una amplia gama de desafíos de procesamiento de cadenas de caracteres en tus propios proyectos y aplicaciones.