На видео более подробное объяснение каждого решения

Постановка задачи

Ссылка на задачу: https://leetcode.com/problems/middle-of-the-linked-list

Дан указатель head на начало односвязного списка, нужно вернуть средний узел списка.

Если средних узлов два, нужно вернуть второй средний узел.

1. Решение с использованием массива

Подход

Преобразование списка в массив упрощает доступ к элементам по индексу. Мы проходим по списку и сохраняем все узлы в массив, так как доступ по индексу в массиве осуществляется за постоянное время O(1). После этого мы легко можем найти средний элемент, просто взяв элемент по индексу len(items) // 2

Алгоритм

  1. Пройти по списку и сохранить все его элементы в массив.

  2. Определить индекс среднего элемента массива.

  3. Вернуть элемент, находящийся по этому индексу.

Код решения

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]:
        items = []

        while head: #None
            items.append(head)
            head = head.next
        
        return items[len(items) // 2]

Сложность алгоритма

По времени: O(n), где n — количество элементов в списке.

По памяти: O(n), так как мы сохраняем все элементы списка в массив.

Визуализация

  1. Исходный список: 1 -> 2 -> 3 -> 4 -> 5

  2. Массив после прохождения по списку: [1, 2, 3, 4, 5]

  3. Индекс среднего элемента: 5 // 2 = 2

  4. Средний элемент в массиве: 3
    0 1 2 3 4
    [1, 2, {3}, 4, 5]

2. Решение с подсчетом длины списка

Подход

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

Алгоритм

  1. Пройти по списку и подсчитать количество элементов.

  1. Пройти по списку снова до среднего элемента и вернуть его.

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]:
        count = self.count(head)

        for _ in range(count // 2):
            head = head.next
        
        return head
    
    def count(self, head):
        res = 0
        while head:
            res += 1
            head = head.next
        return res

Сложность алгоритма

По времени: O(n), где n — количество элементов в списке.

По памяти: O(1), так как мы не используем дополнительную память.

Визуализация

  1. Исходный список: 1 -> 2 -> 3 -> 4 -> 5

  2. Проход 1: Подсчет длины списка

    • Длина списка: 5

  3. Средний индекс: 5 // 2 = 2

  4. Проход 2: Получение среднего элемента

    • Проходим через узлы до индекса, который мы вычислили на 3 шаге:

    1 (0), 2 (1), 3 (2)

  5. Средний элемент: 3

3. Решение с использованием метода быстрого и медленного указателя

Подход

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

Алгоритм

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

  2. Перемещать медленный указатель на один шаг, а быстрый на два шага, пока быстрый указатель не достигнет конца списка.

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

# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
    def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]:
        slow = head
        fast = head

        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
        
        return slow

Сложность алгоритма

По времени: O(n), где n — количество элементов в списке.

По памяти: O(1), так как используем только два указателя.

Визуализация

  1. Исходный список: 1 -> 2 -> 3 -> 4 -> 5

  2. Начальное положение:

    • Медленный указатель (S) на 1

    • Быстрый указатель (F) на 1

  3. Шаг 1:

    S: 2 (двигается на 1 узел вперед)

    F: 3 (двигается на 2 узла вперед)

    • Список: 1 -> [2 (S)] -> 3 -> [4 (F)] -> 5

  4. Шаг 2:

    S: 3 (двигается на 1 узел вперед)

    F: 5 (двигается на 2 узла вперед)

    • Список: 1 -> 2 -> [3 (S)] -> 4 -> [5 (F)]

  5. Конец:

    • Быстрый указатель (F) достигает конца списка или выходит за его пределы.

    • Медленный указатель (S) находится на 3, который и является средним элементом.

Итоговый результат: Средний элемент списка 3.

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


  1. SpiderEkb
    04.08.2024 10:48

    Если мне приходится делать реализации списков, я обычно сразу закладываю туда счетчик элементов. Если реализован двусвязный список, то любой элемент по номеру можно получить не более чем за length / 2 шагов - если n <= length / 2 - считаем с начала списка, иначе - с конца.

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

    Один узел имеет тип HEAD и содержит счетчик элементов и ссылки на первый и последний элементы списка (при необходимости там же могут быть какие-то другие свойства списка). Остальные элементы имеют тип NODE и содержат блок данных и ссылки на следующий и предыдущий элементы (первый в качестве предыдущего ссылается на HEAD, последний, в качестве следующего, аналогично ссылается на HEAD.

    Сам объект "список" - это ссылка на HEAD.


    1. idsulik Автор
      04.08.2024 10:48
      +1

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


      1. SpiderEkb
        04.08.2024 10:48

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


      1. monpa
        04.08.2024 10:48
        +1

        Тут структура данных неподходящая выбрана) Искать середину полной итерацией - ну такое себе развлечение.


      1. YMA
        04.08.2024 10:48
        +1

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

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


        1. idsulik Автор
          04.08.2024 10:48

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


          1. acsent1
            04.08.2024 10:48

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


            1. idsulik Автор
              04.08.2024 10:48

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


    1. avost
      04.08.2024 10:48
      +3

      Если мне приходится делать реализации списков

      Самое интересное в этом комментарии не озвучено :). Расскажите, пожалуйста, что это были за задачи (во множестве числе!), для решения которых вам приходилось не только использовать связные списки, но и писать свои реализации?

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

      В практических, конечно, задачах, не в литкодных/олимпиадных.


      1. idsulik Автор
        04.08.2024 10:48

        на тех же собеседованиях спрашивают когда linked list лучше, чем array list - обычно ничего вменяемого придумать не удаётся

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

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


      1. SpiderEkb
        04.08.2024 10:48

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

        Список или массив... Нам часто приходится иметь дело с очень большими объемами данных. Память в целом не проблема, можно считать что она не ограничена. Но есть ограничение - 16Мб одним куском (ну можно 2Тб, но там уже приходится "танцевать вприсядку" - это другая модель памяти и, в общем случае, даже другой размер указателя). И вот если нет уверенности что получится выделить массив (даже динамический массив все равно в памяти одним куском будет), то тогда используется список.

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

        А если это статика - один раз заполнил, отсортировал и потом 100500 раз ищешь - там сортированный массив по производительности лучше.


      1. Sap_ru
        04.08.2024 10:48
        +2

        1. Нужен FIFO или LIFO доступ к элементам.

        2. Нужны частые операции добавляения большого количества элементов или объединения списков.

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

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


        1. idsulik Автор
          04.08.2024 10:48

          Лучше не опишешь, добавлю это в комментарии к видео и оставлю ссылку на источник)


      1. aamonster
        04.08.2024 10:48

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


        1. idsulik Автор
          04.08.2024 10:48

          linked list делается не отдельно, а поверх уже существующей структуры данных

          не понял этот момент, что имеется в виду?


          1. aamonster
            04.08.2024 10:48

            В смысле у вас уже есть какое-то хранилище объектов (массив, дерево, ещё что – что вам диктует задача) и вам понадобилось для каких-то нужд объединить эти объекты в цепочки. Можно хранить отдельно списки указателей на ваши объекты, а можно просто добавить в каждый объект поле next.


            1. idsulik Автор
              04.08.2024 10:48

              ну это как-то не очень, лучше создать LinkedList, чтобы код был чище)


              1. aamonster
                04.08.2024 10:48

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


                1. idsulik Автор
                  04.08.2024 10:48

                  ну значение ноды будет ССЫЛКА на нужный объект, поэтому не вижу в этом проблем) ну или плохо понял идею


                  1. aamonster
                    04.08.2024 10:48

                    Ага, только на N объектов вам придётся выделить N нод. Каждая будет хранить указатель на объект, указатель на следующую ноду + накладные расходы кучи. И затраты времени на выделение/освобождение.

                    А если вы прямо внутри вашего объекта сделаете поле указателя на следующий – это куда выгоднее.

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


                    1. idsulik Автор
                      04.08.2024 10:48

                      На самом деле есть место и тому и другому и третьему варианту) тут зависит от конкретной задачи.
                      Но я не знаю кейса, где понадобится такая оптимизация, что нужно менять объякты и добавлять туда указатели, вместо использования linked list


                      1. aamonster
                        04.08.2024 10:48

                        Я за 29 лет практики не припомню задачи, где мне понадобился бы именно отдельный Linked list :-)


                      1. idsulik Автор
                        04.08.2024 10:48

                        тоже не припоминаю) ни в том, ни в другмо виде


          1. wataru
            04.08.2024 10:48
            +1

            Например, при реализации LRU кеша. Там при доступе у ключу, он перемещается в конец списка. При переполнении кеша (хеш таблица значений), удаляется элемент из начала списка.


        1. SpiderEkb
          04.08.2024 10:48

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


          1. aamonster
            04.08.2024 10:48

            Учитывая, что чтобы вставить или удалить элемент, нужно вначале найти его место за O(n) – редко когда linked list в качестве контейнера оправдан.


            1. SpiderEkb
              04.08.2024 10:48

              Простейший случай - идете по цепочке от начала к концу и для каждого элемента принимаете решение - оставить его или удалить. Такой вот play-off алгоритм.

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


              1. aamonster
                04.08.2024 10:48
                +1

                Очередь обычно делается кольцевым буфером, не linked list. Исключение – когда объекты уже лежат где-то и вы их просто связываете прямо на месте.

                Play-off – тот же один проход по массиву (двумя указателями) удаляет всё лишнее за O(n), как и в случае linked list.

                Я не говорю, что linked list нигде не пригодится, но его использование в качестве контейнера – лютая экзотика.


            1. wataru
              04.08.2024 10:48

              Ничего не мешает держать хеш-таблицу из идентификаторов элементов в указатели/итераторы в list. Популярное применение - LRU cache. Там список навешан сверху на хеш-таблицу и задает порядок элементов, чтобы выбирать, какой элемент самый старый и его надо удалить. При доступе элемент из центра списка будет перемещен в конец.


              1. aamonster
                04.08.2024 10:48

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


  1. IvanZaycev0717
    04.08.2024 10:48

    Хочу поинтеросоваться у автора - каким образом была выбрана задача? Когда я проходил собеседование, мне сказали написать random.randrange (3000) и что выведится в консоль, то и решай.


    1. idsulik Автор
      04.08.2024 10:48

      В первую очередь подбираю задачи, так чтобы объяснить какую-то тему, которая будет полезна на интервью, на примере этой задачи или саму задачу, которая часто встречается на интервью.
      У меня на канале идут подряд задачи связанные с какой-то темой(two pointers, sliding window), сейчас перешел на тему fast and slow pointer и на примере этой задачи объяснил, что это такое + объяснил что такое связный список и какие они бывают(на видео).

      Мне вот давали Reverse Linked List(фаанг в РФ) и десятки других задач(easy/medium/hard).


  1. NevoWin
    04.08.2024 10:48
    +4

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

    Единственное, что в 3 варианте более красивая и элегантная идея.

    Но 2 вариант проще в понимании и чтении кода в будущем.

    Я бы взял на работу того, кто не усложняет код, где это не требуется, а пишет более просто.


    1. wataru
      04.08.2024 10:48

      Второй и третий варинаты имеют одинаковое количество сравнений и обращений к памяти.

      Но из-за архитектурных ньюансов, сложно сказать, какой будет быстрее.

      Третий вариант имеет меньше условных переходов (n/2 в случае одного цикла и n+n/2 в случае двух). Но это хорошо предсказываемые условные переходы на следующую итерацию цикла, поэтому они относительно дешевые.

      С другой стороны, во втором варианте циклы короче.

      С точки зрения кода второй вариант лучше, я с вами согласен.


    1. idsulik Автор
      04.08.2024 10:48

      Согласен, что третий вариант довольно хитрий и да, второй вариант проще в понимании


    1. SquareRootOfZero
      04.08.2024 10:48

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


  1. x89377
    04.08.2024 10:48

    Хорошо было бы добавить голосование, что бы каждый мог выбрать правильный вариант № 3.


    1. idsulik Автор
      04.08.2024 10:48

      Самый оптимальный вариант третий) а две другие лишь подводят к третьему


      1. wataru
        04.08.2024 10:48
        +1

        Я выше это расписал: не факт. Второй может выполняться даже быстрее.


      1. aamonster
        04.08.2024 10:48

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

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


  1. kibergus
    04.08.2024 10:48

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


    1. idsulik Автор
      04.08.2024 10:48

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


    1. aamonster
      04.08.2024 10:48

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


      1. idsulik Автор
        04.08.2024 10:48

        не понял про кольцевой список


        1. aamonster
          04.08.2024 10:48

          Google "поиск цикла в односвязном списке" или "алгоритм черепахи и зайца".

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


          1. idsulik Автор
            04.08.2024 10:48

            а, решал эту задачу и планирую тоже сделать видео по нему)


    1. SquareRootOfZero
      04.08.2024 10:48

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


  1. kibergus
    04.08.2024 10:48
    +2

    Если хочется извращений, можно съэкономить половину итераций на втором итераторе: заводим два допонительных итератора i1, i2. Когда основной прохтдит через степень двойки (2, 4, 8, 16,...) сохраняем:

    i1 = i2

    i2 = i

    Теперь когда дойдем до конца списка у нас есть закешированный итератор, который между 1/4 и 1/2 длины.

    Но общая сложность все равно линия.


    1. idsulik Автор
      04.08.2024 10:48

      что-то звучит сложно и извращено)