Определение сложности данных кодов

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

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        System.out.println("*");
    }
}

TA объяснил это чем-то вроде комбинаций. Как это n выберите 2 = (n (n-1))/2 = n^2 + 0.5, затем удалите константу, чтобы она стала n^2. Я могу поставить значения теста int и попробовать, но как эта комбинация приходит?

что если есть оператор if? Как сложность определена?

for (int i = 0; i < n; i++) {
    if (i % 2 ==0) {
        for (int j = i; j < n; j++) { ... }
    } else {
        for (int j = 0; j < i; j++) { ... }
    }
}

тогда как насчет рекурсии ...

int fib(int a, int b, int n) {
    if (n == 3) {
        return a + b;
    } else {
        return fib(b, a+b, n-1);
    }
}

6 ответов


В общем, нет способа определить сложность данной функции

предупреждение! Стена входящего текста!

1. Есть очень просто алгоритмы, которые никто не знает, останавливаются они или нет.

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

//The Collatz conjecture states that the sequence generated by the following
// algorithm always reaches 1, for any initial positive integer. It has been
// an open problem for 70+ years now.
function col(n){
    if (n == 1){
        return 0;
    }else if (n % 2 == 0){ //even
        return 1 + col(n/2);
    }else{ //odd
        return 1 + col(3*n + 1);
    }
}

2. алгоритмы есть странные и непривычные сложности

общая "схема определения сложности" легко станет слишком сложной из-за этих парней

//The Ackermann function. One of the first examples of a non-primitive-recursive algorithm.
function ack(m, n){
    if(m == 0){
        return n + 1;
    }else if( n == 0 ){
        return ack(m-1, 1);
    }else{
        return ack(m-1, ack(m, n-1));
    }
}

function f(n){ return ack(n, n); }

//f(1) = 3
//f(2) = 7
//f(3) = 61
//f(4) takes longer then your wildest dreams to terminate.

3. некоторые функции очень просты, но будут путать много видов статического анализа попытки

//Mc'Carthy's 91 function. Try guessing what it does without
// running it or reading the Wikipedia page ;)
function f91(n){
    if(n > 100){
        return n - 10;
    }else{
        return f91(f91(n + 11));
    }
}

тем не менее, нам все еще нужен способ найти сложность вещей, верно? Ибо петли-это простой и распространенный паттерн. Возьмем Ваш первоначальный пример:

for(i=0; i<N; i++){
   for(j=0; j<i; j++){
       print something
   }
}

С каждого print something О(1), временная сложность алгоритма будет определяться, сколько раз мы запускаем эту линию. Ну, как упоминал ваш TA, мы делаем это, глядя на комбинации в этом случае. Внутренний цикл будет работать (N + (N-1) + ... + 1) раз, в общей сложности (N+1)*N/2.

так как мы игнорируем константы, мы получаем O (N2).

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

например:

function fib_like(n){
    if(n <= 1){
        return 17;
    }else{
        return 42 + fib_like(n-1) + fib_like(n-2);
    }
 }

легко видеть, что время выполнения, с точки зрения N, будет задано

T(N) = 1 if (N <= 1)
T(N) = T(N-1) + T(N-2) otherwise

Ну, T (N)-это просто старая добрая функция Фибоначчи. Мы можем использовать индукцию, чтобы поставить некоторые границы.

например, докажем индукцией, что T (N)

  • базовый регистр: n = 0 или n = 1
    T(0) = 1 <= 1 = 2^0
    T(1) = 1 <= 2 = 2^1
  • индуктивный случай (n > 1):
    T(N) = T(n-1) + T(n-2)
    aplying the inductive hypothesis in T(n-1) and T(n-2)...
    T(N) <= 2^(n-1) + 2^(n-2)
    so..
    T(N) <= 2^(n-1) + 2^(n-1)
         <= 2^n

(мы можем попробовать сделать что-то подобное, чтобы доказать нижнюю границу)

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

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


кроме того, в вашем примере" if case " я бы решил это, обманув и разделив его на две отдельные петли, которые не имеют if внутри.

for (int i = 0; i < n; i++) {
    if (i % 2 ==0) {
        for (int j = i; j < n; j++) { ... }
    } else {
        for (int j = 0; j < i; j++) { ... }
    }
}

имеет ту же среду выполнения as

for (int i = 0; i < n; i += 2) {
    for (int j = i; j < n; j++) { ... }
}

for (int i = 1; i < n; i+=2) {
    for (int j = 0; j < i; j++) { ... }
}

и каждую из двух частей можно легко увидеть как O(N^2) для суммы, которая также O (N^2).

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


в общем, решить сложность алгоритма теоретически невозможно.

тем не менее, один классный и ориентированный на код метод для этого-на самом деле просто думать с точки зрения программ напрямую. Возьмите ваш пример:

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        System.out.println("*");
    }
}

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

int counter = 0;
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        System.out.println("*");
        counter++;
    }
}

потому что система.из.println line не имеет значения, давайте удалим это:

int counter = 0;
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        counter++;
    }
}

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

int counter = 0;
for (int i = 0; i < n; i++) {
    counter += n;
}

... потому что мы знаем, что приращение выполняется точно n раза. И теперь мы видим, что счетчик увеличивается на n ровно n раз, поэтому мы упрощаем это:

int counter = 0;
counter += n * n;

и мы появились с (правильным) O (n2) сложность :) это есть в коде :)

давайте посмотрим, как это работает для калькулятор Фибоначчи рекурсивным:

int fib(int n) {
  if (n < 2) return 1;
  return fib(n - 1) + fib(n - 2);
}

измените процедуру так, чтобы она возвращала количество итераций, проведенных внутри нее, а не фактические числа Фибоначчи:

int fib_count(int n) {
  if (n < 2) return 1;
  return fib_count(n - 1) + fib_count(n - 2);
}

это все еще Фибоначчи! :) Итак, теперь мы знаем, что рекурсивный калькулятор Фибоначчи имеет сложность O(F (n)), где F-само число Фибоначчи.

Хорошо, давайте посмотрим на что-то более интересное, скажем простое (и неэффективно) mergesort:

void mergesort(Array a, int from, int to) {
  if (from >= to - 1) return;
  int m = (from + to) / 2;
  /* Recursively sort halves */
  mergesort(a, from, m);
  mergesort(m, m,    to);
  /* Then merge */
  Array b = new Array(to - from);
  int i = from;
  int j = m;
  int ptr = 0;
  while (i < m || j < to) {
    if (i == m || a[j] < a[i]) {
      b[ptr] = a[j++];
    } else {
      b[ptr] = a[i++];
    }
    ptr++;
  }
  for (i = from; i < to; i++)
    a[i] = b[i - from];
}

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

int mergesort(Array a, int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  /* Recursively sort halves */
  int count = 0;
  count += mergesort(a, from, m);
  count += mergesort(m, m,    to);
  /* Then merge */
  Array b = new Array(to - from);
  int i = from;
  int j = m;
  int ptr = 0;
  while (i < m || j < to) {
    if (i == m || a[j] < a[i]) {
      b[ptr] = a[j++];
    } else {
      b[ptr] = a[i++];
    }
    ptr++;
    count++;
  }
  for (i = from; i < to; i++) {
    count++;
    a[i] = b[i - from];
  }
  return count;
}

затем мы удаляем те строки, которые фактически не влияют на подсчеты и упрощают:

int mergesort(Array a, int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  /* Recursively sort halves */
  int count = 0;
  count += mergesort(a, from, m);
  count += mergesort(m, m,    to);
  /* Then merge */
  count += to - from;
  /* Copy the array */
  count += to - from;
  return count;
}

еще в несколько упрощенном виде:

int mergesort(Array a, int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  int count = 0;
  count += mergesort(a, from, m);
  count += mergesort(m, m,    to);
  count += (to - from) * 2;
  return count;
}

теперь мы можем вообще обойтись без массива:

int mergesort(int from, int to) {
  if (from >= to - 1) return 1;
  int m = (from + to) / 2;
  int count = 0;
  count += mergesort(from, m);
  count += mergesort(m,    to);
  count += (to - from) * 2;
  return count;
}

теперь мы можем увидеть что на самом деле абсолютные значения from и to уже не имеют значения, а только их расстояние, поэтому мы модифицируем это на:

int mergesort(int d) {
  if (d <= 1) return 1;
  int count = 0;
  count += mergesort(d / 2);
  count += mergesort(d / 2);
  count += d * 2;
  return count;
}

и затем мы получаем:

int mergesort(int d) {
  if (d <= 1) return 1;
  return 2 * mergesort(d / 2) + d * 2;
}

здесь явно d при первом вызове размер массива для сортировки, поэтому у вас есть повторение для сложности M (x) (это видно на второй строке :)

M(x) = 2(M(x/2) + x)

и это вам нужно решить, чтобы добраться до решения закрытой формы. Это ты сделать проще всего, угадав решение M (x) = x log x, и проверить для правой стороны:

2 (x/2 log x/2 + x)
        = x log x/2 + 2x
        = x (log x - log 2 + 2)
        = x (log x - C)

и убедитесь, что он асимптотически эквивалентен левой стороне:

x log x - Cx
------------ = 1 - [Cx / (x log x)] = 1 - [C / log x] --> 1 - 0 = 1.
x log x

хотя это Чрезмерное обобщение, мне нравится думать о Big-O в терминах списков, где длина списка составляет N элементов.

таким образом, если у вас есть цикл for, который повторяет все в списке, Это O(N). В вашем коде у вас есть одна строка, которая(в изоляции сама по себе) равна 0 (N).

for (int i = 0; i < n; i++) {

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

и наоборот, если вы просто выполняете быструю операцию над одним элементом, то это будет O (1). Нет "списка длины n", чтобы перейти, только один раз операция.Чтобы поместить это в контекст, в вашем примере выше, операция:

if (i % 2 ==0)

равно 0 (1). Важно не "Если", а то, что проверка того, равен ли один элемент другому элементу, является быстрой операцией над одним элементом. Как и раньше, оператор if вложен внутри вашего внешнего цикла for. Однако, поскольку это 0( 1), то вы умножаете все на "1", и поэтому нет "заметного" влияния в вашем окончательном расчете на время выполнения всего функция.

для журналов и решения более сложных ситуаций (например, это дело подсчета до j или i, а не только N снова), я бы указал вам на более элегантное объяснение здесь.


Мне нравится использовать две вещи для обозначения Big-O: стандартный Big-O, что является худшим сценарием, и средний Big-O, что обычно происходит. Это также помогает мне помнить, что нотация Big-O пытается приблизить время выполнения как функцию N, количество входов.

TA объяснил это чем-то вроде комбинаций. Как это n выберите 2 = (n (n-1))/2 = n^2 + 0.5, затем удалите константу, чтобы она стала n^2. Я могу поставить значения теста int и попробуйте, но как эта комбинация приходит?

Как я уже сказал, нормальный big-O-худший сценарий. Вы можете попытаться подсчитать количество раз, когда каждая строка выполняется, но проще просто посмотреть на первый пример и сказать, что есть две петли по длине n, одна встроена в другую, поэтому это n * n. Если бы они были один за другим, это было бы n + n, равное 2n. Поскольку это приближение, вы просто говорите n или линейное.

Что делать, если есть ли заявление? Как определяется сложность?

вот где для меня средний случай и лучший случай помогает много для организации моих мыслей. В худшем случае вы игнорируете if и говорите n^2. В среднем случае, например, у вас есть цикл над n, с другим циклом над частью n, который происходит в половине случаев. Это дает вам n * n/x / 2 (x-это любая часть n, зацикленная в ваших встроенных циклах. Это дает вам n^2/(2x), поэтому вы получите n^2 только тот же. Это потому, что его приближение.

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

Как было сказано в ответах выше моего, это явно невозможно определить для всех фрагментов кода; я просто хотел добавить идею использования среднего случая Big-O к обсуждению.


для первого фрагмента это просто n^2, потому что вы выполняете n операций n раз. Если j инициализирован i и подошел к i, объяснение, которое вы разместили, было бы более уместным, но в его нынешнем виде это не так.

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

рекурсивные уравнения (включая третий фрагмент) могут быть записаны как таковые: третий будет отображаться как

T(n) = T(n-1) + 1

что мы можем легко увидеть, это O (n).


Big-O - это просто аппроксимация, она не говорит, сколько времени требуется алгоритму для выполнения, она просто говорит что-то о том, сколько времени требуется, когда размер его ввода растет.

поэтому, если вход имеет размер N и алгоритм оценивает выражение постоянной сложности: O(1) N раз, сложность алгоритма линейна: O (N). Если выражение имеет линейную сложность, алгоритм имеет квадратичную сложность: O (N*N).

некоторые выражения имеют экспоненциальный сложность: O(N^N) или логарифмическая сложность: O (log N). Для алгоритма с циклами и рекурсией умножьте сложности каждого уровня цикла и / или рекурсии. С точки зрения сложности, цикл и рекурсия эквивалентны. Алгоритм, который имеет разные сложности на разных этапах алгоритма, выбирает самую высокую сложность и игнорирует остальные. И, наконец, все постоянные сложности считаются эквивалентными: O(5) совпадает с O(1), O(5*N) совпадает с O(N).