原文连接:https://jiang-hao.com/articles/2020/algorithms-data-structure-n-algorithm-1.htmlhtml
从广义上讲,数据结构就是指一组数据的存储结构。算法就是操做数据的一组方法。面试
数据结构和算法是相辅相成的。数据结构是为算法服务的,算法要做用在特定的数据结构之上。好比,由于数组具备随机访问的特色,经常使用的二分查找算法须要用数组来存储数据。但若是咱们选择链表这种数据结构,二分查找算法就没法工做了,由于链表并不支持随机访问。算法
想要学习数据结构与算法,首先要掌握一个数据结构与算法中最重要的概念——复杂度分析。它几乎占了数据结构和算法这门课的半壁江山,是数据结构和算法学习的精髓。编程
数据结构和算法解决的是如何更省、更快地存储和处理数据的问题,所以,咱们就须要一个考量效率和资源消耗的方法,这就是复杂度分析方法。数组
下图几乎涵盖了全部数据结构和算法书籍中都会讲到的知识点:数据结构
可是,做为初学者,或者一个非算法工程师来讲,并不须要掌握图里面的全部知识点。下面总结了 20 个最经常使用的、最基础数据结构与算法,无论是应付面试仍是工做须要,其实只要集中精力逐一攻克这 20 个知识点就足够了:数据结构和算法
10 个数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie 树;函数
10 个算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法;性能
在学习数据结构和算法的过程当中,也要注意,不要只是死记硬背,不要为了学习而学习,而是要学习它的“来历”“自身的特色”“适合解决的问题”以及“实际的应用场景”。学习
数据结构和算法自己解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。因此,执行效率是算法一个很是重要的考量指标。那如何来衡量你编写的算法代码的执行效率呢?这里就要用到咱们今天要讲的内容:时间、空间复杂度分析。
关键结论:
假设每行代码执行的时间都同样,为 $unitTime$,则全部代码的执行时间 T(n) 与每行代码的执行次数成正比。
咱们能够把这个规律总结成一个公式:
其中,T(n) 咱们已经讲过了,它表示代码执行的时间;n 表示数据规模的大小;f(n) 表示每行代码执行的次数总和。由于这是一个公式,因此用 f(n) 来表示。公式中的 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。
按照这个分析思路,咱们再来看这段代码。
int cal(int n) { int sum = 0; int i = 1; int j = 1; for (; i <= n; ++i) { j = 1; for (; j <= n; ++j) { sum = sum + i * j; } } }
第 二、三、4 行代码,每行都须要 1 个 $unit Time$ 的执行时间,第 五、6 行代码循环执行了 $n$ 遍,须要$ 2n * unitTime$ 的执行时间,第 七、8 行代码循环执行了 $n^2$遍,因此须要 $2n^2* unitTime$ 的执行时间。因此,整段代码总的执行时间 $T(n) = O(2n^2+2n+3)$。
大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增加的变化趋势,因此,也叫做渐进时间复杂度(asymptotic time complexity),简称时间复杂度。
当 n 很大时,你能够把它想象成 10000、100000。而公式中的低阶、常量、系数三部分并不左右增加趋势,因此均可以忽略。咱们只须要记录一个最大量级就能够了,若是用大 O 表示法表示刚讲的那段代码的时间复杂度,就能够记为:$T(n) = O(n^2)$。
如何分析一段代码的时间复杂度?咱们有三个比较实用的方法。
只关注循环执行次数最多的一段代码
大 O 这种复杂度表示方法只是表示一种变化趋势。咱们一般会忽略掉公式中的常量、低阶、系数,只须要记录一个最大阶的量级就能够了。因此,咱们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就能够了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。
这里我要再强调一下,即使某段代码循环 10000 次、100000 次,只要是一个已知的数,跟 n 无关,照样也是常量级的执行时间。当 n 无限大的时候,就能够忽略。尽管对代码的执行时间会有很大影响,可是回到时间复杂度的概念来讲,它表示的是一个算法执行效率与数据规模增加的变化趋势,因此无论常量的执行时间多大,咱们均可以忽略掉。由于它自己对增加趋势并无影响。
多段同级代码的总复杂度等于量级最大的那段代码的复杂度
抽象成公式就是:
$$
若是 T_1(n)=O(f(n)),T_2(n)=O(g(n));那么 T(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n))).
$$
多个嵌套循环代码的复杂度等于嵌套内外代码复杂度的乘积
抽象成公式就是:
$$
若是 T_1(n)=O(f(n)),T_2(n)=O(g(n));那么 T(n)=T_1(n)T_2(n)=O(f(n))O(g(n))=O(f(n)*g(n)).
$$
举个例子:
int cal(int n) { int ret = 0; int i = 1; for (; i < n; ++i) { ret = ret + f(i); } } int f(int n) { int sum = 0; int i = 1; for (; i < n; ++i) { sum = sum + i; } return sum; }
咱们单独看 $cal()$ 函数。假设 $f()$ 只是一个普通$O(1)$的操做,那第 4~6 行的时间复杂度就是,$T_1(n) = O(n)$。但 $f()$ 函数自己不是一个简单的操做,它的时间复杂度是 $T_2(n) = O(n)$,因此,整个 $cal()$ 函数的时间复杂度就是,$T(n) = T_1(n) * T_2(n) = O(n*n) = O(n^2)$。
虽然代码千差万别,可是常见的复杂度量级并很少。我稍微总结了一下,这些复杂度量级几乎涵盖了你从此能够接触的全部代码的复杂度量级。
对于以上罗列的复杂度量级,咱们能够粗略地分为两类,多项式量级和非多项式量级。其中,非多项式量级只有两个:$O(2^n)$ 和 $O(n!)$。
咱们把时间复杂度为非多项式量级的算法问题叫做 NP(Non-Deterministic Polynomial,非肯定多项式)问题。
当数据规模 n 愈来愈大时,非多项式量级算法的执行时间会急剧增长,求解问题的执行时间会无限增加。因此,非多项式时间复杂度的算法实际上是很是低效的算法。所以,关于 NP 时间复杂度咱们就不展开讲了。咱们主要来看几种常见的多项式时间复杂度。
O(1)
首先必须明确一个概念,O(1) 只是常量级时间复杂度的一种表示方法,并非指只执行了一行代码。好比这段代码,即使有 3 行,它的时间复杂度也是 O(1),而不是 O(3)。
int i = 8; int j = 6; int sum = i + j;
总结一下,只要代码的执行时间不随 n 的增大而增加,这样代码的时间复杂度咱们都记做 O(1)。或者说,通常状况下,只要算法中不存在循环语句、递归语句,即便有成千上万行的代码,其时间复杂度也是Ο(1)。
O(logn)、O(nlogn)
对数阶时间复杂度很是常见,同时也是最难分析的一种时间复杂度。咱们经过一个例子来讲明一下。
i=1; while (i <= n) { i = i * 2; }
根据咱们前面讲的复杂度分析方法,第三行代码是循环执行次数最多的。因此,咱们只要能计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度。
从代码中能够看出,变量 i 的值从 1 开始取,每循环一次就乘以 2。当大于 n 时,循环结束。还记得咱们高中学过的等比数列吗?实际上,变量 i 的取值就是一个等比数列。若是我把它一个一个列出来,就应该是这个样子的:
因此,咱们只要知道 x 值是多少,就知道这行代码执行的次数了。经过 $2^x=n$ 求解 x 这个问题咱们想高中应该就学过了,我就很少说了。$x=log_2n$,因此,这段代码的时间复杂度就是 $O(log_2n)$。
如今,我把代码稍微改下,你再看看,这段代码的时间复杂度是多少?
i=1; while (i <= n) { i = i * 3; }
根据我刚刚讲的思路,很简单就能看出来,这段代码的时间复杂度为 $O(log_3n)$。
实际上,无论是以 2 为底、以 3 为底,仍是以 10 为底,咱们能够把全部对数阶的时间复杂度都记为 $O(logn)$。为何呢?
咱们知道,对数之间是能够互相转换的,$log_3n$ 就等于 $log_32 * log_2n$,因此 $O(log_3n) = O(C * log_2n)$,其中 $C=log_32$ 是一个常量。基于咱们前面的一个理论:在采用大 O 标记复杂度的时候,能够忽略系数,即 $O(Cf(n)) = O(f(n))$。因此,$O(log_2n)$ 就等于 $O(log_3n)$。所以,在对数阶时间复杂度的表示方法里,咱们忽略对数的“底”,统一表示为 $O(logn)$。
若是你理解了我前面讲的 $O(logn)$,那 $O(nlogn)$ 就很容易理解了。还记得咱们刚讲的乘法法则吗?若是一段代码的时间复杂度是 $O(logn)$,咱们循环执行 n 遍,时间复杂度就是 $O(nlogn)$ 了。并且,$O(nlogn)$ 也是一种很是常见的算法时间复杂度。好比,归并排序、快速排序的时间复杂度都是 $O(nlogn)$。
O(m+n)、O(m*n)
再来说一种跟前面都不同的时间复杂度,代码的复杂度由两个数据的规模来决定。
int cal(int m, int n) { int sum_1 = 0; int i = 1; for (; i < m; ++i) { sum_1 = sum_1 + i; } int sum_2 = 0; int j = 1; for (; j < n; ++j) { sum_2 = sum_2 + j; } return sum_1 + sum_2; }
从代码中能够看出,m 和 n 是表示两个数据规模。咱们没法事先评估 m 和 n 谁的量级大,因此咱们在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。因此,上面代码的时间复杂度就是 O(m+n)。
针对这种状况,原来的法则就不正确了,咱们须要将规则改成:$T_1(m) + T_2(n) = O(f(m) + g(n))$。可是对于嵌套循环来讲的乘法法则继续有效:$T_1(m)*T_2(n) = O(f(m) * f(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; } 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) 吗?很显然,我们上一节讲的分析方法,解决不了这个问题。
由于,要查找的变量 x 可能出如今数组的任意位置。若是数组中第一个元素正好是要查找的变量 x,那就不须要继续遍历剩下的 n-1 个数据了,那时间复杂度就是 O(1)。但若是数组中不存在变量 x,那咱们就须要把整个数组都遍历一遍,时间复杂度就成了 O(n)。因此,不一样的状况下,这段代码的时间复杂度是不同的。
为了表示代码在不一样状况下的不一样时间复杂度,咱们须要引入三个概念:最好状况时间复杂度、最坏状况时间复杂度和平均状况时间复杂度。
顾名思义,最好状况时间复杂度就是,在最理想的状况下,执行这段代码的时间复杂度。就像咱们刚刚讲到的,在最理想的状况下,要查找的变量 x 正好是数组的第一个元素,这个时候对应的时间复杂度就是最好状况时间复杂度。
同理,最坏状况时间复杂度就是,在最糟糕的状况下,执行这段代码的时间复杂度。就像刚举的那个例子,若是数组中没有要查找的变量 x,咱们须要把整个数组都遍历一遍才行,因此这种最糟糕状况下对应的时间复杂度就是最坏状况时间复杂度。
咱们都知道,最好状况时间复杂度和最坏状况时间复杂度对应的都是极端状况下的代码复杂度,发生的几率其实并不大。为了更好地表示平均状况下的复杂度,咱们须要引入另外一个概念:平均状况时间复杂度,后面我简称为平均时间复杂度。平均时间复杂度又该怎么分析呢?我仍是借助刚才查找变量 x 的例子来解释。
要查找的变量 x 在数组中的位置,有 n+1 种状况:在数组的 0~n-1 位置中和不在数组中。咱们把每种状况下,查找须要遍历的元素个数累加起来,而后再除以 n+1,就能够获得须要遍历的元素个数的平均值,即:
咱们知道,时间复杂度的大 O 标记法中,能够省略掉系数、低阶、常量,因此,我们把刚刚这个公式简化以后,获得的平均时间复杂度就是 O(n)。这个结论虽然是正确的,可是计算过程稍微有点儿问题。到底是什么问题呢?咱们刚讲的这 n+1 种状况,出现的几率并非同样的。
咱们知道,要查找的变量 x,要么在数组里,要么就不在数组里。这两种状况对应的几率统计起来很麻烦,为了方便你理解,咱们假设在数组中与不在数组中的几率都为 1/2。另外,要查找的数据出如今 0~n-1 这 n 个位置的几率也是同样的,为 1/n。因此,根据几率乘法法则,要查找的数据出如今 0~n-1 中任意位置的几率就是 1/(2n)。
所以,前面的推导过程当中存在的最大问题就是,没有将各类状况发生的几率考虑进去。若是咱们把每种状况发生的几率也考虑进去,那平均时间复杂度的计算过程就变成了这样:
这个值就是几率论中的加权平均值,也叫做指望值,因此平均时间复杂度的全称应该叫加权平均时间复杂度或者指望时间复杂度。
引入几率以后,前面那段代码的加权平均值为 (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 值放到数组的第一个位置,而后再将新的数据插入。但若是数组一开始就有空闲空间,则直接将数据插入数组。
那这段代码的时间复杂度是多少呢?你能够先用咱们刚讲到的三种时间复杂度的分析方法来分析一下。
最理想的状况下,数组中有空闲空间,咱们只须要将数据插入到数组下标为 count 的位置就能够了,因此最好状况时间复杂度为 O(1)。最坏的状况下,数组中没有空闲空间了,咱们须要先作一次数组的遍历求和,而后再将数据插入,因此最坏状况时间复杂度为 O(n)。
那平均时间复杂度是多少呢?答案是 O(1)。咱们仍是能够经过前面讲的几率论的方法来分析。
假设数组的长度是 n,根据数据插入的位置的不一样,咱们能够分为 n 种状况,每种状况的时间复杂度是 O(1)。除此以外,还有一种“额外”的状况,就是在数组没有空闲空间时插入一个数据,这个时候的时间复杂度是 O(n)。并且,这 n+1 种状况发生的几率同样,都是 1/(n+1)。因此,根据加权平均的计算方法,咱们求得的平均时间复杂度就是:
至此为止,前面的最好、最坏、平均时间复杂度的计算,理解起来应该都没有问题。可是这个例子里的平均复杂度分析其实并不须要这么复杂,不须要引入几率论的知识。这是为何呢?咱们先来对比一下这个 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)。这就是均摊分析的大体思路。你都理解了吗?
均摊时间复杂度和摊还分析应用场景比较特殊,因此咱们并不会常常用到。为了方便你理解、记忆,我这里简单总结一下它们的应用场景。若是你遇到了,知道是怎么回事儿就好了。
对一个数据结构进行一组连续操做中,大部分状况下时间复杂度都很低,只有个别状况下时间复杂度比较高,并且这些操做之间存在先后连贯的时序关系,这个时候,咱们就能够将这一组操做放在一起分析,看是否能将较高时间复杂度那次操做的耗时,平摊到其余那些时间复杂度比较低的操做上。并且,在可以应用均摊时间复杂度分析的场合,通常均摊时间复杂度就等于最好状况时间复杂度。
尽管不少数据结构和算法书籍都花了很大力气来区分平均时间复杂度和均摊时间复杂度,但其实我我的认为,均摊时间复杂度就是一种特殊的平均时间复杂度,咱们不必花太多精力去区分它们。你最应该掌握的是它的分析方法,摊还分析。至于分析出来的结果是叫平均仍是叫均摊,这只是个说法,并不重要。
前面我讲过,时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增加关系。类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增加关系。看下面的例子:
void print(int n) { int i = 0; int[] a = new int[n]; for (i; i <n; ++i) { a[i] = i * i; } for (i = n-1; i >= 0; --i) { print out a[i] } }
跟时间复杂度分析同样,咱们能够看到,第 2 行代码中,咱们申请了一个空间存储变量 i,可是它是常量阶的,跟数据规模 n 没有关系,因此咱们能够忽略。第 3 行申请了一个大小为 n 的 int 类型数组,除此以外,剩下的代码都没有占用更多的空间,因此整段代码的空间复杂度就是 O(n)。
咱们常见的空间复杂度就是 O(1)、O(n)、O(n2),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。并且,空间复杂度分析比时间复杂度分析要简单不少。因此,对于空间复杂度,掌握以上述的这些内容已经足够了。
什么是复杂度分析?
为何要进行复杂度分析?
如何进行复杂度分析?
大O表示法
算法的执行时间与每行代码的执行次数成正比,用T(n) = O(f(n))表示,其中T(n)表示算法执行总时间,f(n)表示每行代码执行总次数,而n每每表示数据的规模。以时间复杂度为例,因为时间复杂度描述的是算法执行时间与数据规模的增加变化趋势,因此常量阶、低阶以及系数实际上对这种增加趋势不产决定性影响,因此在作时间复杂度分析时忽略这些项。
复杂度分析法则
1)单段代码看高频:好比循环。
2)多段代码取最大:好比一段代码中有单循环和多重循环,那么取多重循环的复杂度。
3)嵌套代码求乘积:好比递归、多重循环等
4)多个规模求加法:好比方法有两个参数控制两个循环的次数,那么这时就取两者复杂度相加。
为了表示代码在不一样状况下的不一样时间复杂度,引入最好状况时间复杂度、最坏状况时间复杂度、平均状况时间复杂度、均摊时间复杂度。在引入这几个概念以后,咱们能够更加全面地表示一段代码的执行效率。并且,这几个概念理解起来都不难。最好、最坏状况下的时间复杂度分析起来比较简单,但平均、均摊两个复杂度分析相对比较复杂。若是你以为理解得还不是很深刻,不用担忧,在后续具体的数据结构和算法学习中,咱们能够继续慢慢实践!
经常使用的复杂度级别?
多项式阶:随着数据规模的增加,算法的执行时间和空间占用,按照多项式的比例增加。包括,
$$
O(1)(常数阶)、O(logn)(对数阶)、O(n)(线性阶)、O(nlogn)(线性对数阶)、O(n2)(平方阶)、O(n3)(立方阶)
$$
非多项式阶:随着数据规模的增加,算法的执行时间和空间占用暴增,这类算法性能极差。包括,
$$
O(2^n)(指数阶)、O(n!)(阶乘阶)
$$
性能测试和复杂度分析的关系?
有人说,咱们项目以前都会进行性能测试,再作代码的时间复杂度、空间复杂度分析,是否是画蛇添足呢?并且,每段代码都分析一下时间复杂度、空间复杂度,是否是很浪费时间呢?事实上,渐进时间,空间复杂度分析为咱们提供了一个很好的理论分析的方向,而且它是宿主平台无关的,可以让咱们对咱们的程序或算法有一个大体的认识,让咱们知道,好比在最坏的状况下程序的执行效率如何,同时也为咱们交流提供了一个不错的桥梁,咱们能够说,算法1的时间复杂度是O(n),算法2的时间复杂度是O(logN),这样咱们马上就对不一样的算法有了一个“效率”上的感性认识。
固然,渐进式时间,空间复杂度分析只是一个理论模型,只能提供给粗略的估计分析,咱们不能直接判定就以为O(logN)的算法必定优于O(n), 针对不一样的宿主环境,不一样的数据集,不一样的数据量的大小,在实际应用上面可能真正的性能会不一样.针对不一样的实际状况,进而进行必定的性能基准测试也是颇有必要的,好比在统一在某一批型号手机上(一样的硬件,系统等等)进行横向基准测试,进而选择适合特定应用场景下的最优算法。
综上所述,渐进式时间,空间复杂度分析与性能基准测试并不冲突,而是相辅相成的,可是一个低阶的时间复杂度程序有极大的可能性会优于一个高阶的时间复杂度程序,因此在实际编程中,时刻关心理论时间,空间度模型是有助于产出效率高的程序的,同时,由于渐进式时间,空间复杂度分析只是提供一个粗略的分析模型,所以也不会浪费太多时间,重点在于在编程时,要具备这种复杂度分析的思惟。