一:初始集合框架
1.1 什么是集合框架
Java 集合框架 Java Collection Framework ,又被称为容器 container ,是定义在 java.util 包下的一组接口 interfaces和其实现类 classes 。
其主要表现为将多个元素 element 置于一个单元中,用于对这些元素进行快速、便捷的存储 store 、检索 retrieve 、管理 manipulate ,即平时我们俗称的增删查改 CRUD
例如,一副扑克牌(一组牌的集合)、一个邮箱(一组邮件的集合)、一个通讯录(一组姓名和电话的映射关系)等等。
Java集合框架位于java.util包中,所以当使用集合框架的时候需要进行导包。
集合中类和接口总览:
- 其中List,Queue,Set,Map,Deque都是常用的接口
- ArrayList,LinkedList,Stack,Queue,PriorityQueue,TreeSet,HashSet,TreeMap,HashMap都是常用的类,
什么是数据结构呢?数据结构(Data Structure)是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。下面是容器背后对应的数据结构。
- ArrayList - 顺序表
- LinkedList - 链表
- Stack - 栈
- Queue -队列
- PriorityQueue - 优先队列
- TreeSet - 有序集合
- HashSet - 无序集合
- TreeMap - 有序映射
- HashMap - 无序映射
二:时间复杂度和空间复杂度
2.1 什么是时间复杂度和空间复杂度
当我们分析算法的性能时,时间复杂度和空间复杂度是两个重要的指标。时间复杂度衡量了算法执行所需的时间,而空间复杂度衡量了算法所需的内存空间。
时间复杂度:
时间复杂度用来描述算法的执行时间随输入规模的增长而增长的速度。我们通常使用大O渐近表示法来表示时间复杂度,表示算法执行时间的上界。
以下是常见的时间复杂度从低到高的排序:
- 常数时间复杂度 O(1):无论输入规模的大小,算法的执行时间都是固定的。
- 对数时间复杂度 O(logN):随着输入规模的增加,算法的执行时间会增长,但是增长速度很慢。
- 线性时间复杂度 O(N):算法的执行时间与输入规模成线性关系。
- 线性对数时间复杂度 O(NlogN):算法的执行时间与输入规模的对数呈线性关系。
- 平方时间复杂度 O(N^2):算法的执行时间与输入规模的平方成正比。
- 立方时间复杂度 O(N^3):算法的执行时间与输入规模的立方成正比。
- 指数时间复杂度 O(2^N):算法的执行时间是指数级别的,随着输入规模的增长,执行时间急剧增加。
按照速度从快到慢进行排序的顺序是:
O(1), O(logN), O(N), O(NlogN), O(N^2), O(N^3), O(2^N)
空间复杂度:
空间复杂度描述了算法运行所需的额外内存空间量。它通常用来衡量算法使用的额外内存与输入规模的关系。
以下是常见的空间复杂度:
- 常数空间复杂度 O(1):算法使用的额外内存空间是固定的,与输入规模无关。
- 线性空间复杂度 O(N):算法使用的额外内存空间与输入规模成线性关系。
- 平方空间复杂度 O(N^2):算法使用的额外内存空间与输入规模的平方成正比。
当使用大O渐近表示法计算时间复杂度和空间复杂度时,我们关注的是算法在输入规模增长时的增长率。下面详细讲解如何计算时间复杂度和空间复杂度,并提供代码示例来说明。
- 计算时间复杂度:
- 单语句执行时间:对于基本操作(如赋值、算术运算、比较等),我们将它们视为常数时间,表示为O(1)。
- 循环语句:对于循环语句,我们将循环体内的操作重复执行的次数与输入规模n 相关的部分作为循环的时间复杂度。
- 顺序执行的循环语句,时间复杂度为循环次数乘以循环体内操作的时间复杂度。
- 嵌套循环的时间复杂度为内外循环的时间复杂度的乘积。
- 递归函数:递归的时间复杂度可以通过递归函数的递推关系和递归的深度来计算。
下面是一个用大O渐近表示法计算时间复杂度的例子:
void exampleFunction(int[] array) { for (int i = 0; i < array.length; i++) { // 循环 n 次 System.out.println(array[i]); // 操作时间复杂度为 O(1) } }
该函数的时间复杂度为O(n),其中n为输入数组的长度。
- 计算空间复杂度:
- 基本数据类型和常量的空间复杂度为O(1)。
- 数组和字符串:每开辟一次新空间都算1次,再看看开辟的空间是否是循环开辟的
- 递归函数:递归的空间复杂度取决于递归的深度,每层递归需要保存的变量所占的空间大小。
下面是一个用大O渐近表示法计算空间复杂度的例子:
void exampleFunction(int n) { int[] array = new int[n]; // 需要额外的空间来存储数组,空间复杂度为 O(n) for (int i = 0; i < n; i++) { // 循环 n 次 array[i] = i; // 操作时间复杂度为 O(1) } }
该函数的空间复杂度为O(n),其中n为输入值。
2.2 时间复杂度和空间复杂度练习
2.2.1时间复杂度
- 时间复杂度为 O(1) 的示例(常数时间复杂度):
public void printFirstElement(int[] arr) { System.out.println(arr[0]); }
这个示例代码只是简单地打印数组 arr
的第一个元素。不管输入的数组大小是多少,该函数只执行一次操作,因此时间复杂度为常量级别,即 O(1)。
- 时间复杂度为 O(logN) 的示例(对数时间复杂度):
public int binarySearch(int[] arr, int target) { int left = 0; int right = arr.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; } else if (arr[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; }
该代码实现了二分查找算法,根据给定的目标值 target
在已排序数组 arr
中查找其索引。在每次迭代中,数组范围减半,因此时间复杂度为对数级别,即 O(logN)。
- 时间复杂度为 O(N) 的示例(线性时间复杂度):
public void linearPrint(int[] arr) { for (int num : arr) { System.out.println(num); } }
该代码示例遍历并打印给定数组 arr
中的每个元素。随着输入数组大小的增加,操作次数也会线性增长,因此时间复杂度为 O(N)。
- 时间复杂度为 O(NlogN) 的示例(线性对数时间复杂度):
public void mergeSort(int[] arr) { if (arr.length <= 1) { return; } int mid = arr.length / 2; int[] left = Arrays.copyOfRange(arr, 0, mid); int[] right = Arrays.copyOfRange(arr, mid, arr.length); mergeSort(left); mergeSort(right); merge(arr, left, right); } private void merge(int[] arr, int[] left, int[] right) { int i = 0, j = 0, k = 0; while (i < left.length && j < right.length) { if (left[i] <= right[j]) { arr[k++] = left[i++]; } else { arr[k++] = right[j++]; } } while (i < left.length) { arr[k++] = left[i++]; } while (j < right.length) { arr[k++] = right[j++]; } }
该代码示例实现了归并排序算法,将输入数组 arr
分解为较小的子数组,然后合并排序这些子数组。通过递归调用 mergeSort
方法,每个子数组的大小都会以对数级别增长,因此时间复杂度为 O(NlogN)。
- 时间复杂度为 O(N^2) 的示例(平方时间复杂度):
public void bubbleSort(int[] arr) { int n = arr.length; for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - i - 1; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } }
该代码示例实现了冒泡排序算法,在每个迭代中比较相邻的元素并进行交换。通过嵌套的循环,操作的次数与输入数组大小的平方成比例,因此时间复杂度为 O(N^2)。
- 时间复杂度为 O(N^3) 的示例(立方时间复杂度):
public void tripleNestedLoop(int n) { for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { for (int k = 0; k < n; k++) { System.out.println("Triple nested loop"); } } } }
该代码示例展示了一个三重嵌套循环。每个嵌套循环的迭代次数都是输入大小 n
的线性增长,因此总的操作次数是立方级别,即时间复杂度为 O(N^3)。
- 时间复杂度为 O(2^N) 的示例(指数时间复杂度):
public int fibonacci(int n) { if (n <= 1) { return n; } return fibonacci(n - 1) + fibonacci(n - 2); }
该代码示例展示了一个经典的递归斐波那契数列计算。每次递归调用会导致指数级别的操作次数增加,因此时间复杂度为 O(2^N)。请注意,此递归实现效率较低,对于较大的 n
值可能导致性能问题。
2.2.2 空间复杂度
- 空间复杂度为 O(1) 的示例(常量级空间复杂度):
// 示例1 - O(1) 空间复杂度 public void printNumber(int n) { System.out.println(n); }
算法所需的额外空间与输入规模无关,常量级别的空间消耗。所以空间复杂度为O(1)。
- 空间复杂度为 O(N) 的示例(线性空间复杂度):
// 示例2 - O(N) 空间复杂度 int[] fibonacci(int n) { long[] fibArray = new long[n + 1]; fibArray[0] = 0; fibArray[1] = 1; for (int i = 2; i <= n ; i++) { fibArray[i] = fibArray[i - 1] + fibArray [i - 2]; } return fibArray; }
因为这段代码动态开辟了N个空间,算法所需的额外空间与输入规模线性相关。所以空间复杂度为O(N)
- 空间复杂度为 O(N^2) 的示例(平方空间复杂度):
// 示例3 - O(N^2) 空间复杂度 public void printPairs(int[] numbers) { for (int i = 0; i < numbers.length; i++) { for (int j = 0; j < numbers.length; j++) { System.out.println(numbers[i] + ", " + numbers[j]); } } }
算法所需的额外空间与输入规模的平方相关。所以空间复杂度为O(N^2)
三:包装类
基本数据类型在Java中具有固定的大小和默认值,并且不具备面向对象的特性。为了能够以面向对象的方式处理基本数据类型,Java引入了包装类。包装类是一种特殊的类,用于将基本数据类型包装为对象。每个基本数据类型都有对应的包装类,
如图所示:
基本数据类型 | 包装类 |
byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
char | Character |
boolean | Boolean |
很容易发现,除了 Integer 和 Character, 其余基本类型的包装类都是首字母大写。
包装类的主要意义包括:
- 提供面向对象的方法和属性:包装类提供了一系列方法和属性,可以实现基本数据类型的各种操作和转换,使得基本数据类型具有了面向对象的特性。
- 允许将基本数据类型作为对象存储:将基本数据类型包装为对象后,可以将其作为参数传递给方法、存储在集合中,以及在其他需要对象的上下文中使用。
- 支持自动装箱和拆箱:Java提供了自动装箱和拆箱的机制,使得基本数据类型和对应的包装类可以自动转换。这样可以方便地在基本数据类型和包装类之间进行转换,提高了代码的简洁性和可读性。
- 提供了null值的表示:基本数据类型不具备表示空值的能力,而包装类通过包装null值为对象来表示空值,可以更好地处理空值的情况。
3.1 自动装箱和自动拆箱
当我们在Java中使用基本数据类型(如int、double、char等)时,有时需要将它们转换为对象类型。这种转换过程称为装箱(Boxing)。而将对象类型转换为基本数据类型的过程称为拆箱(Unboxing)。
Java提供了自动装箱和自动拆箱的特性,使得在基本数据类型和对应的对象类型之间的转换更加便捷。
下面是一些示例代码,将帮助我们理解自动装箱和自动拆箱的过程:
// 自动装箱 int num = 10; // 基本数据类型 Integer obj = num; // 自动装箱,将基本数据类型转换为Integer对象 // 自动拆箱 Integer obj2 = 20; // Integer对象 int num2 = obj2; // 自动拆箱,将Integer对象转换为基本数据类型
在第一段代码中,我们有一个int类型的变量num
,然后使用自动装箱将其转换为一个Integer
对象obj
。这个过程是隐式进行的,不需要显式调用任何函数或方法来实现装箱操作。
在第二段代码中,我们有一个Integer
对象obj2
,然后使用自动拆箱将其转换为一个int类型的变量num2
。同样,这个过程也是隐式进行的,不需要显式调用任何函数或方法来实现拆箱操作。
当然我们还可以显式装箱:
// 显式装箱 int num = 10; // 基本数据类型 Integer obj = new Integer(num); // 调用 Integer 类的构造函数将基本数据类型转换为 Integer 对象 // 或者使用静态方法 valueOf int num = 10; // 基本数据类型 Integer obj = Integer.valueOf(num); // 使用 valueOf 方法将基本数据类型转换为 Integer 对象
这两种方法都是显式装箱。
3.2 Integer cache机制
下列代码输出什么,为什么?
public static void main(String[] args) { Integer a = 127; Integer b = 127; Integer c = 128; Integer d = 128; System.out.println(a == b); System.out.println(c == d); }
Java中的Integer cache机制是一种优化技术,用于在一定范围内缓存常用的Integer对象,避免了频繁创建和销毁对象的开销。在这个机制中,Java为了提高性能和节省内存,对一定范围内的整数值(通常是-128到127)预先创建了对应的Integer对象,并将其缓存起来。
在上述代码中,我们声明了四个Integer对象a、b、c和d,并进行了比较。首先,我们可以注意到a和b的值都是127,而c和d的值都是128。根据Integer cache机制,Java会以缓存对象的方式来存储并重新使用小范围内的整数值。
因此,当我们对a和b进行比较时,由于它们的值在缓存范围内,实际上是在比较两个引用是否指向同一个对象。所以,a == b的结果为true。
而当我们对c和d进行比较时,由于它们的值超出了缓存范围,Java会创建新的Integer对象来表示这两个值。所以,c == d的结果为false,因为它们并不是同一个对象。
这个Integer cache机制可以提高性能,因为对于频繁使用的小整数值,不需要每次都创建新的Integer对象,而是直接使用缓存中的对象。这样可以减少内存的消耗,并提高代码的执行效率。
四:泛型
4.1 引出泛型
实现一个类,类中包含一个数组成员,使得数组中可以存放任何类型的数据,也可以根据成员方法返回数组中某个下标的值?
思路:
- 我们以前学过的数组,只能存放指定类型的元素,例如:int[] array = new int[10]; String[] strs = new
String[10]; - 所有类的父类,默认为Object类。数组是否可以创建为Object?
代码示例:
class MyArray { public Object[] array = new Object[10]; public Object getPos(int pos) { return this.array[pos]; } public void setVal(int pos,Object val) { this.array[pos] = val; } } public class TestDemo { public static void main(String[] args) { MyArray myArray = new MyArray(); myArray.setVal(0,10); myArray.setVal(1,"hello");//字符串也可以存放 String ret = myArray.getPos(1);//编译报错 System.out.println(ret); } }
问题:以上代码实现后发现
- 任何类型数据都可以存放
- 1号下标本身就是字符串,但是确编译报错。必须进行强制类型转换
虽然在这种情况下,当前数组任何数据都可以存放,但是,更多情况下,我们还是希望他只能够持有一种数据类型。而不是同时持有这么多类型。所以,泛型的主要目的:就是指定当前的容器,要持有什么类型的对象。让编译器去做检查。此时,就需要把类型,作为参数传递。需要什么类型,就传入什么类型。
4.2泛型的语法
泛型的语法:
class 泛型类名称<类型形参列表> { // 这里可以使用类型参数 } class 泛型类名称<类型形参列表> extends 继承类/* 这里可以使用类型参数 */ { // 这里可以使用类型参数 }
所以我们可以把上述代码改成这样:
class MyArray<T> { public T[] array = (T[])new Object[10];//1 public T getPos(int pos) { return this.array[pos]; } public void setVal(int pos,T val) { this.array[pos] = val; } } public class TestDemo { public static void main(String[] args) { MyArray<Integer> myArray = new MyArray<>();//2 myArray.setVal(0,10); myArray.setVal(1,12); int ret = myArray.getPos(1);//3 System.out.println(ret); myArray.setVal(2,"bit");//4 } }
- MyArray< Integer > myArray = new MyArray<>();
我们对泛型类型参数的指定需要通过实例化对象时的 < > ,所以这行代码代表着我们在实例化对象的同时指定T为Integer,所以当我们实例化这个对象的时候,这个对象的属性和方法中的T都被替换成为了Integer,就相当于:
class MyArray<Integer> { public Integer[] array = (Integer[])new Object[10];//1 public Integer getPos(int pos) { return this.array[pos]; } public void setVal(int pos,T val) { this.array[pos] = val; } }
注意:
- MyArray< Integer > myArray = new MyArray<>();
- MyArray< > myArray = new MyArray< Integer >();
- MyArray< Integer > myArray = new MyArray< Integer >();
这3种写法是等效的,只不过是在不同的时期指定了泛型参数类型而已
类名后的 代表占位符,表示当前类是一个泛型类
规范:类型形参一般使用一个大写字母表示,常用的名称有:
E 表示 Element
K 表示 Key
V 表示 Value
N 表示 Number
T 表示 Type
S, U, V 等等 - 第二、第三、第四个类型
注意:在java中,我们不能去实例化一个泛型类型的数组,因为这个数组里面可以存放任何的数据类型,可能是String,可能是Person,运行的时候,如果直接转给一种类型的数组,编译器认为是不安全的,所以在注释1处:
public T[] array = (T[])new Object[10];//我们这样写,这样是对的 T[] ts = new T[5];//是不对的
所以通过类型,我们就可以把类型当作一个参数进行传递,并且当我们指定了一个类型之后,编译器会自动为我们检查传入的数据类型是否和指定类型匹配
4.3 泛型的上界
因为在普通的泛型中,我们传入的类型可以是任何类型,但是某些时候,我们又想对传入的类型进行一定的限制,那么此时就要用到泛型的上界了。
在Java中,泛型上界(Upper Bound)用于限制泛型类型参数的范围。通过使用上界,我们可以指定泛型类型参数必须是某个特定类型或其子类型。
通常,使用上界的语法是在泛型参数后面使用关键字 “extends” 加上一个类型。例如,假设我们有一个泛型类 Box
,并且我们想要限制它的类型参数只能是 Number
类型或其子类型,我们可以这样写:
public class Box<T extends Number> { private T value; public Box(T value) { this.value = value; } public T getValue() { return value; } }
在上面的代码中,我们使用 <T extends Number>
来表示 T
必须是 Number
类型或其子类型。这样,我们就可以确保在使用 Box
类时,只能传入 Number
类型或其子类的参数。
使用泛型上界,我们可以根据需求进行更加具体的限制。例如,我们可以指定泛型类型参数必须是某个接口的实现类:
public interface Animal { void eat(); } public class Box<T extends Animal> { private T animal; public void setAnimal(T animal) { this.animal = animal; } public void feedAnimal() { animal.eat(); } }
在上面的例子中,我们通过 <T extends Animal>
将泛型类型参数限制为必须是 Animal
接口或者其实现类。这样,我们可以确保 setAnimal()
方法只能接受实现了 Animal
接口的对象作为参数。
在使用泛型上界时,我们可以使用多个上界,通过使用 &
符号将它们连接起来。例如,我们可以限制泛型类型参数必须是某个类和某个接口的子类型:
public class Box<T extends Number & Comparable<T>> { // ... }
在上面的例子中,我们使用 <T extends Number & Comparable<T>>
指定泛型类型参数必须是 Number
类型或其子类和实现了 Comparable
接口的子类型。这样,我们就限制了类型参数必须同时满足这两个条件。
4.4 泛型方法
当我们需要在一个类或方法中处理多种类型的数据时,泛型方法就能派上用场。泛型方法允许我们在方法的定义中使用类型参数,从而实现参数类型的灵活性。
以下是泛型方法的基本语法:
修饰符 <T> 返回类型 方法名(参数列表) { // 方法实现 }
在这个语法中,<T>
是类型参数的声明,可以是任意标识符,通常使用大写字母来表示类型。它放在修饰符后面,返回类型前面。
4.4.1示例1:打印数组元素
public class GenericMethodExample { public static <T> void printArray(T[] array) { for (T element : array) { System.out.print(element + " "); } System.out.println(); } public static void main(String[] args) { Integer[] intArray = {1, 2, 3, 4, 5}; Double[] doubleArray = {1.1, 2.2, 3.3, 4.4, 5.5}; String[] stringArray = {"Apple", "Banana", "Orange"}; System.out.print("Int Array: "); printArray(intArray); System.out.print("Double Array: "); printArray(doubleArray); System.out.print("String Array: "); printArray(stringArray); } }
输出:
Int Array: 1 2 3 4 5 Double Array: 1.1 2.2 3.3 4.4 5.5 String Array: Apple Banana Orange
上述示例中,我们定义了一个名为printArray
的泛型方法。它接受一个类型为T
的数组作为参数,并使用增强型for
循环打印数组中的元素。
4.4.2示例2:获取最大值
public class GenericMethodExample { public static <T extends Comparable<T>> T getMax(T[] array) { T max = array[0]; for (T element : array) { if (element.compareTo(max) > 0) { max = element; } } return max; } public static void main(String[] args) { Integer[] intArray = {1, 2, 5, 4, 3}; Double[] doubleArray = {1.1, 2.2, 5.5, 4.4, 3.3}; System.out.println("Max value in Int Array: " + getMax(intArray)); System.out.println("Max value in Double Array: " + getMax(doubleArray)); } }
输出:
Max value in Int Array: 5 Max value in Double Array: 5.5
在上述示例中,我们定义了一个名为getMax
的泛型方法。类型参数T
必须实现Comparable<T>
接口,以便我们可以使用compareTo
方法比较元素的大小。方法返回数组中最大的元素。
通过泛型方法,我们可以灵活地处理不同类型的数据,提高代码的重用性和类型安全性。同时,泛型方法还可以与泛型类相互配合使用,充分发挥泛型的优势。
注意:
- 泛型的信息只存在于代码编译阶段,在代码编译结束之后,于泛型相关的信息会被擦除,专业术语叫做类型擦除,也就是说,成功编译后的class文件不包含任何泛型信息,泛型信息不会进入运行时阶段。
- 泛型类中的静态方法和静态变量不可以使用泛型类所声明的类型参数,因为泛型类中类型参数的确定是在创建泛型类的时候,而此时静态变量和静态方法在类加载的时候就已经初始化了。
以下是一个例子来证明这一点:
public class GenericClass<T> { private static T staticVariable; // 静态变量 //public static void staticMethod(T parameter) { // // 错误,无法在静态方法中使用类型参数 T // 静态方法不能访问泛型类的类型参数 // 因为在类加载时,泛型类的类型参数还没有确定 // 所以在静态方法中使用泛型类的类型参数是非法的 } public static void main(String[] args) { // 创建泛型类的实例 GenericClass<String> instance = new GenericClass<>(); // 在创建实例时,类型参数被确定为String // 所以在实例方法中可以使用类型参数 instance.instanceMethod("Hello"); } public void instanceMethod(T parameter) { // 实例方法可以直接使用泛型类的类型参数 System.out.println(parameter); } }
静态方法内部不能使用泛型类所声明的类型参数,静态方法的参数也不能使用泛型类所声明的类型参数。