目录
前言
数据结构就是指一组数据的存储结构,算法就说操作数据的一组方法。
数据结构和算法相辅相成。
数据结构为算法服务,算法则需要作用在指定数据结构之上。
复杂度
数据结构与算法解决的问题是如何更快,更省的存储和处理数据,那么我们就需要一个考量资源消耗和效率的方法,就说复杂度分析。
从低阶到高阶的复杂度有:O(1)、O(logn)、O(n)、O(nlogn)、O(n2)。
时间复杂度
概念介绍
表示算法的执行时间与数据规模之间的关系称为时间复杂度
所有代码的执行时间T(n)和每行代码的执行次数n成正比。所以可以总结为:T(n) = O(f(n))
- T(n)代表代码执行的时间,也就是时间复杂度。
- f(n)表示每行代码执行的次数总和。
- O表示代码的执行时间T(n)和f(n)表达式成正比。也就是大O时间复杂度表示法。
分析逻辑
1.总复杂度等于量级最大的代码的复杂度。
2.只要代码的执行时间不随n的增长而增长,时间复杂度则为O(1)。
举例
1 int cal(int n) {
2 int sum_1 = 0;
3 int p = 1;
4 for (; p < 100; ++p) {
5 sum_1 = sum_1 + p;
6 }
7
8 int sum_2 = 0;
9 int q = 1;
10 for (; q < n; ++q) {
11 sum_2 = sum_2 + q;
12 }
13
14 int sum_3 = 0;
15 int i = 1;
16 int j = 1;
17 for (; i <= n; ++i) {
18 j = 1;
19 for (; j <= n; ++j) {
20 sum_3 = sum_3 + i * j;
21 }
22 }
23 i=1;
24 while (i <= n)
25 { i = i * 2; }
26
27 return sum_1 + sum_2 + sum_3;
28 }
- 2,3,4,5,8,9行 的时间复杂度为O(1)
- 10,11行 的时间复杂度为O(n)
- 14,15,16行 的时间复杂度为O(1)
- 17,18行 时间复杂度为O(n)
- 19,20行 的时间复杂度为O(n^2)
- 24,25行 的时间复杂度为O(logn)
所以取量级最大的代码的复杂度则上述代码的时间复杂度为O(n^2)。
空间复杂度
概念介绍
表示算法的执行时间与存储空间之间的关系称为空间复杂度
常见的空间复杂度有 O(1)、O(n)、O(n2 )
分析逻辑
1.同时间复杂度类似
2.判断代码所需存储空间。
举例
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]
}
}
空间复杂度为: O(n)
数组
概念
数组(Array)是一种线性表数据结构,它是用一组连续的内存空间来存储数据。
线性表
线性表就是数据在二维空间表示的结构,数据排成一条线,每个线性表上的数据最多只有前和后两个方向。其实,除了数组,链表,队列,栈等都是线性表结构。二叉树,堆,图等这样子数据之间并不是简单的前后关系的为非线性结构。
连续的内存空间
因为连续的内存空间的特性
1.在数组中进行插入,删除操作,为了保证连续性,就需要做大量的数据搬移工作。
2.随机访问。计算机会给该连续空间分配地址,可以通过连续空间的首地址 + 元素所需字节数(比如int,4个字节)* 元素在数组中的位置数 来计算的出该元素所在的空间地址。所以可以达到随机访问的特性。
3.对内存要求高,如果没有连续的要申请的内存大小,可能会申请失败
复杂度分析
数组随机访问的时间复杂度为O(1)。
数组的插入和删除操作最坏则是在数组开头操作,那样则需要将所有数据往后移或者往前移,时间复杂度则为O(n)。
扩展
1.基于数组实现的ArrayList等容器与数组比较
- 容器最大的优点就是将很多数组操作的细节封装起来
- 容器支持动态扩容。比如arraylist每次存储空间不够的时候,就会将空间自动扩容为1.5倍大小。而数组需要预先指定大小
- 容器比如ArrayList无法存储基本类型,需要包装类。所以性能会稍微比数组低一点。如果特别关注性能,或者希望使用基本数据类型,就选用数组。
2.为什么数组的下标要从0开始
在进行计算某个元素的内存地址的时候公式会从:
a[k]_address = base_address(内存首地址) + k * type_size(元素类型所占字节数)
变为
a[k]_address = base_address(内存首地址) + (k-1) * type_size(元素类型所占字节数)
这样对于CPU来说,多了一次减法指令。
其次,就是效仿C语言,沿用了从0计数的习惯。
链表
概念
链表是一种线性表结构。通过指针将一组零散的内存块串联在一起
分类
常见的链表结构有: 单链表,双向链表,循环链表。
单链表
头部节点指向的是链表的基地址,尾部节点指向空地址NULL。
复杂度分析
因为链表的存储空间本身不是连续的,而链表对随机访问的性能并不好需要遍历,否则得不到对应的元素地址,则链表随机查询对应的时间复杂度为O(N).如果给定前驱节点,则插入,删除时间复杂度为O(1)。所以单纯的链表插入删除时间复杂度为O(1),但是如果没有给定,则需要遍历,那么链表的插入,删除时间复杂度为O(n).
循环链表
和单链表唯一的区别在于尾节点。循环链表的尾节点指向头节点。
优点则是从链尾到链头比较方便,当要处理的数据据有环形结构特点时即可使用循环链表。
双向链表
举例:LinkedHashMap
支持两个方向,每个节点不仅有后继指针next指向后面的节点,还有指向前面的prev节点。所以双向链表要比单向链表占用更多的内存。
时间复杂度
双向链表的删除操作,可以根据要删除的节点得到前驱节点,则不需要遍历,删除时间复杂度为O(1).
其他情况下,和单向链表一样。所以双向链表的优势在于可以通过O(1)复杂度找到前驱节点。
扩展
我们可以在链表的基础上,运用链表实现LRU淘汰策略。越靠近链表尾部的节点越早之前访问的,当要查询链表某个数据时,可以从头部开始遍历,因为头部都是最新的数据,如果此数据已经被缓存在链表中,将原位置删除,然后再插入到链表头部。如果此数据在链表中不存在,(链表没满,将该数据直接插入到头部。链表已满,将尾节点删除,插入到头部)
数组和链表对比
1.数组因为使用的连续内存空间,可以借助CPU的缓存机制,预读数组中的数据,访问效率更高。链表在内存中不是连续的,所以对CPU缓存不太友好,没办法有效预读。(CPU每次从内存读取数据时,会先将数据加载到CPU缓存中,而每次CPU不是只读取特定访问的地址,每次是读取一个数据块,并保持到CPU缓存中)
2.数组大小固定。一旦申请就需要一块连续的内存空间来占用。链表天然支持动态扩容。(ArrayList占满也会重新申请1.5倍的大小来将原数组数据拷贝过去)
3.链表占用内存更大些。因为每个节点都要存储下个节点的指针。
栈
栈:后进者先出,先进者后出。也是一种线性表结构。只允许在一端插入和删除数据。入栈(在栈顶插入)和出栈(在栈顶删除)
实际上, 栈既可以通过数组实现(顺序栈),也可以通过链表实现(链式栈)。
复杂度分析
不管是入栈还是出栈,时间复杂度都是O(1)。
不管是在入栈还是出栈的过程,只需要一两个变量的存储空间,空间复杂度也都是O(1)。
扩展
操作系统给每个线程都会分配一块独立的内存空间,这块内存被组织成栈结构,比如函数调用等操作符合后进先出的特性。本质相同。
队列
队列: 先进者先出。
队列也只支持入队(放一个数据到队列尾部)和出队(从队列头部取一个元素)。
所以,队列和栈一样,也是一种操作受限的线性表结构。
队列的应用再平常工作中有很多,比如循环队列,阻塞队列,并发队列。环形缓存等等。
实际上,队列也是既可以通过数组实现(顺序队列),也可以通过链表实现(链式队列)。
时间复杂度分析
基于数组实现在数组长度达到的时候需要扩容,所以,我们选择在每次入队的时候判断空间是否够用,不够就将数组迁移到新扩容的数组上,这样,出队的时间复杂度任然为O(1).但是入队在队满的时候的时间复杂度则变为了O(n),其他情况任是O(1).
基于链表实现,出队入队时间复杂度为O(1)
区别
基于链表可以实现一个支持无限排队的队列,但是会导致过多的请求排队等待,针对响应时间敏感的系统则无限排队是不合适的。
基于数组的队列大小有限,可以合理设置大小,队列满了可以进行阻塞
散列表
基于数组支持随机访问的特性,比如我们常常使用的HashMap则是使用散列表实现的。
但是存在散列冲突的问题。解决散列冲突的方法可以通过开放寻址法(发现冲突后,从当前位置往后一次查找,如果有空闲位置则插入。),或者链表法。hashMap则使用了链表法。
散列表在不扩容的情况下查询的时间复杂度为O(1).扩容则是O(N)。
LinkedHashMap是通过散列表+双向链表+LRU淘汰算法实现的。
散列表:插入删除查找都是O(1), 是最常用的,但其缺点是不能顺序遍历以及扩容缩容的性能损耗。适用于那些不需要顺序遍历,数据更新不那么频繁的。
二叉树
顾名思义,每个节点最多有两个叉,也就是左子节点和右子节点。
二叉树中有两种特殊的二叉树,满二叉树和完全二叉树。
二叉树存储可以有两种方式,1.基于指针的链式存储。2.基于数组的顺序存储。
每个节点除了存储数据还有指向左右子节点的指针。基于链式存储大部分都是通过这种结构实现的。
基于数组存储则根据下标计算左右位置,比如下标i的左节点就会是2*i,下标为i的右节点就为2*i+1.通过下标计算就可以把整个树串起来。
如果二叉树是完全二叉树,也就是除了最后一层其他层的节点个数都达到了最大,数组存储更省内存,因为数组根据下标计算,是连续空间存储,那么除了完全二叉树,其他情况空的节点也需要消耗多余的存储空间。所以,完全二叉树使用数组顺序存储更省空间。其他则不。这也是为什么完全二叉树要求最后一层的子节点都靠左的原因。
二叉树遍历的时间复杂度为O(n)。
数组顺序存储的方式比较适合完全二叉树,其他类型的二叉树用数组存储会比较浪费存储空间
二叉树的弊端:
查找性能偏低。
平衡二叉树/红黑树
因为二叉树的操作和树的高度成正比,为了降低二叉树的高度,查找操作的时间复杂度都是 O(logn)。
红黑树:插入删除查找都是O(logn), 中序遍历即是顺序遍历,稳定。缺点是难以实现,去查找不方便。其实跳表更佳,但红黑树已经用于很多地方了。
跳表
跳表是在链表之上加上多层索引构成的
跳表:插入删除查找都是O(logn), 并且能顺序遍历。缺点是空间复杂度O(n)。适用于不那么在意内存空间的,其顺序遍历和区间查找非常方便。