Pull to refresh

Comments 43

UFO just landed and posted this here

Вложенный цикл суммарно выполняет O(n) операций. Это как, допустим


step = n;
for (i = 0; i < n; ++i) {
    step /= 2;
    for (j = 0; j < step; ++j) {
         do_somthing();
    }
}

Вложенный цикл выполнит n/2 + n/4 + n/8 +… операций. Cуммарно — меньше n.


Еще одна аналогия — обход в ширину графа c n ребрами (если ребра заданы списком смежности). Вроже бы опять 2 вложенных цикла, но внутренняя итерация суммарно выполнится на более n раз.

UFO just landed and posted this here
Внутренний цикл — O(n), внешний цикл — n…

Ну да, большинство итераций внешнего цикла не сделают почти ничего. Цикл for посмотрит на условие и не выполнит ни одну итерацию. Также и алгоритм в этой статье.


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


Я подозреваю, что этот алгоритм — плод народного творчества. Поэтому нигде не удалось найти объяснение алгоритма с доказательством.

На мой взгляд, здесь модифицированное решето Эратосфена, и сложность будет примерно та же. Внешний цикл перебирает все числа, внутренний — только простые, меньшие найденного.
Для n верхняя граница количества простых чисел в интервале [1,n] в первом приближении оценивается как ln(n). То есть, общая оценка алгоритма — O(n*log(n)).

Ну прочитайте же, пожалуйста, доказательство.


Внутренний цикл перебирает только простые меньше lp(i), что, например, для всех четных чисел — всего 2. Т.е. перебираются сильно меньше чисел.


Алгоритмы, где 2 вложенных цикла суммарно делают линейное количество операций встречаются довольно часто. Тот же KMP для поиска подстроки, или обход дерева в ширину.


Для n верхняя граница количества простых чисел в интервале [1,n] в первом приближении оценивается как ln(n). То есть, общая оценка алгоритма — O(n*log(n)).

Кстати, верхняя оценка количества простых — n/log n, а не log n, как вы написали.

Действительно, я невнимательно посмотрел. Каждое непростое число вычёркивается только один раз, значит сложность O(n).
А КМП и обход дерева в ширину реализуются без вложенных циклов, так что тут ваши примеры несколько неудачны.
А КМП и обход дерева в ширину реализуются без вложенных циклов, так что тут ваши примеры несколько неудачны.

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


Очень интересно. Могли бы вы привести наброски кода? И для кмп или хотя бы только для обхода в ширину.

С КМП реализуется легко и без конечного автомата:
Заголовок спойлера
var needle = "abracadabra";
var needle_pref = [0];
var i = 0;
var j = 1;
while (i < needle.length) {
  if (needle[i] == needle[j]) {
    needle_pref[i++] = ++j;
  } else if (j == 0) {
    needle_pref[i++] = 0
  } else {
    j = needle_pref[j-1];
  }
}

var haystack = "abyrvalgabramabracadabracadabrass";
i = 0;
j = 0;
while (i < haystack.length) {
  if (haystack[i] == needle[j]) {
    if (j == needle.length - 1) {
      console.log(i-j, haystack.substr(i-j, j+1));
      i++;
      j = needle_pref[j];
    } else {
      i++;
      j++;
    }
  } else if (j == 0) {
    i++;
  } else {
    j = needle_pref[j];
  }
}

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

Действительно, можно было и так реализовать КМП, спасибо!

Ну покажите же, пожалуйста, цифры :)


Искать неточность в доказательстве сложнее, чем замерить производительность. У меня получилась нелинейная зависимость времени от n для вашего алгоритма:


Calculating -------------------------------------
                1000      2.755k (± 3.9%) i/s -     13.860k in   5.038808s
               10000    273.275  (± 2.9%) i/s -      1.378k in   5.046871s
              100000     26.691  (± 3.7%) i/s -    134.000  in   5.027327s
             1000000      2.590  (± 0.0%) i/s -     13.000  in   5.021461s
             2000000      1.309  (± 0.0%) i/s -      7.000  in   5.349981s

Comparison:
                1000:     2755.2 i/s
               10000:      273.3 i/s - 10.08x  slower
              100000:       26.7 i/s - 103.23x  slower
             1000000:        2.6 i/s - 1063.97x  slower
             2000000:        1.3 i/s - 2105.49x  slower

код
# ruby
def fn(n)
  pr = []
  lp = Array.new(n)
  2.upto(n) do |i|
    unless lp[i]
      lp[i] = i
      pr.push i
    end
    pr.each do |p|
      break unless p <= lp[i] && p * i <= n
      lp[p * i] = p
    end
  end
  pr
end

puts fn(100).join(' ')

require 'benchmark/ips'
puts Benchmark.ips { |x|
  [1_000, 10_000, 100_000, 1_000_000, 2_000_000].each do |n|
    x.report(n.to_s) { fn(n) }
  end
  x.compare!
}

Если проблема в языке с GC, или в чем-то еще, покажите, пожалуйста, это вашими измерениями.

стороной проходил, но вроде бы линейная по вашему отчету)

Eсли точно подсчитать, то сложность будет Θ((c1 — c2/logn) * n).


Это следует из того, что количество составных чисел ~ n — n/log n, а алгоритм выполняет действия для каждого из них.


Обратите внимание, эта сложность остается O(n), потому что с ростом n слагаемое c1*n доминирует над c2*n/logn.


Ваши же цифры вполне подходят под эту теорию — даже n log log n растет сильно быстрее, чем время работы вашего решения.


Плюс к этому, на больших массивах оно перестает влезать в кэш и работает медленнее.
Мерить точное время работы при скачках по 4-64Мб массиву — дело неблагодарное.


Давайте лучше подсчитаем количество операций:


Код
long long Erato(unsigned int n) {
    int cnt1 = 0, cnt2 = 0, cnt3 = 0;
    vector<int> pr;
    pr.reserve(n-1);
    vector<int> lp(n+1);
    unsigned int i, j;
    long long sum = 0;
    for (i = 2; i <= n; ++i) {
      if (lp[i] == 0) {
        lp[i] = i;
        sum += i;
        pr.push_back(i);
        ++cnt1;
      }
      int maxp = n / i;
      for (j = 0; ++cnt3 && j < pr.size() && pr[j] <= lp[i] && pr[j] <= maxp; ++j) {
        ++cnt2;
        lp[i*pr[j]] = pr[j];
      }
    }
    cout << n << ' ' << cnt1 << ' ' << cnt2 << ' ' << cnt3 << '\n';
    return sum;
}

Результат.
10 4 5 14
100 25 74 173
1000 168 831 1830
10000 1229 8770 18769
100000 9592 90407 190406
1000000 78498 921501 1921500
10000000 664579 9335420 19335419

Как видите — все операции (и внутри цикла, и сравнение в цикле) выполняются меньше 2n раз. С ростом n все ближе к границе. Отсюда, вместе с несовершенством компьютеров, и получается небольшое замедление в ваших числах.

А в какой вычислительной модели оценка O(N)?

Тут есть Θ(N) умножений двух чисел длины log(N), то есть Ω(N * log(N)) битовых операций.

Модель такая же, как и для обычного решета — умножения происходят за константу.


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

за n надо брать не границу до которой ищутся простые числа, а ее длину

А почему не длину этой длины, степенные башенки должны быть выше!
Если серьёзно, то длина это как раз граница до которой ищутся простые числа — n. Или КМП у вас работает за 2^n, а числа перемножаются за n*2^n?

А почему не длину этой длины, степенные башенки должны быть выше!

Потому что берется длина входных данных в битах, если вы битовую сложность хотите.
В кмп как раз длина 8*n. И при перемножении чисел в k знаков сложность считается от k, а не значения числа.

У вас n — это граница до которой ищутся простые числа, зачем называть этой буквой ещё и её длину?

Что бы использовать привычную нотацию вида O(f(n)). Так-то можно обозначить такой буквой, какой мы захотим. Но тут вы правы, я зря Вас запутал этим.


В любом случае, если вы хотите считать битовые операции, то и стандартное решето будет не O(n*log log n), как везде написано, а O(n*log n*log log n), ведь там числа до n складываются.

Есть решето Аткина с временем работы Θ( n / log(log(n)) ) — статья
Там нет умножений чисел размера O( log(n) ), только сложения-вычитания и прочие линейные операции, т.е. битовая сложность Θ( n * log(n) / log(log(n)) )
При сложности умножения Θ(n * log(n)) у вас битовая сложность Θ(n * log n * log log n), такая же как и у обычного решета Эратосфена.
Т.е. «сэкономиленные» log(log(n)) вы на самом деле спрятали в сложность умножения.
такая же как и у обычного решета Эратосфена.

Да, в этой модели вычислений — вы правы.

От log(log(n)) легко избавиться, если заметить, что мы вычисляем p*i, для фиксированного i, а p бежит по простым. Пользуясь тем, что (pr[k]-pr[k-1]) = O(pr[k] ^ (2/3)), можно предпосчитать q*i для нужных q, а потом умножение свести к сложению предыдущего с предпосчитанным.

Была такая мысль, но я так и не придумал, как это сделать быстро.


Ведь для разных i использутеся разный префикс pr. Так что, мы можем много-много итераций не трогать конец списка. А потом должны как-то быстро подсчитать (pr[k]-pr[k-1])*i.


Или я вас не правльно понял?

Я же зафиксировал i, значит для разных i считается независимо.
Так как (pr[k]-pr[k-1]) = O(pr[k] ^ (2/3)) асимптотически мало, мы можем предпосчитать все нужные произведения (pr[k]-pr[k-1])*i и это будет асимптотически меньше чем последующие сложения pr[k]*i = pr[k-1]*i + (pr[k]-pr[k-1])*i.
Так как (pr[k]-pr[k-1]) = O(pr[k] ^ (2/3)) асимптотически мало,

Это все-равно 2/3 log n бит. Вы таким образом ускорите умножения, но не ассимптотически.

Точнее O(k^(2/3)*log(k)*log(n)) у предпосчёта против O(k*log(n))
k — количество простых в цикле для данного i.
Предподсчёт тоже будем не в лоб умножать, а складывать.

Почему вы количество простых-то берете в степени? все теже итерации сделать предется, все столько-же i*p значений подсчитать. Да, теперь вместо умножения двух чисел в log n бит вы умножаете число в 2/3 log n бит (pr[k]-pr[k-1]) на число в log n бит (i). Все те же n умножений во всем алгоритме, но числа на 33% короче. На асимтотику не влияет.

Потому что смысл предподсчёта — посчитать i*j только для j до k^(2/3)*log(k), так как pr[k] = O(k*log(k)), а разности между соседними простыми — O(pr[k]^(2/3)). А потом основной цикл — предыдущее складываем с предподсчитанным.

Я думал k у вас — сколько раз внутренний цикл для данного i выполнится. Туда по определению записано ограничение i*p <= n. А теперь мне кажется, что k у вас количество простых в списке pr для данного i.


Но это все не важно. Суть в том, что то же самое количество значений i*p вам все-равно придется вычислить, ведь это все вычеркнутые по одному разу числа и их надо обязательно вычеркнуть.


Теперь вместо каждого умножения ip вы предлагаете делать prev + i(pr[j]-pr[j-1]), так? Количество умножений не изменилось, потому что мы считаем все те же значения. Сами умножения работают на на ~15% быстрее, потому что один из множителей на треть короче, но на асимптотику это не влияет.

Все i(pr[j]-pr[j-1]) мы предпосдчитали заранее в отдельный массив и заново умножать не надо, только складывать. Всего O(k) сложений, но так как (pr[j]-pr[j-1]) = O( k^(2/3)*log(k) ), то умножений в предподсчёте гораздо меньше.
Все i(pr[j]-pr[j-1]) мы предпосдчитали заранее

Вот этих комбинаций, реально используемых O(n) и будет. Ровно столько, сколько умножений делает наивный алгоритм.


Я не понимаю, как из размера разности между простыми числами следует, что понадобится меньше умножений. Ведь каждое используемое значение, которое мы предподсчитываем, делается умножением. А нужно нам в каждом внутреннем цикле все те же k, суммарно O(n).


Если вас не затруднит, хотя бы псевдокод вашего решения приведите, пожалуйста.

for (i=2; i<=n; ++i) {
	if (lp[i]==0)
		pr[++np] = lp[i] = i;
	m = min(lp[i], n/i), d=0;
	for (k=0; k<np && pr[k+1]<=m; ++k)
		d = max(d, pr[k+1]-pr[k]);
	for (j=1; j<=d; ++j)
		a[j] = a[j-1]+i;
	pi=0;
	for (j=1; j<=k; ++j)
		pi += a[pr[j]-pr[j-1]], lp[pi] = pr[j];
}

От n/i можно избавиться, но тогда код станет сложнее.

Шикарно вообще! Ни единого умножения. Вы — гений. Я вообще вас не правильно сначала понял.


Единственное, надо подумать, а не станет ли d сильно больше k? И не сломает ли это асимптотику? Ведь если для каких-то определенных значений k Ваш алгоритм может сделать сильно больше операций и если это k довольно часто встречается, то все может поломаться.


Кажется, что не должно, ведь разница между простыми до n в среднем log n. Но это в среднем. И судя по Вики эта разница всегда меньше количества простых. Надо только убедится, что так и будет для очень больших простых.


P.S. жаль, не могу вам плюс в карму поставить!

> а не станет ли d сильно больше k?

d = O(k ^ (2/3)), и это не самая лучшая известная оценка.

Есть Гипотеза Крамера, утверждающая, что d = O(log(m)^2) = O(log(k)^2)

Так что всё, кроме вычисления n/i, выполняется в сумме за O(n*log(n)) битовых операций. Ну а как справиться с n/i подумайте сами.

rd(x) < x ведь всегда, а не только если x ∉ P. Если x — простое, то rd(x) = 1 < x.

Линейная скорость, это, конечно, замечательно, да вот только, вроде бы, в проджект Эйлере, например, в первой же десятке заданий есть решето и там, насколько я помню, линейная скорость не то чтоб слишком годится, я не готов минуту ждать расчёта против варианта за 100-300 ms.

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


Но все же, если вы про 10-ое задание на Эйлере, то там надо подсчитать простые всего до 2 миллионов. Тут что линейное решето требует 0,02с, что стандартное 0.015с на моем компьютере. И вся разница объясняется необходимостью работать с 8mb памяти вместо 250kb.

Мог тупануть и сравнить с временем исполнения poorly-written хаскеля и с++, ручками оптимизированную, в таком случае прошу прощения.
Всё же 20мс на линейном решете мне кажутся не то чтоб самым реальным результатом, наверняка улучшали его, как минимум, если все числа > 2 крутить в цикле, то будут отжираться огромные куски времени проца на молочение впустую составных чисел.

Ну помилуйте, цикл до 2 миллионов срабатывает мгновенно. На олимпиадах всегда был такой прием — просто считаем операции, вроде присвоения, if-ов и т.д. В секунду таких 400-600 миллионов точно влезет. Тут же выполнится, дай бог 6-8 миллионов. Как раз 1/50-ая


Код и счетчики операций есть в этом комментарии. Никаких оптимизаций почти нет.


Потом, по-моему, вы что-то путаете. То что O(n log log n) работает за 15мс у вас вопросов не вызывает, а в то что O(n) работает за 20мс вы поверить не можете.

С готовностью признаю, что никогда не оценивал сложность алгоритмов, помимо пары пар в универе.
Ежели Ваша оценка сложности вычислена верно, то подобные значения вполне правдоподобны, я же просто приводил цифры, что меня удивили; в любом случае, благодарю за занятный диалог.
Sign up to leave a comment.

Articles