前言
其实在Java编程中大家一直都比较忽视数据结构,大部分都是常用几个集合类型,能实现业务功能就行,对数据结构都缺乏系统认知。最近计划翻翻源码,对java中常用的数据结构加深下理解。
一、为什么要研究数据结构
应付大厂面试,升职加薪
熟悉数据结构可以帮忙我们更好的理解源码和架构设计,明白设计的精妙之处
数据结构可以帮助我们进行程序的性能优化
数据结构是软件编程的基石,具有普遍的适用性,俗称的“内功”,不像具体的技术栈,很容易过时。
二、数据结构的分类
程序 = 算法 + 数据结构
数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。可分为八大数据结构:数组(Array)、栈(Stack)、链表(Linked List)、图(Graph)、散列表(Hash)、队列(Queue)、树(Tree)、堆(Heap)。
1.数组(Array)
数组是最基本的数据结构,很多语言都内置支持数组。数组是使用一块连续的内存空间保存数据,存储一组具有相同类型的数据,保存的数据个数在分配内存的时候就是确定的。
访问数组中第n个数据(即根据数据的下标随机访问)的时间复杂度是O(1);但是要在数组中查找一个指定的数据则是O(N);
当向数组中插入或者删除数据的时候:
最好的情况是在数组的末尾进行操作,时间复杂度是O(1) ;
最坏情况是插入或者删除第一个数据,时间复杂度是 O(N) 。在数组的任意位置插入或者删除数据的时候,后面的数据全部需要移动,移动的数据还是和数据个数有关所以总体的时间复杂度仍然是 O(N) 。平均情况时间复杂度也为O(n)。
特点:按照下标(索引)查询速度快、遍历数组方便。
限制:
数组大小固定后无法扩容;
数组只能存储一种类型的数据;
添加删除慢(需要移动其它元素);
使用场景:
频繁查询,对存储空间要求不大、增加删除少的场景。
2.链表(Linked List)
链表是在非连续的内存单元中保存数据,并且通过指针将各个内存单元链接在一起,最右一个节点的指针指向NULL。链表不需要提前分配固定大小存储空间,当需要存储数据的时候分配一块内存并将这块内存插入链表中。
链表是物理存储单元上非连续的、非顺序的存储结构,数据元素的逻辑顺序是通过链表的指针地址实现,每个元素包含两个节点,一个是存储元素的数据域(内存空间),另一个是指向下一个节点的指针域。根据指针的指向,链表能形成不同的结构。
在链表中查找第n个数据以及查找指定的数据的时间复杂度是O(N),但是插入和删除数据的时间复杂度是O(1),因为只需要调整指针就可以。
像上面这样的链表结构在插入和删除的时候编程会比较困难,因为需要记住当前节点的前一个节点,这样才能完成插入和删除。为了简便通常使用带有头节点的链表:
单链表中第一个结点的存储位置叫做头指针,那么整个链表的存取就必须是从头指针开始进行了。之后的每一个结点,其实就是上一个的后继指针指向的位置。
头指针就是链表的名字,头指针仅仅是个指针而已。
添加头结点的好处:
主要是为了统一操作。头结点是为了操作的统一与方便而设立的,放在第一个元素结点之前,其数据域一般无意义(当然有些情况下也可存放链表的长度、用做监视哨等等)。
有了头结点后,对在第一个元素结点前插入结点和删除第一个结点,其操作与对其它结点的操作统一了。
上面的链表是单链表,此外还有双链表,就是节点中包含指向下一个节点的指针和指向上一个节点的指针:
不带有头节点的双向链表在插入和删除数据的时候也不会出现单链表那样的问题。此外还有一种链表是循环链表,它是将双向链表的头尾相接:
向循环双向链表和循环链表中插入或者从中删除数据只是多移动几个指针。
特点:
很常用的一种数据结构,不需要初始化容量,可以任意加减元素;
添加或删除元素时只需要改变前后两个元素节点的指针域指向地址即可,所以添加删除速度很快。
限制:
因含有大量的指针域,占用空间较大;
查找元素需要遍历链表来查找,非常耗时。
使用场景:
数据量较小,需要频繁添加删除操作的场景。
注意⚠️:
有一种说法是软件编程中只有两种数据结构:数组和链表。其他数据结构都是基于这两种数据结构的演化,由此可见数组和链表这两种数据结构的重要性。
3.队列(Queue)
队列实现了先入先出的语义,队列也可以使用数组和链表来实现。队列也是一种线性表 。不同的是,队列可以在一端添加元素,在另一端取出元素 。
队列只允许在队尾添加数据,在队头删除数据。但是可以查看队头和队尾的数据。还有一种是双端队列,在两端都可以插入和删除:
特点:先进先出(FIFO);
使用场景:因为队列先进先出的特点,在多线程阻塞队列管理中非常适用,MQ消息顺序消费,集合对象的有序遍历。
队列(Queue)在java体系中既可以采用数组实现,又可以采用双向链表实现。
ArrayDeque基于数组实现的双向链表,可以满足队列先进先出(FIFO)的特性。
LinkedList基于链表实现双向链表,也可以满足队列先进先出(FIFO)的特性。
4.栈(Stack)
栈或者称为堆栈,栈是一种特殊的线性表(桶状线性数据结构)。仅能在线性表的一端操作,栈顶允许操作,栈底不允许操作 。
特点:先进后出(FILO);
限制:只允许操作栈顶,不允许操作栈底;
使用场景:栈常应用于实现递归功能方面的场景,例如斐波那契数列。
java中栈(Stack)的相关实现:
Deque双端队列也可以用作 LIFO(后进先出)堆栈。官方推荐应优先使用Deque接口而不是旧的Stack类。当双端队列用作堆栈时,从双端队列的开头推送和弹出元素。 Stack 方法完全等同于Deque方法,如下表所示:
Stack 和 Deque 方法的比较
堆栈方法 | 等效Deque方法 |
push(e) | addFirst(e) |
pop() | removeFirst() |
peek() | peekFirst() |
请注意,当双端队列用作队列或堆栈时, peek方法同样有效; |
注意⚠️:
Java中的Deque双端队列也有2种实现,一种是基于数组,一种是基于链表。也再次验证了数组和链表在数据结构中的核心地位。
基于数组:
Deque<Integer> stack = new ArrayDeque<Integer>();
基于链表
Deque<TreeNode> stack = new LinkedList<Integer>();
有刷过leetcode的同学都会发现,现在关于递归和栈的算法题中,栈都是采用的Deque了:
比如:利用栈替代递归实现树的前序遍历
public List<Integer> preorderTraversal(TreeNode root) { //采用非递归方式实现前序遍历 List<Integer> res = new ArrayList<Integer>(); if(root == null){ return res; } //java官方文档推荐用deque实现栈(stack)。 //push压入,pop弹出,peek取出但不弹出 Deque<TreeNode> stack = new LinkedList<TreeNode>(); TreeNode node = root; while (!stack.isEmpty() || node != null) { while(node != null){ //前序遍历,先访问根节点 res.add(node.val); stack.push(node); //继续遍历下一个左节点 node = node.left; } //FIFO倒序遍历每个节点的右节点 node = stack.pop(); node = node.right; } return res; }
5.散列表(Hash)
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。针对某个数据的等值查询时间复杂度为O(1)。
数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易。
那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。
优点:一对一的查找效率很高;
缺点:一个关键字可能对应多个散列地址;需要查找一个范围时,效果不好。
hash冲突:不同的关键字经过散列函数的计算得到了相同的散列地址。
好的散列函数=计算简单+分布均匀(计算得到的散列地址分布均匀)
哈希表是种数据结构,它可以提供快速的插入操作和快速精确查找操作。
在java体系中,HashMap就是典型的散列表(Hash)结构。
其底层实现就是:数组 + 链表+(红黑树)
6.树(Tree)
维基百科中树的定义:
在计算机科学中,树(英语:tree)是一种抽象数据类型(ADT)或是实现这种抽象数据类型的数据结构,用来模拟具有树状结构性质的数据集合。它是由n(n>0)个有限节点组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:
每个节点都只有有限个子节点或无子节点;
没有父节点的节点称为根节点;
每一个非根节点有且只有一个父节点;
除了根节点外,每个子节点可以分为多个不相交的子树;
树里面没有环路(cycle)
树又分为很多种类,因使用场景的不同被定义出很多类型:
无序树:树中任意节点的子节点之间没有顺序关系,这种树称为无序树,也称为自由树;
有序树:树中任意节点的子节点之间有顺序关系,这种树称为有序树;
二叉树:每个节点最多含有两个子树的树称为二叉树;
完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个节点的二叉树,当且仅当其每一个节点都与深度为K的满二叉树中编号从1至n的节点一一对应时称之为完全二叉树。
public class TreeNode { int val; TreeNode left; TreeNode right; TreeNode() {} TreeNode(int val) { this.val = val; } TreeNode(int val, TreeNode left, TreeNode right) { this.val = val; this.left = left; this.right = right; } }
定义树的一个节点:
注意⚠️:
树可以理解成一种特殊的链表结构。
7.堆(Heap)
**堆(Heap)**是计算机科学中一类特殊的数据结构的统称,堆通常是一个可以被看做一棵完全二叉树的数组对象,根节点可以为大于等于任何子节点(也可以小于等于任意子节点,看具体的排序方法),在存取时没有任何限制,可以随意的访问某一个子节点。最大堆:每个节点的值都大于等于它的孩子节点。最小堆:每个节点的值都小于等于它的孩子节点。
注意⚠️:
特别要声明和注意的是,这里的堆仅仅是存储数据的一种结构方式,与内存的堆栈不是一个概念。
特点:
堆中某个节点的值总是不大于或不小于其父节点的值;
堆总是一棵完全二叉树。
将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的作用:
构建优先队列
支持堆排序
快速找出一个集合中的最小值(或者最大值)
在朋友面前装逼
堆和普通树的区别
堆并不能取代二叉搜索树,它们之间有相似之处也有一些不同。我们来看一下两者的主要差别:
节点的顺序。在二叉搜索树中,左子节点必须比父节点小,右子节点必须必比父节点大。但是在堆中并非如此。在最大堆中两个子节点都必须比父节点小,而在最小堆中,它们都必须比父节点大。
内存占用。普通树占用的内存空间比它们存储的数据要多。你必须为节点对象以及左/右子节点指针分配内存。堆仅仅使用一个数据来存储数组,且不使用指针。
平衡。二叉搜索树必须是“平衡”的情况下,其大部分操作的复杂度才能达到O(log n)。你可以按任意顺序位置插入/删除数据,或者使用 AVL 树或者红黑树,但是在堆中实际上不需要整棵树都是有序的。我们只需要满足堆属性即可,所以在堆中平衡不是问题。因为堆中数据的组织方式可以保证O(log n) 的性能。
搜索。在二叉树中搜索会很快,但是在堆中搜索会很慢。在堆中搜索不是第一优先级,因为使用堆的目的是将最大(或者最小)的节点放在最前面,从而快速的进行相关插入、删除操作。
优先队列:出队与入队时的顺序无关,与优先级有关。
堆是优先队列这种数据结构的一种实现方式。
/** * 基于数组实现的最大堆 * 堆中的元素需要具有可比较性,所以需要实现Comparable * 在此实现中是从数组的下标0开始存储元素,因为使用ArrayList作为数组的角色 * * @author 01 * @date 2021-01-19 **/ public class MaxHeap<E extends Comparable<E>> { /** * 使用ArrayList的目的是无需关注动态扩缩容逻辑 */ private final ArrayList<E> data; public MaxHeap(int capacity) { this.data = new ArrayList<>(capacity); } public MaxHeap() { this.data = new ArrayList<>(); } /** * 返回对中的元素个数 */ public int size() { return data.size(); } /** * 判断堆是否为空 */ public boolean isEmpty() { return data.isEmpty(); } /** * 根据传入的index,计算其父节点所在的下标 */ private int parent(int index) { if (index == 0) { throw new IllegalArgumentException("index-1 doesn't have parent."); } return (index - 1) / 2; } /** * 根据传入的index,计算其左子节点所在的下标 */ private int leftChild(int index) { return index * 2 + 1; } /** * 根据传入的index,计算其右子节点所在的下标 */ private int rightChild(int index) { return index * 2 + 2; } }
8.图(Graph)
图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。在图中的数据元素,我们称之为顶点(Vertex),顶点集合有穷非空。在图中,任意两个顶点之间都可能有关系,顶点之间的逻辑关系用边来表示,边集可以是空的。
图的数据结构还可能包含和每条边相关联的数值(edge value),例如一个标号或一个数值(即权重,weight;表示花费、容量、长度等)。
java中遇到的场景真的不多,状态机、图数据库等。
总结
本文主要对软件编程中常见的8种数据结构进行了概要说明。
1、八大数据结构:数组(Array)、栈(Stack)、链表(Linked List)、图(Graph)、散列表(Hash)、队列(Queue)、树(Tree)、堆(Heap)。
2、其中数组(Array)和链表(Linked List)是核心,其他很多结构都是基于这两种结构演变而来。