一文掌握Java 数据结构超详细笔记(收藏版)
作者:聪明勇敢有力气!
前言
Java 中的数据结构是用来存储和组织数据的方式,不同的数据结构适用于不同的场景。数据结构的理解非常重要,如果哪里写的不对,请指出。
一、数组(Array)
数组是一种线性表,就是数据排列成一条直线一样的结构。在内容空间中,数组的表现是一块连续的内存和储存有相同的数据类型。正因为这个特性,数组可以实现通过索引下标,在O(1)的时间复杂度内快速检索某个数据,这就是“随机访问”。但是由于内存空间是连续的,所以数组在进行插入和删除操作时,就需要对数据进行维护,进行大量的数据搬移工作。
数组初始化的方式
使用动态初始化
动态初始化是指先使用 new 关键字指定数组的长度,之后再为数组元素赋值。
public class ArrayDynamicInitialization { public static void main(String[] args) { // 动态初始化一个长度为 5 的整型数组 int[] arr = new int[5]; // 为数组元素赋值 arr[0] = 1; arr[1] = 2; // 输出数组元素 for (int i = 0; i < arr.length; i++) { System.out.println(arr[i]); } } }
使用静态初始化
静态初始化时,无需使用 new 关键字显式指定数组长度,而是直接在声明数组的同时为其元素赋值。
public class ArrayStaticInitialization { public static void main(String[] args) { // 静态初始化一个整型数组 int[] arr = {1, 2, 3, 4, 5}; // 输出数组元素 for (int i = 0; i < arr.length; i++) { System.out.println(arr[i]); } } }
注意:如果声明了数组但没有进行初始化就直接使用,会导致编译通过,但在运行时抛出 NullPointerException 异常。
数组能否存储空值(null)
基本类型数组不能存储 null 值。Java 中的基本数据类型(如 int、double、char 等)有其对应的默认值,当创建基本类型数组时,数组元素会被初始化为这些默认值,而不能将 null 赋值给它们。
例如:
public class PrimitiveArrayNull { public static void main(String[] args) { // 创建一个 int 类型的数组 int[] intArray = new int[3]; // 尝试将 null 赋值给基本类型数组元素,会导致编译错误 // intArray[0] = null; // 基本类型数组元素有默认值,int 类型默认值为 0 System.out.println(intArray[0]); } }
引用类型数组可以存储 null 值。引用类型数组的元素存储的是对象的引用,null 表示该引用不指向任何对象。
例如:
class Person { private String name; public Person(String name) { this.name = name; } public String getName() { return name; } } public class ReferenceArrayNull { public static void main(String[] args) { // 创建一个 Person 类型的数组 Person[] personArray = new Person[3]; // 可以将 null 赋值给引用类型数组元素 personArray[0] = null; // 创建一个 Person 对象并赋值给数组元素 personArray[1] = new Person("Alice"); // 输出数组元素 for (int i = 0; i < personArray.length; i++) { if (personArray[i] != null) { System.out.println(personArray[i].getName()); } else { System.out.println("Element at index " + i + " is null"); } } } }
Java中的包装类是引用数据类型。包装类(Wrapper Classes)是为Java中的8种基本数据类型(boolean、char、byte、short、int、long、float、double)分别定义的对应引用类型。这些包装类包括:Boolean、Character、Byte、Short、Integer、Long、Float、Double。
包装类的作用是将基本数据类型转换为引用数据类型,以便在泛型和集合中使用。通过包装类,可以将基本数据类型作为对象处理,从而可以使用对象的属性和方法。例如,Integer类提供了将字符串转换为整数的静态方法valueOf(),以及将整数转换为字符串的toString()方法。
内置方法
新增
数组没有内置的新增方法,只能根据索引赋值,(如 arr[0] = 10;)。
删除
数组没有内置的删除方法,不过,你可以通过一些间接的方法来模拟删除操作。如果需要频繁进行删除操作,使用 Java 的集合类(如 ArrayList)会更方便。
例如:
创建一个新数组,把原数组中除了要删除元素之外的其他元素复制到新数组中。
public class DeleteElementByNewArray { public static void main(String[] args) { int[] originalArray = {1, 2, 3, 4, 5}; int indexToDelete = 2; // 要删除元素的索引 int[] newArray = new int[originalArray.length - 1]; int newIndex = 0; for (int i = 0; i < originalArray.length; i++) { if (i != indexToDelete) { newArray[newIndex] = originalArray[i]; newIndex++; } } // 输出新数组元素 for (int element : newArray) { System.out.println(element); } } }
修改
数组没有内置的修改方法,只能覆盖值,通过索引赋值(如 arr[0] = 10;)是唯一修改数组元素的方式。
查询
数组没有内置专门用于查询特定元素是否存在或获取元素位置的方法,通过循环遍历数组中的每一个元素,将其与目标元素进行比较,从而判断数组中是否包含该元素,或者找到元素所在的位置。
遍历
数组没有内置的遍历方法,只能通过传统 for 循环,或增强 for 循环(for - each 循环)实现数组的遍历。
扩容
无法直接扩容:数组没有像 ArrayList 的 add() 方法那样的动态扩容机制。
存储位置
不管是基本类型的数组还是其他对象数组,都会存于堆内存。栈内存里只存放指向这些数组对象的引用, 通过引用可以在程序中对数组对象进行操作。
数组对象存于堆内存
堆内存是用于存储对象实例的区域。数组本质上也是对象,不管是基本类型(如 int、char 等)的数组,还是对象类型(如自定义类、String 等)的数组,都是通过 new 关键字或者静态初始化(编译器会隐式转换为 new 操作)来创建的,而使用 new 操作创建的对象都会被分配到堆内存中。
对于对象类型,这些引用也存储在堆上的数组对象内部;而对象实体本身则可能(但不是一定)存储在堆上。
栈内存存放数组对象引用
栈内存主要用于存储局部变量和方法调用的上下文信息。当在方法内部声明一个数组变量时,这个变量实际上是一个引用类型的变量,它存储在栈内存中,其值是指向堆内存中数组对象的地址。
在程序中,对数组对象的各种操作(如访问元素、修改元素值等)都是通过栈内存中的引用进行的。
优点和影响
动态内存分配: 堆内存允许在运行时动态分配和释放内存,使得数组的大小可以根据程序的需求进行调整(虽然 Java 数组本身大小固定,但可以通过创建新数组并复制元素来模拟动态调整)。
多方法共享对象: 不同的方法可以通过引用访问同一个堆内存中的数组对象,实现数据的共享和传递。
不过,也存在一些潜在的问题,比如堆内存的垃圾回收机制会带来一定的性能开销,需要合理管理对象的生命周期以避免内存泄漏。
数组特性:
查询和修改速度快
连续内存存储:数组在内存中是一段连续的存储空间,每个元素在内存中依次排列
高效的地址计算:根据数组的起始地址和元素的索引,能通过简单的计算快速定位到元素的位置。假设数组的起始地址是 baseAddress,每个 int 类型元素占用 4 个字节,要访问索引为 i 的元素,其内存地址可以通过公式 baseAddress + i * 4 计算得出。这种基于地址计算的访问方式时间复杂度是 O(1),意味着无论数组的长度是多少,访问任意元素的时间都是固定的,所以查询速度非常快。
直接覆盖值:修改数组元素时,只需定位到该元素的内存位置,然后将新的值覆盖原来的值即可。由于查询元素位置的操作很快,所以修改操作也能快速完成,时间复杂度同样为 O(1)。
增加和删除操作慢
插入操作:在数组中插入元素时,通常需要将插入位置之后的所有元素向后移动,为新元素腾出空间。
删除操作:删除数组中的元素时,需要将删除位置之后的所有元素向前移动,填补删除元素所留下的空位。
扩容问题:如果数组容量不足,在增加元素时还需要进行扩容操作。通常的做法是创建一个更大的新数组,再把原数组的元素复制到新数组中,这也会增加操作的时间复杂度。
基础数组,在增加和删除,以及扩容上,其实不符合数据结构中对数组的定义,因为原生的数组没有增加和删除方法,也不能自动扩容。
在深入一些,数组的底层代码数据结构
在 Java 里,数组实际上是一个对象。每个数组对象都包含一个头部信息和元素数据区。
头部信息: 包含数组的一些元数据,例如数组的长度。这个长度信息在数组创建时就被确定,并且后续无法更改。可以通过数组对象的 length 属性来获取这个长度值,像 arr.length 就能得到数组 arr 的长度。
元素数据区: 数组的元素存储在连续的内存块中。对于基本数据类型的数组(如int[]、double[]等),这些元素直接存储在这个内存块中。对于对象数组(如Object[]、String[]等),这些对象引用存储在内存块中,而对象本身一般存储在堆内存中。
多维数组
Java 支持多维数组,多维数组本质上是数组的数组。例如,二维数组可以看作是由多个一维数组组成的。
int[][] twoDArray = new int[3][4];
这里创建了一个 3 行 4 列的二维数组。在底层,它首先是一个包含 3 个元素的一维数组,每个元素又是一个包含 4 个 int 类型元素的一维数组。每个一维子数组在内存中也是连续存储的,但不同的一维子数组在内存中不一定是连续的。
二、链表
在 Java 里,链表主要有三种,分别是单向链表、双向链表和循环链表。链表的节点和数组一样,都存储在堆内存中。链表的每个节点都是独立的对象,通过引用相互连接,数组是连续内存,而链表是不连续内存。
单向链表
结构特点
单向链表由一系列节点构成,每个节点包含两部分:数据域和指向下一个节点的引用(指针)。链表的第一个节点称为头节点,最后一个节点的引用指向 null,表示链表的结束。
插入和删除操作: 在已知节点的情况下,在链表中间插入或删除节点的时间复杂度为 O(1),因为只需要修改节点的引用。但如果要在指定位置插入或删除节点,需要先遍历链表找到该位置,此时时间复杂度为 O(n)。
查询操作: 由于单向链表只能从头节点开始依次遍历,所以查询指定位置或值的节点的时间复杂度为 O(n)。
空间开销: 每个节点只需要额外存储一个指向下一个节点的引用,空间开销相对较小。
双向链表
结构特点
双向链表的节点除了包含数据域和指向下一个节点的引用外,还包含一个指向前一个节点的引用。这使得链表可以双向遍历,既可以从头节点开始向后遍历,也可以从尾节点开始向前遍历。
插入和删除操作: 在已知节点的情况下,插入和删除节点的时间复杂度为
O(1),因为可以直接修改前后节点的引用。同样,如果要在指定位置插入或删除节点,需要先遍历链表找到该位置,时间复杂度为 O(n)。
查询操作: 查询指定位置或值的节点的时间复杂度为 O(n),但可以根据节点的位置选择从头部或尾部开始遍历,在某些情况下可以提高查询效率。
空间开销: 每个节点需要额外存储两个引用(指向前一个节点和后一个节点),空间开销相对单向链表较大。
循环链表
结构特点
循环链表分为单向循环链表和双向循环链表。单向循环链表中,最后一个节点的引用指向头节点,形成一个闭环;双向循环链表中,头节点的前一个引用指向尾节点,尾节点的后一个引用指向头节点。
插入和删除操作: 与单向链表和双向链表类似,在已知节点的情况下,插入和删除节点的时间复杂度为 O(1),但在指定位置插入或删除节点需要先遍历链表,时间复杂度为 O(n)。
查询操作: 查询指定位置或值的节点的时间复杂度为 O(n),由于链表是循环的,在遍历过程中需要注意避免陷入无限循环。
应用场景: 循环链表适用于需要循环访问数据的场景,如实现循环队列、游戏中的循环角色列表等。
数组与链表的区别是什么?
内存分配:
数组在内存中是连续分配的,而链表的节点在内存中不一定连续,通过指针相互连接。
随机访问:
数组支持随机访问,通过索引可以直接访问数组中的元素,时间复杂度为 O (1)。链表不支持随机访问,要访问某个节点,需要从链表头开始遍历,时间复杂度为 O (n)。
插入和删除操作:
在数组中间插入或删除元素时,需要移动大量元素,时间复杂度为 O (n)。链表在插入和删除节点时,只需修改相关节点的指针,时间复杂度为 O (1)(前提是已经找到要操作节点的前驱节点)。
内存空间:
数组需要预先分配固定大小的内存空间,如果元素数量不确定,可能会造成内存浪费或溢出。链表则根据需要动态分配内存,灵活性更高,但每个节点需要额外的空间存储指针。
三、Collection (单列集合)
List接口:
List是一个有序的集合,允许重复元素,并且每个元素都有一个位置索引。
ArrayList:
底层数据结构是数组,查询快,增删慢,线程不安全,效率高,可以存储重复元素。
ArrayList扩容机制
ArrayList 的底层是基于数组实现的,当向 ArrayList 中添加元素时,如果当前数组的容量不足以容纳新元素,就会触发扩容操作。
具体的扩容步骤如下:
计算新容量:默认情况下,新容量是旧容量的 1.5 倍(oldCapacity + (oldCapacity >> 1))。例如,初始容量为 10,当添加第 11 个元素时,会触发扩容,新容量为 10 + (10 >> 1) = 15。
创建新数组: 根据计算得到的新容量,创建一个新的数组。
复制元素: 将旧数组中的元素复制到新数组中。
更新引用: 将 ArrayList 内部的数组引用指向新数组。
LinkedList:
底层数据结构是双向链表,查询慢,增删快,线程不安全.效率高,可以存储重复元素。
LinkedList不存在 “扩容” 概念的原因
与 ArrayList 基于数组实现不同,LinkedList 是基于链表实现的。数组在创建时需要指定固定的长度,当元素数量超过数组容量时,需要创建一个更大的数组并将原数组元素复制过去,这就是 ArrayList 的扩容机制。而 LinkedList 中的节点是动态分配内存的,每添加一个新元素,就会创建一个新的节点对象,并将其插入到链表中,不需要预先分配固定大小的内存空间,因此不存在扩容的问题。只要系统的内存足够,就可以不断地向 LinkedList 中添加元素。
Vector:
底层数据结构是数组,查询快.,增删慢. 线程安全,它通过在方法上使用 synchronized 关键字来保证线程安全,效率低,可以存储重要元素。
在选择线程安全的 List 时,需要根据具体的业务场景来决定:
如果对性能要求不高,且使用简单,可以选择 Vector。
如果需要将现有的非线程安全的 List 转换为线程安全的,可以使用 Collections.synchronizedList。
如果是读多写少的场景,CopyOnWriteArrayList 是一个更好的选择。
CopyOnWriteArrayList :
是 Java 并发包(java.util.concurrent)中提供的线程安全的 List 实现。它采用==写时复制(Copy-On-Write)==的策略,当进行写操作(如 add、remove 等)时,会先将原数组复制一份,在新数组上进行操作,操作完成后再将原数组的引用指向新数组。读操作则直接在原数组上进行,不需要加锁。
优缺点:
优点:读操作不需要加锁,并发读的性能非常高,适用于读多写少的场景。
缺点:写操作需要复制数组,会占用额外的内存空间,并且写操作的性能较低。同时,由于写时复制的特性,迭代器遍历的是创建迭代器时的数组副本,可能无法反映最新的修改。
Set接口:
Set是一个不包含重复元素的集合,没有顺序的概念。
HashSet:
hashSet 是基于哈希表的 Set 实现,提供了快速查找、添加和删除性能,但它不保证元素的顺序,并允许一个 null 元素。
LinkedHashSet:
LinkedHashSet 继承自 HashSet,并且维护了一个双向链表来记录插入顺序,因此它既保持了插入顺序又具备 HashSet 的高效性能。
TreeSet :
TreeSet 实现了 SortedSet 接口,能够对元素进行排序,默认按照自然顺序或根据提供的比较器进行排序。底层使用红黑树数据结构,保证了对数级别的性能。
TreeSet 的对数级性能意味着其操作时间与数据规模的对数成正比,这使得它在需要排序和高效操作的场景中表现优异。红黑树的自平衡特性是实现这一性能的关键。
安全的ConcurrentSkipListSet
ConcurrentSkipListSet 是线程安全的 SortedSet 实现,基于跳表(Skip List)结构,支持并发访问而不需要外部同步。采用了无锁算法(基于 CAS,Compare-And-Swap)来保证并发操作的线程安全性,避免了传统锁机制带来的性能开销。
跳表是什么,下次再更
Queue接口:
队列是一个典型的先进先出的容器,常被当作一种可靠的将对象从程序的某个区城传输到另-区域的途径.,在并发编程中特别重要。
四、Map(双列集合)
Map保存具有映射关系的数据。 key和value。 key不能重复,没有继承Collection接口。
HashMap
HashMap中的对象并不是线程安全的,最多只有一个key值为null。但可以有多个value值为null,性能最好。HashMap 允许 key 为 “”,并且可以和其他键同时存在,因为空字符串也是一个有效的键。
底层结构: 基于数组 + 链表 + 红黑树实现。数组作为哈希桶,链表用于解决哈希冲突,当链表长度超过 8 且数组长度大于 64 时,链表会转化为红黑树,以提升查找性能。
扩容触发条件: HashMap 有两个关键参数,初始容量(默认 16)和负载因子(默认 0.75)。当元素数量超过阈值(阈值 = 容量 × 负载因子)时,就会触发扩容操作。
扩容步骤
创建新数组:新数组的容量是原数组的 2 倍。
重新计算哈希值并迁移元素:将原数组中的元素重新计算哈希值,然后根据新的哈希值插入到新数组的相应位置。对于链表节点,会根据新的哈希值拆分成两个链表;对于红黑树节点,会拆分成两个红黑树或链表。
LinkedHashMap
LinkedHashMap 结合了哈希表和双向链表的特性。它不仅像 HashMap 一样可以快速地存储和检索键值对,还能维护键值对的插入顺序(默认)或者访问顺序。
底层结构: 继承自 HashMap,在 HashMap 的基础上维护了一个双向链表,用于记录元素的插入顺序或访问顺序。
扩容触发条件: 和 HashMap 一致,当元素数量超过阈值时触发扩容。
扩容步骤: 和 HashMap 相同,创建新数组,重新计算哈希值并迁移元素,同时维护双向链表的顺序。
Hashtable
是同步的.这个类中的-些方法加入了synchronized关键字,保证了
Hashtable中的对象是线程安全的,性能最差,
Hashtable 不允许 key 为 null。当尝试将 null 作为 key 插入 Hashtable 时,会抛出 NullPointerException。这是因为 Hashtable 的 put 方法内部会对 key 进行 null 检查。
Hashtable 允许 key 为 “”,可以正常存储和访问。
底层结构: 基于数组 + 链表实现,通过 synchronized 保证线程安全。
扩容触发条件:当元素数量超过阈值(阈值 = 容量 × 负载因子,默认负载因子为 0.75)时,触发扩容。
扩容步骤 创建新数组:新数组容量为原数组的 2 倍 + 1。
重新计算哈希值并迁移元素:将原数组元素重新计算哈希值,插入到新数组相应位置。
TreeMap
底层基于 红黑树(Red-Black Tree) 实现,这是一种自平衡的二叉搜索树。红黑树的性质确保了键的有序性,每个节点存储的是 Entry<K, V> 对象,其中 K 是键,V 是值。
TreeMap 不允许 key 为 null,因为 TreeMap 基于红黑树实现,需要对 key 进行比较排序,而 null 无法参与比较,所以如果尝试将 null 作为 key 插入 TreeMap,会抛出 NullPointerException。
TreeMap 允许 key 为 “”,它会根据键的自然顺序或指定的比较器对空字符串键进行排序存储。
底层结构: 基于红黑树实现,元素按照键的自然顺序或指定的比较器顺序排序。
扩容机制: TreeMap 不存在像数组那样的扩容概念。红黑树是动态调整结构的,插入和删除元素时会通过旋转和变色操作来保持树的平衡。
在选择线程安全的 Map 时,需要根据具体的业务场景来决定:
如果对性能要求不高,且使用简单,可以选择 Hashtable。
如果需要将现有的非线程安全的 Map 转换为线程安全的,可以使用 Collections.synchronizedMap。
如果是高并发的读写场景,ConcurrentHashMap 是一个不错的选择。
如果需要键有序且支持并发操作,可以选择 ConcurrentSkipListMap。
安全的map
ConcurrentHashMap
原理: ConcurrentHashMap 是 Java 并发包(java.util.concurrent)中提供的线程安全的 Map 实现。在 Java 7 及以前,它采用分段锁机制,将整个 Map 分成多个段(Segment),不同的段可以被不同的线程同时访问,从而提高并发性能。在 Java 8 及以后,ConcurrentHashMap 采用了 CAS(Compare-And-Swap)和 synchronized 来实现并发控制,进一步优化了性能。
ConcurrentHashMap不允许 key 为 null。因为 ConcurrentHashMap 是为多线程环境设计的,null 可能会导致歧义,例如在 get 方法返回 null 时,无法确定是键不存在还是值为 null,所以不允许 key 为 null。
ConcurrentHashMap 允许 key 为 “”,可以正常存储和访问。
底层结构: Java 8 及之后版本采用数组 + 链表 + 红黑树的结构,通过 CAS 和 synchronized 保证线程安全。
扩容触发条件: 和 HashMap 类似,当元素数量超过阈值时会触发扩容。此外,插入元素时若发现链表长度超过 8 且数组长度小于 64,会先尝试扩容数组而非将链表转为红黑树。
扩容步骤 创建新数组:新数组容量为原数组的 2 倍。
多线程协助迁移元素:一个线程发现需要扩容时,先创建新数组并标记扩容戳。其他线程在插入元素时若发现正在扩容,会协助进行元素迁移。每个线程负责迁移一段连续的桶,迁移完成后将原数组对应桶置为 null。
ConcurrentSkipListMap
原理: ConcurrentSkipListMap 也是 Java 并发包中的线程安全的 Map 实现,它基于跳表(Skip List)数据结构。跳表是一种随机化的数据结构,它通过在每个节点中维护多个指向其他节点的指针,从而可以在 O(logn) 的平均时间复杂度内完成插入、删除和查找操作。ConcurrentSkipListMap 的键是有序的,默认按照键的自然顺序排序,也可以通过构造函数传入 Comparator 来指定排序规则。
ConcurrentSkipListMap不允许 key 为 null。ConcurrentSkipListMap 实现了 SortedMap 接口,它会对 key 进行排序。其底层基于跳表(Skip List)数据结构,排序操作依赖于 key 实现 Comparable 接口或者在创建 ConcurrentSkipListMap 时传入一个 Comparator 来定义排序规则。由于 null 无法与其他对象进行比较,所以不能作为 key 使用。
在多线程环境下,如果允许 key 为 null,在进行查找、插入等操作时会产生歧义。例如,当调用 get 方法返回 null 时,无法确定是因为 key 不存在,还是 key 对应的 value 为 null。
ConcurrentSkipListMap 没有传统的扩容机制,而是通过跳表的动态调整来适应元素的插入和删除操作。这种方式使得 ConcurrentSkipListMap 具有良好的并发性能和自适应性能,能够在不同的数据规模下保持高效的操作。
五、增删改查方法
操作类型 | List(以 ArrayList 为例 | Set(以 HashSet 为例 | Map(以 HashMap 为例) |
---|---|---|---|
添加元素 | boolean add(E e):在列表末尾添加元素 void add(int index, E element):在指定位置插入元素 | boolean add(E e):如果集合中不存在该元素,则添加并返回 true,否则返回 false | V put(K key, V value):将指定的键值对插入 Map,若键已存在则覆盖旧值并返回旧值,不存在则返回 null |
删除元素 | E remove(int index):移除指定位置的元素并返回该元素 boolean remove(Object o):移除列表中首次出现的指定元素,成功移除返回 true | boolean remove(Object o):移除集合中指定的元素,成功移除返回 true | V remove(Object key):移除指定键对应的键值对,并返回该键对应的值,若键不存在则返回 null |
修改元素 | E set(int index, E element):用指定元素替换列表中指定位置的元素,并返回被替换的元素 | 一般没有直接修改元素的方法,通常先删除再添加新元素 | V put(K key, V value):若键已存在,用新值替换旧值并返回旧值 |
查找元素 | E get(int index):返回列表中指定位置的元素 int indexOf(Object o):返回列表中首次出现指定元素的索引,若不存在则返回 -1 ,int lastIndexOf(Object o):返回列表中最后一次出现指定元素的索引,若不存在则返回 -1 | boolean contains(Object o):判断集合中是否包含指定元素 | V get(Object key):返回指定键对应的值,若键不存在则返回 null,boolean containsKey(Object key):判断 Map 中是否包含指定的键,boolean containsValue(Object value):判断 Map 中是否包含指定的值 |
List 操作:使用 ArrayList 演示,借助 add 方法添加元素,set 方法修改元素,remove 方法删除元素,get 方法查找元素。
Set 操作:使用 HashSet 演示,通过 add 方法添加元素,remove 方法删除元素,contains 方法查找元素。
Map 操作:使用 HashMap 演示,利用 put 方法添加或修改元素,remove 方法删除元素,get 方法查找元素。
六、迭代器
迭代器(Iterator)是一种设计模式,它提供了一种统一的方式来遍历集合(如 List、Set、Map 等)中的元素,而不需要关心集合的具体实现细节。
迭代器是一个对象,它实现了 java.util.Iterator 接口,该接口定义了三个主要方法:
boolean hasNext(): 判断集合中是否还有下一个元素,如果有则返回 true,否则返回 false。
E next(): 返回集合中的下一个元素,并将迭代器的位置向后移动一位。如果没有下一个元素,调用该方法会抛出 NoSuchElementException 异常。
void remove(): 移除迭代器最后返回的元素。该方法只能在每次调用 next() 方法之后调用一次,如果违反此规则,会抛出 IllegalStateException 异常。
遍历 List(set使用方式和list相同)
import java.util.ArrayList; import java.util.Iterator; import java.util.List; public class IteratorListExample { public static void main(String[] args) { List<String> list = new ArrayList<>(); list.add("apple"); list.add("banana"); list.add("cherry"); // 获取迭代器 Iterator<String> iterator = list.iterator(); // 遍历集合 while (iterator.hasNext()) { String element = iterator.next(); System.out.println(element); } } }
遍历 Map
对于 Map,需要先获取 Map 的 entrySet 或 keySet,然后再使用迭代器进行遍历。
mport java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.Map.Entry; public class IteratorMapExample { public static void main(String[] args) { Map<String, Integer> map = new HashMap<>(); map.put("one", 1); map.put("two", 2); map.put("three", 3); // 获取 entrySet 的迭代器 Iterator<Entry<String, Integer>> iterator = map.entrySet().iterator(); // 遍历集合 while (iterator.hasNext()) { Entry<String, Integer> entry = iterator.next(); System.out.println(entry.getKey() + ": " + entry.getValue()); } } }
优点
统一的遍历方式: 迭代器提供了一种统一的方式来遍历不同类型的集合,使得代码更加简洁和易于维护。
支持并发修改检测: 在使用迭代器遍历集合时,如果在迭代过程中对集合进行了结构上的修改(如添加、删除元素),会抛出 ConcurrentModificationException 异常,从而避免了并发修改带来的问题。
可以在遍历过程中删除元素:通过迭代器的 remove() 方法,可以在遍历集合的过程中安全地删除元素。
缺点
只能单向遍历: 迭代器只能从前往后依次遍历集合中的元素,不能反向遍历。
性能相对较低: 与增强 for 循环和 forEach 方法相比,迭代器的性能相对较低,因为它需要额外的方法调用和状态维护。
七、其他遍历方法
增强 for 循环
增强 for 循环是 Java 5 引入的一种简化的遍历方式,它可以更方便地遍历数组和集合。与迭代器相比,增强 for 循环的语法更加简洁,但它不能在遍历过程中删除元素。
import java.util.ArrayList; import java.util.List; public class EnhancedForLoopExample { public static void main(String[] args) { List<String> list = new ArrayList<>(); list.add("apple"); list.add("banana"); list.add("cherry"); // 使用增强 for 循环遍历集合 for (String element : list) { System.out.println(element); } } }
forEach 方法
forEach 方法是 Java 8 引入的一种函数式编程风格的遍历方式,它结合了 Lambda 表达式,可以更简洁地遍历集合。与迭代器相比,forEach 方法的语法更加简洁,但它也不能在遍历过程中删除元素。
import java.util.ArrayList; import java.util.List; public class ForEachExample { public static void main(String[] args) { List<String> list = new ArrayList<>(); list.add("apple"); list.add("banana"); list.add("cherry"); // 使用 forEach 方法遍历集合 list.forEach(element -> System.out.println(element)); } }
八、 栈(Stack)
栈是一种线性数据结构,它就像一叠盘子,你只能从最上面添加或移除盘子。添加元素的操作叫入栈(push),移除元素的操作叫出栈(pop)。除此之外,通常还会有查看栈顶元素(peek)、判断栈是否为空(isEmpty)以及获取栈中元素数量(size)等操作。
java.util.Stack 类
Stack 类继承自 Vector 类,它是 Java 早期提供的栈实现。不过,由于 Vector 是线程安全的,其操作会带来一定的性能开销。
import java.util.Stack; public class StackExample { public static void main(String[] args) { Stack<Integer> stack = new Stack<>(); // 入栈操作 stack.push(1); stack.push(2); stack.push(3); // 出栈操作 int poppedElement = stack.pop(); System.out.println("出栈元素: " + poppedElement); // 查看栈顶元素 int topElement = stack.peek(); System.out.println("栈顶元素: " + topElement); // 判断栈是否为空 boolean isEmpty = stack.isEmpty(); System.out.println("栈是否为空: " + isEmpty); // 获取栈的大小 int size = stack.size(); System.out.println("栈的大小: " + size); } }
Deque 接口
Deque(双端队列)接口可以当作栈来使用,并且它的性能通常比 Stack 类更好。ArrayDeque 是 Deque 接口的一个常用实现类。
import java.util.ArrayDeque; import java.util.Deque; public class DequeAsStackExample { public static void main(String[] args) { Deque<Integer> stack = new ArrayDeque<>(); // 入栈操作 stack.push(1); stack.push(2); stack.push(3); // 出栈操作 int poppedElement = stack.pop(); System.out.println("出栈元素: " + poppedElement); // 查看栈顶元素 int topElement = stack.peek(); System.out.println("栈顶元素: " + topElement); // 判断栈是否为空 boolean isEmpty = stack.isEmpty(); System.out.println("栈是否为空: " + isEmpty); // 获取栈的大小 int size = stack.size(); System.out.println("栈的大小: " + size); } }
使用场景
栈在很多场景中都有广泛应用,以下是一些常见的例子:
表达式求值:在计算中缀表达式、后缀表达式时,栈可以用来处理运算符和操作数,确保运算顺序正确。
函数调用:在程序执行过程中,函数调用栈用于记录函数的调用关系和局部变量,当函数调用结束时,会按照后进先出的顺序返回。
回溯算法:在回溯算法中,栈可以用来保存路径和状态,方便进行回溯操作。
浏览器的前进后退功能:浏览器使用两个栈分别记录用户的前进和后退历史,通过栈的操作实现页面的前进和后退。
九、树(Tree)
二叉树(Binary Tree)
结构灵活: 每个节点最多有两个子节点,结构相对简单但能表示各种复杂的层次关系。
无特定顺序: 节点的值没有特定的大小顺序要求,节点的排列可以非常多样化。
应用广泛: 是许多其他树结构的基础,可用于模拟各种具有层次特性的场景。
原理
二叉树由节点和边组成,节点包含数据和指向左右子节点的引用。节点之间通过这些引用形成树状结构。创建和操作二叉树主要围绕节点的插入、删除和遍历展开。在插入节点时,根据具体需求和规则将新节点添加到合适的位置;删除节点时,需要处理节点的子节点以及维护树的结构完整性;遍历则是按照特定顺序访问树中的每个节点。
用于表示具有层次关系的数据,如文件系统目录结构、XML 解析等。
二叉搜索树(Binary Search Tree,BST)
有序性: 对于树中的任意节点,其左子树中所有节点的值都小于该节点的值,右子树中所有节点的值都大于该节点的值。
高效查找: 平均情况下,查找、插入和删除操作的时间复杂度为 (O(log n)),因为每次操作都可以根据节点值的大小缩小搜索范围。
中序遍历有序: 对二叉搜索树进行中序遍历会得到一个有序的节点值序列。
原理
基于节点值的比较来构建和维护树的结构。插入新节点时,从根节点开始比较,若新节点值小于当前节点值,则进入左子树继续比较;若大于,则进入右子树,直到找到合适的插入位置。查找和删除操作也遵循类似的比较规则。删除节点时,需要处理不同情况,如节点无子节点、有一个子节点或有两个子节点,以保证树的结构仍然满足二叉搜索树的特性。
平衡二叉搜索树 - AVL 树定义
高度平衡: 每个节点的左右子树的高度差不超过 1,这保证了树的高度始终保持在 (O(log n)) 级别。
性能稳定: 由于高度平衡,查找、插入和删除操作的时间复杂度始终为 (O(log n)),避免了二叉搜索树在最坏情况下退化为链表的问题。
旋转操作频繁: 为了保持平衡,在插入和删除节点后可能需要进行多次旋转操作(左旋、右旋、左右旋、右左旋)。
原理
在插入或删除节点后,会计算每个节点的平衡因子(左子树高度 - 右子树高度)。如果平衡因子的绝对值大于 1,则通过旋转操作来调整树的结构,使其重新达到平衡。旋转操作会改变节点之间的父子关系,但保证树仍然满足二叉搜索树的特性。
红黑树(Red - Black Tree)
近似平衡: 红黑树并不像 AVL 树那样严格平衡,但它通过颜色标记和特定规则保证了树的最长路径不超过最短路径的两倍,从而保证了操作的时间复杂度为 (O(log n))。
插入和删除效率高: 相比于 AVL 树,红黑树在插入和删除节点时需要的调整操作相对较少,因为它的平衡要求相对宽松。
应用广泛: Java 中的 TreeMap 和 TreeSet 底层就是基于红黑树实现的,在需要有序存储和高效查找的场景中表现出色。
原理
红黑树的每个节点都有一个颜色属性(红色或黑色),并遵循以下规则:每个节点要么是红色,要么是黑色。根节点是黑色。每个叶子节点(NIL 节点,空节点)是黑色。如果一个节点是红色的,则它的子节点必须是黑色的。对每个节点,从该节点到其所有后代叶节点的简单路径上,均包含相同数目的黑色节点。在插入和删除节点时,通过颜色调整和旋转操作来维护这些规则,从而保证树的近似平衡。
多路搜索树 - B 树和 B+ 树
B 树
多路存储: 每个节点可以有多个子节点(通常大于 2),这使得 B 树可以在一个节点中存储更多的键值对,减少了树的高度。
适用于磁盘存储: 由于树的高度较低,B 树在磁盘存储中非常有用,因为磁盘 I/O 操作的次数与树的高度成正比,较低的树高度可以减少磁盘 I/O 次数。
所有节点都存储数据: B 树的每个节点都可以存储键值对和指向子节点的指针。
B+ 树
数据集中在叶子节点: 所有的数据都存储在叶子节点,非叶子节点只存储索引信息,这使得范围查询更加高效。
叶子节点相连: 叶子节点之间通过指针相连,形成一个有序链表,方便进行范围查询和顺序访问。
更适合数据库索引: 在数据库系统中,B+ 树广泛应用于索引结构,因为它能够高效地支持查找、插入、删除和范围查询操作。
原理
B 树
插入节点时,当节点中的键值对数量超过一定阈值(节点的最大容量)时,会进行节点分裂操作,将节点一分为二,并将中间的键值提升到父节点。删除节点时,若节点中的键值对数量小于一定阈值(节点的最小容量),会进行节点合并或借键操作来保持树的结构。
B+ 树
插入和删除操作与 B 树类似,但由于数据只存储在叶子节点,操作主要集中在叶子节点上。在插入时,若叶子节点已满,则进行分裂操作;删除时,若叶子节点的键值对数量过少,则进行合并或借键操作。同时,需要维护叶子节点之间的链表结构。
特性对比
树结构 | 节点数量限制 | 平衡特性 | 查找时间复杂度 | 插入 / 删除时间复杂度 | 适用场景 |
---|---|---|---|---|---|
二叉树 | 每个节点最多 2 个子节点 | 无 | 最坏 (O(n)),平均取决于树的形状 | 最坏 (O(n)),平均取决于树的形状 | 表示层次关系,基础树结构 |
二叉搜索树 | 每个节点最多 2 个子节点 | 无 | 平均 (O(log n)),最坏 (O(n)) | 平均 (O(log n)),最坏 (O(n)) | 有序数据存储和查找 |
AVL 树 | 每个节点最多 2 个子节点 | 严格平衡(左右子树高度差不超过 1) | O(logn) | O(logn) | 对查找性能要求极高,插入和删除操作相对较少 |
红黑树 | 每个节点最多 2 个子节点 | 近似平衡(最长路径不超过最短路径的两倍) | O(logn) | O(logn) | 插入、删除和查找操作都比较频繁的场景,如 Java 集合框架 |
B 树 | 每个节点有多个子节点(m 阶 B 树,子节点数量在 ([ [m/2], m ]) 之间) | 平衡(所有叶子节点在同一层) | O(logn) | O(logn) | 磁盘存储,数据库索引 |
B+ 树 | 每个节点有多个子节点(m 阶 B+ 树,非叶子节点子节点数量在 ([ [ m/2], m ]) 之间,叶子节点可存储多个键值对) | 平衡(所有叶子节点在同一层) | O(logn) | O(logn) | 数据库索引,范围查询 |
十、 哈希是什么
哈希是把任意长度的输入通过哈希函数转换为固定长度输出的过程,这个输出值就是哈希值(也叫哈希码或散列值)。哈希的核心目标是高效地存储和查找数据。借助哈希函数,能把数据的键映射到一个特定的位置,进而实现快速访问。
哈希函数
哈希函数是哈希技术的关键,它接收一个键作为输入,然后返回一个哈希值。在 Java 中,Object 类有一个 hashCode() 方法,所有类都继承了这个方法,可用于生成对象的哈希码。例如:
public class HashExample { public static void main(String[] args) { String str = "hello"; int hashCode = str.hashCode(); System.out.println("字符串 \"hello\" 的哈希码: " + hashCode); } }
在上述代码里,String 类重写了 hashCode() 方法,按照特定算法生成字符串的哈希码。一个好的哈希函数应该具备以下特性:
确定性:相同的输入始终产生相同的输出。
高效性:计算哈希值的过程要快速。
均匀性:尽可能让哈希值均匀分布,减少哈希冲突的发生。
哈希冲突
当不同的键通过哈希函数计算出相同的哈希值时,就会产生哈希冲突。因为哈希函数的输出空间通常比输入空间小,所以哈希冲突难以避免。例如,在一个大小为 10 的哈希表中,若有 11 个不同的键,必然会有至少两个键的哈希值相同。
解决哈希冲突的方法
链地址法(Separate Chaining)
这种方法是在哈希表的每个位置维护一个链表。当发生哈希冲突时,把冲突的元素添加到对应位置的链表中。Java 的 HashMap 就采用了链地址法,当链表长度超过一定阈值(默认为 8)且哈希表长度大于 64 时,链表会转换为红黑树,以提升查找性能。
开放地址法(Open Addressing)
开放地址法是在发生哈希冲突时,通过某种规则在哈希表中寻找下一个可用的位置。常见的开放地址法有线性探测、二次探测和双重哈希等。
哈希在 Java 中是一种高效的数据存储和查找技术,通过合理选择哈希函数和解决哈希冲突的方法,能显著提升程序的性能。
到此这篇关于一文掌握Java 数据结构超详细笔记(收藏版)的文章就介绍到这了,更多相关Java 数据结构内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!