我们经常听到数据结构这个词,它指的是数据之间的结构组织。数据组织的结构不同,数据的存取效率就会产生巨大的差异。而这,也就是我们对数据进行组织的原因。
数据结构的三要素分别是逻辑结构、存储结构、数据的运算。我们可以将数据结构理解为一组数据在计算机中的存储结构,或者是相互之间存在一种或多种特定关系的数据集合。
再说算法。它是操作数据、解决特定问题的求解步骤和方法,在计算机中,表现为指令的有限序列,并且每条指令表示一个或者多个操作。通常来说,对于给定的问题,可以有多种解决问题的算法 。当然,这些算法有好有坏。
从数据结构与算法的关系来看,数据结构服务于算法,算法也要作用于特定的数据结构之上,两者相辅相成,不能孤立。
算法通常具备这样五个特性。
不过,具备以上特性的算法就可以了么?我们知道,解决同一个问题的算法可能有多个,它们有好有坏。那么,一个好的算法在设计上通常需要满足什么要求呢?一般有四个。
所以我们就把重点放在 “高效率” 上。要想知道自己的代码是不是能运行得更快,更节省存储空间,那么下一步,就要度量算法的执行效率了。
度量的方法通常有两种,事后统计方法,以及事前分析估算方法。
事后统计方法 是通过设计好的测试程序和数据,通过计时,比较不同算法编写的程序的时间,确定算法效率的高低。但这种方法缺陷比较大,并不准确。为什么这么说呢?
一方面,测试结果受测试环境影响很大,比如不同的机器硬件性能、不同的操作系统、编程语言、编译器等都会影响测试结果。另一方面,测试结果受数据规模影响很大,比如效率高的算法在少量测试数据时无法体现。
事前分析估算方法 是在程序编写前依据一些统计方法对算法进行粗略估算的方法,它会抛开计算机硬件、软件等因素,单纯以算法本身作为估算的核心。通常,我们将它分为算法的时间复杂度分析与空间复杂度分析这两类。这两个复杂度分析方法非常重要,我们一起来把它学通、学透。
算法的时间复杂度用于度量算法的执行时间。在进行算法的时间复杂度分析之前,我们首先了解一下它的表示方法。
先来看一个算法例子的代码:
void calc(int n)
{
①int sum = 0; //执行1次
②for(int i = 1; i <= n; ++i) //执行(判断)n+1次
③{
④ sum = sum + i; //执行n次
⑤}
⑥cout << sum << endl; //执行1次
}
上面的 calc 函数用于求从 1 到 n 的和值,这就是一个算法。
从程序执行的角度来看,程序中的每一行执行的操作都类似 —— 读、写、运算。虽然每行代码执行的时间都不太一样,但作为时间复杂度分析,只需要进行粗略估计,所以我们简单的认为每行代码执行的时间相同即可。
当我们运行代码 calc(1000)
时,调用 calc 函数后,因为传递进去的实参是 1000,这表示问题规模 n=1000。参考代码中圆圈数字表示的行号,就可以知道具体的执行次数了。
这意味着 calc 函数中有效代码行一共执行了 1+1001+1000+1=2003 次。如果把 1000 用 n 替换,那么一共执行的次数就是 2n+3 次。
好,现在我们引入一个新的算法 —— calc2 函数,在 calc 函数基础之上,再增加一个双重 for 循环。
void calc2(int n)
{
①int sum = 0; //执行1次
②for(int i = 1; i <= n; ++i) //执行(判断)n+1次
③{
④ sum = sum + i; //执行n次
⑤}
⑥for(int j = 1; j <= n; ++j) //执行n + 1次
⑦{
⑧for(int k = 1; k <= n; ++k) //执行n*(n + 1)次
⑨{
⑩ sum = sum + k; //执行n*n次
⑪}
⑫}
⑬cout << sum << endl; //执行1次
}
上面的 calc2 函数中,第一个 for 循环用于求从 1 到 n 的和值。接着,双重 for 循环用于求 n 次从 1 到 n 的和值。
那么,当我们运行代码 calc2(1000)
时,调用 calc2 函数后,又引入了一个双重循环,这个时候,calc2 函数中的代码行执行次数应该怎么算呢?
这意味着 calc2 函数中有效代码行一共执行了 1+1001+1000+1001+1001000+1000000+1=2004004 次。如果把 1000 用 n 替换,那么一共执行的次数就是 2 n 2 + 4 n + 4 2n^2+4n+4 2n2+4n+4。现在,大 O 时间复杂度表示法就要登场了!
这其中:
所以,calc 函数中 T ( n ) = O ( 2 n + 3 ) T(n)=O(2n+3) T(n)=O(2n+3)。同样的,calc2 函数的时间复杂度可以怎么表示呢?是的, T ( n ) = O ( 2 n 2 + 4 n + 4 T(n)=O(2n^2+4n+4 T(n)=O(2n2+4n+4)。
需要注意的是,算法的时间复杂度表示的不是代码真正的执行时间,而是代码执行时间随问题规模增长的变化趋势,或者说代码的执行时间与问题规模之间的增长关系。 所以,我们也把它叫作算法的渐进时间复杂度,简称时间复杂度。
在大 O 时间复杂度表示法中,当算法的问题规模 n 足够大的时候,f(n) 中的系数、常量、低阶都变得无关紧要,可以忽略掉,不会影响代码执行时间随问题规模增长的变化趋势。
我们还是从 calc 函数和 calc2 函数这两个例子去说。
(1)calc 函数中 T ( n ) = O ( 2 n + 3 ) T(n)=O(2n+3) T(n)=O(2n+3) 可以直接记为 T ( n ) = O ( n ) T(n)=O(n) T(n)=O(n)。对于 2n+3 而言:
(2)calc2 函数中 T ( n ) = O ( 2 n 2 + 4 n + 4 ) T(n)=O(2n^2+4n+4) T(n)=O(2n2+4n+4) 可以直接记为 T ( n ) = O ( n 2 ) T(n)=O(n^2) T(n)=O(n2)。对于 2 n 2 + 4 n + 4 2n^2+4n+4 2n2+4n+4 而言:
不过,当一个算法中的代码比较复杂,比如有多条循环语句,甚至循环语句中还嵌套着循环语句等情形发生时,要按照什么样的规则来求得算法的时间复杂度呢?这就要引出算法时间复杂度计算规则了。
在分析一个算法的时间复杂度时,我们只需要关注循环中的代码段,该代码段的执行次数对应的问题规模 n 的阶数(数量级)就是整个算法的时间复杂度。
举个例子,在 calc 函数中,第 1、6 行代码与问题规模 n 无关,计算时间复杂度时忽略,而第 2、4 行循环语句的执行次数与问题规模 n 直接相关,这两行代码一共执行了 2n+1 次。又因为系数、常量可以忽略,所以算法的总时间复杂度就是 O(n)。
什么是加法规则呢?举个例子,在 calc2 函数中,第 2 行到第 5 行是一个单重循环,而第 6 行到第 12 行是一个双重循环。我们可以分别分析这个单重循环和双重循环的时间复杂度,根据加法规则的公式,取其中阶数最高(数量级最大)的作为整个算法的时间复杂度。
回到例子中,单重循环的时间复杂度为 O(n),双重循环的时间复杂度为 O ( n 2 ) O(n^2) O(n2),所以整个算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。这说明,加法规则的算法时间复杂度取决于阶数最高的代码段的复杂度。
用公式表达,即为:
最后,我们来说乘法规则。什么意思呢?先看下面两个函数的代码,其中在 calc3 函数中调用了 testfunc 函数。
int testfunc(int n)
{
int sum = 0;
for (int i = 1; i <= n; ++i)
{
sum = sum + i;
}
return sum;
}
void calc3(int n)
{
int sum = 0;
for (int i = 1; i <= n; ++i)
{
sum = sum + testfunc(i);
}
}
显然,testfunc 函数的时间复杂度是 O(n),而如果 calc3 在 for 循环中没有调用 testfunc 函数,而只是一个普通运算,那么 calc3 函数的时间复杂度也应该为 O(n),但因为在 calc3 函数中调用了 testfunc 函数,这相当于一个双重循环,所以 calc3 函数的时间复杂度应该是 O ( n ∗ n ) = O ( n 2 ) O(n*n) = O(n2) O(n∗n)=O(n2)。
这说明,乘法规则的算法时间复杂度取决于内外循环(将对 testfunc 函数的调用看成是内循环)代码段时间复杂度的乘积。
这说明,乘法规则的算法时间复杂度取决于内外循环(将对 testfunc 函数的调用看成是内循环)代码段时间复杂度的乘积。
用公式表达,即为:
清楚了这些算法时间复杂度计算规则,我们再来说 6 个常见的算法时间复杂度,让我们之后对算法时间复杂度的计算更加得心应手。
O(1) 是常数阶时间复杂度表示法。不过,它并不意味着只执行了一行代码。
我们看 calc4 函数的代码。
void calc4(int n)
{
int i = 100;
int j = 15;
int sum = i + j + n;
cout << sum << endl;
}
上面即便有 4 行代码,它的时间复杂度仍旧是 O(1) 而不是 O(4)。换句话说,只要算法的执行时间不随问题规模 n 的增加而增大(执行时间恒定),那么算法的时间复杂度就都是 O(1)。即便算法中包含成百上千行代码也是如此,O 后的圆括号中只能是 1,不能是其他数字。
先说 O ( l o g 2 n ) O(log_2^{n}) O(log2n)。这是是对数阶时间复杂度表示法,这种时间复杂度很常见却不好分析。不过不用着急,先来看看 calc5 函数代码。
void calc5(int n)
{
int i = 1;
while (i <= n)
{
i = i * 2;
}
}
根据计算代码时间复杂度的规则,“只关注循环中的代码段”,上面的代码中,i 的初始值从 1 开始,每循环一次则乘以 2,当 i 值大于 n 时,整个循环执行结束。也就是说,i 乘以一次 2,距离 n 就更近了一些。
我们假设 i 乘以了 x 次 2 后值大于 n,此时就会退出循环。也就是说,x 就是 i = i ∗ 2 i = i*2 i=i∗2;这行代码的执行次数,当 2 x > n 2^x>n 2x>n,也就是循环次数 x = l o g 2 n + 1 x=log_2^{n}+1 x=log2n+1 时,i 的值将大于 n,此时会跳出 while 循环,所以,这段代码的时间复杂度就是 O ( l o g 2 n ) O(log_2^{n}) O(log2n)。
可能你会问,上述代码的循环体中,如果代码不是 i = i ∗ 2 i=i*2 i=i∗2,而是 i = i ∗ 3 i=i*3 i=i∗3,那么代码的时间复杂度又是多少呢?
显然,代码的时间复杂度应该是 O ( l o g 3 n ) O(log_3^{n}) O(log3n)
但是,根据对数换底公式 l o g b n = l o g b a ∗ l o g a n log_b^{n}=log_b^{a}*log_a^{n} logbn=logba∗logan 可知,对数之间可以相互转换,所以 l o g 3 n = l o g 3 2 ∗ l o g 2 n log_3^{n}=log_3^{2}*log_2^{n} log3n=log32∗log2n,因此 l o g 3 n = O ( l o g 3 2 l o g 2 n ) log_3^{n}=O(log_3^{2}log_2^{n}) log3n=O(log32log2n),而其中的 l o g 3 2 log_3^{2} log32 是一个常数,作为系数可以忽略。
最后得出, O ( l o g 3 n ) = O ( l o g 2 n ) O(log_3^{n})=O(log_2^{n}) O(log3n)=O(log2n)。
你看,在讨论对数阶时间复杂度时,不管是以 2 为底,3 为底,还是以其他数字为底,我们统一认为是以 2 为底就可以了,而且在书写时间复杂度时,通常这个底数 2 也忽略不写,统一表示为 O ( l o g n ) O(logn) O(logn)。
再说 O ( n l o g 2 n ) O(nlog_2^{n}) O(nlog2n)。它是线性对数阶时间复杂度表示法,同理,这里的底数 2 也可以忽略不写,所以线性对数阶时间复杂度表示为 O ( n l o g n ) O(nlogn) O(nlogn)。
上述 calc5 中 while 循环代码段的时间复杂度是 O ( l o g n ) O(logn) O(logn),那么 O ( n l o g n ) O(nlogn) O(nlogn) 的代码是什么样子的呢?
只需要把这段 while 循环代码段循环执行 n 次,根据时间复杂度的乘法规则,得到的时间复杂度就是 O ( n l o g n ) O(nlogn) O(nlogn),你可以参考下面 calc6 函数代码段。
void calc6(int n)
{
int i = 1;
for (int count = 0; count < n; ++count)
{
while (i <= n)
{
i = i * 2;
}
}
}
O ( n ) O(n) O(n) 是线性阶时间复杂度表示法。我们先来看看 calc7 函数的代码。
void calc7(int n)
{
int sum = 0;
for (int i = 1; i <= n; ++i)
{
sum += i;
}
}
上面的代码中,因为循环体中的代码需要执行 n 次,因此代码的时间复杂度为 O ( n ) O(n) O(n)。
先说 O ( n 2 ) O(n^2) O(n2)。它是平方阶时间复杂度表示法。我们看看 calc8 函数的代码。
void calc8(int n)
{
int sum = 0;
for (int i = 1; i <= n; ++i)
{
for (int j = 1; j <= n; ++j)
{
sum++;
}
}
}
上面的代码是个循环嵌套,它的内循环代码的时间复杂度刚刚分析过,是 O ( n ) O(n) O(n),而外循环又循环了 n 次,因此整个代码段的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。不难想象,如果在外循环外面再套一个 n 次循环,则整个代码段的时间复杂度为 O ( n 3 ) O(n^3) O(n3)。
那如果改造一下 calc8 函数,将内循环的 j 初值从 1 修改为 i,修改后的函数为 calc81,你能再尝试分析一下它的时间复杂度吗?
void calc81(int n)
{
int sum = 0;
for (int i = 1; i <= n; ++i)
{
for (int j = i; j <= n; ++j)
{
sum++;
}
}
}
上面的代码中,当 i=1 时,内循环执行了 n 次,当 i=2 时,内循环执行了 n-1 次,当 i=3 时,内循环执行了 n-2 次……以此类推,当 i=n 时,内循环就只执行了 1 次。所以内循环的总执行次数是 n + ( n − 1 ) + ( n − 2 ) + … + 1 n+(n-1)+(n-2)+…+1 n+(n−1)+(n−2)+…+1,反过来也就是 1 + 2 + 3 + … + n 1+2+3+…+n 1+2+3+…+n。
根据等差数列求和公式, 1 + 2 + 3 + … + n = n ( n + 1 ) / 2 = n 2 / 2 + n / 2 1+2+3+…+n=n(n+1)/2=n^2/2+n/2 1+2+3+…+n=n(n+1)/2=n2/2+n/2,因为大 O 时间复杂度表示法中的系数、低阶都可以忽略掉,所以这段代码的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
好,再看 calc9 函数的代码。
void calc9(int m, int n)
{
int sum = 0;
for (int i = 1; i <= m; ++i)
{
for (int j = 1; j <= n; ++j)
{
sum++;
}
}
}
上面的代码中,不一样的地方是 m 和 n 都表示问题规模,而且并不知道 m 和 n 谁的数量级大,在嵌套循环中,外循环循环了 m 次,内循环循环了 n 次,因此代码的复杂度会根据这两个问题的规模决定。
那么根据乘法规则,整个代码段的时间复杂度就是 O ( m ∗ n ) O(m*n) O(m∗n)。
这仍是一个代码的复杂度会根据两个问题规模来决定的情形。我们来看 calc10 函数的代码。
void calc10(int m, int n)
{
int sum1 = 0;
for (int i = 1; i <= m; ++i)
{
sum1 += i;
}
int sum2 = 0;
for (int j = 1; j <= n; ++j)
{
sum2 += j;
}
}
上面的代码中,同样是 m 和 n 两个问题规模,不知道 m 和 n 谁的数量级大,而且和上面不同,不是嵌套的循环。所以,评估整个代码复杂度的时候,就不能用加法规则来取这两个 for 循环中数量级最大的循环的时间复杂度,而是相加。整个代码段的时间复杂度,就是 O ( m + n ) O(m+n) O(m+n)。
相信你经常听到这几个词,最好、最坏、平均情况时间复杂度,这些说的是什么情况呢?我们还是从实际的代码示例中找答案。
代码示例:
void calc11(int array[], int n, int x)
{
int pos = -1;
for (int i = 0; i < n; ++i)
{
if (array[i] == x)
{
pos = i;
break;
}
}
if (pos == -1)
{
cout << "没找到值为" << x << "的元素" << endl;
}
else
{
cout << "找到了值为" << x << "的元素,其在数组中的位置下标为:" << pos << endl;
}
}
上面的 calc11 函数是在一个数组中查找某个元素 x,问题规模 n 代表的是数组的大小。可以用下面的代码对 calc11 函数进行调用。
int main()
{
int asz[5] = { 1,2,3,4,5 };
calc11(asz, 5, 3); //5:数组中元素个数,3:要在数组中查找的值
return 0;
}
calc11 函数的工作过程是从 array 数组的第一个数组元素开始往后查找,所以,如果要查找的元素 x 位于 array 数组中最前面的位置,那么整个 for 循环只需要执行一次就 break 出来了,此时算法的时间复杂度是 O ( 1 ) O(1) O(1)。
而如果元素 x 位于数组 array 中最后的位置,那么整个循环必须要循环 n 次,查找到最后一个元素后才会执行 break 跳出 for 循环,当然,如果元素 x 并没有位于数组 array 中,那么整个循环也会执行 n 次后结束。
所以,对于要查找的元素位于数组的最后位置或者根本不在数组中的情况,算法时间复杂度就变成了 O ( n ) O(n) O(n)。
你会发现,要查找的元素不同,这段代码的时间复杂度也不同。因此,这里我们就要引入三个概念了:最好情况时间复杂度、最坏情况时间复杂度以及平均情况时间复杂度。
最理想情况下和最差的情况通常很少发生,这个时候就需要引入平均情况时间复杂度来表示平均情况下的时间复杂度了。
那么,平均情况时间复杂度应该如何计算呢?
我们回到代码当中。因为数组 array 中有 n 个元素,因此可以假设元素 x 在任意一个下标位置出现的概率相同,都为 1 / n 1/n 1/n。
不过,为了简化问题,“所查找的元素 x 不在数组中”的情形就不讨论了。因为所查找的元素 x 不在数组中的情形会使问题更加复杂,并且所求得的大 O 时间复杂度计算结果也只会多出一些可以忽略的系数和常量。
好了,说回到计算的方法。设想一下,如果元素 x 出现在数组中下标为 0 的位置,则只需要循环 1 次,如果元素 x 出现在数组中下标为 1 的位置则需要循环 2 次,如果元素 x 出现在数组中下标为 2 的位置则需要循环 3 次。以此类推,如果元素 x 出现在数组中下标为 n-1 的位置则需要循环 n 次。
那么,把每种情况下查找的次数累加起来,再除以 n,就得到了查找次数的平均值。
根据等差数列求和公式 1 + 2 + 3 + … + n 1+2+3+…+n 1+2+3+…+n 等于 n ( n + 1 ) / 2 n(n+1)/2 n(n+1)/2,把该公式带入到查找次数平均值中去,就有查找次数平均值 ( n ( n + 1 ) / 2 ) ∗ 1 / n = ( n + 1 ) / 2 (n(n+1)/2)*1/n=(n+1)/2 (n(n+1)/2)∗1/n=(n+1)/2。而又因为大 O 时间复杂度表示法中,系数、常量都可以忽略掉,所以平均情况时间复杂度,兜兜转转,又回到了 O ( n ) O(n) O(n)。
可能你会问了,那如果问需要给出一个明确的复杂度的时候,应该用哪个结果呢?
通常情况下,算法并不需要区分最好、最坏、平均情况时间复杂度,只有相同的代码段在不同情况下有数量级的差异时才会用到这三种情况的时间复杂度来区分。当谈论算法复杂度时,通常指最坏情况时间复杂度,平均情况时间复杂度也应该给予关注。平均情况时间复杂度分析起来一般比较复杂,而最好情况时间复杂度,基本也没有多少参考价值。
这篇文章探讨了数据结构与算法的基本概念、算法的特性以及好算法的设计要求等等。其中,一个最重要的话题是讲解了算法时间复杂度。同时引入了算法时间复杂度计算规则,对常见的算法时间复杂度举例进行了分析。
算法时间复杂度主要用于评估一个算法写成的程序在执行时耗费时间的数量级(阶数),让我们对算法的执行时间做到心中有数,甚至更进一步,帮助我们优化算法的编写。
其实 O ( m + n ) O(m+n) O(m+n), O ( m ∗ n ) O(m*n) O(m∗n) 等等这些计算就是简单的加减乘除运算,辅助一些文中所举的代码例子,基本都可以清晰的读懂、明了,而且后续随着学习深入,这些知识的运用也会自然呈现,让你对它们的认识越来越深。当前,我们主要以理解为主。
思考下面的一些问题
(1)算法时间复杂度的加法规则和乘法规则分别是什么?
算法中,循环 A 中嵌套循环 B,它的时间复杂度是A的循环次数乘以B的循环次数。
如果循环 A 和循环B分开,则时间复杂度是 A的循环次数加B的循环次数
(2)常见算法时间复杂度有哪些?尝试做个归纳,写一写这些算法时间复杂度的关系式(大小关系)?
1)常量级 O ( 1 ) O(1) O(1) 随着数据规模n增大,对应算法的时间复杂度不变
2)对数级 O ( l o g n ) O(logn) O(logn) 随着数据规模n增大,对应算法的时间复杂度成对数曲线 l o g n logn logn 变化
3)线性级 O(n) 随着数据规模n增大,对应算法的时间复杂度成线性曲线 n n n 变化
4)线性对数级 O ( n l o g n ) O(nlogn) O(nlogn) 随着数据规模 n 增大,对应算法的时间复杂度成线性对数曲线 n l o g n nlogn nlogn 变化
5)平方级 O ( n 2 ) O(n^2) O(n2) 随着数据规模 n 增大,对应算法的时间复杂度成平方级 n 2 n^2 n2 变化
6)立方级 O ( n 3 ) O(n^3) O(n3) 随着数据规模 n 增大,对应算法的时间复杂度成立方级 n 3 n^3 n3 变化
7)K 次方级 O ( n k ) O(n^k) O(nk) 随着数据规模 n 增大,对应算法的时间复杂度成 n k n^k nk 次方级变化
8)指数级 O ( 2 n ) O(2^n) O(2n) 随着数据规模 n 增大,对应算法的时间复杂度成 2 n 2^n 2n 次方级变化
9)阶乘级 O ( n ! ) O(n!) O(n!) 随着数据规模 n 增大,对应算法的时间复杂度成 n ! n! n! 阶乘级变化