Java 集合框架与泛型
前言
本文根据课堂课件和个人学习体会整理,系统梳理了 Java 集合与泛型相关知识。内容如有不足,欢迎指正与交流。
完成 Java SE 基础语法后,深入学习数据结构非常重要。在正式实现数据结构和算法前,先了解 Java 的集合框架和泛型,有助于后续学习和应用。
1. 什么是集合框架
Java 集合框架(Java Collection Framework),也叫容器,是定义在 java.util
包下的一组接口和实现类。
集合框架的核心作用是把多个元素组织在一起,方便高效地存储、检索和管理,也就是我们常说的增删查改(CRUD)操作。
集合框架设计时主要遵循以下目标:
- 高性能:基本集合(如动态数组、链表、树、哈希表)的实现要高效。
- 高度互操作性:不同类型的集合应以类似方式工作,方便互相配合。
- 易于扩展:集合的扩展和适应应尽量简单。
因此,集合框架围绕一组标准接口设计。你可以直接用这些接口的标准实现类(如 LinkedList
、HashSet
、TreeSet
等),也可以基于接口自定义集合类型。
如上图所示,Java 集合框架主要包括两大类容器:
- Collection:用于存储一组元素,是最常用的集合类型。Collection 接口又细分为三种子类型:
- List:元素有序、可重复,支持按索引访问。常见实现有
ArrayList
(基于动态数组,查询快,增删慢)、LinkedList
(基于链表,增删快,查询慢)。 - Set:元素无序、不可重复,常用于去重。常见实现有
HashSet
(基于哈希表,元素无序)、LinkedHashSet
(有插入顺序)、TreeSet
(基于红黑树,元素有序)。 - Queue:用于队列结构,支持先进先出(FIFO)等操作。常见实现有
LinkedList
(也实现了 Queue 接口)、PriorityQueue
(优先队列,元素按优先级排序)。
- List:元素有序、可重复,支持按索引访问。常见实现有
Map:用于存储键/值对映射(暂时不做介绍)。
Collection 体系结构下的每个接口都有抽象类和具体实现类,开发中可根据实际需求选择合适的集合类型。例如:
ArrayList
适合频繁查找和遍历的场景;LinkedList
适合频繁插入和删除的场景;HashSet
适合需要去重且不关心顺序的场景;TreeSet
适合需要有序且去重的场景。
合理选择集合类型,有助于提升程序的性能和可维护性。
集合框架是一个用于统一表示和操作各种集合的架构。其主要组成包括:
- 接口:代表集合的抽象数据类型(如 Collection、List、Set、Map 等),以不同方式操作集合对象。
- 实现(类):集合接口的具体实现,实质上是可复用的数据结构(如 ArrayList、LinkedList、HashSet、HashMap)。
- 算法:集合对象中实现的一些常用操作方法(如搜索、排序等),这些算法通过多态机制在不同实现上表现出不同效果。
了解完集合框架之后,我们来聊聊背后的数据结构。
2. 集合框架后的数据结构以及算法
2.1 什么是数据结构
数据结构(Data Structure)是计算机中用于存储和组织数据的一种方式。它不仅关注数据本身,更强调数据之间的逻辑关系和操作方式。简单来说,数据结构就是一组数据元素的集合,这些元素之间存在着一种或多种特定的关系。
2.2 容器背后对应的数据结构
Java 集合框架中的各种容器,其底层实现都依赖于不同的数据结构。了解这些数据结构有助于我们在实际开发中做出更优的选择。下面简要梳理常见容器及其底层结构和适用场景:
2.2.1 List 体系
- List(接口):定义了有序、可重复元素集合的操作规范。
- ArrayList:底层为动态数组,支持高效的随机访问,适合频繁查找和遍历。插入和删除操作(非末尾)效率较低。
- LinkedList:底层为双向链表,插入和删除操作效率高,适合频繁增删,随机访问效率较低。
- Stack:继承自 Vector,底层为动态数组,实现了后进先出(LIFO)的栈结构。
2.2.2 Queue/Deque 体系
- Queue(接口):定义了队列的基本操作,通常用于先进先出(FIFO)场景。
- LinkedList:同时实现了 Queue 和 Deque 接口,既可用作队列,也可用作双端队列。
- PriorityQueue:底层为堆(通常是小顶堆),支持按优先级出队。
- Deque(接口):双端队列,支持两端插入和删除。
2.2.3 Set 体系
- Set(接口):定义了元素不可重复的集合。
- HashSet:底层为哈希表,插入、删除、查找的平均时间复杂度为 O(1),元素无序。
- LinkedHashSet:继承自 HashSet,底层为哈希表+链表,保证元素有插入顺序。
- TreeSet:底层为红黑树,元素有序,查找、插入、删除的时间复杂度为 O(logN)。
2.2.4 Map 体系(键值对映射)
- Map(接口):用于存储键值对(K-V)。
- HashMap:底层为哈希表,查询、插入、删除的平均时间复杂度为 O(1),键无序。
- LinkedHashMap:底层为哈希表+链表,保持插入顺序。
- TreeMap:底层为红黑树,键有序,相关操作时间复杂度为 O(logN)。
合理选择集合类型和底层数据结构,有助于提升程序的性能和可维护性。实际开发中应根据数据的特性和操作需求,选择最合适的容器类型。
2.3 相关的 Java 内容
在学习和使用 Java 集合框架时,还需要掌握以下相关的 Java 基础知识,这些内容与集合的使用和实现密切相关:
- 泛型(Generic):用于在集合中指定元素的数据类型,保证类型安全,避免强制类型转换,提高代码的健壮性和可读性。(后文将详细介绍)
- 自动装箱(Autoboxing)与自动拆箱(Unboxing):Java 支持基本数据类型与其包装类之间的自动转换。例如,
int
和Integer
可以自动互转,这在集合只能存储对象类型时尤为重要。 - Object 的 equals 方法:集合在判断元素是否相等(如 Set 去重、Map 查找键)时,会调用对象的
equals
方法。正确重写equals
方法对于集合的正确性至关重要。 - Comparable 和 Comparator 接口:用于定义对象的比较规则。集合如
TreeSet
、TreeMap
需要元素具备可比性,排序操作也依赖于这两个接口。Comparable
是对象自身的自然顺序,Comparator
则用于自定义排序规则。
掌握上述内容,有助于更好地理解和使用 Java 集合框架,编写出类型安全、功能强大且高效的代码。
2.4 什么是算法
算法(Algorithm)就是一组定义良好的计算步骤,用来将输入数据转化成输出结果。简单来说,算法就是解决问题的方案。
应对灯泡不亮的简单算法流程图⬆️
既然说到了算法,那就必须得讨论如何衡量一个算法的好坏。
3. 如何衡量一个算法的好坏
在学习数据结构和算法时,评价一个算法的优劣非常重要。一个优秀的算法不仅能正确解决问题,还能高效地利用计算机资源。衡量算法好坏的标准主要包括以下几个方面:
3.1 正确性
- 算法必须能够正确地解决所要求的问题,输出期望的结果。
3.2 可读性与可维护性
- 算法的逻辑应清晰、结构合理,便于理解和后续维护。
3.3 健壮性
- 算法应能合理处理各种异常和边界情况,不会因输入不合法而崩溃。
3.4 效率(时间复杂度与空间复杂度)
- 这是衡量算法优劣最核心的标准之一。
- 时间复杂度(Time Complexity):衡量算法运行所需的时间,通常用基本操作次数随输入规模 n 的增长趋势来描述。
- 空间复杂度(Space Complexity):衡量算法运行时所需的额外存储空间。
在实际开发中,时间复杂度往往更受关注,但在某些场景下,空间复杂度同样重要。
3.5 算法效率与复杂度
时间复杂度主要衡量算法执行所需的基本操作次数,反映算法随输入规模增长时的运行速度变化。
空间复杂度衡量算法在运行过程中所需的额外存储空间,反映算法对内存资源的消耗。
随着计算机硬件的发展,内存容量大幅提升,空间复杂度的影响有所减弱,但对于大数据处理等场景,空间效率依然不可忽视。
3.6 时间复杂度的定义与常见类型
在计算机科学中,算法的时间复杂度是一个数学函数,用来定量描述算法的运行时间随输入规模(n)变化的增长趋势。
- 实际上,算法的运行时间受多种因素影响(如硬件、编译器、输入数据等),但我们更关注算法本身的增长趋势。
- 因此,时间复杂度分析关注的是"基本操作"的执行次数与输入规模 n 的关系。
常见的时间复杂度有:
- O(1):常数阶
- O(log n):对数阶
- O(n):线性阶
- O(n log n):线性对数阶
- O(n^2):平方阶
- O(n^3):立方阶
- O(2^n):指数阶
上图展示了不同时间复杂度的增长趋势。随着数据规模的增大,低阶复杂度算法的优势会越来越明显。
3.7 大O的渐进表示法
为了简洁地表示算法的时间复杂度,计算机科学中引入了"大O符号"表示法。它描述了算法在输入规模趋近于无穷大时,基本操作执行次数的数量级。
- 只关注最高阶项,忽略常数和低阶项。
- 例如:T(n) = 3n^2 + 2n + 1,时间复杂度为 O(n^2)。
示例代码分析:
// 请计算一下func1基本操作执行了多少次?
void func1(int N){
int count = 0;
for (int i = 0; i < N ; i++) {
for (int j = 0; j < N ; j++) {
count++;
}
}
for (int k = 0; k < 2 * N ; k++) {
count++;
}
int M = 10;
while ((M--) > 0) {
count++;
}
System.out.println(count);
}
Func1 执行的基本操作次数:
F(N) = N^2 + 2 * N + 10
- N = 10 F(N) = 130
- N = 100 F(N) = 10210
- N = 1000 F(N) = 1002010
使用大O渐进表示法Func1的时间复杂度为:
- 第一组嵌套循环:执行 N*N 次
- 第二个循环:执行 2N 次
- while 循环:执行 10 次
- 总操作次数:N^2 + 2N + 10,时间复杂度为 O(N^2)
3.8 推导大O阶的方法
- 只保留最高阶项:在运行次数函数中,只保留最高阶项。
- 忽略常数系数:用常数1取代运行时间中的所有加法常数。
- 忽略低阶项:如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了地表示了执行次数。
时间复杂度的三种情况
- 最坏情况(Worst Case):任意输入规模下的最大运行次数(上界)。
- 平均情况(Average Case):任意输入规模下的期望运行次数。
- 最好情况(Best Case):任意输入规模下的最小运行次数(下界)。
例如:在一个长度为 N 的数组中搜索一个数据 x:
- 最好情况:1 次找到
- 最坏情况:N 次找到
常见时间复杂度对比及实际意义:
- O(1) < O(log n) < O(n) < O(n log n) < O(n^2) < O(2^n)
- 在实际中一般关注算法的最坏运行情况,例如数组中搜索数据时间复杂度为 O(N)
总结:衡量一个算法的好坏,既要关注其正确性、可读性、健壮性,也要重视其时间和空间效率。掌握复杂度分析方法,有助于我们编写出高效、优雅的代码。
了解完了集合框架和算法等一系列概念后,最后来探讨一下什么是泛型和包装类。
3.9 空间复杂度
空间复杂度(Space Complexity)是衡量算法在运行过程中临时占用存储空间多少的指标。它反映了算法对内存资源的需求。
空间复杂度不是指程序总共用了多少字节,而是关注算法运行时临时变量、辅助数据结构等所占用的空间。一般来说,空间复杂度的计算规则和时间复杂度类似,也用大O渐进表示法。
常见空间复杂度:
- O(1):只用到常量级别的额外空间,比如只用几个变量。
- O(n):需要一个与输入规模成正比的辅助数组或集合。
- O(n^2):比如需要一个二维数组存储数据。
举例:
- 只用几个变量计数、交换,空间复杂度为 O(1)。
- 用一个长度为 n 的数组存储数据,空间复杂度为 O(n)。
在实际开发中,空间复杂度通常不是主要瓶颈,但在大数据、嵌入式等场景下,合理控制空间消耗同样重要。
4. 包装类和泛型的简单认识
4.1 包装类
在 Java 里,基本数据类型(int、float、char 等)不是对象,不能直接用在泛型、集合这些只能存对象的地方。为了解决这个问题,Java 给每个基本类型都配了一个"包装类",就是把基本类型包成一个对象。
4.1.1 基本数据类型和包装类对照表
基本类型 | 包装类 |
---|---|
byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
char | Character |
boolean | Boolean |
注意:只有 Integer 和 Character 名字有点特别,其他都是首字母大写。
4.1.2 装箱和拆箱
- 装箱:把基本类型变成包装类对象。
- 拆箱:把包装类对象还原成基本类型。
int i = 10;
// 装箱
Integer ii = Integer.valueOf(i);
Integer ij = new Integer(i);
// 拆箱
int j = ii.intValue();
4.1.3 自动装箱和自动拆箱
其实手动写装箱/拆箱挺麻烦的,Java 1.5 以后支持自动装箱和自动拆箱,写起来更省事:
int i = 10;
Integer ii = i; // 自动装箱
int j = ii; // 自动拆箱
这样用起来就和普通变量一样,Java 会自动帮你搞定类型转换。
4.2 什么是泛型
平时写类和方法的时候,类型都要写死,比如 int、String、自己定义的类…如果想让一个类或者方法能支持多种类型,靠传统写法就很麻烦。泛型就是为了解决这个问题——让代码能适配多种类型,而且还能让编译器帮我们做类型检查。
泛型是 JDK1.5 加进来的,说白了就是"类型参数化",用的时候再指定具体类型。
4.2.1 泛型的实际场景
比如我们想写一个能存任意类型的数组,最简单的想法是用 Object[]:
class MyArray {
public Object[] array = new Object[10];
public Object getPos(int pos) {
return array[pos];
}
public void setVal(int pos, Object val) {
array[pos] = val;
}
}
public class Test {
public static void main(String[] args) {
MyArray myArray = new MyArray();
myArray.setVal(0, 10); // 存 int
myArray.setVal(1, "hello"); // 存 String
// 取出时需要强制类型转换,容易出错
String ret = (String) myArray.getPos(1);
System.out.println(ret);
}
}
这样虽然啥都能存,但用的时候还得强制类型转换,容易出错。
其实大多数时候,我们希望容器里只存一种类型的数据,这样更安全。泛型就是用来解决这个问题的——让容器只存某种类型,编译器帮我们检查类型安全。
4.2.2 泛型的语法格式
泛型的写法其实很简单,就是在类名、方法名后面加上 这种占位符,T 可以换成别的字母。
// 泛型类定义
class MyArray<T> {
public Object[] array = new Object[10];
public T getPos(int pos) {
return (T) array[pos];
}
public void setVal(int pos, T val) {
array[pos] = val;
}
}
public class TestDemo {
public static void main(String[] args) {
MyArray<Integer> myArray = new MyArray<>(); // 只能存 Integer
myArray.setVal(0, 10);
myArray.setVal(1, 12);
int ret = myArray.getPos(1); // 类型安全,不用强转
System.out.println(ret);
// myArray.setVal(2, "bit"); // 编译报错,类型不匹配
}
}
- 类名后的 就是类型参数,T 只是个习惯叫法,也可以用 E、K、V、S 等。
- 用的时候写成 MyArray,就只能存 Integer 类型,编译器会帮你检查类型。
- 这样写代码更安全,也不用到处强转类型。
常见泛型参数命名:
- E:Element(元素)
- K:Key(键)
- V:Value(值)
- T:Type(类型)
- S、U、N 等:第二、第三、数字等
泛型用得最多的地方就是集合类,比如 ArrayList、HashMap<String, Integer> 这些,都是泛型的实际应用。
4.3 泛型的使用
泛型类<类型实参> 变量名; // 定义一个泛型类引用
new 泛型类<类型实参>(构造方法实参); // 实例化一个泛型类对象
MyArray<Integer> list = new MyArray<Integer>();
注意:泛型只能接受类,所有的基本数据类型必须使用包装类!
4.3.1 类型推导(Type Inference)
当编译器可以根据上下文推导出类型实参时,可以省略类型实参的填写:
MyArray<Integer> list = new MyArray<>(); // 可以推导出实例化需要的类型实参为 Integer
4.4 裸类型(Raw Type)(仅补充介绍)
裸类型是一个泛型类但没有带着类型实参,例如 MyArray 就是一个裸类型:
MyArray list = new MyArray();
注意: 裸类型实际上是为了兼容老版本的 API 保留的机制。
4.5 泛型的编译原理(简单了解)
泛型本质上是编译器层面的语法糖。编译时,所有的 T 都会被替换为 Object,这种机制叫做"类型擦除"。Java 的泛型机制是在编译级别实现的,生成的字节码在运行期间并不包含泛型的类型信息。
4.5.1 泛型的上界
有时候需要对泛型类型做一定的约束,可以通过类型边界来实现:
class MyArray<E extends Number> {
// 只接受 Number 的子类型作为 E 的类型实参
}
MyArray<Integer> l1; // 正常,因为 Integer 是 Number 的子类型
MyArray<String> l2; // 编译错误,因为 String 不是 Number 的子类型
没有指定类型边界时,E 默认等价于 E extends Object。
4.5.2 复杂的泛型
有时需要 E 必须实现某个接口,比如 Comparable:
public class MyArray<E extends Comparable<E>> {
// ...
}
4.6 泛型方法
泛型方法是指在方法定义时就声明了类型参数的方法。和泛型类不同,泛型方法可以出现在普通类、泛型类、静态方法或实例方法中。
什么时候用泛型方法?
- 当方法内部需要处理多种类型的数据,但这些类型之间没有继承关系时,可以用泛型方法来实现类型安全的复用。
4.6.1 定义语法
泛型方法的类型参数要写在返回值类型前面:
// 泛型方法的定义
public class Util {
// 静态泛型方法,<E> 声明类型参数 E
public static <E> void swap(E[] array, int i, int j) {
E temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
- 这里的
<E>
表示类型参数,E 可以是任意类型。 - 泛型方法和泛型类没有直接关系,普通类里也能写泛型方法。
4.6.2 使用示例
泛型方法用起来很灵活,编译器通常能自动推断类型:
Integer[] a = {1, 2, 3, 4};
Util.swap(a, 0, 3); // 交换 a[0] 和 a[3]
String[] b = {"A", "B", "C"};
Util.swap(b, 0, 2); // 交换 b[0] 和 b[2]
有时候也可以手动指定类型参数:
Integer[] a = {1, 2, 3, 4};
Util.<Integer>swap(a, 0, 3);
String[] b = {"A", "B", "C"};
Util.<String>swap(b, 0, 2);
小结:
- 泛型方法让方法更通用,能处理多种类型的数据。
- 泛型方法和泛型类是两回事,泛型方法只在方法内部生效。
- 静态方法如果用泛型,类型参数必须写在方法前面。