Проверить связанный список на Cyclicity

Проверьте связанный список на Cyclicity

1. Вступление

Односвязный список - это последовательность связанных узлов, заканчивающихся ссылкойnull. Однако в некоторых сценариях последний узел может указывать на предыдущий узел - эффективно создавая цикл.

В большинстве случаев мы хотим иметь возможность обнаруживать и осознавать эти циклы; эта статья будет посвящена именно этому - обнаружению и возможному удалению циклов.

2. Обнаружение цикла

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

2.1. Грубая сила - O (n ^ 2) Время сложности

С помощью этого алгоритма мы пересекаем список, используя два вложенных цикла. Во внешнем цикле мы проходим один за другим. Во внутреннем цикле мы начинаем с головы и проходим столько узлов, сколько пересекает внешний цикл к тому времени.

If a node that is visited by the outer loop is visited twice by the inner loop, then a cycle has been detected. И наоборот, если внешний цикл достигает конца списка, это означает отсутствие циклов:

public static  boolean detectCycle(Node head) {
    if (head == null) {
        return false;
    }

    Node it1 = head;
    int nodesTraversedByOuter = 0;
    while (it1 != null && it1.next != null) {
        it1 = it1.next;
        nodesTraversedByOuter++;

        int x = nodesTraversedByOuter;
        Node it2 = head;
        int noOfTimesCurrentNodeVisited = 0;

        while (x > 0) {
            it2 = it2.next;

            if (it2 == it1) {
                noOfTimesCurrentNodeVisited++;
            }

            if (noOfTimesCurrentNodeVisited == 2) {
                return true;
            }

            x--;
        }
    }

    return false;
}

Преимущество этого подхода в том, что он требует постоянного объема памяти. Недостатком является то, что производительность очень низкая, когда в качестве входных данных предоставляются большие списки.

2.2. Хеширование - космическая сложность

С помощью этого алгоритма мы поддерживаем набор уже посещенных узлов. Для каждого узла мы проверяем, существует ли он в наборе. Если нет, то мы добавим его в набор. Наличие узла в наборе означает, что мы уже посетили этот узел, и указывает на наличие цикла в списке.

Когда мы сталкиваемся с узлом, который уже существует в наборе, мы обнаруживаем начало цикла. Обнаружив это, мы можем легко прервать цикл, установив в полеnext предыдущего узла значениеnull, как показано ниже:

public static  boolean detectCycle(Node head) {
    if (head == null) {
        return false;
    }

    Set> set = new HashSet<>();
    Node node = head;

    while (node != null) {
        if (set.contains(node)) {
            return true;
        }
        set.add(node);
        node = node.next;
    }

    return false;
}

В этом решении мы посетили и сохранили каждый узел один раз. Это составляет O (n) сложность времени и O (n) сложность пространства, что в среднем не оптимально для больших списков.

2.3. Быстрые и медленные указатели

Следующий алгоритм поиска циклов лучше всего объясняетсяusing a metaphor.

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

Здесь мы используем аналогичный подход, перебирая список одновременно с медленным итератором и быстрым итератором (2-кратная скорость). Как только оба итератора вошли в цикл, они в конечном итоге встретятся в определенной точке.

Следовательно, если два итератора встречаются в любой точке, то мы можем заключить, что наткнулись на цикл:

public static  CycleDetectionResult detectCycle(Node head) {
    if (head == null) {
        return new CycleDetectionResult<>(false, null);
    }

    Node slow = head;
    Node fast = head;

    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;

        if (slow == fast) {
            return new CycleDetectionResult<>(true, fast);
        }
    }

    return new CycleDetectionResult<>(false, null);
}

ГдеCycleDetectionResult - это вспомогательный класс для хранения результата: переменнаяboolean, которая говорит, существует ли цикл или нет, и если существует, то она также содержит ссылку на точку встречи внутри цикла:

public class CycleDetectionResult {
    boolean cycleExists;
    Node node;
}

Этот метод также известен как «алгоритм черепахи и зайца» для «алгоритма нахождения циклов Флойда».

3. Удаление циклов из списка

Давайте рассмотрим несколько методов удаления циклов. All these methods assume that the ‘Flyods Cycle-Finding Algorithm' was used for cycle detection and build on top of it.с

3.1. Грубая сила

Как только быстрый и медленный итераторы встречаются в какой-то точке цикла, мы берем еще один итератор (скажем,ptr) и указываем его на начало списка. Мы начинаем итерацию списка с ptr. На каждом этапе мы проверяем, доступен лиptr из точки встречи.

Это завершается, когдаptr достигает начала цикла, потому что это первая точка, когда он входит в цикл и становится достижимым из точки встречи.

Как только начало цикла (bg) обнаружено, то легко найти конец цикла (узел, следующее поле которого указывает наbg). Следующий указатель этого конечного узла затем устанавливается наnull, чтобы удалить цикл:

public class CycleRemovalBruteForce {
    private static  void removeCycle(
      Node loopNodeParam, Node head) {
        Node it = head;

        while (it != null) {
            if (isNodeReachableFromLoopNode(it, loopNodeParam)) {
                Node loopStart = it;
                findEndNodeAndBreakCycle(loopStart);
                break;
            }
            it = it.next;
        }
    }

    private static  boolean isNodeReachableFromLoopNode(
      Node it, Node loopNodeParam) {
        Node loopNode = loopNodeParam;

        do {
            if (it == loopNode) {
                return true;
            }
            loopNode = loopNode.next;
        } while (loopNode.next != loopNodeParam);

        return false;
    }

    private static  void findEndNodeAndBreakCycle(
      Node loopStartParam) {
        Node loopStart = loopStartParam;

        while (loopStart.next != loopStartParam) {
            loopStart = loopStart.next;
        }

        loopStart.next = null;
    }
}

К сожалению, этот алгоритм также плохо работает в случае больших списков и больших циклов, потому что нам приходится проходить цикл несколько раз.

3.2. Оптимизированное решение - подсчет узлов петли

Сначала определим несколько переменных:

  • n = размер списка

  • k = расстояние от заголовка списка до начала цикла

  • l = размер цикла

Между этими переменными существует следующая связь:k + l = n

Мы используем эти отношения в этом подходе. В частности, когда итератор, который начинается с начала списка, уже прошелl узлов, он должен пройти ещеk узлов, чтобы достичь конца списка.

Вот схема алгоритма:

  1. Как только быстрые и медленные итераторы встретятся, найдите длину цикла. Это можно сделать, удерживая один из итераторов на месте, продолжая другой итератор (итерируя с нормальной скоростью, один за другим), пока он не достигнет первого указателя, сохраняя количество посещенных узлов. Это считается какl

  2. Возьмите два итератора (ptr1 иptr2) в начале списка. Переместить один из шагов итератора (ptr2)l

  3. Теперь повторяйте оба итератора, пока они не встретятся в начале цикла, затем найдите конец цикла и укажите его наnull

Это работает, потому чтоptr1 - этоk шагов от цикла, аptr2,, который продвигается наl шагов, также требует шаговk, чтобы достичь конца цикл (n – l = k).

А вот простая возможная реализация:

public class CycleRemovalByCountingLoopNodes {
    private static  void removeCycle(
      Node loopNodeParam, Node head) {
        int cycleLength = calculateCycleLength(loopNodeParam);
        Node cycleLengthAdvancedIterator = head;
        Node it = head;

        for (int i = 0; i < cycleLength; i++) {
            cycleLengthAdvancedIterator
              = cycleLengthAdvancedIterator.next;
        }

        while (it.next != cycleLengthAdvancedIterator.next) {
            it = it.next;
            cycleLengthAdvancedIterator
              = cycleLengthAdvancedIterator.next;
        }

        cycleLengthAdvancedIterator.next = null;
    }

    private static  int calculateCycleLength(
      Node loopNodeParam) {
        Node loopNode = loopNodeParam;
        int length = 1;

        while (loopNode.next != loopNodeParam) {
            length++;
            loopNode = loopNode.next;
        }

        return length;
    }
}

Затем давайте сосредоточимся на методе, в котором мы даже можем исключить этап вычисления длины цикла.

3.3. Оптимизированное решение - без подсчета узлов петли

Давайте математически сравним расстояния, пройденные быстрым и медленным указателями.

Для этого нам понадобится еще несколько переменных:

  • y = расстояние до точки, где встречаются два итератора, если смотреть с начала цикла

  • z = расстояние до точки, где встречаются два итератора, если смотреть с конца цикла (это также равноl – y)

  • m = количество раз, когда быстрый итератор завершил цикл, прежде чем медленный итератор войдет в цикл

Сохраняя другие переменные такими же, как определено в предыдущем разделе, уравнения расстояния будут определены как:

  • Расстояние, пройденное медленным указателем =k (расстояние цикла от головы) +y (точка встречи внутри цикла)

  • Расстояние, пройденное быстрым указателем =k (расстояние цикла от головки) +m (количество раз, когда быстрый указатель завершает цикл до того, как входит медленный указатель) *l (длина цикла) +y (точка встречи внутри цикла)

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

к + м * 1 + у = 2 * (к + у)

который оценивает:

у = м * л - к

Вычитание обеих частей изl дает:

л - у = л - м * л + к

или эквивалентно:

k = (m - 1) * l + z (где l - y - это z, как определено выше)

Это ведет к:

k = (m - 1) Полный цикл + дополнительное расстояние z

Другими словами, если мы сохраним один итератор в начале списка и один итератор в точке встречи и будем перемещать их с одинаковой скоростью, то второй итератор завершит циклыm – 1 по циклу и встретит первый указатель в начале цикла. Используя это понимание, мы можем сформулировать алгоритм:

  1. Используйте «алгоритм нахождения цикла» Флойда, чтобы обнаружить петлю. Если цикл существует, этот алгоритм заканчивается в точке внутри цикла (назовите это местом встречи)

  2. Возьмите два итератора, один в начале списка (it1) и один в точке встречи (it2)

  3. Обходите оба итератора с одинаковой скоростью

  4. Поскольку расстояние цикла от головы равно k (как определено выше), итератор, запущенный с головы, достигнет цикла послеk шагов.

  5. В шагахk итераторit2 будет проходитьm – 1 циклов цикла и дополнительное расстояниеz., поскольку этот указатель уже находился на расстоянииz от начало цикла, прохождение этого дополнительного расстоянияz, также принесет его в начало цикла

  6. Оба итератора встречаются в начале цикла, впоследствии мы можем найти конец цикла и указать его наnull

Это может быть реализовано:

public class CycleRemovalWithoutCountingLoopNodes {
    private static  void removeCycle(
      Node meetingPointParam, Node head) {
        Node loopNode = meetingPointParam;
        Node it = head;

        while (loopNode.next != it.next) {
            it = it.next;
            loopNode = loopNode.next;
        }

        loopNode.next = null;
    }
}

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

4. Заключение

В этой статье мы описали различные алгоритмы обнаружения цикла в списке. Мы изучили алгоритмы с различными требованиями к вычислительному времени и памяти.

Наконец, мы также показали три метода удаления цикла, как только он обнаружен с помощью «алгоритма нахождения цикла Flyods».

Полный пример кода доступенover on Github.