Как стать автором
Обновить

Об одной тестовой задаче

Уровень сложностиСредний
Время на прочтение5 мин
Количество просмотров8.5K

Недавно 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. Слава богу, есть на свете много умных людей, судя по комментариям. А то я реально испугался, глядя на ютуб.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы решали бы эту задачу?
34.06% Методом кролика и черепахи47
33.33% Методом маркировки46
5.07% Другим хитрым методом (в комментарии)7
3.62% Сейчас не средневековье, забабахал бы вектор5
21.74% Сейчас не средневековье, забабахал бы хеш-таблицу30
6.52% Не в силах человеческого разума обнаружить цикл в односвязном списке9
5.8% Автор – ламер, в односвязном списке не может быть цикла8
2.9% О чём это вообще, тут нет объектов4
5.8% О чём это вообще, тут нет формы ввода8
Проголосовали 138 пользователей. Воздержались 34 пользователя.
Теги:
Хабы:
Всего голосов 8: ↑6 и ↓2+7
Комментарии147

Публикации

Истории

Ближайшие события