Привет, Хабр! Сегодня разберём простую на первый взгляд, но очень показательную задачку: найти максимальное произведение двух чисел в массиве целых чисел.

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

Казалось бы — что сложного скрывает поставленная задача о нахождении максимального произведения двух чисел? Но в зависимости от подхода решение может работать за O(n^2), O(n log n) или O(n).

С этой задачей я столкнулся на своём первом собеседовании и предложил не самое оптимальное решение.

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

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

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

Базовое решение или как делать не надо (O(n²))

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

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

Как именно перебирать пары?

Чтобы не дублировать комбинации (например, (i=0, j=1) и (i=1, j=0)), будем перебирать только пары с i < j:

  • i идёт от 0 до n-2

  • j идёт от i + 1 до n-1

Так мы рассмотрим ровно n·(n−1)/2 уникальных пар — это и есть квадратичная сложность или O(n^2).

✅ Преимущества

  • Простота

  • Надёжность: не зависит от знаков, нулей, порядка — работает всегда.

  • Лёгкость тестирования: легко проверить на маленьких примерах вручную.

❌ Недостатки

  • Скорость: при n = 10⁴ — уже ~50 миллионов операций, исполнение которых может занять секунды. При n = 10⁵ — ~5 миллиардов пар, задача выйдет за пределы Time Limit на большинстве платформ.

  • Не масштабируется: даже если процессор станет в 10 раз быстрее — вы выиграете лишь в константе, а не в асимптотике.

Реализация на Python под катом
def max_product(arr):
    n = len(arr)
    if n < 2:
        return -1
    
    max_prod = arr[0] * arr[1]
    # либо max_prod = arr[0] - по сути, вкусовщина
    for i in range(n):
        for j in range(i + 1, n):
            product = arr[i] * arr[j]
            if product > max_prod:
                max_prod = product
                
    return max_prod

Компромиссное решение (O(n log n))

Перебор всех пар - это решение слишком «в лоб», давайте подумаем: где вообще может лежать ответ?

Заметим важную вещь: Максимальное произведение двух чисел в массиве — это 

  • либо произведение двух самых больших чисел

  • либо произведение двух самых маленьких (т.е. самых отрицательных) чисел

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

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

 ✅ Преимущества

  • Очень легко понять и написать — идеально для собеседования.

  • Работает быстро даже для миллионов объектов.

  • Минимум логики — всего два сравнения после сортировки.

❌ Недостатки

  • Сложность O(n log n) — проигрывает линейному решению на больших данных.

  • Изменяет исходный массив

Реализация на Python под катом
def max_product(arr):
    if len(arr) < 2:
        return -1
    
    arr.sort()    
    # Два кандидата:
    product_of_largest = arr[-1] * arr[-2]   # самые большие
    product_of_smallest = arr[0] * arr[1]    # самые маленькие
    
    return max(product_of_largest, product_of_smallest)

Оптимальное решение (O(n))

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

  • два самых больших числа,

  • два самых маленьких числа,

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

Их можно найти за один проход, не трогая остальные элементы.

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

Идея алгоритма

Будем отслеживать:

  • max1, max2 — два наибольших числа (max1 ≥ max2)

  • min1, min2 — два наименьших числа (min1 ≤ min2)

Проходим по массиву один раз. Для каждого элемента x:

  • Если x > max1 → max2 = max1, max1 = x

    • Иначе если x > max2 → max2 = x

  • Если x < min1 → min2 = min1, min1 = x

    • Иначе если x < min2 → min2 = x

В конце возвращаем max(max1 max2, min1 min2).

✅ Преимущества

  • Оптимальная временная сложность: O(n) — один проход.

  • Постоянная память: O(1) — только четыре переменные.

  • Не изменяет исходный массив — безопасен для дальнейшего использования.

  • Масштабируется: работает быстро даже для миллиарда объектов.

Реализация на Python под катом
def max_product_linear(arr):
    if len(arr) < 2:
       return -1
    
    # Инициализируем экстремумы первыми двумя элементами
    if arr[0] > arr[1]:
        max1, max2 = arr[0], arr[1]
        min1, min2 = arr[1], arr[0]
    else:
        max1, max2 = arr[1], arr[0]
        min1, min2 = arr[0], arr[1]
    
    # Проходим по оставшимся элементам
    for i in range(2, len(arr)):
        x = arr[i]
        
        # Обновляем максимумы
        if x > max1:
            max2 = max1
            max1 = x
        elif x > max2:
            max2 = x
        
        # Обновляем минимумы
        if x < min1:
            min2 = min1
            min1 = x
        elif x < min2:
            min2 = x
    
    return max(max1 * max2, min1 * min2)

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

P.S.

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

Сравнение 3-х решений - RunningTime(N), где N - размер массива
Сравнение 3-х решений - RunningTime(N), где N - размер массива

Если рассматривать оставшиеся 2 решения, то, казалось бы, графики времени выполнения «почти совпадают», но нельзя не принимать во внимание тот факт, что в большинстве реальных задач размеры входных данных куда больше 20, чем ограничивается ось абсцисс на графике. Давайте будем реалистами и возьмём массив размера хоть сколько‑то приближенного к возможной реальной задачи:

Вы разрабатываете систему оповещения для умного дома. У вас есть данные за месяц наблюдений с интервалом в 5 минут — это более 8000 измерений отклонений температуры от нормы (например, −3°, +5°, −7° и т.д.). Чтобы оценить риск термического стресса для оборудования, инженеры предложили метрику: «наибольшее возможное совместное отклонение» — то есть максимальное произведение двух отклонений. Почему? Потому что два сильных отрицательных скачка (например, −8 и −6) дают положительный вклад в износ системы, как и два сильных положительных (+7 и +5).

Сравнение компромиссного и оптимального решений - RunningTime(N), где N - размер массива
Сравнение компромиссного и оптимального решений - RunningTime(N), где N - размер массива

Думаю, комментарии в пользу выбора третьего решения с линейной сложностью излишни?

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


  1. wataru
    13.02.2026 11:36

    Заметим важную вещь: Максимальное произведение двух чисел в массиве — это 

    • либо произведение двух самых больших чисел

    • либо произведение двух самых маленьких (т.е. самых отрицательных) чисел

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


    1. Akina
      13.02.2026 11:36

      А вот это бы хорошо доказать.

      Это несложно. Просто надо по отдельности рассматривать варианты, что среди набора этих 4 элементов имеется то или иное количество положительных, отрицательных и нулевых элементов.


      1. wataru
        13.02.2026 11:36

        Это несложно.

        Тем более, это должно быть в статье.


    1. its_fire_fire_fun_fun_fun
      13.02.2026 11:36

      Извините, это любому 5-7 класснику очевидно с базовыми склонностями к информатике. Если к такому на Хабре нужно прилагать доказательство, ресурс можно закрывать.


      1. wataru
        13.02.2026 11:36

        Во-первых, вы переоцениваете 5-7 классников. И вообще людей. Если бы это было так очевидно, это не было бы задачей для собеседований.

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

        В-третьих, для кого, по вашему, эта статья? Она как раз "для самых маленьких", людей только учащихся решать задачи. Для всех остальных вся статья тривиальна и не нужна. А вот для этих начинающих это тем более не очевидно.


  1. Pusk1
    13.02.2026 11:36

    Я даже не подумал, что можно перебирать все возможные пары или сортировать массивы. Давайте что-нибудь более элегантное.
    С обработкой строк 1000+ таких примеров.


  1. Akina
    13.02.2026 11:36

    • Если x > max1 → max2 = max1, max1 = x

      • Иначе если x > max2 → max2 = x

    Чтобы алгоритм работал, в паре max1, max2 необходимо поддерживать условие max2 >= max1 после каждого переприсвоения, тогда как у вас этот момент отмечен только в самом начале описания алгоритма, а не в описании блока перебора. А в нижеприведённом коде такая сортировка выполняется именно в процессе перебора, к тому же ну совершенно неявно. Впрочем, тому, кто захочет разобраться, это даже полезно..


    1. tolich_the_shadow
      13.02.2026 11:36

      Наоборот, max1>=max2: max1 -- максимальное значение, max2 -- второе максимальное.


  1. lightln2
    13.02.2026 11:36

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

    def max_product_linear(arr):
        n = len(arr)
        if n < 2: return None
        max1 = max(range(n), key=lambda i: arr[i])
        max2 = max(range(n), key=lambda i: arr[i] if i != max1 else -math.inf)
        min1 = min(range(n), key=lambda i: arr[i])
        min2 = min(range(n), key=lambda i: arr[i] if i != min1 else math.inf)
        return max(arr[max1]*arr[max2], arr[min1]*arr[min2])
    


    1. alex_tulski
      13.02.2026 11:36

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


      1. lightln2
        13.02.2026 11:36

        O(4n) - это тот же O(n). Нафига бороться за константу в коде на питоне? Тогда уж надо на c++ писать.


        1. alex_tulski
          13.02.2026 11:36

          Я не спорю, что асимптотика остаётся O(n). Но если мы обсуждаем оптимальные алгоритмы, то сознательно добавлять лишние полные проходы по данным — странно. Это не n², но это всё равно ухудшение константы, которое в реальном коде иногда выливается в заметную разницу.

          Плюс это не выглядит как выигрыш в читаемости. Конструкция с key=lambda и костылём через math.inf добавляет скрытую логику и хуже читается, чем один явный проход с понятными инвариантами. Хотя может это моя деформация.

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

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


          1. lightln2
            13.02.2026 11:36

            что вы понимаете под оптимальными алгоритмами?
            обычно это означает "асимптотически оптимальные", но вы имеете в виду, видимо, абсолютное время? тогда перепишите код на c++ и скомпилируйте с -Ofast -march=native и это будет оптимально, так как упрется в пропускную способность памяти (и то не на всех архитектурах, так как иногда в один поток невозможно нагрузить память, надо будет параллелить).
            Даже если вы имеете в виду абсолютное время на питоне, то наверняка какой-нибудь numpy.partition будет быстрее, так как выполняется нативно на numpy-массивах. Ну и даже для вашего алгоритма наверняка распараллеливание на несколько потоков его ускорит.

            повышение читаемости сомнительно

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


            1. edwardoid
              13.02.2026 11:36

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


        1. winkyBrain
          13.02.2026 11:36

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


        1. avk22
          13.02.2026 11:36

          Если массив из 4 элементов получим сложность O(n²) :)


      1. wataru
        13.02.2026 11:36

        Ну нет, тут замедление не в 4 раза алгоритмически. Максимум в 2. Ибо у вас тоже 4 сравнения в теле цикла. Но зато вы экономите на единой проверке счетчика итераций, а тут 4 цикла каждый отдельно проверяют i. И некоторые проверки у вас иногда пропускаются.

        Оригинальный подход был бы быстрее на языке более низкого уровня, там бы один проход читал бы из памяти лишь один раз, что на больших массивах сильно быстрее чтения 4 раза. Но в питоне все эти оптимизации весьма условные, все переменные - огромные объекты, а ручная итерация по массиву работает вообще страшно медленно по сравнению со встроенными функциями. Так что это еще большой вопрос, чей код на самом деле быстрее. Надо бенчмарки гонять.

        И вообще O(4n) - то же самое O(n).

        В контексте статьи для обучения и интервью вот эти вот 4 отдельных цикла на порядки читабельнее и поэтому лучше.


        1. alex_tulski
          13.02.2026 11:36

          Я не поленился и побенчил. Вариант с лямбдами медленнее в 3 раза.

          ------------------------------------------------------------------------------------------------------------- benchmark: 12 tests --------------------------------------------------------------------------------------------------------------
          Name (time in ns)                      Min                         Max                        Mean                  StdDev                      Median                     IQR            Outliers             OPS            Rounds  Iterations
          ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
          test_linear[10]                   708.0380 (1.0)           57,209.0503 (1.02)             835.6700 (1.0)          295.8940 (1.0)              833.0680 (1.0)            1.1642 (1.0)     354;43287  1,196,644.5862 (1.0)      142861           1
          test_linear2[10]                2,583.0232 (3.65)          55,833.9525 (1.0)            2,728.8291 (3.27)         399.6901 (1.35)           2,708.0532 (3.25)           1.1642 (1.0)     294;50327    366,457.5425 (0.31)     116509           1
          test_linear[100]                6,582.8208 (9.30)          97,834.0395 (1.75)           6,750.1172 (8.08)         746.9517 (2.52)           6,708.0837 (8.05)          41.9095 (36.00)   712;11586    148,145.5749 (0.12)     129734           1
          test_linear2[100]              18,165.9125 (25.66)         61,791.8558 (1.11)          18,398.7960 (22.02)        924.1897 (3.12)          18,291.8739 (21.96)         83.1205 (71.40)   1113;4551     54,351.3825 (0.05)      50850           1
          test_linear[1000]              67,624.9620 (95.51)        179,792.0559 (3.22)          68,376.8114 (81.82)      2,290.5980 (7.74)          67,957.9098 (81.58)        166.0082 (142.60)   724;1534     14,624.8411 (0.01)      14261           1
          test_linear2[1000]            200,291.8627 (282.88)       309,707.8297 (5.55)         202,454.4575 (242.27)     5,584.4435 (18.87)        200,666.0216 (240.88)     1,750.0133 (>1000.0)   325;564      4,939.3825 (0.00)       4497           1
          test_linear[10000]            682,916.9579 (964.52)       791,667.0293 (14.18)        691,476.7826 (827.45)     8,098.1468 (27.37)        689,707.9293 (827.91)     3,542.0526 (>1000.0)   117;182      1,446.1802 (0.00)       1442           1
          test_linear2[10000]         2,076,457.9531 (>1000.0)    2,245,165.8733 (40.21)      2,090,560.7943 (>1000.0)   19,249.7314 (65.06)      2,084,770.4727 (>1000.0)   11,041.0620 (>1000.0)     29;29        478.3405 (0.00)        434           1
          test_linear[100000]         6,895,666.0107 (>1000.0)    7,726,832.9915 (138.39)     6,937,461.8904 (>1000.0)   80,379.9075 (271.65)     6,919,166.4224 (>1000.0)   24,874.9275 (>1000.0)     11;16        144.1449 (0.00)        142           1
          test_linear2[100000]       20,933,165.9134 (>1000.0)   21,397,583.1866 (383.24)    21,026,074.6729 (>1000.0)  100,371.3917 (339.21)    20,985,625.4514 (>1000.0)   73,229.5448 (>1000.0)       7;6         47.5600 (0.00)         48           1
          test_linear[1000000]       69,027,584.0461 (>1000.0)   69,449,540.9261 (>1000.0)   69,141,688.9119 (>1000.0)  132,736.1878 (448.59)    69,087,291.8349 (>1000.0)  164,583.9075 (>1000.0)       2;0         14.4631 (0.00)         15           1
          test_linear2[1000000]     209,953,666.1990 (>1000.0)  210,920,625.1334 (>1000.0)  210,415,533.2316 (>1000.0)  376,636.6391 (>1000.0)  210,339,416.9826 (>1000.0)  564,583.2280 (>1000.0)       2;0          4.7525 (0.00)          5           1
          ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
          Код бенчмарка
          import math
          import random
          import pytest
          
          
          def max_product_linear(arr):
              if len(arr) < 2:
                  return -1
          
              # Инициализируем экстремумы первыми двумя элементами
              if arr[0] > arr[1]:
                  max1, max2 = arr[0], arr[1]
                  min1, min2 = arr[1], arr[0]
              else:
                  max1, max2 = arr[1], arr[0]
                  min1, min2 = arr[0], arr[1]
          
              # Проходим по оставшимся элементам
              for i in range(2, len(arr)):
                  x = arr[i]
          
                  # Обновляем максимумы
                  if x > max1:
                      max2 = max1
                      max1 = x
                  elif x > max2:
                      max2 = x
          
                  # Обновляем минимумы
                  if x < min1:
                      min2 = min1
                      min1 = x
                  elif x < min2:
                      min2 = x
          
              return max(max1 * max2, min1 * min2)
          
          
          def max_product_linear_2(arr):
              n = len(arr)
              if n < 2: return None
              max1 = max(range(n), key=lambda i: arr[i])
              max2 = max(range(n), key=lambda i: arr[i] if i != max1 else -math.inf)
              min1 = min(range(n), key=lambda i: arr[i])
              min2 = min(range(n), key=lambda i: arr[i] if i != min1 else math.inf)
              return max(arr[max1]*arr[max2], arr[min1]*arr[min2])
          
          
          @pytest.fixture(scope="session")
          def rng():
              return random.Random(123456)
          
          
          @pytest.mark.parametrize("n", [10, 100, 1_000, 10_000, 100_000, 1_000_000])
          def test_linear(benchmark, rng, n):
              arr = [rng.randint(-10**6, 10**6) for _ in range(n)]
              benchmark(max_product_linear, arr)
          
          
          @pytest.mark.parametrize("n", [10, 100, 1_000, 10_000, 100_000, 1_000_000])
          def test_linear2(benchmark, rng, n):
              arr = [rng.randint(-10**6, 10**6) for _ in range(n)]
              benchmark(max_product_linear_2, arr)
          


          1. wataru
            13.02.2026 11:36

            Спасибо. Значит лямбды добавляют накладных расходов. Но все-равно, вы утверждали про 4 раза, а тут 3.

            Все равно, мне переписанный код кажется более полезным. Он проще и понятнее. На интервью его проще написать без ошибок, ни один адекватный интервьювер прикапываться к константе не будет. Людям, обучающимся решать задачи, он тоже более понятен и к нему проще прийти. На практике же, если вам важно ускорение этого кода в 3 раза, вы перепишите его с numpy (что с раздельными 4-мя циклами проще, чем с одним) и получите ускорение в несколько десятков раз.


            1. edwardoid
              13.02.2026 11:36

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


              1. wataru
                13.02.2026 11:36

                Нет, там желание сделать более понятный код. Можно хоть 4 цикла написать, хоть 4 min/index, хоть с лямбдами. Оно понятнее, не надо в голове держать инварианты max1 > max2 и думать, все ли варианты покрыты, или нет. Это более естественный перевод с человеческого "найти минимальный и второй минимальный элемент". Сразу явно видно, что вот минимальный, а вот минимальный из оставшихся.

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


          1. arseniy-t
            13.02.2026 11:36

            И все-таки, самый быстрый вариант — без циклов ))

            def max_product_linear_3(arr):
                if len(arr) < 2:
                    return -1
            
                max1 = max(arr)
                i = arr.index(max1)
                arr[i] = -math.inf
                max2 = max(arr)
                arr[i] = max1
            
                min1 = min(arr)
                i = arr.index(min1)
                arr[i] = math.inf
                min2 = min(arr)
                arr[i] = min1
            
                return max(max1 * max2, min1 * min2)


            1. yaroslavp
              13.02.2026 11:36

              А min и max наверное по волшебству отдают нужное значение, как и функция index, точно не используя циклы под капотом?


    1. PashtetMedved
      13.02.2026 11:36

      Может импортнуть heapq проще ?


  1. Artem_Omny
    13.02.2026 11:36

    Задача сложнее - это найти в реальном мире где это вообще можно использовать.


    1. lightln2
      13.02.2026 11:36

      Это, наверно, был риторический вопрос, но я попробую ответить.
      Конкретно эта задача, может, и не имеет практического значения, но она является упражнением на поиск второго маскимума(минимума).
      Это частный случай поиска k-го максимума (или первых k максимумов). Практическое значение - Вас с помощью этой задачи подводят к алгоритму quick-select (используется много где, например, в статистике для вычисления медианы/перцентилей)
      Второе применение - поиск второго максимума/минимума в более сложных объектах. Классическая задача - поиск второго оптимального пути в ациклических направленных графах. Практическое применение - быстрое вычисление оптимальной траектории в вашей сети, если одно из ее ребер удалить (то есть, нарушилась связность между двумя узлами в сети).


  1. A1ternative
    13.02.2026 11:36

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