Java道经第1卷 - 第5阶 - 数据结构(一)
传送门:JB1-5-数据结构(一)
传送门:JB1-5-数据结构(二)
传送门:JB1-5-数据结构(三)
传送门:JB1-5-数据结构(四)
文章目录
心法:本章使用 Maven 父子结构项目进行练习
练习项目结构如下:
|_ v1-5-basic-algorithm
|_ test
武技:搭建练习项目结构
- 创建父项目 v1-5-basic-algorithm,删除 src 目录,并添加依赖如下:
<dependencies>
<!--junit-->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<scope>provided</scope>
</dependency>
</dependencies>
- 创建子项目 test,不添加任何依赖。
S01. 算法基本概念
E01. 常用概念
1. 数据结构
心法:数据结构是一种组织、管理和存储数据的方式,使得我们可以高效地访问和修改这些数据。
数据结构的关键点在于选型,选择合适的数据结构对于提高程序性能非常重要。
常见数据结构 | 中文 | 结构性 | 结构描述 |
---|---|---|---|
Array | 数组 | 线性 | 定长且连续 |
LinkedList | 链表 | 线性 | 由一系列节点(数据 + 前后指针)组成,不定长,不连续 |
Stack | 栈 | 线性 | 只能在一端操作,FILO 后进先出 |
Queue | 队列 | 线性 | 可以在双端操作,FIFO 先进先出 |
HashTable | 哈希表 | 非线性 | 通过键值对存储数据,利用哈希函数计算索引位置来实现快速存取数据 |
Tree | 树 | 非线性 | 由一个根节点以及多个叶子节点组成 |
Graph | 图 | 非线性 | 由一系列顶点以及连接这些顶点的边组成 |
2. 算法
心法:算法是指解决特定问题的一组明确指令或步骤,可能有零个或多个输入,并产生至少一个输出。
一个优秀算法的关键点在于可读,健壮和效率这三个方面。
算法和数据结构的区别与联系:
- 数据结构关注的是数据如何被组织起来以达到最优的空间利用率和访问速度。
- 算法关注的是使用这些数据结构来有效地解决问题的方法论。
- 总结:数据结构和核心是 “数据布局的方式”,而算法的核心是 “处理数据的过程”。
E02. 时间复杂度
心法:时间复杂度描述了算法运行时间与输入数据量之间的关系,通常用大 O 符号来表示。
时间复杂度主要用于帮助我们理解算法在最坏情况、平均情况和最好情况下的性能表现。
1. 常数O(1)
// 无论数组有多大,只执行一次常数时间
int a = arr[3];
2. 线性O(N)
// 数组长度为N,需要执行N次常数时间
for (int i = 0; i < arr.length; i++) {
System.out.println(arr[i]);
}
3. 对数O(logN)
// 每次循环N会折半,所以需要执行logN次常数时间
while (n > 0) {
n = n / 2;
}
4. 线性对数O(NlogN)
// 外层循环执行N次常数时间,内层循环执行logN次常数时间,总计NlogN
for (int i = 0; i < n; i++) {
for (int j = 1; j < n; j *= 2) {
System.out.println(i + " " + j);
}
}
5. 平方O(N^2)
// 外层循环执行N次常数时间,内层循环执行N次常数时间,总计N*N
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
System.out.println(i + " " + j); // 双重循环,总共 n * n 次
}
}
S02. 数组数据结构
E01. 比较类排序
心法:比较类排序通过比较元素大小关系(如 a > b),交换元素位置来实现排序,时间复杂度下界为 O(nlogn),适用于数据不具有明显数值特性的排序场景。
常用的比较类排序算法如下:
排序算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n) | O(n^2) | O(n^2) | O(1) | ✅ 稳定 |
选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | ❌ 不稳定 |
插入排序 | O(n) | O(n^2) | O(n^2) | O(1) | ✅ 稳定 |
希尔排序 | O(n) | O(n^1.3) | O(n^2) | O(1) | ❌ 不稳定 |
快速排序 | O(nlogn) | O(nlogn) | O(n^2) | O(logn) | ❌ 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | ✅ 稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | ❌ 不稳定 |
1. 冒泡排序
心法: 冒泡排序过程中,较大或较小的元素会经由交换慢慢浮到数列的顶端,故名冒泡排序,核心是 交换。
冒泡排序核心思想:从头开始,不断地比较相邻的两个元素,并进行对应的交换,直到最后排序完成。
冒泡排序算法流程:假设需要对长度为 N 的数组进行升序排序:
- 第 1 轮操作:
- 从头开始比较每一对相邻元素,共需比较 N-1 次。
- 若前数比后数大则交换,否则跳过,即让大的元素向后移动。
- 轮次结束后,数组 N-1 号位 元素一定是数组中第 1 大的元素。
- 第 2 轮操作:
- 从头开始比较每一对相邻元素,共需比较 N-2 次。
- 若前数比后数大则交换,否则跳过,即让大的元素向后移动。
- 轮次结束后,数组 N-2 号位元素一定是数组中第 2 大的元素。
- 第 N-1 轮操作:
- 比较最后一对相邻元素,共需比较 1 次。
- 若前数比后数大则交换,否则跳过,即让大的元素向后移动。
- 轮次结束后,数组 2 号位元素一定是数组中第 2 大的元素。
- 排序完成。
冒泡排序算法总结:
- 时间复杂度:平均和最坏(数据逆序时)都是 O(n^2),最好 O(n)(数据已有序,需优化)。
- 空间复杂度:O(1)(原地排序,仅需常数级额外空间)。
- 稳定性:稳定(相邻元素比较,相等时不交换)。
- 优化方向:
- 提前终止:若某轮未发生交换,说明数组已有序,提前结束(示例代码已实现)。
- 双向冒泡(鸡尾酒排序):交替从前往后和从后往前冒泡,减少比较次数。
武技: 使用冒泡排序算法对数组进行升序排序
package sort;
/** @author 周航宇 */
public class BubbleSortTest {
@Test
public void bubbleSort() {
int[] arr = {101, 2, 23, 133, 412, 23, 412, 51, 235};
bubbleSort(arr);
System.out.println(Arrays.toString(arr));
}
/** 使用冒泡排序算法对数组进行升序排序 */
private void bubbleSort(int[] arr) {
// 每一轮:9个数,两两相比,要比9-1轮
for (int i = 0, j = arr.length - 1; i < j; i++) {
// 相邻两个数比较,需要比较length-1-i次
for (int m = 0, n = arr.length - 1 - i; m < n; m++) {
// 前数大于后数就交换,循环一次完毕保证最大的数排最后
if (arr[m] > arr[m + 1]) {
swap(arr, m, m + 1);
}
}
}
}
/**
* 交换数组中两个不同位置上的值
*
* @param arr 数组
* @param a 数组中的某位置,不能和b重复,否则亦或运算会抹除为0
* @param b 数组中的某位置,不能和a重复,否则亦或运算会抹除为0
*/
private void swap(int[] arr, int a, int b) {
arr[a] = arr[a] ^ arr[b];
arr[b] = arr[a] ^ arr[b];
arr[a] = arr[a] ^ arr[b];
}
}
2. 选择排序
心法: 每一次从无序区中选出最小或最大的一个元素,追加到有序区,直到全部无序区的数据元素排完,核心是 抢夺。
选择排序不稳定:如序列 [5,5,3],第1轮就将第一个 5 与 3 交换,导致第一个 5 被挪动到第二个 5 的后面。
选择排序算法流程:假设需要对长度为 N 的数组进行升序排序,排序开始之前,全部元素处于无序区,暂无有序区:
- 第 1 轮操作:
- 使用数组中的 0 号位元素作为挑战变量,假设为 A。
- 使用 A 依次和无序区其他元素比较,发现小的就抢夺其值,共需比较 N-1 次。
- 轮次结束后,数组 0 号位存放的一定是数组中第 1 小的值,进入有序区。
- 第 2 轮操作:
- 使用数组中的 1 号位元素作为挑战变量,假设为 B。
- 使用 B 依次和无序区其他元素比较,发现小的就抢夺其值,共需比较 N-2 次。
- 轮次结束后,数组 1 号位存放的一定是数组中第 2 小的值,进入有序区。
- 第 N-1 轮操作:
- 使用数组中的 N-1 号位元素作为挑战变量,假设为 M。
- 使用 B 依次和无序区最后一个元素比较,比它小的就抢夺其值,共需比较 1 次。
- 轮次结束后,数组 N-1 号位存放的一定是数组中 N-1 小的值,进入有序区。
- 排序完成:此时无序区没有任何元素了,全部元素处于有序区。
选择排序算法总结:
- 时间复杂度:平均、最坏、最好均为 O(n2)(无论数据分布如何,都需完整遍历未排序部分)。
- 空间复杂度:O(1)(原地排序)。
- 稳定性:不稳定(例如
[5, 5, 2]
中,第一个5
会与2
交换,破坏相对顺序)。 - 优化方向:
- 同时选择最大和最小元素,减少遍历次数(双向选择排序)。
- 适用于数据量小或交换操作代价高的场景(比较次数多,但交换次数少)。
武技: 使用选择排序法对数组进行升序排序
package sort;
/** @author 周航宇 */
public class SelectionSortTest {
@Test
public void selectionSort() {
int[] arr = {101, 2, 23, 133, 412, 23, 412, 51, 235};
selectionSort(arr);
System.out.println(Arrays.toString(arr));
}
/** 使用选择排序算法对数组进行升序排序 */
public void selectionSort(int[] arr) {
// 每轮都将确定将一个无序区中最小的元素追加到有序区,需要比较N-1次
for (int i = 0, j = arr.length - 1; i < j; i++) {
// 每一次都拿一个元素和后面所有的元素进行比较
for (int m = i + 1, n = arr.length; m < n; m++) {
// 只要比arr[x]小,arr[x]就将其抢夺,最终arr[x]一定是无序区最小的元素
if (arr[i] > arr[m]) {
swap(arr, i, m);
}
}
}
}
/**
* 交换数组中两个不同位置上的值
*
* @param arr 数组
* @param a 数组中的某位置,不能和b重复,否则亦或运算会抹除为0
* @param b 数组中的某位置,不能和a重复,否则亦或运算会抹除为0
*/
private void swap(int[] arr, int a, int b) {
arr[a] = arr[a] ^ arr[b];
arr[b] = arr[a] ^ arr[b];
arr[a] = arr[a] ^ arr[b];
}
}
3. 插入排序
心法: 插入排序算法算法适用于少量数据的排序,是稳定的排序方法,且在对几乎已经排好序的数据操作时,效率最高,核心是 插队。
插入排序核心思想:在一个有序队列中插入一个新的元素后,该队列仍然有序,可对比理解为生活中按大小个排序的思想。
插入排序算法流程:假设需要对长度为 N 的数组进行升序排序,排序开始之前,将数组 0 号位元素加入队伍,其余元素都处于队伍之外:
- 第 1 轮操作:
- 将数组中的 1 号位元素视为要插队的元素,假设为 A。
- 使用 A 在队伍中从后向前依次比较,发现小的就交换位置,共需比较 1 次。
- 轮次结束后,得到一个新的队伍,队伍中存在 2 个元素。
- 第 2 轮操作:
- 将数组中的 2 号位元素视为要插队的元素,假设为 B。
- 使用 B 在队伍中从后向前依次比较,发现小的就交换位置,共需比较 2 次。
- 轮次结束后,得到一个新的队伍,队伍中存在 3 个元素。
- 第 N-1 轮操作:
- 将数组中的 N-1 号位元素视为要插队的元素,假设为 M。
- 使用 M 在队伍中从后向前依次比较,发现小的就交换位置,共需比较 N-1 次。
- 轮次结束后,得到一个新的队伍,队伍中存在 N 个元素。
- 排序完成:此时队伍中的元素都是从小到大排好序的了。
插入排序算法总结:
- 时间复杂度:平均 O(n2),最坏 O(n^2)(数据逆序时),最好 O(n)(数据已有序)。
- 空间复杂度:O(1)(原地排序)。
- 稳定性:稳定(插入时相同元素不交换)。
- 优化方向:
- 二分插入:在已排序部分用二分查找确定插入位置,减少比较次数至 O(nlogn),但移动次数仍为 O(n^2)。
- 链表插入:对链表进行插入排序,减少元素移动开销(仅需修改指针)。
武技: 使用插入排序法对数组进行升序排序
package sort;
/** @author 周航宇 */
public class InsertSortTest {
@Test
public void insertSort() {
int[] arr = {101, 2, 23, 133, 412, 23, 412, 51, 235};
insertSort(arr);
System.out.println(Arrays.toString(arr));
}
public void insertSort(int[] arr) {
// 从第i个位置开始依次向前比较,i从1开始,因为第0个人无法和它前面的人进行比较
// 由于你的i是从1开始的,所以判断条件要改为i<arrs.length,不能使用length-1,否则会少比一次
for (int i = 1, j = arr.length; i < j; i++) {
// 角标为1的人(第二个人),最多需要向前比较1次
// 角标为2的人(第三个人),最多需要向前比较2次
// 角标为i的人(第i+1个人),最多需要向前比较i次,所以m = i ; m > 0 ; m--
for (int m = i; m > 0; m--) {
// 若后面的数小,交换,若后面的数大,直接结束循环,没有再向前比较的必要
if (arr[m] < arr[m - 1]) {
swap(arr, m, m - 1);
} else {
break;
}
}
}
}
/**
* 交换数组中两个不同位置上的值
*
* @param arr 数组
* @param a 数组中的某位置,不能和b重复,否则亦或运算会抹除为0
* @param b 数组中的某位置,不能和a重复,否则亦或运算会抹除为0
*/
private void swap(int[] arr, int a, int b) {
arr[a] = arr[a] ^ arr[b];
arr[b] = arr[a] ^ arr[b];
arr[a] = arr[a] ^ arr[b];
}
}
4. 希尔排序
心法: 希尔排序是插入排序的优化版本,是稳定的排序方法,核心是 步长。
希尔排序核心思想:将数组中的元素,按下标的一定增量分组,然后对每一组进行插入排序,然后缩减增量后,重复再进行排序,直到增量缩减到1时,进行最后一次直接插入排序,排序结束。
希尔排序算法流程:假设需要对长度为 N 的数组进行升序排序,排序开始之前,设定初始步长为 数组长度 / 2,尾为 步长,头为 尾 - 步长:
- 第 1 轮操作:
- 设置 尾 = 步长,设置 头 = 尾 - 步长。
- 比较 尾 和 头,将较小的值向前交换,作为下一次比较的 尾,这个 尾 是临时的。
- 将 头 向前移动一个 步长 的距离,继续和临时的 尾 比较并交换,直到 头 移动到数组开头时停止。
- 将 尾 向后移动一个位置,然后重新计算 头,并开启新一轮比较,直到 尾 移动到数组末尾时停止。
- 轮次结束后,得到一个新的数组,该数组比原始数组有序了一点,但并不是完全有序。
- 第 2 轮操作:
- 缩减 步长 为当前 步长 的一半,后续操作和第 1 轮步骤一致。
- 直到 步长 缩减为 1 时,进行最后一轮操作。
- 排序完成:此时队伍中的元素都是从小到大排好序的了。
希尔排序算法总结:
- 时间复杂度:取决于增量序列(如
n/2
递减),平均约 O(n^1.3),最坏 O(n^2)(如增量互质且每次移动一个元素)。 - 空间复杂度:O(1)(原地排序)。
- 稳定性:不稳定(相同元素可能被分在不同子序列中,导致相对顺序改变)。
- 优化方向:
- 增量序列优化:使用更高效的增量序列(如 Hibbard 序列:2k−1,时间复杂度约 O(n^1.5))。
- 小数组优化:子数组规模较小时改用插入排序(希尔排序在子数组内部使用插入排序)。
武技: 使用希尔排序算法对数组进行升序排序
package sort;
/** @author 周航宇 */
public class ShellSortTest {
/** 希尔排序 */
@Test
public void shellSort() {
int[] arr = {101, 2, 23, 133, 412, 23, 412, 51, 235};
shellSort(arr);
System.out.println(Arrays.toString(arr));
}
public void shellSort(int[] arr) {
// [步长]: 初始长度为数组长度的一半
int step = arr.length / 2;
// 当 [步长] 缩减到0时排序完成
while (step > 0) {
// [尾]: 从step开始向后移动
int tail = step;
// 当 [尾] 移动到数组末尾时结束,需要缩减一次 [步长] 后重新循环
while (tail < arr.length) {
// [头]: 从倒数第2个端点开始向前移动
int head = tail - step;
// 当 [头] 移动到数组开头时结束,需要向后移动一次 [尾] 后重新循环
while (head >= 0) {
// 升序排序: 比较 [当前头元素] 和 [当前尾元素],值小的向前交换
if (arr[head] > arr[head + step]) {
swap(arr, head, head + step);
}
// 向前移动一次 [头],每次移动的距离为当前 [步长]
head -= step;
}
// 向后移动一次 [尾],每次移动的距离为1
tail++;
}
// [步长] 缩减一半
step /= 2;
}
}
/**
* 交换数组中两个不同位置上的值
*
* @param arr 数组
* @param a 数组中的某位置,不能和b重复,否则亦或运算会抹除为0
* @param b 数组中的某位置,不能和a重复,否则亦或运算会抹除为0
*/
private void swap(int[] arr, int a, int b) {
arr[a] = arr[a] ^ arr[b];
arr[b] = arr[a] ^ arr[b];
arr[a] = arr[a] ^ arr[b];
}
}
5. 快速排序
心法:快速排序采用分治思想,选择基准值将数组分为两部分,递归排序子数组,核心是 分治 + 基准值。
快速排序核心思想:选择一个基准值(pivot),将数组分为两部分,左边部分元素均小于等于基准值,右边部分元素均大于基准值,然后递归地对左右两部分分别排序。
快速排序算法流程:假设需要对长度为 N 的数组进行升序排序:
- 选择基准值:从数组中选择一个元素作为基准值(pivot),通常选择数组的第一个元素、中间元素或最后一个元素。
- 分区操作(Partition):
- 初始化两个指针,左指针指向数组起始位置,右指针指向数组末尾位置。
- 左指针向右移动,直到找到一个大于等于基准值的元素;右指针向左移动,直到找到一个小于等于基准值的元素。
- 交换左右指针所指向的元素。
- 重复步骤 2 和 3,直到左指针超过右指针。
- 将基准值与右指针最终位置的元素交换,此时基准值左侧的元素均小于等于它,右侧的元素均大于等于它。
- 递归排序:
- 对基准值左侧的子数组(不包含基准值)递归执行快速排序。
- 对基准值右侧的子数组(不包含基准值)递归执行快速排序。
- 排序完成:当子数组长度为 0 或 1 时,递归终止,整个数组排序完成。
快速排序算法总结:
- 时间复杂度:平均 O(nlogn),最坏 O(n^2)(如数组已有序且选择第一个元素为基准值)。
- 空间复杂度:平均 O(logn)(递归栈深度),最坏 O(n)。
- 稳定性:不稳定(分区过程可能改变相同元素的相对顺序)。
- 优化方向:
- 随机选择基准值以避免最坏情况。
- 小数组使用插入排序优化。
- 三数取中法选择基准值。
武技:使用快速排序算法对数组进行升序排序
package sort;
/** @author 周航宇 */
public class QuickSortTest {
@Test
public void quickSort() {
int[] arr = {101, 2, 23, 133, 412, 23, 412, 51, 235};
quickSort(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
/**
* 使用快速排序算法对数组的指定区间进行升序排序
* @param arr 待排序的数组
* @param left 左边界索引(包含)
* @param right 右边界索引(包含)
*/
private void quickSort(int[] arr, int left, int right) {
if (left < right) {
// 分区操作,获取基准值的最终位置
int pivotIndex = partition(arr, left, right);
// 递归排序左子数组
quickSort(arr, left, pivotIndex - 1);
// 递归排序右子数组
quickSort(arr, pivotIndex + 1, right);
}
}
/**
* 分区操作:选择最右元素作为基准值,将数组分为两部分
* @param arr 待分区的数组
* @param left 左边界索引
* @param right 右边界索引(包含基准值)
* @return 基准值的最终位置
*/
private int partition(int[] arr, int left, int right) {
// 选择最右元素作为基准值
int pivot = arr[right];
// 记录小于等于基准值的元素应该插入的位置
int i = left - 1;
for (int j = left; j < right; j++) {
if (arr[j] <= pivot) {
i++;
// 将小于等于基准值的元素交换到左边
swap(arr, i, j);
}
}
// 将基准值放到正确位置
swap(arr, i + 1, right);
return i + 1;
}
/**
* 交换数组中两个不同位置上的值
* @param arr 数组
* @param a 位置a
* @param b 位置b
*/
private void swap(int[] arr, int a, int b) {
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
}
6. 归并排序
心法:归并排序遵循分治策略,将数组递归拆分为子数组,再合并有序子数组,核心是 分治 + 合并。
归并排序核心思想:将数组分成两个子数组,分别对两个子数组递归排序后,再将它们合并成一个有序数组。
归并排序算法流程:假设需要对长度为 N 的数组进行升序排序:
- 分解(Divide):
- 将当前数组从中间分成两个子数组,直到每个子数组只有一个元素(递归终止条件)。
- 合并(Merge):
- 创建一个临时数组,用于存放合并后的有序元素。
- 比较两个子数组的元素,按升序依次放入临时数组。
- 将剩余未处理的元素直接复制到临时数组末尾。
- 将临时数组的内容复制回原数组的对应位置。
- 排序完成:递归合并所有子数组后,整个数组变为有序。
归并排序算法总结:
- 时间复杂度:始终为 O(nlogn),无论输入数据的分布如何。
- 空间复杂度:O(n),需要额外的临时数组存储合并结果。
- 稳定性:稳定(合并过程中相同元素的相对顺序不会改变)。
- 适用场景:数据量大且对稳定性有要求的场景。
- 优化方向:
- 小数组使用插入排序减少递归开销。
- 原地归并(In-place Merge)可将空间复杂度优化到 O(1),但实现复杂。
武技:使用归并排序算法对数组进行升序排序
package sort;
/** @author 周航宇 */
public class MergeSortTest {
@Test
public void mergeSort() {
int[] arr = {101, 2, 23, 133, 412, 23, 412, 51, 235};
mergeSort(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
/**
* 使用归并排序算法对数组的指定区间进行升序排序
* @param arr 待排序的数组
* @param left 左边界索引(包含)
* @param right 右边界索引(包含)
*/
private void mergeSort(int[] arr, int left, int right) {
if (left < right) {
// 计算中间位置
int mid = left + (right - left) / 2;
// 递归排序左半部分
mergeSort(arr, left, mid);
// 递归排序右半部分
mergeSort(arr, mid + 1, right);
// 合并已排序的两部分
merge(arr, left, mid, right);
}
}
/**
* 合并两个有序子数组
* @param arr 待合并的数组
* @param left 左子数组的起始索引
* @param mid 中间索引(左子数组的结束位置)
* @param right 右子数组的结束索引
*/
private void merge(int[] arr, int left, int mid, int right) {
// 计算临时数组的长度
int n1 = mid - left + 1;
int n2 = right - mid;
// 创建临时数组
int[] L = new int[n1];
int[] R = new int[n2];
// 复制数据到临时数组
System.arraycopy(arr, left, L, 0, n1);
System.arraycopy(arr, mid + 1, R, 0, n2);
// 合并临时数组回原数组
int i = 0, j = 0, k = left;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}
// 复制左临时数组的剩余元素
while (i < n1) {
arr[k] = L[i];
i++;
k++;
}
// 复制右临时数组的剩余元素
while (j < n2) {
arr[k] = R[j];
j++;
k++;
}
}
}
7. 堆排序
心法:堆排序利用堆结构特性,将数组转化为最大堆(升序)或最小堆(降序),核心是 堆化 + 交换 + 调整。
堆排序核心思想:将待排序数组构建成最大堆(根节点为最大值),重复提取堆顶元素并调整剩余元素为新堆,直到整个数组有序。
堆排序算法流程:假设需要对长度为 N 的数组进行升序排序:
- 构建最大堆:
- 从最后一个非叶子节点开始向前调整,确保每个父节点的值大于子节点。
- 交换与调整:
- 将堆顶元素(最大值)与末尾元素交换。
- 排除末尾元素后,重新调整剩余元素为最大堆。
- 重复步骤 2:
- 每次交换后,堆大小减 1,继续调整堆结构,直到堆为空。
堆排序算法总结:
- 时间复杂度:始终为 O(nlogn),无论输入数据的分布如何。
- 空间复杂度:O(1),原地排序无需额外空间。
- 稳定性:不稳定(交换操作可能改变相同元素的相对顺序)。
- 适用场景:对空间效率要求高且数据量大的场景。
- 优势:
- 最坏情况时间复杂度仍为 O(nlogn),优于快速排序。
- 原地排序,无需额外存储。
- 劣势:
- 缓存不友好,元素移动距离大,性能略低于快速排序。
- 不适合小规模数据排序。
武技:使用堆排序算法对数组进行升序排序
package sort;
/** @author 周航宇 */
public class HeapSortTest {
@Test
public void heapSort() {
int[] arr = {101, 2, 23, 133, 412, 23, 412, 51, 235};
heapSort(arr);
System.out.println(Arrays.toString(arr));
}
/**
* 使用堆排序算法对数组进行升序排序
* @param arr 待排序的数组
*/
public void heapSort(int[] arr) {
int n = arr.length;
// 构建最大堆
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 逐个提取元素
for (int i = n - 1; i > 0; i--) {
// 将当前堆顶元素(最大值)移到末尾
swap(arr, 0, i);
// 重新调整堆结构,排除已排序的元素
heapify(arr, i, 0);
}
}
/**
* 将以i为根的子树调整为最大堆
* @param arr 数组
* @param n 堆的当前大小
* @param i 根节点索引
*/
private void heapify(int[] arr, int n, int i) {
int largest = i; // 初始化根节点为最大值
int left = 2 * i + 1; // 左子节点
int right = 2 * i + 2; // 右子节点
// 如果左子节点大于根节点
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
// 如果右子节点大于当前最大值
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
// 如果最大值不是根节点,交换并继续调整子树
if (largest != i) {
swap(arr, i, largest);
heapify(arr, n, largest);
}
}
/**
* 交换数组中两个元素的位置
*/
private void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
E02. 非比较类排序
心法:非比较类不直接比较元素大小,而是利用元素的 数值特性(如位数、范围、频率)或 统计信息 进行排序,时间复杂度下界可以突破 O(nlogn),实现线性时间排序,适用于数据具有明显数值特性的排序场景。
常用的非比较类排序算法如下:
排序算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|---|---|---|
计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | ✅ 稳定 |
基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O(n+r) | ✅ 稳定 |
桶排序 | O(n) | O(n) | O(n^2) | O(n) | ✅ 稳定 |
相关参数描述:
- k:数据范围(如最大值与最小值的差 + 1),若 k远小于 n,效率远高于比较排序。
- d:数据位数(如整数的十进制位数,例:数字 123 的 d=3)。
- r:基数(如十进制 r=10,二进制 r=2),桶的数量等于 r。
1. 计数排序
心法:计数排序利用数组下标直接存储元素出现次数,核心是 统计频率 + 前缀和定位 + 反向填充。
计数排序核心思想:统计每个元素出现的次数,通过前缀和计算元素在有序数组中的位置,适用于数据范围较小的整数排序。
计数排序算法流程:假设需要对长度为 N 的数组进行升序排序,元素范围为 [min, max]:
- 统计频率:
- 创建一个计数数组
count
,长度为max - min + 1
,统计每个元素出现的次数。
- 创建一个计数数组
- 前缀和计算:
- 将
count
数组转换为前缀和数组,记录每个元素在有序数组中的最后位置。
- 将
- 反向填充结果:
- 从原数组末尾开始遍历,根据前缀和数组确定元素在结果数组中的位置,同时更新前缀和数组。
计数排序算法总结:
- 时间复杂度:O(n+k),其中 k 是数据范围(
max - min + 1
)。 - 空间复杂度:O(n+k),需额外的计数数组和临时结果数组。
- 稳定性:稳定(通过反向填充保证相同元素的相对顺序)。
- 适用场景:
- 数据范围 k 远小于元素数量 n(如年龄、分数排序)。
- 非负整数排序(可扩展到负数,但需调整索引计算)。
- 优化方向:
- 若数据范围已知且固定(如 ASCII 字符),可直接创建固定大小的计数数组。
- 仅需排序结果时,可省略临时数组,直接在原数组上操作(但会失去稳定性)。
- 注意事项:
- 计数排序不适用于数据范围过大的场景,否则空间开销过大。
- 示例代码未处理负数,如需处理负数,可将最小值作为偏移量。
武技:使用计数排序算法对数组进行升序排序
package sort;
/** @author 周航宇 */
public class CountingSortTest {
@Test
public void countingSort() {
int[] arr = {4, 2, 2, 8, 3, 3, 1};
countingSort(arr);
System.out.println(Arrays.toString(arr));
}
/**
* 使用计数排序算法对数组进行升序排序
* @param arr 待排序的数组
*/
public void countingSort(int[] arr) {
if (arr == null || arr.length == 0) return;
// 确定数组的最大值和最小值
int max = arr[0];
int min = arr[0];
for (int num : arr) {
if (num > max) max = num;
if (num < min) min = num;
}
// 创建计数数组并统计频率
int[] count = new int[max - min + 1];
for (int num : arr) {
count[num - min]++;
}
// 前缀和计算,确定每个元素在有序数组中的最后位置
for (int i = 1; i < count.length; i++) {
count[i] += count[i - 1];
}
// 创建临时数组存储排序结果
int[] output = new int[arr.length];
// 反向填充结果数组,保证稳定性
for (int i = arr.length - 1; i >= 0; i--) {
int num = arr[i];
int position = count[num - min] - 1;
output[position] = num;
count[num - min]--; // 更新位置指针
}
// 将结果复制回原数组
System.arraycopy(output, 0, arr, 0, arr.length);
}
}
2. 基数排序
心法:基数排序从低位到高位逐位排序,借助「桶」按位分组,核心是 按位分配 + 收集 + 稳定排序。
基数排序核心思想:将整数按位数拆分,从最低位(个位)到最高位(如万位)依次排序。每一位排序时,将元素分配到对应的桶中(0-9 号桶),再按顺序收集元素,最终实现整体有序。
基数排序算法流程:
假设待排序数组为 [329, 457, 657, 839, 436, 720, 355]
,最大数为三位数:
- 初始化参数:
- 位数
d
:最大数的位数(如三位数d=3
)。 - 基数
r
:每一位的取值范围(十进制r=10
,即 0-9)。
- 位数
- 按位排序(从低位到高位):
- 第 1 轮:个位排序
- 创建 10 个桶(数组或队列),用于存储每一位的元素。
- 遍历数组,将每个元素按个位值放入对应桶(如
329
个位为 9,放入 9 号桶)。 - 按桶顺序(0-9 号)收集元素,得到新数组:
[720, 355, 436, 329, 839, 457, 657]
。
- 第 2 轮:十位排序
- 按十位值将元素放入对应桶(如
720
十位为 2,放入 2 号桶;355
十位为 5,放入 5 号桶)。 - 收集后得到新数组:
[329, 720, 436, 839, 355, 457, 657]
。
- 按十位值将元素放入对应桶(如
- 第 3 轮:百位排序
- 按百位值将元素放入对应桶(如
329
百位为 3,放入 3 号桶;720
百位为 7,放入 7 号桶)。 - 收集后得到最终有序数组:
[329, 355, 436, 457, 657, 720, 839]
。
- 按百位值将元素放入对应桶(如
- 第 1 轮:个位排序
基数排序算法总结:
- 时间复杂度:O(d(n+r)),其中 d 是位数,n 是元素个数,r 是基数(十进制为 10)。
- 空间复杂度:O(n+r),需额外空间存储桶和临时元素。
- 稳定性:稳定(同一位值的元素按原始顺序入桶,保证相对顺序不变)。
- 适用场景:整数排序,尤其是位数固定且数据量大的场景(如学号、ID 号)。
- 优势:无需比较元素,时间复杂度与数据分布无关,适合处理大量同位数整数。
- 局限性:仅适用于整数,对浮点数或负数需额外处理;数据范围大时位数
d
增加。
武技:使用基数排序算法对数组进行升序排序
package sort;
/** @author 周航宇 */
public class RadixSortTest {
@Test
public void radixSort() {
int[] arr = {329, 457, 657, 839, 436, 720, 355};
radixSort(arr);
System.out.println(Arrays.toString(arr)); // 输出: [329, 355, 436, 457, 657, 720, 839]
}
/**
* 使用基数排序算法对数组进行升序排序(处理正整数)
* @param arr 待排序的数组
*/
public void radixSort(int[] arr) {
if (arr == null || arr.length <= 1) return;
int max = Arrays.stream(arr).max().getAsInt(); // 找最大值确定位数
int d = String.valueOf(max).length(); // 位数
// 基数(十进制为10)
final int RADIX = 10;
Queue<Integer>[] buckets = new Queue[RADIX]; // 创建10个桶(队列)
for (int i = 0; i < d; i++) { // 从低位到高位逐位处理
// 初始化桶
for (int j = 0; j < RADIX; j++) {
buckets[j] = new LinkedList<>();
}
// 按当前位分配元素到桶中
for (int num : arr) {
int digit = (num / (int) Math.pow(10, i)) % RADIX; // 获取当前位的值(个位i=0,十位i=1,以此类推)
buckets[digit].offer(num);
}
// 按桶顺序收集元素
int index = 0;
for (Queue<Integer> bucket : buckets) {
while (!bucket.isEmpty()) {
arr[index++] = bucket.poll();
}
}
}
}
}
3. 桶排序
心法:桶排序将数据分到有限个桶中,每个桶内单独排序后合并结果,核心是 分桶 + 内排序 + 合并。
桶排序核心思想:将待排序数据均匀分配到多个桶中,每个桶内进行排序(如插入排序),最后按桶顺序合并结果。
桶排序算法流程:假设需要对长度为 N 的数组进行升序排序:
- 确定桶数量:根据数据范围和分布确定桶的数量
k
。 - 分桶映射:
- 遍历数组,将每个元素映射到对应的桶中。
- 映射函数通常为:
桶索引 = (元素值 - 最小值) / 桶容量
,其中桶容量 = (最大值 - 最小值) / 桶数量。
- 桶内排序:
- 对每个非空桶内的元素单独排序(如使用插入排序)。
- 合并结果:
- 按桶的顺序依次收集所有桶内的元素,形成有序数组。
桶排序算法总结:
- 时间复杂度:
- 平均:O(n+k)(k 为桶的数量),当元素均匀分布时接近线性时间。
- 最坏:O(n2)(所有元素落入同一个桶,退化为插入排序)。
- 空间复杂度:O(n+k)(需额外的桶空间和临时数组)。
- 稳定性:稳定(取决于桶内排序算法,若使用稳定排序如插入排序,则整体稳定)。
- 适用场景:
- 数据均匀分布在某个区间(如浮点数
[0, 1)
、年龄[0, 100]
)。 - 已知数据范围且范围不大(桶数量可控)。
- 数据均匀分布在某个区间(如浮点数
- 优势:
- 线性时间复杂度(在理想分布下)。
- 适用于非整数(如浮点数)。
- 局限性:
- 依赖数据均匀分布,否则效率下降。
- 需提前确定桶数量和映射函数。
武技:使用桶排序算法对数组进行升序排序
package sort;
/** @author 周航宇 */
public class BucketSortTest {
@Test
public void bucketSort() {
double[] arr = {0.42, 0.32, 0.33, 0.52, 0.37, 0.47, 0.51};
bucketSort(arr);
System.out.println(Arrays.toString(arr));
}
/**
* 使用桶排序算法对数组进行升序排序
* @param arr 待排序的数组,元素范围为 [0, 1)
*/
private void bucketSort(double[] arr) {
int n = arr.length;
if (n <= 0) return;
// 创建桶
List<List<Double>> buckets = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
buckets.add(new ArrayList<>());
}
// 将元素分配到桶中
for (double num : arr) {
int bucketIndex = (int) (num * n);
buckets.get(bucketIndex).add(num);
}
// 对每个桶进行排序
for (List<Double> bucket : buckets) {
Collections.sort(bucket);
}
// 合并所有桶的结果
int index = 0;
for (List<Double> bucket : buckets) {
for (double num : bucket) {
arr[index++] = num;
}
}
}
}
E03. 二分查找算法
二分查找算法流程:假设需要在长度为 N 的数组(已排好序)中查找 M 元素:
- 取中:计算查找区间的中间位:
- 最高位:
highIndex = N - 1
:初始值,后续会被覆盖。 - 最低位:
lowIndex = 0
:初始值,后续会被覆盖。 - 中间位:
midIndex = (highIndex + lowIndex) / 2
。
- 最高位:
- 比较:用中间位上的元素与 M 进行比较:
- 若等于 M:查找成功,返回元素对应的位置,即
return midIndex
。 - 若大于 M:划掉中间位及中间位之后的全部元素,即
highIndex = midIndex - 1
。 - 若小于 M:划掉中间位及中间位之前的全部元素,即
lowIndex = midIndex + 1
。
- 若等于 M:查找成功,返回元素对应的位置,即
- 重复:重复第 1 和第 2 步骤,直到找到该元素的位置,若最高位和最低位已相遇,仍然未找到该元素,则
return -1
表示元素不存在。
武技: 使用二分查找算法数组中的值
package search;
/** @author 周航宇 */
public class BinarySearchTest {
@Test
public void testBinarySearch() {
int[] arr = {1, 3, 5, 7, 9, 11, 13, 15};
System.out.println(binarySearch(arr, 13));
System.out.println(binarySearch(arr, 27));
}
/**
* 二分法查找
*
* @param arr 待查找数组
* @param target 目标元素
* @return 目标元素在数组中的索引,若未找到则返回-1
*/
private int binarySearch(int[] arr, int target) {
int result = -1;
// 最低位,最高位和中间位
int lowIndex = 0;
int highIndex = arr.length - 1;
int midIndex = (lowIndex + highIndex) / 2;
// 当最低位小于等于最高位的时候进行二分查找
while (lowIndex <= highIndex) {
// 若中间位上的元素是目标元素,则查找成功,循环结束
if (arr[midIndex] == target) {
result = midIndex;
break;
}
// 重新计算最低位,最高位和中间位
lowIndex = arr[midIndex] < target ? midIndex + 1 : lowIndex;
highIndex = arr[midIndex] > target ? midIndex - 1 : highIndex;
midIndex = (lowIndex + highIndex) / 2;
}
return result;
}
}
E04. 位组结构
BitSet 的底层数据结构是一个 long 数组,每个 long 类型的值可以存储 64 个位。
1. 布隆过滤器
心法: 布隆过滤器底层使用一个 BitMap 和 N 个函数,用于过滤大量无效请求以解决部分缓存穿透问题
布隆过滤器中设置的函数越多,BitMap 长度越长,失误率越低。
布隆过滤器说有,不一定有,但布隆过滤器说没有,一定没有。
布隆过滤器中的内容建议定期异步重构,以规避删除某条数据后带来的结果上的影响。
布隆过滤器使用流程:
- 对数据库中的每个元素依次执行布隆过滤器的 N 个函数。
- 在结果对应的 BitMap 位图索引处标 1。
- 当客户端请求查询某元素时,先经过布隆过滤器,分别执行 N 个函数。
- 在 BitMap 中比对,全为 1 才允许通过。
武技:使用 BitSet 开发一个布隆过滤器工具
- 封装布隆过滤器工具类:
package com.joezhou.util;
/** @author 周航宇 */
public class BloomFilterUtil {
/** 布隆函数类 */
public static class BloomFunction {
// bitmap位组长度:用于hash计算中的取余过程,尽量保证为2的N次方值
private final int length;
// hash种子:值随意,仅用在hash计算时确保不同的对象具有不同的哈希值
private final int seed;
public BloomFunction(int length, int seed) {
this.length = length;
this.seed = seed;
}
/**
* hash函数:仿照 HashMap.hash() 方法
*
* @param value: 需要进行hash的值
* @return int 返回hash计算出来的值
**/
public int hash(Object value) {
if (value == null) {
return 0;
}
// 计算对象的初始哈希码:int型Hash值为32位,约有40亿种可能,直接在内存中创建40亿长度的数组是不明智的
// 所以不推荐直接使用这个hash值,需要二次处理
int initHash = value.hashCode();
/*
* 扰动函数:对32位的Hash值的操作
*
* 1. 将Hash值右移动16位:
* 1.1 原Hash的高16位被移动到低16位
* 1.2 原Hash的低16位被移出
* 1.3 原Hash的高16位全部补充0
*
* 2. 将右移动结果和原Hash值进行异或混合:
* 2.1 原Hash的高16位信息和低16位信息在结果的低16位进行混合,提升Hash值低16位的随机性以减少Hash冲突
* 2.2 结果的高16位信息可忽略
*/
int hash = initHash ^ (initHash >>> 16);
/*
* 使用位运算取余:使用Hash值对bitmap的长度取余:以决定最终存放的位置
*
* 位运算取余效率比 (hash % length) 高,但前提是bitmap的长度会被尽量保证为2的N次方
*
* 1. 从二进制的角度来看,对一个数右移N位就相当于对这个数除以2的N次方:
* 1.1 右移N位后,剩余的二进制数字所表示的就是商,被移出的N个数字就是余数
* 1.2 所以想要通过位运算获取一个数对2的N次方取余的结果,直接提取该数的后N位数即可
*
* 2. 一个2的N次方值减去1后,位图末尾就会变为N个1:
* 2.1 如2的2次方 - 1 = 03(0000 0011):末尾2个1
* 2.2 如2的3次方 - 1 = 07(0000 0111):末尾3个1
* 2.3 如2的4次方 - 1 = 15(0000 1111):末尾4个1
*
* 3. 任何数和此时的 `&` 操作会提取Hash值中后N位:
* 3.1 如Hash值为21(0001 0101),数组长度为16,减1后为15(0000 1111)
* 3.2 那么 (0001 0101) & (0000 1111) = (0000 0101),相当于提取了21的后4位
* 3.3 而这后4位,就是 21%16 的结果,为5。
*
* 4. seed 用于确保不同的对象具有不同的哈希值
*/
int result = (seed * (length - 1)) & hash;
// 最终结果不考虑负数
return Math.abs(result);
}
}
/** 位组:起始长度相当于2乘以2的24次方 */
private static final BitSet BIT_SET = new BitSet(2 << 24);
/** 布隆函数数组 */
private static final BloomFunction[] BLOOM_FUNCTIONS = {
new BloomFunction(6, 3),
new BloomFunction(6, 13),
new BloomFunction(6, 46),
new BloomFunction(6, 71),
new BloomFunction(6, 91),
new BloomFunction(6, 134)
};
/**
* 将一个元素设置到BitMap中
*
* @param value: 元素
**/
public static void add(Object value) {
// 依次通过6个布隆函数
for (BloomFunction bloomFunction : BLOOM_FUNCTIONS) {
// 将对应位置标记为1
BIT_SET.set(bloomFunction.hash(value), true);
}
}
/**
* 判断BitMap中是否存在指定元素
*
* @param value: 元素
* @return true可能存在,false不存在
**/
public static boolean isExists(Object value) {
boolean res = true;
// 依次通过6个布隆函数
for (BloomFunction bloomFunction : BLOOM_FUNCTIONS) {
// 仅 1 & 1 = 1,任意一个函数结果为0,则整体结果为0
res &= BIT_SET.get(bloomFunction.hash(value));
}
return res;
}
}
- 测试布隆过滤器工具类:
package util;
/** @author 周航宇 */
public class BloomFilterUtilTest {
@Test
public void testBloomFilterUtil() {
BloomFilterUtil.add("Java");
BloomFilterUtil.add("MySQL");
BloomFilterUtil.add("Oracle");
BloomFilterUtil.add("Node");
BloomFilterUtil.add("HTML");
BloomFilterUtil.add("JavaScript");
BloomFilterUtil.add("CSS");
System.out.println(BloomFilterUtil.isExists("Java"));
System.out.println(BloomFilterUtil.isExists("MySQL"));
System.out.println(BloomFilterUtil.isExists("Oracle"));
System.out.println(BloomFilterUtil.isExists("Node"));
System.out.println(BloomFilterUtil.isExists("HTML"));
System.out.println(BloomFilterUtil.isExists("JavaScript"));
System.out.println(BloomFilterUtil.isExists("CSS"));
System.out.println(BloomFilterUtil.isExists("Nginx"));
System.out.println(BloomFilterUtil.isExists("Elasticsearch"));
}
}
Java道经第1卷 - 第5阶 - 数据结构(一)
传送门:JB1-5-数据结构(一)
传送门:JB1-5-数据结构(二)
传送门:JB1-5-数据结构(三)
传送门:JB1-5-数据结构(四)