前言
或许大家看过这句话,数据结构 + 算法 == 程序。同语法一样,数据结构同样是优秀程序员必备的基础,写代码时选择了适合地数据结构,可以帮助我们对数据进行更好地处理。因此这门课程对于我们来说是十分重要的,话不多说,干就完了!
一、初识集合框架
(一)什么是集合框架
Java 集合框架 Java Collection Framework,又被称为容器container,是定义在 java.util 包下的一组接口 interfaces 和其实现类 classes
其主要表现为将多个元素 element 置于一个单元中,用于对这些元素进行快速、便捷的存储 store 、检索 retrieve 、管理 manipulate ,即平时我们俗称的增删查改(CRUD)
接下来我们来看一下 Java 数据结构集合框架,由于CSDN上已经有大佬很好的画出了集合框架图,大家可以通过超链接直接访问
集合框架图1
集合框架图2
(二)集合框架的重要性
1.开发中的使用
- 使用成熟的集合框架,有助于我们便捷、快速的写出高效、稳定的代码
- 学习背后的数据结构知识,有助于我们理解各个集合的优缺点及使用场景
2.几乎每个大厂都会问到数据结构和集合框架相关的问题
二、背后所涉及的数据结构以及算法
(一)什么是数据结构
定义:数据结构(Data Structure)是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合
(二)容器背后对应的数据结构
容器分类:
- Collection:是一个接口,包含了大部分容器常用的一些方法
- List:是一个接口,规范了ArrayList 和 LinkedList中要实现的方法
* ArrayList:实现了List接口,底层为动态类型顺序表
* LinkedList:实现了List接口,底层为双向链表- Stack:底层是栈,栈是一种特殊的顺序表
- Queue:底层是队列,队列是一种特殊的顺序表
- Deque:是一个接口
- Set:集合,是一个接口,里面放置的是K模型
* HashSet:底层为哈希桶,查询的时间复杂度为O(1)
* TreeSet:底层为红黑树,查询的时间复杂度为O(log(以2为底)N),关于key有序- Map:映射,里面存储的是K-V模型的键值对
* HashMap:底层为哈希桶,查询时间复杂度为O(1)
* TreeMap:底层为红黑树,查询的时间复杂度为O(log(以2为底)N),关于key有序
(三)什么是算法
算法(Algorithm):就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果
三、时间和空间复杂度
(一)算法效率
算法效率分析分为两种:时间效率和空间效率。时间效率被称为时间复杂度,而空间效率被称为空间复杂度。时间复杂度主要衡量的是一个算法的运行速度,而空间复杂度主要衡量一个算法所需要的额外空间
(二)时间复杂度
定义:算法的时间复杂度是一个数学函数,结果就是算法中的基本操作的执行次数(估计)
1.大O的渐进表示法
大O符号(Big O notation):是用于描述函数渐进行为的数学符号
2.推导大O阶方法
- 用常量1取代运行时间中的所有加法常数
- 在修改后的运行次数函数中,只保留最高阶项
- 如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)
3.常见时间复杂度计算举例
示例1
public static void func2(int N) {
int count = 0;
for (int k = 0; k < 2 * N; k++) {
count++;
}
int M = 10;
while ((M--) > 0) {
count++;
}
System.out.println(count);
}
时间复杂度:O(N)
示例2
public static void func3(int N, int M) {
int count = 0;
for (int k = 0; k < M; k++) {
count++;
}
for( int k = 0; k < N; k++){
count++;
}
System.out.println(count);
}
时间复杂度:O(M + N)
示例3
void func4(int N) {
int count = 0;
for (int k = 0; k < 100; k++) {
count++;
}
System.out.println(count);
}
时间复杂度:O(1)
示例4
//冒泡排序
void bubbleSort(int[] array) {
for (int end = array.length; end > 0; end--) {
boolean sorted = true;
for (int i = 1; i < end; i++) {
if (array[i - 1] > array[i]) {
Swap(array, i - 1, i);
sorted = false;
}
}
if
(sorted == true) {
break;
}
}
}
时间复杂度:O(N ^ 2),最好的情况下时间复杂度为O(N)
示例5
//二分查找
int binarySearch(int[] array, int value) {
int begin = 0;
int end = array.length - 1;
while (begin <= end) {
int mid = begin + ((end-begin) / 2);
if (array[mid] < value)
begin = mid + 1;
else if (array[mid] > value)
end = mid - 1;
else
return mid;
}
return -1;
}
时间复杂度:O(logN)(计算机的对数底数如果忽略默认是 2)
示例6
long factorial(int N) {
return N < 2 ? N : factorial(N - 1) * N;
}
递归的时间复杂度 = 递归的次数 * 每次递归之后执行的次数
时间复杂度:O(N)
示例7
//计算斐波那契递归fibonacci的时间复杂度?
int fibonacci(int N) {
return N < 2 ? N : fibonacci(N - 1) + fibonacci(N - 2);
}
时间复杂度:O(2 ^ N)
(三)空间复杂度
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度,空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也是使用大O渐进表示法
示例1
//计算 bubbleSort 的空间复杂度
void bubbleSort(int[] array) {
for (int end = array.length; end > 0; end--) {
boolean sorted = true;
for (int i = 1; i < end; i++) {
if (array[i - 1] > array[i]) {
Swap(array, i - 1, i);
sorted = false;
}
}
if(sorted == true) {
break;
}
}
}
空间复杂度:O(1)
示例2
//计算fibonacci的空间复杂度?
public static int[] fibonacci1(int n) {
int[] fibArray = new int[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;
}
空间复杂度:O(N)
示例3
//计算阶乘递归Factorial的空间复杂度?
public static long factorial2(int N) {
return N < 2 ? N : factorial2(N - 1) * N;
}
空间复杂度:O(N)
示例4(易错)
//计算斐波那契递归fibonacci的空间复杂度
int fibonacci(int N) {
return N < 2 ? N : fibonacci(N - 1) + fibonacci(N - 2);
}
空间复杂度:O(N)
四、泛型
(一)什么是泛型
泛型是在JDK1.5引入的新的语法,通俗讲,泛型:就是适用于许许多多类型。从代码上讲,就是对类型实现了参数化
(二)引出泛型
Object类可以存放任意类型的数据,但是无法强转成子类
public static void main(String[] args) {
Object[] objects = new Object[]{1,2,3,"abc",1.2};
//数组Object 类是不允许强转的,但是单个类可以
String[] strings = (String[]) objects;
}
运行结果:
定义一个成员变量为 Object 数组的类,并实现几个方法,代码示例:
class MyArray{
public Object[] obj = new Object[10];
public void add(int pos,Object val) {
obj[pos] = val;
}
public Object getPos(int pos) {
return obj[pos];
}
public static void main(String[] args) {
MyArray myArray = new MyArray();
myArray.add(0,10);
myArray.add(1,"你好");
myArray.add(2,12.3);
String s = (String)myArray.getPos(1);
System.out.println(s);
}
}
运行结果:
可以看出,定义成 Object 类的数组确实可以存放任意类型的元素,但当我们维护或者需要得到数组中的某个元素时,却是非常麻烦,每个位置的元素不确定是什么类型的,并且就算确定了,要得到该位置的元素我们每次必须强转成具体类型
1.泛型语法
class 泛型类名称<类型形参列表> {
// 这里可以使用类型参数
}
class ClassName<T1, T2, ..., Tn> {
}
class 泛型类名称<类型形参列表> extends 继承类/* 这里可以使用类型参数 */ {
// 这里可以使用类型参数
}
class ClassName<T1, T2, ..., Tn> extends ParentClass<T1> {
// 可以只使用部分类型参数
}
所以我们上面的代码就可以改成这样:
class MyArray<T>{
//注释1,可以运行,但是写法并不太好
public T[] obj = (T[])new Object[10];
public void add(int pos,T val) {
obj[pos] = val;
}
public T getPos(int pos) {
return obj[pos];
}
public static void main(String[] args) {
//注释2
MyArray<Integer> myArray = new MyArray<Integer>();
myArray.add(0,10);
myArray.add(1,2);
//注释3
int num = myArray.getPos(0);
//注释4
/*myArray.add(2,"hello");*/
System.out.println(num);
}
}
运行结果:
代码解释:
- 类名后的代表占位符,表示当前类是一个泛型类
了解:【规范】类型参数一般使用一个大写字母表示,常用的名称有:
* E 表示 Element
* K 表示 Key
* V 表示 Value
* N 表示 Number
* T 表示 Type
* S, U, V 等等 - 第二、第三、第四个类型- **注释1处,不能 new 泛型类型的数组(在下面具体介绍)
- 注释2处,类型后加入指定当前类型,只能是类类型,不能是基本类型,new 后面那个<>中可加可不加
- 注释3处,不需要进行强制类型转换
- 注释4处,代码编译报错,此时因为在注释2处指定类当前的类型,此时在注释4处,编译器会在存放元素的时候帮助我们进行类型检查**
(三)泛型类的使用
1.语法
泛型类<类型实参> 变量名; // 定义一个泛型类引用
new 泛型类<类型实参>(构造方法实参); // 实例化一个泛型类对象
2.示例
MyArray<Integer> myArray = new MyArray<Integer>();
注意:泛型只能接受类,所有的基本数据类型必须使用包装类(比如 int 的包装类是 Integer)
3.类型推导
//即根据前面的类型,可以推导出实例化的类,因此后面不必再写上 Integer
MyArray<Integer> myArray = new MyArray<>();
(四)裸类型(Raw Type)(了解)
说明:
裸类型是一个泛型类但没有带着类型参数,例如下面这种写法:
public static void main2(String[] args) {
MyArray myArray = new MyArray();
myArray.add(0,10);
myArray.add(1,"你好");
myArray.add(2,12.3);
String s = (String)myArray.getPos(1);
System.out.println(s);
}
我们可以看到,定义的类又可以在 new 出来的同一个对象中存放不同的类型,最后依然要进行强制类型转换
注意:我们不要自己去使用裸类型,裸类型是为了兼容老版本的 API 保留的机制
而在擦除部分,会说明编译器是怎么使用裸类型的
小结
- 泛型是将数据类型参数化,进行传递
- 使用 表示当前类是一个泛型类
- 泛型目前为止的优点:数据类型参数化,编译时自动进行类型检查和转换
(五)包装类
在 Java 中,由于基本类型不是继承自 Object ,为了在泛型代码中可以支持基本类型,Java 给每个基本类型都对应了一个包装类型
1.基本数据类型和对应的包装类型
除了 Integer 和 Character ,其余基本类型的包装类都是首字母大写
2.装箱和拆箱,自动装箱和手动装箱
为什么会有自动装箱和自动拆箱,就是为了减少开发者的负担,Java 提供了自动机制
代码示例:
基本数据类型 | 包装类 |
---|---|
byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
char | Character |
boolean | Boolean |
除了 Integer 和 Character ,其余基本类型的包装类都是首字母大写
2.装箱和拆箱,自动装箱和手动装箱
为什么会有自动装箱和自动拆箱,就是为了减少开发者的负担,Java 提供了自动机制
代码示例:
public static void main(String[] args) {
//装箱操作:新建一个 Integer 类型对象,将值放入对象的某个属性中
//第一个和第三个都是手动装箱,第二个是自动装箱
Integer a = new Integer(10);
Integer b = 20;
Integer c = Integer.valueOf(30);
//拆箱操作,将 Integer 对象中的值取出,放到基本数据类型中
//第一个是自动拆箱,下面的是手动拆箱
int d = a;
int e = b.intValue();
float f = c.floatValue();
}
面试题
下列代码输出什么,为什么?
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);
}
运行结果:
原因:
首先我们看 Integer 包装类的 valueOf 方法
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
static final int low = -128;
static final int high;
int h = 127;
high = h;
static final Integer cache[];
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
由上面的第二三段代码对第一段代码进行分析,我们可以知道,当传入的 i 值在[-128~127]的时候,直接返回由 Integer 包装类定义的静态不可变的包装类的数组中的对象,因此当两个 Integer 类都赋值为127时,实际上引用的是同一个对象,而当赋值 128 时,超出了范围,因此会重新创建一个包装类进行返回。以后使用 Integer 包装类时要格外注意这点
(六)泛型的编译
1.擦除机制
在编译的过程当中,将所有的 T 替换为 Object 这种机制,我们称为:擦除机制
Java 的泛型机制是在编译期间实现的。编译器生成的字节码在运行期间并不包含泛型的类型信息,即泛型的概念只在编译的过程提出,运行期间没有泛型的概念
2.为什么不能实例化泛型类型数组
代码示例:
class MyArray<T>{
public T[] obj = (T[])new Object[10];
public T[] getObj() {
return obj;
}
public static void main(String[] args) {
MyArray<Integer> myArray = new MyArray<>();
myArray.add(0,10);
myArray.add(1,2);
myArray.add(2,19);
Integer[] x = myArray.getObj();
}
}
运行结果:
/*Exception in thread "main" java.lang.ClassCastException: [Ljava.lang.Object; cannot be cast to [Ljava.lang.Integer;
at Test.main(Test.java:48)*/
原因:替换后的方法为:将 Object[] 分配给 Integer[] 引用,程序报错
数组和泛型之间的一个重要区别是它们是如何强制执行类型检查。具体来说,数组在运行时存储和检查类型信息,然而,泛型在编译时检查类型错误。通俗的将,返回的Object数组里面,可能存放的是任何的数据类型,可能是String,可能是Person,运行的时候,直接转给Integer类型的数组,编译器认为是不安全的
(七)泛型的上界(泛型没有下界)
在定义泛型类时,有时需要对传入的类型变量做一定的约束,可以通过类型边界来约束
1.语法
class 泛型类名称<类型形参 extends 类型边界> {
...
}
2.示例
示例1:
public class MyArray<E extends Number> {
...
}
表示只接受 Number 的子类型或者 Number 类本身作为 E 的类型实参
代码示例:
public static void main(String[] args) {
//正常,因为 Integer 是 Number 的子类型
MyArray<Integer> arr = new MyArray<>();
//编译错误,因为 String 不是 Number 的子类型
MyArray<String> arr2 = new MyArray<>();
}
运行结果:
示例二(特殊的上界,把接口作为上界):
class Alg<T extends Comparable<T>> {
public T comMax(T[] arrays) {
T max = arrays[0];
for (int i = 1; i < arrays.length; i++) {
if(max.compareTo(arrays[i]) < 0) {
max = arrays[i];
}
}
return max;
}
public static void main(String[] args) {
Alg<Integer> alg = new Alg<>();
Integer[] arr = new Integer[]{10,20,30,40};
int ret = alg.comMax(arr);
System.out.println(ret);
}
}
运行结果:
(八)泛型方法
1.定义语法
方法限定符<类型形参列表>返回值类型 返回值类型 方法名称(形参列表){
...
}
2.示例:
以上面 T 的上界为 Comparable 的类为例
class Alg1{
//stact 无所谓,也可以定义为非静态的
public static <T extends Comparable<T>>T comMax(T[] arrays) {
T max = arrays[0];
for (int i = 1; i < arrays.length; i++) {
if(max.compareTo(arrays[i]) < 0) {
max = arrays[i];
}
}
return max;
}
}
public class Test {
//没有实例化对象,直接利用类调用
public static void main(String[] args) {
Integer[] arr = new Integer[]{10,20,30,40};
//第一种写法,会自动推导类型
int ret = Alg1.comMax(arr);
//第二种写法,表明类型
int ret = Alg1.<Integer>comMax(arr);
System.out.println(ret);
}
}
(九)通配符
? 用于在泛型的使用,即为通配符
1.通配符的作用
通配符是用来解决泛型无法协变的问题的。协变指的就是如果 Student 是 Person 的子类,那么 List 也应该是 List 的子类,但是泛型是不支持这样的父子类的关系的,比如说一个方法的形参是 Number,那么按理也应该能传 Number 的子类,实现向上转型和多态,但泛型的情况不允许,所以设了一个更加灵活的类型,称为通配符
下来用一段代码引出通配符
class Message<T> {
private T message ;
public T getMessage() {
return message;
}
public void setMessage(T message) {
this.message = message;
}
}
public class Test2 {
public static void main(String[] args) {
Message<String> message = new Message() ;
message.setMessage("hello world!");
fun(message);
}
public static void fun(Message<String> temp){
System.out.println(temp.getMessage());
}
}
运行结果:
但是以上代码有个问题,加入我们传入的 T 的类型不是 Integer 的情况,而是 String 类的,那么就不能使用 fun 方法,而如果说进行重载呢?
代码示例:
public static void fun(Message<String> temp){
System.out.println(temp.getMessage());
}
public static void fun(Message<Integer> temp){
System.out.println(temp.getMessage());
}
代码会报错,报错信息:
提示以上两种方法具有相同的擦除功能,因为我们使用的是泛型,因此无论调用这两个方法的哪一个,最后实际都擦成了这个类的泛型规定的上界或者是 Object 类,因此在实际运行时,这两个方法其实是一样的,这样,我们就引出了通配符的概念
代码示例:
public static void main(String[] args) {
Message<String> message = new Message() ;
Message<Integer> message1 = new Message<>();
message.setMessage("hello world!");
message1.setMessage(5);
fun(message);
fun(message1);
}
//明显,我们把实际的类型改成了"?"
public static void fun(Message<?> temp){
System.out.println(temp.getMessage());
}
运行结果:
这样做的效果就是可以接收到所有的泛型类型
随之带来的一个问题就是在这种情况下绝对不能在通配符的方法内对对象进行修改,原因就是系统并不知道我们将要传的类型是什么类型,因此也不会允许我们进行这种修改操作,如下面两张图所示,无论是在方法内直接用常量,还是利用形参赋值,都是不被允许的,如果要对某一种特定的类型进行赋值,那就无法使用通配符
2.通配符上界
语法:
<? extends 上界>
//例如
<? extends Number>//可以传入的实参类型是 Number 或者 Number 的子类
通配符的上界与泛型的上界理解起来类似,但仍不能写入数据,只能读取数据,且如果要接收必须利用向上转型定义一个上界的类型进行接收
3.通配符下界
语法:
<? super 下界>
//例如
<? super Integer>//代表可以传入的实参的类型是 Integer 或者 Integer 的父类类型
通配符的下界,只能写入数据,不能取数据,并且只能写入下界的子类类型的对象。因为如果要取数据,我们没有一个上界,不能利用上界通过向上转型进行接收。但是由于实参的类型都是下界本身或者下界的父类,因此我们可以利用向下转型对实参进行写入操作
代码示例:
class Message<T> {
private T message ;
public T getMessage() {
return message;
}
public void setMessage(T message) {
this.message = message;
}
}
class Food{
}
class Fruit extends Food{
}
class Apple extends Fruit{
}
public class Test2 {
public static void fun2(Message<? super Fruit> temp) {
temp.setMessage(new Apple());
}
}
总结
以上就是今天要讲的内容,本文介绍的集合,时间复杂度与空间复杂度,以及泛型,都是为了让我们更好地学习 Java 的数据结构打基础