数据结构 - 1( 11000 字详解 )

一:初始集合框架

1.1 什么是集合框架

Java 集合框架 Java Collection Framework ,是定义在 java.util 包下的一组接口和其实现类,其主要表现为将多个元素 element 置于一个单元中,用于对这些元素进行快速、便捷的存储 store 、检索 retrieve 、管理 manipulate ,即平时我们俗称的增删查改 CRUD

集合中类和接口总览:

在这里插入图片描述

  • 其中 List,Queue,Set,Map,Deque 都是常用的接口
  • ArrayList,LinkedList,Stack,PriorityQueue,TreeSet,HashSet,TreeMap,HashMap 都是常用的类,

什么是数据结构呢?数据结构(Data Structure)是计算机存储、组织数据的方式,下面是容器背后对应的数据结构。

  • ArrayList - 顺序表
  • LinkedList - 链表
  • Stack - 栈
  • Queue -队列
  • PriorityQueue - 优先队列
  • TreeSet - 有序集合
  • HashSet - 无序集合
  • TreeMap - 有序映射
  • HashMap - 无序映射

二:时间复杂度和空间复杂度

2.1 什么是时间复杂度和空间复杂度

当我们分析算法的性能时,时间复杂度和空间复杂度是两个重要的指标。时间复杂度衡量了算法执行所需的时间,而空间复杂度衡量了算法所需的内存空间。

  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)

  1. 空间复杂度:

空间复杂度描述了算法运行所需的额外内存空间量。它通常用来衡量算法使用的额外内存与输入规模的关系。

以下是常见的空间复杂度:

  • 常数空间复杂度 O(1):算法使用的额外内存空间是基本不变的,与输入规模无关。
  • 线性空间复杂度 O(N):算法使用的额外内存空间与输入规模成线性关系。
  • 平方空间复杂度 O(N^2):算法使用的额外内存空间与输入规模的平方成正比。

当使用大O渐近表示法计算时间复杂度和空间复杂度时,我们关注的是算法在输入规模增长时的增长率。下面详细讲解如何计算时间复杂度和空间复杂度。

2.1.1 计算时间复杂度

  • 单语句执行时间:对于基本操作( 如赋值、算术运算、比较等 ),我们将它们视为常数时间,表示为 O(1)。

  • 循环语句:对于循环语句,我们将循环体内的操作重复执行的次数与输入规模 n 相关的部分作为循环的时间复杂度。

    • 顺序执行的循环语句,时间复杂度为循环次数乘以循环体内操作的时间复杂度。
    • 嵌套循环的时间复杂度为内外循环的时间复杂度的乘积。
  • 递归函数:递归的时间复杂度可以通过递归函数的递推关系和递归的深度来计算。

下面是一个用大O渐近表示法计算时间复杂度的例子:

void exampleFunction(int[] array) {
    for (int i = 0; i < array.length; i++) {     // 循环 n 次
        System.out.println(array[i]);            // 操作时间复杂度为 O(n)
    }
}

该函数的时间复杂度为 O(n),其中 n 为输入数组的长度。

2.1.2 计算空间复杂度

  • 基本数据类型和常量的空间复杂度为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 时间复杂度

  1. 时间复杂度为 O(1) 的示例(常数时间复杂度):
public void printFirstElement(int[] arr) {
    System.out.println(arr[0]);
}

这个示例代码只是简单地打印数组 arr 的第一个元素。不管输入的数组大小是多少,该函数只执行一次操作,因此时间复杂度为常量级别,即 O(1)。

  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)。

  1. 时间复杂度为 O(N) 的示例(线性时间复杂度):
public void linearPrint(int[] arr) {
    for (int num : arr) {
        System.out.println(num);
    }
}

该代码示例遍历并打印给定数组 arr 中的每个元素。随着输入数组大小的增加,操作次数也会线性增长,因此时间复杂度为 O(N)。

  1. 时间复杂度为 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)。

  1. 时间复杂度为 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)。

  1. 时间复杂度为 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)。

  1. 时间复杂度为 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 空间复杂度

  1. 空间复杂度为 O(1) 的示例(常量级空间复杂度):
// 示例1 - O(1) 空间复杂度
public void printNumber(int n) {
    System.out.println(n);
}

算法所需的额外空间与输入规模无关,常量级别的空间消耗。所以空间复杂度为O(1)。

  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)

  1. 空间复杂度为 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 引入了包装类。包装类是一种特殊的类,用于将基本数据类型包装为对象。每个基本数据类型都有对应的包装类。

如图所示:

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

很容易发现,除了 Integer 和 Character, 其余基本类型的包装类都是首字母大写。

包装类的主要意义包括:

  1. 提供面向对象的方法和属性:包装类提供了一系列方法和属性,可以实现基本数据类型的各种操作和转换,使得基本数据类型具有了面向对象的特性。

  2. 允许将基本数据类型作为对象存储:将基本数据类型包装为对象后,可以将其作为参数传递给方法、存储在集合中,以及在其他需要对象的上下文中使用。

  3. 支持自动装箱和拆箱:Java 提供了自动装箱和拆箱的机制,使得基本数据类型和对应的包装类可以自动转换。

  4. 提供了 null 值的表示:基本数据类型不具备表示空值的能力,而包装类通过包装 null 值为对象来表示空值,可以更好地处理空值的情况。

3.1 自动装箱和自动拆箱

当我们在 Java 中使用基本数据类型时,有时需要将它们转换为对象类型。这种转换过程称为装箱。而将对象类型转换为基本数据类型的过程称为拆箱。

Java 提供了自动装箱和自动拆箱的特性,下面是一些示例代码,将帮助我们理解自动装箱和自动拆箱的过程:

// 自动装箱
int num = 10; // 基本数据类型
Integer obj = num; // 自动装箱,将基本数据类型转换为Integer对象

// 自动拆箱
Integer obj2 = 20; // Integer对象
int num2 = obj2; // 自动拆箱,将Integer对象转换为基本数据类型

在第一段代码中,我们有一个int类型的变量num,然后 num 自动转换为一个Integer对象obj。这个过程是隐式进行的,不需要显式调用任何函数或方法来实现装箱操作。

在第二段代码中,我们有一个Integer对象obj2,然后 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);//true
  System.out.println(c == d);//false
}

Java 为了提高性能和节省内存,对一定范围内的整数值(通常是 -128 到 127)预先创建了对应的 Integer 对象,并将其缓存起来,避免了频繁创建和销毁对象的开销

首先,我们可以注意到 a 和 b 的值都是 127,而 c 和 d 的值都是 128。根据 Integer cache 机制,当我们对 a 和 b 进行比较时,由于它们的值在缓存范围内,实际上是在比较两个引用是否指向同一个对象。所以,a == b 的结果为 true。

而当我们对 c 和 d 进行比较时,由于它们的值超出了缓存范围,Java 会创建新的 Integer 对象来表示这两个值。所以,c == d 的结果为 false,因为它们并不是同一个对象。

四:泛型

4.1 引出泛型

实现一个类,类中包含一个数组成员,使得数组中可以存放任何类型的数据,也可以根据成员方法返回数组中某个下标的值?

思路:

  1. 我们以前学过的数组,只能存放指定类型的元素,例如:
 int[] array = new int[10]; 
 String[] strs = new String[10];
  1. 所有类的父类,默认为 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. 任何类型数据都可以存放
  2. 一号下标本身就是字符串,但是确编译报错。必须进行强制类型转换

虽然在这种情况下,当前数组任何数据都可以存放,但是,更多情况下,我们还是希望他只能够持有一种数据类型。而不是同时持有这么多类型。所以,泛型的主要目的:就是指定当前的容器,要持有什么类型的对象。此时,就需要把类型,作为参数传递。需要什么类型,就传入什么类型。

4.2 泛型的语法

泛型的语法:

class 泛型类名称<类型形参列表> {
  // 这里可以使用类型参数
}

所以我们可以把上述代码改成这样:

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;
 }
}

注意:

  1. MyArray< Integer > myArray = new MyArray<>();
  2. MyArray< > myArray = new MyArray< Integer >();
  3. MyArray< Integer > myArray = new MyArray< Integer >();

这 3 种写法是等效的,只不过是在不同的时期指定了泛型参数类型而已

类名后的 < T > 代表占位符,表示当前类是一个泛型类

规范:类型形参一般使用一个大写字母表示,常用的名称有:

  • E 表示 Element
  • K 表示 Key
  • V 表示 Value
  • N 表示 Number
  • T 表示 Type

4.3 泛型的上界

因为在普通的泛型中,我们传入的类型可以是任何类型,但是某些时候,我们又想对传入的类型进行一定的限制,那么此时就要用到泛型的上界了,通过上界,我们可以指定泛型类型参数必须是某个特定类型或其子类型。

通常,使用上界的语法是在泛型参数后面使用关键字 “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方法比较元素的大小。方法返回数组中最大的元素。

注意:

  1. 泛型的信息只存在于代码编译阶段,在代码编译结束之后,于泛型相关的信息会被擦除,专业术语叫做类型擦除,也就是说,成功编译后的 class 文件不包含任何泛型信息,泛型信息不会进入运行时阶段。
  2. 泛型类中的静态方法和静态变量不可以使用泛型类所声明的类型参数,因为泛型类中类型参数的确定是在创建泛型类的时候,而此时静态变量和静态方法在类加载的时候就已经初始化了。

以下是一个例子来证明这一点:

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);
    }
}


静态方法内部不能使用泛型类所声明的类型参数,静态方法的参数也不能使用泛型类所声明的类型参数。

  • 31
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 30
    评论
评论 30
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ice___Cpu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值