Learning Platform
Глоссарий Troubleshooting
Урок 02.03 · 20 мин
Начальный
AlgorithmsSearchPerformance intuition

Две задачи, которые кажутся похожими

В прошлых уроках мы наткнулись на важную разницу. Достать элемент по индексу — мгновенно: компьютер знает адрес и идёт прямо туда. А искать элемент по значению — приходится перебирать ячейки одну за другой, пока не найдём. Сейчас мы посмотрим, насколько велика эта разница. И ответ вас удивит: она не «немного больше», она огромна.

Возьмём конкретный пример. У нас есть список чисел, и мы хотим узнать: «есть ли в нём число 777?». Если список не отсортирован (числа лежат как попало), у нас нет адреса — мы вынуждены смотреть каждое число подряд: первое? не оно. второе? не оно. И так до тех пор, пока не найдём или не дойдём до конца.

числа = [10, 88, 5, 777, 42, 9]

def искать_перебором(данные, цель):
    for число in данные:
        if число == цель:
            return True
    return False

print(искать_перебором(числа, 777))
print(искать_перебором(числа, 999))

Вывод:

True
False

В худшем случае — когда числа 777 в списке нет — мы посмотрим все элементы. Если в списке миллион чисел, мы заглянем в миллион ячеек. Вот это «заглянуть во все» и есть источник медленности.

Считаем на пальцах: 1000 против 1 миллиона

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

Поиск перебором (число неизвестно где):

  • список из 1000 элементов: до 1000 заглядываний;
  • список из 1 000 000 элементов: до 1 000 000 заглядываний.

Стало в тысячу раз больше данных — и работы стало в тысячу раз больше. Перебор «растёт вместе с данными»: удвоили список — удвоили работу, увеличили в миллион раз — в миллион раз больше заглядываний.

Теперь доступ по индексу (мы знаем номер, как из прошлого урока):

  • список из 1000 элементов: 1 заглядывание;
  • список из 1 000 000 элементов: 1 заглядывание.

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

Вот эта разница и есть суть. Представьте две задачи на работе:

  • «Дай мне 500-ю строку из файла, я знаю номер» — мгновенно.
  • «Найди в файле строку, где упоминается заказ №777, я не знаю где» — придётся читать файл, пока не наткнёшься.

На маленьком файле обе задачи кажутся одинаково быстрыми. На файле в миллион строк первая всё ещё мгновенна, а вторая заставит компьютер прочитать до миллиона строк. Та же программа, те же данные — но одна задача «не замечает» размера, а вторая «давится» им.

Почему на маленьких данных разницы не видно

Тут кроется ловушка, в которую попадают все новички. На списке из десяти элементов перебор и доступ по индексу работают одинаково мгновенно — глазом не отличить. Поэтому легко решить: «да какая разница, всё быстро».

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

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

Примечание: именно поэтому существует сортировка и специальные структуры данных. Если список заранее отсортирован, искать в нём можно гораздо умнее, чем перебором, — не заглядывая во все элементы. А словарь (dict) в Python умеет находить значение почти как «по адресу», даже без сортировки. Об этих приёмах — дальше в курсе.

Попробуй сам

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

import time

for n in [1000, 100_000, 10_000_000]:
    данные = list(range(n))      # числа от 0 до n-1
    цель = -1                    # такого числа нет -> переберём всё

    старт = time.perf_counter()
    найдено = цель in данные     # это и есть перебор по списку
    прошло = time.perf_counter() - старт

    print(f"n={n:>10}: перебор занял {прошло*1000:.3f} ms")

# Для сравнения: доступ по индексу на самом большом списке
большой = list(range(10_000_000))
старт = time.perf_counter()
_ = большой[9_000_000]           # знаем индекс -> мгновенно
прошло = time.perf_counter() - старт
print(f"доступ по индексу на 10M: {прошло*1000:.6f} ms")

Примерный вывод (числа на вашей машине будут другими, но пропорции — те же):

n=      1000: перебор занял 0.008 ms
n=    100000: перебор занял 0.700 ms
n=  10000000: перебор занял 70.000 ms
доступ по индексу на 10M: 0.000200 ms

Посмотрите на перебор: данные выросли в 100 раз (с 1000 до 100000) — и время выросло примерно в 100 раз. Ещё в 100 раз больше данных — ещё примерно в 100 раз больше времени. Перебор честно растёт вместе с размером. А доступ по индексу на десяти миллионах элементов всё равно занимает крошечную долю миллисекунды — он размера просто не замечает.

TIP

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

Мостик: дальше будет Big-O

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

Поэтому программисты придумали короткую запись для этих идей — она называется Big-O нотация. «Растёт вместе с данными» записывают как O(n). «Не зависит от размера» — как O(1). Это просто компактные ярлыки для того, что мы уже поняли интуитивно в этом модуле.

Следующий модуль — как раз про Big-O: формальную нотацию, её определение и классы сложности. Там появятся буквы, формулы и значки. И вот что важно знать заранее: если математика покажется тяжёлой — это нормально. Вы уже понимаете самое главное на уровне интуиции (камера хранения, перебор против индекса, рост вместе с данными). Формальная запись — это просто аккуратный способ записать ту же интуицию. Если в каком-то месте формулы собьют с толку — ничего страшного, вернётесь к этому модулю, перечитаете про ячейки и заглядывания, и формулы встанут на место.

Проверка знанийKnowledge check
Почему поиск перебором в неотсортированном списке из 1 миллиона элементов гораздо медленнее, чем доступ к элементу по индексу в том же списке? И почему на списке из 10 элементов разницы почти не видно?
ОтветAnswer
При поиске перебором мы не знаем, где лежит нужный элемент, поэтому в худшем случае заглядываем во все элементы по очереди: на миллионе элементов — до миллиона заглядываний. Эта работа растёт вместе с размером списка: больше данных — больше работы. Доступ по индексу — это одно действие независимо от размера: компьютер знает адрес и идёт прямо туда (как в камере хранения по номеру ячейки), поэтому на миллионе элементов это так же быстро, как на десяти. На списке из 10 элементов перебор — это всего 10 заглядываний, доля микросекунды, поэтому глазом отличить от доступа по индексу невозможно. Разница прячется на маленьких данных и становится огромной на больших — вот почему скорость алгоритма надо выбирать с расчётом на рост данных.

Поздравляем — вы прошли вводный модуль и теперь готовы к формальной нотации Big-O. Увидимся в следующем модуле.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. В неотсортированном списке мы ищем число перебором (смотрим элементы по очереди). Сколько примерно заглядываний в худшем случае нужно на списке из 1 000 000 элементов?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3