Определение сложностей по заданным кодам - PullRequest
34 голосов
/ 25 октября 2011

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

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

ТА объяснил это чем-то вроде комбинаций. Как это, 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 ]

69 голосов
/ 03 ноября 2011

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

Внимание! Стена текста входящего!

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 равен O (1), временная сложность алгоритма будет определяться тем, сколько раз мы выполняем эту строку. Ну, как упоминалось в вашей TA, мы делаем это, рассматривая комбинации в данном случае. Внутренний цикл будет выполняться (N + (N-1) + ... + 1) раз, всего (N + 1) * N / 2.

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

Теперь для более сложных случаев мы можем получить больше математики. Попытайтесь создать функцию, значение которой представляет, сколько времени занимает алгоритм, учитывая размер 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) <= 2 ^ n для всех N (т. Е. T (N) равно O (2 ^ n)) </strong>

  • базовый случай: 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

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

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

И в качестве последнего замечания я хотел бы указать на Основную теорему , единственное правило для более сложных проблем повторения, которое я могу себе представить, которое обычно используется. Используйте его, когда вам нужно разобраться с хитрым алгоритмом «разделяй и властвуй».


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

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++) { ... }
    }
}

Имеет то же время выполнения, что и

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).

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

14 голосов
/ 03 ноября 2011

Как правило, определение сложности алгоритма теоретически невозможно.

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

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++;
    }
}

Поскольку строка System.out.println на самом деле не имеет значения, давайте удалим ее:

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 (n 2 ) сложностью :) Это есть в коде:)

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

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 - само число Фибоначчи.

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

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;
}

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

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
2 голосов
/ 09 ноября 2011

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

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

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

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

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

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

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

2 голосов
/ 25 октября 2011

Несмотря на то, что это чрезмерное обобщение, мне нравится думать о 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), я бы указал на более элегантное объяснение здесь .

0 голосов
/ 07 ноября 2011

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).

0 голосов
/ 25 октября 2011

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

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

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

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

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

...