Подсчет делителей чисел до N в O (N)?

Итак, мы можем посчитать делители каждого числа от 1 до N в алгоритме O(NlogN) с ситом:

int n;
cin >> n;
for (int i = 1; i <= n; i++) {
    for (int j = i; j <= n; j += i) {
        cnt[j]++; //// here cnt[x] means count of divisors of x
    }
}

Есть ли способ уменьшить его до O (N)? Заранее спасибо.


person Barish Namazov    schedule 10.11.2017    source источник
comment
все они за O(N)??   -  person user2736738    schedule 10.11.2017
comment
@coderredoc Да.   -  person Barish Namazov    schedule 10.11.2017


Ответы (4)


Вот простая оптимизация решения @גלעד ברקן. Вместо того, чтобы использовать наборы, используйте массивы. Это примерно в 10 раз быстрее, чем установленная версия.

n = 100

answer = [None for i in range(0, n+1)]
answer[1] = 1

small_factors = [1]
p = 1
while (p < n):
    p = p + 1
    if answer[p] is None:
        print("\n\nPrime: " + str(p))
        limit = n / p
        new_small_factors = []
        for i in small_factors:
            j = i
            while j <= limit:
                new_small_factors.append(j)
                answer[j * p] = answer[j] + answer[i]
                j = j * p
        small_factors = new_small_factors

print("\n\nAnswer: " + str([(k,d) for k,d in enumerate(answer)]))

Стоит отметить, что это также алгоритм перечисления простых чисел за O(n). Однако с помощью колеса, сгенерированного из всех простых чисел размером меньше log(n)/2, можно создать список простых чисел за время O(n/log(log(n))).

person btilly    schedule 25.11.2017
comment
Мне также нравится лаконичность кода; и разница в вычислении количества делителей с использованием сложения, а не умножения. - person גלעד ברקן; 25.11.2017
comment
Я добавил дополнительную оптимизацию, которая уменьшила мое время до 8 секунд, а ваше — до 6 секунд для 10 000 000 на моем ноутбуке. p = p + 2 :) - person גלעד ברקן; 25.11.2017
comment
Между прочим, это: while (p < n) может быть так: while (p < n / 2), так как все, что умножается на большее p, выходит за пределы допустимого диапазона (уменьшено еще 0,4 секунды в вашей версии). - person גלעד ברקן; 25.11.2017
comment
@גלעדברקן Вы можете найти другое ускорение, заметив, что все, что ниже min(p, n/p), находится в small_factors, поэтому его можно исключить из этого списка и пройти с добавлением. С моей точки зрения, p < n/2 на самом деле дает неправильные ответы, потому что ставит None вместо 2. Хотя это может быть частный случай. - person btilly; 26.11.2017
comment
в специальном корпусе? да, а как насчет того, чтобы называть их простыми числами? ;) Я не уверен, что вы подразумеваете под обходом с добавлением, не могли бы вы объяснить? - person גלעד ברקן; 26.11.2017
comment
@גלעדברקן Я имею в виду, что если p простое число, вы можете пройти 1*p, 2*p, 3*p, ..., (p-1)*p, просто добавляя p снова и снова и сохраняя счетчик того, где вы находитесь. И количество, которое вы пишете, всегда удваивается. Что касается специального случая, я имел в виду, что в выводе None будет выводиться как 2. - person btilly; 27.11.2017

Как насчет этого? Начните с простого числа 2 и сохраните список кортежей, (k, d_k), где d_k — количество делителей k, начиная с (1,1):

for each prime, p (ascending and lower than or equal to n / 2):
  for each tuple (k, d_k) in the list:
    if k * p > n:
      remove the tuple from the list
      continue
    power = 1
    while p * k <= n:
      add the tuple to the list if k * p^power <= n / p
      k = k * p
      output (k, (power + 1) * d_k)
      power = power + 1
  the next number the output has skipped is the next prime
  (since clearly all numbers up to the next prime are
   either smaller primes or composites of smaller primes)

Приведенный выше метод также генерирует простые числа, полагаясь на O(n) памяти, чтобы продолжать поиск следующего простого числа. Имея более эффективный, независимый поток простых чисел, мы могли бы избежать добавления каких-либо кортежей (k, d_k) в список, где k * next_prime > n, а также освободить всю память, содержащую выходные данные, превышающие n / next_prime.

код

person גלעד ברקן    schedule 21.11.2017
comment
проще всего убедиться в этом, протестировав его на простом прямом алгоритме и сравнив результаты; а затем измерьте эмпирические порядки роста для обоих. - person Will Ness; 22.11.2017
comment
но из списка простых чисел до n за время O (n) я сразу вижу, что n ограничено размером кеша, потому что для O (n) вам нужен непрерывный массив, AFAIK ( то есть решето Эйлера). Эратосфен работает в n log log n. - person Will Ness; 22.11.2017
comment
@WillNess Я в замешательстве - вы говорите, что сохранить список простых чисел до n невозможно за время O (n)? Я просто предположил эту часть из небольшого чтения в Интернете. Сито Аткина видимо. - person גלעד ברקן; 22.11.2017
comment
Эратсофен работает в n log log n. Я видел информацию о n / log log n с простой оптимизацией в Википедии, но это было добавлено математиком; просто для них не так просто для меня. он основывал его на статьях другого математика, в которых говорится о раскручивании спиралей колес, которые требуют огромных размеров, если сделать их просто, что обязательно повлияет на производительность. Единственное известное мне практическое O(n), которое не так просто сделать самому, - это Эйлера (или также Гриса и Мишры); и это не может быть сегментировано AFAIK. - person Will Ness; 22.11.2017
comment
просто для меня это 4-5 строк Haskell, максимум. желательно не более 2. Аткин, на SO есть много критических замечаний пользователя GordonBGood. - person Will Ness; 22.11.2017
comment
@WillNess, глядя на решето Эйлера, вдохновил меня немного изменить свой код, чтобы легко включить и основное поколение :) См. здесь: repl.it/repls/MistyroseTruthfulВальдшнеп - person גלעד ברקן; 23.11.2017
comment
хорошо, я постараюсь понять это (позже). я все время чувствовал, что нужно как-то увеличить решето Эйлера для этой проблемы; но я не продумал это. все же. :) (разве вам не нравятся эти причудливые названия ссылок repl.it, ха. Интересно, у них уже был SqueamishOssifrage.) - person Will Ness; 23.11.2017
comment
Алгоритм здесь кажется самым быстрым, но это не совсем O(N), есть какая-то константа, хотя и не большая константа. Вы получили мой голос :) - person Barish Namazov; 23.11.2017
comment
Каждая операция del в середине списка — это O(size of list), так что на самом деле это реализация O(n^2). Но вы можете исправить это, переключив свой список на 2 очереди. При каждом проходе вы читаете из одной очереди, а затем решаете, вставлять ли ее в другую. Когда проход заканчивается, вы меняете роли очередей. Но идея работает, вы получаете мой голос! - person btilly; 23.11.2017
comment
@btilly спасибо за проверку и за ваше предложение об очередях! - person גלעד ברקן; 23.11.2017
comment
Версия теперь имеет бесконечный цикл. :-( - person btilly; 24.11.2017
comment
@btilly Не могли бы вы объяснить, что вы имеете в виду? Все ссылки и код у меня работают. - person גלעד ברקן; 24.11.2017
comment
Когда я вырезал и вставлял его в терминал, он снова и снова находил 2. Теперь это работает. Но есть ряд оптимизаций, которые отсутствуют. Я добавлю еще один ответ. - person btilly; 25.11.2017
comment
@WillNess У меня есть практичный алгоритм нахождения простых чисел до N за время O(n/log(log(n)). Начните с запуска этого алгоритма, чтобы найти все простые числа до log(n)/2. Постройте колесо для произведения всех этих простых чисел (которое будет достаточно близко к длине sqrt(n)). Запустите этот алгоритм (кроме случаев, когда флаги не учитываются) на значениях, относительно простых ко всем этим простым числам. Это будет работать во времени O(n log(log(n))). - person btilly; 25.11.2017

Рассмотрим общее количество этих подсчетов, sum(phi(i) for i=1,n). Эта сумма равна O(N log N), поэтому любое O(N) решение должно будет обходить индивидуальный подсчет.

Это говорит о том, что любое улучшение должно зависеть от предыдущих результатов (динамическое программирование). Мы уже знаем, что phi(i) — это произведение каждой простой степени плюс единица. Например, 12 = 2^2 * 3^1. Степени 2 и 1 соответственно. (2+1)*(1+1) = 6. У 12 6 делителей: 1, 2, 3, 4, 6, 12.

Это «сводит» вопрос к тому, можете ли вы использовать априорные знания, чтобы получить O(1) способ прямого вычисления количества делителей, без необходимости подсчитывать их по отдельности.

Подумайте о данном случае... количество делителей до сих пор включает:

1 1
2 2
3 2
4 3
6 4

Есть ли O(1) способ получить phi(12) = 6 из этих цифр?

person Prune    schedule 10.11.2017
comment
правильно ли сказать, что важно, чтобы это перечисление выполнялось в одном непрерывном массиве от 1 до N? в любом случае это то, что я имею в виду, я не могу придумать никакого способа выполнить перечисление O (1), начиная с некоторого верхнего числа. - person Will Ness; 13.11.2017
comment
@WillNess Это мое внутреннее чувство. Я преуспел в теории чисел в колледже, но здесь я не вижу решения. Если вы можете идентифицировать какую-либо факторизацию в относительных простых числах (например, 3 * 4 выше), то вы можете просто умножить их значения phi, чтобы получить новое. Однако я не знаю способа O(1) идентифицировать два таких фактора. Кроме того, вам нужен другой метод (хотя и простой) для степени простого числа. Например, 8 не имеет такой факторизации. - person Prune; 13.11.2017

Вот алгоритм, который теоретически лучше, чем O(n log(n)), но может быть хуже для разумного n. Я полагаю, что его время работы равно O(n lg*(n)), где lg* — это https://en.wikipedia.org/wiki/Iterated_logarithm.

Прежде всего, вы можете найти все простые числа до n за время O(n), используя решето Аткина. Подробнее см. https://en.wikipedia.org/wiki/Sieve_of_Atkin.

Теперь идея состоит в том, что мы создадим наш список счетчиков, вставляя каждый счетчик только один раз. Мы пройдемся по простым множителям один за другим и вставим значения для всего, что является максимальным простым числом. Однако для этого нам нужна структура данных со следующими свойствами:

  1. Мы можем сохранить значение (в частности, количество) для каждого значения.
  2. Мы можем пройтись по списку вставленных значений вперед и назад в O(1).
  3. Мы можем найти последнее вставленное число ниже i "эффективно".
  4. Вставка должна быть "эффективной".

(Цитаты — это части, которые трудно оценить.)

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

Мы можем сделать это с помощью массива узлов (которые не инициализированы) со следующими полями, которые выглядят как двусвязный список:

  1. value Ответ, который мы ищем.
  2. prev Последнее предыдущее значение, для которого у нас есть ответ.
  3. next Следующее значение, для которого у нас есть ответ.

Теперь, если i находится в списке, а j является следующим значением, трюк с пропуском списка будет заключаться в том, что мы также заполним prev для первого даже после i, первого кратного 4, кратного 8 и так далее, пока мы не достигнем j. Итак, если i = 81 и j = 96, мы заполним prev вместо 82, 84, 88, а затем 96.

Теперь предположим, что мы хотим вставить значение v в k между существующими i и j. Как мы делаем это? Я представлю псевдокод, начиная с известного только k, а затем заполню его для i = 81, j = 96 и k = 90.

k.value := v
for temp in searching down from k for increasing factors of 2:
    if temp has a value:
        our_prev := temp
        break
    else if temp has a prev:
        our_prev = temp.prev
        break
our_next := our_prev.next
our_prev.next := k
k.next := our_next
our_next.prev := k
for temp in searching up from k for increasing factors of 2:
    if j <= temp:
        break
    temp.prev = k
k.prev := our_prev

В нашем конкретном примере мы хотели искать вниз от 90 до 90, 88, 80, 64, 0. Но на самом деле нам говорят, что prev это 81, когда мы добираемся до 88. Мы хотели бы искать до 90, 92, 96, 128, 256, ..., однако нам просто нужно установить 92.prev 96.prev, и все готово.

Теперь это сложный код, но его производительность O(log(k-i) + log(j-k) + 1). Это означает, что он начинается как O(log(n)), но становится лучше по мере заполнения большего количества значений.

Так как же нам инициализировать эту структуру данных? Итак, мы инициализируем массив неинициализированных значений, затем устанавливаем 1.value := 0, 1.next := n+1 и 2.prev := 4.prev := 8.prev := 16.prev := ... := 1. И затем мы начинаем обрабатывать наши простые числа.

Когда мы достигнем простого p, мы начнем с поиска предыдущего вставленного значения ниже n/p. Идя назад оттуда, мы продолжаем вставлять значения для x*p, x*p^2, ..., пока не достигнем предела. (Причина в обратном порядке заключается в том, что мы не хотим пытаться вставить, скажем, 18 один раз для 3 и один раз для 9. Обратный ход предотвращает это.)

Каково наше время работы? Нахождение простых чисел равно O(n). Поиск начальных вставок также легко O(n/log(n)) операций времени O(log(n)) для другого O(n). А как насчет вставки всех значений? Это тривиально O(n log(n)), но можем ли мы сделать лучше?

Ну, во-первых, все заполненные вставки с плотностью 1/log(n) могут быть выполнены за время O(n/log(n)) * O(log(n)) = O(n). И тогда все вставки с плотностью 1/log(log(n)) также могут быть выполнены за время O(n/log(log(n))) * O(log(log(n))) = O(n). И так далее с увеличением количества логов. Количество таких факторов, которое мы получаем, составляет O(lg*(n)) для оценки O(n lg*(n)), которую я дал.

Я не показал, что эта оценка настолько хороша, насколько это возможно, но я думаю, что это так.

Так что не O(n), но чертовски близко.

person btilly    schedule 20.11.2017