Cómo funcionan los algoritmos
Chapter 6 Strings

Capítulo 6: Cadenas en Algoritmos

Las cadenas son un tipo de dato fundamental en la ciencia de la computación, que representan secuencias de caracteres. El estudio de algoritmos y estructuras de datos para procesar cadenas es una parte crucial de cualquier plan de estudios de ciencia de la computació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 tienen numerosas aplicaciones prácticas, desde la edición de texto y la recuperación de documentos hasta la bioinformática y el procesamiento del lenguaje natural.

Ordenamiento de Cadenas

El ordenamiento es una operación fundamental en la ciencia de la computación, y ordenar cadenas es una tarea común en muchas aplicaciones. Si bien los algoritmos de ordenamiento de propósito general como quicksort y mergesort se pueden usar para ordenar cadenas, existen algoritmos más eficientes que aprovechan las propiedades especiales de las cadenas.

Conteo por Clave

El conteo por clave es un algoritmo simple y eficiente para ordenar cadenas que se componen de un pequeño conjunto de caracteres distintos. La idea básica es contar el número de ocurrencias de cada carácter y luego usar estos recuentos para determinar la posición de cada cadena en el orden ordenado.

Aquí hay un ejemplo de conteo por clave en acción:

Entrada:  ["dab", "cab", "fad", "bad", "dad", "ebb", "ace"]
Salida: ["ace", "bad", "cab", "dab", "dad", "ebb", "fad"]

El algoritmo funciona de la siguiente manera:

  1. Contar la frecuencia de cada carácter en las cadenas.
  2. Calcular el índice de inicio para cada carácter en la matriz ordenada.
  3. Copiar las cadenas a la matriz ordenada, usando los recuentos para determinar las posiciones.

El conteo por clave se ejecuta en O(n + R) tiempo, donde n es el número total de caracteres en las cadenas y R es el tamaño del alfabeto (el número de caracteres distintos). Esto lo convierte en un algoritmo muy eficiente para ordenar cadenas con un tamaño de alfabeto pequeño.

Ordenamiento Radix LSD

El ordenamiento radix de dígito menos significativo primero (LSD) es una extensión delAquí está la traducción al español del archivo Markdown:

Ordenación que puede ordenar cadenas de longitud igual sobre un alfabeto grande. La idea básica es ordenar las cadenas carácter por carácter, comenzando desde el último carácter y moviéndose hacia el primero.

Aquí hay un ejemplo de ordenación radix LSD:

Entrada:  ["4PGC938", "2IYE230", "3CIO720", "1ICK750", "1OHV845", "4JZY524", "1ICK750", "3CIO720", "1OHV845", "1OHV845", "2RLA629", "2RLA629", "3ATW723"]
Salida: ["1ICK750", "1ICK750", "1OHV845", "1OHV845", "1OHV845", "2IYE230", "2RLA629", "2RLA629", "3ATW723", "3CIO720", "3CIO720", "4JZY524", "4PGC938"]

El algoritmo funciona de la siguiente manera:

  1. Ordenar las cadenas por el último carácter usando conteo por clave.
  2. Repetir el paso 1 para cada carácter moviéndose hacia el primero.

La ordenación radix LSD se ejecuta en O(w * n) tiempo, donde w es la longitud de las cadenas y n es el número de cadenas. Esto lo convierte en una opción eficiente para ordenar cadenas de longitud fija.

Ordenación radix MSD

La ordenación radix MSD (más significativa primero) es una variante recursiva de la ordenación radix que puede manejar cadenas de diferentes longitudes. Al igual que el quicksort, la ordenación radix MSD particiona el array en subarrays que se pueden ordenar de forma independiente.

Aquí hay un ejemplo de ordenación radix MSD:

Entrada:  ["she", "sells", "seashells", "by", "the", "sea", "shore", "the", "shells", "she", "sells", "are", "surely", "seashells"]
Salida: ["are", "by", "sea", "seashells", "seashells", "sells", "sells", "she", "she", "shells", "shore", "surely", "the", "the"]

El algoritmo funciona de la siguiente manera:

  1. Particionar el array en subarrays en función del primer carácter de cada cadena.
  2. Ordenar recursivamente cada subarray.

La ordenación radix MSD tiene un tiempo de ejecución en el peor de los casos de O(n * w), pero en la práctica, a menudo es más rápida que la ordenación radix LSD para cadenas de longitudes variables.

Tries

Un trie (pronunciado "try") es una estructura de datos en forma de árbol para almacenar cadenas que permite un emparejamiento de prefijos y búsquedas de cadenas eficientes. Cada nodo en un trie representa un carácter, y el camino desde la raíz hasta un nodo representa un prefijo de laAquí está la traducción al español del archivo markdown:

Cadenas almacenadas en el trie.

Aquí hay un ejemplo de un trie que almacena las cadenas "sea", "sells", "she", "shells" y "shore":

     (raíz)
    /  |  \
   s   h   o
  / \     / \
 e   h   r   t
 |   |   |   |
 a   e   e   h
     |       |
     l       e
     |       |
     l       r
     |
     s

Los tries admiten las siguientes operaciones:

  • Insertar una cadena en el trie.
  • Buscar una cadena en el trie.
  • Eliminar una cadena del trie.
  • Encontrar todas las cadenas con un prefijo dado.

La complejidad temporal de estas operaciones es O(w), donde w es la longitud de la cadena que se está insertando, buscando o eliminando. Esto hace que los tries sean una estructura de datos muy eficiente para tareas relacionadas con cadenas.

Búsqueda de subcadenas

La búsqueda de subcadenas es el problema de encontrar todas las apariciones de una cadena de patrón dentro de una cadena de texto más grande. Este es un problema fundamental en el procesamiento de cadenas, con aplicaciones en la edición de texto, la bioinformática y muchos otros dominios.

Búsqueda de fuerza bruta

El enfoque más simple para la búsqueda de subcadenas es el algoritmo de fuerza bruta, que comprueba cada posición posible en el texto en busca de una coincidencia con el patrón.

Aquí hay un ejemplo de búsqueda de fuerza bruta:

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

El algoritmo de fuerza bruta tiene un tiempo de ejecución en el peor de los casos de O(n * m), donde n es la longitud del texto y m es la longitud del patrón. Si bien es simple de implementar, puede ser ineficiente para textos y patrones grandes.

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 coincidido hasta ahora. Esto nos permite "saltar adelante" en el texto cuando encontramos un desajuste, en lugar de retroceder.

Aquí hay un ejemplo del algoritmo KMP:

Texto:    "abacadabrabr
```Aquí está la traducción al español del archivo Markdown, con los comentarios traducidos, pero sin traducir el código:

"acabracadabrabrabracad"
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 mediante el preprocesamiento de 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 usar dos funciones precalculadas: la función "buen sufijo" y la función "mal carácter". Estas funciones nos permiten saltar hacia adelante 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 óptimo 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, que coincide con cualquier carácter individual.Aquí está la traducción al español del archivo Markdown, con los comentarios traducidos, pero sin traducir el código:

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".
- `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 una 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 su freAquí está la traducción al español del archivo Markdown, con los comentarios del código traducidos al español:

Frecuencias en los datos de entrada. Los símbolos más frecuentes se les asignan códigos más cortos, mientras que los símbolos menos frecuentes se les asignan códigos más largos.

El algoritmo funciona construyendo un árbol binario, llamado árbol de Huffman, de 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 hasta el nodo hoja correspondiente, donde una rama izquierda representa un "0" y una rama derecha 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 le asigna el código "00", "B" se le asigna "01", "C" se le asigna "10", "D" se le asigna "110" y "E" se le 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 prefijo de 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 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 cadenas de caracteres con códigos únicos. 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 unAquí está la traducción al español del archivo Markdown, con los comentarios del código traducidos:

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, 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...Aquí está la traducción al español del archivo markdown, con los comentarios del código traducidos:

ns, LZW sigue siendo un algoritmo de compresión popular y eficaz, particularmente para aplicaciones donde la velocidad es más importante que lograr las relaciones de compresión más altas posibles.

## 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 indexada, 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.

Comprender estos algoritmos y estructuras de datos de procesamiento de cadenas 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 eficientemente la información se vuelve cada vez más importante.Aquí está la traducción al español del archivo Markdown:

Las cadenas de texto solo se volverán más valiosas. Al dominar las técnicas cubiertas en este capítulo, estarás bien equipado para enfrentar una amplia gama de desafíos de procesamiento de cadenas en tus propios proyectos y aplicaciones.

```python
# Función para contar la frecuencia de caracteres en una cadena
def count_chars(string):
    char_counts = {}
    for char in string:
        if char in char_counts:
            char_counts[char] += 1
        else:
            char_counts[char] = 1
    return char_counts

# Ejemplo de uso
text = "¡Hola, mundo!"
char_freq = count_chars(text)
print(char_freq)