Рекурсивные методы в Java — это методы, которые вызывают сами себя и требуют осторожности с их обращением.

Чтобы не увидеть «StackOverflowError» на экране, нужно помнить о двух штуках: базисе и шаге рекурсии.

Базис — это условие выхода из рекурсии, а шаг — это вызов методом самого себя с измененными параметрами.

Самый частый пример, который можно встретить в интернете при попытке найти информацию о рекурсии — нахождение факториала числа. Быстренько пройдемся по нему перед рассмотрением более интересной задачки с leetCode.

public int calculateFactorial(int x) {

    int result = 1;

    if (x == 1 || x == 0) {

        return result;

    }

    return x * calculateFactorial(x - 1);

}

Базис рекурсии в данном случае определяется условием «if», а шаг указан здесь - calculateFactorial(x - 1);

Что будет происходить в методе, если в качестве параметра х передадим значение 3?Сначала мы провалимся в условие calculateFactorial(3 - 1), затем снова в него же, но уже со значением calculateFactorial(2 - 1), затем упремся в базис:

if (x == 1 || x == 0) {

        return result;

    }

И пойдем наверх: result = 2 * 1 = 2. И снова наверх (т.к. проваливались мы дважды): result = 3 * 2 = 6. 6 - финальный результат, который вернет метод.

Теперь рассмотрим более интересную задачку с leetCode. Если вы еще не знаете про классы, переменные, геттеры, сеттеры и конструкторы, то нужно сначала их изучить.

Условие задачи здесь: https://leetcode.com/problems/merge-two-sorted-lists/description/

Кратко: у нас есть два упорядоченных связанных списка, которые представлены набором чисел. Например: 

  • Список 1: [1,4,8]

  • Список 2:  [2,3,9,10]

Задача - объединить эти два списка в один.

Ожидаемый результат: [1,2,3,4,8,9,10]

Сами списки представлены классом ListNode с двумя переменными, сеттерами, геттерами и конструкторами (на leetCode сеттеров и геттеров нет, но в реальной мире лучше помнить об инкапсуляции): 

public class ListNode {

    private int val;

    private ListNode next;

    public int getVal() {

        return val;

    }

    public void setVal(int val) {

        this.val = val;

    }

    public ListNode getNext() {

        return next;

    }

    public void setNext(ListNode next) {

        this.next = next;

    }

    public ListNode() {

    }

    public ListNode(int val) {

        this.val = val;

    }

    public ListNode(int val, ListNode next) {

        this.val = val;

        this.next = next;

    }

}

Метод, который будет объединять наши два связанных списка в один, принимает на вход следующие параметры:

public static ListNode mergeTwoLists(ListNode list1, ListNode list2) {}

Итак, сначала подумаем про базис - условие выхода из рекурсии. Упорядочивайте мы закончим тогда, когда в любом из списков не останется элементов, т.е.

if (list1 == null) {

    return list2;

} else if (list2 == null) {

    return list1;

}

Отлично, с базисом определились. Теперь нужно подумать о шаге рекурсии - здесь шаг рекурсии - это переход к следующему элементу, который представлен переменной private ListNode next в классе ListNode. 

Если ни один из списков не пустой, то начинаем со сравнения первых элементов каждого списка, в нашем случае это 1 и 2.

1 < 2, соответсвенно, мы должны добавить в наш объединенный список число «1» и перейти к следующему элементу из list1, используя рекурсию. На Java это будет выглядеть следующим образом:

if (list1.getVal() < list2.getVal()) {

            list1.getNext() = mergeTwoLists(list1.getNext(), list2);

            return list1;

        }

Провалившись в рекурсию на второй строке, мы получаем на вход уже следующие списки:

  • Список 1: [4,8]

  • Список 2:  [2,3,9,10]

И проходим снова по нашему циклу, сравнивая первые элементы каждого списка, только теперь 2 < 4, то есть list1.val < list2.val. А поэтому мы должны перейти к следующему элементу из «Списка 2», запишем в коде:

else {

            list2.getNext() = mergeTwoLists(list1, list2.getNext());

            return list2;

}

Итого, наш метод должен выглядеть следующим образом:

public static ListNode mergeTwoLists(ListNode list1, ListNode list2) {

    if (list1 == null) {

        return list2;

    } else if (list2 == null) {

        return list1;

    }

    if (list1.getVal() < list2.getVal()) {

        list1.setNext(mergeTwoLists(list1.getNext(), list2));

        return list1;

    } else {

        list2.setNext(mergeTwoLists(list1, list2.getNext()));

        return list2;

    }

}

Быстро пройдемся по каждому шагу рекурсии, просмотрев данные на входе:

Шаг 3: 

  • Список 1: [4,8]

  • Список 2:  [3,9,10]

Шаг 4: 

  • Список 1: [4,8]

  • Список 2:  [9,10]

Шаг 5:

  • Список 1: [8]

  • Список 2:  [9,10]

Шаг 6:

  • Список 1: []

  • Список 2:  [9,10]

На 6-ом шаге мы достигаем одного из базиса (условия выхода) рекурсии, и начинаем двигаться наверх. 

Вернувшись на шаг 5, мы получаем list1.next =  [9,10],  а сам list1 = [8,9,10]. 

На 4-ом шаге: list1.val = 4, list1.next = [8,9,10], возвращаем на шаг 3 - [4,8,9,10];

На 3-ий шаг мы заходили через list2.next, поэтому list2.next = [4,8,9,10], а list2.val = 3, возвращаем на 2-ой шаг:  [3,4,8,9,10].

На 2-ом шаге,  list2.next = [3,4,8,9,10], list2.val = 2, list2 = [2,3,4,8,9,10].

На 1-ом шаге, то есть на самом верху и финальном выходе из рекурсии, мы получим list1.next = [2,3,4,8,9,10], list1.val = 1, а list1 = [1,2,3,4,8,9,10] - это и есть результат работы метода.

Думаю, для полного понимания стоит несколько раз продебажить код через идею. Кстати, рекурсия бывает двух видов: хвостовая и головная. Если последнее, что выполняет метод — это вызов самого себя, значит это хвостовая рекурсия, иначе головная. В примерах выше у нас была головная рекурсия, но для Java это не особо имеет значение, просто терминология.

Вот такая штука рекурсия :)

Комментарии (13)


  1. ris58h
    22.07.2024 15:48

    Чтобы не увидеть «StackOverflowError» на экране, нужно помнить о двух штуках: базисе и шаге рекурсии.

    Это, определенно, не достаточное условие. На этом и стоит заострить внимание.


  1. Alexandroppolus
    22.07.2024 15:48
    +1

    в случае с нахождением факториал мы использовали хвостовую рекурсию

    Нет. Последнее действие там - умножение.

    А вообще, обе задачи в статье, это пример когда лучше и проще решить обычным циклом. Вот всякие "древесно-развесистые" случаи с многократным рекурсивным вызовом, или взаимной рекурсией функций - другое дело. Конечно, они всегда могут быть переписаны на цикл/стек, или на хвостовую рекурсию, но код будет убойный.


    1. akozyrenko Автор
      22.07.2024 15:48

      Да, спасибо. В обоих случаях, действительно, используется головная рекурсия, поправила

      Насчет использования цикла: в случае с факториалом, согласна, но здесь пример просто для базового понимания работы рекурсии приведен.

      А вот в случае решения задачи с ListNode - мне кажется, код без рекурсии, будет довольно громоздким и сложным.


      1. Rsa97
        22.07.2024 15:48

        А вот в случае решения задачи с ListNode - мне кажется, код без рекурсии, будет довольно громоздким и сложным.

        Без рекурсии код будет несколько больше по размеру, но гораздо понятнее:

        public static ListNode mergeTwoLists(ListNode list1, ListNode list2) {
            if (list1 == null) {
                return list2;
            }
        
            if (list2 == null) {
                return list1;
            }
        
            ListNode result;
            if (list1.getVal() < list2.getVal()) {
                result = list1;
                list1 = list1.getNext();
            } else {
                result = list2;
                list2 = list2.getNext();
            }
        
            ListNode current = result;
            while (list1 != null && list2 != null) {
                if (list1.getVal() < list2.getVal()) {
                    current.setNext(list1);
                    current = list1;
                    list1 = list1.getNext();
                } else {
                    current.setNext(list2);
                    current = list2;
                    list2 = list2.getNext();
                }
            }
        
            if (list1 != null) {
                current.setNext(list1);
            } else {
                current.setNext(list2);
            }
        
            return result;
        }
        


        1. akozyrenko Автор
          22.07.2024 15:48

          на мой взгляд, рекурсия гораздо понятнее, если в ней разобраться

          Но тут, кому что нравится)


          1. Alexandroppolus
            22.07.2024 15:48
            +1

            кому что нравится)

            Проблема в том, что на достаточно длинных списках это свалится в переполнение стека. А тогда уж не до эстетики.


  1. Andrey_Solomatin
    22.07.2024 15:48

    Для Java не важен тип рекурсии. В языках с оптимизацией хвостовой рекурсии это важно.


  1. tmk826
    22.07.2024 15:48
    +1

    Пример кода для подсчёта факториала не правильный. Факториал нуля равен единице.


    1. akozyrenko Автор
      22.07.2024 15:48

      Да, спасибо, действительно ошибка, поправила


  1. Rsa97
    22.07.2024 15:48
    +1

    Хвостовая рекурсия показана неправильно. Рекурсивный вызов должен быть единственным и являться последним действием в функции перед возвратом из неё.

    public int calculateFactorial(int x, int acc) {
        if (x == 0) {
            return acc;
        }
        return calculateFactorial(x - 1, acc * x);
    }
    
    public int factorial(int x) {
        return calculateFactorial(x, 1);
    }
    

    При головной рекурсии вызов также должен быть единственным и являться первым действием в одной из основных веток функции.

    public int factorial(int x) {
        if (x > 0) {
            int t = factorial(x - 1);
            return t * x;
        }
        return 1;
    }
    

    И ничего не сказано о взаимной (непрямой, косвенной) рекурсии, когда несколько функций вызывают друг друга.

    public int is_even(int x) {
        if (x == 0) {
            return true;
        }
        return !is_odd(x - 1);
    }
    
    public int is_odd(int x) {
        if (x == 0) {
            return true;
        }
        return !is_even(x - 1);
    }
    


    1. akozyrenko Автор
      22.07.2024 15:48

      Про непрямую и косвенную рекурсию не было плана рассказывать в этой статье:)

      По поводу головной и хвостовой - обсуждали в комментарии выше: оба примера, действительно являются головной рекурсией, в статье скорректировала.


    1. ris58h
      22.07.2024 15:48

      Рекурсивный вызов должен быть единственным

      Единственным в пределах исполняемой ветки, имеете в виду?


      1. Rsa97
        22.07.2024 15:48

        Каждый возможный путь исполнения функции должен содержать не более одного рекурсивного вызова.