🤯 Как простая задачка поставила в тупик программистов (и как они из него выбрались)
В 2010 году пользователь Stack Overflow не справился с задачей на собеседовании. Обсуждение способов решения этого задания стало одной из самых популярных тем на платформе.
Пользователю polygenelubricants задали на собеседовании такой вопрос: «В мешке находятся числа 1, 2, 3…100. Каждое число появляется только один раз, поэтому чисел ровно 100. Предположим, одно число вынули из мешка. Как определить это число?»
Это очень популярная задача, и разработчик без труда ее решил: «Сумму последовательности 1 + 2 + 3 + … + N можно определить по формуле (N+1)(N/2). Для N = 100 сумма будет равна 5050. Если вычесть из 5050 сумму чисел, которые остались в мешке, разница будет равна отсутствующему числу».
Но радоваться было рано – коварный интервьюер задал следующий вопрос: «А как найти два отсутствующих числа»? Разработчик такого поворота событий не ожидал и запаниковал. Интервьюер пытался навести программиста на конструктивные размышления и намекнул, что есть подход для решения этой задачи, который:
- Включает несколько уравнений.
- Можно использовать для определения 2, 3, и даже k отсутствующих чисел.
- Обладает лучшей временной и пространственной сложностью, чем О(n), потому что зависит от k.
Несколько месяцев разработчик размышлял над максимально оптимальным решением этой задачи для 2, 3, k ≥ 4 чисел, и наконец создал тему на Stack Overflow. К этому моменту легких решений он уже не искал:
- Искомое решение должно работать не только для 100, но и для любого N чисел.
- Не должно использовать самые очевидные подходы – битовый массив и сортировку.
- Должно быть максимально универсальным, максимально эффективным, эдаким «Святым Граалем» всех возможных решений, даже если сложность реализации сделает этот подход непрактичным.
Два предложенных вариантa решения
Решений в теме много, но оптимальными являются только два. Одно из решений подходит для поиска пропущенных чисел в массиве или списке, второе можно использовать и для массива, и для потока данных.
Решение для массива
Этот элегантный алгоритм работает с линейной скоростью O(n) – чуть хуже, чем было нужно автору вопроса, – зато отличается универсальностью и полным отсутствием сложной математики:
Решение для потока данных
Этот подход взят из книги об алгоритмах для поточной обработки данных, автор которой ссылается на алгоритм Мински-Трахтенберга-Зиппеля, представленный в 2003 году. Метод очень эффективен – работает за O(k2 log n), и одинаково хорошо подходит как для массивов фиксированного размера, так и для потоков данных. Недостаток у него только один – относительная сложность реализации, которую предвидел автор вопроса.
Алгоритм построен на использовании симметричных многочленов, для вычисления которых нужно использовать тождества Ньютона и числа Бернулли. Из этих многочленов составляют систему уравнений или полином k-степени – корни будут равны пропущенным числам. Как именно это делается – рассмотрим ниже.
Как быстро освоить математику?
Заглядывай на наш онлайн-курс по математике для Data Science, где ты познакомишься с основными моделями машинного обучения, научишься выбирать и применять подходящие tree-based модели.
Программа занятий:
- Школьная математика: от теории множеств до производной и интеграла.
- Математический анализ: от числовых последовательностей до теории меры и интеграла Ламбера.
- Линейная алгебра: от матриц до билинейных форм.
- Комбинаторика: правила комбинаторики, множества и сочетания.
- Теория вероятностей и математическая статистика: от случайных событий до регрессии.
- Машинное обучение: Word2vec, градиентный спуск, KNN и т. д.
«Святой Грааль»
Пользователи, которым почему-то не понравилось «математическое» решение, предложенное в исходной теме, продолжили обсуждение в форке. Однако обсуждение быстро зашло в тупик, и это неудивительно: как показывает профессор Мэрилендского университета в Колледж-Парке Уильям Гасарш в этой публикации (pdf), на сегодняшний день известны только два относительно простых (и при этом очень эффективных) подхода для вычисления k ≥ 4, и оба используют симметрические функции. При использовании первого подхода симметрические многочлены выражаются через степенные суммы с помощью тождеств Ньютона, а при втором функции применяются на всех этапах решения.
Как найти 1, 2, 3 и k ≥ 4 пропущенных чисел в массиве или потоке
В упомянутой выше публикации Гасарш приводит 2-3 варианта решения для каждого случая. Все решения описаны математически. Так, например, выглядит первый вариант решения для общего случая:
А это второй вариант:
По представленным в публикации решениям несложно написать код. Рассмотрим несколько примеров.
Первый способ решения для k = 1:
Второй способ решения для k = 1 (с использованием XOR):
Решение для k = 2 с использование суммы квадратов:
Решение для k = 3 с использованием тождеств Ньютона
Основные шаги:
- Вычисляем суммы степеней чисел от 1 до n, используя формулы суммирования степеней последовательных натуральных чисел. Это ожидаемые суммы степеней полного набора чисел.
- Вычисляем неполные суммы степеней для фактически имеющихся чисел в списке data.
- Находим разности между полными и неполными суммами степеней – это значения b1, b2, b3, которые представляют собой суммы соответствующих степеней недостающих чисел.
- Используя тождества Ньютона, из найденных сумм степеней восстанавливаем коэффициенты c1, c2, c3 многочлена третьей степени, корнями которого являются недостающие числа.
Как очевидно, при k > 3 (и при обработке больших массивов данных) нецелесообразно хардкодить формулы и держать все числа в памяти одновременно. Поэтому для k ≥ 4 и потоковой обработки данных используют другой подход, с рекуррентными формулами.
Общий случай
До сих пор для вычисления ожидаемых сумм степеней чисел мы использовали готовые формулы. Таких формул выведено немало, но по мере возрастания степени они становятся все более и более громоздкими:
Однако существует и общая формула – ее вывел в конце 17 века Якоб Бернулли:
Здесь B – соответствующее число Бернулли:
Что же касается коэффициентов (симметрических многочленов), то они определяются с помощью тождеств Ньютона. В примерах выше мы уже использовали эти формулы:
Общая формула выглядит так:
Как уже упоминалось выше, при обработке очень больших массивов данных целесообразнее использовать поточную вариацию алгоритма: тогда вычисления для входного потока данных можно делать рекуррентно, по мере поступления чисел, а вместо тождеств Ньютона использовать прямой подход к получению симметрических функций. Как реализовать этот алгоритм – разберем в следующей статье.