Недавно Youtube (*сайт, нарушающий закон РФ) порекомендовал мне любопытный с различных сторон видеоролик. В нём рассматривалась задача, которую, по словам автора, задали его знакомому на собеседовании при приёме на работу в Apple. Эту задачу его знакомый решить не смог.

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

Это было проиллюстрировано следующей картинкой:

Очевидно, в данных трёх вариантах ответы будут, соответственно, "2", "нет" и "1".

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

КДПВ
КДПВ

Следующим шагом зрителям в ролике давалась подсказка: если кто придумал сохранять пройденные узлы в какой-то своей структуре данных и проверять каждый новый узел на присутствие в этой структуре, то такое решение плохое, так как алгоритм не должен требовать O(n) дополнительной памяти.

КДПВ2
КДПВ2

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

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

Теперь перейдём к предложенному автором ролика (и, видимо, сообщённому в Apple неудачному претенденту) решению задачи.

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

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

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

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

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

Сравним эффективность алгоритма кролика-черепахи и алгоритма с маркировкой.

Алгоритм кролика-черепахи не гарантирует нам, что указатели встретятся на первом же обороте цикла, быстрый указатель может сначала перескочить медленный. Поэтому у нас может образоваться лишний проход быстрого указателя по циклу. Поскольку нам надо проводить по списку два указателя, то количество операций доступа к памяти и проверки в среднем навскидку где-то около 2*N + 1.5*L, где N – длина списка, а L – длина цикла. Всё это будут операции чтения.

Алгоритм с маркировкой требует только одного указателя и гарантированно находит начало цикла сразу, поэтому количество операций доступа к памяти и проверки будет N, если мы не восстанавливаем список, и 2*N, если восстанавливаем. Но доступ в данном случае состоит из чтения и записи.

Проверим наши соображения на практике. Запрограммируем алгоритмы на языке Си. В алгоритме кролика и черепахи немножко облегчим себе жизнь и начнём движение указателей сразу со второго шага, чтобы не утяжелять проверку в цикле начальным равенством указателей. Для простоты отрицательный ответ обозначим номером вершины 0.

Алгоритм кролика и черепахи:

#include <stdio.h>
#include <time.h>

int main () {

  #define SIZE 100000000

  struct Node {
    long num;
    struct Node *next;
  };

  static struct Node array [SIZE];

  long i, time1, time2, loopnode;
  struct Node *ptr1, *ptr2;

  for (i=0; i<SIZE-1; i++) {
    array [i].num = i+1;
    array [i].next = &array[i+1];
  }
  array [SIZE-1].num = SIZE-1;
  array [SIZE-1].next = &array[1];

  time1 = clock();

  ptr1 = &array[1];
  ptr2 = &array[2];

  while ((ptr2 != NULL) && 
        (ptr2->next != NULL) && 
        (ptr1 != ptr2)) {
    ptr1 = ptr1->next;
    ptr2 = ptr2->next->next;
  }
    
  if (ptr1 != ptr2)
    loopnode = 0;
  else {
    ptr1 = &array[0];
    while (ptr1 != ptr2) {
      ptr1 = ptr1->next;   
      ptr2 = ptr2->next;
    }
    loopnode = ptr1->num;
  }
   
  time2 = clock();
  
  printf ("Loopnode is %ld, time is %ld\n", 
          loopnode, time2-time1);
  
  return 0;

}

Алгоритм с маркировкой:

#include <stdio.h>
#include <time.h>

int main () {

  #define SIZE 100000000
  #define RESTORE Y

  struct Node {
    long num;
    struct Node *next;
  };

  static struct Node array [SIZE];

  long i, time1, time2, loopnode;
  struct Node *ptr1, *ptr2;

  for (i=0; i<SIZE-1; i++) {
    array [i].num = i+1;
    array [i].next = &array[i+1];
  }
  array [SIZE-1].num = SIZE-1;
  array [SIZE-1].next = &array[1];

  time1 = clock();

  ptr1 = &array[0];

  while ((ptr1->num > 0) && (ptr1->next != NULL)) {
    ptr1->num = -ptr1->num;
    ptr1 = ptr1->next;
  }
    
  if (ptr1->next == NULL)
    loopnode = 0;
  else
    loopnode = -ptr1->num;

  #ifdef RESTORE
  
  ptr1 = &array [0];
  while ((ptr1 != NULL) && (ptr1->num < 0)) {
    ptr1->num = -ptr1->num;
    ptr1 = ptr1->next;
  }
  
  #endif

  time2 = clock();

  printf ("Loopnode is %ld, time is %ld\n", 
          loopnode, time2-time1);

  return 0;
    
}

Откомпилируем их при помощи Apple clang 14.0.3 и посчитаем время выполнения на Mac mini с процессором Intel Core i3 3.6 GHz.

оптимизация -O0

оптимизация -O3

Кролик и черепаха

522082

347758

Маркировка с восстановлением

726725

340528

Маркировка без восстановления

363929

171820

Таким образом, более простой алгоритм маркировки с восстановлением не только существенно не уступает кролику и черепахе по производительности, но и в ряде случаев превосходит. А маркировка без восстановления лучше кролика и черепахи вдвое (так как зарплату платить надо одной черепахе).

Какие мысли имеются у читателей по данному поводу?

P.S. Слава богу, есть на свете много умных людей, судя по комментариям. А то я реально испугался, глядя на ютуб.

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


  1. just-a-dev
    23.06.2023 10:48
    +2

    А если там unsigned int?


    1. vadimr Автор
      23.06.2023 10:48
      +1

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

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

      Правда, баловство с указателями трудноисполнимо в системах программирования со сборщиком мусора.


      1. unreal_undead2
        23.06.2023 10:48
        +6

        Но в целом место для лишнего битика мы обычно можем найти, если не в номере ноды, то в указателе.

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

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


      1. RomeoGolf
        23.06.2023 10:48

        Вопрос даже не в том, есть ли место. Вопрос — не занято ли место? Если номер узла уникальный и не обязательно в порядке узлов, то неизвестно, не встретится ли отрицательный номер в нормальной немаркированной нумерации. Это ведь тоже в условии не запрещено. Возможны ложные срабатывания.


      1. RomeoGolf
        23.06.2023 10:48
        +3

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


        1. unreal_undead2
          23.06.2023 10:48
          +2

          В реальности я бы запользовал хеш таблицу, а дальше смотрел, насколько проблемно выделение дополнительной памяти на реальных данных.


          1. vadimr Автор
            23.06.2023 10:48

            Допустим, хеш совпал. Что это нам даёт?

            Это ведь может быть и случайное совпадение.


            1. unreal_undead2
              23.06.2023 10:48
              +2

              Под хешом имелся в виду std::unordered_map, оно коллизии само обработает.


            1. LedIndicator
              23.06.2023 10:48

              Допустим, хеш совпал. Что это нам даёт?

              Не знаю как у вас в сишечке, а у нас в жаве, если хэш коды совпадают, ноды в HashMap (в нашем случае в HashSet, но неважно) попадают в один бакет, и там хранятся в виде списка или дерева. При доставании ноды с одним хэш кодом сравниваются методом equals. Есть реализация хэш структур со сдвигом, open addressing, так называемый. Там в случае коллизии просто ищется свободный слот.


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


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


              1. vadimr Автор
                23.06.2023 10:48

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


  1. OldFisher
    23.06.2023 10:48
    +5

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

    Но ведь прыжок "кролика" на 2 шага - не атомарная операция, и ему ничто не помешает проверять совпадение и на промежуточном узле тоже.


    1. vadimr Автор
      23.06.2023 10:48

      Справедливое замечание.

      Но в смысле производительности это не особенно влияет.


      1. pda0
        23.06.2023 10:48

        Не на промежуточном. Вместо скорости черепаха - 1, кролик - 2, использовать черепаха - 0.5, кролик - 1. Т.е. каждый цикл сдвигаем указатель кролика, а черепаший только каждую чётную (или не чётную) итерацию. Прос их скорость работы можно попробовать выровнять дополнительным указателем пометки. Помечаем позицию кролика, затем не трогаем её, пока её не догонит черепаха. Если догнала, то помечаем текущее положение кролика. А критерием остановки станет: 1. если достигнут конец незамкнутого списка. 2. кролик на первом элементе списка (кольцо). 3. кролик догнал черепаху. 4. кролик догнал помеченный указатель.

        Чисто на пальцах тогда кол-во операций на поиск петли устремиться в 1.5*N в среднем...


        1. vadimr Автор
          23.06.2023 10:48

          1 и 2 – это по сути то же самое, что 0.5 и 1.

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


      1. SiGGthror
        23.06.2023 10:48

        На самом деле алгоритм как раз гарантирует встречу на первом проходе черепахи, оптимизация не нужна


        1. vadimr Автор
          23.06.2023 10:48

          Как он может это гарантировать? Всё зависит от чётности длины пути.


          1. SiGGthror
            23.06.2023 10:48

            1. vadimr Автор
              23.06.2023 10:48

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


              1. SiGGthror
                23.06.2023 10:48

                Я хочу сказать именно то что и написал - гарантируется что за первый проход черепахи по циклу кролик ее встретит. Доказательство - по ссылке. Третий пример вырожденный, потому что там "хвост" = 0, очевидно что он ее перескочит, т.к. мы уже находимся в цикле.


                1. vadimr Автор
                  23.06.2023 10:48

                  Оптимизация нужна для того, чтобы кролик встретил черепаху за первый свой совместный с черепахой проход по циклу, а не первый проход черепахи. За один проход черепахи кролик успевает обернуться два раза, это в наихудшем случае лишние L*1.5 операций по сравнению с неоптимизированной версией (лишние полкруга проходит черепаха и лишний круг проходит кролик).


                  1. SiGGthror
                    23.06.2023 10:48

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

                    Тогда у меня вопрос - как найти начало цикла в вашей оптимизированной версии?


                    1. vadimr Автор
                      23.06.2023 10:48

                      Это не моя оптимизированная версия, но ответ простой – отъехать кроликом на одну клетку назад (т.е. на место встречи, которое он перепрыгнул бы), а черепахой на одну клетку вперёд (т.е. начать во второй раз черепахой со второго узла), чтобы скомпенсировать изменившуюся чётность:

                        while ((ptr2 != NULL) && (ptr2->next != NULL) 
                                && (ptr1 != ptr2) && (ptr1 != ptr2->next)) {
                          ptr1 = ptr1->next;
                          ptr2 = ptr2->next->next;
                        }
                      
                        if (ptr1 == ptr2->next) {
                          ptr2 = ptr1;
                          ptr1 = &array[1];
                        }
                        else 
                          ptr1 = &array[0];
                      
                        if (ptr1 != ptr2)
                          loopnode = 0;
                        else {
                          while (ptr1 != ptr2) {
                            ptr1 = ptr1->next;
                            ptr2 = ptr2->next;
                          }
                          loopnode = ptr1->num;
                        }


                      1. SiGGthror
                        23.06.2023 10:48

                        Дело - не в четности как таковой. Тот факт что X(n) = X(2n) дает гарантию, что после того как мы скинем один из указателей на начало списка встреча произойдет в начале цикла. Есть ли такая гарантия после оптимизации?


                      1. vadimr Автор
                        23.06.2023 10:48

                        Да, может я и неправ.

                        Тогда получается, что алгоритм неоптимизируемо содержит дофига лишних операций.


                      1. SiGGthror
                        23.06.2023 10:48

                        Я бы не назвал это "лишними операциями". Задача стоит в поиске такой ноды, в которой черепаха равноудалена от начала, а кролик равноудален от черепахи. После ее нахождения мы точно:

                        • можем сказать, что в списке есть цикл

                        • найти начало цикла

                        • вычислить его длину

                        При этом гарантируется, что при наличии цикла такая нода встретится при первом проходе черепахи по нему. На мой взгляд это крайне изящный алгоритм)


                      1. vadimr Автор
                        23.06.2023 10:48

                        Алгоритм с маркировкой позволяет всего этого достичь ровно за N*2 обращений к вершинам (если мы восстанавливаем исходную структуру), что в наиболее неудачном случае на 75% быстрее.


  1. sepulkary
    23.06.2023 10:48
    +2

    Эта задача помечена как "Easy" на Leetcode. Там же, в разделе "Solutions" описаны примерно 8000 решений с использованием широкого спектра языков программирования и алгоритмических подходов.


    1. vadimr Автор
      23.06.2023 10:48
      +1

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


      1. sepulkary
        23.06.2023 10:48
        +2

        Да, действительно, это я невнимательно прочитал условие задачи. На Leetcode, кстати, есть и этот вариант.


      1. Akina
        23.06.2023 10:48

        Это сложность выбранного способа решения, а не собственно задачи.

        Но она действительно сложнее литкодовской. А более сложная эта задача потому, что поставлено дополнительное условие не использовать дополнительную память для накопления списка пройденных узлов. Без этого условия задача становится чуть ли не тривиальной, и необходимость найти начало цикла её почти не усложняет.


        1. Politura
          23.06.2023 10:48

          В самом низу задачки:

          Follow up: Can you solve it using O(1) (i.e. constant) memory?


      1. datacompboy
        23.06.2023 10:48
        +4

        А где, кстати, доказательство что движение из точки встречи приведёт именно к началу цикла? Самое важное в посте отсутствует :)


        1. vadimr Автор
          23.06.2023 10:48

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


          1. avdx
            23.06.2023 10:48
            +1

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

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


            1. SiGGthror
              23.06.2023 10:48
              +2

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


              1. datacompboy
                23.06.2023 10:48

                Даже если расстояние до начала цикла взаимопростое с длиной цикла?

                Кстати, оптимизация "заец проверяет пересечение когда бежит" ломает гарантию встречи вначале


                1. SiGGthror
                  23.06.2023 10:48

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


                  1. datacompboy
                    23.06.2023 10:48

                    Я про кратность расстояния - не понимаю как оно гарантирует встречу в начале цикла.

                    А оптимизация это мысли вслух по поводу


                    1. SiGGthror
                      23.06.2023 10:48

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


                      1. datacompboy
                        23.06.2023 10:48
                        +1

                        Понятнее не стало совершенно...


                      1. qw1
                        23.06.2023 10:48
                        +3

                        Обозначим
                        r — "длина ручки", оно же число узлов до входа в цикл.
                        L — длина цикла.


                        Пусть до первой встречи черепаха прошла r+t шагов, то есть r шагов вне цикла, t — внутри цикла. Черепаха стоит на позиции цикла
                        r+t-r (mod L) = t (mod L)
                        (считаем, что начальная нода цикла имеет номер 0).


                        Заяц за это время прошёл 2(r+t) шагов и стоит на позиции цикла
                        2(r+t)-r (mod L) = r+2t (mod L)


                        Т.к. позиции одинаковые,
                        t = r+2t (mod L)
                        r+t = 0 (mod L)


                        То есть, от той позиции t (mod L), где сейчас стоит черепаха, до стартовой позиции цикла 0, нужно сделать r шагов.
                        Запускаем зайца с начала "хвостика" с единичной скоростью, он делает r шагов по хвостику до входа в цикл. Черепаха делает за это время те же r шагов, и они встречаются в начале цикла.


                      1. datacompboy
                        23.06.2023 10:48

                        Черепаха стоит на позиции цикла

                        r+t-r (mod L) = t (mod L)

                        Так вот это утверждение и требует доказательства...


                      1. vadimr Автор
                        23.06.2023 10:48
                        +1

                        По определению t и r.


                      1. qw1
                        23.06.2023 10:48

                        Это равенство было лишним (в левой части подсчёт шагов с начала движения, в правой части — с момента входа в цикл).


                        Достаточно было просто написать:
                        Пусть t — число шагов, которое черепаха прошла внутри цикла. Тогда она стоит на позиции цикла t (mod L)


                      1. SiGGthror
                        23.06.2023 10:48

                        На сколько я помню, до гарантируется, что первое r + t:
                        r < r + t < r + L
                        0 < t < L
                        Т.е. остаток от деления здесь тоже лишний


                      1. vadimr Автор
                        23.06.2023 10:48
                        +1

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

                        В частном случае операции +mod и =mod могут совпадать с + и =, но нам это неважно.


                      1. qw1
                        23.06.2023 10:48

                        гарантируется, что первое r + t: 0 < t < L

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


                        Строго говоря, надо доказывать, что позиция когда-нибудь совпадёт. Это так, потому что скорости, 1 и 2, взаимно простые (upd. если точнее, необходимо, чтобы разница скоростей была взаимно простой с L)


                      1. datacompboy
                        23.06.2023 10:48

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


                      1. SiGGthror
                        23.06.2023 10:48
                        +1

                        Строго говоря, надо доказывать, что позиция когда-нибудь совпадёт. Это так, потому что скорости, 1 и 2, взаимно простые.

                        Свойство цикла: X(n) = X (n + k * L), где k - натуральное число, а L - длина цикла. Словами: если мы из любой ноды пройдем количество шагов кратное циклу мы окажемся в ней же.

                        В случае если X(n) = X(2n), то n (mod L) = 0 из свойства выше, т.е. n = k * L.

                        Теперь можно показать, если n - первое такое число (их больше чем одно), то r < n < r + L
                        n > r - т.к. такое X(n) = X(2n) справедливо только для нод внутри цикла.

                        Теперь покажем что r + L > n

                        r = z * L + y, где y = r (mod L) и принадлежит натуральным числам.

                        Пусть первым n, которое больше r и делится без остатка на L будет r + t, или

                        z * L + y + t

                        Т.к. n (mod L) = 0, то y + t = L, или t = L - y

                        Получается что:
                        r + L > r + L - y

                        Неравенство выполняется, потому что y - натуральное число

                        Выходит что r < n < r + L


                      1. qw1
                        23.06.2023 10:48

                        Довольно сложное (как по мне) доказательство, но ошибок я не нашёл.


                        Ключевой момент тут
                        X(n) = X(2n) ↔ n = 0 (mod L)
                        импликация работает в обе стороны.


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


                      1. SiGGthror
                        23.06.2023 10:48

                        Вы все верно ухватили, у Кнута именно это и было написано, дальше я лишь доказал тот факт, что если мы ищем ближайшее к r число которое делится на L без остатка, то оно будет лежать в промежутке от r до r + L


                      1. SiGGthror
                        23.06.2023 10:48

                        То есть, от той позиции t (mod L), где сейчас стоит черепаха, до стартовой позиции цикла 0, нужно сделать r шагов.

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


                      1. datacompboy
                        23.06.2023 10:48

                        Откуда гарантии?


                      1. qw1
                        23.06.2023 10:48
                        +1

                        Из равенства
                        r+t = 0 (mod L)


                      1. datacompboy
                        23.06.2023 10:48

                        Спасибо, теперь доказательство полно :)


          1. SiGGthror
            23.06.2023 10:48
            +1

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


  1. cliver
    23.06.2023 10:48
    +1

    Это часом не про эту задачу? :)


  1. KongEnGe
    23.06.2023 10:48
    +11

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


    1. vadimr Автор
      23.06.2023 10:48
      +2

      Есть сермяжная правда в ваших словах, но в системном программировании такое встречается :)

      В частности, в сборщиках мусора любимое дело размещать свои временные данные в собираемом мусоре.


    1. kinh
      23.06.2023 10:48

      Я часто использую модификацию указателей. Если адрес выровнен на границе 4 байт, то это значит, что младшие 2 бита указателя всегда 0, и их можно использовать в качестве битовых флагов. В задаче из статьи один из битов можно использовать для пометки того, что элемент уже пройден. Если нам попадётся элемент с этим установленным битом, то в списке - цикл.

      // Распределение блока памяти, всегда выровненного на границе 4 байт
      
      unsigned char *real_block = 
        (unsigned char *)malloc(num_bytes); // контейнер для блока
      unsigned char *aligned_block; // выровненный блок
      if((((size_t)real_block) & 3) == 0)
      {
        aligned_block = real_block;
      }
      else
      {
        // здесь можно использовать _expand(...), но я не стал усложнять пример
        free(real_block);
        real_block = (unsigned char *)malloc(num_bytes + 3);
        size_t correction = (-(int)(size_t)real_block) & 3;
        aligned_block = real_block + correction;
      }

      В качестве блока можно рассматривать массив элементов, используемый для контейнера hive/colony. Из этого контейнера распределяются элементы для списка, и каждый из них будет выровнен на 4 байта.

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

      struct ListItem
      {
        size_t next_with_flags;
        int data;
        
        ListItem* next() {return (ListItem *)(next_with_flags & (size_t)(ptrdiff_t)~3);}
        unsigned flags() {return (unsigned)next_with_flags & 3;}
      };


  1. bay73
    23.06.2023 10:48
    +2

    Ага, а параллельно этот список читает ещё сотня потоков, которые будут очень рады его модификации.


    1. ZyXI
      23.06.2023 10:48

      Достаточно одного потока и связного списка определённого в виде


      struct linked_list;
      int linked_list_node_number(const struct linked_list *);
      const struct linked_list *linked_list_next(const struct linked_list *);
      
      extern const struct linked_list *start;

      Попробуйте помодифицировать что‐то, не получив Undefined Behaviour. Для разумных программистов можно даже удалить все const — одной инкапсуляции достаточно.


      1. vadimr Автор
        23.06.2023 10:48

        Вопрос только в том, зачем в данном случае так делать.


        1. qw1
          23.06.2023 10:48

          Это может быть чужое API, с которым приходится работать.


          1. vadimr Автор
            23.06.2023 10:48

            С тем же успехом можно предположить, что чужое API не позволяет завести больше одного итератора.

            Всё-таки я за то, чтобы решать задачу в том виде, как она поставлена.


  1. Akina
    23.06.2023 10:48

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

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

    Если же после каждого прыжка хоть кого-то сравнивать местоположения, то никакого "перепрыгнет" не будет. И соответственно не будет потенциального "второго оборота".

    В этих условиях при проверке на каждом прыжке можно кролику прыгать не на 2, а на 3 узла. Всё равно мимо не проскочит.


    1. datacompboy
      23.06.2023 10:48
      +2

      Но при этом возможна лишняя бегодня кролика по циклу, к которому ползёт черепаха


      1. Akina
        23.06.2023 10:48

        А какая разница? Иногда кролик сделает лишний круг, иногда нет. Но в среднем мы немного сэкономим, навскидку где-то 1/12 круга.


        1. qw1
          23.06.2023 10:48

          Ваше решение будет медленнее, если цикл — 3 ноды, а "хвостик" перед циклом — миллион нод.


    1. Alexandroppolus
      23.06.2023 10:48

      В этих условиях при проверке на каждом прыжке можно кролику прыгать не на 2, а на 3 узла. Всё равно мимо не проскочит.

      Если кролик и черепаха стартуют от двух соседних элементов, как в статье, то попадают на бесконечный цикл, при условии, что длина цикла в списке равна равна 6*n


      1. vadimr Автор
        23.06.2023 10:48

        В статье просто начинаем цикл со второго шага. На первом оба были в нуле.


        1. Alexandroppolus
          23.06.2023 10:48

          Алгоритм кролика и черепахи:

          ...

          ptr1 = &array[1];
          ptr2 = &array[2];

          Я вот об этом.


          1. vadimr Автор
            23.06.2023 10:48

            Именно. Это их позиции на втором шаге. Кролик продвинулся с 0 на 2, а черепаха - с 0 на 1.


            1. Alexandroppolus
              23.06.2023 10:48

              Если что, в этой ветке комментов обсуждается идея двигать зайца не на 2, а на 3 шага.


              1. vadimr Автор
                23.06.2023 10:48

                Тогда там будет array[3]. Это не та проблема.


  1. mpa4b
    23.06.2023 10:48
    +1

    В приведённых примерах оказывается, что элемены списка лежат подряд, и хождение по нему оказывается равносильно линейному чтению памяти. Для более релевантных результатов хорошо бы для начала "перемешать" список: так, чтобы прыжки по .next были бы в рандомные позиции. И время создания/перемешивания тоже не измерять, а только целевую задачу.


    1. vadimr Автор
      23.06.2023 10:48
      +1

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

      Меня в измерениях беспокоил, в основном, ответ на вопрос, не просядет ли производительность из-за операций записи. И на другом процессоре, образца 12-летней давности, она действительно заметно просаживается.

      А что касается времени создания, то оно не учитывается.


      1. mpa4b
        23.06.2023 10:48
        +1

        Интересное наблюдение: первый алгоритм делает 4 прохода по массиву от начала до конца: сначала 3 прохода до совпадения указателей, потом ещё один для нахождения 1ого элемента (один потому что ввиду построенного списка, где конец указывает на 2ой элемент, ptr1 и ptr2 оказываются недалеко друг от друга, и зафетченное в кеш для "спешащего" указателя пригождается чуть позже "опаздывающему"). Второй алгоритм (с восстановлением) тоже делает 4 прохода: один раз вычитывает всю память, тут же (с лагом в размер кеша, предполагая что тот writeback) отписывает обратно, потом ещё раз делает то же самое (для восстановления). Последний алгоритм делает лишь 2 прохода.

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


  1. guryanov
    23.06.2023 10:48
    +2

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


    1. mpa4b
      23.06.2023 10:48
      +4

      И тогда получается типичная задача на собеседовании: если не знать подхода, не решишь, если знаешь трюк про шаг по 1 и по 2 -- решишь. По подходу ничем особо не отличается от "почему люки круглые" :)


    1. vadimr Автор
      23.06.2023 10:48

      В таком условии нет особого содержательного смысла. Почему нельзя?

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


      1. unreal_undead2
        23.06.2023 10:48

        Почему нельзя?

        Например у нас 32битное приложение на 64битной системе (соответственно, практически всё адресное пространство может быть использовано, лишнего бита в адресе нет), а номер элемента - unsigned short (и допустимы списки до 65535 элементов), то есть там тоже битика не найдётся.


        1. vadimr Автор
          23.06.2023 10:48
          +1

          Даже при таких замороченных условиях будет лишний младший бит адреса, так как 6- или 8- байтовые структуры на практике будут выравнены на 2.


          1. unreal_undead2
            23.06.2023 10:48

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

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


            1. vadimr Автор
              23.06.2023 10:48

              Уже многовато героических задач нужно решить простому солдату израильской армии :) По сути, четырёхслойная 8-16-32-64 архитектура.


  1. RouR
    23.06.2023 10:48
    +1

    Я бы копал в сторону фильтра Блума и его модификаций


  1. code_panik
    23.06.2023 10:48

    1. code_panik
      23.06.2023 10:48
      +2

      Можно рассуждать так. Пусть цикл есть. Пусть r - длина ручки списка (число шагов до первого узла в цикле), m - длина цикла. Тогда черепаха делает r + k шагов, где k шагов в цикле. Заяц делает r + t * m + k = 2 * (r + k), где t - полное число проходов по циклу, и т.к. на каждый шаг черепахи 2 шага зайца. Поэтому r + k = t * m, r = t * m - k. Пусть оба встречаются в некотором узле А цикла, тогда после встречи пусть одна черепаха бежит из начала списка, друга из узла А. Тогда они встретятся в искомом узле (начале цикла), потому что пока одна пробегает длину ручки, другая пробегает остаток цикла m - k и делает несколько полных оборотов - число кратное m, если t > 0 это t - 1.


    1. squaremirrow
      23.06.2023 10:48
      +1

      Как насчет такого ненормального подхода?


      Т.к. значение каждого узла ограничено -10^5 <= Node.val <= 10^5, значит можно каждому такому числу сопоставить некое простое число, не превышающее какой-то фиксированный размер в памяти, например взять простое число под номером 2*abs(Node.val) + (sign(Node.val) + 1)//2. При проходе по листу, считаем бегущее произведение прошлых простых чисел, и если простое число, соответствующее текущему Node.val делит это произведение без остатка, значит мы оказались в узле, в котором мы уже раньше были.


      1. vadimr Автор
        23.06.2023 10:48

        Вот это мощно!!!

        Но по сути это форма запоминания номеров.


      1. code_panik
        23.06.2023 10:48

        Если правильно понял, для этого нужно взаимно-однозначное соответствие между узлами и простыми числами. По ссылке длина списка до 10^4, то есть в случае отсутствия цикла у нас в итоге будет произведение 10^4 первых простых чисел. Множество чисел изначально слишком большое и произведение будет слишком большое. Десятитысячное простое число больше 100000.


  1. rukhi7
    23.06.2023 10:48
    +2

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

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


    1. vadimr Автор
      23.06.2023 10:48

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


      1. khajiit
        23.06.2023 10:48
        +4

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


        UPD: задача, на самом деле, не то что нереалистичная, просто она из серии как сделать через жопу а потом рвать на жопе волосся, героически превозмогая.


      1. rukhi7
        23.06.2023 10:48

        Мы ж не знаем прикладного назначения этой структуры данных

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


        1. vadimr Автор
          23.06.2023 10:48

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


          1. khajiit
            23.06.2023 10:48
            +3

            А для такой задачи вот это допусловие


            Следующим шагом зрителям в ролике давалась подсказка: если кто придумал сохранять пройденные узлы в какой-то своей структуре данных и проверять каждый новый узел на присутствие в этой структуре, то такое решение плохое, так как алгоритм не должен требовать O(n) дополнительной памяти.

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


          1. rukhi7
            23.06.2023 10:48
            +1

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

            Потом вы допустили одно, он другое, кто то третье, решать будем что-то в среднем что ли? Вы считаете это имеет смысл?


    1. code_panik
      23.06.2023 10:48
      +1

      Задача действительно лишена практического смысла

      Нет. Пусть есть последовательность зависимостей, которую нужно проверить на отсутствие циклов. Циклы могут образовывать зависимости, например, в программных файлах, включаемых друг в друга, или в вызывающих друг друга функциях. Тут https://youtu.be/Qc8zuhaqWPM?t=457 Тим Кейн рассказывает о таком случае в разработке Arcanum. Он решает её добавлением ограничения на количество вложенных вызовов. Эта задача - разновидность задачи поиска цикла в графе. Она возникает в самых разных местах. Именно о задаче из статьи впервые услышал на лекции Степанова А.А. в Яндексе. Яндекс, кажется, местами отмирает - не могу найти ссылку на лекцию, а та что есть не работает.


      1. rukhi7
        23.06.2023 10:48

        Пусть есть последовательность зависимостей

        Если можно начать с "Пусть", я предпочту начать так:

        (А-) Пусть ее НЕТ, этой последовательности зависимостей ...

        В том смысле, например, что давайте не будем ее допускать по мере формирования списка, список то в памяти все равно как то формируется, а не из сферической реальности прилетает!

        Гоголь когда-то написал:

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


        1. qw1
          23.06.2023 10:48

          реторические

          Эх, Гоголь, Гоголь...


          1. vadimr Автор
            23.06.2023 10:48
            +2

            Тут смешно. По старой орфографии действительно было “реторическiя” и затем “реторические”. Процитировано, видимо, по изданию 1933 года.


            1. rukhi7
              23.06.2023 10:48

              Здорово! Там где рассуждают о том кто быстрее черепаха или кролик или когда они встретятся бегая в лабиринте, не грех и орфографию Гоголя обсудить!

              Я то, понятно, орфографию не проверял, скопировал откуда-то изначально, пол года назад, сыну показать. Не как не думал что возможны такие нюансы :) .


  1. andybiiig
    23.06.2023 10:48
    +1

    Если модификация списка не запрещена, есть один вариант, только я его до конца не додумал. Таки да, на первом проходе надо указатели разворачивать, тогда или (1) список закончится (циклов нет), или (2) мы вернёмся к его началу. При этом надо считать количество шагов L. При исходе (1) можно список развернуть обратно. При исходе (2) у нас будет элемент в позиции L/2, который является самой дальней точкой и находится точно в середине цикла (или два элемента, с учётом четности L). Нам нужно дойти до него (опять же, разворачивая указатели) и дальше идти по обеим веткам обратно, пока указатели не укажут на один элемент, это и будет начало цикла. При этом ветку, которая идёт "вперёд", надо разворачивать, а ветку, которая идёт "назад" и по которой мы пришли в L/2, разворачивать не надо. Тогда после завершения поиска у нас список вернется в исходное состояние.

    Возможно, я сейчас изобрёл велосипед, который описан где-то в умных книгах. Зато сам)


  1. shasoftX
    23.06.2023 10:48
    +2

    В каждом узле списка располагается его уникальный номер (не обязательно в порядке связности списка) 

    А обязательно ли нумерация начинается с 1 и нет пропусков в нумерации? А то ведь можно просто считать максимум и количество пройденных узлов. Как только превышение, значит мы на первом узле кольца. Само собой если встретили null, то кольца нет


    1. rukhi7
      23.06.2023 10:48
      +1

      осталось только выяснить откуда взять количество узлов в списке, когда список зациклен их посчитать не получится.

      Как я уже писал выше:

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


      1. shasoftX
        23.06.2023 10:48

        откуда взять количество узлов в списке

        Так посчитать можно.

        int count = 0;
        int maxId = 0;
        Node *item = <первый элемент>;
        while(item!=null) {
          maxId = max(maxId, item->info);
          count++;
          if(count > maxId) {
             printf("Первый элемент цикла %i",item->info);
             break;
          }
          item = item->link;
        }
        if(head==null) {
          printf("Нет циклов");
        }

        Если начинается всегда с 1-го номера и без пропусков, то даже если они не по порядку, то алгоритм будет работать.


        1. rukhi7
          23.06.2023 10:48

          Проблема в том что постоянно вылезают какие-то "если"!

          если у item есть item->info

          если определена операция max() для (maxId, item->info)

          Это только то что вы придумали, выше смотрите другие варианты!

          При таком количестви "если" это превращается в какой-то маразматический перебор для всех "если" и "не-если".

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


          1. vadimr Автор
            23.06.2023 10:48
            +1

            Я считаю, что задача для интервью должна решаться в том виде, в каком она была поставлена. Если человек нарисовал в качестве примеров три схемы с последовательными натуральными числами и ничего больше не сказал, то имеет право получить решение в соответствии со своим ТЗ. Поэтому, с моей точки зрения, важен приведённый скриншот.


          1. shasoftX
            23.06.2023 10:48
            +2

            если у item есть item->info

            если определена операция max() для (maxId, item->info)

            Вот это не предположение, а 100% верно исходя из условия задачи. Тут я ничего не придумываю.

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


  1. squaremirrow
    23.06.2023 10:48
    +3

    upd: слишком долго писал комментарий, shasoftX меня опередил


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


    Заводим два числа:


    1. Счетчик количества узлов с начала
    2. Бегущий максимум встреченных значений элементов

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


    Почему это работает? Потому что максимум инвариантен к перестановкам в списке, и при проходе всех элементов списка мы обязательно получим 2-е число равное N. Поэтому, как только мы вернемся в элемент, который уже посещали до этого, т.е. перейдем в N+1 элемент по счету, мы получим выполнение условия на выход из цикла.


    1. datacompboy
      23.06.2023 10:48

      А как при этом найти начало цикла?


      1. vadimr Автор
        23.06.2023 10:48
        +1

        Это и есть (max+1)-й по счёту элемент.


  1. pda0
    23.06.2023 10:48
    +1

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

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

    Т.е. 1. Инвертируем список. 2. Проверяем равна ли новая голова старой. 3. Восстанавливаем список, снова инвертируя его, относительно новой головы.

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

    Что было под рукой...
    {$MODE OBJFPC}{$H+}
    {$R+}
    
    type
      PListNode = ^TListNode;
      TListNode = record
        Next: PListNode;
        Id: Integer;
      end;
    
    function AddNode(PrevNode: PListNode; Id: Integer): PListNode;
    begin
      New(Result);
      Result^.Next := nil;
      Result^.Id := Id;
      
      if PrevNode <> nil then
        PrevNode^.Next := Result;
    end;
    
    procedure DeleteList(FirstNode, LastNode: PListNode);
    var
      DelNode: PListNode;
    begin
      repeat
        DelNode := FirstNode;
        FirstNode := FirstNode^.Next;
        if FirstNode = LastNode then
          FirstNode^.Next := nil;
    
        Write(DelNode^.Id, ' ');
        Dispose(DelNode);
      until FirstNode = nil;
      Writeln;
    end;
    
    function InvertList(FirstNode: PListNode): PListNode;
    var
      PrevNode, CurrNode, NextNode: PListNode;
      FirstPass: Integer;
    begin
      PrevNode := nil;
      CurrNode := FirstNode;
      NextNode := CurrNode^.Next;
      FirstPass := 0;
      repeat
        if CurrNode = FirstNode then
          FirstPass := FirstPass + 1;
    
        CurrNode^.Next := PrevNode;
        PrevNode := CurrNode;
        CurrNode := NextNode;
        if CurrNode <> nil then
          NextNode := CurrNode^.Next;
      until (CurrNode = nil) or ((FirstPass = 2) and (PrevNode = FirstNode));
    
      if CurrNode = nil then
        Result := PrevNode
      else
        Result := CurrNode;
    end;
    
    function CheckLoop(FirstNode: PListNode): Boolean;
    var
      NewFirstNode: PListNode;
    begin
      if FirstNode^.Next <> nil then
      begin
        if FirstNode^.Next <> FirstNode then
        begin
          NewFirstNode := InvertList(FirstNode);
          Result := NewFirstNode = FirstNode;
          InvertList(NewFirstNode);
        end
        else
          Result := True;
      end
      else
        Result := False;
    end;
    
    procedure WriteBool(Value: Boolean);
    begin
      if Value then
        Writeln('True')
      else
        Writeln('False');
    end;
    
    var
      N1_1, N1_2, N1_3, N1_4, N1_5, N1_6, N2_1, N2_3, N2_2, N3_1, N3_2, N3_3,
      N3_4: PListNode;
    
    begin
      N1_1 := AddNode(nil, 1);
      N1_4 := AddNode(N1_1, 4);
      N1_2 := AddNode(N1_4, 2);
      N1_3 := AddNode(N1_2, 3);
      N1_5 := AddNode(N1_3, 5);
      N1_6 := AddNode(N1_5, 6);
      N1_6^.Next := N1_2;
    
      N2_1 := AddNode(nil, 1);
      N2_3 := AddNode(N2_1, 3);
      N2_2 := AddNode(N2_3, 2);
    
      N3_1 := AddNode(nil, 1);
      N3_3 := AddNode(N3_1, 3);
      N3_4 := AddNode(N3_3, 4);
      N3_2 := AddNode(N3_4, 2);
      N3_2^.Next := N3_1;
    
      WriteBool(CheckLoop(N1_1));
      WriteBool(CheckLoop(N2_1));
      WriteBool(CheckLoop(N3_1));
    
      DeleteList(N1_1, N1_6);
      DeleteList(N2_1, N2_2);
      DeleteList(N3_1, N3_2);
    end.


  1. VBKesha
    23.06.2023 10:48
    +4

    Я не смотрел оригинальное видео и не знаю как там преподносится, но я просто обожаю такие варианты постановки задачи, а именно:


    1. Вот задача и у вас развязаны руки
    2. А нет не развязаны, а немного связаны(так как алгоритм не должен требовать O(n) дополнительной памяти), почему сразу это не озвучить
    3. Подсказка никак не выплывающая из постановки задачи(Использовать один указатель, и в каждом пройденном узле менять, например, значение номера на отрицательное) Оказывается можем колбасить список как ходим.

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


    1. pda0
      23.06.2023 10:48

      Да какогож хрена я должен догадаться до пункта два и пункта три.

      Ни с какого. Так в общем случае делать нельзя. Такой подход называется "тегирование", но его применение допустимо, лишь если у вас есть дополнительные гарантии. В данном случае, что все числа не отрицательные.

      А то так-то и верхний бит указателей на следующий узел можно использовать для пометки.


  1. Zara6502
    23.06.2023 10:48

    Есть универсальное решение, а есть контекстно-зависимое, с оптимизацией под конкретные данные. Так как в задаче про конкретные данные ничего не сказано, то и решение должно быть универсальное. Так как я могу на том же собеседовании сказать, что это поиск петли в readonly БД на 45 петабайт и вся эта маркировка канет в лету.


    1. vadimr Автор
      23.06.2023 10:48

      Если это поиск во внешней памяти, то мы не только вправе, но и обязаны строить кеш в оперативке. Не бывает универсальных решений.


      1. Zara6502
        23.06.2023 10:48

        Если это поиск во внешней памяти, то мы не только вправе, но и обязаны строить кеш в оперативке

        нет оперативки

        Не бывает универсальных решений

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


        1. vadimr Автор
          23.06.2023 10:48

          Это существует, но это далеко не универсальный метод. Попробуйте так отсортировать магнитную ленту.


          1. Zara6502
            23.06.2023 10:48

            Это проблема не метода и не реализации, это вопрос организации доступа к оборудованию. Например я пользовался стиммерами HP еще в 2008 году, там всё делала сама железка и софтовая часть в ядре, для Oracle *nix работа ничем не отличалась от работы с SAS СХД или HDD. Соответственно и Sort будет работать, а вопрос эффективности - уже находится в категории "контекстно-зависимое решение".


            1. vadimr Автор
              23.06.2023 10:48

              В принципе будет работать, а практически результата вы не дождётесь.


              1. Zara6502
                23.06.2023 10:48

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


                1. vadimr Автор
                  23.06.2023 10:48

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


                  1. Zara6502
                    23.06.2023 10:48

                    потому что он контекстно-зависимый :рукалицо:


  1. agalakhov
    23.06.2023 10:48
    +2

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


    1. vadimr Автор
      23.06.2023 10:48

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


      1. qw1
        23.06.2023 10:48
        +1

        Это игра словами

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


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

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


        1. vadimr Автор
          23.06.2023 10:48

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

          Это вполне нормальный ход, если стоит именно такая задача.

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

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

          Формально, это не метод маркировки, это другой алгоритм. И он по нескольким причинам хуже "зайца и черепахи", потому что 1) требует 2 раза пройти и хвостик, и петлю,

          Алгоритм кролика и черепахи требует от 2 до 3.75 раз пройти все узлы. Указателя-то два.

          2) не находит позицию входа в петлю.(не говоря уже о модификации данных)

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


        1. vadimr Автор
          23.06.2023 10:48

          3.75 3.5


  1. Andrey_Solomatin
    23.06.2023 10:48
    +1

    Как вы решали бы эту задачу?


    Тестовая задача обычно рассматривается в контексте интервью. В опросе нет ни одного правильного варианта.

    Кроме решения задачи, на интервью, смотрят на то, как вы работаете с требованиями и рассуждаете.

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

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

    - Проговорить несколько вариантов, хэш, зайц и черепах, маркировка. Объяснить плюсы и минусы (сложность по памяти, сложность выполнения, сложность реализации). В процессе этого уточнять требования, типа: "Подойдёт ли решение O(n) по памяти?" (если вы на сеньёра, то скорее всего нет) или "Влезут ли данне в хэшмэпу?" (она большая, но не бесконечная). "Можно ли мутировать?" (без этого вопроса, предлагать вариант с мутацией, это путь к провалу).

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

    Этим вы покажете ширину своих знаний и инженерный подход к решению задач. И пойметё, что решаете ту задачу которую нужно. Я как-то похоже на собеседовании сделал, https://habr.com/ru/articles/743514/#comment_25682476, не перезвонили.


  1. igorekudashev
    23.06.2023 10:48
    +1

    вызвать метод для получения количества элементов в структуре и цикле пройти n + 1 элементов


    1. vadimr Автор
      23.06.2023 10:48

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


      1. datacompboy
        23.06.2023 10:48

        Это литкод изи... Плюс, не надо недооценивать как много людей могут провалить даже такую реализацию


        1. Andrey_Solomatin
          23.06.2023 10:48

          Нет, изи это просто проверить, что цикл есть, эта задача средняя.
          https://leetcode.com/problems/linked-list-cycle-ii/