Что может привести к сложности алгоритма O (log n)?

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

Может кто-нибудь может объяснить мне простыми словами, что такое алгоритм O(log n)? Откуда берется логарифм?

Это особенно пришло, когда я пытался решить этот средний практический вопрос:

Пусть X (1..n) и Y (1..n) содержат два списка целых чисел, каждый из которых сортируется в неубывающем порядке. Дайте алгоритму O (log n) -time, чтобы найти медиану (или nth наименьшее целое) всех 2n объединенных элементов. Для ex X = (4, 5, 7, 8, 9) и Y = (3, 5, 8, 9, 10), то 7 является медианой объединенного списка (3, 4, 5, 5, 7, 8, 8, 9, 9, 10). [Подсказка: используйте понятия бинарного поиска]

Ответы

Ответ 1

Я должен согласиться, что это довольно странно в первый раз, когда вы видите алгоритм O (log n)... откуда на самом деле возникает этот логарифм? Тем не менее, оказывается, что существует несколько различных способов, с помощью которых вы можете получить логарифмический термин, который будет отображаться в примечаниях с большим О. Вот несколько:

Повторное деление на константу

Возьмем любое число n; скажем, 16. Сколько раз вы можете разделить n на два, прежде чем вы получите число, меньшее или равное одному? Для 16 имеем, что

16 / 2 = 8
 8 / 2 = 4
 4 / 2 = 2
 2 / 2 = 1

Обратите внимание, что это заканчивается для выполнения четырех шагов. Интересно, что у нас также есть log 2 16 = 4. Хммм... как насчет 128?

128 / 2 = 64
 64 / 2 = 32
 32 / 2 = 16
 16 / 2 = 8
  8 / 2 = 4
  4 / 2 = 2
  2 / 2 = 1

Это заняло семь шагов, а log 2 128 = 7. Это совпадение? Неа! Для этого есть веская причина. Предположим, что мы делим число n на 2 я раз. Тогда получим число n/2 i. Если мы хотим решить для значения i, где это значение не более 1, получим

n/2 i & le; 1

n & le; 2 я

log 2 n & le; я

Другими словами, если мы выберем целое число я такое, что я & ge; log 2 n, то после деления n на половину я раз мы будем иметь значение, самое большее 1. Наименьший i, для которого это гарантировано, составляет примерно log 2 n, поэтому, если у нас есть алгоритм, который делит на 2 до тех пор, пока число не станет достаточно малым, мы можем сказать, что он заканчивается шагами O (log n).

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

Итак, откуда это? Один классический пример - двоичный поиск - быстрый алгоритм поиска отсортированного массива для значения. Алгоритм работает следующим образом:

  • Если массив пуст, верните, что этот элемент отсутствует в массиве.
  • В противном случае:
    • Посмотрите на средний элемент массива.
    • Если он равен элементу, который мы ищем, верните успех.
    • Если это больше, чем элемент, который мы ищем:
      • Отбросьте вторую половину массива.
      • Повторите
    • Если это меньше, чем элемент, который мы ищем:
      • Отбросьте первую половину массива.
      • Повторите

Например, для поиска 5 в массиве

1   3   5   7   9   11   13

Сначала мы рассмотрим средний элемент:

1   3   5   7   9   11   13
            ^

Так как 7 > 5, и поскольку массив отсортирован, мы знаем, что число 5 не может быть в задней половине массива, поэтому мы можем просто отказаться от него. Это оставляет

1   3   5

Итак, теперь мы рассмотрим средний элемент здесь:

1   3   5
    ^

Так как 3 < 5, мы знаем, что 5 не может появиться в первой половине массива, поэтому мы можем выбросить первый массив половин, чтобы оставить

        5

Снова посмотрим на середину этого массива:

        5
        ^

Так как это именно то число, которое мы ищем, мы можем сообщить, что 5 действительно находится в массиве.

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

Алгоритмы, следуя общей методике divide-and-conquer (разрезаем проблему на куски, решая эти части, а затем сведение проблемы вместе) имеют тенденцию иметь логарифмические термины в них по той же самой причине - вы не можете разрезать какой-либо объект наполовину больше, чем O (log n) раз. Вы можете посмотреть merge sort как отличный пример.

Обработка значений по одной цифре за раз

Сколько цифр в базовом номере 10? Ну, если в номере есть k цифр, тогда у нас будет самая большая цифра, кратная 10 k Наибольшее k-значное число составляет 999... 9, k раз, и это равно 10 k + 1 - 1. Следовательно, если мы знаем, что n имеет k цифр в нем, то мы известно, что значение n не превышает 10 k + 1 - 1. Если мы хотим решить для k в терминах n, получим

n & le; 10 k + 1 - 1

n + 1 & le; 10 к + 1

log 10 (n + 1) & le; k + 1

(log 10 (n + 1)) - 1 & le; к

Из которого получаем, что k приблизительно является логарифмом базы 10. Другими словами, число цифр в n равно O (log n).

Например, подумайте о сложности добавления двух больших чисел, которые слишком велики, чтобы вписаться в машинное слово. Предположим, что мы имеем числа, представленные в базе 10, и будем называть номера m и n. Один из способов добавить их - через метод школьной школы - записывать цифры из одной цифры за раз, а затем работать справа налево. Например, чтобы добавить 1337 и 2065, мы начнем с написания чисел как

    1  3  3  7
+   2  0  6  5
==============

Мы добавляем последнюю цифру и нести 1:

          1
    1  3  3  7
+   2  0  6  5
==============
             2

Затем мы добавим вторую-последнюю ( "предпоследнюю" ) цифру и нести 1:

       1  1
    1  3  3  7
+   2  0  6  5
==============
          0  2

Затем добавим цифру от третьего до последнего ( "антепенультитум" ):

       1  1
    1  3  3  7
+   2  0  6  5
==============
       4  0  2

Наконец, мы добавляем четвертый-последний ( "preantepenultimate"... я люблю английский):

       1  1
    1  3  3  7
+   2  0  6  5
==============
    3  4  0  2

Теперь, сколько мы работали? Мы выполняем в общей сложности O (1) работу на цифру (т.е. Постоянный объем работы), и есть полные цифры O (max {log n, log m}), которые необходимо обработать. Это дает общую сложность O (max {log n, log m}), потому что нам нужно посещать каждую цифру в двух числах.

Многие алгоритмы получают в них термин O (log n) от работы по одной цифре за раз в некоторой базе. Классическим примером является radix sort, который сортирует целые числа по одной цифре за раз. Существует много разновидностей сортировки radix, но они обычно запускаются во времени O (n log U), где U - наибольшее возможное целое, которое сортируется. Причиной этого является то, что каждый проход сортировки принимает время O (n), и есть всего итераций O (log U), необходимых для обработки каждой из O (log U) цифр наибольшего числа, которое сортируется. Многие передовые алгоритмы, такие как алгоритм кратчайших путей Габова или масштабирующая версия Алгоритм Max-flow от Ford-Fulkerson, имеют логарифмический термин в своей сложности, потому что они работают по одной цифре за раз.


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

Надеюсь, это поможет!

Ответ 2

Когда мы говорим о больших описаниях О, мы обычно говорим о времени, которое требуется для решения проблем определенного размера. И обычно для простых задач этот размер просто характеризуется количеством входных элементов и обычно называется n или N. (Очевидно, что это не всегда верно - проблемы с графами часто характеризуются числами вершин V и количество ребер, E, но пока мы поговорим о списках объектов, в которых есть N объектов в списках.)

Мы говорим, что проблема "большая-О (некоторая функция от N)" тогда и только тогда, когда:

Для всех N > произвольных N_0 существует некоторая константа c, такая, что время выполнения алгоритма меньше этой константы c times (некоторая функция N.)

Другими словами, не думайте о небольших проблемах, где "постоянные накладные расходы" для решения проблемы важны, подумайте о больших проблемах. И когда мы думаем о больших проблемах, большой-Oh (некоторая функция N) означает, что время выполнения все равно всегда меньше, чем некоторое постоянное время, которое функционирует. Всегда.

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

Итак, "big-Oh log (n)" означает то же самое, что я сказал выше, за исключением того, что "некоторая функция N" заменена на "log (n)".

Итак, ваша проблема говорит вам подумать о бинарном поиске, поэтому подумайте об этом. Предположим, у вас есть, скажем, список из N элементов, отсортированных в порядке возрастания. Вы хотите узнать, существует ли в этом списке определенный номер. Один из способов сделать то, что не является бинарным, - это просто сканировать каждый элемент списка и посмотреть, является ли он вашим целевым номером. Вам может повезти и найти его с первой попытки. Но в худшем случае вы проверите N разное время. Это не бинарный поиск, и он невелик-Oh log (N), потому что нет способа заставить его в критерии, которые мы набросали выше.

Вы можете выбрать эту произвольную константу c = 10, и если ваш список содержит N = 32 элемента, вы в порядке: 10 * log (32) = 50, что больше, чем время выполнения 32. Но если N = 64, 10 * log (64) = 60, что меньше времени выполнения 64. Вы можете выбрать c = 100, или 1000, или gazillion, и вы все равно сможете найти N, который нарушает это требование. Другими словами, нет N_0.

Если мы выполняем двоичный поиск, мы выбираем средний элемент и делаем сравнение. Затем мы выбрасываем половину чисел и делаем это снова, и снова, и так далее. Если ваш N = 32, вы можете сделать это примерно 5 раз, что является журналом (32). Если ваш N = 64, вы можете сделать это примерно 6 раз и т.д. Теперь вы можете выбрать эту произвольную константу c таким образом, чтобы требование всегда выполнялось для больших значений N.

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

(ПРИМЕЧАНИЕ. Почти всегда, Log (N) означает log-base-two, что я и предполагаю выше.)

Ответ 3

В следующем решении все линии с рекурсивным вызовом выполняются по половину заданных размеров суб-массивов X и Y. Другие линии выполняются в постоянное время. Рекурсивная функция T (2n) = T (2n/2) + c = T (n) + c = O (lg (2n)) = O (lgn).

Вы начинаете с MEDIAN (X, 1, n, Y, 1, n).

MEDIAN(X, p, r, Y, i, k) 
if X[r]<Y[i]
    return X[r]
if Y[k]<X[p]
    return Y[k]
q=floor((p+r)/2)
j=floor((i+k)/2)
if r-p+1 is even
    if X[q+1]>Y[j] and Y[j+1]>X[q]
        if X[q]>Y[j]
            return X[q]
        else
            return Y[j]
    if X[q+1]<Y[j-1]
        return MEDIAN(X, q+1, r, Y, i, j)
    else
        return MEDIAN(X, p, q, Y, j+1, k)
else
    if X[q]>Y[j] and Y[j+1]>X[q-1]
        return Y[j]
    if Y[j]>X[q] and X[q+1]>Y[j-1]
        return X[q]
    if X[q+1]<Y[j-1]
        return MEDIAN(X, q, r, Y, i, j)
    else
        return MEDIAN(X, p, q, Y, j, k)

Ответ 4

Не могу комментировать... necro это! Ответ Avi Cohen неверен, попробуйте:

X = 1 3 4 5 8
Y = 2 5 6 7 9

Ни одно из условий не является истинным, поэтому MEDIAN (X, p, q, Y, j, k) сократит и пять. Это неубывающие последовательности, не все значения различны.

Также попробуйте этот пример с четными значениями с различными значениями:

X = 1 3 4 7
Y = 2 5 6 8

Теперь MEDIAN (X, p, q, Y, j + 1, k) разрежет четыре.

Вместо этого я предлагаю этот алгоритм, назовите его с помощью MEDIAN (1, n, 1, n):

MEDIAN(startx, endx, starty, endy){
  if (startx == endx)
    return min(X[startx], y[starty])
  odd = (startx + endx) % 2     //0 if even, 1 if odd
  m = (startx+endx - odd)/2
  n = (starty+endy - odd)/2
  x = X[m]
  y = Y[n]
  if x == y
    //then there are n-2{+1} total elements smaller than or equal to both x and y
    //so this value is the nth smallest
    //we have found the median.
    return x
  if (x < y)
    //if we remove some numbers smaller then the median,
    //and remove the same amount of numbers bigger than the median,
    //the median will not change
    //we know the elements before x are smaller than the median,
    //and the elements after y are bigger than the median,
    //so we discard these and continue the search:
    return MEDIAN(m, endx, starty, n + 1 - odd)
  else  (x > y)
    return MEDIAN(startx, m + 1 - odd, n, endy)
}

Ответ 5

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