Простые числа. Решето Эратосфена
Это статья посвящена простым числам и эффективным способам их вычисления. Сразу скажу, что те алгоритмы, которые тут приведены, являются весьма и весьма нетривиальными и самому мне не давались довольно долго, но в итоге я их всё-таки придумал, написал и спешу поделиться со всеми вами. Исходные тексты в статье будут приведены на языках C# и Haskell.
Простое число – это натуральное число больше единицы, которое имеет ровно два делителя: единицу и само это число.
Решето Эратосфена – древний, но при этом весьма эффективный и до сих пор широко используемый алгоритм поиска всех простых чисел не превосходящих некоторого N.
Запишем подряд все числа от 2 до N. Дальше вычеркнем из этого списка все числа кратные 2, исключая саму двойку, потом вычеркнем все числа кратные 3, исключая само число 3, число 4 уже вычеркнуто, вычеркиваем числа кратные 5 и т.д. Продолжаем этот процесс, пока квадрат очередного числа не превысит N.
Самая простая программная реализация этого алгоритма выглядит следующим образом:
|
|
Итак, программка очень простая, но что в ней можно улучшить? Приведённая программа выведет список всех простых чисел из первой сотни. Если необходимо вычислить все числа до 1000 достаточно просто изменить размер массива table. Однако в C# объем памяти, занимаемый переменной типа bool равен одному байту. Следовательно, для нахождения всех чисел до одного миллиарда нам потребуется памяти чуть меньше чем гигабайт. А нам ведь хочется найти очень много простых чисел, не только до жалкого миллиарда, правда? :)
Первая и очевидная оптимизация – это использовать для хранения информации об одном числе одного бита. В этом случае в один гигабайт войдут все числа не превосходящие 8`589`934`592. Для работы с такими числами уже не подходят 32-х битные переменные. Очень хорошо! Переходим на 64-х битные. Сама программа:
|
|
Кому хватит для жизни, достигнутых восьми с половиной миллиардов, могут дальше не читать. А со всеми кому мало, переходим к самому интересному. Если внимательно посмотреть, можно увидеть, что сразу после первой итерации оказываются вычеркнутыми все четные числа. Таким образом, буквально после первых нескольких итераций будет вычеркнуто больше половины массива. Существует ли способ не хранить эти числа в массиве, тем самым существенно сэкономив память? Я не буду приводить программу, хранящую только нечетные числа, я сразу обобщу задачу следующим образом: реализовать алгоритм решета, таким образом, чтобы массив не включал чисел, кратных некоторому количеству первых простых (например: 2,3,5,7).
Для исследования разных закономерностей распределения чисел, не делящихся на первые несколько простых, я использовал язык Haskell. Здесь я приведу только ту цепочку рассуждений, и покажу только тот код, которые привели к верному решению. На самом деле я предпринял много попыток.
Итак, запускаем hugs или ghci и начинаем колдовать :) .
Для начала нам нужно определить список чисел, из которого исключены кратные некоторому множеству чисел:
|
|
Для удобства я определю несколько вариантов, с которыми и буду работать:
|
|
Нам понадобиться работать с разностями между ближайшими числами в последовательностях. Функция преобразования некоторой последовательности в список разностей между её соседними элементами:
|
|
Ага! Уже что-то есть, можно заметить периодически повторяющиеся последовательности. Пишем функцию поиска повторяющихся последовательностей:
|
|
Я не буду на ней подробно останавливаться. Просто предположим, что она есть и корректно работает :) . Кому интересно, я думаю, смогут в ней разобраться. Проверяем с помощью этой функции наши последовательности:
|
|
Теперь разберемся, в какую сторону нам предстоит двигаться дальше. Собственно для полного счастья нам необходимо научиться делать две взаимообратные вещи: во-первых, по номеру числа в массиве определять само это число (ведь число в массиве это всего один бит), а во-вторых, по числу определять его позицию в массиве.
Самое время перейти к конкретным примерам. Будем разбираться с массивом без чисел кратных двум и трем.
Первые 10 чисел этого списка выглядят так:
|
|
Его период:
|
|
Следовательно, этот массив состоит из подряд идущих пар, числа внутри пары отличаются на 2, разница между первыми числами подряд идущих пар равна 6. При этом не забываем, что элемент с индексом 0 равен 5.
Имеем функцию нахождения индекса по числу:
|
|
Обратите внимание, мы проверяем остаток от деления на 6, если он больше 2 (разницы между числами в паре), то, значит, мы имеем дело со вторым числом в паре и, следовательно, должны к индексу прибавить единицу.
|
|
Обратная функция. Нахождение числа по индексу:
|
|
Тут мы уже делим и находим остаток от деления аргумента на 2 – длину периода для нашего массива.
|
|
Ну что ж, в частном случае задача решена. Осталась самая малость – обобщить полученный результат до списка с произвольным числом вычеркнутых первых простых.
Индекс по числу:
|
|
Здесь наибольший интерес представляет функция find_inc, она находит положение числа внутри периода: суммируем поочередно числа из периода, пока полученное число не станет больше чем искомое число (остаток от деления аргумента num, функции find_idx_by_num на сумму всех чисел периода).
Число по индексу:
|
|
Здесь функция sm суммирует первые n чисел списка list.
Самые глазастые заметили использование некой неописанной функции integer_len, исправляюсь:
|
|
Это обычный length, отличающийся от стандартного типом возвращаемого значения, в данном случае это Integer вместо Int. Haskell язык со строгой типизацией, и вольностей между Int и Integer он не позволяет.
Проверяем, проверяем, проверяем:
|
|
Ну что ж, теоретическую часть на этом можно считать законченной, способ работы с нужным форматом массива найден, с чем я себя и вас поздравляю! Ура! :)
Переходим к практической реализации всего вышенаколдованного.
Сразу приходит в голову примерно такая реализация: берем очередной ненулевой бит нашего массива, по его номеру находим число, которому он соответствует, дальше начинаем перебирать все кратные ему числа и вычеркивать их из массива с помощью функции поиска номера числа по самому этому числу. Звучит вроде не плохо и так уже можно сделать работающую реализацию, однако нужно учесть, что каждый поиск числа по индексу и поиск индекса по числу содержит внутри себя перебор периода для нашего формата, хотелось бы от этой операции избавиться.
Снова уходим в глубину Haskell’я (последний раз, клянусь :) ). Посмотрим, как распределены числа кратные некоторому N в наших массивах. Функция находит индексы всех чисел кратных num в списке list:
|
|
К примеру, индексы чисел кратных 5 в массиве без двоек и троек:
|
|
Ну что, знакомая картина, явно наша любимая периодически повторяющаяся последовательность разностей между соседними числами:
|
|
Конечный алгоритм будет выглядеть следующим образом: берем очередной единичный бит, находим соответствующее ему число. Находим N+1 ближайших кратных ему чисел (при этом исключаем числа кратные простым из списка вычеркнутых) и находим разности между их индексами. Дальше начинаем последовательно обнулять индексы всех кратных найденному чисел. К примеру, мы обнаружили единичный бит с номером 0 соответствующий числу 5 в массиве без 2 и 3, мы знаем, что индексы чисел кратных 5 изменяются с периодом [7,3] (см. выше), соответственно, обнуляем биты с номерами 7,10,17,20,27 и т.д.
В этом месте следовало бы сказать, что и это ещё не всё, однако этого (пока?) не произойдет. Дальнейший путь ускорения я вижу в выявлении математического закона, формирующего период разностей индексов чисел кратных некоторому N. Господа математики, если у вас есть какие-то идеи на этот счет, милости прошу писать комментарии тут или мне на e-mail.
Я не буду тут приводить полный вариант финальной программы на C#, остановлюсь разве только на C# реализации функций получения индекса по числу и числа по индексу:
|
|
Здесь period, period_sum и first_number определены в файле gen.cs, этот файл генерируеться программой на Haskell, один из вариантов этого файла:
|
|
Генератор для таких файлов находится в папке haskell внутри приложенного к статье файла. Он получает из командной строки число первых простых, которые должны быть исключены и выводит в стандартный поток вывода сгенерированный C# код.
Теперь посмотрим в цифрах, чего мы всё-таки добились:
Классический вариант, для хранения числа используется один бит. Размер массива один гигабайт:
|
|
Вариант решета с исключением чисел кратных 2,3,5,7,11,13. Размер массива один гигабайт:
|
|
С помощью улучшенного алгоритма удалось найти почти в 5 раз больше простых чисел, при этом затраты по времени увеличились только в полтора раза. Очень хороший результат!
Я должен заметить, что дальше исключения последовательности 2,3,5,7,11,13 продвигаться нет смысла. Во-первых затраты времени на вычисление массива с разностями индексов для каждого очередного числа становятся очень большими. Во-вторых эффект от каждого очередного числа сильно снижается. Рассмотрим, как меняется диапазон чисел доступных для работы. Будем брать сотый член разных последовательностей:
|
|
Видно, что наибольший эффект имеет вычеркивание двойки, чуть худший – тройки и чем дальше, тем меньший эффект от вычеркивания очередного простого числа.
Также надо сказать, что использовать массив с вычеркнутыми 2,3,5,7,11,13 можно только на больших объемах памяти, по крайней мере в моей реализации, иначе не будет хватать чисел для вычисления массива с разностями для очередного числа и этот массив будет заполнен некорректно.
Мне было очень интересно решать эту задачу, надеюсь, вам также было интересно это все читать. Я постарался показать на примере очень интересное свойство функциональных языков: код живет буквально на кончиках пальцев, программу можно крутить и разворачивать как угодно, можно очень быстро поверять свои идеи и смотреть на возникающие закономерности. И, кроме всего, приятно чувствовать себя осилившим долго не дававшуюся сложную задачу :) .
Полные исходные тексты всех приведенных в статье программ находятся в прилагаемом файле.