Nano Hash - криптовалюты, майнинг, программирование

Python list.pop(i) временная сложность?

Я смотрю в Интернете и знаю, что list.pop() имеет временную сложность O (1), но list.pop(i) имеет временную сложность O (n). Пока я пишу leetcode, многие люди используют pop(i) в цикле for, и они говорят, что это сложность по времени O(n), и на самом деле это быстрее, чем мой код, который использует только один цикл, но много строк в этом цикле. Интересно, почему это произошло, и должен ли я использовать pop(i) вместо многих строк, чтобы избежать этого?

Пример: Leetcode 26. Удалить дубликаты из отсортированного массива

Мой код: (быстрее 75%)

class Solution(object):
    def removeDuplicates(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        left, right = 0, 0
        count = 1
        while right < len(nums)-1:
            if nums[right] == nums[right+1]:
                right += 1
            else:
                nums[left+1]=nums[right+1]
                left += 1
                right += 1
                count += 1
        return count

и чужой код быстрее, чем на 90%: (этот парень не говорит O(n), но почему O(n^2) быстрее, чем мой O(n)?)

https://leetcode.com/problems/remove-duplicates-from-sorted-array/discuss/477370/python-3%3A-straight-forward-6-lines-solution-90-faster-100-less-memory

Мой оптимизированный код (быстрее 89%)

class Solution(object):
    def removeDuplicates(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        left, right = 0, 0
        while right < len(nums)-1:
            if nums[right] != nums[right+1]:
                nums[left+1]=nums[right+1]
                left += 1
            right += 1
        return left + 1

  • Временная сложность — довольно теоретическое понятие. Использование pop(i) дает худшую временную сложность, но необходимое перемещение памяти выполняется с помощью высокооптимизированного скомпилированного кода (или, в основном, даже с помощью одной инструкции машинного кода), и поэтому во многих практических случаях выполняется быстрее. 15.01.2020
  • Также довольно сложно сказать, не видя тестовых случаев. Существует 161 тест, но, возможно, большинство из них (или все они?) представляют собой короткие списки, где сложность практически не имеет значения. Возможно, в тестовых примерах большинство чисел, которые нужно удалить, находятся в конце списка. Я думаю, что люди слишком серьезно относятся к тому, что процент быстрее, чем x, из Leetcode. 15.01.2020
  • этот конкретный pop(i) имеет среднюю сложность O(K), где K - количество уникальных элементов в списке (поскольку он начинается с конца, ему никогда не нужно будет перемещать повторяющиеся элементы при смещении. 15.01.2020
  • @MichaelButscher: это не отдельная инструкция машинного кода (Python list намного сложнее). Но в остальном да, O(n) может скрывать огромные постоянные множители; если решение pop(i) работает точно n**2 из-за эффективной версии pop, реализованной на языке C, в то время как эквивалент O(n) без pop, написанный на чистом Python, 100,000 * n работает из-за накладных расходов интерпретатора, то перед превосходным алгоритмом потребуется очень много входных данных. (от big-O) выиграл. Алгоритм O(n**2) тоже выполнен довольно хорошо (сначала выталкиваются самые правые дубликаты), поэтому средняя стоимость ниже. 15.01.2020
  • Также обратите внимание: простые математические вызовы и вызовы функций (особенно функций во встроенной области видимости), такие как len в CPython, имеют одно из самых высоких соотношений накладных расходов и реальной работы в языке. Вы бы сэкономили нетривиальные объемы работы, выполнив _len = len вне цикла и протестировав _len(nums) (чтобы заменить встроенный поиск по двум dict локальным поиском; к сожалению, не удается избежать накладных расходов на вызов) и избавившись от count полностью переменная (return left + 1 получит тот же результат и избежит трети простой математики для не дубликатов). 15.01.2020
  • @ShadowRanger Я давно пытался сказать это, я поражен, насколько лаконично вы это выразили. Точно так же, если каждый цикл n медленный, его O (n) может быть медленнее по сравнению с чьим-либо еще. и решение в ссылке имеет реализацию скалывателя, чтобы сделать поп-музыку не O (n) исходной длины n, а меньшее подмножество с уникальными элементами. 15.01.2020
  • @ShadowRanger Они могут избежать накладных расходов на вызов: просто выполните n = len(nums) один раз перед циклом. 15.01.2020
  • @HeapOverflow: Верно. Я забыл, что эта реализация лжет (перепутал pop решение с этим) и на самом деле не удаляет дубликаты (хотя было бы легко сделать так, чтобы она не лгала, с помощью простого del nums[left + 1:] непосредственно перед возвратом [ может быть не по одному, не утруждайте себя проверкой]), поэтому len является стабильным и может быть вычислено один раз и кэшировано. 15.01.2020
  • @ShadowRanger Это не ложь. Мы не должны на самом деле удалять дубликаты. Поэтому необходимо вернуть новую длину. В постановке задачи также говорится, что не имеет значения, что вы оставляете за пределами возвращаемой длины. 15.01.2020
  • @HeapOverflow: это кроется в названии функции, спецификация просто позволяет лгать. :-) 15.01.2020
  • @ShadowRanger Ну, это не похоже на то, что мы выбрали имя. 15.01.2020

Ответы:


1

Ваш алгоритм действительно занимает O (n) времени, а алгоритм «всплывания в обратном порядке» действительно занимает O (n²) времени. Однако LeetCode не сообщает, что ваша временная сложность лучше, чем 89% представлений; он сообщает, что ваше фактическое время работы лучше, чем 89% всех представлений. Фактическое время работы зависит от того, с какими входными данными тестируется алгоритм; не только размеры, но и количество дубликатов.

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

Чтобы пролить свет на это, я измерил время работы с помощью timeit. На графике ниже показаны мои результаты; формы именно такие, какие вы ожидаете, учитывая временные сложности, а точка пересечения находится где-то между 8000 < n < 9000 на моей машине. Это основано на отсортированных списках, в которых каждый отдельный элемент появляется в среднем дважды. Код, который я использовал для генерации времени, приведен ниже.

время работы

Код времени:

def linear_solution(nums):
    left, right = 0, 0
    while right < len(nums)-1:
        if nums[right] != nums[right+1]:
            nums[left+1]=nums[right+1]
            left += 1
        right += 1
    return left + 1

def quadratic_solution(nums):
    prev_obj = []
    for i in range(len(nums)-1,-1,-1):
        if prev_obj == nums[i]:
            nums.pop(i)
        prev_obj = nums[i]
    return len(nums)

from random import randint
from timeit import timeit

def gen_list(n):
    max_n = n // 2
    return sorted(randint(0, max_n) for i in range(n))

# I used a step size of 1000 up to 15000, then a step size of 5000 up to 50000
step = 1000
max_n = 15000
reps = 100

print('n', 'linear time (ms)', 'quadratic time (ms)', sep='\t')
for n in range(step, max_n+1, step):
    # generate input lists
    lsts1 = [ gen_list(n) for i in range(reps) ]
    # copy the lists by value, since the algorithms will mutate them
    lsts2 = [ list(g) for g in lsts1 ]
    # use iterators to supply the input lists one-by-one to timeit
    iter1 = iter(lsts1)
    iter2 = iter(lsts2)
    t1 = timeit(lambda: linear_solution(next(iter1)), number=reps)
    t2 = timeit(lambda: quadratic_solution(next(iter2)), number=reps)
    # timeit reports the total time in seconds across all reps
    print(n, 1000*t1/reps, 1000*t2/reps, sep='\t')

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

15.01.2020
  • Вы что-то упустили во временах LeetCode: они доминируют от накладных расходов. Реальное время решения почти не имеет значения. Это скрыто за оценочным временем и его вариациями. Но вы можете сделать видимой разницу во времени решения, запустив решение много раз для каждого входа. Посмотрите последние несколько сообщений в чате под ответом @Atreyagaurav. 16.01.2020
  • @HeapOverflow Я не обсуждал это, потому что я недостаточно знаком с LeetCode, чтобы знать, как работает их система судейства. Тем не менее, при оценке накладных расходов преобладает фактическое время работы; накладные расходы не могут доминировать в смысле временной сложности, когда термин n^2 всегда доминирует над термином n. Важно то, что алгоритмы сравниваются путем измерения фактического времени выполнения, а не их сложности. Однако вы делаете правильный вывод; Я отредактировал, чтобы добавить предложение об этом. 16.01.2020
  • Чтобы было понятнее, поскольку я не видел вас в чате: у меня есть решение, которое занимает около 1,7 мс из ~ 80 мс, о которых сообщает LeetCode. Таким образом, если квадратичное решение занимает в 4 раза больше времени, это не будет иметь большого значения и может быть даже указано как более быстрое, потому что компьютер LeetCode в этот момент оказался менее загруженным. Вот почему я несколько не согласен с вашим выводом о том, что проблема в том, что входы LeetCode недостаточно велики. Я думаю, что это отвлекающий маневр. 16.01.2020
  • Квадратичное решение обычно не занимает в 4 раза больше времени, чем линейное решение; 4 раза линейно линейно. Всегда существует достаточно большой размер входных данных, при котором квадратичный алгоритм будет работать хуже, чем линейный алгоритм, какими бы ни были постоянные коэффициенты и постоянные накладные расходы. 16.01.2020
  • Я знаю. Я сказал 4, потому что это примерно тот коэффициент, который вы показываете в правом конце графика. Извините, надо было упомянуть об этом. 16.01.2020
  • Я понимаю; но если вы пойдете дальше, квадратичный алгоритм будет в 10 раз медленнее, или в 1000 раз медленнее, или настолько медленнее, насколько вам нужно, чтобы изменение накладных расходов судьи не имело значения. Вот что я имею в виду под достаточно большим. LeetCode не сообщает свой диапазон входных размеров, но такие сайты, как HackerRank, обычно доходят до 10 ^ 5 или 10 ^ 6 для задач, где ожидается решение с линейным временем; это значительно дальше, чем мой график. 16.01.2020
  • Как предварительный расчет, 10 ^ 6 в 20 раз больше, чем 50000, поэтому квадратичный алгоритм должен масштабироваться как 20 ^ 2 * 45 мс = 18 секунд, а линейный алгоритм должен масштабироваться как 20 * 12 мс = 0,24 секунды. . Этого определенно было бы достаточно, чтобы разница в накладных расходах судьи не имела значения. 16.01.2020
  • Да, конечно. В этом смысле они недостаточно велики. Просто кажется, что они недостаточно велики в том смысле, что они меньше точки пересечения. Что не совсем так. Хотя это почти. Я только что проверил, единственный случай, когда больше 199 элементов, — это случай с 21998 элементами. Эх... LeetCode... 16.01.2020
  • Хорошая находка. В этом случае служебные данные судьи могут легко варьироваться более чем на 5 мс, и это аннулирует размер фактического эффекта; или влияние этого теста может быть легко смягчено путем усреднения по другим тестам с гораздо меньшими входными данными, где квадратичный алгоритм работает быстрее. Или оба. 16.01.2020
  • Я думаю, что вы были правы в том смысле, что это неясно, поэтому я отредактировал заключение, чтобы прояснить это. 16.01.2020
  • Между прочим, эти два решения занимают около 7,8 мс и 11,3 мс на LeetCode. Измерено, заставляя их решать каждый ввод 100 раз, поэтому LeetCode сообщает о 865 мс и 1219 мс (и это довольно стабильно), затем вычитая 86 мс на оценку и копирование ввода, а затем деля на 100. 16.01.2020
  • Спасибо за ваши дополнения - это очень похоже на мои измеренные времена при n = 22 000. Вы измерили накладные расходы, запустив пустую программу? Если вы напишите свои результаты в ответе, я с удовольствием проголосую. 16.01.2020
  • Пустая программа не работает, так как LeetCode останавливается при первом неправильном ответе, поэтому оставшиеся 160 тестов даже не запустятся. В других задачах я измерял накладные расходы, отправляя читерское решение, которое просто выдавало жестко запрограммированные ответы, но в этой задаче это не работает, потому что список нужно корректно изменить. Итак, здесь я определил накладные расходы с помощью небольшой математики из результатов нормального решения и результатов его решения, увеличенного в 100 раз. Я делал это и в других задачах, и я думаю, что это соответствовало измерению методом читерства, поэтому я думаю, что это надежно. 16.01.2020
  • Мне не хочется писать ответ. Вы можете видеть, что у меня уже был один, и в итоге я много разглагольствовал :-). Еще одна вещь: я думаю, что LeetCode не усредняет, а сообщает итоги. Когда я отправил эти 100-кратные решения, и LeetCode сообщил о 1 секунде, это было довольно быстро, а не 161 секунда, которую вы ожидали бы, если бы это было среднее значение из 161 тестового примера. И я совершенно уверен, что они не запускают их массово параллельно. 16.01.2020
  • Я понимаю; ну, эффект одинаков, будь то общий или средний, но вы правы, что моя формулировка может быть неправильной. 16.01.2020

  • 2

    Просто потому, что решение не O (n), вы не можете предположить, что оно равно O (n ^ 2).

    Это не совсем становится O (n ^ 2), потому что он использует pop в обратном порядке, что каждый раз уменьшает время, чтобы вытолкнуть, использование pop (i) в прямом порядке потребует больше времени, чем в обратном порядке, так как pop ищет изнаночной и в каждой петле убавляем количество элементов на спинке. Попробуйте то же самое решение в необратном порядке, запустите несколько раз, чтобы убедиться, вот увидите.

    В любом случае, относительно того, почему его решение быстрее, у вас есть условие if с большим количеством переменных, он использовал только одну переменную prev_obj, использование обратного порядка позволяет делать это только с одной переменной. Таким образом, количество основных математических операций в вашем случае больше, поэтому при одинаковой сложности O (n) каждый из ваших n-циклов длиннее, чем его.

    Просто посмотрите на свою переменную count, на каждой итерации ее значение равно left+1, вы можете вернуть left+1, просто удалив это, вы уменьшите количество count=count+1, которое вам нужно сделать.

    Я только что опубликовал это решение, и оно на 76% быстрее.

    class Solution:
        def removeDuplicates(self, nums: List[int]) -> int:
            a=sorted(set(nums),key=lambda item:item)
            for i,v in enumerate(a):
                nums[i]=v
            return len(a)
    

    и этот дает быстрее, чем 90%.

    class Solution:
        def removeDuplicates(self, nums: List[int]) -> int:
            a ={k:1 for k in nums} #<--- this is O(n)
            for i,v in enumerate(a.keys()): #<--- this is another O(n), but the length is small so O(m)
                nums[i]=v
            return len(a)
    

    Вы можете сказать, что оба они больше, чем O (n), если вы посмотрите на цикл for, но, поскольку мы работаем с дубликатами членов, когда я перебираю сокращенные члены, в то время как ваш код перебирает все члены. Таким образом, время, необходимое для создания этого уникального набора/слова, меньше времени, необходимого вам для перебора этих дополнительных членов и проверки условий if, тогда мое решение может быть быстрее.

    15.01.2020
  • Комментарии не для расширенного обсуждения; этот разговор был перемещен в чат. 15.01.2020
  • Это не совсем равно O(n^2), потому что он использует всплывающие окна в обратном порядке — это неверно, алгоритм всплывающих окон в обратном порядке на самом деле занимает O(n^2). 2) время. У него просто более низкий постоянный коэффициент, чем у алгоритма всплывающего прямого порядка, который также занимает время O (n ^ 2). 15.01.2020
  • Новые материалы

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

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

    Частный метод Python: улучшение инкапсуляции и безопасности
    Введение Python — универсальный и мощный язык программирования, известный своей простотой и удобством использования. Одной из ключевых особенностей, отличающих Python от других языков, является..

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

    Работа с векторными символическими архитектурами, часть 4 (искусственный интеллект)
    Hyperseed: неконтролируемое обучение с векторными символическими архитектурами (arXiv) Автор: Евгений Осипов , Сачин Кахавала , Диланта Хапутантри , Тимал Кемпития , Дасвин Де Сильва ,..

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

    Обеспечение масштабируемости LLM: облачный анализ с помощью AWS Fargate и Copilot
    В динамичной области искусственного интеллекта все большее распространение получают модели больших языков (LLM). Они жизненно важны для различных приложений, таких как интеллектуальные..