如何理解算法时间复杂度的表示法,例如 O(n²)、O(n)、O(1)、O(nlogn) 等?

关注者
3,284
被浏览
748,888

60 个回答

一个程序在写出来之前是无法准确估计实际运行时间的。但是几乎所有算法竞赛的任务都会告知输入数据的规模和运行时间限制,这就允许选手通过分析算法的时间复杂度,从而事先估计能否在限定的时间内运行完程序。

下面一个例子也许可以让大家了解各种时间复杂度。为了使读者更加容易理解(初中水平的数学知识就能读懂,没有用到比较高级的数学工具),失去了部分严谨性,但足以运用到程序设计竞赛中。

例题:区间最大和

luogu.com.cn/problem/P5

给定 n 个正整数组成的数列 a_1,a_2,\cdots ,a_n 和一个整数 M 。要求从这个数列中找到一个子区间 [i,j] ,也就是在这个数列中连续的数字 a_i,a_{i+1},\cdots ,a_{j-1},a_j ,使得这个子区间的和在不超过 M 的情况下最大。输出 ij 和区间和。如果有多个区间符合要求,请输出 i 最小的那一个。对于所有测试数据, a_i\le 10^5 , M\le10^9

例如,当输入是:

5 10
2 3 4 5 6

应当输出 1 3 9。

子任务 1(10分): n\le 200

子任务 2(20分): n\le 3000

子任务 3(30分): n\le 10^5

子任务 4(40分): n\le 4\times 10^6

请读者先尝试独立思考并完成这个题目,先不必考虑子任务是什么(实际上这很重要)。

什么是 O(n³) 算法?

思路 1:最直接的思路就是枚举所有子区间头尾 i 和 j,然后对 i 和 j 里面的所有数字累加起来求和,然后判断是否在不大于 M 的情况下最大。代码非常基础:

#include<iostream>
using namespace std;
int a[10000150];
int main() {
    int n, M;
    int ansmax = 0, ansi, ansj;
    cin >> n >> M;
    for (int i = 1; i <= n; i++)cin >> a[i];
    for (int i = 1; i <= n; i++)
        for (int j = i; j <= n; j++) {
            int sum = 0;
            for (int k = i; k <= j; k++)sum += a[k];
            if (sum <= M && sum > ansmax)
                ansmax = sum, ansi = i, ansj = j;
        }
    cout << ansi << ' ' << ansj << ' ' << ansmax << endl;
    return 0;
}

那这个程序运行要花多久呢?显然程序的运算速度和输入规模 n 有关,毕竟处理越多的数据就越耗时,在后面会给出实验结果。不过现在,就可以根据这份代码估计一下它要运行多少次语句。

这份代码中,当 n 比较大时,sum+=a[k] 这句就要运行很多遍(看到了三重循环了吗)。对于给定的 n ,这个语句一共要运行多少次呢?可以在这个语句后面加一个全局计数器变量,每运行一次就加 1,最后输出这个计数器得到这个语句的运行次数。

当然还可以通过数学推导的方式得到这个数字。假设 n=4,考虑到 i 和 j 是枚举所有子区间。当 i=1 时子区间的长度是 1,2,3,4;当 i=2 时子区间长度是 1,2,3;当 i=3 时子区间长度是 1,2;当 i=4 时子区间长度是 1。所以可以写下这样的式子[^1]:

T(n)=(1+2+3+4)+(1+2+3)+(1+2)+(1)

=\frac{1\times(1+1)}{2}+\frac{2\times(1+2)}{2}+\frac{3\times(1+3)}{2}+\frac{4\times(1+4)}{2} (等差数列求和公式)

=\frac{1}{2}((1^2+2^2+3^2+4^2)+(1+2+3+4))

=\frac{1}{2}(\frac{1}{6}n(n+1)(2n+1)+\frac{1}{2}n(n+1)) (平方和求和公式,其中 n=4)

=\frac{1}{6}(n^3+3n^2+2n) (化简)

对于其他的 n ,这个多项式也是成立的。其中 n^3 对这个多项式的值有主导作用,因为 n 每扩大 10 倍, n^2 会扩大 100 倍,而 n^3 可以扩大 1000 倍!因此当 n 比较大(100 以上)时, n^2nT(n) 的影响就很小了。而系数 \frac{1}{6} 不会影响到这个多项式的值的增长速度,所以也不用管。仅需关注指数最大的那一项,可以说这个算法的时间复杂度O(n^3) 的。

这有什么用吗?一般的家用计算机每秒可以进行数千万到数亿( 10^8 数量级)的运算。当 n=3000 时,运算量是 n^3=2.7\times10^{10} 这个数量级的,显然无法在 1 秒中运行出结果。虽然这个运算次数的估算并不精确,但是通过时间复杂度分析就可以知道这个算法就已经无法完成第二个子任务了。

[^1]: 严谨起见本应当使用求和符号,但是还是不够直观

什么是 O(n²) 算法?

思路 2:枚举每次区间,计算区间和是很浪费时间的。使用前缀和可以很快速度求出区间 [i,j] 之间的序列和。

令 s[i] 为 a[1] 到 a[i] 之间所有数字的和,特殊地,s[0]=0。可以根据 s[i]=s[i-1]+a[i] ,进行一次循环来计算前缀和 s 数组。那么 a[i]+a[i+1]+...+a[j-1]+a[j] 就等于 s[j]-s[i-1](请尝试证明)。根据这个思路,可以写出如下改进代码:

#include<iostream>
using namespace std;
int a[10000100], s[10000100];
int main() {
    int n, M;
    int ansmax = 0, ansi, ansj;
    cin >> n >> M;
    for (int i = 1; i <= n; i++)cin >> a[i];
    s[0] = 0;
    for (int i = 1; i <= n; i++)s[i] = s[i - 1] + a[i];
    for (int i = 1; i <= n; i++)
        for (int j = i; j <= n; j++) {
            int sum = s[j] - s[i - 1];
            if (sum <= M && sum > ansmax)
                ansmax = sum, ansi = i, ansj = j;
        }
    cout << ansi << ' ' << ansj << ' ' << ansmax << endl;
    return 0;
}

可以发现由原来的三重循环简化成了二重循环,原来运行次数最多的 sum+=a[k] 不见了。运行次数最多的就是内循环的语句。这段语句运行总次数就好求多了,根据等差数列求和公式,直接给出答案: T(n)=\frac{1}{2}n(n+1) ,只保留最高指数项,得到这个算法的时间复杂度是 O(n^2)

不过对所有的程序进行数学推导运行次数还是很麻烦的,因此可以使用定性的方式估算时间复杂度。对于思路 1 中的算法,是由 3 重循环组成的。第一重循环 i 要运行 n 次,第二重循环 j 要运行 n-i+1 次,第三重循环要运行 j-k+1 次;其中的 i、j 和 k 都与 n 同阶(与 n 的增长速度相同);三重循环的运行次数都与 n 同阶,所以得到算法时间复杂度是 O(n^3) 的结论[^2]。同理思路 2 有两重和 n 同阶的循环,所以时间复杂度是 O(n^2)

对于子任务 2,计算次数 n^2=9\times10^6 的数量级,相比于 10^8 还有不少余地,因此是可以接受的算法。但是对于子任务 3, n^2=10^{10} 又太大,这个算法就无法胜任了。

[^2]: 初学时可以认为每多一重循环,复杂度就要乘上一个 n,但是在很多情况下并不是这样的。

什么是 O(nlogn) 算法?

思路 3:还能再快一点吗?固定区间左端点 i,右端点越往右面,区间和就越大。那么可以找到一个右端点 j,从 a[i] 到 a[j] 的累加和不大于 M,但是再往右边加哪怕一个就会超过 M。有没有想到什么?这个问题的解存在单调性(s 数组肯定是从小到大的),因此可以二分!

枚举所有的 i,对于给定的 i,要求 s[j]-s[i-1]<=M。这需要找到最大的 j,使 s[j]<=s[i-1]+M 即可。也就是说在 s 数组中通过二分查找中找到 s[i-1]+M 这个数字,并返回其数组下标。根据本书《二分》一章中介绍的 lower_bound() 的特性,如果没找到这个数字,就返回比它大一点的数字的数组下标减 1,使其小于这个数字。代码如下:

#include<iostream>
using namespace std;
int a[8000010];
long long s[8000010];
long long n, M;
int find(long long x) {
    int l = 1, r = n + 1;
    while (l < r) {
        int mid = l + (r - l) / 2;
        if (s[mid] >= x)r = mid;
        else l = mid + 1;
    }
    if (s[l] == x)return l;
    else return l - 1;
}
int main() {
    long long ansmax = 0, ansi, ansj;
    cin >> n >> M;
    for (int i = 1; i <= n; i++)cin >> a[i];
    s[0] = 0;
    for (int i = 1; i <= n; i++)s[i] = s[i - 1] + a[i];
    for (int i = 1; i <= n; i++) {
        long long x = s[i - 1] + M;
        int j = find(x);
        long long sum = s[j] - s[i - 1];
        if (sum <= M && sum > ansmax)
            ansmax = sum, ansi = i, ansj = j;
    }
    cout << ansi << ' ' << ansj << ' ' << ansmax << endl;
    return 0;
}

由于所有 a_i 的和比较大,这里要考虑使用 long long 类型防止运算溢出。find 函数是二分查找有序数组中指定数字的下标。

这个程序看起来中间部分也是只有一重循环,所以时间复杂度是 O(n) 吗?别忘了 find 函数也是要时间的。每次从长度为 n 的序列中二分查找到一个数字需要进行 \log_2{n} 次比较(和若干次赋值运算),所以实际的时间复杂度是 O(n\log{n}) 。在这里并不关心底数(这里是 2)是多少,因为即使是不同的底数,通过换底公式转换也只是常数倍数倍的差别。

前面三个思路中,读入和计算前缀和时间复杂度都是 O(n) 。由于存在运行次数多得多的其他语句,这两部分所消耗的时间可以忽略不计(需要注意的是 n\log{n} 还是比 n 大很多,虽然比 n^2 小)。因此子任务 3 可以轻松地解决,但是子任务 4 大约运算次数的数量级是 n\log_2{n}\approx 4\times10^6\times22 \approx10^8 ,非常接近运算速度的极限了,因此通过子任务 4 有危险(不是说一定通过不了,而是留下余地很小),因此还得改进算法。

什么是 O(n) 算法?

思路 4:还有更快的方式!维护一个队列,依次将这个数列的数字加入到队尾中(入队),直到加入下一个数字会导致队列的数字和会超过 M 为止。这里的队列就是一个子区间,记录这个子区间的所有元素和并更新答案。丢弃队列的队首(出队),然后继续将原数列的接下来几个元素入队,使队列中的数字和不超过 M,并更新答案……不断这么操作,直到所有的的元素都被丢弃为止。代码如下:

#include<iostream>
using namespace std;
long long a[8000010], s[8000010];
long long n, M;
int main() {
    long long ansmax = 0, sum = 0;
    int i = 1, j = 1, ansi, ansj; //i和j分别是队首和队尾指针,j指向区间的下一个数
    cin >> n >> M;
    for (int i = 1; i <= n; i++)cin >> a[i];
    while (i <= n) {
        while (j <= n && sum + a[j] <= M)
            sum += a[j], j++; //入队
        if (sum <= M && sum > ansmax)
            ansmax = sum, ansi = i, ansj = j - 1; //注意这里是减1
        sum -= a[i], i++; //出队
    }
    cout << ansi << ' ' << ansj << ' ' << ansmax << endl;
    return 0;
}

并不需要专门开立一个队列表示子区间并存储这些数字。因为进队序列就是输入数列的数字,所以只需要维护首尾两个指针即可。这里有两重循环,那么时间复杂度是 O(n^2) ?非也。所有元素的入队次数和出队次数都是 1 次,所以时间复杂度就是 O(n) 。这个程序效率很高了,当 n=4\times10^6 时,可以完成子任务 4。

经过分析可以得到:数据量越大,需要耗时越多,但是不同时间复杂度随数据量增长的速度是不一样的。有的算法对数据量增长比较敏感,数据量增长一点就会增加非常多的运算次数;有的算法对数据量增长不敏感,运行时间和数据规模呈线性关系。比 O(n^3 ) 还大的算法复杂度包括 O(2^n)O(n!) ,即使 n ​只有几十,其运算次数也是天文数字,这就是很多搜索回溯算法不能处理稍大规模数据的原因。

不同复杂度的运算次数随数据规模增长趋势

本题复杂度还能在优化吗?不能了,因为光读入步骤的算法时间复杂度都达到 O(n) ,因此复杂度没有优化的余地了。

但是运行速度还是可以再快一些,只是应当优化的瓶颈在于输入部分了。在本书《顺序结构程序设计》一章提到,使用 cin 的速度比较慢,因此使用 scanf 读入会加快速度(但是复杂度没变),甚至可以自己实现“快速读入”的黑科技。这些在不改变算法复杂度但可以加快程序运行速度的方法被称为常数优化

实际运行实验

经过理论分析,那实际情况是怎样的呢?可以亲自做个试验对比一下。首先按照不同的数据规模生成几组不同的测试数据,使用洛谷作为评测工具进行评测,设定超时时间为 20 秒,得到的结果如下:

可以发现结果符合之前的分析结果: O(n^3) 的效率特别低,运行时间和数据规模成立方关系; O(n^2) 效率好一点,运行时间和数据规模成平方关系; O(n\log{n})O(n) 的效率都很高,但是 O(n) 的效率更高。之所以 O(n) 算法相对于 O(\log{n}) 算法的效率没有 \log_2{n} 倍提升的原因是 O(n) 算法会自带常数 kk 难以定量求出,但是显然每多一条语句 k 就要大一些),在一定程度上抵消了和 log 级别相比的优势。

还有一点非常重要的是,使用 cin 语句输入大量的数字的速度是很慢的,因此这给 O(n) 算法乘上了巨大的常数,以至于使用 cin 的 O(n) 算法甚至还慢于使用读入优化的 O(n\log{n}) 算法。对于小规模数据,常数对运行时间的影响并不大,所以前面两种思路并没有加上快速读入优化(即使加上了,也照样通过不了大规模的测试数据);而当数据规模较大时,就不能不考虑常数带来的影响了。

常数优化对于进阶选手来说是很重要的技能。举一个浅显的例子,当 n=1000 时, \frac{1}{100}n^2<100n\log_2{n} ,当数据范围没那么大时,精心优化高复杂度的算法,使其常数降低,可能运行速度比具有大常数的低复杂度算法更快。当然这并不意味着时间复杂度分析失效。这部分内容学问很深,选手需要大量的经验才能感受到其中的平衡点,最终达到程序运行效率最高的效果。限于篇幅这里不具体介绍读入优化和常数优化的策略,请读者自行查阅相关资料。

读者可以发现,无论测试数据规模多小,运行程序都要花费几毫秒的时间。这是因为需要花费一些时间进行初始化工作(例如分配内存等资源),这些操作的时间和数据规模无关。如果一种算法的运算次数与数据规模无关,那么它的时间复杂度是常数级别的,写成 O(1) 。如果不使用循环语句,仅使用一些公式来计算答案,那很可能就是 O(1) 复杂度的算法。

通过上面的例子,读者应当知道了时间复杂度是什么和如何计算时间复杂度,并可以作为评价算法效率的重要参考。一些程序设计竞赛的题目会给出数据规模不同的子任务,这会暗示可能的复杂度,选手可以根据这些信息,选择合适的算法,并尽可能通过多的子任务。


知乎公式编辑器真™难用。

以上内容节选自《深入浅出程序设计竞赛-基础篇-附录》(高等教育出版社出版)。未经允许禁止转载。

如果希望获得其他的更有趣的算法学习内容,欢迎阅读本书。

~~~2018.6.21,6.23修改~~~~

如果要在定义上严格点儿的话,请看 @冒泡 的回答,以及 @invalid s的回答,如果想再形象点儿的话 @司马懿 的回答是很精彩的

~~~~原回答~~~~

1.动机

首先让我们先回顾下提出算法时间复杂度的动机:分析与比较完成同一个任务而设计的不同算法。

分析算法的结果意味着算法需要的资源,虽然有时我们关心像内存,通信带宽或者计算机硬件这类资源,但是通常我们想要度量的是计算时间。一般来说,通过分析求解某个问题的几种候选算法,我们可以选出一种最有效的算法。这种分析可能指出不止一个可行的候选算法,但是在这个过程中,我们往往可以抛弃几个较差的算法。——《算法导论》中文版第二章2.2分析算法

2.RAM模型

为了便于理论分析,我们需要对真实的计算机根据算法,执行命令,输出结果完成任务的过程进行建模。我们常用的是随机访问机模型(Random Access Machine, RAM)模型。在RAM模型中

i.指令一条接着一条执行(串行);

ii.指令包含了真实计算机的常见指令,比如,算数指令(加法,减法,乘法,除法,取余,向下取整,向上取整)、数据移入指令(装入,存储,复制)和控制命令(条件与无条件转移、子程序调用与返回);

iii.上述每条指令所用的时间均为常数。

我们定义一个算法在特定输入上的运行时间是指算法中执行指令操作的综述,根据RAM模型,我们总可以从一个算法的伪代码,写出其在给定输入规模n下的运行时间T(n)。也就是说,我们把一个算法的运行时间,描述成了其输入规模的函数。通过研究这个函数,我们就能够评估算法的时间复杂的。研究T(n),常用的一种策略是衡量当输入规模增大的情况下,T(n)的变化。

3.比较f(n)与g(n)

由2可知,我们总可以把算法的分析比较问题转化为不同函数的增长率比较的问题,而且这里函数值为正,自变量为自然数。接下来讨论的更多的是数学,而不是计算机。

研究函数的增长,常用的数学工具是微积分。冲动的读者,可能一言不合就要给f(n)求导,然后分析导数,然而一般来说,对于一个算法,我们的输入规模通常是自然数,所以求导不是个好的想法。但是求导之外,我们可以用微积分里最为基本的工具——极限来刻画两个函数f(n)与g(n)的增长快慢。具体说来,我们就是计算下面这个极限,来比较函数f(n)与g(n)的增长快慢

\lim_{n \rightarrow \infty} \frac{f(n)}{g(n)}

假定,我们设计的算法,总能使得上面的极限存在(不会出现那种反复震荡的情况)。

i.当上面的式子的极限为无穷大时,我们可以说,f(n)的增长速度比g(n)快,

ii.而与此相反,当上面的结果为0时,我们可以说,f(n)的增长速度比g(n)慢,记为 f(n)=o(g(n)),或者记作 f(n) \prec g(n)

iii.最后当上面的结果为一个不为零的正常数c时,我们可以说,f(n)的增长速度与g(n)是一样的,记为 f(n)=O(g(n))c=1 我们记作 f(n)\sim g(n)

4.Big Oh的含义

情况iii说明,任何一个运行时间函数 f(n) 都会属于某一个函数构成的集合 O(g(n)) ,比如 f_1(n)=3n^2+n,f_2(n)=n^2+n 都属于集合 O(n^2) ,而这个集合中最有代表性的函数就是 f(n)=n^2 。所以对于某一个运行时间函数,我们总可以找到一个形式上简单,但是在增长速率上和它一致的函数来代表它。 这就是 f(n)=O(g(n)) 的含义。于是,我们只要比较这些 有代表性的复杂度函数的增长快慢就可以了

5.几个有代表性的时间复杂度函数及其比较

几个有代表性的常见的时间复杂度函数如下 :

1,k\log n, n, n \log n, \log(n!), n^k, a^n, n!,n^n

其中 k\geq2,a>1 ,log n 代表其为自然对数,底为e(这是沿用数论里的记法)

而且,我们有如下关系

1 \prec k\log n \prec n \prec n \log n \sim \log(n!) \prec n^k \prec a^n \prec n! \prec n^n

证明:

i. \lim_{n \rightarrow \infty} \frac{1}{k\log n}=0\Rightarrow 1 \prec k\log n

ii. 因为 1 \leq \sqrt[n]{n} = n^{\frac{1}{n}} = (1\dots1\sqrt{n}\sqrt{n})^{\frac{1}{n}} \leq \frac{n-2+2\sqrt{n}}{n} < 1 + \frac{2}{\sqrt{n}}

所以 \lim_{n \rightarrow \infty} \sqrt[n]{n} =1

所以有极限的定义,我们知道 \exists N \in \mathbb{N}, n > N, \forall \epsilon > 0

0 < \sqrt[n]{n} -1 < e^\epsilon -1 \Rightarrow 1 < \sqrt[n]{n} < e^{\epsilon}

两边取对数

0 < \frac{\log n}{n} < \epsilon \Rightarrow \lim_{n \rightarrow \infty} \frac{\log n}{n} = 0 \Rightarrow \lim_{n \rightarrow \infty} \frac{k\log n}{n} = 0

iii. \lim_{n \rightarrow \infty} \frac{n}{n\log n}=0\Rightarrow n \prec n\log n

iv. 由 e^{x} = 1 + \sum_{n=1}^{\infty} \frac{x^n}{n!} \Rightarrow e^{n} \ge \frac{n^n}{n!} \Rightarrow n! \ge \frac{n^n}{e^n}

两边取对数

\log(n!) \ge n \log n -n

而同时,我们有

\log(n!) = \sum_{k=1}^{n} \log(k) \le \sum_{k=1}^{n} \log(n) = n \log n

综上,我们得到

n \log(n) - n \le \log(n!) \le n \log n \Rightarrow \lim_{n \rightarrow \infty} \frac{\log(n!)}{n \log n} =1 \Rightarrow n\log n \sim \log(n!)

v. \lim_{n \rightarrow \infty} \frac{n\log n}{n^k} = \lim_{n \rightarrow \infty} \frac{\log n}{n^{k-1}} = \lim_{n\rightarrow \infty} \frac{\frac{1}{k-1}\log n^{k-1}}{n^{k-1}} = 0

所以 n \log n \prec n^k

vi. 因为 a^n = (1+\lambda)^{n} = 1 + n \lambda + \frac{n(n-1)\lambda^2}{2} + \dots > \frac{n(n-1)\lambda^2}{2}

注意到 n > 2 \Rightarrow n^2 > 2n \Rightarrow \frac{n^2}{4} > \frac{n}{2} \Rightarrow \frac{n^2}{2} - \frac{n}{2} > \frac{n^2}{4}

所以 a^{n} > \frac{(a-1)^2}{4}n^2

所以 0 < \frac{n}{a^n} < \frac{n}{\frac{(a-1)^2n^2}{4}} < \frac{1}{n} \Rightarrow \lim_{n \rightarrow \infty} \frac{n}{a^n} =0

a^{\frac{1}{k}} = b

\lim_{n\rightarrow\infty} \frac{n^k}{a^n} = \lim_{n\rightarrow\infty} (\frac{n}{b^n})^k = 0 \Rightarrow n^k \prec a^n

vii. \exists N_0, N \in \mathbb{N} , N_0 > a , N > \max\{N_0,\frac{a^{N_0+1}}{N_0!\epsilon}\} ,\forall \epsilon > 0,

n > N

|\frac{a^n}{n!}-0| = \frac{a^n}{n!} = \frac{a^{N_0}}{N_0!} \frac{a}{N_0+1}\dots\frac{a}{n} \le \frac{a^{N_0}}{N_0!}\frac{a}{n} \le \frac{a^{N_0}}{N_0!}\frac{a}{N} \le \epsilon

\lim_{n\rightarrow \infty} \frac{a^n}{n!} = 0 \Rightarrow a^n \prec n!

viii. 在iv.中,我们已知 \frac{n!}{n^n} \ge \frac{1}{e^n} ,接下来我们估计其上界

\frac{n!}{n^n} = \frac{n}{n}\cdot\frac{n-1}{n}\cdot \dots \frac{1}{n} \le (\frac{\frac{n(n+1)}{2n}}{n})^n = \frac{(1+\frac{1}{n})^n}{2^n} < \frac{e}{2^n}

综上

\lim_{n\rightarrow\infty} \frac{n!}{n^n}=0 \Rightarrow n! \prec n^n

综合i -> viii,我们有

1 \prec k\log n \prec n \prec n \log n \sim \log(n!) \prec n^k \prec a^n \prec n! \prec n^n

~~~2018.6.22补充~~~~

关于Big Oh记号用于算法的具体估计, @梦回琼华 写的很好。

再啰嗦一段背景。综合Concrete Mathematics的第九章以及算法导论上的介绍, O (big Oh)这个符号最早由一个叫P.Bachmann的德国人发明,后来经Edmund Landau(德国人,数学家,不是苏联物理学家Landau)推广到数学界尤其是做数论的圈子里,发明者自己这么说的(不懂德文,下面的引文来自Concrete Mathematics的中译本):

"我们记号 O(n) 表示一个量时(这个量关于n的阶不超过n的阶),它本身是否真的包含阶为n的项,都是不确定的。”

说白了就是 O 只是一个对于函数的上界的估计,用通俗的说法就是,最坏情况的估计,比如轮子哥 @vczh 所说的

O是上界的话,哈希表的搜索就应该是O(n)

如果想再多了解点儿渐进估计与算法分析的话,请看Concrete Mathematics的第九章,写的真的很不错。以及《算法导论》。