什么因素会导致算法具有O(log n)的复杂度?

113

我对大 O 记号的了解有限,当等式中出现对数项时,我更加困惑。

有人能否用简单的语言解释一下 O(log n) 算法是什么?对数从哪里来?

这特别是在我试图解决这个期中练习题时出现:

让 X(1..n) 和 Y(1..n) 分别包含两个整数列表,每个列表都按非降序排序。给出一种 O(log n) 时间复杂度算法,以找到所有 2n 个元素的中位数(或第n个最小的整数)。例如,X = (4,5,7,8,9),Y = (3,5,8,9,10),那么 7 就是组合列表 (3,4,5,5,7,8,8,9,9,10) 的中位数。[提示:使用二分搜索的概念]


31
O(log n)可以理解为:如果您将问题规模 n 增加一倍,您的算法只需要增加常数步骤即可。 - phimuemue
1
我在想为什么上面的示例中7是中位数,顺便说一下,它也可以是8。这不是一个很好的示例,是吗? - stryba
14
O(log(n)) 算法的一个好方法是,在每一步中将问题的大小缩小一半。以二分查找为例,每一步都会检查搜索范围中间的值,将范围分成两半;然后你会从搜索范围中消除其中一半,并将另一半作为下一步的搜索范围。因此,在每一步中,搜索范围的大小减半,这就是该算法的 O(log(n)) 复杂度。(缩小并不一定要恰好为一半,可以是三分之一、四分之一、任何常数百分比;但通常是一半)。 - Krzysztof Kozielczyk
@stryba,我们有10个数字,因此中位数是两个中间数字的平均值。(7+8 = 15,15/2 = 7) - Hengameh
我认为这会帮助你理解:https://dev59.com/q3E95IYBdhLWcg3wb9hb#13093274 - 2cupsOfTech
显示剩余2条评论
6个回答

311

我必须承认,第一次看到O(log n)算法时感觉很奇怪...这个对数从哪里来的呢?然而,事实证明,在大O符号表示法中,有几种不同的方法可以出现对数项。以下是其中几种:

反复除以常数

取任意数字n,比如16。你可以将n除以2多少次,直到得到一个小于或等于1的数字?对于16,我们有

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

请注意,这最终需要四个步骤才能完成。有趣的是,我们还有log2 16 = 4。嗯...那128呢?
128 / 2 = 64
 64 / 2 = 32
 32 / 2 = 16
 16 / 2 = 8
  8 / 2 = 4
  4 / 2 = 2
  2 / 2 = 1

这需要七个步骤,log2 128 = 7。这是巧合吗?不是!有一个很好的原因。假设我们将一个数n除以2 i次。那么我们得到数字n / 2i。如果我们想解出这个值最多为1的i的值,我们得到:
n / 2i ≤ 1 n ≤ 2i log2 n ≤ i
换句话说,如果我们选择一个整数i,使得i ≥ log2 n,则在将n除以一半i次后,我们会得到一个最多为1的值。保证这样的最小i大约是log2 n,因此,如果我们有一个算法,它通过除以2来使数字变得足够小,那么我们可以说它在O(log n)步内终止。
重要的细节是,无论你用什么常数来除以n(只要它大于1),如果你除以常数k,它将需要logkn步骤才能到达1。因此,任何反复将输入大小除以某个分数的算法都需要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,它是一个很好的例子。

逐位处理数值

一个十进制数n有多少个数字呢?如果这个数有k个数字,那么最大的数字是10的k次幂的某个倍数。最大的k位数是999...9,有k个9,等于10的k+1次幂减1。因此,如果我们知道n有k个数字,那么我们知道n的值最多为10的k+1次幂减1。如果我们想要解出k关于n的式子,我们得到:

n ≤ 10k+1 - 1

n + 1 ≤ 10k+1

log10 (n + 1) ≤ k + 1

(log10 (n + 1)) - 1 ≤ k

由此我们得出k大约是n的以10为底的对数。换句话说,n的位数是O(log n)。

例如,让我们考虑添加两个太大而无法适应机器字的大数字的复杂性。假设我们用十进制表示这些数字,并将这些数字称为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

接下来,我们添加倒数第三位(“antepenultimate”)数字:

       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})。请注意保留html标记。
许多算法在某些基数中每次处理一位数字,因此它们的复杂度中会有一个O(log n)项。一个经典的例子是基数排序,它逐位对整数进行排序。基数排序有许多不同的变体,但通常的时间复杂度为O(n log U),其中U是被排序的最大可能整数。这是因为排序的每个步骤需要O(n)的时间,并且总共需要O(log U)次迭代来处理被排序的最大数字的O(log U)位数字。许多高级算法,例如Gabow的最短路径算法Ford-Fulkerson最大流算法的比例版本,其复杂度中有一个log项,因为它们逐位处理数字。
关于你的第二个问题,如何解决这个问题,你可能想看看这个相关的问题,它探讨了更高级的应用。鉴于这里描述的问题的一般结构,当你知道结果中有一个对数项时,现在你可以更好地思考问题的方式,所以我建议在考虑清楚之前不要看答案。

10
当我们谈论大O描述时,通常是在谈论解决给定大小的问题所需的时间。通常情况下,对于简单问题,该大小仅由输入元素的数量表示,并且通常称为n或N。(显然并非总是如此——用图形表达的问题通常以顶点数V和边数E表示;但现在,我们将讨论具有N个列表对象的列表。)
我们说一个问题“是(某个N的函数)的大O”,当且仅当:
对于所有N > 某个任意N0,都存在一些常数c使得算法的运行时间小于该常数c乘以(某个N的函数)。
换句话说,不要考虑设置问题的“固有开销”很小的问题,而要考虑大问题。当考虑大问题时,“(某个N的函数)的大O”意味着运行时间始终小于某个常数乘以该函数。总是。
简而言之,该函数直至常数因子为上界。
因此,“log(n)的大O”意味着与上述内容相同,只是“(某个N的函数)”被替换为“log(n)”。
因此,您的问题要求您考虑二分查找,因此让我们考虑一下。假设您有一个按升序排列的N个元素的列表。您想要找出某个给定的数字是否存在于该列表中。一种非二分查找的方法是扫描列表的每个元素并查看它是否是您的目标数字。您可能会很幸运,在第一次尝试时就找到它。但在最坏情况下,您需要检查N次。这不是二分查找,也不是log(N)的大O,因为没有办法将其强制符合我们上面概述的标准。您可以将任意常数选择为c=10,如果您的列表有N = 32个元素,则没问题:10*log(32) = 50,大于32的运行时间。但如果N=64,则10*log(64)=60,小于64的运行时间。您可以选择c=100、1000或无限大的数字,但仍然可以找到一些违反该要求的N。换句话说,没有N_0。
但是,如果我们进行二进制搜索,就会选择中间元素并进行比较。然后我们丢弃一半的数字,再重复此过程,直到完成。如果N=32,您只能进行约5次操作,即log(32)次。如果N=64,则只能进行约6次操作等等。现在,您可以选择那个任意常数c,以使该要求始终对大的N值成立。
通过以上背景知识,O(log(N))通常意味着您有一种简单的方法,可以将问题规模减半,就像上面的二进制搜索一样。一旦将问题减半,您就可以再次将其分成两个问题,再次分割,如此反复。但至关重要的是,您不能进行预处理步骤,这需要超过O(log(N))的时间。例如,您不能将两个列表混洗成一个大列表,除非您能够以O(log(N))的时间找到一种方法来实现。
(注意:几乎总是,Log(N)表示以2为底的对数,这也是我上面所假设的。)

4
在以下解决方案中,所有包含递归调用的行都在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
算法复杂度分析中经常出现对数术语。以下是一些解释:

1. 如何表示数字?

假设有数字X = 245436。这种“245436”的表示法中含有隐含信息。将该信息显式化:

X = 2 * 10 ^ 5 + 4 * 10 ^ 4 + 5 * 10 ^ 3 + 4 * 10 ^ 2 + 3 * 10 ^ 1 + 6 * 10 ^ 0

这是该数字的十进制展开式。因此,表示该数字所需的最少信息6位数字。这不是巧合,因为任何小于10^d的数字都可以用d位数字表示。

那么需要多少位数字来表示X?等于X中10的最大指数加1。

==> 10 ^ d > X
==> log (10 ^ d) > log(X)
==> d* log(10) > log(X)
==> d > log(X) // 并且log再次出现...
==> d = floor(log(x)) + 1

还要注意,这是表示此范围内数字最简洁的方式。任何缩减都会导致信息丢失,因为缺少的数字可以映射到其他10个数字。例如:12 * 可以映射到120、121、122、……、129。

2. 如何在(0,N-1)中搜索数字?

取N = 10^d,我们使用最重要的观察结果:

唯一标识0到N-1之间的值所需的最小信息量= log(N)位数。

这意味着,当要求在从0到N-1的整数线上搜索数字时,我们需要至少log(N)次尝试才能找到它。为什么?任何搜索算法都需要在搜索数字时逐个选择数字。
它需要选择的最小位数是log(N)。因此,在大小为N的空间中搜索数字所需的最小操作次数为log(N)。
你能猜出二分查找、三分查找或十分查找的时间复杂度吗?它是O(log(N))!
3.如何对一组数字进行排序?
当要求将一组数字A排序为数组B时,情况如下-> 每个原始数组中的元素都必须映射到已排序数组中的相应索引。因此,对于第一个元素,我们有n个位置。为了正确地在0到n-1的范围内找到相应的索引,我们需要... log(n)操作。
下一个元素需要log(n-1)个操作,下一个需要log(n-2),以此类推。总共需要的操作数为:
==> log(n) + log(n - 1) + log(n - 2) + … + log(1)
使用log(a) + log(b) = log(a * b),
==> log(n!)
这可以近似为nlog(n) - n。即O(n*log(n))!
因此,我们得出结论,没有比O(n*log(n))更好的排序算法。一些具有这种复杂度的算法是流行的归并排序和堆排序!
这些是我们在算法复杂性分析中经常看到log(n)的原因之一。同样可以扩展到二进制数字。我在这里制作了一个视频。
干杯!

2
我们称基于对n进行迭代的解决方案时间复杂度为O(log n),其中每次迭代完成的工作量是前一次迭代的一部分,因此算法朝着解决方案的方向进行。请注意保留HTML标签,并简化内容以便更易理解。

1

暂时无法评论...这是个死灵法师! Avi Cohen的回答是不正确的,请尝试:

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

没有任何一个条件为真,因此MEDIAN(X, p, q, Y, j, k)将切断两个5。这些是非递减序列,不是所有的值都是不同的。

还可以尝试具有不同值的偶数长度示例:

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接