认识数据结构与算法
# 认识数据结构与算法
数据结构、算法是两个部分,但是我们在提到时,总是把它们放在一起,它们之间的关系,就像是梁山伯与祝英台、罗密欧与朱丽叶。
# 什么是数据结构
数据结构是一种存储和组织数据的方式。是相互之间存在一种或多种特定关系的数据元素的集合。
数据结构是一门研究非数值计算的程序设计问题中的操作对象,以及它们之间的关系和操作等相关问题的学科。
# 数据结构在生活中应用
我们知道,计算机中数据量非常庞大,如何以高效的方式组织和存储呢?
例如:一个庞大的图书馆中存放了大量的书籍,我们不仅仅要把书放进入,还应该在合适的时候能够取出来。
图书摆放要使得两个相关操作方便实现:
- 操作 1:新书怎么插入?
- 操作 2:怎么找到某本指定的书?
图书各种摆放方式:
方法 1:随便放
- 操作 1:哪里有空位放哪里。
- 操作 2:找某本书,累死。
方法 2:按照书名的拼音字母顺序排放
- 操作 1:新进一本《阿 Q 正传》, 按照字母顺序找到位置,插入。
- 操作 2:二分查找法。
方法 3:把书架划分成几块区域,按照类别存放,类别中按照字母顺序
- 操作 1:先定类别,二分查找确定位置,移出空位。
- 操作 2:先定类别,再二分查找。
结论:
- 解决问题方法的效率,根据数据的组织方式有关。
- 计算机中存储的数据量相对于图书馆的书籍来说数据量更大,数据更加多。
- 以什么样的方式,来存储和组织我们的数据才能在使用数据时更加方便呢?
- 这就是数据结构需要考虑的问题。
# 常见的数据结构
- 数组(Aarray)
- 栈(Stack)
- 链表(Linked List)
- 图(Graph)
- 散列表(Hash)
- 队列(Queue)
- 树(Tree)
- 堆(Heap)
数据结构和语言无关, 基本常见的编程语言都有直接或者间接的使用上述常见的数据结构
# 什么是算法
摘自 《大话数据结构》
算法Algorithm
是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。
算法有五个基本特性:输入、输出、有穷性、确定性和可行性。
- 输入:算法具有零个或多个输入
- 输出:算法至少有一个或多个输出。输出形式可以是多个打印输出,也可以是返回一个或多个值等。
- 有穷性:指算法在执行有限的步骤后,自动结束而不会出现无限循环,并且每一个步骤在可接受的时间内完成。
- 确定性:算法的每一步都具有确定的含义,不会出现二义性。
- 可行性:算法的每一步都必须是可行的。也就是说,每一步都能够通过执行有限次数完成。
# 算法设计要求
假如上海和杭州之间有一条高架线,高架线长度是 1,000,000 米,有一天高架线中有其中一米出现了故障,请你想出一种算法,可以快速定位到处问题的地方。
线性查找
- 从上海的起点开始一米一米的排查,最终一定能找到出问题的线段。
- 但是如果线段在另一头,我们需要排查 1,000,000 次,这是最坏的情况,平均需要 500,000 次。
二分查找
- 从中间位置开始排查,看一下问题出在上海到中间位置,还是中间到杭州的位置。
- 查找对应的问题后,再从中间位置分开,重新锁定一般的路程。
- 最坏的情况,需要多少次可以排查完呢? 最坏的情况是 20 次就可以找到出问题的地方。
- 怎么计算出来的呢? ,以 2 位底,1000000 的对数 ≈ 20。
你会发现,解决问题的办法有很多,但是好的算法对比于差的算法,效率天壤之别。
算法设计要求的算法不是唯一的,解决一个问题可以有多种算法。不过好的算法应该具有的四个特征是:可读性、健壮性、时间效率高和存储量低。
- 可读性:算法设计的目的是为了便于阅读、理解和交流。可读性是算法好坏很重要的标志。
- 健壮性:当输入数据不合法时,算法也能做出相关处理,而不是产生产生异常或莫名其妙的错误。
- 时间效率:算法的执行时间短,算法效率高。
- 存储量低:存储量指算法在执行过程中需要的最大存储空间,主要指算法程序运行时所占用的内存或外部硬盘的存储空间。
# 算法效率的度量方法
事后统计方法:通过设计好的测试程序和数据,利用计算机计时器对不同的算法编制的程序的运行时间进行比较,从而确定算法效率的高低。此法缺陷明显:
- 必须依据算法实现编制程序,花费时间精力。
- 受计算机硬件和软件等环境因素影响大。
- 算法的测试数据设计困难。
事前分析估算方法:在计算机程序编制前,依据统计方法对算法进行估算。
经过分析我们发现,一个用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:
- 算法采用的策略、方法:算法好坏的根本
- 编译产生的代码质量:要由软件来支持
- 问题的输入规模:输入量的多少
- 机器执行指令的速度:硬件性能
例子一:1到100求和
方法一:
let i, sum = 0, n = 100; // 执行1次
for(i = 1; i <= n; i++) { // 执行n+1次
sum += 1; // 执行n次
}
console.log(sum) // 执行1次
执行次数:1 + (n+1) + n + 1
即 2n + 3
次
方法二:
let sum = 0, n = 100; // 执行1次
sum = (1 + n) * n / 2; // 执行1次
console.log(sum) // 执行1次
执行次数:1 + 1 + 1
即 3
次
事实上两个算法的第一条和最后条语句相同,把循环看作一个整体,忽略头尾循环判断的开销,那么这两个算法其实就是2n + 1
与n
进行对比,也就是n次与1次的差距。
再来看下面的计算
let i, j, x, sum = 0, n = 100;/* 执行一次*/
for (i = 1; i <= n; i++) {
for (j = 1; j <= n; j++) {
x++; /* 执行n×n次*/
sum = sum + x;
}
}
console.log(sum); /* 执行一次*/
上面计算的是1+2+3+...+10000
,即 。循环代码的整体需要执行 次。
此时可看到,测定运行时间最可靠的办法就是计算对运行时间有消耗的基础操作的执行次数。运行时间和这个计数成正比。
我们不关心编写程序所用的程序设计语言是什么,也不关心这些程序将跑在什么样的计算机中,我们只关心它所实现的算法。这样,不计那些循环索引的递增和循环终止条件、变量声明、打印结果等操作,最终,在分析程序的运行时间时,最重要的是把程序看成是独立于程序设计语言的算法或一系列步骤。
可以从问题描述中得到结论,同样问题的输入规模是n
的求和算法:
- 第一种,求
1+2+...+n
需要一段代码运行n
次。那么这个问题的输入规模使得操作数量是f(n)=n
,显然运行100次的同一段代码规模是运算10次的10倍。 - 第二种,无论
n
为多少,运行次数都为1
,即 f(n)= 1; - 第三种,运算100次是运算10次的1000倍。因为它是 。
我们可以这样认为,随着n
值的越来越大,它们在时间效率上的差异也就越来越大。
# 函数的渐进增长
我们现在来判断一下,以下两个算法A和B哪个更好。假设两个算法的输入规模都是n
,算法A要做2n+3
次操作,算法B要做3n+1次
操作。你觉得它们谁更快呢?
次数(n) | 算法A(2n + 3) | 算法A+(2n) | 算法B(3n+1) | 算法B+(3n) |
---|---|---|---|---|
1 | 5 | 2 | 4 | 3 |
2 | 7 | 4 | 7 | 6 |
3 | 9 | 6 | 20 | 9 |
10 | 23 | 20 | 31 | 30 |
100 | 203 | 200 | 301 | 300 |
当n=1时,算法A效率不如算法B(次数比算法B要多一次)。而当n=2时,两者效率相同;当n>2时,算法A就开始优于算法B了,随着n的增加,算法A比算法B越来越好了(执行的次数比B要少)。于是我们可以得出结论,算法A总体上要好过算法B。
此时我们给出这样的定义,输入规模n在没有限制的情况下,只要超过一个数值N,这个函数就总是大于另一个函数,我们称函数是渐近增长的。
函数的渐近增长:给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么,我们说f(n)的增长渐近快于g(n)。
随着n的增大,后面的+3还是+1其实是不影响最终的算法变化的,例如算法A+与算法B+,所以,我们可以忽略这些加法常数。
后面的例子,这样的常数被忽略的意义可能会更加明显。
来看第二个例子,算法C是4n+8,算法D是
次数() | 算法C() | 算法C+() | 算法D() | 算法D+() |
---|---|---|---|---|
1 | 12 | 1 | 3 | 1 |
2 | 16 | 2 | 9 | 4 |
3 | 20 | 3 | 19 | 9 |
10 | 48 | 10 | 201 | 100 |
100 | 408 | 100 | 20001 | 10000 |
1000 | 4008 | 1000 | 2000001 | 1000000 |
当n≤3的时候,算法C要差于算法D(因为算法C次数比较多),但当n>3后,算法C的优势就越来越优于算法D了,到后来更是远远胜过。
而当后面的常数去掉后,我们发现其实结果没有发生改变。甚至我们再观察发现,哪怕去掉与n相乘的常数,这样的结果也没发生改变,算法C+的次数随着n的增长,还是远小于算法D+。也就是说,与最高次项相乘的常数并不重要。
来看第三个例子,算法E是,算法F是
次数() | 算法E() | 算法E+() | 算法F() | 算法F+() |
---|---|---|---|---|
1 | 6 | 1 | 6 | 1 |
2 | 15 | 4 | 23 | 8 |
3 | 28 | 9 | 64 | 27 |
10 | 231 | 100 | 20301 | 1000 |
100 | 20301 | 10000 | 2000301 | 10000000 |
当n=1的时候,算法E与算法F结果相同,但当n>1后,算法E的优势就要开始优于算法F,随着n的增大,差异非常明显。通过观察发现,最高次项的指数大的,函数随着n的增长,结果也会变得增长特别快。
来看最后一个例子,算法G
是 ,算法H
是 ,算法L
是
次数() | 算法G() | 算法H() | 算法L() |
---|---|---|---|
1 | 2 | 4 | 6 |
2 | 8 | 7 | 15 |
5 | 50 | 16 | 66 |
10 | 200 | 31 | 231 |
100 | 20000 | 301 | 20301 |
1000 | 2000000 | 3001 | 3003001 |
10000 | 200000000 | 30001 | 200030001 |
100000 | 20000000000 | 300001 | 20000300001 |
1000000 | 2000000000000 | 3000001 | 2000003000001 |
这组数据应该就看得很清楚。当n的值越来越大时,你会发现,3n+1已经没法和 的结果相比较,最终几乎可以忽略不计。也就是说,随着n值变得非常大以后,算法G其实已经很趋近于算法L。于是我们可以得到这样一个结论,判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数。
判断一个算法的好坏,只通过少量的数据是不能做出判断的。如果可以对比几个算法的关键执行次数函数的渐进增长性,基本就可以分析出:某个算法,随着n的增大,它会越来越优于另一个算法,或者越来越差于另一算法。这其实就是事前估算方法的理论依据,通过算法复杂度来估算算法时间效率。
# 算法的时间复杂度
# 算法时间复杂度的定义
在进行算法分析时,语句总的执行次数T(n)
是关于问题规模n
的函数,进而分析T(n)
随n
的变化情况并确定T(n)
的数量级。算法的时间复杂度,也就是算法的时间量度,记作T(n)=O(f(n))
。它表示随问题规模n
的增大,算法执行时间的增长率和f(n)
的增长率相同,称作算法的渐进时间复杂度。其中f(n)
是问题规模n
的某个函数。
这样用大写O()
来体现算法时间复杂度的记法,我们称之为大O记法。
一般情况下,随着n
的增大,T(n)
增长最慢的算法为最优算法。
显然,由此算法时间复杂度的定义可知,我们的三个求和算法的时间复杂度分别为O(n)、O(1)、O(),分别取非官方名称,常数阶、线性阶、平方阶。
# 推导大O阶方法
推导大O阶:
- 用常数1取代运行时间中的所有加法常数。
- 在修改后的运行次数函数中,只保留最高阶项。
- 如果最高阶项存在且不是1,则去除与这个项相乘的常数。
得到的结果就是大O阶。我们好像得到了一个推导算法的时间复杂度的万能公式,可是事实上,分析一个算法的时间复杂度,没有这么简单。
# 常数阶
上面提到的算法:
let sum = 0, n = 100; // 执行1次
sum = (1 + n) * n / 2; // 执行1次
console.log(sum) // 执行1次
这个算法的运行次数函数是f(n)=3
。根据推导大O阶方法,第一步就是把3改为1。并且这个算法没有最高项,所以算法的时间复杂度为O(1)
。
如果算法中sum=(1+n)*n/2
出现10句:
let sum = 0, n = 100; /* 执行1次 */
sum = (1 + n) * n / 2; /* 执行1次 */
sum = (1 + n) * n / 2; /* 执行2次 */
sum = (1 + n) * n / 2; /* 执行3次 */
sum = (1 + n) * n / 2; /* 执行4次 */
sum = (1 + n) * n / 2; /* 执行5次 */
sum = (1 + n) * n / 2; /* 执行6次 */
sum = (1 + n) * n / 2; /* 执行7次 */
sum = (1 + n) * n / 2; /* 执行8次 */
sum = (1 + n) * n / 2; /* 执行9次 */
sum = (1 + n) * n / 2; /* 执行10次 */
console.log(sum) /* 执行1次 */
事实上无论n为多少,上面的两段代码就是3次和12次执行的差异。这种与问题的大小无关(n的多少),执行事件恒定的算法,我们称之为具有O(1)
的时间复杂度,又叫常数阶。
注意:不管常数是多少,都记作O(1)
,而不能是O(3)
、O(12)
等其他任何数字。
对于分支结构而言,无论是真,还是假,执行的次数都是恒定的,不会随着n的变大而发生变化,所以单纯的分支结构(不包含在循环结构中),其时间复杂度也是O(1)
。
# 线性阶
要确定某个算法的阶次,常常需要确定某个特定语句或某个语句集运行的次数。因此,要分析算法的复杂度,关键就是要分析循环结构的运行情况。
下面代码的循环的时间复杂度为O(n)
,因为循环体中的代码需要执行n次。
for(let i = 0; i < n; i++) {
/* 时间复杂度为O(1)的程序步骤序列 */
}
# 对数阶
下面代码由于每次count*2之后,就距离n更近了一分。也就是说,有多少个2相乘后大于n,则退出循环。由 得到 。所以这个循环的时间复杂度是O(logn)
。
let count = 1;
while(count < n) {
count = count * 2;
/* 时间复杂度为O(1)的程序步骤序列 */
}
# 平方阶
下面例子是一个循环嵌套,它的内循环刚才我们已经分析过,时间复杂度为O(n)。而对于外层的循环,不过是内部这个时间复杂度为 的语句再循环n次。所以这段代码的时间复杂度是
for(let i = 0; i < n; i++) {
for(let j = 0; j < n; j++) {
/* 时间复杂度是O(1)的程序步骤序列 */
}
}
如果外循环的循环次数改成了m,时间复杂度就变为O(m*n)
。
for(let i = 0; i < m; i++) {
for(let j = 0; j < n; j++) {
/* 时间复杂度是O(1)的程序步骤序列 */
}
}
所以我们可以得出,循环的时间复杂度等于循环体的复杂度乘以该循环的运行的次数。
下面的循环嵌套,时间按复杂度是多少呢?
for(let i = 0; i < n; i++) {
for(let j = i; j < n; j++) {
/* 时间复杂度为O(1)的程序步骤序列 */
}
}
当i=0
时,内循环体执行n
次,当i=1
时,执行了n-1
次,......当i=n-1
时,执行了1
次。所以循环的执行总次数为
推导大O阶的方法,第一条,没有加常数不予考虑;第二条,只保留最高阶项,因此保留n^2^/2;第三条,去除这个项相乘的常数,也就是去除1/2,最终这段代码的时间复杂度是 。
其实理解大O推导不算难,难的是对数列的一些相关运算,这更多的是考察你的数学知识和能力,所以要想准确地求算法时间复杂度,可能需要强化你的数学,特别是数列方面的知识和解题能力。
对于方法调用的时间复杂度又如何分析?
function counter(count) {
console.log(count);
}
for (let i = 0; i < n; i++) {
counter(i);
}
函数体是打印这个参数。其实这很好理解,function函数的时间复杂度是O(1)。所以整体的时间复杂度为O(n)。
假如function是下面这样的
function counter(count) {
for (let j = count; j < n; j++) {
/* 时间复杂度为O(1)的程序步骤序列*/
}
}
事实上,这和刚才举的例子是一样的,只不过把嵌套内循环放到了函数中,所以最终的时间复杂度为 。
n++; // 执行次数为1
counter(n); // 执行次数为n
let i, j;
for (i = 0; i < n; i++) { // 执行次数为n2
counter(i);
}
for (i = 0; i < n; i++) { // 执行次数为n(n + 1)/2
for (j = i; j < n; j++) {
/* 时间复杂度为O(1)的程序步骤序列*/
}
}
它的执行次数 ,根据推导大O阶的方法,最终这段代码的时间复杂度也是 。
# 常见的时间复杂度
执行次数 | 函数阶 | 非正式术语 |
---|---|---|
O(1) | 常数阶 | |
2n+3 | O(n) | 线性阶 |
O() | 平方阶 | |
O(logn) | 对数阶 | |
O(nlogn) | nlogn阶 | |
O() | 立方阶 | |
O() | 指数阶 |
常用的时间复杂度所耗费的时间从小到大依次是:
像 、指数阶 、阶乘阶 等,除非是很小的n值,否则哪怕n只是100,都是噩梦般的运行时间。这种不切实际的算法时间复杂度,一般我们不去讨论它。
# 最坏情况与平均情况
我们在查找一个有n个随机数数组中的某个数字时,最好的情况是第一个数字就是,即O(1),当也可能最后一个找到,即O(n)。
最坏情况运行时间是一种保证,那就是运行时间将不会再怀了。在应用中,这只是一种最重要的需求,通常,除非特别指定,我们提到的运行时间都是最坏情况的运行时间。
平均运行时间就是从概率角度来看,这个数字在每一个位置的可能性是相同的,所以平均的查找时间为n/2次后发现这个元素目标。
对算法的分析,一种方法是计算所有情况的平均值,这种时间复杂度的计算方法称为平均时间复杂度。另一种方法是计算最坏情况下的时间复杂度,这种方法称为最坏时间复杂度。一般没有特殊说明情况下,都是指最坏的时间复杂度。
# 算法的空间复杂度
我们在写代码时,完全可以用空间来换取时间,比如说,要判断某某年是不是闰年,你可能会花一点心思写了一个算法,而且由于是一个算法,也就意味着,每次给一个年份,都是要通过计算得到是否是闰年的结果。
还有另一个办法就是,事先建立一个有2050个元素的数组(年数略比现实多一点),然后把所有的年份按下标的数字对应,如果是闰年,此数组项的值就是1,如果不是值为0。这样,所谓的判断某一年是否是闰年,就变成了查找这个数组的某一项的值是多少的问题。此时,我们的运算是最小化了,但是硬盘上或者内存中需要存储这2050个0和1。
这是通过一笔空间上的开销来换取计算时间的小技巧。到底哪一个好,其实要看你用在什么地方。
算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作:S(n)=O(f(n)),其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数。
一般情况下,一个程序在机器上执行时,除了需要存储程序本身的指令、常数、变量和输入数据外,还需要存储对数据操作的存储单元。若输入数据所占空间只取决于问题本身,和算法无关,这样只需要分析该算法在实现时所需的辅助单元即可。若算法执行时所需的辅助空间相对于输入数据量而言是个常数,则称此算法为原地工作,空间复杂度为 O(1)。
通常,我们都使用“时间复杂度”来指运行时间的需求,使用“空间复杂度”指空间需求。当不用限定词地使用“复度”时,通常都是指时间复杂度。
很多程序员,做了很长时间的编程工作,却始终都弄不明白算法的时间复杂度的估算,这是很可悲的一件事。因为弄不清楚,所以也就从不深究自己写的代码是否效率低下,是不是可以通过优化让计算机更加快速高效。
他们通常的借口是,现在CPU越来越快,根本不用考虑算法的优劣,实现功能即可,用户感觉不到算法好坏造成的快慢。
可事实真是这样吗?还是让我们用数据来说话吧。假设CPU在短短几年间,速度提高了100倍,这其实已经很夸张了。而我们的某个算法本可以写出时间复杂度是O(n)的程序,却写出了 的程序,仅仅因为容易想到,也容易写。即在 的时间复杂度算法程序下,速度其实只提高了10倍。
希望大家在今后的学习中,好好利用算法分析的工具,改进自己的代码,让计算机轻松一点,这样你就更加胜人一筹。