前言
在正式进入今天的主题之前,我们不妨先来回顾一下初步学习数据结构后必须知道的概念。🎶
数据结构
数据结构是计算机存储、组织数据的方式,指相互间存在一种或多种特定关系的数据元素的集合。
(没有一种单一的数据结构能够满足所有用途,因此我们需要学习各种不同的数据结构。如:线性表、树、图、哈希等。)
算法
算法(Algorithm)指的是定义好的计算过程,它取一个或一组的值为输入,并产生出一个或一组值作为输出。
简单来说算法就是一系列的计算步骤,用于将输入数据转化成输出结果。
正文
我们知道算法有好坏之分,但是要怎么去衡量一个算法的好坏呢?
比如我们在一些刷题平台做算法题时会遇到“超出时间限制”的“滑铁卢”,所以我们可以知道,时间就是其中一个标准。除此之外还有空间。用最少的时间办最大的事是我们现实中办事能力好坏的标准,对于算法来说也是类似的:用最少的时间、最少的空间,解决问题,这就是好的算法。
其实我们有一个专门衡量算法好坏的概念:复杂度。复杂度分为时间复杂度和空间复杂度,刚好从两个维度去衡量一个算法。
说得更精确一些:时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。
其实在计算机发展的早期,计算机的存储容量很小所以对空间复杂度很是在乎。而如今计算机的存储容量不可同日而语,所以我们已不再特别关注空间复杂度而是以时间复杂度为主。
小小补充:摩尔定律
摩尔定律最初是由英特尔公司的创始人之一戈登·摩尔在1965年提出的观察结果和预测。他发现集成电路上可容纳的晶体管数量每隔一段时间就会翻倍,同时价格也会减半。这一发现揭示了信息技术进步的速度,并成为了计算机行业的基础法则之一。
摩尔定律的实际效果是,计算机芯片的性能每隔一段时间就会显著提高,而成本也会随之下降。这一定律对整个信息技术产业的发展产生了深远的影响,推动了半导体芯片的集成化趋势,进而给人们的生活带来了巨大的变化。然而,随着晶体管电路逐渐接近性能极限,摩尔定律的适用性也面临挑战。
归纳起来,“摩尔定律”主要有以下3种“版本”:
- 集成电路芯片上所集成的电路的数目,每隔18个月就翻一番;
- 微处理器的性能每隔18个月提高一倍,而价格下降一半;
- 用一美元所能买到的计算机性能,每隔18个月翻两番。
这里不再多阐述,回到复杂度。
时间复杂度
在计算机科学中,算法的时间复杂度是一个函数式T(N),它定量描述了该算法的运行时间。
这里的函数不是我们C语言中指的函数,而是数学概念。
先来说说,时间复杂度是衡量程序的时间效率,那么为什么不去计算程序的运行时间呢?
我们是可以计算程序的运行时间的。我们可以分别计算程序开始和结束的时间进行相减得到运行时间。
如:
#include<stdio.h>
#include<time.h>
int main()
{
//计算程序运行时间
int begin = clock();//用来保存程序的运行时间
int count = 0;
for (int i = 0; i < 1000000000; i++)
{
count++;
}
int end = clock();
printf("time:%d\n", end - begin);
return 0;
}
执行后我的电脑上VS(Debug模式)得到的结果是time:507
,Release模式则是0。单位是毫秒。
而且多次运行得出来的结果还不一定相同,无法给出精确的运行时间。
-
程序运行时间和编译环境以及运行机器的配置都有关系,同一个算法程序,用一个老编译器和新编译器进行编译,在同样机器下运行时间不同。
-
同一个算法程序,用一个老低配置机器和新高配置机器,运行时间也不同。
-
并且运行的时间只能程序写好后测试,不能在编写程序之前通过理论思想计算评估。(而我们希望算法的复杂度是我们在编写程序之前就能知道的。)
这几点解释了为什么通过计算程序的运行时间来评估算法的时间效率不好。
时间复杂度和空间复杂度是我们在提出一个算法,编写程序之前就能知道的,是一个粗估而非精确的结果。
时间复杂度该怎么计算呢?
程序的时间效率由两个维度决定:每条语句运行的时间和运行的次数。
程序时间效率:每条语句运行时间×运行次数
每条语句运行的时间也如前面所说,和编译环境、运行机器的配置等有关,不好确定。但是运行的次数我们是可以知道的。比如上面的int count = 0;
这句代码的运行次数为1,而count++;
的运行次数为1000000000。
我们可以将每条语句运行时间去掉,只看运行次数。
为什么可以这样呢?
因为程序的运行时间和运行次数是正相关的,所以我们可以不看每条语句运行时间,通过运行次数来看时间复杂度。
计算时间复杂度的案例
我们现在有一道算法题,来看看怎么计算它的时间复杂度。
// 请计算⼀下Func1中++count语句总共执⾏了多少次?
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N ; ++ i)
{
for (int j = 0; j < N ; ++ j)
{
++count;
}
}
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
}
我们看的是运行次数。在第一个嵌套循环中:外循环每执行1次,内循环要执行N次,而外循环执行了N次。所以整个部分执行了N×N次;在下一个循环中执行了2×N次;最后一个循环执行了10次。所以该算法的时间复杂度函数式为T(N)=N²+2N+10。
定义变量这样的语句相较于其他的循环语句运行次数太小,故忽略不计。
我们可以看出N的大小决定了T(N)的大小:
N | T(N) | N² |
---|---|---|
10 | 130 | 100 |
100 | 10210 | 10000 |
1000 | 1002010 | 1000000 |
可以看出对T(N)影响大的是N²,而2N+10对结果的影响小,可以忽略不计,只看影响最大的项。T(N)=N²,是一个粗估。
大O的渐进表示法
上面我们讲了T(N),但其实复杂度的表示我们使用的是大O的渐进表示法。
比如上面的那个算法的时间复杂度就表示为O(N²)。
大O既可以表示时间复杂度,也可以表示空间复杂度。
接下来我们来看本文中最重要的内容:
推导大O阶规则
- 时间复杂度函数式T(N)中,只保留最⾼阶项,去掉那些低阶项,因为当N不断变⼤时, 低阶项对结果影响越来越⼩,当N⽆穷⼤时,就可以忽略不计了。
- 如果最⾼阶项存在且不是1,则去除这个项⽬的常数系数,因为当N不断变⼤,这个系数 对结果影响越来越⼩,当N⽆穷⼤时,就可以忽略不计了。
- T(N)中如果没有N相关的项⽬,只有常数项,⽤常数1取代所有加法常数。
可以看到,刚才那个算法的例子就是符合以上规则的一个例子。
我们现在再来看几个例子。
示例1
// 计算Func2的时间复杂度?
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
第一个循环执行次数为2N,第二个循环次数为10。所以T(N)=2N+10
根据规则1,10作为低阶项,被去掉;
根据规则2,最⾼阶项存在且不是1,去除这个项⽬的系数2。
所以最后我们得到的时间复杂度为O(N)。
对于计算机来说,1万和2万,1亿和2亿的区别并不大,所以可以去除系数。2倍的无穷大与无穷大结果一样。
示例2
// 计算Func3的时间复杂度?
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++ k)
{
++count;
}
for (int k = 0; k < N ; ++k)
{
++count;
}
printf("%d\n", count);
}
第一个循环的执行次数为M,第二个循环执行次数为N,所以T(N)=M+N,M和N都是变量,都会影响T(N)的大小。那么时间复杂度是多少呢?答案是O(M+N)。
如果T(N)=2M+N,也是O(M+N),没有区别。因为M和N取极限时,2倍的极限和极限没有区别。
大小关系 | 复杂度 |
---|---|
M>>N | O(M) |
M<<N | O(N) |
M==N | O(M+N) |
注意:
-
这里的高阶项与低阶项如何区别?高和低是相对而言的,看谁对结果影响最大则为高阶。
-
一亿这样的数对于我们来说虽然感觉很大,对于高速处理数据的计算机来说却不过是一秒不到的事,所以我们说数字大与小是要对于计算机来说而不是我们的主观感受。当我们说“非常大”,指的是无限。
本文到此结束,但对于复杂度的探讨并没有结束,敬请期待下卷。( ̄︶ ̄)↗