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.

Cómo usar este artículo

  1. Lee el enunciado y la salida esperada.
  2. Inténtalo al menos 15 minutos antes de abrir la pista.
  3. Prueba tu patrón también con casos que deberían fallar: es donde se esconden los errores.
  4. 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:

  1. Ignore las líneas que no tienen el formato.
  2. Diga cuántas líneas válidas hay y la hora de la primera y de la última.
  3. 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:

  1. Tras crear a y b, verás **dos objetos Matcher distintos**, cada uno con su propio patrón, aunque trabajen sobre el mismo texto.
  2. Cuando se ejecuta mesA, su valor es "10"; en el paso siguiente, mesB vale "2026". El mismo group(2) da cosas distintas según dónde estén los paréntesis.
  3. Cambia en b el primer ( por (?: y vuelve a ejecutar: mesB pasa a ser "10".
  4. Prueba también a quitar la línea b.matches();: al llegar a b.group(2) salta IllegalStateException. Un Matcher no 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ódigoResultado
Compilar una vezstatic 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 coincidenciaswhile (m.find()) { m.group(); }cada coincidencia
Posiciónm.start() / m.end()índices (fin excluido)
Parte capturadam.group(1) / m.group("mes")texto del grupo
Reemplazar con grupost.replaceAll("(\\d{2})/(\\d{2})/(\\d{4})", "$3-$2-$1")2027-01-15
Reemplazar con lógicaP.matcher(t).replaceAll(m -> …)lo que devuelva la función
Troceart.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íntomaCausa 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 encajaEspacios o \r invisibles al final: strip() antes
Acepta 192x168y1z20 como IPPunto sin escapar: \\.
group(2) devuelve otra cosa tras tocar el patrónGrupos renumerados: usa nombres o (?:…)
IllegalStateException: No match foundgroup() sin un find()/matches() que diera true
Se lleva hasta el último cierreCuantificador codicioso: .+?
PatternSyntaxException al ejecutar[ o ( sin cerrar
Un $ del reemplazo desapareceEn el reemplazo $ es un grupo: \\$ o Matcher.quoteReplacement

Antes de entregar, pregúntate

  1. ¿He probado mi patrón con casos que deberían fallar, no solo con los buenos?
  2. ¿Estoy usando matches() para validar y find() para buscar?
  3. ¿He escapado todos los puntos y símbolos que quiero literales?
  4. ¿Hay alguna regla (rangos, letras de control) que debería comprobar en Java y no en la regex?
Sigue aprendiendoTodos los artículos de Java

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *