Java 集合框架与泛型

Java 集合框架与泛型

前言

本文根据课堂课件和个人学习体会整理,系统梳理了 Java 集合与泛型相关知识。内容如有不足,欢迎指正与交流。

完成 Java SE 基础语法后,深入学习数据结构非常重要。在正式实现数据结构和算法前,先了解 Java 的集合框架和泛型,有助于后续学习和应用。

1. 什么是集合框架

JDK17官方教程

Java 集合框架(Java Collection Framework),也叫容器,是定义在 java.util 包下的一组接口和实现类。

集合框架的核心作用是把多个元素组织在一起,方便高效地存储、检索和管理,也就是我们常说的增删查改(CRUD)操作。

集合框架设计时主要遵循以下目标:

  • 高性能:基本集合(如动态数组、链表、树、哈希表)的实现要高效。
  • 高度互操作性:不同类型的集合应以类似方式工作,方便互相配合。
  • 易于扩展:集合的扩展和适应应尽量简单。

因此,集合框架围绕一组标准接口设计。你可以直接用这些接口的标准实现类(如 LinkedListHashSetTreeSet 等),也可以基于接口自定义集合类型。

如上图所示,Java 集合框架主要包括两大类容器:

  • Collection:用于存储一组元素,是最常用的集合类型。Collection 接口又细分为三种子类型:
    • List:元素有序、可重复,支持按索引访问。常见实现有 ArrayList(基于动态数组,查询快,增删慢)、LinkedList(基于链表,增删快,查询慢)。
    • Set:元素无序、不可重复,常用于去重。常见实现有 HashSet(基于哈希表,元素无序)、LinkedHashSet(有插入顺序)、TreeSet(基于红黑树,元素有序)。
    • Queue:用于队列结构,支持先进先出(FIFO)等操作。常见实现有 LinkedList(也实现了 Queue 接口)、PriorityQueue(优先队列,元素按优先级排序)。
  • 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 基础知识,这些内容与集合的使用和实现密切相关:

  1. 泛型(Generic):用于在集合中指定元素的数据类型,保证类型安全,避免强制类型转换,提高代码的健壮性和可读性。(后文将详细介绍)
  2. 自动装箱(Autoboxing)与自动拆箱(Unboxing):Java 支持基本数据类型与其包装类之间的自动转换。例如,intInteger 可以自动互转,这在集合只能存储对象类型时尤为重要。
  3. Object 的 equals 方法:集合在判断元素是否相等(如 Set 去重、Map 查找键)时,会调用对象的 equals 方法。正确重写 equals 方法对于集合的正确性至关重要。
  4. Comparable 和 Comparator 接口:用于定义对象的比较规则。集合如 TreeSetTreeMap 需要元素具备可比性,排序操作也依赖于这两个接口。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. 只保留最高阶项:在运行次数函数中,只保留最高阶项。
  2. 忽略常数系数:用常数1取代运行时间中的所有加法常数。
  3. 忽略低阶项:如果最高阶项存在且不是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 基本数据类型和包装类对照表

基本类型包装类
byteByte
shortShort
intInteger
longLong
floatFloat
doubleDouble
charCharacter
booleanBoolean

注意:只有 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);

小结:

  • 泛型方法让方法更通用,能处理多种类型的数据。
  • 泛型方法和泛型类是两回事,泛型方法只在方法内部生效。
  • 静态方法如果用泛型,类型参数必须写在方法前面。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值