Java实现数据结构与算法
- 前言
- 一、简述什么是数据结构
- 二、算法分析(时间复杂度和空间复杂度)
- 三、各类数据结构实现及应用
- 1. 数组
- 2. 链表
- 3. 栈
- 4. 队列
- 5. 树
- 5.1 二叉树
- 5.2 二叉查找树
- 5.3 AVL树[(Balanced binary search trees)](https://www.cs.usfca.edu/~galles/visualization/AVLtree.html)
- 5.4 红黑树 [Red-Black Trees](https://www.cs.usfca.edu/~galles/visualization/RedBlack.html)
- 5.5 B树以及B+树(扩展学习,可了解MySQL建索引原理)- [B-Tree](https://www.cs.usfca.edu/~galles/visualization/BTree.html)
- 6. 哈希散列
- 7. 堆与图(不常用,了解即可)
- 四、常用的排序算法
- 五、总结
前言
本篇文章主要帮助有计算机基础的同学了解一下各类数据结构的基本概念,优劣分析及使用场景,并附带了大牛文章的详解链接,可供读者学习。
一、简述什么是数据结构
数据结构主要是表示数据在计算机中的存储形式,主要表示元素之间的位置及关联关系,用现实的话来说就是用桶装,管道装,手链的串链等等,数据结构只是一种存储方式,只要实现了它的概念,不管是Java还是C++或是Python实现,都大同小异。
按照笔者个人的理解,计算机所做的事情主要是对数据的存储、传输、和计算。其中存储,我们知道计算机底层所有的数据都是0和1,即电信号,而不管是数字还是字母或者中文,都是用统一的一种编码格式来表示,目前常用的如ASCII码,Unicode编码和UTF-8编码等等。
如用49表示字符’1’,用97表示字符’a’,49转换成二机制即为0011 0001,用8位bit即一个字节来表示,所以不管是中文英文多长的数据,最终都是长度比较长的0和1而已,并无两样。
有关字符编码格式的可以参考这篇文章:字符编码笔记:ASCII,Unicode 和 UTF-8
故而之所以选择不同的数据结构,主要是为了在当前的场景下,提高数据的查询效率,或插入、删除效率,以及使用的空间大小,在其中取得一个平衡点,即算法运算的时间复杂度和空间复杂度。
二、算法分析(时间复杂度和空间复杂度)
1. 时间复杂度
统计算法运行的「计算操作的数量」,以代表算法运行所需时间,需注意不是真实的运行时间,而是操作的次数。
时间复杂度指输入数据数量为N时,算法运行所需花费的时间,时间复杂度具有「最差」、「平均」、「最佳」三种情况,分别使用O , Θ , Ω 三种符号表示。一般都使用O即最坏情况来表示时间复杂度。
根据从小到大排列,常见的算法时间复杂度主要有:
O(1) < O(log N) < O(N) < O(Nlog N) < O(N^2) < O(2^N) < O(N!)
O(1):指的是常数时间,不管输入N数量多大,算法都在固定次数内完成,范例:
O(log N):对数阶常出现于「二分法」、「分治」等算法中,体现着 “一分为二” 或 “一分为多” 的算法思想,如二叉查找树
O(N):指的是跟N成正比关系,如单层For循环
int algorithm(int N) {
int count = 0;
for (int i = 0; i < N; i++)
count++;
return count;
}
O(Nlog N):双层循环,一层为O(N),一层为O(log N)
O(N^2):一般指两层循环,都与N成正比关系
O(2^N):类似于细胞分裂,越分越多,如双递归
O(N!):阶乘,N×(N−1)×(N−2)×⋯×2×1=N!
参考链接力扣图解数据结构与算法
2. 空间复杂度
空间复杂度涉及的空间类型有:
- 输入空间: 存储输入数据所需的空间大小;
- 暂存空间: 算法运行过程中,存储所有中间变量和对象等数据所需的空间大小;
- 输出空间: 算法运行返回时,存储输出数据所需的空间大小;
而根据不同来源,算法使用的内存空间分为三类:
- 指令空间:编译后,程序指令所使用的内存空间。
- 数据空间:算法中的各项变量使用的空间,包括:声明的常量、变量、动态数组、动态对象等使用的内存空间。
- 栈帧空间:程序调用函数是基于栈实现的,函数在调用期间,占用常量大小的栈帧空间,直至返回后释放,算法中,栈帧空间的累计常出现于递归调用。
空间复杂度的计算与时间复杂度类似,这里不拓展讲解,详情可参考链接https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/r8ytog/
对于某个算法问题,同时优化时间复杂度和空间复杂度是非常困难的。降低时间复杂度,往往是以提升空间复杂度为代价的,反之亦然。
由于当代计算机的内存充足,通常情况下,算法设计中一般会采取「空间换时间」的做法,即牺牲部分计算机存储空间,来提升算法的运行速度。
三、各类数据结构实现及应用
1. 数组
数组是将相同类型的元素存储于连续内存空间的数据结构,其长度不可变。特点是连续存储,遍历查询快,增删慢
// 数组的三种初始化方式
int[] array = new int[5];
int[] array = {2, 3, 1, 0, 2};
int[] array = new int[]{2, 3, 1, 0, 2};
Java中常用的为可变数组java.util.ArrayList,里面维护了一个数组对象,初始化容量为10,当新增时插入元素到最后,若新增后容量超过数组容量,则进行1.5倍扩容,删除时找到对应元素删除,并讲该元素后面所有元素向前移动一位。
2. 链表
链表以节点为单位,每个节点为一个对象,存储value值以及下一个节点的指针next,在内存空间的存储是非连续的,特点是。
class Node {
int value; // 节点值
Node next; // 后继节点引用
Node(int x) { value = x; }
}
java中的实现为java.util.LinkedList,其中实现为双向链表,即Node节点中存储了前置节点指针prev和后置节点指针next,增加了存储空间,但是可以进行头部以及尾部的插入删除,以此可以实现stack栈和queue队列以及deque双端队列的特性。
详细可以参考这篇文章
3. 栈
栈(stack)是限制插入和删除只能在一个位置上进行的表,该位置是表的末端,叫作栈的顶
(top)。特点是后进先出(Last In First Out,LIFO),对栈的基本操作有push(进栈)和pop(出栈),前者相当于插入,后者则是删除。
栈有两种实现方式,一种是数组实现,如java.util.stack,java.util.ArrayDeque,一种是链表实现,如java.util.LinkedList,他们都实现了从同一端插入,删除的操作。
栈的应用:方法的调用,编译器平衡符号(小括号,中括号,大括号的左右平衡),HTML和XML文件中的标签匹配
详细可以参考这篇文章
4. 队列
队列是一端插入,另一端删除的数据结构,特点是先进先出(First In First Out,FIFO),有入队和出队操作。
队列同样可以用数组和链表实现,Java中java.util.queue接口中有几个方法,常用的入队操作offer(), 出队操作poll(), 其中Deque实现了queue的方法并扩展,是双端队列,java.util.ArrayDeque以及java.util.LinkedList都实现了Deque的方法,所以可以做栈或者队列的操作。
三者区别可以参考Stack,ArrayDeque,LinkedList的区别
队列的应用:消息队列,打印机队列,以及现实生活中先到先服务的场景
5. 树
树在数据结构中属于比较复杂的,可以分很多篇文章进行讲解,本文只简要叙述他们的概念和实现,以及应用场景。分为二叉树和多叉树,有序或无序(无序基本没有应用场景不讨论),包含根节点,每个节点带有他的父节点以及子孙节点的引用,查找时间复杂度为O(log N), 增删的复杂度也为O(log N),麻烦的是增删后的平衡操作,包括左旋右旋(二叉查找树),变色(红黑树),分裂合并(B树)。
5.1 二叉树
孩子节点只有两个的树的统称,分为根节点,左子树和右子树
基本概念可以参考这篇文章
5.2 二叉查找树
二叉树本身是无序的,只规定的节点的定义,二叉查找树才规定了有序,对于每个节点,左子树中的元素必须小于或等于其父节点中的键(L <P
),右子树中的元素必须大于或等于其父节点中的键( R> P
)。
因为有序,查询时就可以使用二分法查找数据,在理想的左右完全平衡的情况下,查找时间复杂度为O(log N),插入删除的时间复杂度也为O(log N)。
但很多时候插入的顺序并不是理想状态,可能会导致树偏斜,最差情况树变成了一个链表,查找复杂度就为O(N)了,故需要通过平衡提升二叉树的查找性能,就有了AVL树(平衡二叉树)以及红黑树。
5.3 AVL树(Balanced binary search trees)
平衡二叉树,一棵AVL树是其每个结点的左子树和右子树的高度最多相差1的二叉查找树(空树的高度为-1),这个差值也称为平衡因子,当插入或删除导致平衡不满足要求时,则通过左旋或者右旋来维持树的平衡,从插入点向上递归,直至整棵树保持平衡。
当整棵树基本保持平衡时,可保持查找的时间复杂度接近O(log N),插入和删除需要维护树的平衡消耗更多的性能。
AVL树由于实现比较复杂,而且插入和删除性能差,在实际环境下的应用不如红黑树。
有关AVL树的具体实现可参考此篇文章
5.4 红黑树 Red-Black Trees
红黑树是平衡二叉树的变种,降低了对平衡的要求,牺牲了小部分的查找性能,减少了插入和删除时平衡所需的时间。
红黑树的定义如下:
- 任何一个节点都有颜色,黑色或者红色。
- 根节点是黑色的。
- 父子节点之间不能出现两个连续的红节点。
- 任何一个节点向下遍历到其子孙的叶子节点,所经过的黑节点个数必须相等。
- 空节点被认为是黑色的。
由此可见,红黑树允许红色节点的存在不影响树的平衡,只需保持黑色节点的平衡,且不允许连续两个的红色节点,保证树的高度在[logN,logN+1](理论上,极端的情况下可以出现RBTree的高度达到2*logN,但实际上很难遇到)。
处理平衡时相比AVL树在旋转上多了变色的处理,维持树的平衡。
红黑树的实际应用非常广泛,比如Linux内核中的完全公平调度器、高精度计时器、ext3文件系统等等,各种语言的函数库如Java的TreeMap和TreeSet,C++ STL的map、multimap、multiset等。
有关红黑树树的具体实现可参考此篇文章
5.5 B树以及B+树(扩展学习,可了解MySQL建索引原理)- B-Tree
既然我们已经有了性能很好的红黑树,为什么还要有B树呢,原因在于当我们要检索的数据量很大时,
主内存已装不下,则需存储在磁盘中,而磁盘I/O的性能要比内存慢10万倍,故提升查询效率最重要的是减少磁盘的I/O次数。
根据局部性原理和磁盘预读,当一个数据被用到时,其附近的数据也通常会马上被使用,由于磁盘顺序读取的速度远大于随机读写的速度,故磁盘一次读写通常为页(page)的整数倍(大多数操作系统一页通常为4kb),所以我们希望索引树在一次磁盘I/O中可以读取到更多的数据,提示索引性能。
B树采用了有序数组+平衡多叉树的存储方式,如图,每个节点可以存储多个数据,节点之间的缝隙连接子树,以降低整棵树的高度且保持有序。MySQL将一页作为一个节点,假设为4kb,当我们以8个字节的bigint作为主键索引时,一个节点大约可以容纳100个关键字(每两个关键字之间存储子节点的指针也占有一部分空间),故查询的时间复杂度为O(log100 N),即100万条数据,树的高度都只有3(第一层100,第二层10000,第三层100万),在3次的磁盘I/O以及300次的内存遍历即可查询出数据,这对性能的提升是巨大的。
引用维基百科的定义
根据 Knuth 的定义,一个 m 阶的B树是一个有以下属性的树:
- 每一个节点最多有 m 个子节点
- 每一个非叶子节点(除根节点)最少有 ⌈m/2⌉ 个子节点
- 如果根节点不是叶子节点,那么它至少有两个子节点
- 有 k 个子节点的非叶子节点拥有 k − 1 个键
- 所有的叶子节点都在同一层
每一个内部节点的键将节点的子树分开。例如,如果一个内部节点有3个子节点(子树),那么它就必须有两个键: a1 和 a2 。左边子树的所有值都必须小于 a1 ,中间子树的所有值都必须在 a1 和a2 之间,右边子树的所有值都必须大于 a2 。
而其中的插入和删除主要通过节点的分裂和合并来完成,由于树的高度较低,也能在很少的次数完成。插入时分裂主要将一个节点一分为二,中间的节点移到父节点,并向上递归,删除的合并反之。
时间复杂度为O(logM N),N为关键字数量,M为每个节点能容纳的关键字数量,故而对于提升B树的索引性能,最重要的是减少磁盘I/O次数,即降低树的高度,而每个节点所能容纳的关键字越多,则树的高度越低,所以建立索引的时候,key的字节大小越小越好,最好用数字,用字符串时设置key的长度。
B+树与B树的区别在于B树的数据直接存在每个节点上,而B+树数据只存在叶子节点上,而内部节点只存储关键字,作为索引,这样就进一步提升了每个节点所能容纳的关键字数量,并且在叶子节点之间增加了下一个节点的指针,方便顺序读写和遍历,故而数据库和文件系统一般采用B+树作为索引。
想要具体了解B树和B+树可以参考这两篇文章,一篇单纯讲B树数据结构的插入删除,索引数据结构之B-Tree与B+Tree,一篇为MySQL具体实现,MySQL索引背后的数据结构及算法原理。
6. 哈希散列
Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,即哈希冲突,所以不可能从散列值来确定唯一的输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
可用与对数据的加密(不可逆),hash索引(java中hashMap的使用),常见的哈希算法有MD5,SHA-256等。
java中String的hashCode()代码如下:
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
主要将字符数组的每一个unicode编码依次乘以31再累加。超出int最大长度后为通过二进制补码运算的结果。
相关内容参考维基百科
7. 堆与图(不常用,了解即可)
二叉堆是一种基于「完全二叉树」的数据结构,可使用数组实现。以堆为原理的排序算法称为「堆排序」,基于堆实现的数据结构为「优先队列」。堆分为「大顶堆」和「小顶堆」,大(小)顶堆:任意节点的值不大于(小于)其父节点的值。
主要应用为优先队列,即对数据的优先级进行排序,可快速获取最大值(或最小值)
详细可参考Java数据结构之堆和优先队列
图是一种非线性数据结构,由「节点(顶点)vertex」和「边 edge」组成,每条边连接一对顶点。根据边的方向有无,图可分为「有向图」和「无向图」。本文 以无向图为例 开展介绍。
如下图所示,此无向图的 顶点 和 边 集合分别为:
顶点集合: vertices = {1, 2, 3, 4, 5}
边集合: edges = {(1, 2), (1, 3), (1, 4), (1, 5), (2, 4), (3, 5), (4, 5)}
可参考 算法之《图》Java实现
四、常用的排序算法
1. 插入排序
将一个记录插入到已排序好的有序表中,从而得到一个新,记录数增1的有序表。即:先将序列的第1个记录看成是一个有序的子序列,然后从第2个记录逐个进行插入,直至整个序列有序为止。
时间复杂度为O(N^2)
2. 希尔排序
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。也被称为缩减增量排序。
3. 堆排序
首先建立N个元素的二叉堆,这个阶段花费 0(N)时间。然后我们执行N次deletemax()操作,将最大节点放到最后面,并将剩下的节点重新构建二叉堆,重复此操作直到所有节点有序。deletemax()消耗时间为O(log N),故总时间为O(Nlog N)。
java代码实现
import java.util.Arrays;
public class HeapSort {
private int[] arr;
public HeapSort(int[] arr) {
this.arr = arr;
}
/**
* 堆排序的主要入口方法,共两步。
*/
public void sort() {
/*
* 第一步:将数组堆化
* beginIndex = 第一个非叶子节点。
* 从第一个非叶子节点开始即可。无需从最后一个叶子节点开始。
* 叶子节点可以看作已符合堆要求的节点,根节点就是它自己且自己以下值为最大。
*/
int len = arr.length - 1;
int beginIndex = (arr.length >> 1)- 1;
for (int i = beginIndex; i >= 0; i--)
maxHeapify(i, len);
/*
* 第二步:对堆化数据排序
* 每次都是移出最顶层的根节点A[0],与最尾部节点位置调换,同时遍历长度 - 1。
* 然后从新整理被换到根节点的末尾元素,使其符合堆的特性。
* 直至未排序的堆长度为 0。
*/
for (int i = len; i > 0; i--) {
swap(0, i);
maxHeapify(0, i - 1);
}
}
private void swap(int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
/**
* 调整索引为 index 处的数据,使其符合堆的特性。
*
* @param index 需要堆化处理的数据的索引
* @param len 未排序的堆(数组)的长度
*/
private void maxHeapify(int index, int len) {
int li = (index << 1) + 1; // 左子节点索引
int ri = li + 1; // 右子节点索引
int cMax = li; // 子节点值最大索引,默认左子节点。
if (li > len) return; // 左子节点索引超出计算范围,直接返回。
if (ri <= len && arr[ri] > arr[li]) // 先判断左右子节点,哪个较大。
cMax = ri;
if (arr[cMax] > arr[index]) {
swap(cMax, index); // 如果父节点被子节点调换,
maxHeapify(cMax, len); // 则需要继续判断换下后的父节点是否符合堆的特性。
}
}
/**
* 测试用例
*
* 输出:
* [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9, 9, 9]
*/
public static void main(String[] args) {
int[] arr = new int[] {3, 5, 3, 0, 8, 6, 1, 5, 8, 6, 2, 4, 9, 4, 7, 0, 1, 8, 9, 7, 3, 1, 2, 5, 9, 7, 4, 0, 2, 6};
new HeapSort(arr).sort();
System.out.println(Arrays.toString(arr));
}
}
参考堆排序
4. 归并排序
归并(Merge)排序法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的,然后再把有序子序列合并为整体有序序列。时间复杂度为O(Nlog N)。
采用分治法:
- 分割:递归地把当前序列平均分割成两半。
- 集成:在保持元素顺序的同时将上一步得到的子序列集成到一起(归并)。
java代码实现:
static void merge_sort_recursive(int[] arr, int[] result, int start, int end) {
if (start >= end)
return;
int len = end - start, mid = (len >> 1) + start;
int start1 = start, end1 = mid;
int start2 = mid + 1, end2 = end;
merge_sort_recursive(arr, result, start1, end1);
merge_sort_recursive(arr, result, start2, end2);
int k = start;
while (start1 <= end1 && start2 <= end2)
result[k++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++];
while (start1 <= end1)
result[k++] = arr[start1++];
while (start2 <= end2)
result[k++] = arr[start2++];
for (k = start; k <= end; k++)
arr[k] = result[k];
}
public static void merge_sort(int[] arr) {
int len = arr.length;
int[] result = new int[len];
merge_sort_recursive(arr, result, 0, len - 1);
}
5. 快速排序
快速排序使用分治法(Divide and conquer)策略来把一个序列(list)分为较小和较大的2个子序列,然后递归地排序两个子序列。平均时间复杂度为O(Nlog N),最差为O(N^2)。
步骤为:
- 挑选基准值:从数列中挑出一个元素,称为“基准”(pivot),
- 分割:重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(与基准值相等的数可以到任何一边)。在这个分割结束之后,对基准值的排序就已经完成,
- 递归排序子序列:递归地将小于基准值元素的子序列和大于基准值元素的子序列排序。
java代码实现:
public class QuickSort implements IArraySort {
@Override
public int[] sort(int[] sourceArray) throws Exception {
// 对 arr 进行拷贝,不改变参数内容
int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);
return quickSort(arr, 0, arr.length - 1);
}
private int[] quickSort(int[] arr, int left, int right) {
if (left < right) {
int partitionIndex = partition(arr, left, right);
quickSort(arr, left, partitionIndex - 1);
quickSort(arr, partitionIndex + 1, right);
}
return arr;
}
private int partition(int[] arr, int left, int right) {
// 设定基准值(pivot)
int pivot = left;
int index = pivot + 1;
for (int i = index; i <= right; i++) {
if (arr[i] < arr[pivot]) {
swap(arr, i, index);
index++;
}
}
swap(arr, pivot, index - 1);
return index - 1;
}
private void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
参考文章八大排序算法
五、总结
本文对各类常用的数据结构做了一次简要分析,对笔者也是一个学习的过程,其中学习过程参考的链接也已经挂在对应的栏目下,方便大家深入学习。
作者:龙猫帝
原文链接:https://blog.csdn.net/chang_mao/article/details/135955992?spm=1001.2014.3001.5502
版权所有,欢迎保留原文链接进行转载:)