Алгоритм Циклического Связанного Списка

меня недавно попросили на собеседовании разработать алгоритм, который может определить, является ли связанный список циклическим. Как это связанный список, мы не знаем его размер. Это дважды связанный список с каждым узлом, имеющим указатели "next" и "previous". Узел может быть подключен к любому другому узлу или к самому себе.

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

11 ответов


общее решение состоит в том, чтобы иметь 2 указателя, движущихся с разной скоростью. В конечном итоге они будут равны, если какая-то часть списка будет круговой. Что-то вроде этого:--2-->

 function boolean hasLoop(Node startNode){
   Node slowNode = startNode;
   Node fastNode1 = startNode;
   Node fastNode2 = startNode;

   while (slowNode && fastNode1 = fastNode2.next() && fastNode2 = fastNode1.next()){
     if (slowNode == fastNode1 || slowNode == fastNode2) 
        return true;

     slowNode = slowNode.next();
   }
   return false;
 }

нагло украден отсюда:http://ostermiller.org/find_loop_singly_linked_list.html


то, что вы ищете,-это алгоритм поиска циклов. Алгоритм, на который ссылается Джоэл, называется либо алгоритмом "черепаха и заяц", либо алгоритмом поиска цикла Флойда. Я предпочитаю второе, потому что похоже, что это будет хорошее заклинание D&D.

Wikpedia обзор алгоритмов поиска циклов, С пример кода


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

Это алгоритм O(n), если ваша хэш-таблица постоянна.


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

 - -*- \
     \  \
      \---

на узле * есть 2 входящих ссылок, только одна из которых может быть предыдущей.

что-то типа:

 bool hasCycle(Node head){
    if( head->next == head ) return true;

    Node current = head -> next;

    while( current != null && current->next != null ) {
         if( current == head || current->next->prev != current )
            return true;

         current = current->next;
    }
    return false; // since I've reached the end there can't be a cycle.
 }

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

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


начать с двух указателей, указывающих на один и тот же элемент. Пройдите один указатель по списку, следуя за next указатели. Другой ходит по списку после previous указатели. Если два указателя встречаются, то список является круговым. Если вы найдете элемент с previous или next указатель на значение NULL, тогда вы знаете, что список не круговые.


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

его двойной список ссылок с каждым узлом наличие указателей "next" и "previous".

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

[Edit] как указано, это только проверяет, является ли список круговым в целом, а не если в нем есть циклы, но это была формулировка первоначального вопроса. Если список круговой, tail - >next == head и / или head->prev == tail. Если у вас нет доступа к хвостовому и головному узлу и есть только один из них, но не оба, то достаточно просто проверить, есть ли head->prev != NULL или tail - > далее != НЕДЕЙСТВИТЕЛЬНЫЙ.

Если это недостаточный ответ поскольку нам дан только случайный узел [и поиск циклов в любом месте списка], то все, что вам нужно сделать, это взять этот случайный узел и продолжать пересекать список, пока не достигнете узла, который соответствует (в этом случае он круговой) или нулевой указатель (в этом случае это не так).

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

[Edit] мой ум переключился с фокуса на обнаружение циклов в списке, а не на определение, является ли связанный список круговым.

Если у нас есть такой случай, как: 123[2]

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

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

здесь предлагается решение [которое я украл из другого ответа], называемое "алгоритмом поиска циклов Флойда". Давайте взглянем на него (изменено, чтобы мне было немного легче читать).

function boolean hasLoop(Node startNode)
{
    Node fastNode2 = startNode;
    Node fastNode1 = startNode;
    Node slowNode = startNode;

    while ( slowNode && (fastNode1 = fastNode2.next()) && (fastNode2 = fastNode1.next()) )
    {
        if (slowNode == fastNode1 || slowNode == fastNode2) 
            return true;
        slowNode = slowNode.next();
    }
    return false;
}

Это в основном используется 3 итератора вместо 1. Мы можем посмотреть на дело, как: 1->2->3->4->5->6->[2] дело:

сначала начнем с [1] с быстрого итератора до [2], а другой-с [3] или [1, 2, 3]. Мы останавливаемся, когда первый итератор соответствует любому из двух вторых итераторов.

переходим к [2, 4, 5] (первый быстрый итератор пересекает следующий узел второго быстрого итератора, а второй быстрый итератор пересекает следующий узел первого быстрого итератора после этого). Затем [3, 6, 2] и, наконец, [4, 3, 4].

Yay, мы нашли совпадение и, таким образом, определили список, содержащий цикл в 4 итерациях.


предполагая, что кто-то говорит "Вот указатель на элемент списка. Является ли он членом циркулярного списка?"тогда вы могли бы изучить все доступные члены в одном направлении списка для указателей на один узел, на который вам был дан указатель в их указателе, который должен указывать от вас. Если вы решите пойти в далее направление, то вы ищите next указатели, которые равны указателю, который вы впервые дали. Если вы решите пойти в пред направление, то вы ищете prev указатели, которые равны указателю, который вам был дан первым. Если вы достигнете NULL указатель в любом направлении, тогда вы нашли конец И знаете, что он не круговой.

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

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


Что нужно алгоритм поиска циклов Флойда. Вы также можете подумать о поиске точки пересечения цикла в качестве домашней работы.


вот чистый подход к тестированию, имеет ли связанный список циклы в нем (если он цикличен) на основе алгоритма Флойда:

int HasCycle(Node* head)
{
    Node *p1 = head;
    Node *p2 = head;

  while (p1 && p2) { 
        p1 = p1->next;
        p2 = p2->next->next;

        if (p1 == p2)
            return 1;
    }
    return 0;
}

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


невероятно, насколько широко могут распространяться сложные решения.

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

bool is_circular(node* head)
{
    node* p = head;

    while (p != nullptr) {
        p = p->next;
        if (p == head)
            return true;
    }

    return false;
}