TL;DR
- El Problema: Optimización de complejidad temporal y espacial para estructuras de datos clave.
- El Enfoque: Comprime rachas de letras (aabcccccaaa a a2b1c5a3) con StringBuilder y devuelve el original cuando la compresión no acorta. Guía en Java con casos límite.
- Complejidad: Relación óptima de tiempo y espacio con gestión de casos límite.
Imagina la lista de equipaje cuando llevas cinco calcetines negros iguales. No escribes "calcetín, calcetín, calcetín, calcetín, calcetín". Escribes "calcetín x 5". Esa es la idea del problema: sustituir una racha del mismo carácter por el carácter más cuántas veces aparece seguidas.
Este es el problema 1.6 del estilo clásico de Cracking the Coding Interview (Arrays and Strings). Lo que sigue es una explicación original en Java, no un pegado del texto de un libro. Mapa de la serie: guía CTCI.
El problema en palabras simples
Implementa una compresión básica de cadena usando conteos de caracteres consecutivos repetidos.
| Pieza | Significado |
|---|---|
| Entrada | Una cadena solo con letras mayúsculas y minúsculas (a-z, A-Z) |
| Regla | Recorre de izquierda a derecha. Cada racha máxima del mismo carácter se convierte en ese carácter seguido de su conteo |
| Ejemplo | aabcccccaaa se convierte en a2b1c5a3 |
| Condición | Si la forma comprimida no es más corta que la original, devuelve la cadena original |
Los conteos van en decimal. Una racha de doce x se escribe x12 (carácter más dígitos), no doce unos sueltos.
"Consecutivo" importa. aba son tres rachas de longitud 1: a1b1a1. Eso es más largo que aba, así que devuelves aba.
Cómo pensar antes de codificar
Instinto bruto: recorrer la cadena y construir otra concatenando "a" + "2" + "b" + ... con + sobre String.
La forma es correcta y el coste no. En Java, cada concatenación que alarga el resultado vuelve a copiar todo el prefijo. Con muchas rachas cortas pagas tiempo casi cuadrático.
Forma mejor:
- Recorre la cadena una vez con un índice
i. - Mientras el siguiente carácter sea igual al actual, sube un contador.
- Añade el carácter y el conteo a un
StringBuilder. - Al terminar, compara longitudes. Si el builder no es más corto, devuelve el original.
StringBuilder usa un buffer mutable. Los append son amortizados O(1) por carácter escrito, así que el conjunto es lineal en el tamaño de la salida (y el recorrido es lineal en la entrada).
También puedes precalcular si la compresión acorta contando rachas y estimando la longitud. Así evitas crear el builder cuando pierdes. En entrevista, un pase al builder más la comparación final suele bastar y se explica bien.
Solución en Java con StringBuilder
public final class StringCompression {
private StringCompression() {}
/**
* Compress consecutive runs: aabcccccaaa -> a2b1c5a3.
* Returns the original string when compression is not strictly shorter.
*/
public static String compress(String s) {
if (s == null || s.isEmpty()) {
return s;
}
StringBuilder compressed = new StringBuilder();
int n = s.length();
int i = 0;
while (i < n) {
char c = s.charAt(i);
int count = 0;
// grow the run of c starting at i
while (i < n && s.charAt(i) == c) {
count++;
i++;
}
compressed.append(c);
compressed.append(count);
}
// only keep compression when it truly shrinks the string
if (compressed.length() >= n) {
return s;
}
return compressed.toString();
}
}
Recorrido de aabcccccaaa:
- Racha de
alongitud 2 → añadea,2 - Racha de
blongitud 1 → añadeb,1 - Racha de
clongitud 5 → añadec,5 - Racha de
alongitud 3 → añadea,3 - Resultado
a2b1c5a3longitud 8. Original longitud 10. Devuelve comprimido.
append(count) funciona porque StringBuilder tiene sobrecarga append(int). No hace falta String.valueOf(count) salvo que quieras más claridad.
Opcional: parar pronto si la compresión no puede ganar
Cada racha se convierte en al menos dos caracteres (letra + al menos un dígito). Si todas las rachas miden 1, la longitud comprimida es 2 * n. Un early exit frecuente:
// rough check: if there are too many short runs, skip building
private static int countCompressedLength(String s) {
int length = 0;
int i = 0;
int n = s.length();
while (i < n) {
char c = s.charAt(i);
int count = 0;
while (i < n && s.charAt(i) == c) {
count++;
i++;
}
length += 1 + String.valueOf(count).length();
}
return length;
}
Llámalo primero. Si countCompressedLength(s) >= s.length(), devuelve s sin un segundo pase al builder. Dos pases lineales siguen ganando a la concatenación cuadrática. En entrevista, di el trade-off en voz alta: un pase extra frente a no asignar un builder grande que vas a tirar.
En la pizarra, la versión de un solo pase con builder suele ser suficiente.
Complejidad
| Métrica | Cota | Por qué |
|---|---|---|
| Tiempo | O(n) | Un recorrido de la entrada; cada índice avanza como mucho una vez |
| Espacio extra | O(n) | El builder guarda hasta O(n) caracteres en el peor caso |
| Con chequeo previo de longitud | O(n) tiempo, O(1) extra si devuelves el original sin construir | Segundo pase solo cuando la compresión ayuda |
n es la longitud de la entrada. Los dígitos de cada conteo son cortos (log10(count) + 1 por racha), así que no cambian el big-O en entradas de entrevista.
Casos límite que tocan en entrevista
| Entrada | Esperado | Por qué |
|---|---|---|
"" |
"" |
Vacío se queda vacío (define con el entrevistador la política de null) |
"a" |
"a" |
a1 es más largo |
"aa" |
"aa" |
La forma a2 tiene la misma longitud, se queda el original |
"aaa" |
"a3" |
Claramente más corto |
"aabbcc" |
"aabbcc" |
Comprimido a2b2c2 longitud 6, no más corto |
"AAAAA" |
"A5" |
Se conserva mayúsculas; A y a son distintos |
"aAaA" |
"aAaA" |
Mayúsculas y minúsculas alternadas: cuatro rachas de 1 |
Sé explícito con la comparación: estrictamente más corta. Misma longitud implica devolver el original. Eso encaja con el enunciado habitual.
También confirma: los conteos son solo de rachas consecutivas, no de la frecuencia total del carácter en toda la cadena. aba no es a2b1.
Errores frecuentes
- Usar
String+en un bucle. Respuesta correcta, complejidad mala. Preguntarán el runtime. - Olvidar la última racha. Si solo flusheas cuando el siguiente carácter cambia, hace falta un flush al final del bucle (o estructurar el bucle como arriba para que el while interno consuma la racha final).
- Conteos totales en vez de longitudes de racha. Un mapa de frecuencias resuelve otro problema.
- Devolver comprimido cuando las longitudes son iguales. El problema quiere el original si no hay acortamiento.
- Mezclar
Aya. Son rachas distintas.
Explícaselo a un amigo
Recorres la cadena y agrupas vecinos que se ven iguales. Cada grupo se convierte en "letra + cuántas". Pegas las piezas con un StringBuilder para no reconstruir toda la cadena en cada append. Al final mides: si la nueva escritura no es más corta, la tiras y te quedas con la lista original.
Es compresión al estilo run-length para letras, con una comprobación honesta: la compresión tiene que ayudar de verdad.
Práctica siguiente
Sigue en el Capítulo 1:
- Calentamiento: en papel, cuenta en voz alta las rachas de
aaabbc. - Siguiente del plan: Rotate Matrix (1.7).
- Inicio de serie: guía CTCI en Java.
Mañana reescribe compress de memoria sin mirar. Si puedes decir en una frase por qué importa StringBuilder, ya dominas el problema.
