前言
想要深入理解一个事物,关键在于理解它的价值和作用。当你明白它的重要性和缺失的弊端时,自然会产生学习的兴趣。死记硬背API和知识点只能停留在表面,而深入理解其核心价值才能真正掌握精髓。
数组是Java世界的基石之一,其核心价值有如下3点:
- 存储和处理
大量相同类型的数据。 高效的随机访问和实现其他数据结构(如ArrayList、HashMap等)。算法的基础(如排序、查找)都是基于数组进行操作的。
操千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意。
一、数组的基本概念
1.1、图像表示
若别人问起数组是啥? 首先头脑中呈现的应该是下面这张图。以此为起点,然后再联想及回顾其相关知识。图片形式更容易记忆,可将下图深刻印入脑海。
1.2、定义
在计算机科学中,数组数据结构(
Array data structure),简称数组(Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储。利用元素的索引(index)可以计算出该元素对应的存储地址。——维基百科
对上述定义的深入理解:
上述定义中虽然出现了
集合的概念,但我们很多人并没有对旧知识进行对比及联系。费曼学习法中有一项必须遵守的原则就是,要用一种系统化思维对待学习,对待陌生的知识。当我们理解了一个概念时,不要把它作为孤立的个体存入大脑,要将它看着自身知识系统的一部分,和旧的知识做对比,存优去劣,也要存新去旧,才能升级自己的知识水平。下面我们回顾下高中所学的知识。集合:
一般地,我们把研究的
对象统称为元素,把一些元素组成的总体叫做集合。集合三要素:
确定性、互异性、无序性。对比及联系:
1、确定性:数组中
相同类型的元素可以体现这一特性。2、多重集:数学概念中有一种集合叫
多重集,其中元素是可以重复的,数组具有多重集的特性。3、有序性:由于数组在计算机中是
连续存储以及可通过索引快速访问,数组具有有序性的特性。4、集合的
值域的闭区间使用[]表示,集合的值使用列举法表示为{1,2,3,4,5}。5、数组声明与初始化与第
4点的关联:int[]array ={1,2,3,4,5}。6、使用
[]表示数组的一些思考:
- 编程语言中数据类型的
范围是有限制的,使用[]可以规范上下界。- 数组的
连续存储的空间在计算机内存中是有限制的。- 由上述分析可得出如下结论:
[]的核心作用是限制大小。通过上述的分析理解,可以得出数组的
价值1:
用于存储和处理大量相同类型的数据。
1.3、内存布局
- 数组中的元素在内存中是
连续储存的。 - 通过
基地址和偏移量可以快速访问数组中的任意元素。例如,如果数组的基地址是base,每个元素占用size字节,那么第i个元素的地址是base + i * size,该公式也叫数组的寻址公式。
1.4、索引
- 数组中的每个元素都有一个
唯一的索引(或下标),索引从0开始。 - 通过
索引可以快速访问数组中的任意元素,时间复杂度为O(1)。
二、数组的特点
2.1、类型一致:
- 数组中的所有元素
必须是相同类型的数据。 - 这种
一致性简化了内存管理和数据处理。
2.2、连续存储(核心价值):
- 数组中的元素在内存中是
连续存储的,这对CPU的缓存机制非常友好。连续的内存访问可以减少缓存未命中,提高性能。 连续存储使得数组的内存利用率较高。连续存储使得数组在插入和删除的操作上效率较低。
2.3、随机访问(核心价值):
- 通过
索引可以快速访问数组中的任意元素,不需要遍历整个数组,时间复杂度为O(1)。 - 这使得数组
非常适合需要频繁随机访问的应用场景。
2.4、固定大小:
- 静态数组:数组的
大小在创建时确定,并且在运行时不能改变。 - 动态数组(
ArrayList):可以在运行时调整大小,但内部实现仍然是基于固定大小的数组。
对上述第2点中的CPU的缓存机制及减少缓存未命中,提高性能的深入理解:
缓存机制:
缓存是一种
高速存储器,位于CPU和主内存之间,用于存储最近或频繁访问的数据。缓存的访问速度远快于主内存,因此通过将常用数据存储在缓存中,可以显著提高程序的执行速度。示例说明:
假设有一个大小为1000的整数数组arr,每个整数占用4个字节,数组的起始地址是1000。数组在内存中的布局如下:
假设每个缓存行的大小是64字节(常见的缓存行大小),那么一个缓存行可以容纳16个整数(64字节 / 4字节/整数 = 16个整数)。
- 当程序访问arr[0]时,地址1000到1063之间的所有数据(即arr[0]到arr[15])都会被
加载到缓存中。- 如果程序接着访问arr[1]、arr[2]等,这些访问会
直接命中缓存,而不需要再次访问主内存。- 由于数组是连续存储的,这种连续访问模式非常符合缓存的工作方式,从而
大大减少了缓存未命中的情况。
三、数组的类型
3.1、一维数组:
- 只有一个维度的数组。
3.2、多维数组:
- 具有多个维度的数组,如二维数组(
矩阵)。
3.3、动态数组(ArrayList):
- 可以在
运行时调整大小的数组,内部实现是基于固定大小的数组。
3.4、稀疏数组:
- 用于存储大部分
元素为空或默认值的数组,以节省空间。
上述类型中一维数组和动态数组是最常用,也是我们需要重点学习的方向。
四、数组的声明及初始化
4.1、声明数组:
- 先声明
数组变量,指定数组元素的类型。例如:
int[] array; // 声明一个整数类型的一维数组
String[] strArray; // 声明一个字符串类型的一维数组
4.2、静态初始化:
- 在
声明数组时同时为数组元素赋值。例如:
int[] array = {1, 2, 3, 4, 5};
String[] strArray = {"Android", "iOS", "Flutter"};
4.3、动态初始化:
- 先指定数组的
大小,然后再为数组元素赋值。例如:
int[] array = new int[5]; // 创建一个包含 5 个整数的数组
array[0] = 1;
array[1] = 2;
array[2] = 3;
array[3] = 4;
array[4] = 5;
4.4、数组默认值:
未初始化的数组元素会自动设置为其类型的默认值。
- 基本类型:
- 整型 (
byte、short、int、long):默认值为0。 - 浮点型 (
float、double):默认值为0.0。 - 字符型 (
char):默认值为\u0000(空字符)。 - 布尔型 (
boolean):默认值为false。
- 整型 (
- 引用类型:默认值为
null。
五、数组的内存分析
5.1、堆内存:
- 存放
new出来的数组和对象。 - 是所有
线程共享的数据区。
5.2、栈内存:
- 存放
基本数据类型的值。 - 存放
引用类型的引用(也称为内存地址),数组存放的内存地址是数组的首地址。 - 是
线程隔离的数据区。
5.3、内存分析:
//1、声明一个整型的数组, 属于引用类型的变量, 存放在栈中。
int[] array;
//2、创建一个长度为10的数组, 属于new出来的对象, 存放在堆中。
array = new int[10];
//3、给数组中的元素赋值, 为new出来的对象赋值, 存放在堆中。
array[0] = 0;
array[1] = 1;
array[2] = 2;
array[3] = 3;
array[4] = 4;
array[5] = 5;
array[6] = 6;
array[7] = 7;
array[8] = 8;
array[9] = 9;
使用下图可以形象的描述其关系:
5.4、空间占用:
如上图所示,Java中数组结构为:
8字节的markword(对象头)4字节的class指针(压缩class指针的情况)4字节的数组大小(决定数组最大容量是2^32)数组元素+对齐字节(Java中所有对象大小都是8字节的整数倍,不足的要用对齐字节补足)
例如:
int[] array = {1,2,3,4,5}
的大小为40个字节, 组成如下:
8 + 4 + 4 + 5*4 + 4 (
alignment)
Markword简介:
64位虚拟机MarkWord分布
-
MarkWord:主要包含对象
hashcode、偏向线程ID(ThreadID)、偏向锁状态(0,1 1为是偏向锁)、锁标志位(01,00,10,11无锁状态和偏向锁状态都为01,这时依靠偏向锁状态来区分,00位轻量级锁状态,10位重量级锁状态,11为GC状态)、和偏向时间戳。可以看到64位虚拟机其实是浪费了一部分空间的,JVM支持通过-XX:+UseCompressedOops参数来进行指针压缩。 -
Klass Pointer(
类指针):主要保存了指向元空间(方法区)类信息的指针,HotSpot虚拟机引用类型变量指针直接指向堆中对象地址,但是此时不知道对象的类型,需要对象头中的这部分数据来寻找元空间的类型信息。可以通过通过-XX:+UserCompressedClassPointers参数来进行指针压缩。 -
数组长度:当对象为数组类型时存储数组长度。
六、数组的元素访问
6.1、寻址公式:
从上图中,我们知道了数组在栈内存中存放的是数组的首地址。
灵魂拷问1:数组是如何获取其他元素的地址值的?
回答上面的问题,我们需要掌握如下公式:
寻址公式:
a[i] = baseAddress + i* dataTypeSize。baseAddress:表示数组的
基地址(也可称为首地址)dataTypeSize:表示数组中
元素类型的大小,比如int型的数据,其dataTypeSize = 4个字节。
6.2、零基索引:
灵魂拷问2:为什么数组索引是从0开始的? 假如从1开始不行吗?
回答上述问题,可以从如下几个方面考虑:
- 数学和计算机科学的传统:
- 零基索引:在数学中,特别是在计算机科学中,
零基索引是一种常见的惯例。例如,在数论中,序列或集合通常从0开始计数。这种惯例可以简化一些数学表达式和算法。 - 指针算术:在底层编程(如
C语言)中,数组实际上是一个连续内存块的指针。通过将数组的起始地址作为基地址,使用索引进行偏移量计算时,从0开始更自然。例如,arr[i]实际上是*(arr + i),其中arr是数组的起始地址,i是偏移量。
- 零基索引:在数学中,特别是在计算机科学中,
- 性能优化:
- 直接内存访问:从
0开始索引使得直接内存访问更加高效。 假如从1开始:寻址公式为:a[i] = baseAddress + (i-1)* dataTypeSize。对于CPU而言,增加了一个减法指令,在大规模数据处理中可能会累积成显著的开销。 - 缓存友好:现代处理器的
缓存机制通常是基于块(缓存行)进行管理的。从0开始索引可以更好地利用空间局部性,提高缓存命中率。
- 直接内存访问:从
- 一致性和简洁性:
- 一致性:从
0开始索引使得代码的一致性更好。例如,对于一个长度为n的数组,最后一个元素的索引是n-1,而不是n。这样可以避免在循环条件和其他边界检查中出现复杂的逻辑。 - 简洁性:从
0开始索引可以使代码更加简洁。例如,遍历数组的循环可以写成for (int i = 0; i < n; i++),而不需要额外的-1操作。
- 一致性:从
知晓上述原理,对于数组能实现高效的随机访问的理解就豁然开朗了
int[] arr = {1, 2, 3, 4, 5};
// 通过索引访问数组中的任意元素
int first = arr[0]; // 第一个元素
int second = arr[1]; // 第二个元素
int last= arr[4]; // 最后一个元素
6.3、边界检查:
为了避免越界异常(ArrayIndexOutOfBoundsException),在访问数组元素之前,应该进行边界检查。边界检查确保索引值在有效范围内。
int[] arr = {1, 2, 3, 4, 5};
int index = 5; // 假设我们要访问索引为5的元素
// 进行边界检查
if (index >= 0 && index < arr.length) {
int element = arr[index];
System.out.println("Element at index " + index + ": " + element);
} else {
System.out.println("Index out of bounds: " + index);
}
6.4、异常处理:
即使进行了边界检查,有时仍然可能因为某些原因导致越界异常。在这种情况下,可以使用异常处理机制来捕获并处理这些异常。
int[] arr = {1, 2, 3, 4, 5};
int index = 5; // 假设我们要访问索引为5的元素
try {
int element = arr[index];
System.out.println("Element at index " + index + ": " + element);
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("ArrayIndexOutOfBoundsException: " + e.getMessage());
// 可以在这里添加更多的错误处理逻辑
}
七、数组的基本操作
7.1、遍历数组
数组的遍历是指访问数组中的每一个元素,并对其进行操作。示例如下:
int[] arr = {1, 2, 3, 4, 5};
// 方式 1、使用 for 循环遍历数组
for (int i = 0; i < arr.length; i++) {
System.out.println("Element at index " + i + ": " + arr[i]);
}
// 方式 2、使用增强型 for 循环遍历数组
for (int element : arr) {
System.out.println("Element: " + element);
}
// 方式 3、使用 while 循环遍历数组
int i = 0;
while (i < arr.length) {
System.out.println("Element at index " + i + ": " + arr[i]);
i++;
}
// 方式 4、使用 do-while 循环遍历数组
int i = 0;
do {
System.out.println("Element at index " + i + ": " + arr[i]);
i++;
} while (i < arr.length);
小结
for循环:适用于需要索引的情况。- 增强型
for循环(foreach):适用于不需要索引的情况,代码更简洁。 while循环:适用于某些特定场景,但不如 for 循环常见。do-while循环:确保至少执行一次的循环体。
7.2、插入元素
7.2.1、末尾插入新元素
在数组末尾插入新元素是最简单的情况,因为不需要移动任何现有的元素。只需要将新元素添加到数组的最后一个位置即可。
public class ArrayInsertionExample {
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5};
int newElement = 6;
// 在数组末尾插入新元素
arr = insertAtEnd(arr, newElement);
// 打印更新后的数组
for (int element : arr) {
System.out.print(element + " ");
}
}
public static int[] insertAtEnd(int[] arr, int newElement) {
// 创建一个新的数组,长度比原数组多1
int[] newArr = new int[arr.length + 1];
// 复制原数组的所有元素到新数组
for (int i = 0; i < arr.length; i++) {
newArr[i] = arr[i];
}
// 将新元素插入到新数组的末尾
newArr[newArr.length - 1] = newElement;
return newArr;
}
}
输出: 1 2 3 4 5 6
7.2.2、中间位置插入新元素
在数组中间位置插入新元素需要将插入位置之后的所有元素向后移动一个位置,然后将新元素插入到指定位置。
public class ArrayInsertionExample {
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5};
int newElement = 99;
int position = 2; // 插入位置
// 在数组中间位置插入新元素
arr = insertAtPosition(arr, newElement, position);
// 打印更新后的数组
for (int element : arr) {
System.out.print(element + " ");
}
}
public static int[] insertAtPosition(int[] arr, int newElement, int position) {
// 检查插入位置是否有效
if (position < 0 || position > arr.length) {
throw new IllegalArgumentException("Invalid position");
}
// 创建一个新的数组,长度比原数组多1
int[] newArr = new int[arr.length + 1];
// 复制插入位置之前的元素
for (int i = 0; i < position; i++) {
newArr[i] = arr[i];
}
// 插入新元素
newArr[position] = newElement;
// 复制插入位置之后的元素
for (int i = position; i < arr.length; i++) {
newArr[i + 1] = arr[i];
}
return newArr;
}
}
输出: 1 2 99 3 4 5
7.2.3、插入性能分析
- 末尾插入:
- 时间复杂度:
O(1) - 解释:在数组末尾插入新元素只需要
复制原数组并添加新元素,这个操作是常数时间操作。
- 时间复杂度:
- 中间位置插入:
- 时间复杂度:
O(n) - 解释:在数组中间位置插入新元素需要将插入位置之后的
所有元素向后移动一个位置。假设数组长度为n,插入位置为k(0 ≤ k ≤ n),则需要移动n - k个元素。最坏情况下(插入位置为0),需要移动所有n个元素。因此,时间复杂度为O(n)。
- 时间复杂度:
7.3、删除元素
7.3.1、删除末尾元素
删除数组末尾的元素是最简单的情况,因为不需要移动其他元素。只需减少数组的有效长度即可。
public class ArrayDeletionExample {
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5};
// 删除末尾元素
int[] newArr = new int[arr.length - 1];
System.arraycopy(arr, 0, newArr, 0, arr.length - 1);
arr = newArr;
// 打印数组
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i] + " ");
}
}
}
输出: 1 2 3 4
7.3.2、删除中间元素
删除数组中间的元素需要移动后续的所有元素。假设我们要删除索引 i 处的元素,那么从索引 i+1 到数组末尾的所有元素都需要向前移动一位。
public class ArrayDeletionExample {
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5};
int index = 2; // 删除索引2处的元素
// 移动后续元素
for (int i = index; i < arr.length - 1; i++) {
arr[i] = arr[i + 1];
}
// 减少数组的有效长度
int[] newArr = new int[arr.length - 1];
System.arraycopy(arr, 0, newArr, 0, arr.length - 1);
arr = newArr;
// 打印数组
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i] + " ");
}
}
}
输出: 1 2 3 4
7.3.3、删除性能分析时间复杂度分析
- 删除末尾元素:时间复杂度为
O(1),前提是不需要调整数组大小。如果需要创建一个更小的新数组并复制现有元素,时间复杂度为O(n)。 - 删除中间元素:时间复杂度为
O(n),因为需要移动从删除点到数组末尾的所有元素。
7.4、查找元素
7.4.1、线性查找(顺序查找)
线性查找是一种简单的查找方法,它从数组的第一个元素开始,逐个比较每个元素,直到找到目标元素或遍历完整个数组。
public class LinearSearchExample {
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5};
int target = 3;
int index = linearSearch(arr, target);
if (index != -1) {
System.out.println("Element " + target + " found at index: " + index);
} else {
System.out.println("Element " + target + " not found in the array.");
}
}
public static int linearSearch(int[] arr, int target) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == target) {
return i; // 返回目标元素的索引
}
}
return -1; // 如果未找到目标元素,返回-1
}
}
输出: Element 3 found at index: 2
7.4.2、查找性能分析
- 线性查找:
- 时间复杂度:
O(n),其中n是数组的长度。- 最坏情况:目标元素不在数组中,或者在数组的最后一个位置,需要
遍历整个数组。 - 最好情况:目标元素在数组的第一个位置,只需要
一次比较。 - 平均情况:平均需要比较
n/2次。
- 最坏情况:目标元素不在数组中,或者在数组的最后一个位置,需要
- 时间复杂度:
八、数组的相关算法
8.1、冒泡排序
8.1.1、基本思想:
- 1、在
待排序的一组数中,比较相邻的两元素,若逆序就交换两元素的位置,直至最终完成排序。 - 2、每轮遍历会将当前
待排序部分的最大元素移动到末尾。 - 3、每轮遍历的范围
减少一个元素,因为最后一个元素已经是最大值。
8.1.2、优化:
- 如果在某次遍历中
没有发生任何交换,说明列表已经有序,可以提前结束排序。
8.1.3、动效演示及代码实现详解:
网上爬了一个生动形象的动效,如下图:
- 设置指针
i,j,i从角标0开始,到n-1结束。 j每次从0开始,到n-i-1结束。
- 第
1轮,i从0开始。 j从0开始,到n-i-1(5-0-1=4)结束。- 排序后的数据为
2-3-5-4-8。
- 第
2轮,i从1开始。 j从0开始,到n-i-1(5-1-1=3)结束。- 排序后的数据为
2-3-4-5-8。
以此类推...
详细实现步骤:
- 外层循环:
- 控制排序的轮数,一共进行了
n-1轮,n是数组的长度。
- 控制排序的轮数,一共进行了
- 内层循环:
- 在每一轮中,遍历
未排序的部分。 比较相邻的两个元素,如果顺序不对就进行交换操作。- 交换操作通过引入一个临时变量
temp来实现。
- 在每一轮中,遍历
- 优化:
- 设置一个标志变量
swapped,如果在一次完整的内层循环中没有发生交换,则退出外层循环。
- 设置一个标志变量
代码实现如下:
public static void main(String[] args) {
int[] arr = {5, 2, 3, 8, 4};
bubbleSort(arr);
}
/**
* 未优化前的实现
*/
public static 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;
}
}
}
}
/**
* 优化后的实现1
*/
public static void bubbleSort1(int[] arr) {
int n = arr.length;
boolean swapped;
for (int i = 0; i < n - 1; i++) {
swapped = false;
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
// 交换 arr[j] 和 arr[j+1]
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
// 设置 swapped 为 true
swapped = true;
}
}
// 如果没有发生交换,说明数组已经有序,提前结束
if (!swapped) {
break;
}
}
}
8.1.4、时间复杂度:
- 最坏情况:
n²,当输入数组是逆序时。 - 最好情况:
O(n),当输入数组已经是有序时(使用优化后的版本)。 - 平均情况:
n²。
8.1.5、空间复杂度:
O(1),因为冒泡排序是原地排序算法,不需要额外的空间。
8.2、选择排序
8.2.1、基本思想:
- 将数组分为两部分:
已排序部分和未排序部分。 - 初始时,
已排序部分为空,未排序部分包含所有元素。 - 在未排序部分中找到
最小(或最大)的元素,并将其交换到已排序部分的末尾。 - 重复上述过程,直到
未排序部分为空。
8.2.2、优化:
- 选择排序本身没有明显的优化,因为每一轮都需要
完全遍历未排序部分。 - 但可以选择在每轮中找到
最小元素的同时也找到最大元素,然后同时交换到两端,这称为双向选择排序。
8.2.3、动效演示及代码实现详解:
第1轮比较:
i从0开始。j从i+1开始。- 结果排成
2-5-8-4-3。
第2轮比较:
i从1开始。j从i+1开始。- 结束排成
2-3-8-5-4,倒数第二小值3出来了。
依此类推...
详细实现步骤:
- 外层循环:
- 控制排序的轮数,一共进行了
n-1轮,n是数组的长度。
- 控制排序的轮数,一共进行了
- 内层循环:
- 初始化:设定已排序部分的起始位置为
start,初始时start为0。 - 寻找最小元素:从
start开始到数组末尾,找到未排序部分中的最小元素的索引minIndex。 - 交换元素:将
minIndex处的元素与start处的元素交换。 - 更新已排序部分:将
start后移一位,表示已排序部分增加了一个元素。 - 重复上述步骤:直到
start达到数组末尾。
- 初始化:设定已排序部分的起始位置为
代码实现如下:
public class SelectionSort {
public static void main(String[] args) {
int[] arr = {5, 3, 8, 4, 2};
selectionSort(arr);
}
/**
* 未优化前的实现
*/
public static void selectionSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
// 假设 i 是当前未排序部分的最小元素的索引
int minIndex = i;
// 寻找未排序部分中的最小元素
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
// 如果找到了更小的元素,就交换它们的位置
if (minIndex != i) {
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
}
/**
* 优化后的实现
*/
public static void bidirectionalSelectionSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n / 2; i++) {
int minIndex = i;
int maxIndex = n - 1 - i;
for (int j = i; j <= n - 1 - i; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
if (arr[j] > arr[maxIndex]) {
maxIndex = j;
}
}
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
if (maxIndex != n - 1 - i) {
temp = arr[n - 1 - i];
arr[n - 1 - i] = arr[maxIndex];
arr[maxIndex] = temp;
}
}
}
}
8.2.4、时间复杂度:
- 最坏情况:
n²,当输入数组是逆序时。 - 最好情况:
n²,即使输入数组已经是有序时。 - 平均情况:
n²。
8.2.5、空间复杂度:
O(1),因为冒泡排序是原地排序算法,不需要额外的空间。
8.3、二分查找
8.3.1、概述
在计算机科学中,二分查找算法(binary search algorithm),也称折半搜索算法、对数搜索算法,是一种在有序数组中查找某一特定元素的搜索算法。
- 搜索过程从数组的
中间元素开始, - 如果中间元素
正好是要查找的元素,则搜索过程结束; - 如果某一
特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。 - 如果在某一步骤数组
为空,则代表找不到。 - 这种搜索算法每一次比较都使搜
索范围缩小一半。
8.3.2、需求描述
- 在有序数组
A内,查找值target。 - 若找到返回目标值的
索引。 - 若找不到返回
-1。
8.3.3、算法描述
再通过如下动画体会一下:
8.3.4、代码实现
图示比较容易映入脑海,根据上述讲解以及上图所示,手写代码实现就比较容易多了,具体实现代码如下:
public class BinarySearchExample {
public static void main(String[] args) {
int[] arr = {5, 14, 22, 30, 31, 38, 41, 44};
int target = 3;
int index = binarySearch(arr, target);
if (index != -1) {
System.out.println("Element " + target + " found at index: " + index);
} else {
System.out.println("Element " + target + " not found in the array.");
}
}
public static int binarySearch(int[] arr, int target) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int m = (right + left) >>> 1; // 防止溢出
int midVal = arr[m];
if (target < midVal) {
right = m - 1; // 目标元素在左边, 位置往左移动
} else if (midVal < target) {
left = m + 1; // 目标元素在右边, 位置往右移动
} else {
return m; // 找到目标元素,返回索引
}
}
return -1; // 未找到目标元素,返回-1
}
- 二分查找:
- 时间复杂度:
O(log n),其中n是数组的长度。- 最坏情况:目标元素不在数组中,或者在数组的边界位置,需要进行
log n次比较。 - 最好情况:目标元素正好在数组的中间位置,只需要
一次比较。 - 平均情况:平均需要进行
log n次比较。
- 最坏情况:目标元素不在数组中,或者在数组的边界位置,需要进行
- 时间复杂度:
- 二分查找中的
数组和函数的关系:
九、Java标准库对数组的支持
9.1、Arrays 类中常用的操作方法:
9.1.1、排序系列方法
sort(T[] a):对数组进行升序排序。sort(T[] a, int fromIndex, int toIndex):对数组的指定范围进行升序排序。
9.1.2、查找系列方法
binarySearch(T[] a, T key):在已排序的数组中使用二分查找算法查找指定值。binarySearch(T[] a, int fromIndex, int toIndex, T key):在已排序的数组的指定范围内使用二分查找算法查找指定值。
9.1.3、比较系列方法
equals(T[] a, T[] a2):判断两个数组是否相等。deepEquals(Object[] a1, Object[] a2):判断两个多维数组是否相等。
9.1.4、复制系列方法
copyOf(T[] original, int newLength):复制数组,新数组的长度为指定值。copyOfRange(T[] original, int from, int to):复制数组的指定范围。
9.1.5、转换方法
toString(T[] a):将数组转换为字符串。asList(T... a):将数组转换为列表。
9.1.6、填充系列方法
fill(T[] a, T val):将数组的所有元素设置为指定的值。fill(T[] a, int fromIndex, int toIndex, T val):将数组的指定范围内的所有元素设置为指定的值。
9.1.7、计算hashCode
hashCode(T[] value):计算数组的哈希码。deepHashCode(Object[] a):计算多维数组的哈希码。
9.2、System 类中的arraycopy方法(重点掌握):
/**
* @param src 源数组.
* @param srcPos 源数组中的起始位置.
* @param dest 目标数组.
* @param destPos 目标数组中的起始位置.
* @param length 要复制的元素数量.
* 注意异常处理
* @exception IndexOutOfBoundsException
* @exception ArrayStoreException
* @exception NullPointerException
*/
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
/**
* 简单使用
*/
public class SystemArrayCopyExample {
public static void main(String[] args) {
// 初始化源数组
int[] sourceArray = {1, 2, 3, 4, 5};
// 初始化目标数组
int[] destinationArray = new int[5];
// 使用 System.arraycopy 方法复制数组
System.arraycopy(sourceArray, 0, destinationArray, 0, sourceArray.length);
// 输出结果
System.out.println("Source Array: " + Arrays.toString(sourceArray));
System.out.println("Destination Array: " + Arrays.toString(destinationArray));
}
}
十、总结
在人类科学中,一件重要事物的存在必然有其发挥作用的领域,在其所属领域内,它是所向披靡的王。接触新知识时,感知其适用领域非常重要,因为工具只有在合适的场景下才能发挥最大效能。
核心价值1:存储和处理大量相同类型(Java中的基本类型)的数据。
核心价值2:高效的随机访问及实现其他数据结构。
核心价值3:算法的基础(排序和查找等)。
码字不易,记得 关注 + 点赞 + 收藏 + 评论