Expresiones regulares en Java — ejercicios con matrículas, fechas y logs (y la matrícula con vocales que se me coló)
La primera vez que validé una matrícula escribí \\d{4} [A-Z]{3} y me quedé tan tranquilo. Funcionaba con 1234 BCD. También con 1234 AEI y con 1234 QQQ. El problema es que en las matrículas españolas actuales no hay vocales, ni Ñ, ni Q. Mi regex aceptaba matrículas que no pueden existir.
Esa es la trampa de las expresiones regulares: un patrón que «funciona» con el caso que probaste puede ser demasiado permisivo con todo lo demás. En estos ejercicios vas a escribir patrones precisos, probarlos con casos que deberían fallar y usar grupos para transformar texto. Y como siempre, antes de las soluciones hay una sección de código con trampa.
Tabla de Contenidos
Cómo usar este artículo
- Lee el enunciado y la salida esperada.
- Inténtalo al menos 15 minutos antes de abrir la pista.
- Prueba tu patrón también con casos que deberían fallar: es donde se esconden los errores.
- Compara con la solución y lee el porqué de cada decisión.
Todas las soluciones son métodos static dentro de una clase EjerciciosRegex. Necesitas import java.util.regex.Matcher; y import java.util.regex.Pattern;.
Nivel 1 — Básico: validar y normalizar una matrícula
Enunciado. Escribe String normalizarMatricula(String texto). Una matrícula española actual tiene 4 dígitos y 3 consonantes (sin vocales, sin Ñ y sin Q). El usuario puede escribirla junta, con espacio o con guion, y en minúsculas. El método devuelve la matrícula en formato 1234 BCD, o null si no es válida.
Salida esperada:
"1234BCD" -> 1234 BCD "1234 bcd" -> 1234 BCD "0000-XYZ" -> 0000 XYZ "1234 AEI" -> null "123 BCD" -> null "1234 QWR" -> null
Escribe tú la lista de letras permitidas dentro de [ ], en lugar de usar [A-Z]. Usa dos grupos (números y letras) para poder recomponer el formato. Y normaliza a mayúsculas antes de validar.
Nivel 2 — Intermedio: fechas al formato internacional
Enunciado. Escribe String fechasAIso(String texto), que busca dentro de un texto todas las fechas d/m/aaaa (el día y el mes pueden tener 1 o 2 cifras) y las cambia a aaaa-mm-dd, con ceros a la izquierda. Si el día no está entre 1 y 31, o el mes entre 1 y 12, la fecha se deja como estaba. Las fechas con el año en dos cifras no se tocan.
Entrada: "Entrega: 7/3/2027. Examen: 15/01/2027. Mal: 31/13/2027 y 3/4/27."
Salida esperada:
Entrega: 2027-03-07. Examen: 2027-01-15. Mal: 31/13/2027 y 3/4/27.
Con $3-$2-$1 no puedes añadir el cero que falta en 7/3. Usa la versión de replaceAll que recibe una función (la viste en la práctica) y String.format("%02d", numero).
Nivel 3 — Desafío: resumen de un log
Enunciado. Cada línea de este log tiene el formato hh:mm:ss NIVEL ip mensaje. Escribe void resumen(String[] log) que:
- Ignore las líneas que no tienen el formato.
- Diga cuántas líneas válidas hay y la hora de la primera y de la última.
- Liste cada IP que haya generado algún
ERROR, con cuántos errores, en el orden en que aparecen.
Todavía no hemos visto colecciones, así que usa arrays paralelos (como en el artículo de arrays).
String[] log = {
"09:00:01 INFO 192.168.1.20 GET /",
"09:00:05 ERROR 10.0.0.7 login fallido",
"09:00:09 WARN 192.168.1.20 respuesta lenta",
"basura",
"09:01:12 ERROR 10.0.0.7 login fallido",
"09:02:30 ERROR 172.16.0.3 error 500",
"09:03:00 INFO 10.0.0.7 GET /recursos"
};
Salida esperada:
Líneas válidas: 6 (de 09:00:01 a 09:03:00) 10.0.0.7 -> 2 error(es) 172.16.0.3 -> 1 error(es)
Un solo patrón con grupos con nombre (hora, nivel, ip, msg). Para la IP, el grupo interior que solo sirve para repetir .número puede ser sin captura: (?:…). Para contar por IP: un array de IPs, un array de contadores y una variable con cuántas IPs distintas llevas.
¿Qué falla aquí?
Tres fragmentos que parecen correctos. Antes de leer la explicación, intenta predecir qué imprimen.
Fragmento 1 — ¿Hay un año en el texto?
String texto = "Aprobé FP2 en 2026 con un 8";
if (texto.matches("\\d{4}")) {
System.out.println("Hay un año");
} else {
System.out.println("No hay ningún año");
}
Imprime No hay ningún año. matches() pregunta si todo el texto son 4 dígitos, y el texto tiene muchas más cosas. Para saber si hay un año dentro necesitas find(): Pattern.compile("\\d{4}").matcher(texto).find() devuelve true. (También funciona texto.matches(".*\\d{4}.*"), pero recorre el texto de forma más torpe y oculta la intención.)
Usar matches() cuando la pregunta es «¿contiene…?». Regla rápida: **validar = matches(); buscar = find()**.
Fragmento 2 — Un validador de IP que acepta cualquier cosa
String ip = "\\d{1,3}.\\d{1,3}.\\d{1,3}.\\d{1,3}";
System.out.println("192.168.1.20".matches(ip));
System.out.println("192x168y1z20".matches(ip));
System.out.println("1921681200".matches(ip));
Imprime true tres veces. El punto sin escapar significa cualquier carácter, así que x, y y z valen como separadores. Y en 1921681200 los «puntos» se comen dígitos: la regex lo reparte como 192, (1), 68, (1), 2, (0), 0, donde lo que va entre paréntesis hace de «punto», y lo da por bueno. Con \\. el segundo y el tercero pasan a ser false.
Un punto sin escapar en una IP, una versión (2.4.1) o un dominio. Casi siempre «funciona» con los ejemplos buenos, así que solo lo descubres probando ejemplos malos.
Fragmento 3 — El mes que se convirtió en año
Este patrón funcionaba:
Matcher m = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})").matcher("2026-10-07");
m.matches();
System.out.println("mes = " + m.group(2)); // mes = 10
Luego alguien añadió un grupo para capturar también «año-mes» junto:
Matcher m = Pattern.compile("((\\d{4})-(\\d{2}))-(\\d{2})").matcher("2026-10-07");
m.matches();
System.out.println("mes = " + m.group(2)); // ???
Ahora imprime mes = 2026. Los grupos se numeran por el orden de su paréntesis de apertura: el nuevo paréntesis exterior es el grupo 1, el año pasa a ser el 2 y el mes el 3. No salta ningún error: el programa sigue funcionando y da un dato equivocado. Hay dos soluciones: grupos con nombre ((?<mes>\\d{2})), que no se mueven, o un grupo sin captura (?:…) si el paréntesis nuevo solo sirve para agrupar.
Soluciones
Nivel 1 — Matrícula
// 4 dígitos + 3 consonantes (sin vocales, sin Ñ ni Q), con espacio o guion opcional
static final Pattern MATRICULA = Pattern.compile("(\\d{4})[ -]?([BCDFGHJKLMNPRSTVWXYZ]{3})");
static String normalizarMatricula(String texto) {
Matcher m = MATRICULA.matcher(texto.strip().toUpperCase());
if (!m.matches()) return null; // null = no es una matrícula válida
return m.group(1) + " " + m.group(2); // formato único: "1234 BCD"
}
Por qué la lista de letras escrita a mano. [A-Z] acepta vocales y la Q; [B-DF-HJ-NP-TV-Z] es más corto pero casi ilegible. Escribir las 20 consonantes permitidas es largo, pero cualquiera puede comprobar de un vistazo qué entra y qué no. En validación, legible gana a ingenioso.
**Por qué [ -]?.** El ? hace opcional el separador: acepta «junto», «espacio» o «guion» con un solo patrón. Dentro de [ ], el guion al final es literal.
**Por qué toUpperCase() antes y no [A-Za-z].** Así la salida sale ya en mayúsculas sin trabajo extra, y el patrón es la mitad de largo.
**Por qué devolver null.** Es la forma de decir «no hay matrícula» sin inventar un valor. Quien llame al método debe comprobarlo; cuando veamos excepciones, verás otra forma de avisar de un dato no válido.
Nivel 2 — Fechas
static final Pattern FECHA = Pattern.compile("\\b(\\d{1,2})/(\\d{1,2})/(\\d{4})\\b");
static String fechasAIso(String texto) {
return FECHA.matcher(texto).replaceAll(m -> {
int dia = Integer.parseInt(m.group(1));
int mes = Integer.parseInt(m.group(2));
if (dia < 1 || dia > 31 || mes < 1 || mes > 12) return m.group(); // la dejamos tal cual
return String.format("%s-%02d-%02d", m.group(3), mes, dia);
});
}
**Por qué \\b al principio y al final.** Sin el \\b final, 3/4/27 no encaja porque pide 4 cifras de año… pero en un texto como 3/4/20271 sí encontraría 3/4/2027 dentro de un número más largo. Los límites de palabra evitan coincidencias «a medias».
Por qué comprobar el rango en Java y no en la regex. Se puede escribir un mes válido con regex (0?[1-9]|1[0-2]), pero los días son peores, y aun así 31/02 pasaría. Es la idea de la práctica: la regex encuentra la forma; el código comprueba el significado. Si la fecha no tiene sentido, devolvemos m.group(), que es el texto original sin tocar.
**Por qué %02d.** Rellena con ceros hasta 2 cifras: 3 → 03. Es lo que $1 nunca podría hacer, y la razón de usar una función.
Nivel 3 — Resumen del log
static final Pattern LINEA = Pattern.compile(
"(?<hora>\\d{2}:\\d{2}:\\d{2}) (?<nivel>INFO|WARN|ERROR) (?<ip>\\d{1,3}(?:\\.\\d{1,3}){3}) (?<msg>.*)");
static void resumen(String[] log) {
String[] ips = new String[log.length]; // arrays paralelos: IP y nº de errores
int[] errores = new int[log.length];
int distintas = 0, validas = 0;
String primera = null, ultima = null;
for (String linea : log) {
Matcher m = LINEA.matcher(linea.strip());
if (!m.matches()) continue; // "basura" se salta aquí
validas++;
if (primera == null) primera = m.group("hora");
ultima = m.group("hora"); // se sobrescribe: al final queda la última
if (!m.group("nivel").equals("ERROR")) continue;
String ip = m.group("ip");
int pos = -1;
for (int i = 0; i < distintas; i++) if (ips[i].equals(ip)) pos = i; // ¿ya la teníamos?
if (pos == -1) { pos = distintas; ips[distintas++] = ip; } // IP nueva al final
errores[pos]++;
}
System.out.println("Líneas válidas: " + validas + " (de " + primera + " a " + ultima + ")");
for (int i = 0; i < distintas; i++) System.out.println(ips[i] + " -> " + errores[i] + " error(es)");
}
**Por qué (?:…) dentro de la IP.** Ese paréntesis solo sirve para repetir .número tres veces. Si fuera un grupo normal, además de los nombrados existiría un grupo numerado que no vamos a usar. Con (?:…) agrupa sin capturar: es el arreglo del fragmento 3 aplicado desde el principio.
**Por qué continue.** Mantiene el bucle «plano»: cada condición que descarta una línea sale enseguida, en lugar de anidar if dentro de if.
**Por qué los arrays miden log.length.** En el peor caso, cada línea es un error de una IP distinta. Es el mismo patrón de array + contador que usaste para quitar duplicados en los ejercicios de arrays: solo miramos hasta distintas, nunca hasta ips.length.
**Por qué ips[i].equals(ip).** Son String: con == compararías referencias (ya sabes cómo acaba eso).
Míralo paso a paso en Python Tutor
Vamos a ver en directo el fallo del fragmento 3. Abre Python Tutor, elige Java, pega este código y avanza paso a paso:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Grupos {
public static void main(String[] args) {
Matcher a = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})").matcher("2026-10-07");
Matcher b = Pattern.compile("((\\d{4})-(\\d{2}))-(\\d{2})").matcher("2026-10-07");
a.matches();
b.matches();
String mesA = a.group(2);
String mesB = b.group(2);
System.out.println(mesA + " vs " + mesB);
}
}
Salida:
10 vs 2026
Qué observar:
- Tras crear
ayb, verás **dos objetosMatcherdistintos**, cada uno con su propio patrón, aunque trabajen sobre el mismo texto. - Cuando se ejecuta
mesA, su valor es"10"; en el paso siguiente,mesBvale"2026". El mismogroup(2)da cosas distintas según dónde estén los paréntesis. - Cambia en
bel primer(por(?:y vuelve a ejecutar:mesBpasa a ser"10". - Prueba también a quitar la línea
b.matches();: al llegar ab.group(2)saltaIllegalStateException. UnMatcherno tiene grupos hasta que encuentra algo.
Chuletario: expresiones regulares en Java
Lo esencial en 30 segundos
- En Java la regex va dentro de un String: cada
\se escribe\\. - Validar (todo el texto) →
matches(). Buscar dentro →find(). - Los paréntesis agrupan y capturan;
(?:…)agrupa sin capturar;(?<nombre>…)captura con nombre. - Si un patrón se usa muchas veces:
static final Pattern P = Pattern.compile(…). - La regex comprueba la forma; las cuentas (letra del DNI, días del mes) se hacen en Java.
Sintaxis
| Quiero… | Patrón (en Java) | Encaja con |
|---|---|---|
| Un dígito / varios | "\\d" / "\\d+" | 7 / 2026 |
| Exactamente n | "\\d{4}" | 1234 |
| Entre n y m | "\\d{1,3}" | 7, 42, 255 |
| Opcional | "colou?r" | color, colour |
| Uno de varios caracteres | "[ -]" | espacio o guion |
| Cualquiera menos… | "[^0-9]" | x |
Letra, dígito o _ | "\\w" | a, 7, _ |
| Espacio en blanco | "\\s+" | espacios, tabuladores, saltos |
| Un punto de verdad | "\\." | . |
| Límite de palabra | "\\b" | entre a y espacio |
| Uno u otro | "INFO|WARN|ERROR" | WARN |
| Lo mínimo posible | ".+?" | hasta el primer cierre |
| Ignorar mayúsculas | "(?i)hola" | HOLA |
Pattern y Matcher
| Quiero… | Código | Resultado |
|---|---|---|
| Compilar una vez | static final Pattern P = Pattern.compile("\\d+"); | patrón reutilizable |
| ¿Todo el texto encaja? | P.matcher(t).matches() | true/false |
| ¿Hay algo dentro? | P.matcher(t).find() | true/false |
| Recorrer coincidencias | while (m.find()) { m.group(); } | cada coincidencia |
| Posición | m.start() / m.end() | índices (fin excluido) |
| Parte capturada | m.group(1) / m.group("mes") | texto del grupo |
| Reemplazar con grupos | t.replaceAll("(\\d{2})/(\\d{2})/(\\d{4})", "$3-$2-$1") | 2027-01-15 |
| Reemplazar con lógica | P.matcher(t).replaceAll(m -> …) | lo que devuelva la función |
| Trocear | t.split("[,;\\s]+") | array de partes |
Plantillas de los ejercicios
Validar y normalizar (matrícula):
Matcher m = MATRICULA.matcher(texto.strip().toUpperCase()); if (!m.matches()) return null; return m.group(1) + " " + m.group(2);
"1234 bcd" → 1234 BCD · "1234 AEI" → null
Transformar con función (fechas):
FECHA.matcher(texto).replaceAll(m -> String.format("%s-%02d-%02d", m.group(3),
Integer.parseInt(m.group(2)), Integer.parseInt(m.group(1))));
7/3/2027 → 2027-03-07
Recorrer líneas y extraer campos (log):
Matcher m = LINEA.matcher(linea.strip());
if (!m.matches()) continue;
String ip = m.group("ip");
Resultado: 10.0.0.7 -> 2 error(es) · 172.16.0.3 -> 1 error(es)
Errores que más se repiten
| Síntoma | Causa probable |
|---|---|
illegal escape character al compilar | \d con una sola barra: en Java es \\d |
matches() da false con algo que «está ahí» | La pregunta era «¿contiene?»: usa find() |
| Ninguna línea de un archivo encaja | Espacios o \r invisibles al final: strip() antes |
Acepta 192x168y1z20 como IP | Punto sin escapar: \\. |
group(2) devuelve otra cosa tras tocar el patrón | Grupos renumerados: usa nombres o (?:…) |
IllegalStateException: No match found | group() sin un find()/matches() que diera true |
| Se lleva hasta el último cierre | Cuantificador codicioso: .+? |
PatternSyntaxException al ejecutar | [ o ( sin cerrar |
Un $ del reemplazo desaparece | En el reemplazo $ es un grupo: \\$ o Matcher.quoteReplacement |
Antes de entregar, pregúntate
- ¿He probado mi patrón con casos que deberían fallar, no solo con los buenos?
- ¿Estoy usando
matches()para validar yfind()para buscar? - ¿He escapado todos los puntos y símbolos que quiero literales?
- ¿Hay alguna regla (rangos, letras de control) que debería comprobar en Java y no en la regex?
¿Te ha servido este artículo? Así puedes apoyar SergioLearns →
