Cómo funcionan los algoritmos
Chapter 9 Algorithm Design Paradigms Divide and Conquer

Capítulo 9: Paradigmas de Diseño de Algoritmos

En los capítulos anteriores, hemos explorado una amplia variedad de algoritmos para resolver problemas específicos, como ordenamiento, búsqueda, recorrido de grafos y procesamiento de cadenas. Si bien estos algoritmos son diversos en sus aplicaciones e implementaciones, muchos de ellos comparten principios o paradigmas de diseño subyacentes comunes.

En este capítulo, examinaremos tres paradigmas fundamentales de diseño de algoritmos: divide y vencerás, algoritmos voraces y programación dinámica. Estos paradigmas proporcionan enfoques generales para la resolución de problemas que se pueden adaptar para resolver una gran variedad de problemas. Al comprender estos paradigmas, podemos obtener una visión más profunda de la estructura de los algoritmos y desarrollar nuevos algoritmos para los problemas que enfrentamos.

Divide y Vencerás

El paradigma de divide y vencerás es un enfoque poderoso y ampliamente utilizado para diseñar algoritmos eficientes. La idea básica es dividir un problema en subproblemas más pequeños, resolver estos subproblemas de forma recursiva y luego combinar sus soluciones para resolver el problema original.

Un algoritmo típico de divide y vencerás consta de tres pasos:

  1. Dividir: Si el problema es lo suficientemente pequeño como para resolverse directamente, resuélvelo. De lo contrario, divide el problema en subproblemas más pequeños.
  2. Conquistar: Resuelve cada subproblema de forma recursiva.
  3. Combinar: Combina las soluciones de los subproblemas para obtener la solución del problema original.

La eficacia de los algoritmos de divide y vencerás se deriva de su capacidad para reducir el tamaño de un problema por un factor constante en cada paso recursivo. Esto a menudo conduce a algoritmos con tiempos de ejecución logarítmicos o polilogatítmicos.

Mergesort: Un Algoritmo Clásico de Divide y Vencerás

Uno de los ejemplos más conocidos de un algoritmo de divide y vencerás es mergesort, que estudiamos en detalle en el Capítulo 2. Recuerda que mergesort ordena un arreglo dividiéndolo en dos mitades, ordenando cada mitad de forma recursiva y luego fusionando las mitades ordenadas.

Aquí hay una descripción de alto nivel del mAquí está la traducción al español del archivo Markdown:

Algoritmo de mergesort:

función mergesort(array):
    si la longitud del array es menor o igual a 1:
        devolver el array
    de lo contrario:
        mid = la longitud del array / 2
        izquierda = mergesort(array[0:mid])
        derecha = mergesort(array[mid:])
        devolver merge(izquierda, derecha)

La función merge combina dos arrays ordenados en un solo array ordenado:

función merge(izquierda, derecha):
    resultado = []
    mientras izquierda no esté vacío y derecha no esté vacío:
        si el primer elemento de izquierda es menor o igual al primer elemento de derecha:
            agregar el primer elemento de izquierda al resultado
            eliminar el primer elemento de izquierda
        de lo contrario:
            agregar el primer elemento de derecha al resultado
            eliminar el primer elemento de derecha
    agregar los elementos restantes de izquierda al resultado
    agregar los elementos restantes de derecha al resultado
    devolver el resultado

La estrategia de divide y vencerás permite que el mergesort alcance un tiempo de ejecución en el peor de los casos de O(n log n), convirtiéndolo en uno de los algoritmos de ordenamiento más eficientes de propósito general.

El Teorema Maestro

El tiempo de ejecución de muchos algoritmos de divide y vencerás puede analizarse utilizando el Teorema Maestro, que proporciona una fórmula general para recurrencias de la forma:

T(n) = aT(n/b) + f(n)

Aquí, a es el número de llamadas recursivas, n/b es el tamaño de cada subproblema, y f(n) es el costo de dividir el problema y combinar los resultados.

El Teorema Maestro establece que la solución a esta recurrencia es:

  1. Si f(n) = O(n^(log_b(a) - ε)) para alguna constante ε > 0, entonces T(n) = Θ(n^log_b(a)).
  2. Si f(n) = Θ(n^log_b(a)), entonces T(n) = Θ(n^log_b(a) * log n).
  3. Si f(n) = Ω(n^(log_b(a) + ε)) para alguna constante ε > 0, y si af(n/b) ≤ cf(n) para alguna constante c < 1 y para todo n suficientemente grande, entonces T(n) = Θ(f(n)).

Para el mergesort, tenemos a = 2 (dos llamadas recursivas), b = 2 (cada subproblema es la mitad del tamaño), y f(n) = Θ(n) (el paso de fusión toma tiempo lineal). Dado que log_2(2) = 1, estamos en el caso 2 del Teorema Maestro, y el tiempo de ejecución es Θ(n log n).

Otros Algoritmos de Divide y Vencerás

Muchos otros alAquí está la traducción al español del archivo markdown, con los comentarios traducidos pero sin traducir el código:

Los algoritmos se pueden diseñar utilizando el paradigma de divide y vencerás. Algunos ejemplos notables incluyen:

  • Quicksort: Al igual que el mergesort, el quicksort es un algoritmo de ordenación basado en el paradigma de divide y vencerás. Particiona el array alrededor de un elemento pivote, ordena recursivamente los subarrays a la izquierda y a la derecha del pivote, y concatena los resultados.

  • Búsqueda binaria: El algoritmo de búsqueda binaria para encontrar un elemento en un array ordenado se puede ver como un algoritmo de divide y vencerás. Compara el valor objetivo con el elemento del medio del array y busca recursivamente en la mitad izquierda o derecha, dependiendo de la comparación.

  • Multiplicación de Karatsuba: Este es un algoritmo de divide y vencerás para multiplicar dos números de n dígitos en O(n^log_2(3)) ≈ O(n^1.585) tiempo, que es más rápido que el algoritmo tradicional O(n^2).

  • Multiplicación de matrices de Strassen: El algoritmo de Strassen multiplica dos matrices n × n en O(n^log_2(7)) ≈ O(n^2.807) tiempo, mejorando el algoritmo ingenuo O(n^3).

Estos ejemplos demuestran la versatilidad y el poder del paradigma de divide y vencerás para diseñar algoritmos eficientes.

Algoritmos Voraces

Los algoritmos voraces son una clase de algoritmos que toman la opción localmente óptima en cada etapa con la esperanza de encontrar una solución globalmente óptima. A menudo se utilizan para problemas de optimización donde se construye una solución de forma incremental tomando una serie de decisiones, cada una de las cuales parece la mejor en ese momento.

Las características clave de los algoritmos voraces son:

  1. Toman una decisión localmente óptima en cada paso, sin preocuparse por las consecuencias futuras.
  2. Asumen que una elección localmente óptima conducirá a una solución globalmente óptima.
  3. Nunca reconsideran las decisiones anteriores.

Los algoritmos voraces suelen ser fáciles de entender e implementar, y pueden ser muy eficientes. Sin embargo, no siempre producen la solución óptima, ya que las elecciones localmente óptimas pueden no conducir a la solución globalmente óptima.

Codificación de Huffman: Un Algoritmo Voraz para la Compresión de Datos

HuffmanAquí está la traducción al español del archivo markdown:

La codificación de Huffman, que encontramos en el Capítulo 5, es un algoritmo codicioso para construir un código prefijo-libre óptimo para comprimir datos. El algoritmo construye un árbol binario de abajo hacia arriba, asignando secuencias de bits más cortas a los caracteres más frecuentes.

Aquí hay una descripción de alto nivel del algoritmo de codificación de Huffman:

  1. Crea un nodo hoja para cada carácter y agrégalo a una cola de prioridad.
  2. Mientras haya más de un nodo en la cola:
    • Elimina los dos nodos de menor frecuencia de la cola.
    • Crea un nuevo nodo interno con estos dos nodos como hijos y con una frecuencia igual a la suma de las frecuencias de los dos nodos.
    • Agrega el nuevo nodo a la cola de prioridad.
  3. El nodo restante es el nodo raíz, y el árbol está completo.

La elección codiciosa es siempre fusionar los dos nodos de menor frecuencia. Esta elección óptima a nivel local conduce a un código prefijo-libre óptimo a nivel global.

Aquí hay un ejemplo de la codificación de Huffman en acción:

Supongamos que tenemos las siguientes frecuencias de caracteres:

d: 1
e: 1

Aquí está el árbol de Huffman para este ejemplo:

      (15)
     /    \
   (7)    (8)
   / \    / \
 (4) (3) (3) (5)
 /\  /\  /\  /\
A B  C D E

Los códigos de Huffman resultantes son:

A: 00
B: 01
C: 10
D: 110
E: 111

Entonces, la cadena original "AAAABBBCCCDDDEEE" se codificaría como:

00000000010101101010110110110111111111

La codificación de Huffman logra la compresión asignando códigos más cortos a los símbolos más frecuentes. Los códigos son prefijo-libres, lo que significa que ningún código es un prefijo de otro, lo que permite una decodificación inequívoca.

Compresión 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 usa ampliamente en utilidades de compresión de archivos y se usó en el formato de imagen GIF.

La idea clave detrás de LZW es reemplazar 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 cadena de longitud fija por un código.Aquí está la traducción al español del archivo Markdown, con los comentarios del código traducidos, pero sin traducir el código en sí:

Compresión LZW

La compresión LZW es un algoritmo de compresión sin pérdida que utiliza un código de longitud variable. Cuanto más largo sea la cadena, más espacio se ahorra 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 en 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 (0) y eliminarla de la entrada. El diccionario ahora contiene "A", "B" y "AB".
  3. La coincidencia más larga es "B". Emitir su índice (1) y eliminarla de la entrada. El diccionario ahora contiene "A", "B", "AB" y "BA".
  4. La coincidencia más larga es "AB". Emitir su índice (2) y eliminarla de la entrada. El diccionario ahora contiene "A", "B", "AB", "BA" y "ABA".
  5. La coincidencia más larga es "ABA". Emitir su índice (4) y eliminarla de la entrada. El diccionario ahora contiene "A", "B", "AB", "BA", "ABA" y "ABAB".
  6. La coincidencia más larga es "BA". Emitir su índice (3). La entrada ahora está vacía.

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

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

  1. Inicializar el diccionario para que contenga todas las cadenas de un solo carácter.
  2. Leer un código X de la entrada.
  3. Emitir la cadena para X desde el diccionario.
  4. Si el código anterior existe, agregar 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,Aquí está la traducción al español del archivo Markdown, con los comentarios del código traducidos:

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 algoritmos de ordenamiento optimizados que aprovechan las propiedades especiales de las cadenas. El conteo por clave, el ordenamiento radix LSD y el ordenamiento radix MSD proporcionan métodos eficientes para ordenar cadenas 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. Los tries permiten un emparejamiento de prefijos rápido y se utilizan comúnmente en aplicaciones como autocompletar y 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 edición de texto, biología computacional y recuperación de información.

Las expresiones regulares proporcionan una forma poderosa y flexible de describir patrones de cadenas. 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 uno de los cuales tiene sus propias fortalezas y aplicaciones.

Entender estos algoritmos y estructuras de datos de procesamiento de cadenas es crucial para cualquier persona que trabajeAquí está la traducción al español del archivo Markdown, con los comentarios traducidos y el código sin traducir:

Trabajando con datos textuales

A medida que la cantidad de datos no estructurados continúa creciendo, la capacidad de manipular, buscar y comprimir cadenas 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 en tus propios proyectos y aplicaciones.

# Importar la biblioteca necesaria
import re
 
# Definir una función para contar palabras en una cadena
def count_words(text):
    words = text.split()
    return len(words)
 
# Definir una función para encontrar la palabra más larga en una cadena
def find_longest_word(text):
    words = text.split()
    longest_word = max(words, key=len)
    return longest_word
 
# Definir una función para reemplazar todas las ocurrencias de una palabra en una cadena
def replace_word(text, old_word, new_word):
    return text.replace(old_word, new_word)
 
# Definir una función para eliminar todas las etiquetas HTML de una cadena
def remove_html_tags(text):
    return re.sub(r'<[^>]+>', '', text)
 
# Definir una función para contar la frecuencia de palabras en una cadena
def count_word_frequency(text):
    words = text.split()
    word_freq = {}
    for word in words:
        if word in word_freq:
            word_freq[word] += 1
        else:
            word_freq[word] = 1
    return word_freq