Módulo 07: Métodos y recursividad

Objetivos del módulo

  • Entender la programación modular y por qué conviene dividir un problema en partes
  • Definir y llamar métodos static, con y sin parámetros, con y sin valor de retorno
  • Distinguir parámetros de argumentos y usar return correctamente
  • Comprender el ámbito de las variables locales y la pila de llamadas
  • Entender el paso por valor en Java, tanto con primitivos como con referencias
  • Sobrecargar métodos y usar varargs
  • Aprovechar los métodos de la biblioteca estándar (Math, Character)
  • Documentar métodos con comentarios Javadoc
  • Diseñar soluciones recursivas (caso base y caso recursivo) y compararlas con las iterativas
  • Reconocer un desbordamiento de pila (StackOverflowError)

1. Programación modular

Hasta ahora todo tu código vivía dentro de main. Con programas de 30 líneas es manejable, pero el menú del cajero del módulo anterior ya empezaba a ser largo, y una aplicación real tiene miles de líneas. Nadie es capaz de entender (ni de corregir) un main de 5000 líneas.

📘 Concepto: La programación modular consiste en dividir un problema grande en subproblemas más pequeños, cada uno resuelto por un subprograma independiente. En Java, los subprogramas se llaman métodos. Es la estrategia de divide y vencerás y el diseño descendente (top-down) que viste en el módulo 02.

Piensa en una receta de cocina: «prepara la masa», «haz el relleno», «hornea 20 minutos». Cada paso se puede explicar por separado, y «prepara la masa» se reutiliza en muchas recetas. Ventajas de los métodos:

Ventaja Explicación
Reutilización Escribes el código una vez y lo usas todas las veces que quieras
Legibilidad if (esPrimo(n)) se entiende sin leer cómo se calcula
Mantenimiento Si hay un error, se corrige en un único sitio
Pruebas Cada método se puede probar por separado (módulo 19)
Trabajo en equipo Cada persona puede programar métodos distintos
Abstracción Quien usa un método solo necesita saber qué hace, no cómo

💡 Consejo: Principio DRY (Don’t Repeat Yourself, «no te repitas»): si te sorprendes copiando y pegando el mismo bloque de código, ese bloque debería ser un método.

Llevas usando métodos desde el primer día: main es un método, y System.out.println, sc.nextInt() o Math.sqrt son métodos que otros programaron por ti. Ahora vas a escribir los tuyos.


2. Definir y llamar métodos

Un método se define (se escribe) una vez dentro de la clase, fuera de cualquier otro método, y se llama (se invoca) escribiendo su nombre seguido de paréntesis:

public class Saludos {

    // Definición de un método sin parámetros ni valor de retorno
    static void mostrarLinea() {
        System.out.println("------------------------------");
    }

    // Método con un parámetro
    static void saludar(String nombre) {
        System.out.println("¡Hola, " + nombre + "! Bienvenido al curso.");
    }

    public static void main(String[] args) {
        mostrarLinea();          // llamada (invocación) al método
        saludar("Ana");
        saludar("Luis");
        mostrarLinea();
    }
}
------------------------------
¡Hola, Ana! Bienvenido al curso.
¡Hola, Luis! Bienvenido al curso.
------------------------------

2.1. Anatomía de un método

Partes de la definición de un método: modificadores, tipo de retorno, nombre, parámetros y cuerpo; y de su llamada con un argumento

Figura 1. Anatomía de un método y de su llamada.

Parte Significado
public Modificador de acceso (módulo 11). Es opcional en los métodos de este módulo
static El método pertenece a la clase y se puede llamar sin crear objetos. Hasta el módulo 10, todos tus métodos serán static
Tipo de retorno Tipo del valor que devuelve (int, double, boolean, String…) o void si no devuelve nada
Nombre En camelCase, empezando por un verbo: calcularMedia, esPrimo, mostrarMenu
Parámetros Lista de datos de entrada entre paréntesis, cada uno con su tipo. Puede estar vacía: ()
Cuerpo Instrucciones entre llaves

La firma (signature) de un método es su nombre más la lista de tipos de sus parámetros: areaCirculo(double). Dentro de una clase no puede haber dos métodos con la misma firma.

⚠️ Importante: No se puede definir un método dentro de otro. Todos van al mismo nivel dentro de la clase, y el orden en que los escribes da igual: main puede estar al principio o al final.

2.2. ¿Qué pasa cuando se llama a un método?

Cuando el programa llega a una llamada, interrumpe lo que estaba haciendo, salta al método, lo ejecuta entero y vuelve justo al punto donde se hizo la llamada:

public class FlujoLlamadas {

    static void b() {
        System.out.println("      dentro de b");
    }

    static void a() {
        System.out.println("   entra en a");
        b();
        System.out.println("   vuelve a a y termina");
    }

    public static void main(String[] args) {
        System.out.println("main empieza");
        a();
        System.out.println("main continúa tras a()");
        b();
        System.out.println("main termina");
    }
}
main empieza
   entra en a
      dentro de b
   vuelve a a y termina
main continúa tras a()
      dentro de b
main termina
sequenceDiagram
    participant main
    participant a
    participant b
    main->>a: a()
    a->>b: b()
    b-->>a: termina b
    a-->>main: termina a
    main->>b: b()
    b-->>main: termina b

En pseudocódigo, los métodos se llaman subprocesos o funciones (en PSeInt: SubProceso ... FinSubProceso o Funcion ... FinFuncion), y los diagramas de flujo los representan con un rectángulo con dos líneas verticales en los laterales («proceso predefinido»).


3. Parámetros y argumentos

📘 Concepto: Los parámetros (o parámetros formales) son las variables que aparecen en la definición del método: double base, double altura. Los argumentos (o parámetros reales) son los valores concretos que se pasan en la llamada: areaRectangulo(4, 3). Al llamar, el valor de cada argumento se copia en el parámetro correspondiente.

Reglas de correspondencia:

  1. El número de argumentos debe coincidir con el de parámetros.
  2. Se emparejan por posición: el primer argumento va al primer parámetro, etc. (Java no tiene argumentos con nombre).
  3. Cada argumento debe ser de un tipo compatible: se permite la conversión automática de ensanchamiento (int → double), pero no la contraria sin cast.
  4. Un argumento puede ser cualquier expresión: se evalúa antes de llamar.
public class Parametros {

    static double media(double a, double b) {
        return (a + b) / 2;
    }

    static void repetir(String texto, int veces) {
        for (int i = 0; i < veces; i++) {
            System.out.print(texto);
        }
        System.out.println();
    }

    public static void main(String[] args) {
        System.out.println(media(4, 7));          // int -> double automáticamente
        System.out.println(media(3 + 4, 2 * 5));  // se evalúan las expresiones primero
        int x = 10;
        System.out.println(media(x, x + 1));
        repetir("=-", 10);                       // orden: primero String, luego int
        // repetir(10, "=-");                     // ERROR: tipos en orden incorrecto
    }
}
5.5
8.5
10.5
=-=-=-=-=-=-=-=-=-=-

4. Valor de retorno, void y return

Un método puede devolver un resultado a quien lo llamó con return. El tipo de lo que devuelve debe coincidir con el tipo de retorno declarado.

public class Areas {

    static double areaCirculo(double radio) {
        return Math.PI * radio * radio;
    }

    static double areaRectangulo(double base, double altura) {
        return base * altura;
    }

    public static void main(String[] args) {
        double a1 = areaCirculo(2.5);            // guardar el resultado
        System.out.printf("Círculo de radio 2.5: %.2f%n", a1);

        // usar el resultado directamente en una expresión
        double total = areaRectangulo(4, 3) + areaCirculo(1);
        System.out.printf("Rectángulo 4x3 + círculo de radio 1: %.2f%n", total);

        // el argumento puede ser cualquier expresión del tipo adecuado
        double lado = 5;
        System.out.println("Cuadrado de lado 5: " + areaRectangulo(lado, lado));
    }
}
Círculo de radio 2.5: 19.63
Rectángulo 4x3 + círculo de radio 1: 15.14
Cuadrado de lado 5: 25.0

Un método que devuelve un valor se usa como si fuera ese valor: se puede guardar en una variable, usar en una expresión, pasar como argumento a otro método o escribir directamente.

4.1. Métodos void

Si el método solo hace algo (mostrar, dibujar…) y no calcula ningún resultado, su tipo de retorno es void («vacío»). No puede usarse dentro de una expresión:

public class VoidEnExpresion {
    static void saludar(String nombre) {
        System.out.println("Hola, " + nombre);
    }

    public static void main(String[] args) {
        String s = saludar("Ana");
    }
}
VoidEnExpresion.java:7: error: incompatible types: void cannot be converted to String
        String s = saludar("Ana");
                          ^
1 error

4.2. return: devolver y salir

return hace dos cosas: devuelve el valor y termina el método inmediatamente. Por eso:

  • Puede haber varios return (por ejemplo, uno en cada rama de un if); el primero que se ejecute termina el método.
  • En un método void, return; (sin valor) sirve para salir antes de tiempo.
  • Un return dentro de un bucle termina el bucle y el método.
public class Returns {

    static boolean esPar(int n) {
        return n % 2 == 0;                    // devuelve directamente la condición
    }

    static String signo(int n) {
        if (n > 0) {
            return "positivo";                // varios return: el primero que se
        } else if (n < 0) {                   // ejecuta termina el método
            return "negativo";
        }
        return "cero";
    }

    static void mostrarInverso(double x) {
        if (x == 0) {
            System.out.println("El 0 no tiene inverso");
            return;                           // sale del método void antes de tiempo
        }
        System.out.println("Inverso de " + x + ": " + 1 / x);
    }

    public static void main(String[] args) {
        System.out.println("¿7 es par? " + esPar(7));
        System.out.println("-3 es " + signo(-3));
        System.out.println("0 es " + signo(0));
        mostrarInverso(4);
        mostrarInverso(0);
    }
}
¿7 es par? false
-3 es negativo
0 es cero
Inverso de 4.0: 0.25
El 0 no tiene inverso

⚠️ Importante: Un método que no es void debe devolver un valor por todos los caminos posibles. Si el compilador encuentra un camino que llega al final sin return, protesta:

public class FaltaReturn {
    static String signo(int n) {
        if (n > 0) {
            return "positivo";
        } else if (n < 0) {
            return "negativo";
        }
    }

    public static void main(String[] args) {
        System.out.println(signo(5));
    }
}
FaltaReturn.java:8: error: missing return statement
    }
    ^
1 error

Si n vale 0, ninguna de las dos ramas se ejecuta y el método llegaría al final sin devolver nada. La solución es añadir un return "cero"; final, como en Returns.

💡 Consejo: Separa el cálculo de la presentación. Un método calcularMedia que devuelve un double es más útil que uno que lo imprime: el que lo llama decide si mostrarlo, guardarlo o usarlo en otro cálculo. Reserva los métodos void para mostrar resultados y leer datos.


5. Ámbito de las variables y la pila de llamadas

📘 Concepto: Las variables declaradas dentro de un método, incluidos sus parámetros, son variables locales: se crean al llamar al método y se destruyen al terminar. Ningún otro método puede verlas, ni siquiera main.

Consecuencias:

  • Dos métodos distintos pueden tener variables locales con el mismo nombre sin interferir entre sí.
  • La única forma de que un método reciba datos es por sus parámetros, y la única forma de que devuelva un resultado es con return (o modificando un objeto compartido, como verás en la sección 6).
  • Las constantes que necesitan varios métodos se declaran a nivel de clase, fuera de los métodos, con static final.
public class AmbitoMetodos {

    static final double IVA = 0.21;     // constante de clase: visible en todos los métodos

    static double conIva(double precio) {
        double total = precio + precio * IVA;   // total es local de conIva
        return total;
    }

    static double conDescuento(double precio, double porcentaje) {
        double total = precio - precio * porcentaje / 100;  // otra 'total' distinta
        return total;
    }

    public static void main(String[] args) {
        double precio = 100;
        System.out.println("Con IVA: " + conIva(precio));
        System.out.println("Con 15 % de descuento: " + conDescuento(precio, 15));
        // System.out.println(total);   // ERROR: total no existe en main
    }
}
Con IVA: 121.0
Con 15 % de descuento: 85.0

5.1. La pila de llamadas

¿Cómo consigue Java que cada llamada tenga sus propias variables? Gracias a la pila de llamadas (call stack), una zona de la memoria que funciona como una pila de platos: lo último que se pone es lo primero que se quita (LIFO, Last In, First Out).

  1. Al llamar a un método se crea un marco (stack frame) con sus parámetros y variables locales, y se coloca en la cima de la pila.
  2. Mientras se ejecuta, solo trabaja con su marco.
  3. Al terminar, su marco se destruye y se continúa con el marco que queda debajo (el del método que lo llamó).

Lo verás en las figuras de las secciones 6 y 10. Esta idea es la clave para entender tanto el paso de parámetros como la recursividad.


6. Paso por valor

📘 Concepto: En Java, los argumentos siempre se pasan por valor: el método recibe una copia del valor del argumento. No existe el paso por referencia de otros lenguajes (como ref en C# o & en C++).

Lo que se copia depende del tipo:

  • Tipos primitivos (int, double, char, boolean…): se copia el dato. Cambiar el parámetro no afecta a la variable original.
  • Arrays y objetos: la variable no contiene el objeto, sino una referencia (la dirección donde está en memoria). Se copia la referencia, así que el parámetro y el argumento apuntan al mismo objeto: si el método lo modifica por dentro, el cambio se ve fuera. Pero si el método hace que el parámetro apunte a otro objeto, el original no cambia.

El siguiente ejemplo usa un array (una lista de valores, int[] datos = {1, 2, 3}), que estudiarás a fondo en el módulo 08. De momento, basta con saber que v[i] es el elemento de la posición i, que v.length es el número de elementos y que Arrays.toString(v) lo convierte en texto.

import java.util.Arrays;

public class PasoPorValor {

    static void duplicar(int n) {
        n = n * 2;                               // modifica SOLO la copia
        System.out.println("  dentro de duplicar: n = " + n);
    }

    static void duplicarElementos(int[] v) {
        for (int i = 0; i < v.length; i++) {
            v[i] = v[i] * 2;                     // modifica el array compartido
        }
    }

    static void reasignar(int[] v) {
        v = new int[] {0, 0, 0};                 // la copia apunta a OTRO array
        System.out.println("  dentro de reasignar: " + Arrays.toString(v));
    }

    public static void main(String[] args) {
        int x = 5;
        duplicar(x);
        System.out.println("en main: x = " + x);

        int[] datos = {1, 2, 3};
        duplicarElementos(datos);
        System.out.println("tras duplicarElementos: " + Arrays.toString(datos));

        reasignar(datos);
        System.out.println("tras reasignar: " + Arrays.toString(datos));
    }
}
  dentro de duplicar: n = 10
en main: x = 5
tras duplicarElementos: [2, 4, 6]
  dentro de reasignar: [0, 0, 0]
tras reasignar: [2, 4, 6]

Pila con los marcos de main y duplicar para un primitivo, y marcos que comparten una referencia a un array en el montón

Figura 2. Paso por valor con un primitivo (se copia el dato) y con un array (se copia la referencia).

⚠️ Importante: Por esto un método no puede intercambiar dos variables int de main (intercambiar(a, b) solo intercambia las copias), pero sí puede modificar el contenido de un array que recibe. Y como los String son inmutables (módulo 09), un método tampoco puede cambiar una cadena que recibe: solo puede devolver una nueva.


7. Sobrecarga de métodos

📘 Concepto: La sobrecarga (overloading) consiste en definir varios métodos con el mismo nombre pero con distinta lista de parámetros (distinto número o tipos). Java elige cuál ejecutar según los argumentos de la llamada.

Ya la conoces: System.out.println está sobrecargado para int, double, String, char, boolean…

public class Sobrecarga {

    static int maximo(int a, int b) {
        System.out.print("[int,int] ");
        return a > b ? a : b;
    }

    static double maximo(double a, double b) {
        System.out.print("[double,double] ");
        return a > b ? a : b;
    }

    static int maximo(int a, int b, int c) {
        System.out.print("[int,int,int] ");
        return maximo(maximo(a, b), c);      // reutiliza la versión de dos
    }

    public static void main(String[] args) {
        System.out.println(maximo(3, 9));
        System.out.println(maximo(2.5, 1.5));
        System.out.println(maximo(4, 2.5));  // int + double -> versión double
        System.out.println(maximo(7, 1, 12));
    }
}
[int,int] 9
[double,double] 2.5
[double,double] 4.0
[int,int,int] [int,int] [int,int] 12
  • maximo(4, 2.5): no hay versión (int, double), así que Java convierte el 4 en 4.0 y usa (double, double).
  • maximo(7, 1, 12): la versión de tres parámetros llama dos veces a la de dos. Por eso se imprimen tres etiquetas.

⚠️ Importante: El tipo de retorno no cuenta para la sobrecarga. Dos métodos con los mismos parámetros y distinto tipo de retorno no se pueden distinguir en la llamada:

public class SobrecargaRetorno {
    static int calcular(int x) {
        return x * 2;
    }

    static double calcular(int x) {
        return x * 2.0;
    }

    public static void main(String[] args) {
        System.out.println(calcular(3));
    }
}
SobrecargaRetorno.java:6: error: method calcular(int) is already defined in class SobrecargaRetorno
    static double calcular(int x) {
                  ^
1 error

💡 Consejo: Un patrón muy habitual es que las versiones «cortas» de un método sobrecargado llamen a la versión «completa» con valores por defecto, como harás en el ejercicio 5. Así la lógica está en un único sitio.


8. Varargs: número variable de argumentos

A veces no sabes cuántos argumentos va a recibir un método. Desde Java 5 puedes declarar el último parámetro con tres puntos (tipo... nombre) y el método aceptará cualquier cantidad de valores de ese tipo, incluso ninguno. Dentro del método, el parámetro se comporta como un array:

public class Varargs {

    static int sumar(int... numeros) {         // numeros es un array int[]
        int total = 0;
        for (int i = 0; i < numeros.length; i++) {
            total += numeros[i];
        }
        return total;
    }

    static void informe(String titulo, double... valores) {   // varargs al final
        System.out.print(titulo + " (" + valores.length + " valores): ");
        for (int i = 0; i < valores.length; i++) {
            System.out.print(valores[i] + " ");
        }
        System.out.println();
    }

    public static void main(String[] args) {
        System.out.println(sumar());              // 0 argumentos
        System.out.println(sumar(5));             // 1 argumento
        System.out.println(sumar(1, 2, 3, 4));    // 4 argumentos
        int[] v = {10, 20, 30};
        System.out.println(sumar(v));             // también acepta un array
        informe("Temperaturas", 21.5, 23.0, 19.8);
        informe("Vacío");
    }
}
0
5
10
60
Temperaturas (3 valores): 21.5 23.0 19.8 
Vacío (0 valores): 

Reglas: solo puede haber un parámetro varargs y debe ser el último. printf es el ejemplo más conocido: printf(String formato, Object... args).


9. Métodos de la biblioteca: Math y Character

Java incluye miles de métodos static listos para usar. Se llaman con el nombre de la clase delante. Las clases Math y Character están en java.lang, así que no necesitan import.

public class Biblioteca {
    public static void main(String[] args) {
        System.out.println("--- Math ---");
        System.out.println("Math.abs(-7.5)      = " + Math.abs(-7.5));
        System.out.println("Math.pow(2, 10)     = " + Math.pow(2, 10));
        System.out.println("Math.sqrt(144)      = " + Math.sqrt(144));
        System.out.println("Math.cbrt(27)       = " + Math.cbrt(27));
        System.out.println("Math.max(3, 8)      = " + Math.max(3, 8));
        System.out.println("Math.min(3, 8)      = " + Math.min(3, 8));
        System.out.println("Math.round(2.5)     = " + Math.round(2.5));
        System.out.println("Math.round(-2.5)    = " + Math.round(-2.5));
        System.out.println("Math.floor(2.7)     = " + Math.floor(2.7));
        System.out.println("Math.ceil(2.1)      = " + Math.ceil(2.1));
        System.out.println("Math.hypot(3, 4)    = " + Math.hypot(3, 4));
        System.out.println("Math.PI             = " + Math.PI);

        System.out.println("--- Character ---");
        System.out.println("isDigit('7')        = " + Character.isDigit('7'));
        System.out.println("isLetter('ñ')       = " + Character.isLetter('ñ'));
        System.out.println("isLetterOrDigit('#')= " + Character.isLetterOrDigit('#'));
        System.out.println("isUpperCase('A')    = " + Character.isUpperCase('A'));
        System.out.println("isWhitespace(' ')   = " + Character.isWhitespace(' '));
        System.out.println("toUpperCase('é')    = " + Character.toUpperCase('é'));
        System.out.println("getNumericValue('7')= " + Character.getNumericValue('7'));
    }
}
--- Math ---
Math.abs(-7.5)      = 7.5
Math.pow(2, 10)     = 1024.0
Math.sqrt(144)      = 12.0
Math.cbrt(27)       = 3.0
Math.max(3, 8)      = 8
Math.min(3, 8)      = 3
Math.round(2.5)     = 3
Math.round(-2.5)    = -2
Math.floor(2.7)     = 2.0
Math.ceil(2.1)      = 3.0
Math.hypot(3, 4)    = 5.0
Math.PI             = 3.141592653589793
--- Character ---
isDigit('7')        = true
isLetter('ñ')       = true
isLetterOrDigit('#')= false
isUpperCase('A')    = true
isWhitespace(' ')   = true
toUpperCase('é')    = É
getNumericValue('7')= 7
Método Devuelve
Math.abs(x) Valor absoluto
Math.pow(b, e) b elevado a e (siempre double)
Math.sqrt(x) / Math.cbrt(x) Raíz cuadrada / cúbica
Math.max(a, b) / Math.min(a, b) El mayor / menor de dos valores
Math.round(x) Redondeo al entero más cercano (long); los casos .5 van hacia arriba: -2.5 → -2
Math.floor(x) / Math.ceil(x) Redondeo hacia abajo / hacia arriba (double)
Math.hypot(x, y) √(x² + y²), la hipotenusa
Math.random() double aleatorio en [0, 1)
Math.PI, Math.E Constantes π y e
Character.isDigit(c) / isLetter(c) / isLetterOrDigit(c) ¿Es un dígito / una letra (incluidas ñ y acentuadas) / alguna de las dos?
Character.isUpperCase(c) / isLowerCase(c) ¿Es mayúscula / minúscula?
Character.isWhitespace(c) ¿Es un espacio, tabulador o salto de línea?
Character.toUpperCase(c) / toLowerCase(c) El carácter convertido
Character.getNumericValue(c) Valor numérico de un dígito: '7' → 7

💡 Consejo: En IntelliJ IDEA, escribe Math. y espera: el autocompletado te muestra todos los métodos disponibles. Con el cursor sobre un método, Ctrl+Q muestra su documentación. Usarás Character a fondo en el módulo 09.


10. Documentar métodos con Javadoc

Un comentario Javadoc empieza por /** y se coloca justo antes del método. Describe qué hace (no cómo), sus parámetros y lo que devuelve, con etiquetas:

/**
 * Calcula el área de un círculo.
 *
 * @param radio radio del círculo, en centímetros (debe ser positivo)
 * @return el área en centímetros cuadrados
 */
static double areaCirculo(double radio) {
    return Math.PI * radio * radio;
}
Etiqueta Uso
@param nombre descripción Una por cada parámetro
@return descripción Qué devuelve (no se pone en los void)
@throws Excepción descripción Errores que puede lanzar (módulo 15)

Ventajas: IntelliJ muestra esta documentación al pasar el ratón sobre cualquier llamada al método, y la herramienta javadoc del JDK genera con ella páginas HTML como las de la documentación oficial de Java. Si escribes /** encima de un método y pulsas Intro, IntelliJ crea la plantilla con los @param y @return. Verás más sobre documentación en el módulo 20.


11. Diseñar un programa con métodos

Con métodos, main se convierte en un guion de alto nivel que se lee casi como el pseudocódigo, y cada tarea concreta queda en su propio método. Este programa reorganiza varios algoritmos del módulo 06 y añade un método reutilizable para leer enteros validados:

import java.util.Scanner;

public class MenuMetodos {

    /**
     * Pide un número entero hasta que esté dentro del rango indicado.
     *
     * @param sc      lector de teclado
     * @param mensaje texto que se muestra al usuario
     * @param min     valor mínimo admitido
     * @param max     valor máximo admitido
     * @return un entero entre min y max (ambos incluidos)
     */
    static int leerEntero(Scanner sc, String mensaje, int min, int max) {
        int n;
        do {
            System.out.print(mensaje);
            n = sc.nextInt();
        } while (n < min || n > max);
        return n;
    }

    /**
     * Comprueba si un número es primo.
     *
     * @param n número a comprobar
     * @return true si n es primo
     */
    static boolean esPrimo(int n) {
        if (n < 2) {
            return false;
        }
        for (int d = 2; d * d <= n; d++) {
            if (n % d == 0) {
                return false;          // return dentro de un bucle: sale del método
            }
        }
        return true;
    }

    /**
     * Calcula el máximo común divisor con el algoritmo de Euclides.
     *
     * @param a primer número
     * @param b segundo número
     * @return el MCD de a y b
     */
    static int mcd(int a, int b) {
        while (b != 0) {
            int resto = a % b;
            a = b;
            b = resto;
        }
        return a;
    }

    static long factorial(int n) {
        return n <= 1 ? 1 : n * factorial(n - 1);
    }

    static void mostrarMenu() {
        System.out.println();
        System.out.println("1. ¿Es primo?   2. MCD   3. Factorial   0. Salir");
    }

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int opcion;
        do {
            mostrarMenu();
            opcion = leerEntero(sc, "Opción: ", 0, 3);
            switch (opcion) {
                case 1 -> {
                    int n = leerEntero(sc, "Número: ", 0, Integer.MAX_VALUE);
                    System.out.println(n + (esPrimo(n) ? " es primo" : " no es primo"));
                }
                case 2 -> {
                    int a = leerEntero(sc, "Primer número: ", 1, Integer.MAX_VALUE);
                    int b = leerEntero(sc, "Segundo número: ", 1, Integer.MAX_VALUE);
                    System.out.println("MCD = " + mcd(a, b));
                }
                case 3 -> {
                    int n = leerEntero(sc, "Número (0-20): ", 0, 20);
                    System.out.println(n + "! = " + factorial(n));
                }
                default -> System.out.println("¡Hasta luego!");
            }
        } while (opcion != 0);
    }
}

1. ¿Es primo?   2. MCD   3. Factorial   0. Salir
Opción: 7
Opción: 1
Número: 97
97 es primo

1. ¿Es primo?   2. MCD   3. Factorial   0. Salir
Opción: 2
Primer número: 84
Segundo número: 36
MCD = 12

1. ¿Es primo?   2. MCD   3. Factorial   0. Salir
Opción: 3
Número (0-20): 25
Número (0-20): 15
15! = 1307674368000

1. ¿Es primo?   2. MCD   3. Factorial   0. Salir
Opción: 0
¡Hasta luego!

Fíjate en las decisiones de diseño:

  • leerEntero se usa seis veces con mensajes y rangos distintos: la validación está escrita una sola vez.
  • esPrimo sale del bucle con return false en cuanto encuentra un divisor: no necesita bandera.
  • mcd modifica sus parámetros a y b sin miedo: son copias locales.
  • Cada método hace una sola cosa y su nombre lo dice. Si no encuentras un nombre claro para un método, probablemente está haciendo demasiado.

12. Recursividad

📘 Concepto: Un método es recursivo cuando se llama a sí mismo. La idea es resolver un problema reduciéndolo a una versión más pequeña del mismo problema, hasta llegar a un caso tan sencillo que se resuelve directamente.

Toda solución recursiva necesita dos partes:

Parte Qué es En el factorial
Caso base El caso trivial que se resuelve sin recursión. Detiene la cadena de llamadas 0! = 1! = 1
Caso recursivo Expresa el problema en función de un caso más pequeño que se acerca al caso base n! = n · (n − 1)!

Piensa en unas muñecas rusas (matrioskas): para contar cuántas hay, abres una y cuentas las que hay dentro (el mismo problema, más pequeño) más una. El caso base es la muñeca maciza que ya no se abre.

12.1. Factorial recursivo

public class FactorialRecursivo {

    static long factorial(int n) {
        if (n <= 1) {                        // caso base
            return 1;
        }
        return n * factorial(n - 1);         // caso recursivo
    }

    // Versión "chivata" para ver las llamadas y los retornos
    static long factorialTraza(int n, String sangria) {
        System.out.println(sangria + "factorial(" + n + ") empieza");
        long resultado;
        if (n <= 1) {
            resultado = 1;
        } else {
            resultado = n * factorialTraza(n - 1, sangria + "   ");
        }
        System.out.println(sangria + "factorial(" + n + ") devuelve " + resultado);
        return resultado;
    }

    public static void main(String[] args) {
        System.out.println("10! = " + factorial(10));
        factorialTraza(4, "");
    }
}
10! = 3628800
factorial(4) empieza
   factorial(3) empieza
      factorial(2) empieza
         factorial(1) empieza
         factorial(1) devuelve 1
      factorial(2) devuelve 2
   factorial(3) devuelve 6
factorial(4) devuelve 24

La versión «chivata» muestra el proceso en dos fases: primero las llamadas se apilan hasta el caso base (bajada), y luego cada una completa su multiplicación con el resultado de la siguiente (subida).

Pila de llamadas de factorial(4) en el momento más profundo y escalera de llamadas y retornos

Figura 3. La pila de llamadas de factorial(4): cada llamada espera el resultado de la siguiente.

12.2. Fibonacci recursivo frente a iterativo

La definición de Fibonacci es recursiva por naturaleza: fib(0) = 0, fib(1) = 1 y fib(n) = fib(n − 1) + fib(n − 2). Traducirla a Java es inmediato… y muy ineficiente:

public class FibonacciComparacion {

    static long llamadas = 0;    // contador compartido por todas las llamadas

    static long fibRecursivo(int n) {
        llamadas++;
        if (n <= 1) {
            return n;            // casos base: fib(0) = 0, fib(1) = 1
        }
        return fibRecursivo(n - 1) + fibRecursivo(n - 2);
    }

    static long fibIterativo(int n) {
        long anterior = 0, actual = 1;
        for (int i = 0; i < n; i++) {
            long siguiente = anterior + actual;
            anterior = actual;
            actual = siguiente;
        }
        return anterior;
    }

    public static void main(String[] args) {
        for (int n = 10; n <= 40; n += 10) {
            llamadas = 0;
            long r = fibRecursivo(n);
            System.out.printf("fib(%d) = %d -> %,d llamadas recursivas%n", n, r, llamadas);
        }
        System.out.println("fib(90) iterativo = " + fibIterativo(90) + " (90 vueltas)");
    }
}
fib(10) = 55 -> 177 llamadas recursivas
fib(20) = 6765 -> 21,891 llamadas recursivas
fib(30) = 832040 -> 2,692,537 llamadas recursivas
fib(40) = 102334155 -> 331,160,281 llamadas recursivas
fib(90) iterativo = 2880067194370816120 (90 vueltas)

Para calcular fib(40) la versión recursiva hace más de 331 millones de llamadas (en un ordenador normal tarda del orden de un segundo; fib(50) tardaría minutos), mientras que la iterativa calcula fib(90) con 90 vueltas de bucle. El motivo es que la recursiva repite los mismos cálculos una y otra vez:

Árbol de llamadas de fib(5) en el que fib(3) aparece dos veces y fib(2) tres veces

Figura 4. Árbol de llamadas de fibRecursivo(5): los mismos subproblemas se resuelven varias veces.

💡 Consejo: Que una definición sea recursiva no obliga a programarla con recursividad. Antes de elegir, piensa cuántas llamadas se van a hacer. (Existe una técnica, la memoización, que guarda los resultados ya calculados para no repetirlos; la verás cuando conozcas los mapas en el módulo 16.)

12.3. Más ejemplos: potencia, suma de cifras y cuenta atrás

public class RecursivosVarios {

    // b^e = b * b^(e-1);  b^0 = 1
    static long potencia(long base, int exp) {
        if (exp == 0) {
            return 1;
        }
        return base * potencia(base, exp - 1);
    }

    // Suma de las cifras: última cifra + suma de las cifras del resto
    static int sumaCifras(int n) {
        if (n < 10) {
            return n;
        }
        return n % 10 + sumaCifras(n / 10);
    }

    // Cuenta atrás: la recursión también sirve para métodos void
    static void cuentaAtras(int n) {
        if (n == 0) {
            System.out.println("¡Despegue!");
            return;
        }
        System.out.print(n + " ");
        cuentaAtras(n - 1);
    }

    public static void main(String[] args) {
        System.out.println("2^10 = " + potencia(2, 10));
        System.out.println("3^5  = " + potencia(3, 5));
        System.out.println("Suma de cifras de 40721 = " + sumaCifras(40721));
        cuentaAtras(5);
    }
}
2^10 = 1024
3^5  = 243
Suma de cifras de 40721 = 14
5 4 3 2 1 ¡Despegue!

El patrón es siempre el mismo: comprobar primero el caso base y, si no se cumple, llamar con un problema más pequeño (exp - 1, n / 10, n - 1) y combinar el resultado.

12.4. Torres de Hanoi

Hay tres torres (A, B y C) y n discos de tamaños distintos apilados en A, del mayor (abajo) al menor (arriba). Hay que pasarlos todos a C moviendo un disco cada vez y sin poner nunca un disco sobre otro más pequeño.

La solución iterativa es complicada, pero la recursiva es sorprendentemente corta. Para mover n discos de A a C:

  1. Mueve los n − 1 discos de arriba de A a B (usando C como auxiliar). Es el mismo problema, más pequeño.
  2. Mueve el disco más grande de A a C.
  3. Mueve los n − 1 discos de B a C (usando A como auxiliar).
public class Hanoi {

    static int movimientos = 0;

    /**
     * Mueve n discos de la torre origen a la torre destino usando la auxiliar.
     */
    static void hanoi(int n, char origen, char auxiliar, char destino) {
        if (n == 0) {
            return;                                   // caso base: nada que mover
        }
        hanoi(n - 1, origen, destino, auxiliar);      // 1) apartar n-1 discos
        movimientos++;
        System.out.println(movimientos + ". Disco " + n + ": " + origen + " -> " + destino);
        hanoi(n - 1, auxiliar, origen, destino);      // 2) ponerlos encima
    }

    public static void main(String[] args) {
        hanoi(3, 'A', 'B', 'C');
        System.out.println("Total: " + movimientos + " movimientos (2^3 - 1)");
    }
}
1. Disco 1: A -> C
2. Disco 2: A -> B
3. Disco 1: C -> B
4. Disco 3: A -> C
5. Disco 1: B -> A
6. Disco 2: B -> C
7. Disco 1: A -> C
Total: 7 movimientos (2^3 - 1)

Con n discos se necesitan 2ⁿ − 1 movimientos. Cuenta la leyenda que unos monjes mueven 64 discos de oro y que el mundo se acabará cuando terminen: a un movimiento por segundo, tardarían unos 585 000 millones de años.

12.5. Búsqueda binaria recursiva

Para buscar un valor en un array ordenado, se mira el elemento central: si es el buscado, hemos terminado; si el buscado es menor, se busca en la mitad izquierda; si es mayor, en la mitad derecha. Buscar en una mitad es el mismo problema, más pequeño. (Verás la versión iterativa y su análisis en el módulo 08.)

public class BusquedaBinariaRec {

    static int buscar(int[] v, int clave, int izq, int der) {
        if (izq > der) {
            return -1;                                 // caso base: no está
        }
        int medio = (izq + der) / 2;
        System.out.println("  buscando entre las posiciones " + izq + " y " + der
                + " -> medio = " + medio + " (valor " + v[medio] + ")");
        if (v[medio] == clave) {
            return medio;                              // caso base: encontrado
        } else if (clave < v[medio]) {
            return buscar(v, clave, izq, medio - 1);   // mitad izquierda
        } else {
            return buscar(v, clave, medio + 1, der);   // mitad derecha
        }
    }

    public static void main(String[] args) {
        int[] numeros = {3, 8, 15, 21, 34, 42, 57, 63, 78, 91};   // ORDENADO
        System.out.println("Buscar 57:");
        System.out.println("Posición: " + buscar(numeros, 57, 0, numeros.length - 1));
        System.out.println("Buscar 10:");
        System.out.println("Posición: " + buscar(numeros, 10, 0, numeros.length - 1));
    }
}
Buscar 57:
  buscando entre las posiciones 0 y 9 -> medio = 4 (valor 34)
  buscando entre las posiciones 5 y 9 -> medio = 7 (valor 63)
  buscando entre las posiciones 5 y 6 -> medio = 5 (valor 42)
  buscando entre las posiciones 6 y 6 -> medio = 6 (valor 57)
Posición: 6
Buscar 10:
  buscando entre las posiciones 0 y 9 -> medio = 4 (valor 34)
  buscando entre las posiciones 0 y 3 -> medio = 1 (valor 8)
  buscando entre las posiciones 2 y 3 -> medio = 2 (valor 15)
Posición: -1

Tiene dos casos base: encontrar el elemento o quedarse sin elementos (izq > der). En cada llamada se descarta la mitad de los candidatos, así que con 10 elementos bastan 4 comprobaciones como mucho.

12.6. Desbordamiento de pila: StackOverflowError

Cada llamada ocupa un marco en la pila, y la pila tiene un tamaño limitado. Si una recursión no llega nunca al caso base (porque falta, o porque el caso recursivo no se acerca a él), las llamadas se apilan hasta que no queda espacio:

public class Desbordamiento {

    static int cuentaAtras(int n) {
        // ¡Falta el caso base! Nunca deja de llamarse a sí mismo
        return cuentaAtras(n - 1);
    }

    public static void main(String[] args) {
        cuentaAtras(10);
    }
}
Exception in thread "main" java.lang.StackOverflowError
	at Desbordamiento.cuentaAtras(Desbordamiento.java:5)
	at Desbordamiento.cuentaAtras(Desbordamiento.java:5)
	at Desbordamiento.cuentaAtras(Desbordamiento.java:5)
...

La JVM detiene el programa con un StackOverflowError y muestra la traza de la pila: la misma línea repetida (Java imprime como máximo 1024 líneas de traza). La profundidad exacta a la que se produce depende del tamaño de cada marco y de la configuración de la JVM, pero suele estar en el orden de miles a decenas de miles de llamadas. Por eso, incluso una recursión correcta puede desbordar la pila si es demasiado profunda (por ejemplo, sumaHasta(100000) sumando de uno en uno).

⚠️ Importante: Antes de escribir una llamada recursiva, comprueba: 1) ¿hay caso base?, 2) ¿se comprueba antes de la llamada recursiva?, 3) ¿cada llamada se acerca al caso base? Si falla cualquiera de las tres, tendrás un StackOverflowError.

12.7. Recursividad frente a iteración

Aspecto Recursividad Iteración
Legibilidad Muy clara cuando el problema es recursivo por naturaleza (Hanoi, árboles, fractales, divide y vencerás) Más clara para recorridos simples
Memoria Un marco de pila por llamada: riesgo de StackOverflowError Memoria constante
Velocidad Algo más lenta (cada llamada tiene un coste) Más rápida
Riesgo Repetir cálculos (Fibonacci) si no se diseña bien Bucles infinitos
Equivalencia Todo algoritmo recursivo se puede escribir de forma iterativa (a veces con una pila explícita) y viceversa  

💡 Consejo: Usa la recursividad cuando haga el código mucho más sencillo (Hanoi, búsqueda binaria, algoritmos de ordenación como quicksort y mergesort, recorrer carpetas o árboles). Para factoriales, sumas o recorridos lineales, el bucle es mejor opción.


13. Ejercicios

Los seis primeros están resueltos; intenta hacerlos antes de mirar la solución.

Ejercicio 1: Método esPrimo

Escribe un método boolean esPrimo(int n) y úsalo para mostrar los primos entre 1 y 50 y cuántos hay.

Ver solución
public class PrimosMetodo {

    static boolean esPrimo(int n) {
        if (n < 2) {
            return false;
        }
        for (int d = 2; d * d <= n; d++) {
            if (n % d == 0) {
                return false;
            }
        }
        return true;
    }

    public static void main(String[] args) {
        int contador = 0;
        for (int i = 1; i <= 50; i++) {
            if (esPrimo(i)) {
                System.out.print(i + " ");
                contador++;
            }
        }
        System.out.println();
        System.out.println("Hay " + contador + " primos entre 1 y 50");
    }
}
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 
Hay 15 primos entre 1 y 50

Compara este main con el Primos del módulo 06: ya no hay bucles anidados a la vista ni banderas. La complejidad está escondida dentro de esPrimo, y main se lee como el enunciado.

Ejercicio 2: Conversor de temperaturas

Escribe dos métodos, celsiusAFahrenheit y fahrenheitACelsius (°F = °C · 9/5 + 32), y úsalos para mostrar una tabla de −10 °C a 40 °C de 10 en 10 y para convertir 98.6 °F.

Ver solución
public class Temperaturas {

    static double celsiusAFahrenheit(double c) {
        return c * 9 / 5 + 32;
    }

    static double fahrenheitACelsius(double f) {
        return (f - 32) * 5 / 9;
    }

    public static void main(String[] args) {
        System.out.println("   °C       °F");
        for (int c = -10; c <= 40; c += 10) {
            System.out.printf("%5d  %7.1f%n", c, celsiusAFahrenheit(c));
        }
        System.out.printf("98.6 °F = %.1f °C%n", fahrenheitACelsius(98.6));
    }
}
   °C       °F
  -10     14.0
    0     32.0
   10     50.0
   20     68.0
   30     86.0
   40    104.0
98.6 °F = 37.0 °C

Los métodos devuelven el valor convertido en lugar de imprimirlo, así sirven tanto para la tabla como para la conversión suelta. La variable c del bucle es int y se convierte a double automáticamente al pasarla como argumento. Cuidado con c * 9 / 5: como c es double dentro del método, la división no es entera.

Ejercicio 3: Números capicúa

Escribe un método int invertir(int n) que devuelva el número con las cifras al revés, y un método boolean esCapicua(int n) que lo use. Pruébalos con varios números.

Ver solución
public class Capicua {

    static int invertir(int n) {
        int invertido = 0;
        while (n > 0) {
            invertido = invertido * 10 + n % 10;
            n /= 10;
        }
        return invertido;
    }

    static boolean esCapicua(int n) {
        return n >= 0 && n == invertir(n);
    }

    static void probar(int n) {
        System.out.println(n + " -> invertido " + invertir(n)
                + (esCapicua(n) ? ", capicúa" : ", no capicúa"));
    }

    public static void main(String[] args) {
        probar(12321);
        probar(4554);
        probar(1230);
        probar(7);
    }
}
12321 -> invertido 12321, capicúa
4554 -> invertido 4554, capicúa
1230 -> invertido 321, no capicúa
7 -> invertido 7, capicúa

Un método puede llamar a otro (esCapicua usa invertir) y main usa un tercer método, probar, para no repetir el println. Observa también que invertir(1230) da 321: los ceros a la izquierda desaparecen, por eso 1230 no es capicúa.

Ejercicio 4: MCD recursivo

Escribe una versión recursiva del algoritmo de Euclides: MCD(a, b) = a si b = 0; si no, MCD(b, a mod b).

Ver solución
public class McdRecursivo {

    static int mcd(int a, int b) {
        if (b == 0) {
            return a;                  // caso base
        }
        return mcd(b, a % b);          // caso recursivo: el problema "encoge"
    }

    public static void main(String[] args) {
        System.out.println("MCD(48, 18) = " + mcd(48, 18));
        System.out.println("MCD(17, 5)  = " + mcd(17, 5));
        System.out.println("MCD(100, 75) = " + mcd(100, 75));
    }
}
MCD(48, 18) = 6
MCD(17, 5)  = 1
MCD(100, 75) = 25

La definición matemática se traduce línea a línea. El caso recursivo se acerca al caso base porque a % b es siempre menor que b. Compara con la versión iterativa del módulo 06: hace lo mismo con un while.

Ejercicio 5: Líneas sobrecargadas

Escribe tres versiones sobrecargadas de dibujarLinea: sin parámetros (20 guiones), con la longitud (guiones) y con la longitud y el carácter.

Ver solución
public class Lineas {

    static void dibujarLinea() {
        dibujarLinea(20, '-');                 // delega en la versión completa
    }

    static void dibujarLinea(int longitud) {
        dibujarLinea(longitud, '-');
    }

    static void dibujarLinea(int longitud, char caracter) {
        for (int i = 0; i < longitud; i++) {
            System.out.print(caracter);
        }
        System.out.println();
    }

    public static void main(String[] args) {
        dibujarLinea();
        dibujarLinea(10);
        dibujarLinea(15, '*');
    }
}
--------------------
----------
***************

Las dos versiones cortas delegan en la completa pasando valores por defecto. Si mañana quieres que las líneas se dibujen de otra forma, solo cambias un método.

Ejercicio 6: Binario recursivo

Escribe un método recursivo void escribirBinario(int n) que muestre un número entero no negativo en binario.

Ver solución
public class Binario {

    static void escribirBinario(int n) {
        if (n >= 2) {
            escribirBinario(n / 2);    // primero las cifras de la izquierda...
        }
        System.out.print(n % 2);       // ...y al volver, la cifra de este nivel
    }

    public static void main(String[] args) {
        for (int n = 0; n <= 16; n += 4) {
            System.out.print(n + " en binario: ");
            escribirBinario(n);
            System.out.println();
        }
    }
}
0 en binario: 0
4 en binario: 100
8 en binario: 1000
12 en binario: 1100
16 en binario: 10000

Al dividir entre 2 sucesivamente, los restos salen al revés (de la cifra menos significativa a la más significativa). La recursión lo resuelve sola: la llamada recursiva va antes del print, así que primero se escriben las cifras de los niveles más profundos (las de la izquierda) y cada nivel escribe la suya al volver.

Ejercicio 7: Números perfectos con métodos (propuesto)

Escribe un método int sumaDivisoresPropios(int n) y otro boolean esPerfecto(int n) que lo use. Muestra los números perfectos menores que 10 000 y, para cada uno, la lista de sus divisores (con un tercer método void mostrarDivisores(int n)).

Ejercicio 8: Lectura validada reutilizable (propuesto)

Escribe un método double leerDouble(Scanner sc, String mensaje, double min, double max) similar a leerEntero y úsalo en un programa que calcule el IMC (módulo 05) pidiendo el peso (entre 20 y 300 kg) y la altura (entre 0.5 y 2.5 m). El cálculo y la clasificación deben estar en métodos separados que no impriman nada.

Ejercicio 9: Triángulo de Pascal (propuesto)

El número combinatorio C(n, k) cumple C(n, 0) = C(n, n) = 1 y C(n, k) = C(n − 1, k − 1) + C(n − 1, k). Escribe un método recursivo long combinatorio(int n, int k) y úsalo para dibujar las 10 primeras filas del triángulo de Pascal. Después, escribe una versión iterativa usando C(n, k) = n! / (k! (n − k)!) y compara hasta qué n funciona cada una.

Ejercicio 10: Contar una cifra (propuesto)

Escribe un método recursivo int contarCifra(long n, int cifra) que devuelva cuántas veces aparece una cifra en un número. Por ejemplo, contarCifra(1203401, 0) devuelve 2. Piensa con cuidado el caso base: ¿qué pasa con contarCifra(0, 0)?

Ejercicio 11: Calculadora geométrica (propuesto)

Haz un programa con menú que calcule el área y el perímetro de un círculo, un rectángulo y un triángulo (fórmula de Herón). Cada cálculo debe ser un método documentado con Javadoc; el menú debe estar en un método mostrarMenu y la lectura de datos, en métodos de lectura validada. main no debe tener más de 20 líneas.

Ejercicio 12: Estadísticas con varargs (propuesto)

Escribe los métodos int maximo(int... v), int minimo(int... v) y double media(int... v). Decide qué deben hacer si no reciben ningún argumento y documéntalo con Javadoc. Pruébalos con 0, 1 y varios argumentos.


Resumen

Concepto Sintaxis / idea clave
Definir un método static tipo nombre(tipo p1, tipo p2) { ... }
Llamar nombre(arg1, arg2); el valor devuelto se usa como una expresión
void El método no devuelve nada; return; sale antes de tiempo
return Devuelve un valor y termina el método; obligatorio en todos los caminos
Parámetros / argumentos En la definición / en la llamada; se emparejan por posición y tipo
Variables locales Existen solo durante la ejecución del método; cada llamada tiene las suyas
Pila de llamadas Un marco por llamada; LIFO
Paso por valor Siempre se copia el valor; con arrays y objetos se copia la referencia
Sobrecarga Mismo nombre, distintos parámetros; el tipo de retorno no cuenta
Varargs static int sumar(int... v): último parámetro, se usa como array
Biblioteca Math.sqrt, Math.pow, Math.max, Character.isDigit, Character.toUpperCase…
Javadoc /** ... @param ... @return ... */ antes del método
Recursividad Caso base + caso recursivo que se acerca al caso base
StackOverflowError Recursión infinita o demasiado profunda
Recursión o bucle Recursión si simplifica mucho (Hanoi, búsqueda binaria); bucle en el resto