数据结构与算法解决的是什么问题?
解决的是“快”和“省”的问题,如何让代码运行得更快?如何让代码更省存储空间?也就是执行效率,如何衡量你编写代码的执行效率呢?
时间复杂度分析、空间复杂度分析

复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半。

时间复杂度

为什么需要复杂度分析?
我们要一个不用具体的测试数据,粗略地估计算法的执行效率的方法,就是——时间、空间复杂度分析方法。

大O复杂度表示法
所有代码的执行时间T(n)与每行代码的执行次数f(n)成正比,把这个规律总结成一个公式:T(n) = O(f(n))
T(n)表示代码执行的时间;n表示数据规模的大小;f(n)表示每行代码执行的次数总和。用f(n)来表示。公式中的O,表示代码的执行时间T(n)与f(n)表达式成正比。
大O时间复杂度并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以也叫时间复杂度。

如何分析一段代码的时间复杂度?
有3个方法:

  1. 只关注循环执行次数最多的一段代码
  2. 加法法则:总复杂度等于量级最大的那段代码的复杂度
  3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
    不用刻意去记忆,复杂度分析关键在于“熟练”,多看案例,多分析,就能做到“无招胜有招”。

常见时间复杂度有哪些?
从低阶到高阶有:常量阶O(1)、对数阶O(logn)、线性阶O(n)、线性对数阶O(nlogn)、平方阶O(n2 )
uploading-image-635754.png

常量级:O(1)
只要不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1);

对数阶:O(logn)、线性对数阶:O(nlogn)

while (i <= n) { 
	i = i * 2; 
}

等比数列:2的0次方 * ... * 2的n次方
x = log2n,所以时间复杂度就是O(log2n)。
采用大O标记复杂度的时候,可以忽略系数,即O(Cf(n)) = O(f(n)),在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为O(logn)。

如果一段代码的时间复杂度是O(logn),循环执行n遍,时间复杂度就是O(nlogn)了。O(nlogn)也是非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是O(nlogn)。

线性阶:O(n)、O(m+n)、O(mn)
代码的复杂度由两个数据的规模来决定
m和n是表示两个数据规模,无法事先评估m和n谁的量级大,所以在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。所以,上面代码的时间复杂度就是O(m+n)。

空间复杂度分析

空间复杂度与时间复杂度有什么区别?
时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。空间复杂度全称就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系。

int[] a = new int[n];

申请了一个大小为n的int类型数组,除此之外,剩下的代码都没有占用更多的空间,整段代码的空间复杂度就是O(n)。

常见的空间复杂度就是O(1)、O(n)、O(n2 ),像O(logn)、O(nlogn)这样的对数阶复杂度平时都用不到,空间复杂度分析比时间复杂度分析要简单很多。

复杂度分析并不难,关键在于多练!!!

讲四个复杂度分析方面的知识点

  1. 最好情况时间复杂度(best case time complexity)
  2. 最坏情况时间复杂度(worst case time complexity)
  3. 平均情况时间复杂度(average case time complexity)
  4. 均摊时间复杂度(amortized time complexity)

掌握这个几个概念后有什么用?
基本可以掌握复杂度分析的内容。

最好、最坏情况时间复杂度
下面这段代码的时间复杂度是多少?

// n表示数组array的长度
int find(int[] array, int n, int x) {
  int i = 0;
  int pos = -1;
  for (; i < n; ++i) {
    if (array[i] == x) pos = i;
  }
  return pos;
}

这段代码要实现的功能是,在一个无序的数组(array)中,查找变量x出现的位置。如果没有找到,就返回-1。按照上节课讲的分析方法,这段代码的复杂度是O(n),n代表数组的长度。

在数组中查找一个数据,并不需要每次都把整个数组都遍历一遍,因为有可能中途找到就可以提前结束循环了。所以优化一下

// n表示数组array的长度
int find(int[] array, int n, int x) {
  int i = 0;
  int pos = -1;
  for (; i < n; ++i) {
    if (array[i] == x) {
       pos = i;
       break;
    }
  }
  return pos;
}

优化完之后,这段代码的时间复杂度还是O(n)吗?
所以要引入3个新概念:最好情况时间复杂度、最坏情况时间复杂度、平均情况时间复杂度。
最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,发生的概率并不大。

平均情况时间复杂度
查找的变量x在数组中的位置,有n+1种情况:在数组的0~n-1位置中和不在数组中。
![[Pasted image 20230707153839.png]]
时间复杂度的大O标记法中,可以省略掉系数、低阶、常量,简化之后,平均时间复杂度就是O(n)。

这个结论虽然是正确的,但刚讲的n+1这种情况,出现概率是不一样?
要查找的变量x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,为了方便你理解,我们假设在数组中与不在数组中的概率都为1/2。另外,要查找的数据出现在0~n-1这n个位置的概率也是一样的,为1/n。所以,根据概率乘法法则,要查找的数据出现在0~n-1中任意位置的概率就是1/(2n)。

前面的推导过程中存在的最大问题就是,没有将各种情况发生的概率考虑进去。如果我们把每种情况发生的概率也考虑进去,那平均时间复杂度的计算过程就变成了这样:
![[Pasted image 20230707154202.png]]
这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称 叫 加权平均时间复杂度期望时间复杂度

引入概率之后,前面那段代码的加权平均值为(3n+1)/4。用大O表示法来表示,去掉系数和常量,这段代码的加权平均时间复杂度仍然是O(n)。

你可能会说,平均时间复杂度分析好复杂啊,还要涉及概率论的知识,但很多时候,我们用一个复杂度就可以满足需求了。

均摊时间复杂度
你讲一个更加高级的概念,均摊时间复杂度,以及它对应的分析方法,摊还分析(或者叫平摊分析)。

// array表示一个长度为n的数组,array.length就等于n
 int[] array = new int[n];
 int count = 0;
 void insert(int val) {
    if (count == array.length) {
       int sum = 0;
       for (int i = 0; i < array.length; ++i) {
          sum = sum + array[i];
       }
       array[0] = sum;
       count = 1;
    }

    array[count] = val;
    ++count;
 }

往数组中插入数据的功能。当数组满了之后,也就是代码中的count == array.length时,我们用for循环遍历数组求和,并清空数组,将求和之后的sum值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。

这段代码的时间复杂度是多少呢?

  1. 最好情况时间复杂度
    最理想的情况下,数组中有空闲空间,将数据插入到数组下标为count的位置就可以了。所以最好情况时间复杂度为O(1)
  2. 最坏情况时间复杂度
    数组中没有空闲空间了,我们需要先做一次数组的遍历求和,然后再将数据插入,所以最坏情况时间复杂度为O(n)。
  3. 平均时间复杂度是多少呢
    通过前面讲的概率论的方法来分析,假设数组的长度是n,根据数据插入的位置的不同,我们可以分为n种情况,每种情况的时间复杂度是O(1)。除此之外,还有一种“额外”的情况,就是在数组没有空闲空间时插入一个数据,这个时候的时间复杂度是O(n)。而且,这n+1种情况发生的概率一样,都是1/(n+1)。所以,根据加权平均的计算方法,我们求得的平均时间复杂度就是:
    ![[Pasted image 20230707155719.png]]

对比一下这个insert()的例子和前面那个find()的例子,你就会发现这两者有很大差别。

首先,find()函数在极端情况下,复杂度才为O(1)。但insert()在大部分情况下,时间复杂度都为O(1)。只有个别情况下,复杂度才比较高,为O(n)。这是insert()第一个区别于find()的地方。

第二,对于insert()函数来说,O(1)时间复杂度的插入和O(n)时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个O(n)插入之后,紧跟着n-1个O(1)的插入操作,循环往复。

所以,针对这样一种特殊场景的复杂度分析,我们并不需要像之前讲平均复杂度分析方法那样,找出所有的输入情况及相应的发生概率,然后再计算加权平均值。

针对这种特殊的场景,我们引入了一种更加简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度我们起了一个名字,叫均摊时间复杂度。

如何使用摊还分析法来分析算法的均摊时间复杂度呢?
继续看在数组中插入数据的这个例子。每一次O(n)的插入操作,都会跟着n-1次O(1)的插入操作,所以把耗时多的那次操作均摊到接下来的n-1次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是O(1)。这就是均摊分析的大致思路。

均摊时间复杂度和摊还分析应用场景比较特殊,所以我们并不会经常用到。为了方便你理解、记忆,简单总结一下它们的应用场景。

对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。

尽管很多数据结构和算法书籍都花了很大力气来区分平均时间复杂度和均摊时间复杂度,但其实我个人认为,均摊时间复杂度就是一种特殊的平均时间复杂度,我们没必要花太多精力去区分它们。

posted on   大元王保保  阅读(9)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律



点击右上角即可分享
微信分享提示