1. Введение
Односвязный список — это последовательность соединенных узлов, оканчивающаяся нулевой
ссылкой. Однако в некоторых сценариях последний узел может указывать на предыдущий узел, что фактически создает цикл.
В большинстве случаев мы хотим иметь возможность обнаруживать и знать об этих циклах; эта статья будет посвящена именно этому — обнаружению и потенциальному удалению циклов.
2. Обнаружение цикла
Давайте теперь рассмотрим пару алгоритмов обнаружения циклов в связанных списках .
2.1. Грубая сила — временная сложность O(n^2)
С помощью этого алгоритма мы проходим по списку, используя два вложенных цикла. Во внешнем цикле мы проходим один за другим. Во внутреннем цикле мы начинаем с головы и проходим столько узлов, сколько за это время пройдено внешним циклом.
Если узел, посещаемый внешним циклом, дважды посещается внутренним циклом, то обнаружен цикл. И наоборот, если внешний цикл достигает конца списка, это означает отсутствие циклов:
public static <T> boolean detectCycle(Node<T> head) {
if (head == null) {
return false;
}
Node<T> it1 = head;
int nodesTraversedByOuter = 0;
while (it1 != null && it1.next != null) {
it1 = it1.next;
nodesTraversedByOuter++;
int x = nodesTraversedByOuter;
Node<T> 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. Хеширование – O(n) космическая сложность
С помощью этого алгоритма мы поддерживаем набор уже посещенных узлов. Для каждого узла мы проверяем, существует ли он в наборе. Если нет, то добавляем его в набор. Наличие узла в наборе означает, что мы уже посетили этот узел, и указывает на наличие цикла в списке.
Когда мы встречаем узел, который уже существует в наборе, мы обнаруживаем начало цикла. Обнаружив это, мы можем легко разорвать цикл, установив для следующего
поля предыдущего узла значение null
, как показано ниже:
public static <T> boolean detectCycle(Node<T> head) {
if (head == null) {
return false;
}
Set<Node<T>> set = new HashSet<>();
Node<T> 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. Быстрые и медленные указатели
Следующий алгоритм поиска циклов лучше всего объяснить с помощью метафоры .
Рассмотрим гоночную трассу, на которой соревнуются два человека. Учитывая, что скорость второго человека в два раза больше скорости первого человека, второй человек будет проходить трассу в два раза быстрее первого и снова встретится с первым человеком в начале круга.
Здесь мы используем аналогичный подход, перебирая список одновременно с медленным итератором (удвоенная скорость). Как только оба итератора вошли в цикл, они в конце концов встретятся в одной точке.
Следовательно, если два итератора встречаются в какой-либо точке, то можно сделать вывод, что мы наткнулись на цикл:
public static <T> CycleDetectionResult<T> detectCycle(Node<T> head) {
if (head == null) {
return new CycleDetectionResult<>(false, null);
}
Node<T> slow = head;
Node<T> 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
— это удобный класс для хранения результата: логическая
переменная, которая говорит, существует ли цикл или нет, и если существует, то она также содержит ссылку на точку встречи внутри цикла:
public class CycleDetectionResult<T> {
boolean cycleExists;
Node<T> node;
}
Этот метод также известен как «алгоритм черепахи и зайца» или «алгоритм поиска цикла Флайода».
3. Удаление циклов из списка
Давайте рассмотрим несколько методов удаления циклов. Все эти методы предполагают, что «Алгоритм поиска циклов Флайода» использовался для обнаружения циклов и построен на его основе.
3.1. Грубая сила
Как только быстрый и медленный итераторы встречаются в какой-то точке цикла, мы берем еще один итератор (скажем, ptr
) и указываем его на начало списка. Мы начинаем перебирать список с ptr. На каждом этапе мы проверяем, доступен ли ptr
из точки встречи.
Это завершается, когда ptr
достигает начала цикла, потому что это первая точка, когда он входит в цикл и становится доступным из точки встречи.
Как только начало цикла ( bg
) обнаружено, найти конец цикла (узел, следующее поле которого указывает на bg
) несложно. Затем следующий указатель этого конечного узла устанавливается равным нулю
, чтобы удалить цикл:
public class CycleRemovalBruteForce {
private static <T> void removeCycle(
Node<T> loopNodeParam, Node<T> head) {
Node<T> it = head;
while (it != null) {
if (isNodeReachableFromLoopNode(it, loopNodeParam)) {
Node<T> loopStart = it;
findEndNodeAndBreakCycle(loopStart);
break;
}
it = it.next;
}
}
private static <T> boolean isNodeReachableFromLoopNode(
Node<T> it, Node<T> loopNodeParam) {
Node<T> loopNode = loopNodeParam;
do {
if (it == loopNode) {
return true;
}
loopNode = loopNode.next;
} while (loopNode.next != loopNodeParam);
return false;
}
private static <T> void findEndNodeAndBreakCycle(
Node<T> loopStartParam) {
Node<T> loopStart = loopStartParam;
while (loopStart.next != loopStartParam) {
loopStart = loopStart.next;
}
loopStart.next = null;
}
}
К сожалению, этот алгоритм также плохо работает в случае больших списков и больших циклов, потому что нам приходится проходить цикл несколько раз.
3.2. Оптимизированное решение — подсчет узлов цикла
Сначала определим несколько переменных:
n
= размер спискаk
= расстояние от начала списка до начала циклаl
= размер цикла
Мы имеем следующую связь между этими переменными:
k + l = n
Мы используем эту связь в этом подходе. В частности, если итератор, начинающийся с начала списка, уже прошел l
узлов, то ему нужно пройти еще k
узлов, чтобы достичь конца списка.
Вот схема алгоритма:
- Как только быстрые и медленные итераторы встретятся, найдите длину цикла. Это можно сделать, оставив один из итераторов на месте, продолжая работу другого итератора (выполняя итерацию с нормальной скоростью, один за другим), пока он не достигнет первого указателя, сохраняя количество посещенных узлов. Это считается как
л
- Возьмите два итератора (
ptr1
иptr2
) в начале списка. Переместить один из шагов итератора (ptr2
)l
- Теперь повторяйте оба итератора до тех пор, пока они не встретятся в начале цикла, затем найдите конец цикла и укажите его на
ноль .
Это работает, потому что ptr1
находится на k
шагов дальше от цикла, а ptr2,
который продвигается на l
шагов, также нуждается в k
шагах, чтобы достичь конца цикла ( n – l = k
).
И вот простая потенциальная реализация:
public class CycleRemovalByCountingLoopNodes {
private static <T> void removeCycle(
Node<T> loopNodeParam, Node<T> head) {
int cycleLength = calculateCycleLength(loopNodeParam);
Node<T> cycleLengthAdvancedIterator = head;
Node<T> 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 <T> int calculateCycleLength(
Node<T> loopNodeParam) {
Node<T> 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
(точка встречи внутри цикла)
Мы знаем, что расстояние, пройденное быстрым указателем, в два раза больше, чем расстояние, пройденное медленным указателем, следовательно:
к + м * л + у = 2 * (к + у)
который оценивается как:
у = м * л - к
Вычитание обеих сторон из l
дает:
л – у = л – м * л + к
или эквивалентно:
k = (m – 1) * l + z (где l – y равно z, как определено выше)
Это ведет к:
k = (m – 1) Полный цикл + Дополнительное расстояние z
Другими словами, если мы будем держать один итератор во главе списка и один итератор в точке встречи и перемещать их с одинаковой скоростью, то второй итератор совершит m – 1
циклов по циклу и встретится с первым указателем. в начале цикла. Используя это понимание, мы можем сформулировать алгоритм:
- Используйте «Алгоритм поиска цикла Флайода» для обнаружения петли. Если цикл существует, этот алгоритм завершится в точке внутри цикла (назовем это точкой встречи).
- Возьмите два итератора, один во главе списка (
it1
) и один в точке встречи (it2
) - Перемещайтесь по обоим итераторам с одинаковой скоростью
- Поскольку расстояние цикла от головы равно k (как определено выше), итератор, запущенный с головы, достигнет цикла через
k
шагов . - За
k
шагов итераторit2
пройдетm – 1
цикл цикла и дополнительное расстояниеz.
Поскольку этот указатель уже находился на расстоянииz
от начала цикла, прохождение этого дополнительного расстоянияz
приведет к тому, что он также окажется в начале цикла. - Оба итератора встречаются в начале цикла, впоследствии мы можем найти конец цикла и указать на
ноль
Это может быть реализовано:
public class CycleRemovalWithoutCountingLoopNodes {
private static <T> void removeCycle(
Node<T> meetingPointParam, Node<T> head) {
Node<T> loopNode = meetingPointParam;
Node<T> it = head;
while (loopNode.next != it.next) {
it = it.next;
loopNode = loopNode.next;
}
loopNode.next = null;
}
}
Это наиболее оптимизированный подход для обнаружения и удаления циклов из связанного списка.
4. Вывод
В этой статье мы описали различные алгоритмы обнаружения цикла в списке. Мы рассмотрели алгоритмы с различными требованиями к времени вычислений и объему памяти.
Наконец, мы также показали три метода удаления цикла после его обнаружения с помощью «Алгоритма поиска цикла Флайода».
Полный пример кода доступен на Github .