文章目录
算法与数据结构 — 结合 Java 源码分析数据结构
本博客为基本知识点的浅尝辄止,仅供参考,详细的需要读者继续查阅资料和实践。
建议读者在学习这些基础的数据结构时,能够 参照编程语言里面的源码,效果会更佳。
一、概念
-
在计算机科学中,数据结构(英语:data structure)是计算机中存储、组织数据的方式。
-
数据结构意味着接口或封装:一个数据结构可被视为两个函数之间的接口,或者是由数据类型联合组成的存储内容的访问方法封装。
-
不同种类的数据结构适合不同种类的应用,部分数据结构甚至是为了解决特定问题而设计出来的。例如B树即为加快树状结构访问速度而设计的数据结构,常被应用在数据库和文件系统上。
-
系统架构的关键因素是数据结构而非算法的见解,导致了多种形式化的设计方法与编程语言的出现。绝大多数的语言都带有某种程度上的模块化思想,透过将数据结构的具体实现封装隐藏于用户界面之后的方法,来让不同的应用程序能够安全地重用这些数据结构。C++、Java、Python等面向对象的编程语言可使用类 (计算机科学)来达到这个目的。
-
因为数据结构概念的普及,现代编程语言及其API中都包含了多种默认的数据结构,例如 C++ 标准模板库中的容器、Java集合框架以及微软的.NET Framework。
二、常见的数据结构
- 线性表
- 顺序表 - 数组(Array)
- 链表(Linked List)
- 栈(Stack)
- 队列(Queue)
- 树(Tree)
- 图(Graph)
- 堆(Heap)
- 散列表(Hash table)
1. 数组(Array)- 顺序表
-
概念:
在计算机科学中,数组数据结构(array data structure),简称数组(Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储。利用元素的索引(index)可以计算出该元素对应的存储地址。-
一维数组:
最简单的数据结构类型是一维数组。例如,索引为0到9的40位整数数组,可作为在存储器地址2000,2004,2008,…2040中,存储10个变量,因此索引为 i 的元素即在存储器中的 2000 + 4×i 地址。数组第一个元素的存储器地址称为第一地址或基础地址。
int[] array = new int[10];
-
二维数组:
二维数组,对应于数学上的矩阵概念,可表示为二维矩形格。
int[][] b = {{3, 6, 2}, {0, 1, -4}, {2, -1, 0}}; // 或 int[][] b = new int[][]{{3, 6, 2}, {0, 1, -4}, {2, -1, 0}};
-
2. 链表(Linked List)
-
概念
链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是并不会按顺序表的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。由于不必须按顺序存储,链表在插入的时候可以达到 O(1) 的复杂度,比顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间,而顺序表相应的时间复杂度分别是O(n)和O(1)。
使用链表结构可以克服数组链表需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。
链表中最简单的一种是单向链表,它包含两个域,一个数据域和一个指针域。这个指针域指向列表中的下一个节点,而最后一个节点则指向一个空值。
-
代码使用:
// LinkedList是双向链表 List<Integer> linkList = new LinkedList<>();
根据jdk中的源码,左边是 LinkedList 提供的函数API,右边箭头处可以看到 LinkedList 是双向链表。
3. 栈(Stack)
-
概念:
堆栈(stack)又称为栈或堆叠,是计算机科学中的一种抽象数据类型。
只允许在有序的线性数据集合的一端(称为栈顶端,top)进行加入数据(push)和移除数据(pop)的运算。因而按照后进先出(LIFO, Last In First Out)的原理运作。
常与另一种有序的线性数据集合队列相提并论。
堆栈常用一维数组或链表来实现。
-
操作:
堆栈使用两种基本操作:推入(压栈,push)和弹出(出栈,pop):
压栈:将数据放入堆栈顶端,堆栈顶端移到新放入的数据。
出栈:将堆栈顶端数据移除,堆栈顶端移到移除后的下一笔数据。 -
特点:
堆栈的基本特点:
先入后出,后入先出。
除头尾节点之外,每个元素有一个前驱,一个后继。 -
堆栈的应用:
回溯
递归
深度优先搜索 -
代码使用:
Stack stack = new Stack();
1. 创建栈: public Stack() { } 2. 出栈: public E push(E item) { } 3. 入栈: public synchronized E pop() { } 4. 查看栈顶元素: public synchronized E peek() { } 5. 查找元素在栈中位置:public synchronized int search(Object o) { }
4. 队列(Queue)
-
概念
队列,是先进先出(FIFO, First-In-First-Out)的线性表。在具体应用中通常用链表或者数组来实现。
队列只允许在后端(称为rear)进行插入操作,在前端(称为front)进行删除操作。 -
使用场景:
因为队列先进先出的特点,在多线程阻塞队列管理中非常适用。 -
代码使用:
Java 集合中的 Queue 接口继承自 Collection 接口 ,Deque, AbstractQueue,CheckedQueue,AsLIFOQueue,BlockingQueue,ConcurrentLinkedQueue 等类(接口)都实现或继承了它。
基础的 Queue接口,声明了如下这些方法:- add
- offer
- remove
- poll
- element
- peek
// LinkedList是双向链表,它实现了Dequeue接口 Queue<Integer> queue = new LinkedList<>();
5. 树(Tree)
-
概念:
在计算机科学中,树(tree)是一种抽象数据类型(ADT)或是实现这种抽象数据类型的数据结构,用来模拟具有树状结构性质的数据集合。它是由n(n>0)个有限节点组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:1)每个节点都只有有限个子节点或无子节点;
2)没有父节点的节点称为根节点;
3)每一个非根节点有且只有一个父节点;
4)除了根节点外,每个子节点可以分为多个不相交的子树;
5)树里面没有环路(cycle)
6. 图(Graph)
7. 堆(Heap)
8. 散列表(Hash table)
-
概念:
-
散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存储存位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。
-
若关键字为 k,则其值存放在 f(k) 的存储位置上。由此,不需比较便可直接取得所查记录。称这个对应关系 f 为散列函数,按这个思想建立的表为散列表。
-
对不同的关键字可能得到同一散列地址,即 k1 != k2,而 f(k1)=f(k2),这种现象称为 冲 突 \color{red}冲突 冲突(Collision)。具有相同函数值的关键字对该散列函数来说称做 同 义 词 \color{red}同义词 同义词。综上所述,根据散列函数 f(k) 和处理冲突的方法将一组关键字映射到一个有限的连续的地址集(区间)上,并以关键字在地址集中的“像”作为记录在表中的存储位置,这种表便称为散列表,这一映射过程称为散列造表或散列,所得的存储位置称散列地址。
-
若对于关键字集合中的任一个关键字,经散列函数映象到地址集合中任何一个地址的概率是相等的,则称此类散列函数为均匀散列函数(Uniform Hash function),这就使关键字经过散列函数得到一个“随机的地址”,从而减少冲突。
-
-
构造散列函数:
散列函数能使对一个数据序列的访问过程更加迅速有效,通过散列函数,数据元素将被更快定位。方法如下:- 直接定址法:取关键字或关键字的某个线性函数值为散列地址。即 hash(k) = k 或 hash(k) = a * k + b,其中a, b为常数(这种散列函数叫做自身函数)
- 数字分析法:假设关键字是以r为基的数,并且哈希表中可能出现的关键字都是事先知道的,则可取关键字的若干数位组成哈希地址。
- 平方取中法:取关键字平方后的中间几位为哈希地址。通常在选定哈希函数时不一定能知道关键字的全部情况,取其中的哪几位也不一定合适,而一个数平方后的中间几位数和数的每一位都相关,由此使随机分布的关键字得到的哈希地址也是随机的。取的位数由表长决定。
- 折叠法:将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为哈希地址。
随机数法 - 除留余数法:取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 hash(k)=k mod p, p <= m。不仅可以对关键字直接取模,也可在折叠法、平方取中法等运算之后取模。对p的选择很重要,一般取素数或m,若p选择不好,容易产生冲突。
-
处理冲突:
为了知道冲突产生的相同散列函数地址所对应的关键字,如当f(k1) = f(k2)时,如何确认关键字为 k1 还是 k2 ,必须选用另外的散列函数,或者对冲突结果进行处理。而不发生冲突的可能性是非常之小的,所以通常对冲突进行处理。常用方法有以下几种:-
开放定址法(open addressing):hashi = (hash(key) + di) mod m, i = 1,2…k(k <= m - 1) 其中hash(key)为散列函数,m为散列表长,di 为增量序列,i为已发生冲突的次数。增量序列可有下列取法:
- di = 1,2,3…(m-1)称为 线性探测(Linear Probing);即di = i,或者为其他线性函数。相当于逐个探测存放地址的表,直到查找到一个空单元,把散列地址存放在该空单元。
- di = ±12,±22,…,±k2称为 平方探测(Quadratic Probing)。相对线性探测,相当于发生冲突时探测间隔 di = i2 个单元的位置是否为空,如果为空,将地址存放进去。
- di = 伪随机数序列,称为 伪随机探测。
-
单独链表法:将散列到同一个存储位置的所有元素保存在一个链表中。实现时,一种策略是散列表同一位置的所有冲突结果都是用栈存放的,新元素被插入到表的前端还是后端完全取决于怎样方便。
-
双散列。
-
再散列:hashi = hashi(key), i=1,2…k。hashi 是一些散列函数。即在上次散列计算发生冲突时,利用该次冲突的散列函数地址产生新的散列函数地址,直到冲突不再发生。这种方法不易产生“聚集”(Cluster),但增加了计算时间。
-
建立一个公共溢出区
举例:
显示线性探测填装一个散列表的过程:
关键字为{89,18,49,58,69}插入到一个散列表中的情况。此时线性探测的方法是取{\displaystyle d_{i}=i}d_{i}=i。并假定取关键字除以10的余数为散列函数法则。
散列地址 空表 插入89 插入18 插入49 插入58 插入69
0 49 49 49
1 58 58
2 69
3
4
5
6
7
8 18 18 18 18
9 89 89 89 89 89
第一次冲突发生在填装49的时候。地址为9的单元已经填装了89这个关键字,所以取{\displaystyle i=1}i=1,往下查找一个单位,发现为空,所以将49填装在地址为0的空单元。第二次冲突则发生在58上,取{\displaystyle i=3}{\displaystyle i=3},往下查找3个单位,将58填装在地址为1的空单元。69同理。
表的大小选取至关重要,此处选取10作为大小,发生冲突的几率就比选择质数11作为大小的可能性大。越是质数,mod取余就越可能均匀分布在表的各处。
聚集(Cluster,也翻译做“堆积”)的意思是,在函数地址的表中,散列函数的结果不均匀地占据表的单元,形成区块,造成线性探测产生一次聚集(primary clustering)和平方探测的二次聚集(secondary clustering),散列到区块中的任何关键字需要查找多次试选单元才能插入表中,解决冲突,造成时间浪费。对于开放定址法,聚集会造成性能的灾难性损失,是必须避免的。
-
-
代码使用:
Map<String, Integer> hashtable = new Hashtable<>(); Map<String, Integer> hashMap = new HashMap<>();