序
之前学习了严蔚敏老师的数据结构与算法,但是当时学习使用的语言是C语言,并且当时学习数据结构仅仅是为了通过期末考试,对数据结构的理解和应用并不成熟,因此该文档以Java语言重新学习数据结构
一、数据结构与算法介绍
1.1 算法
算法是能够对一定规范的输入,在有限时间内获得所要求的输出。如果一个算法有缺陷,或不适合于某个问题,执行这个算法将不会解决这个问题。不同的算法可能用不同的时间、空间或效率来完成同样的任务。一个算法的优劣可以用空间复杂度与时间复杂度来衡量。
算法是独立存在的一种解决问题的方法和思想。
在程序中,我们可以用不同的算法解决相同的问题,而不同的算法成本也是不相同的,总体上,一个优秀的算法追求以下两个目标
1、花最少时间完成目标
2、占用最少的内存空间完成需求
例如下面两种代码
public static void main(String[] args) {
int sum = 0;
int n = 100;
for (int i = 1; i <= n; i++) {
sum += i;
}
System.out.println(sum);
}
public static void main(String[] args) {
int sum = 0;
int n = 100;
sum = (1 + n) * n / 2;
System.out.println(sum);
}
都是实现了从1到100之间的求和,但是前者执行了100次循环,而后者仅仅执行了3次算法则完成了求和,显然后者这种算法更加优越
1.2 数据结构
数据结构就是把数据组织起来,为了更方便地使用数据为我们解决问题
1.2.1 数据结构的分类
传统上,可以把数据结构分为逻辑结构和物理结构两大类
1.2.1.1 逻辑结构
逻辑结构是从具体问题中抽象出来的模型,是抽象意义上的结构,按照对象中数据元素之间的相互关系分类,也是我们后面需要关注和讨论的问题
-
集合结构
集合结构中的元素除了属于同一个集合外,他们之间没有任何其他的关系

-
线性结构
线性结构中的数据元素之间存在一对一的关系

-
树形结构
树形结构中的数据元素之间存在一对多的关系

-
图形结构
图形结构的数据元素是多对多的关系

1.2.1.2 物理结构
逻辑结构在计算机中真正的表示方式(又称为映像)称为物理结构,也可以叫做存储结构。
常见的物理结构有顺序存储结构、链式存储结构
-
顺序存储结构
把数据元素放到地址连续的存储单元里面,其数据间的逻辑关系和物理关系是一致的,比如我们常用的数组就是顺序存储结构

顺序存储结构的优点是能够根据地址做到随机访问
但是顺序存储结构存在一定的弊端,如果插入插入删除数据,整个结构都需要变化
而链式存储结构可以改善顺序结构中插入删除数据造成的整个结构都变化的问题
-
链式存储结构
把数据元素存放在任意的存储单元里面,这组存储单元可以是连续的也可以是不连续的。此时数据之间并不能反映元素间的逻辑关系,因此在链式存储结构中引入了一个指针存放数据元素的地址,这样通过地址就可以找到相关数据元素的位置

但是链式存储结构的弊端是不能进行随机访问,查询不如顺序存储结构
1.2.2 线性结构和非线性结构
1.2.2.1 线性结构
- 线性结构作为最常用的数据结构,其特点是数据元素之间存在一对一的线性关系
- 线性结构有两种不同的存储结构,即顺序存储结构(数组)和链式存储结构(链表),顺序存储的线性表称为顺序表,顺序表中的存储元素是连续的
- 链式存储的线性表称为链表,链表中的存储元素不一定是连续的,元素结点存放数据元素以及相邻元素的地址信息
- 线性结构常见的有:数组、队列、栈、链表
1.2.2.2 非线性结构
- 二维数组、多维数组、广义表、树、图
二、时间和空间复杂度
2.1 概念
我们要计算算法时间耗费情况,首先我们最先想到的就是事后分析估算的方法,也就是把算法执行若干次,然后拿个计时器在旁边记时,这种方法看起来不错,但是这种统计方法主要是通过设计好的测试程序和测试数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低,但是这种算法有很大的缺陷:
一个是必须依赖编制好的测试程序,这通常会花费大量时间和精力,另一个是如果发现测试的是非常糟糕的算法,那么之前所做的事情就全部白费了,再一个就是不同的硬件环境差别导致测试的结果差异也很大
public static void main(String[] args) {
//获取开始前的时间
long start = System.currentTimeMillis();
long sum = 0;
int n = 100000;
for (int i = 1; i <= n; i++) {
sum += i;
}
System.out.println(sum);
//获取运行结束时的时间
long end = System.currentTimeMillis();
System.out.println("程序运行时间: " + (end - start) + "ms");
}
因此有一种方式是事前分析估算法
在计算机程序编写前,依据统计方法对算法进行估算,经过总结,我们发现一个高级语言编写的程序在计算机上运行所消耗的时间取决于下列因素
- 算法采用的策略和方案
- 编译产生的代码质量
- 问题的执行规模
- 机器执行指令的速度
由此可见,抛开这些与计算机硬件、软件相关的因素,一个程序的运行时间依赖于算法的好坏和问题的输入规模,如果算法固定,那么该算法的执行时间就只和问题的输入规模有关系了
我们再分析最开始的两个对求和问题的代码实现
public static void main(String[] args) {
//执行了1次
int sum = 0;
//执行了1次
int n = 100;
//执行了n+1次
for (int i = 1; i <= n; i++) {
//执行力n次
sum += i;
}
System.out.println(sum);
}
public static void main(String[] args) {
//执行了1次
int sum = 0;
//执行了1次
int n = 100;
//执行了1次
sum = (1 + n) * n / 2;
System.out.println(sum);
}
在这个例子中,前者执行了2n + 3 次,而后者仅仅执行了3次,那么如果输入的规模再次变大,求和1到一亿,那么这两个算法效率则差别会更大
但是在事前估算中,如果我们要精确的研究循环的条件执行了多少次,是一件很麻烦的事情,并且由于真正计算机和代码内循环的循环体,所以在研究算法的效率时,我们只考虑核心代码的执行次数
因此我们研究算法复杂度,侧重的是当输入规模不断增大时,算法的增长量的一个抽象,而不是精确定位执行多少次

2.2 函数渐进增长
给定两个函数 f(n)和g(n),如果存在一个整数N,使得对于所有的n > N,f(n)总是比g(n)大,那么我们说f(n)的增长渐进快于g(n)
2.2.1 实验一
| 输入规模 | 算法A1(2n+3)执行次数 | 算法A2(2n)执行次数 | 算法B1(3n+1)执行次数 | 算法B2(3n)执行次数 |
|---|---|---|---|---|
| n=1 | 5 | 2 | 4 | 3 |
| n=2 | 7 | 4 | 7 | 6 |
| n=3 | 9 | 6 | 10 | 9 |
| n=10 | 23 | 20 | 31 | 30 |
| n=100 | 203 | 200 | 301 | 300 |

这个表格是对于A1、A2、B1、B2四种算法不同录入规模的执行次数
对于A1和B1算法,当输入规模n=1时,A1的执行次数要高于B1,但是随着n的不断增大,B1的执行次数会超过A1
也就是B1的渐进增长快于A1
而A1、A2的渐进增长趋于一致,B1、B2的渐进增长也趋于一致
其中仅仅是常数的区别
因此我们可以得到结论
随着输入规模的增大,算法的常数操作可以忽略
2.2.2 试验二
| 输入规模 | 算法C1(4n+8)执行次数 | 算法C2(n)执行次数 | 算法D1(n^2+1)执行次数 | 算法D2(n^2)执行次数 |
|---|---|---|---|---|
| n=1 | 12 | 1 | 3 | 1 |
| n=2 | 16 | 2 | 5 | 4 |
| n=3 | 20 | 3 | 19 | 9 |
| n=10 | 48 | 10 | 201 | 100 |
| n=100 | 408 | 100 | 20001 | 10000 |
| n=1000 | 4008 | 1000 | 2000001 | 1000000 |

在这组数据中,我们C1、C2比较以及D1、D2比较,会发现渐进增长的差距比C和D之间的差距要小得多
因此我们可以得到结论
随着输入规模的增大,最高次项相乘的常数可以忽略
2.2.3 试验三
| 输入规模 | 算法E1(2n^2+3n+1)执行次数 | 算法E2(n^2)执行次数 | 算法F1(2n^3+3n+1)执行次数 | 算法F2(n^3)执行次数 |
|---|---|---|---|---|
| n=1 | 6 | 1 | 6 | 1 |
| n=2 | 15 | 4 | 23 | 8 |
| n=3 | 28 | 9 | 64 | 27 |
| n=10 | 231 | 100 | 2031 | 1000 |
| n=100 | 20301 | 10000 | 2000301 | 1000000 |

在这个表格中,依旧按照之前的思路分析,我们发现
最高次项的指数大的,随着n的增长,结果也会变得增长特别快
2.2.4 实验四
| 输入规模 | 算法G(n^3)执行次数 | 算法H(n^2)执行次数 | 算法I(n)执行次数 | 算法J(logn)执行次数 | 算法K(1)执行次数 |
|---|---|---|---|---|---|
| n=2 | 8 | 4 | 2 | 1 | 1 |
| n=4 | 64 | 16 | 4 | 2 | 1 |

我们通过观察数据表格和折线图,可以得出结论
算法函数中n的最高次幂越小,算法效率越高
2.3 时间频度
一个算法花费的时间与算法中语句的执行次数成正比,哪一个算法中语句执行次数多,那么他所花费的时间就越多。一个算法中语句执行次数称之为语句频度或时间频度,记为T(n)
2.4 时间复杂度大O记法
用大写O()来体现算法时间复杂的的记法,我们称之为大O记法。一般情况下,随着输入规模n的增大,T(n)增长最慢的算法为最优算法
例如
算法一
public static void main(String[] args) {
//执行了1次
int sum = 0;
//执行了1次
int n = 100;
//执行了1次
sum = (1 + n) * n / 2;
System.out.println(sum);
}
当输入规模为n时,这个算法执行了3次
算法二
public static void main(String[] args) {
//执行了1次
int sum = 0;
//执行了1次
int n = 100;
//执行了n+1次
for (int i = 1; i <= n; i++) {
//执行力n次
sum += i;
}
System.out.println(sum);
}
当输入规模为n时,这个算法执行了n+3次
算法三
public static void main(String[] args) {
//执行了1次
int sum = 0;
//执行了1次
int n = 100;
//执行了n+1次
for (int i = 1; i <= n; i++) {
for(int j = 1; j <= n; j++){
//执行力n^2次
sum += i;
}
}
System.out.println(sum);
}
当输入规模是n时,这个算法执行了n^2 + 2次
如果用大O记法表示上述每个算法的时间复杂度,应该如何表示呢?基于我们对函数渐进增长的分析,推导大O阶的表示法有以下几个规则可以使用
1、用常数1取代运行时间中所有的加法常数
2、在修改后的运行次数中,只保留高阶项
3、如果高阶项存在,且常数因子不为1,则去除与这个项相乘的常数
所以上述算法的大O记法分别是
算法一:O(1)
算法二:O(n)
算法三:O(n^2)
2.4.1 常见的大O阶
2.4.1.1 线性阶
随着输入规模的扩大,对应计算次数呈直线增长
2.4.1.2 平方阶
随着输入规模的扩大,对应计算次数呈平方增长,一般嵌套循环属于这种时间复杂度
2.4.1.3 立方阶
和平方阶的理解一致,不过随着输入规模的增大,对应计算次数呈立方增长
2.4.1.4 对数阶
例如
int i = 1, n = 100;
while(i < n){
i *= 2;
}
在这个算法中,x = log 2 n \log_2 n log2n
所以这个循环的时间复杂度为O(logn)
2.4.1.5 常数阶
一般不涉及循环操作的都是常数阶,因为它不会随着n的增长而增加操作次数
2.4.2 总结
| 描述 | 增长的数量级 | 说明 | 举例 |
|---|---|---|---|
| 常数级别 | 1 | 普通语句 | 将两个数相加 |
| 对数级别 | logN | 二分策略 | 二分查找 |
| 线性级别 | N | 循环 | 找出最大元素 |
| 线型对数级别 | NlogN | 分治思想 | 归并排序 |
| 平方级别 | N^2 | 双层循环 | 检查所有元素对 |
| 立方级别 | N^3 | 三层循环 | 检查所有三元组 |
| 指数级别 | 2^N | 穷举查找 | 检查所有子集 |
他们的复杂度从低到高依次是
O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3)
2.5 平均时间复杂度和最坏时间复杂度
平均时间复杂度是指所有可能的输入实例均以等概率的出现情况下得到算法的运行时间
最坏时间复杂度,一般讨论的时间复杂度均是最坏情况下的时间复杂度,这样做的原因是最坏情况下的时间复杂度是算法在任何输入实例上运行的界限,这就保证了算法的运行时间不会比最坏情况更长
【例】
public int search(int num) {
int[] arr = {11,10,8,9,7,22,23,0};
for (int i = 0; i < arr.length; i++) {
if (num == arr[i]) {
return i;
}
}
return -1;
}
最好情况:
查找的第一个数字就是期望的数字,那么算法的时间复杂度是O(1)
最坏情况:
查找的最后一个数字是期望的数字,那么算法的时间复杂度是O(n)
平均情况:
任何数字查找的平均成本是O(n/2)
最坏情况一种保证,在应用中,这是一种最基本的保障,即使在最坏的情况下,也能够正常提供服务,所以除非特别指定,我们提到的运行时间都是指的是最坏情况下的运行时间
空间复杂度分析
计算机访问内存的方式都是一次一个字节

一个机器地址需要8个字节表示
创建一个对象,例如Date date = new Date(),除了Date对象内部存储的数据占用的内容,该对象本身也有内存开销,每个对象的自身开销是16字节,用来保存对象的头信息
一般内存的使用,如果不够8个字节,都会被自动填充为8字节
例如
public class A{
public int a = 1;
}
通过new A()创建一个对象的内存占用如下:
1、整型成员变量a占用4个字节
2、对象本身占用16个字节
那么创建该对象总共需要20个字节,由于不是8的整数倍,会自动填充为24个字节
java中数组被被限定为对象,他们一般都会因为记录长度而需要额外的内存,一个原始数据类型的数组一般需要24字节的头信息(16个自己的对象开销,4字节用于保存长度以及4填充字节)再加上保存值所需的内存
空间算法复杂度公式:S(n) = O(f(n)) 其中n为输入规模,f(n)为语句关于n所占储存空间的函数
三、排序算法
3.1 基数排序
基数排序属于“分配式排序”又称“桶排序”,思想是将整数按位数切割成不同的数字,然后按每个位数分别比较
3.1.1 思想
将所有的待比较数值统一设置为同样的数位长度,位数比较短的数前面补零,然后从最低位开始依次进行一次排序,这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列
3.1.2 实例分析
Int[] array = {53,3,542,728,14,214};
①首先确定最大数是728,为三位数
②将不足十位和百位的在前面补0
③比较数组中的个位数,按照顺序放到对应的桶中,每个桶都是一个一维数组,全部放进去后,再取出来重新排序

按照个位数排序放进桶中得到的顺序为542,53,3,14,214,728
④然后再依次比较十位数,放到对应的桶中,没有十位的前面补0

按照十位数排序放进桶中得到的顺序为3,14,214,728,542,53
⑤依次比较百位数,放到对应的桶中去,没有百位的补0

最终排序结果就是3,14,53,214,542,728
3.1.3 代码实现
public class RadixSort {
private static List<List<Integer>> bucket;
public static List<Integer> radixSort(List<Integer> list) {
// 处理空列表
if (list == null || list.isEmpty()) {
return list;
}
// 找出最小值,用于处理负数
int minNum = list.stream().min(Integer::compareTo).get();
// 如果有负数,将所有数字转为非负数
if (minNum < 0) {
for (int i = 0; i < list.size(); i++) {
list.set(i, list.get(i) - minNum);
}
}
// 找出最大位数
int maxSize = 0;
for (Integer num : list) {
int digitCount = num == 0 ? 1 : (int) Math.floor(Math.log10(num)) + 1;
maxSize = Math.max(maxSize, digitCount);
}
// 基数排序
for (int i = 0; i < maxSize; i++) {
// 每轮重新初始化桶
bucket = new ArrayList<>();
for (int k = 0; k < 10; k++) {
bucket.add(new ArrayList<>());
}
// 分配
for (Integer num : list) {
int index = (num / (int) Math.pow(10, i)) % 10;
bucket.get(index).add(num);
}
// 收集
list.clear();
for (List<Integer> integers : bucket) {
list.addAll(integers);
}
}
// 如果原来有负数,需要还原
if (minNum < 0) {
for (int i = 0; i < list.size(); i++) {
list.set(i, list.get(i) + minNum);
}
}
return list;
}
public static void main(String[] args) {
// 测试数据,包含正数、负数和零
List<Integer> numbers = new ArrayList<>(Arrays.asList(170, -45, 75, 90, -802, 24, 2, 66, 0));
System.out.println("排序前:" + numbers);
List<Integer> sortedList = radixSort(numbers);
System.out.println("排序后:" + sortedList);
}
}
3.2 冒泡排序
3.2.1 思想
冒泡排序的思想是通过对待排序列从前往后依次比较相邻元素值,若发现逆序则交换,使值较大的元素从前逐步移到后面,就像水中气泡
3.2.2 实例分析
假设待排序列为(5,1,4,2,8),如果采用冒泡排序对其进行升序排序,则整个排序过程如下图所示
第一轮排序,此时整个序列中的元素都位于待排序列,依次扫描每对相邻的元素,并对顺序不正确的元素交换位置,整个过程如图所示

经过第一轮排序后,从待排序列中找出了最大数8,并将其放到了待排序列的尾部,并入已排序序列中
第二轮排序,此时待排序列只包含前4个元素,依次扫描每对相邻元素,对顺序不正确的元素对交换位置,整个过程如图

经过第二轮冒泡排序,从待排序列中找出最大值5,并将其放入待排序列尾部
第三轮排序,此时待排序列只包含前3个元素,依次扫描每对相邻元素,对顺序不正确的元素对交换位置,整个过程如图

经过第三轮冒泡排序,从待排序列中找出最大值4,并将其放入待排序列尾部
第四轮排序,此时待排序列只包含前2个元素,依次扫描每对相邻元素,对顺序不正确的元素对交换位置,整个过程如图

经过第四轮冒泡排序,从待排序列中找出最大值2,并将其放入待排序列尾部
第五轮排序因为只剩下一个元素无需再进行比较
3.2.3 代码实现
public class BubbleSort {
public static void main(String[] args) {
int[] arr = {5, 4, 9, 8, 2, 3, 1, 10};
int[] res = bubbleSort(arr);
for (int i = 0; i < res.length; i++){
System.out.println(res[i]);
}
}
public static int[] bubbleSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++){
for (int j = 0; j < arr.length - 1 - i; j ++){
if (arr[j] > arr[j + 1]){
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
return arr;
}
}
但是如果传进来的就是一个有序序列,则没有继续比较的意义,我们可以添加一个标志变量,如果在某一轮中没有进行排序,则不进行后面的排序
修改后的代码为
public static int[] bubbleSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++){
boolean flag = false;
for (int j = 0; j < arr.length - 1 - i; j ++){
if (arr[j] > arr[j + 1]){
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
flag = true;
}
}
if (!flag){
break;
}
}
return arr;
}
3.3 快速排序
3.3.1 思想
快速排序是对冒泡排序的一种改进。通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分所有的数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,一次达到整个数据变成有序序列
3.3.2 实例分析
对以下数列按从小到大的顺序进行排序
4、7、6、5、3、2、8、1
首先选定基准元素p,并设置左右两个指针L和R

开始循环后,从R指针开始,让R指针与基准元素做比较,如果大于等于p,则R指针向左移动,如果小于p则停止移动
对于当前数列,R指针元素为1,1小于4,所以R指针停止移动,切换到L指针
切换到L指针后,让L指针元素与基准元素做比较,如果小于等于p,则指针向右移动,如果大于p则停止移动
按照此思路,后续步骤如下

之后从基准元素位置分开成为两个数列,对每个数列进行递归
3.3.3 代码实现
public static void quickSort(int[] arr, int left, int right) {
//定义出口
if (left >= right) {
return;
}
//定义基准元素
int p = arr[left];
//定义左右指针
int l = left;
int r = right;
while (l != r) {
while (arr[r] >= p && l < r) {
r--;
}
while (arr[l] <= p && l < r) {
l++;
}
//此时l指向的是大于基准元素的元素
//此时r指向的是小于基准元素的元素
//两者元素交换
if (l < r) {
int temp = arr[l];
arr[l] = arr[r];
arr[r] = temp;
}
}
//将基准元素放到正确的位置
int temp = arr[l];
arr[l] = p;
arr[left] = temp;
quickSort(arr, left, l - 1);
quickSort(arr, r + 1, right);
}
3.4 插入排序
3.4.1 思想
插入排序属于内部排序,是对排序元素以插入的方式寻找该元素的适当位置,已达到排序的目的
3.4.2 实例分析
比如对数组5、4、10、20、3进行排序
每次插入的时候将待插入元素插入到正确位置,过程如图

3.4.3 代码实现
public static void insertionSort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
for (int j = i; j > 0; j--) {
if (arr[j] < arr[j - 1]) {
int temp = arr[j];
arr[j] = arr[j - 1];
arr[j - 1] = temp;
}else {
//因为前面已经是有序的,
// 和前一个元素比较之后如果不需要交换就没必要再去和前面元素比较
break;
}
}
}
}
3.5 选择排序
3.5.1 思想
第一次从待排序的数据元素中选出最小(大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)的元素,放到已排序的序列末尾,以此类推直到全部待排序的数据元素的个数为零。选择排序是不稳定的排序方法。
3.5.2 实例分析

3.5.3 代码实现
public static void selectSort(int[] arr) {
//i < arr.length - 1防止越界
for (int i = 0; i < arr.length - 1; i++){
int minIndex = i;
int min = arr[i];
for (int j = i + 1; j < arr.length; j++){
if (arr[j] < min){
min = arr[j];
minIndex = j;
}
}
if (minIndex != i){
arr[minIndex] = arr[i];
arr[i] = min;
}
}
}
3.6 希尔排序
3.6.1 介绍
希尔排序是插入排序的一种又称“缩小增量排序”,是插入排序算法的一种更高效的改进版本
希尔排序是把记录下标的一定增量分组,对每组使用直接插入排序算法排序,随着增量逐渐减少,每组包含的关键词越来越多,当增量减至1时,整个文件恰被分成一组
3.6.2 实例分析



3.6.3 代码实现
//负责分组
public static void shellSort(int[] arr) {
int gap = arr.length / 2;
while (gap > 0) {
shell(arr, gap);
gap /= 2;
}
}
//负责对分完组进行插入排序
public static void shell(int[] arr, int gap) {
if (arr == null || arr.length == 0) {
return;
}
for (int i = gap; i < arr.length; i++){
int tmp = arr[i];
for (int j = i - gap; j >= 0; j -= gap){
if (arr[j] > tmp) {
arr[j + gap] = arr[j];
arr[j] = tmp;
}else {
break;
}
}
}
}
3.7 归并排序
3.7.1 思想
归并排序是建立在归并操作上的一种有效,稳定的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并
3.7.2 实例分析
!
其中粉色底色为分解为不可再分的单个元素
而合并的过程可以通过一个临时数组和两个分解段的第一个元素指针,比较两个指针所指元素大小,将较小指添加到临时数组中
3.7.3 代码实现
public static void mergeSort(int[] arr, int left, int right) {
if (left < right) {
int mid = (left + right) / 2;
// 向左进行分解
mergeSort(arr, left, mid);
// 向右进行分解
mergeSort(arr, mid + 1, right);
// 合并操作
merge(arr, left, mid, right);
}
}
private static void merge(int[] arr, int left, int mid, int right) {
// 创建临时数组
int[] temp = new int[right - left + 1];
// 左半部分起始索引
int i = left;
// 右半部分起始索引
int j = mid + 1;
// 临时数组索引
int t = 0;
// 比较左右两部分的元素,将较小的放入临时数组
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[t++] = arr[i++];
} else {
temp[t++] = arr[j++];
}
}
// 处理剩余元素
while (i <= mid) {
temp[t++] = arr[i++];
}
while (j <= right) {
temp[t++] = arr[j++];
}
// 将临时数组中的元素复制回原数组
t = 0;
while (left <= right) {
arr[left++] = temp[t++];
}
}
四、线性表
4.1 介绍
线性表是最基本、最简单、也是最常用的一种数据结构。一个线性表是n个具有相同特性的数据元素的有限序列
-
前驱元素
若A元素在B元素的前面,则称A为B的前驱元素
-
后继元素
若B元素在A元素的后面,则称B为A的后继元素
线性表的特点:数据元素之间具有一种“一对一”的逻辑关系
第一个元素没有前驱,这个元素称为头结点
最后一个元素没有后继,这个元素称为尾结点
除了第一个和最后一个元素外,其他元素有且仅有一个前驱和后继
4.2 线性表的分类
线性表中数据存储的方式可以是顺序存储,也可以是链式存储,按照数据的存储方式不同,分为顺序表和链表
4.3 顺序表
顺序表是在计算机内存中以数组的形式保存的线性表,线性表的顺序存储是指用一组地址连续的存储单元,依次存储线性表中的各个元素、使得线性表中在逻辑结构上响应的数据元素存储

4.3.1 顺序表API设计
| 类名 | SequenceList |
|---|---|
| 构造方法 | SequenceList(int capacity):创建容量为capacity的SequenceList对象 |
| 成员方法 | 1. public void clear():空置线性表 2. public boolean isEmpty():判断线性表是否为空,是返回true,否返回false 3. public int length():获取线性表中元素的个数 4. public T get(int i):获取并返回线性表中第i个元素的值 5. public void insert(int i,T t):在线性表的第i个元素之前插入一个新的数据元素t 6. public void insert(T t):向线性表中添加一个元素t 7. public T remove(int i):删除并返回线性表中第i个数据元素 8. public int indexOf(T t):返回线性表中首次出现的指定的数据元素的位序号,若不存在,则返回-1 |
| 成员变量 | 1. private T[] eles:存储元素的数组 2. private int N:当前线性表的长度 |
public class SequenceList<T> {
/**
* 存储元素的数组
*/
private T[] data;
//线性表的长度
private int length;
public SequenceList(int capacity){
this.data = (T[]) new Object[capacity];
this.length = 0;
}
/**
* 清空线性表
*/
public void clear(){
this.length = 0;
}
/**
* 是够为空
*/
public boolean isEmpty(){
if (this.length == 0) {
return true;
} else {
return false;
}
}
/**
* 线性表的长度
*/
public int length(){
return this.length;
}
/**
* 获取index位置的元素
*/
public T get(int index){
return this.data[index];
}
/**
* 插入元素
*/
public void insert(T element){
this.data[this.length] = element;
this.length++;
}
/**
* 向指定位置插入元素
*/
public void insert(int index,T element){
for (int i = length; i > index ; i--) {
this.data[i] = this.data[i - 1];
}
this.data[index] = element;
this.length++;
}
/**
* 删除指定位置的元素
*/
public T remove(int index){
T temp = this.data[index];
for (int i = index; i < length; i++) {
this.data[i] = this.data[i + 1];
}
this.length--;
return temp;
}
/**
* 返回线性表中首次出现钙元素的位置,如果没有返回-1
*/
public int indexOf(T element){
for (int i = 0; i < length; i++) {
if (this.data[i] == element) {
return i;
}
}
return -1;
}
}
4.3.2 顺序表遍历
一般作为容器存储数据,都需要向外提供遍历的方式,因此我们需要给顺序表提供遍历方式。
在Java中,我们可以让SequenceList实现Iterable接口,重写iterator方法
在SequenceList内部提供一个内部类Sterator,实现Iterator接口,重写hasNext和next方法
public class SequenceList<T> implements Iterable<T> {
/**
* 存储元素的数组
*/
private T[] data;
//线性表的长度
private int length;
//此处省略上述方法
@Override
public Iterator<T> iterator() {
return new MyIterable();
}
private class MyIterable implements Iterator{
private int cursor = 0;
public MyIterable(){
cursor = 0;
}
@Override
public boolean hasNext() {
if (cursor < length) {
return true;
}
return false;
}
@Override
public Object next() {
return data[cursor++];
}
}
}
4.3.3 顺序表的容量可变
在之前的视线中,当我们使用SequenceList时,先new SequenceList(5)创建一个对象,创建对象时就需要指定容器的大小,初始化指定大小数组来存储元素,当我们插入元素时,如果已经插入5个元素,还要继续插入数据,就会报错。这种设计不符合容器的设计理念,因此我们在设计顺序表时,应该考虑它的容量的伸缩性。
-
添加元素时
添加元素时,应该检查当前数组的大小是否能容纳新的元素,如果不能容纳,则需要创建新的容量更大的数组,我们这里创建一个原数组两倍的数组存储元素

-
移除元素时
移除元素时,应该检查当前数组大小是否太大,比如正在用100个容量的数组存储10个元素,这样就会造成内存空间的浪费,应该创建一个容量更小的数组存储元素,如果我们发现数据元素的数量不足数组容量的1/4,则创建一个是原数组容量1/2的新数组存储元素

/**
* 重新设置数组的大小
*/
private void resize(int newSize){
T[] newData = data;
//创建新的数组
data = (T[]) new Object[newSize];
for (int i = 0; i < length; i++) {
data[i] = newData[i];
}
}
/**
* 插入元素
*/
public void insert(T element){
if (this.length == this.data.length) {
resize(this.data.length * 2);
}
this.data[this.length] = element;
this.length++;
}
/**
* 向指定位置插入元素
*/
public void insert(int index,T element){
if (this.length == this.data.length) {
resize(this.data.length * 2);
}
for (int i = length; i > index ; i--) {
this.data[i] = this.data[i - 1];
}
this.data[index] = element;
this.length++;
}
/**
* 删除指定位置的元素
*/
public T remove(int index){
T temp = this.data[index];
for (int i = index; i < length; i++) {
this.data[i] = this.data[i + 1];
}
this.length--;
if (this.length < this.data.length / 4){
resize(this.data.length / 2);
}
return temp;
}
4.4 链表
链表是一种物理存储单元上非连续,非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接实现的




特点
- 链表是以结点形式存储的,是链式存储
- 每个结点包含data域和next域
- 如上图所示各个结点并不是连续存储的
- 链表分带头结点链表和没带头结点链表,根据实际需要来确定带头结点链表逻辑结构
在Java中如何实现结点对象?
public class Node<T> {
/**
* 存储元素
*/
public T data;
/**
* 下一个结点
*/
public Node next;
public Node(T data, Node next) {
this.data = data;
this.next = next;
}
}
4.4.1 单向链表
| 类名 | LinkList |
|---|---|
| 构造方法 | LinkList():创建LinkList对象 |
| 成员方法 | 1. public void clear():空置线性表 2. public boolean isEmpty():判断线性表是否为空,是返回true,否返回false 3. public int length():获取线性表中元素的个数 4. public T get(int i):读取并返回线性表中第i个元素的值 5. public void insert(T t):在线性表中添加一个元素 6. public void insert(int i,T t):在线性表的第i个元素之前插入一个值为t的数据元素。 7. public T remove(int i):删除并返回线性表中第i个数据元素。 8. public int indexOf(T t):返回线性表中首次出现的指定的数据元素的位序,若不存在,则返回-1。 |
| 成员内部类 | private class Node:结点类 |
| 成员变量 | 1. private Node head:记录首结点 2. private int N:记录链表的长度 |
public class LinkList<T> implements Iterable<T> {
private Node<T> head;
private int size;
@Override
public Iterator<T> iterator() {
return new LIterator();
}
public LinkList() {
head = new Node<>(null, null);
size = 0;
}
private class LIterator implements Iterator<T> {
private Node<T> current = head;
@Override
public boolean hasNext() {
return current.next != null;
}
@Override
public T next() {
if (!hasNext()) {
throw new java.util.NoSuchElementException();
}
current = current.next;
return current.data;
}
}
private class Node<T> {
T data;
Node<T> next;
public Node(T data, Node next) {
this.data = data;
this.next = next;
}
}
/**
* 清空链表元素
*/
public void clear(){
head = null;
size = 0;
}
/**
* 判断链表是否为空
*/
public boolean isEmpty(){
return size == 0;
}
/**
* 链表长度
*/
public int length(){
return size;
}
/**
* 返回链表中指定位置的元素
*/
public T get(int index){
if (index < 0 || index >= size){
throw new RuntimeException("索引不合法");
}
Node<T> cur = head.next;
for (int i = 0; i < index; i++){
cur = cur.next;
}
return cur.data;
}
/**
* 链表插入元素
*/
public void insert(T data){
Node cur = head;
while (cur.next != null) {
cur = cur.next;
}
cur.next = new Node<>(data, null);
size++;
}
/**
* 链表指定位置插入元素
*/
public void insert(int index,T data){
if (index < 0 || index >= size) {
throw new RuntimeException("索引不合法");
}
//找到指定位置的结点
Node cur = head;
for (int i = 0; i < index; i++) {
cur = cur.next;
}
Node newNode = new Node<>(data, cur.next);
cur.next = newNode;
size++;
}
/**
* 链表删除指定位置元素
*/
public T remove(int index){
if (index < 0 || index >= size) {
throw new RuntimeException("索引不合法");
}
Node cur = head;
for (int i = 0; i < index; i++){
cur = cur.next;
}
T temp = (T) cur.next.data;
cur.next = cur.next.next;
size--;
return temp;
}
/**
* 链表查找元素第一次出现的位置,没有返回-1
*/
public int indexOf(T t){
Node cur = head;
for (int i = 0; i < size - 1; i++) {
if (cur.next.data.equals(t)) {
return i;
}
cur = cur.next;
}
return -1;
}
}
4.4.2 双向链表
双向链表也叫双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。

| 类别 | 描述 |
|---|---|
| 类名 | TowWayLinkList |
| 构造方法 | TowWayLinkList():创建TowWayLinkList对象 |
| 成员方法 | 1. public void clear():空置线性表 2. public boolean isEmpty():判断线性表是否为空,是返回true,否返回false 3. public int length():获取线性表中元素的个数 4. public T get(int i):读取并返回线性表中的第i个数据元素 5. public void insert(T t):在线性表末尾插入一个元素 6. public void insert(int i,T t):在线性表的第i个元素之前插入一个新的数据元素 7. public T remove(int i):删除并返回线性表中第i个数据元素 8. public int indexOf(T t):返回线性表中首次出现的指定的数据元素的位序号,若不存在,则返回-1 9. public T getFirst():获取第一个元素 10. public T getLast():获取最后一个元素 |
| 成员内部类 | private class Node:结点类 |
| 成员变量 | 1. private Node first:记录首结点 2. private Node last:记录尾结点 3. private int N:记录链表的长度 |
public class DlinkedList<T> {
/**
* 头结点
*/
private Node head;
/**
* 尾结点
*/
private Node last;
private int size;
public DlinkedList() {
head = new Node(null, null, null);
}
private class Node {
public T data;
public Node next;
public Node pre;
public Node(T data, Node next, Node pre) {
this.data = data;
this.next = next;
this.pre = pre;
}
}
public void clear() {
last = null;
head.next = last;
head.pre = null;
head.data = null;
size = 0;
}
public boolean isEmpty() {
return size == 0;
}
public int length() {
return size;
}
public T get(int index) {
if (index < 0 || index >= size) {
throw new RuntimeException("索引不合法");
}
Node cur = head.next;
for (int i = 0; i < index; i++){
cur = cur.next;
}
return cur.data;
}
public void insert(T data) {
//是否为第一个元素
if(last == null) {
last = new Node(data, null, head);
head.next = last;
} else {
Node oldLast = last;
Node node = new Node(data,null,oldLast);
oldLast.next = node;
last = node;
}
size++;
}
public void insert(int index, T data) {
if (index < 0 || index > size) {
throw new RuntimeException("索引不合法");
}
Node cur = head;
for (int i = 0; i < index; i++){
cur = cur.next;
}
Node nextNode = cur.next;
Node node = new Node(data, nextNode, cur);
cur.next = node;
nextNode.pre = node;
size++;
}
public T remove(int index) {
if (index < 0 || index > size) {
throw new RuntimeException("索引不合法");
}
T data = null;
//考虑删除第一个结点的情况
if (index == 0) {
data = head.next.data;
Node newFirst = head.next.next;
//判断是否有第二个结点
if (newFirst != null) {
newFirst.pre = head;
head.next = newFirst;
} else {
head.next = null;
last = null;
}
} else {
Node cur = head;
for (int i = 0; i < index; i++){
cur = cur.next;
}
data = cur.next.data;
//考虑删除最后一个结点的情况
if (cur.next == last) {
cur.next = null;
last = cur;
} else {
//删除中间结点
cur.next = cur.next.next;
cur.next.pre = cur;
}
}
size--;
return data;
}
public int indexOf(T data) {
int index = 0;
Node cur = head;
while (cur.next != null) {
if (cur.next.data.equals(data)) {
return index;
}
cur = cur.next;
index++;
}
return -1;
}
public T getFirst() {
if (isEmpty()) {
return null;
}
return head.next.data;
}
public T getLast() {
if (isEmpty()) {
return null;
}
return last.data;
}
}
4.4.3 例题
【1】链表翻转
给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]
public void reverse() {
if (size != 0) {
reverse(head.next);
}
}
public Node reverse(Node current) {
if (current.next == null) {
head.next = current;
return current;
}
Node pre = reverse(current.next);
pre.next = current;
current.next = null;
return current;
}
【2】快慢指针
快慢指针指的是定义两个指针,这两个指针的移动速度一快一慢,以此来制造出自己想要的差值,这个差值可以让我们找到链表上相应的结点。一般情况下,快指针的移动步长为慢指针的两倍
应用场景
-
找出链表中的中间值
快指针是慢指针步长的二倍,当快指针走到最后的时候,慢指针刚好是中间值
public Object getMid(){ Node first = head.next; Node slow = first; Node fast = first; while(fast != null && fast.next != null) { fast = fast.next.next; slow = slow.next; } return slow.data; } -
此链表是否存在环
因为在非环形链表中慢指针是永远追不上快指针的,如果慢指针和快指针相遇了,那么说明链表存在环

public boolean isCircle(Node first){
Node slow = first;
Node fast = first;
while(fast != null && fast.next != null){
fast = fast.next.next;
slow = slow.next;
if(fast == slow){
return true;
}
}
return false;
}
4.4.4 单向环形链表介绍
约瑟夫问题
设编号为1,2,…,n 的n个人围坐一圈,约定编号为k(1<=k<=n)的人从1开始报数,数到m的那个人出列,它的下一位又从1开始报数,数到m的那个人又出列,它的下一位又从1开始报数,数到m的那个人又出列,依次类推,直到所有人出列为止。由此产生一个出队编号的序列。

public class Kid {
public int no;
public Kid next;
public Kid(int no) {
this.no = no;
}
public Kid() {
}
}
public class KidCircleLinkedList {
private Kid first = new Kid(-1);
/**
* 创建环形链表
*/
public void addNode(int nums){
if (nums < 1) {
throw new RuntimeException("nums不能小于1");
}
Kid temp = null;
for (int i = 1; i <= nums; i++){
Kid newKid = new Kid(i);
if (i == 1){
first = newKid;
first.next = first;
temp = first;
}else {
temp.next = newKid;
newKid.next = first;
temp = newKid;
}
}
}
public void show(){
if (first == null){
System.out.println("空链表");
}
Kid temp = first;
while(true){
System.out.println("小孩的编号是" + temp.no);
if (temp.next == first) {
break;
}
temp = temp.next;
}
}
public void countKid(int startNo, int countNum, int nums){
if (first == null || startNo < 1 || startNo > nums){
System.out.println("参数有误");
return;
}
Kid helper = first;
//帮助指针移动到最后一个
while (helper.next != first){
helper = helper.next;
}
//移动first和helper到开始数的位置和前一个位置
for (int j = 0; j < startNo - 1; j++){
first = first.next;
helper = helper;
}
//开始出圈
while (true){
if (first == helper){
break;
}
for (int i = 0; i < countNum - 1; i++){
first = first.next;
helper = helper.next;
}
System.out.println("出圈的小孩是:" + first.no);
first = first.next;
helper.next = first;
}
System.out.println("最后留在圈中的小孩是:" + first.no);
}
}
4.5 栈
栈是限制插入和删除只能在一个位置上进行的线性表。其中,允许插入和删除的一端位于表的末端,叫做栈顶(top),不允许插入和删除的另一端叫做栈底(bottom)。对栈的基本操作有 **PUSH(压栈)和 POP(出栈),前者相当于表的插入操作(向栈顶插入一个元素),后者则是删除操作(删除一个栈顶元素)。栈是一种后进先出(LIFO)**的数据结构,最先被删除的是最近压栈的元素。

4.5.1 链表实现栈
| 类名 | Stack |
|---|---|
| 构造方法 | Stack(): 创建Stack对象 |
| 成员方法 | 1. public boolean isEmpty(): 判断栈是否为空,是返回true,否返回false 2. public int size(): 获取栈中元素的个数 3. public T pop(): 弹出栈顶元素 4. public void push(T t): 向栈中压入元素t |
| 成员变量 | 1. private Node head: 记录首结点 2. private int N: 当前栈中的元素个数 |
| 成员内部类 | private class Node: 结点类 |
public class Stack<T> implements Iterable<T> {
private Node head;
private int N;
public Stack() {
head = new Node(null, null);
N = 0;
}
public boolean isEmpty() {
return N == 0;
}
public int size(){
return N;
}
public T pop(){
if (isEmpty()){
return null;
}
Node needToPop = head.next;
head.next = needToPop.next;
N--;
return needToPop.item;
}
public void push(T item){
Node needToPush = new Node(item, head.next);
head.next = needToPush;
N++;
}
@Override
public Iterator<T> iterator() {
return new StackIterator();
}
private class Node{
private T item;
private Node next;
public Node(T item, Node next) {
this.item = item;
this.next = next;
}
}
private class StackIterator implements Iterator<T>{
private Node n = head;
@Override
public boolean hasNext() {
return n.next != null;
}
@Override
public T next() {
Node node = n.next;
n = n.next;
return node.item;
}
}
}
4.5.2 数组实现栈

public class ArrayStack {
private int[] stack;
private int maxStack;
private int top = -1;
public ArrayStack(int maxStack) {
this.maxStack = maxStack;
stack = new int[maxStack];
}
public boolean isFull(){
return top == maxStack - 1;
}
public boolean isEmpty(){
return top == -1;
}
public void push(int data){
if (isFull()){
System.out.println("栈已满");
return;
}
stack[++top] = data;
}
public int pop(){
if (isEmpty()){
throw new RuntimeException("栈已空");
}
return stack[top--];
}
public void show(){
if (isEmpty()){
System.out.println("空栈");
}
for (int i = top; i >= 0; i--){
System.out.println(stack[i]);
}
}
}
4.5.3 栈实现计算器
1、扫描表达式,如果是数字,则将结果放入数字栈
2、如果是一个符号,那么判断符号栈中是否是空,如果是空,符号直接入栈,如果符号栈不为空,需要判断符号的优先级,如果当前符号优先级大于符号栈中的优先级,直接入栈。如果当前符合优先级小于或者等于符号栈中的优先级,那么需要数字栈弹出两个数字,再从符号栈中弹出一个符号进行计算。
3、将计算结果重新放入数字栈中去
4、整个表达式扫描完,则顺序从数字栈中和符号栈中取出对应的数字和符号进行运算
5、最后数字栈中只有一个数字,则就是结果


public int length() {
return stack.length;
}
/**
* 查看栈顶元素
*/
public int peek() {
if (isEmpty()) {
throw new RuntimeException("栈已空");
}
return stack[top];
}
public boolean isOp(char v) {
return v == '+' || v == '-' || v == '*' || v == '/';
}
/**
* 获取优先级
*/
public int pri(int op) {
if (op == '*' || op == '/') {
return 1;
} else if (op == '+' || op == '-') {
return 0;
} else {
return -1;
}
}
public int cal(int num1, int num2, int op) {
int result = 0;
switch (op) {
case '+':
result = num1 + num2;
break;
case '-':
result = num1 - num2;
break;
case '*':
result = num1 * num2;
break;
case '/':
result = num1 / num2;
break;
default:
break;
}
return result;
}
/**
* 计算结果
*/
public int res(String str) {
ArrayStack numStack = new ArrayStack(str.length());
ArrayStack opStack = new ArrayStack(str.length());
int temp1 = 0;
int temp2 = 0;
int op = 0;
int result = 0;
StringBuilder num = new StringBuilder();
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if (isOp(c)) {
if (opStack.isEmpty()) {
opStack.push(c);
} else {
// 当前运算符优先级小于等于栈顶运算符,需要先计算
while (!opStack.isEmpty() && pri(c) <= pri(opStack.peek())) {
temp1 = numStack.pop();
temp2 = numStack.pop();
op = opStack.pop();
result = cal(temp2, temp1, op);
numStack.push(result);
}
opStack.push(c);
}
} else {
// 处理数字,考虑多位数
num.append(c);
// 到达字符串末尾或下一个字符是运算符时,将数字入栈
if (i == str.length() - 1 || isOp(str.charAt(i + 1))) {
numStack.push(Integer.parseInt(num.toString()));
num = new StringBuilder();
}
}
}
// 处理栈中剩余的运算符
while (!opStack.isEmpty()) {
temp1 = numStack.pop();
temp2 = numStack.pop();
op = opStack.pop();
result = cal(temp2, temp1, op);
numStack.push(result);
}
return numStack.pop();
}
4.5.4 括号匹配问题
1、扫描字符串,如果发现存在左括号 “(”
2、扫描发现存在右括号,去栈中弹出一个对应的左括号,如果栈中不存在左括号,那么该字符串错误匹配。如果栈中存在左括号,则弹出左括号即可。
3、当扫描完字符串时,发现括号栈中还存在左括号,则该字符串匹配错误。
public static boolean isMatch(String str) {
Stack<String> stack = new Stack<>();
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) == '(') {
stack.push("(");
} else if(str.charAt(i) == ')') {
if (stack.isEmpty()) {
return false;
}
stack.pop();
}
}
return stack.isEmpty();
}
4.6 队列
队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。

4.6.1 链表实现队列
| 类名 | Queue |
|---|---|
| 构造方法 | Queue():创建Queue对象 |
| 成员方法 | 1. public boolean isEmpty():判断队列是否为空,是返回true,否返回false 2. public int size():获取队列中元素的个数 3. public T dequeue():从队列中拿出一个元素 4. public void enqueue(T t):往队列中插入一个元素 |
| 成员变量 | 1. private Node head:记录首结点 2. private int N:当前栈的元素个数 3. private Node last:记录最后一个结点 |
public class Queue<T> {
private Node head;
private Node last;
private int N;
public Queue() {
head = new Node(null, null);
last = null;
N = 0;
}
public boolean isEmpty() {
return N == 0;
}
public int size() {
return N;
}
/**
* 从队列中获取元素
*/
public T dequeue() {
if (isEmpty()) {
return null;
}
Node oldFirst = head.next;
head.next = oldFirst.next;
N--;
if (isEmpty()) {
last = null;
}
return (T)oldFirst.item;
}
/**
* 插入元素
*/
public void enqueue(T item) {
if (last == null) {
last = new Node(item, null);
head.next = last;
} else {
Node oldLast = last;
last = new Node(item, null);
oldLast.next = last;
}
N++;
}
class Node<T> {
public T item;
public Node next;
public Node(T item, Node next) {
this.item = item;
this.next = next;
}
}
}
4.6.2 数组实现队列



当rear = front时,队列为空,当rear = maxsize - 1时,队列为满
public class ArrayQueue {
private int rear;
private int front;
private int maxSize;
private int[] queue;
public ArrayQueue(int maxSize) {
front = -1;
rear = -1;
this.maxSize = maxSize;
queue = new int[maxSize];
}
public boolean isFull(){
return rear == maxSize - 1;
}
public boolean isEmpty(){
return front == rear;
}
public void enqueue(int data){
if (isFull()){
System.out.println("队列已满");
return;
}
queue[++rear] = data;
}
public int dequeue(){
if (isEmpty()){
throw new RuntimeException("队列已空");
}
return queue[++front];
}
public int length(){
return rear - front;
}
}
4.6.3 循环队列
在上面的队列中,无法再次利用出队元素位置的空间,因此可以通过取模的方式实现循环的效果
此时初始化时rear和front都等于0
队列判空条件是rear == front
队列判满条件是 (rear + 1) % maxSize == front
(循环队列要预留一个空间,否则队列为空和队列为满时条件将都是rear == front)
求队列长度条件是(rear - front + maxSize) % maxSize
public class ArrayQueue {
private int rear;
private int front;
private int maxSize;
private int[] queue;
public ArrayQueue(int maxSize) {
front = 0;
rear = 0;
this.maxSize = maxSize;
queue = new int[maxSize];
}
public boolean isFull(){
return (rear + 1) % maxSize == front;
}
public boolean isEmpty(){
return front == rear;
}
public void enqueue(int data){
if (isFull()){
System.out.println("队列已满");
return;
}
queue[rear] = data;
rear = (rear + 1) % maxSize;
}
public int dequeue(){
if (isEmpty()){
throw new RuntimeException("队列已空");
}
int res = queue[front];
front = (front + 1) % maxSize;
return res;
}
public int length(){
return (rear - front + maxSize) % maxSize;
}
}
4.6.4 用栈实现队列
用栈实现队列,因为队列先进先出而栈是后进先出的特点,一个栈是无法满足这个需求的,但是使用两个栈,将第一个栈中的元素依次出栈再依次进入第二个栈,那么第二个栈中的元素即按照队列的顺序出栈

public class StackQueue {
private Stack<Integer> stack1;
private Stack<Integer> stack2;
public StackQueue() {
stack1 = new Stack<>();
stack2 = new Stack<>();
}
public void enqueue(int data) {
stack1.push(data);
}
public int dequeue(){
if (stack1.isEmpty() && stack2.isEmpty()) {
throw new RuntimeException("队列已空");
}
if (!stack2.isEmpty()){
return stack2.pop();
}else {
//这里必须将stack1的大小赋值给一个变量,不然在pop的过程中N的大小会改变
int size = stack1.size();
for (int i = 0; i < size; i++){
stack2.push(stack1.pop());
}
return stack2.pop();
}
}
}
4.7 符号表
4.7.1 符号表
符号表最主要的目的就是将一个键和一个值联系起来,符号表能够将存储的数据元素是一个键和一个值共同组成的键值对数据,我们可以根据键来查找对应的值。
符号表中,键具有唯一性。

结点类:
| 类名 | Node<Key,Value> |
|---|---|
| 构造方法 | Node(Key key,Value value,Node next):创建Node对象 |
| 成员变量 | 1. public Key key:存储键 2. public Value value:存储值 3. public Node next:存储下一个结点 |
符号表:
| 类名 | SymbolTable<Key,Value> |
|---|---|
| 构造方法 | SymbolTable():创建SymbolTable对象 |
| 成员方法 | 1. public Value get(Key key):根据键key,找对应的值 2. public void put(Key key,Value val):向符号表中插入一个键值对 3. public void delete(Key key):删除键为key的键值对 4. public int size():获取符号表的大小 |
| 成员变量 | 1. private Node head:记录首结点 2. private int N:记录符号表中键值对的个数 |
public class SymbolTable {
private Node head;
private int N;
public SymbolTable() {
head = new Node("", "", null);
N = 0;
}
public String getVal(String key) {
if (head.next == null) {
return null;
}
Node node = head.next;
while (node != null){
if (node.key.equals(key)){
return node.value;
}
node = node.next;
}
return null;
}
public void put(String key, String value) {
// 处理空链表的情况
if (head.next == null) {
head.next = new Node(key, value, null);
N++;
return;
}
// 查找是否存在相同的key
Node node = head;
while (node.next != null) {
// 如果找到相同的key,更新value并返回
if (node.next.key.equals(key)) {
node.next.value = value;
return;
}
node = node.next;
}
// 没找到相同的key,在链表头部添加新节点
Node newNode = new Node(key, value, head.next);
head.next = newNode;
N++;
}
public void delVal(String key) {
// 从头节点开始,这样可以处理删除第一个节点的情况
Node node = head;
while (node.next != null) {
if (node.next.key.equals(key)) {
node.next = node.next.next;
N--;
return;
}
node = node.next;
}
}
public int size() {
return N;
}
private class Node {
private String key;
private String value;
private Node next;
public Node(String key, String value, Node next) {
this.key = key;
this.value = value;
this.next = next;
}
}
}
4.7.2 有序符号表
public class SymbolTable {
private Node head;
private int N;
public SymbolTable() {
head = new Node("", "", null);
N = 0;
}
public String getVal(String key) {
if (head.next == null) {
return null;
}
Node node = head.next;
while (node != null){
//由于是有序的,所以当前结点的key大于key,说明后面的key不会存在,直接返回null
if (node.key.compareTo(key) > 0) {
return null;
}
if (node.key.equals(key)){
return node.value;
}
node = node.next;
}
return null;
}
public void put(String key, String value) {
Node helper = head;
Node cur = head.next;
if (cur == null) {
head.next = new Node(key, value, null);
}
//让cur指向第一个大于等于key的结点
while (cur != null && cur.key.compareTo(key) < 0){
helper = cur;
cur = cur.next;
}
//如果cur和key相等,说明已经存在了,直接修改value即可
if (cur != null && cur.key.equals(key)) {
cur.value = value;
} else {
//在helper和cur之间插入一个新结点
helper.next = new Node(key,value,cur);
N++;
}
}
public void delVal(String key) {
// 从头节点开始,这样可以处理删除第一个节点的情况
Node node = head;
while (node.next != null) {
//因为是有序的,如果当前结点的key大于key,说明后面的key不会存在,直接返回即可
if (node.next.key.compareTo(key) > 0) {
return;
}
if (node.next.key.equals(key)){
node.next = node.next.next;
N--;
return;
}
node = node.next;
}
}
public int size() {
return N;
}
private class Node {
private String key;
private String value;
private Node next;
public Node(String key, String value, Node next) {
this.key = key;
this.value = value;
this.next = next;
}
}
}
五、非线性表
5.1 树
树既能提高数据存储、读取的效率,比如可以使用二叉树,既可以保证数据检索的速度,同时也可以保证数据的插入,删除,修改的速度
5.1.1 二叉树概述
二叉树(Binary tree)是树形结构的一个重要类型,让多实际问题抽象出来的数据结构均住在这种二叉树形式,即使是一般的树也能向单地转换为二叉树,而且二叉树的存储结构及其算法都较为简单,因此二叉树显得特别重要。二叉树特点是每个结点最多只能有两棵子树,且有左右之分

满二叉树
如果该二叉树所有叶子结点都在最后一层,并且结点总数是2^n-1,n是层数,则我们称之为满二叉树

至于2^n-1是如何得到的
对于二叉树,如果每一层结点都是满的,那么这一层结点数量是2^(n-1)
如果一共有n层
那么一共的结点数是2^0 + 2^1 + ... + 2^(n-1)
也就是等比数列求和
a(1-q^n) / 1-q
即1-2^n / -1
即2^n - 1
完全二叉树
完全二叉树是由满二叉树而引出来的,若设二叉树的深度为h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数(即1~h-1层为一个满二叉树),第 h 层所有的结点都连续集中在最左边,这就是完全二叉树。

5.1.2 二叉树API
| 类名 | BinaryTree<Key extends Comparable,Value value> |
|---|---|
| 构造方法 | BinaryTree():创建BinaryTree对象 |
| 成员变量 | 1. private Node root;记录根结点 2. private int N;记录树中元素的个数 |
| 成员方法 | 1. public void put(Key key,Value value):向树中插入一个键值对 2. private Node put(Node x, Key key, Value val):给指定树x上,添加一个键值对,并返回添加后的树 3. public Value get(Key key):根据key,从树中找出对应的值 4. private Value get(Node x, Key key):从指定的树x中,找出key对应的值 5. public void delete(Key key):根据key,删除树中对应的键值对 6. private Node delete(Node x, Key key):删除指定树x上的键为key的键值对,并返回删除后的树 7. public int size():获取树中元素的个数 |
插入方法 put 实现思想:
1、如果当前树中没有任何一个结点,则直接把新结点当做根结点使用
2、如果当前树不为空,则从根结点开始:
3、如果新结点的 key 小于当前结点的 key,则继续找当前结点的左子结点;
4、如果新结点的 key 大于当前结点的 key,则继续找当前结点的右子结点;
5、如果新结点的 key 等于当前结点的 key,则树中已经存在这样的结点,替换该结点的 value 值即可。





查询方法get实现思想
从根节点开始:
1、如果要查询的 key 小于当前结点的 key,则继续找当前结点的左子结点;
2、如果要查询的 key 大于当前结点的 key,则继续找当前结点的右子结点;
3、如果要查询的 key 等于当前结点的 key,则树中返回当前结点的 value。
删除方法delete实现思想
1、找到被删除结点;
2、找到被删除结点右子树中的最小结点 minNode
3、删除右子树中的最小结点
4、让被删除结点的左子树称为最小结点 minNode 的左子树,让被删除结点的右子树称为最小结点 minNode 的右子树
5、让被删除结点的父节点指向最小结点 minNode
若要删除17,因为17是叶子结点,直接删除即可


若要删除10,因为10并非是叶子结点,删除后依然要保持二叉树的特点,该结点的右侧子树均大于该结点,而右侧子树的最左结点又小于右侧子树所有结点,因此将右侧子树的最小结点替换待删除结点的位置即可


如果没有左子树(右子树),只需返回右子树(左子树)即可


public class BinaryTree<K extends Comparable<K>,V> {
/**
* 根节点
*/
private Node root;
/**
* 结点个数
*/
private int N;
/**
* 插入一个结点
*/
public void put(K key, V value) {
root = put(root, key, value);
}
/**
* 往指定的树中插入一个结点
*/
public Node put(Node node,K key, V value) {
//判断要插入的结点是否为null
if (node == null) {
N++;
return new Node(key, value, null, null);
}
if (key.compareTo(node.key) > 0) {
node.right = put(node.right, key, value);
} else if(key.compareTo(node.key) < 0) {
node.left = put(node.left, key, value);
} else {
node.value = value;
}
return node;
}
/**
* 根据key获取value
*/
public V get(K key) {
return get(root, key);
}
/**
* 在指定的树中找到对应的value
*/
public V get(Node node,K key) {
if (node == null) {
return null;
}
if (key.compareTo(node.key) > 0) {
return get(node.right, key);
} else if(key.compareTo(node.key) < 0) {
return get(node.left, key);
} else {
return node.value;
}
}
/**
* 根据key删除一个结点
*/
public void delete(K key) {
delete(root, key);
}
/**
* 在指定的树中删除一个结点
*/
public Node delete(Node node,K key) {
if (node == null) {
return null;
}
if (key.compareTo(node.key) > 0) {
node.right = delete(node.right, key);
} else if(key.compareTo(node.key) < 0) {
node.left = delete(node.left, key);
} else {
N--;
if (node.right == null) {
return node.left;
} else if(node.left == null) {
return node.right;
} else if(node.right != null && node.left != null) {
Node minNode = node.right;
while (minNode.left != null) {
minNode = minNode.left;
}
Node minNodeParent = node.right;
while (minNodeParent.left != null) {
if (minNodeParent.left.left == null) {
minNodeParent.left = null;
} else {
minNodeParent = minNodeParent.left;
}
}
minNode.left = node.left;
minNode.right = node.right;
node = minNode;
}
}
return node;
}
public int size() {
return N;
}
private class Node {
private K key;
private V value;
private Node left;
private Node right;
public Node(K key, V value, Node left, Node right) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
}
}
}
找二叉树的最小结点和最大结点
public K minKey() {
return minKey(root);
}
public K minKey(Node node) {
if (node.left != null) {
return minKey(node.left);
} else {
return node.key;
}
}
public K maxKey() {
return maxKey(root);
}
public K maxKey(Node node) {
if (node.right != null) {
return maxKey(node.right);
} else {
return node.key;
}
}
5.1.3 遍历二叉树
可以使用前序、中序、后序对下面的二叉树进行遍历:
前序遍历:先输出父结点,再遍历左子树和右子树
中序遍历:先遍历左子树,再遍历父结点,再遍历右子树
后序遍历:先遍历左子树,再遍历右子树,最后遍历父结点
public class Node {
public int no;
public String name;
public Node left;
public Node right;
/**
* 前序遍历,先父节点,再左子树,再右子树
*/
public void preOrder() {
System.out.println(this);
if (this.left != null) {
this.left.preOrder();
}
if (this.right != null) {
this.right.preOrder();
}
}
/**
* 中序遍历,先左子树,再父结点,再右子树
*/
public void infixOrder() {
if (this.left != null) {
this.left.infixOrder();
}
System.out.println(this);
if (this.right != null) {
this.right.infixOrder();
}
}
/**
* 后序遍历,先左子树,再右子树,再父结点
*/
public void postOrder() {
if (this.left != null) {
this.left.postOrder();
}
if (this.right != null) {
this.right.postOrder();
}
System.out.println(this);
}
@Override
public String toString() {
return "Node{" +
"no=" + no +
", name='" + name + '\'' +
", left=" + left +
", right=" + right +
'}';
}
}
5.1.4 顺序存储二叉树
从数据存储来看,数组存储方式和树的存储方式可以相互转换,即数组可以转换成树,树也可以转换成数组
例如下图二叉树的结点,要求以数组的方式来存放

arr[5,2,7,1,3,6,7],但是在遍历数组的时候,仍然可以以前序遍历,中序遍历和后序遍历的方式完成结点的遍历
顺序存储二叉树的特点
- i表示二叉树中第i个元素(按0开始编号)
- 顺序二叉树通常只考虑完全二叉树
- 第i个元素的左子结点为2 * i + 1
- 第i个元素的右子结点为2 * i + 2
- 第i个元素的父结点为 (n - 1) / 2
public class ArrayBinaryTree {
/**
* 顺序二叉树
*/
private int[] arr;
public ArrayBinaryTree(int[] arr) {
this.arr = arr;
}
/**
* 前序遍历
*/
public void preOrder(int index){
//如果数组为空,或者arr.length为0,则直接返回
if (arr == null || arr.length == 0){
System.out.println("数组为空,无法遍历");
return;
}
System.out.print(arr[index] + "\t");
if (index * 2 + 1 < arr.length) {
preOrder(index * 2 + 1);
}
if (index * 2 + 2 < arr.length) {
preOrder(index * 2 + 2);
}
}
public void inOrder(int index) {
if (arr == null || arr.length == 0) {
System.out.println("数组为空,无法遍历");
return;
}
if (index * 2 + 1 < arr.length) {
inOrder(index * 2 + 1);
}
System.out.print(arr[index] + "\t");
if (index * 2 + 2 < arr.length) {
inOrder(index * 2 + 2);
}
}
public void largeOrder(int index) {
if (arr == null || arr.length == 0) {
System.out.println("数组为空,无法遍历");
return;
}
if (index * 2 + 1 < arr.length) {
largeOrder(index * 2 + 1);
}
if (index * 2 + 2 < arr.length) {
largeOrder(index * 2 + 2);
}
System.out.print(arr[index] + "\t");
}
public void largeOrder(){
largeOrder(0);
}
public void inOrder(){
inOrder(0);
}
public void preOrder(){
preOrder(0);
}
}
5.1.5 线索二叉树

我们观察上面的二叉树进行中序遍历时,数列为{1,2,3,5,6,7,8},但是1,3,6,8这几个结点的左右指针并没有完全地利用上
如果我们希望充分地利用各个结点的左右指针,让各个结点可以指向自己的前后结点可以使用线索二叉树
线索二叉树基本介绍
1、n个结点的二叉链表中有n + 1个空指针域(n个结点会有2n个指针域,除去跟结点都被一个指针指向,因此空指针域是2n - (n - 1)= n + 1),利用二叉链表中的空指针域,存放指向结点在某种遍历次序下的前驱和后继结点的指针(这种附加指针的方式称为线索)
2、这种加上了线索的二叉链表称为线索链表,相应的二叉树称为线索二叉树。根据线索性质的不同,线索二叉树可以分为前序线索二叉树、中序线索二叉树和后序线索二叉树
3、一个结点的前一个结点,称为前驱结点
4、一个结点的后一个结点,称为后继结点
【例】
将下面的二叉树进行中序线索二叉树

①中序遍历的结果是:{1,2,3,5,6,7}
②结点1没有前驱结点,后继结点是2,2结点没有空指针域,3的前驱结点是2,后继结点是5,结点5没有空指针域,6的前驱结点是5后继结点是7,7没有后继结点,因此中序线索二叉树应该是

当线索化二叉树后,Node结点的属性left和right有如下情况
1、left指向的是左子树,也可能是指向的前驱结点
2、 right指向的是右子树,也可能是指向后继结点
public void threadedNodes(Node node){
if (node == null) {
return;
}
//先线索化左子树
threadedNodes(node.left);
//线索化当前结点
//先处理当前结点的前驱结点
if (node.left == null){
//让当前结点的左指针指向前驱结点
node.left = pre;
//修改左指针的类型
node.isLeftThread = true;
}
if (pre != null && pre.right == null) {
pre.right = node;
//修改右指针的类型
pre.isRightThread = true;
}
pre = node;
//线索化右子树
threadedNodes(node.right);
}
以上代码借助了一个辅助指针pre来记录当前结点在中序遍历中的上一个结点,首先判断当前结点的左指针是否为空,若为空则修改当前结点指向前驱结点。然后让pre指向当前结点,判断pre结点的右指针域是否为空,如果为空则指向后继结点(也就是当前结点)
以下是对图中二叉树进行线索化的具体步骤(帮助理解在递归中进行线索化)
从根结点5开始,递归调用左子树,直到找到最左边的结点1
对结点1进行线索化:
-
结点1的left为null,将其left指向pre(此时pre为null)
-
设置isLeftThread为true
-
将pre更新为结点1
返回到结点2:
-
结点2的left不为null,不做处理
-
检查pre(结点1)的right为null,将其指向当前结点2
-
设置结点1的isRightThread为true
-
将pre更新为结点2
处理结点3:
-
结点3的left为null,将其left指向pre(结点2)
-
设置isLeftThread为true
-
检查pre(结点2)的right为null,将其指向结点3
-
设置结点2的isRightThread为true
-
将pre更新为结点3
返回到结点5:
-
结点5的left不为null,不做处理
-
检查pre(结点3)的right为null,将其指向结点5
-
设置结点3的isRightThread为true
-
将pre更新为结点5
处理结点6:
-
结点6的left为null,将其left指向pre(结点5)
-
设置isLeftThread为true
-
检查pre(结点5)的right为null,将其指向结点6
-
设置结点5的isRightThread为true
-
将pre更新为结点6
最后处理结点7:
-
结点7的left不为null,不做处理
-
检查pre(结点6)的right为null,将其指向结点7
-
设置结点6的isRightThread为true
-
将pre更新为结点7
遍历线索二叉树
二叉树线索化后,各个结点指向有变化,因此原来的遍历方式不能使用,这时需要使用新的方式遍历线索化二叉树,各个结点可以通过线性方式遍历,因此无需使用递归方式,这样也提高了遍历的效率。遍历的次序应当和中序遍历保持一致
public void threadedList(){
//存储当前遍历的结点
Node node = root;
while (node != null) {
//找到第一个isLeftThread为true的结点,该结点是按照线索化的结点
while (!node.isLeftThread) {
node = node.left;
}
System.out.println(node.key);
//如果当前结点的后继结点指向的是后继结点,就一直输出
while (node.isRightThread){
System.out.println(node.right.key);
node = node.right;
}
node = node.right;
}
}
5.1.6 堆
堆是计算机科学中一类特殊的数据结构的统称,堆通常可以被看做是一颗完全二叉树的数组
堆的特性:
1、它是完全二叉树,除了树的最后一层结点不需要是满的,其它的每一层从左到右都是满的,如果最后一层结点不是满的,那么要求左满右不满。

2、它通常用数组来实现,具体方法就是将二叉树的结点按层级顺序放入数组中,根结点在位置1,它的子结点在位置2和3,而子结点的子结点则分别在位置4,5,6和7,以此类推。
如果一个结点的位置为k,则它的父结点的位置为k/2,而它的两个子结点的位置则分别为2k和2k+1。这样,在不使用指针的情况下,我们也可以通过计算组织的索引在树中上下移动:从k向上一层,就令k等于k/2;向下一层就令k等于2k或2k+1。
3.每个结点都大于等于它的两个子结点,这里要注意堆中仅仅规定了每个结点大于等于它的两个子结点,但这两个子结点的顺序并没有做规定,跟我们之前学习的二叉排序树是有区别的。

如上图这就是堆结构,所有根节点的值永远比左右子树的大,那么就可以看出,整棵树的根节点,他的值是整个堆中最大的。
同时我们也发现没有直接父子关系的节点他们的值没有完全地关系,就像第二层的33和第三层的45以及20,没有规定第三层的元素值必须小于第二层,只要满足根节点比自己左右子树节点的值大即可。
| 类名 | Heap<T extends Comparable> |
|---|---|
| 构造方法 | Heap(int capacity):创建容量为capacity的Heap对象 |
| 成员方法 | 1. private boolean less(int i, int j):判断堆中索引i的元素是否小于索引j的元素 2. private void exch(int i, int j):交换堆中索引i和j的值 3. public T delMax():删除堆中最大的元素并返回这个最大元素 4. public void insert(T t):往堆中插入一个元素 5. private void swim(int k):使用上浮算法,使索引k的元素能在堆中处于一个正确的位置 6. private void sink(int k):使用下沉算法,使索引k的元素能在堆中处于一个正确的位置 |
| 成员变量 | 1. private T[] items:用来存储元素的数组 2. private int N:记录堆中元素的个数 |
insert插入实现
堆是用数组完成数据元素的存储的,由于数组的底层是一串连续的存储地址,所以我们通过堆中插入数据,我们只能往数组中从索引1处开始,依次往后存放数据,但是堆中元素的顺序是有要求的,每一个结点的数据要大于等于它的两个子结点的数据,所以每次插入一个元素,都会使得堆中的数据顺序发生变化,这个时候我们就需要通过一些方法让刚才插入的这个数据放入到合适的位置。
在堆中插入元素后,还要满足堆的特性,保持数据层次上的顺序(由小到大,或者由大到小)。
对于一个大顶堆,如果新插入的元素在堆的最底层,需要对这个元素和上层的元素比较大小,
如果比上层的元素大则需要上浮操作,也就是一层一层的进行比较交换,如下图所示:

delete删除操作:
从堆中删除元素和插入元素同理,需要判断要删除的元素和下层的大小关系,其实可以把上面插入的操作反过来理解,对应的操作是下沉操作,如下图所示:

public class Heap <T extends Comparable<T>>{
/**
* 堆数组
*/
private T[] items;
/**
* 堆大小
*/
private int N;
public Heap(int capacity){
items = (T[]) new Comparable[capacity+1];
N = 0;
}
/**
* 判断索引i处元素与j处元素大小
* 判断i处的元素是否小于j处的元素
*/
public boolean less(int i, int j){
return items[i].compareTo(items[j]) < 0;
}
public void exchange(int i, int j){
T temp = items[i];
items[i] = items[j];
items[j] = temp;
}
public void swim(int k){
//k是新加入的结点索引值
while (k > 1){
//父结点小于子节点
if (less(k / 2, k)) {
exchange(k / 2, k);
}
k = k / 2;
}
}
public T delMax(){
T max = items[1];
//根结点和最后一个结点交换位置
exchange(1, N);
items[N] = null;
N--;
sink(1);
return max;
}
/**
* 下沉
*/
public void sink(int k){
while (2 * k <= N){
//存在右结点
int max = 0;
if (2 * k + 1 <= N) {
if (less(2 * k, 2 * k + 1)) {
max = 2 * k + 1;
} else {
max = 2 * k;
}
} else {
max = 2 * k;
}
//当前结点比左右子结点还大,不需要交换位置,结束循环即可
if (!less(k, max)){
break;
} else {
//当前结点小于最大子结点,交换位置
exchange(k, max);
k = max;
}
}
}
public void insert(T t){
items[++N] = t;
swim(N);
}
}
5.1.7 堆排序
利用堆实现对数组的排序,思路为首先构建一个堆,根据堆根结点最大的性质,让根结点和最后元素交换,再让交换过来的元素进行下沉,直至形成新的堆完成排序操作
public class HeapSort <T extends Comparable<T>> {
public static void createHeap(Comparable[] heap, Comparable[] source) {
//构建堆
System.arraycopy(source, 0, heap, 1, source.length);
for (int i = heap.length / 2; i > 0; i--) {
sink(heap, i, heap.length - 1);
}
}
public static void sort(Comparable[] source) {
Comparable[] heap = new Comparable[source.length + 1];
createHeap(heap, source);
int index = heap.length - 1;
while (index > 0) {
exchange(heap, 1, index);
sink(heap, 1, --index);
}
System.arraycopy(heap, 1, source, 0, source.length);
}
public static void sink(Comparable[] heap, int target, int range){
while (2 * target <= range){
if (2 * target + 1 <= range) {
int max = 0;
if (less(heap, 2 * target, 2 * target + 1)) {
if (less(heap, 2 * target, 2 * target + 1)){
max = 2 * target + 1;
} else {
max = 2 * target;
}
} else {
max = 2 * target;
}
if (!less(heap, target, max)){
break;
} else {
exchange(heap, target, max);
target = max;
}
}
}
}
public static boolean less(Comparable[] heap, int i, int j){
return heap[i].compareTo(heap[j]) < 0;
}
public static void exchange(Comparable[] heap, int i, int j){
Comparable temp = heap[i];
heap[i] = heap[j];
heap[j] = temp;
}
}
5.1.8 优先队列
普通的队列是一种先进先出的数据结构,元素在队列追加,而从队列头删除,在某些情况下,我们可能需要找出队列中的最大值或者最小值,例如使用一个队列保存计算机的任务,一般情况下计算机的任务都是有优先级的,我们需要在这些计算机的任务中找出优先级最高的任务先执行,执行完毕后就需要把这个任务从队列中移除。普通的队列要完成这样的功能,需要每次遍历队列中的所有元素,比较并找出最大值,效率不是很高,这个时候,我们就可以使用一种特殊的队列来完成这种需求,优先队列。

优先队列按照其作用不同,可以分为以下两种:
-
最大优先队列
可以获取并删除队列中最大的值
-
最小优先队列
可以获取并删除队列中最小的值
最大优先队列
堆这种结构就可以方便的实现删除最大的值,此处不再重复写一遍代码
最小优先队列
也就是小根堆
1、最小的元素在数组的索引1处
2、每个结点的数据总是小于等于它的两个子结点的数据
public class MinQueue <T extends Comparable<T>>{
private T[] items;
private int N;
public MinQueue(int capacity) {
items = (T[]) new Comparable[capacity];
N = 0;
}
public boolean less(int i, int j){
return items[i].compareTo(items[j]) < 0;
}
public void exchange(int i, int j){
T temp = items[i];
items[i] = items[j];
items[j] = temp;
}
public T delMin(){
T min = items[1];
exchange(1, N);
items[N] = null;
N--;
sink(1);
return min;
}
public void insert(T t){
items[++N] = t;
swim(N);
}
public void swim(int k){
while (k > 1) {
if (less(k, k / 2)){
exchange(k, k / 2);
}
k = k / 2;
}
}
public void sink(int k){
while(2 * k <= N){
int min = 2147483647;
if (2 * k + 1 <= N){
if (less(2 * k, 2 * k + 1)){
min = 2 * k;
} else {
min = 2 * k + 1;
}
} else {
min = 2 * k;
}
if (!less(k, min)){
exchange(k, min);
k = min;
}else {
break;
}
}
}
public boolean isEmpty(){
return N == 0;
}
public int size(){
return N;
}
}
5.1.9 索引优先队列
步骤一:
存储数据时,给每一个数据元素关联一个整数,例如insert(int k,T t)我们可以看做k是t关联的整数,那么我们的实现需要通过k这个值,快速获取到队列中t这个元素,此时有个k这个值需要具有唯一性。
最直观的想法就是我们可以用一个T[] items数组来保存数据元素,在insert(int k,T t)完成插入时,可以把k看做是items数组的索引,把t元素放到items数组的索引k处,这样我们再根据k获取元素t时就很方便了,直接就可以拿到items[k]即可。
步骤二
步骤一完成后的结果,虽然我们给每个元素关联了一个整数,并且可以使用这个整数快速的获取到该元素,但是,items数组中的元素顺序是随机的,并不是堆有序的,所以,为了完成这个需求,我们可以增加一个数组int[] pq来保存每个元素在items数组中的索引,pq数组需要堆有序,也就是说,pq[1]对应的数据元素items[pq[1]]要小于等于pq[2]和pq[3]对应的数据元素items[pq[2]]和items[pq[3]]。
步骤三
但是如果我们要修改items数组中的元素,我们也要调整堆,也就是要调整pq数组,但是仅有这两个数组无法实现对pq数组的随机访问,于是可以再使用一个数组qp存储pq的逆序 ,以便快速找到pq调整元素的位置




| 类名 | IndexMinPriorityQueue<T extends Comparable> |
|---|---|
| 构造方法 | IndexMinPriorityQueue(int capacity) : 创建容量为capacity的IndexMinPriorityQueue对象 |
| 成员方法 | 1.private boolean less(int i,int j) : 判断堆中索引i处的元素是否小于索引j处的元素 2.private void exch(int i,int j):交换堆中索引i和索引j处的值 3.public int delMin():删除队列中最小的元素,并返回该元素关联的索引 4.public void insert(int i,T t) : 往队列中插入一个元素,并关联索引i 5.private void swim(int k):使用上浮算法,使索引k处的元素能在堆中处于一个正确的位置 6.private void sink(int k):使用下沉算法,使索引k处的元素能在堆中处于一个正确的位置 7.public int size():获取队列中元素的个数 8.public boolean isEmpty():判断队列是否为空 9.public boolean contains(int k):判断k对应的元素是否存在 10.public void changeItem(int i, T t):把与索引i关联的元素修改为为t 11.public int minIndex():最小元素关联的索引 12.public void delete(int i):删除索引i关联的元素 |
| 成员变量 | 1.private T[] imtes : 用来存储元素的数组 2.private int[] pq:保存每个元素在items数组中的索引,pq数组需要堆有序 3.private int[] qp:保存qp的逆序,pq的值作为索引,pq的索引作为值 4.private int N : 记录堆中元素的个数 |
public class IndexMinQueue <T extends Comparable<T>>{
/**
* 存储元素的数组
*/
private T[] items;
/**
* 存储items中元素索引的数组,pq是堆有序的
*/
private int[] pq;
/**
* 存储pq的逆序索引
*/
private int[] qp;
private int N;
public IndexMinQueue(int size) {
items = (T[]) new Comparable[size + 1];
pq = new int[size + 1];
qp = new int[size + 1];
N = 0;
//int[] 的数组默认值初始化为-1
for (int i = 0; i < qp.length; i++) {
qp[i] = -1;
}
for (int i = 0; i < pq.length; i++) {
pq[i] = -1;
}
}
public int size(){
return N;
}
public boolean isEmpty(){
return N == 0;
}
public boolean less(int i, int j){
return items[pq[i]].compareTo(items[pq[j]]) < 0;
}
public void exchange(int i, int j){
int temp = pq[i];
pq[i] = pq[j];
pq[j] = temp;
//同时更新qp
qp[pq[i]] = i;
qp[pq[j]] = j;
}
public boolean isContains(int k){
return qp[k] != -1;
}
/**
* 获取最小索引
*/
public int minIndex(){
return pq[1];
}
public void insert(int i, T t){
if (isContains(i)) {
throw new RuntimeException("索引已经存在");
}
N++;
items[i] = t;
pq[N] = i;
qp[i] = N;
swim(N);
}
public int delMin(){
int min = pq[1];
exchange(1, N);
//删除qp中对应的pq[N]
qp[pq[N]] = -1;
pq[N] = -1;
items[min] = null;
N--;
sink(1);
return min;
}
public void delete(int i){
if (!isContains(i)) {
throw new RuntimeException("索引不存在");
}
//找到在pq中的索引
int index = qp[i];
//交换pq[index]和pq[N]
exchange(index, N);
qp[pq[N]] = -1;
pq[N] = -1;
items[i] = null;
N--;
//从当前位置下沉
sink(index);
//从当前位置上浮
swim(index);
}
public void sink(int k){
while (2 * k <= N){
int min = 2 * k;
if (2 * k + 1 <= N){
if (less(2 * k, 2 * k + 1)){
min = 2 * k;
} else {
min = 2 * k + 1;
}
}else {
min = 2 * k;
}
if (!less(k, min)){
exchange(k, min);
k = min;
}else {
break;
}
}
}
public void swim(int k){
while (k > 1){
if (less(k, k / 2)){
exchange(k, k / 2);
}
k = k / 2;
}
}
}
5.1.10 哈夫曼树
路径和路径长度
在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为1,则从根结点到第L层结点的路径长度为L-1。
结点的权及带权路径长度:
若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积。
树的带权路径长度:
树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为WPL。
权值越大的结点距离根结点越近的二叉树才是最优二叉树
wpl最小的就是哈夫曼树

例如上图中根结点5的路径长度为1,结点1的路径长度为3 - 1 = 2,结点1的带权路径长度为1 * 2 = 2
要使该树变为一颗哈夫曼树,必须使权值越大的离根结点越近
构建哈夫曼树思路:
1、从小到大进行排序,每个数据都是一个节点,每个结点可以看成是一颗简单的二叉树
2、取出根结点最小的两颗二叉树
3、组成一颗新的二叉树,该新的二叉树根结点权值是前面两颗二叉树节点权值之和。
4、将这颗新的二叉树以根结点的权值大小再次排序,不断重复以上步骤,直到数列中所有的数据被处理,即可得到一颗哈夫曼树
【例】
将数列{13,7,8,29,6,1}构建一棵哈夫曼树




public class HuffmanNode implements Comparable<HuffmanNode>{
public int value;
public HuffmanNode left;
public HuffmanNode right;
public HuffmanNode(int value) {
this.value = value;
}
@Override
public int compareTo(HuffmanNode o) {
return this.value - o.value;
}
@Override
public String toString() {
return "HuffmanNode{" +
"value=" + value +
", left=" + left +
", right=" + right +
'}';
}
}
public class HuffmanTree {
public static HuffmanNode createHuffmanTree(int[] arr) {
ArrayList<HuffmanNode> nodes = new ArrayList<>();
//把数组中的每一个元素构建结点
for (int item : arr) {
//把构建的结点放入集合中
nodes.add(new HuffmanNode(item));
}
while (nodes.size() > 1) {
//从小到大进行排序操作
Collections.sort(nodes);
//取出两个最小的结点组合成一个新的二叉树
HuffmanNode smallestNode = nodes.get(0);
HuffmanNode secondSmallestNode = nodes.get(1);
//新二叉树的根结点是前面两个二叉树权值之和
HuffmanNode parent = new HuffmanNode(smallestNode.value + secondSmallestNode.value);
parent.left = smallestNode;
parent.right = secondSmallestNode;
//删除已构建的结点
nodes.remove(smallestNode);
nodes.remove(secondSmallestNode);
//把二叉树的父结点放入集合
nodes.add(parent);
}
return nodes.get(0);
}
}
5.1.11 哈夫曼编码
哈夫曼编码(Huffman Coding),也称霍夫曼编码,是一种编码方式。哈夫曼编码是可变字长编码(VLC)的一种。Huffman于1952年提出一种编码方法,该方法完全依据字符出现频率来构造字头的平均长度最短的码字,有时称之为最佳编码,一般都叫做Huffman编码(有时也称为霍夫曼编码) 。
【例】
I am yaojinlun. Nice to meet you.
按照上面字符出现的次数,构建一棵哈夫曼树,次数作为权值
{’ ':6, ‘.’:1, ‘I’:1, ‘N’:1, ‘a’:2, ‘c’:1, ‘e’:3, ‘i’:2, ‘j’:1, ‘l’:1, ‘m’:2, ‘n’:2, ‘o’:2, ‘t’:2, ‘u’:2, ‘y’:2}
注意:
1、根据赫夫曼树,规定前缀编码,向左的路径为0,向右的路径为1
2、前缀编码:设计长短不等的编码,必须是任一字符的编码都不是另一个字符编码的前缀,这种编码称为前缀编码

代码实现
public class HuffmanCode {
public static Map<Byte, String> huffmanCodes = new HashMap<>();
public static StringBuilder stringBuilder = new StringBuilder();
/**
* 将字节数组转换为Node列表,统计每个字节出现的次数作为权重
*/
public static List<HuffmanNode> getNodes(byte[] bytes) {
List<HuffmanNode> nodes = new ArrayList<>();
Map<Byte, Integer> weightMap = new HashMap<>();
for (byte b : bytes) {
weightMap.merge(b, 1, Integer::sum);
}
weightMap.forEach((k, v) -> nodes.add(new HuffmanNode(k, v)));
return nodes;
}
/**
* 构建哈夫曼树
* 1. 将权重最小的两个节点取出
* 2. 创建新节点,权重为两个节点之和
* 3. 新节点的左右子节点为取出的两个节点
* 4. 将新节点加入列表
* 5. 重复直到列表中只剩一个节点
*/
public static HuffmanNode getHuffmanTree(List<HuffmanNode> nodes) {
while (nodes.size() > 1) {
nodes.sort(HuffmanNode::compareTo);
HuffmanNode left = nodes.get(0);
HuffmanNode right = nodes.get(1);
HuffmanNode parent = new HuffmanNode(null, left.weight + right.weight);
parent.left = left;
parent.right = right;
nodes.remove(left);
nodes.remove(right);
nodes.add(parent);
}
return nodes.get(0);
}
/**
* 递归生成哈夫曼编码
* @param node 当前节点
* @param code 路径编码(左子树为0,右子树为1)
* @param stringBuilder 用于拼接路径编码
*/
public static void getCodes(HuffmanNode node, String code, StringBuilder stringBuilder) {
StringBuilder stringBuilder1 = new StringBuilder(stringBuilder);
stringBuilder1.append(code);
if (node != null) {
if (node.data == null) {
// 非叶子节点,继续递归
getCodes(node.left, "0", stringBuilder1);
getCodes(node.right, "1", stringBuilder1);
} else {
// 叶子节点,保存编码
huffmanCodes.put(node.data, stringBuilder1.toString());
}
}
}
/**
* 根据哈夫曼树生成编码表
*/
public static Map<Byte,String> getCodes(HuffmanNode root) {
if (root == null) {
return null;
}
getCodes(root.left, "0", stringBuilder);
getCodes(root.right, "1", stringBuilder);
return huffmanCodes;
}
/**
* 压缩字节数组
* 1. 将每个字节转换为对应的哈夫曼编码
* 2. 将编码串按8位分组转换为字节
* 3. 保存最后一个字节的有效位数
*/
public static byte[] zip(byte[] bytes, Map<Byte, String> huffmanCodes) {
StringBuilder stringBuilder = new StringBuilder();
for (byte b : bytes) {
stringBuilder.append(huffmanCodes.get(b));
}
// 计算最后一个字节的有效位数
int lastValidBits = stringBuilder.length() % 8;
if (lastValidBits == 0) {
lastValidBits = 8;
}
// 计算字节数组长度(包括保存最后一个字节的有效位数的字节)
int len = (stringBuilder.length() + 7) / 8 + 1;
byte[] huffmanCodeBytes = new byte[len];
huffmanCodeBytes[0] = (byte) lastValidBits;
// 将编码串转换为字节数组
int index = 1;
for (int i = 0; i < stringBuilder.length(); i += 8) {
String strByte;
if (i + 8 > stringBuilder.length()) {
strByte = stringBuilder.substring(i);
strByte = String.format("%-8s", strByte).replace(' ', '0');
} else {
strByte = stringBuilder.substring(i, i + 8);
}
huffmanCodeBytes[index++] = (byte) Integer.parseInt(strByte, 2);
}
return huffmanCodeBytes;
}
/**
* 将字节转换为二进制字符串
* @param needFullByte 是否需要补齐8位
*/
private static String byteToBitString(byte b, boolean needFullByte) {
int temp = b;
if (temp < 0 || needFullByte) {
temp |= 256;
String str = Integer.toBinaryString(temp);
return str.substring(str.length() - 8);
} else {
String str = Integer.toBinaryString(temp);
while (str.length() < 8) {
str = "0" + str;
}
return str;
}
}
/**
* 解压缩字节数组为二进制字符串
*/
public static String unzip(byte[] huffmanBytes, Map<Byte, String> huffmanCodes) {
int lastValidBits = huffmanBytes[0];
StringBuilder stringBuilder = new StringBuilder();
// 处理除最后一个字节外的所有字节
for (int i = 1; i < huffmanBytes.length - 1; i++) {
stringBuilder.append(byteToBitString(huffmanBytes[i], true));
}
// 处理最后一个字节
if (huffmanBytes.length > 1) {
String lastByte = byteToBitString(huffmanBytes[huffmanBytes.length - 1], true);
String validBits = lastByte.substring(0, lastValidBits);
stringBuilder.append(validBits);
}
return stringBuilder.toString();
}
/**
* 将二进制字符串解码为字节数组
*/
public static byte[] decode(String huffmanStr, Map<String, Byte> decodeMap) {
ArrayList<Byte> bytes = new ArrayList<>();
int start = 0;
String key = "";
while (start < huffmanStr.length()) {
key += huffmanStr.charAt(start);
if (decodeMap.containsKey(key)) {
bytes.add(decodeMap.get(key));
key = "";
}
start++;
}
byte[] result = new byte[bytes.size()];
for (int i = 0; i < bytes.size(); i++) {
result[i] = bytes.get(i);
}
return result;
}
}
5.1.12 二叉排序树(BST)
需求:假设给定一个数列 [36, 65, 18, 7, 60, 89, 43, 57, 96, 52, 74], 能够有效的完成对数据的查询和添加。
使用数组:
1、使用未排序数组可以直接将数组插入到数组尾部,速度快,但是查找慢
2、使用排序好数组,查询比较快,但是插入新数据时,需要整体移动后,插入有效的位置,过程比较慢
使用链表:
链表特点是插入数据比较方便,但是查询数据比较慢
二叉排序树
二叉排序树 ,又称二叉查找树,亦称二叉搜索树。是数据结构中的一类。在一般情况下,查询效率比链表结构要高。
如果有相同的值,可以将该节点放在左子节点和右子节点上。
排序:
例如,假设原二叉排序树为空树,在对动态查找表 {3, 5, 7, 2, 1} 做查找以及插入操作时,可以构建出一个含有表中所有关键字的二叉排序树

二叉排序树的删除
其中二叉排序树是一个动态树表,其特点就是树的结构不是一次生成的,而是在查找过程中,如果关键字不在树中时,在把关键字插入到树中。而对于删除操作,它分为三种情况:
1、删除结点为叶子结点(左右子树均为NULL),所以我们可以直接删除该结点,如下图所示:

2、删除结点只有左子树或者右子树,此时只需要让其左子树或者右子树直接替代删除结点的位置,称为删除结点的双亲的孩子,就可以了,如下图所示:

3、当要删除的那个结点,其左右子树都存在的情况下,则要从从左子树中找出一个最大的值,那个结点来替换我们要删除的结点,如下图所示:

public class Node {
int value;
Node left;
Node right;
public Node(int value) {
this.value = value;
}
@Override
public String toString() {
return "Node{" +
"value=" + value +
", left=" + left +
", right=" + right +
'}';
}
public void add(Node node){
if (node == null) {
return;
}
if (node.value < this.value){
//往左子结点放
if (this.left == null) {
this.left = node;
} else {
this.left.add(node);
}
} else {
if (this.right == null) {
this.right = node;
} else {
this.right.add(node);
}
}
}
/**
* 找到要删除的结点
*/
public Node searchDelNode(int value){
if (this.value == value){
return this;
} else if (value < this.value){
//往左子结点找找不到了,返回null
if (this.left == null){
return null;
} else {
return this.left.searchDelNode(value);
}
} else {
if (this.right == null){
return null;
} else {
return this.right.searchDelNode(value);
}
}
}
/**
* 找到要删除结点的父结点
*/
public Node searchParent(int value){
if ((this.left != null && this.left.value == value) || (this.right != null && this.right.value == value)) {
return this;
} else {
if (value < this.value && this.left != null){
return this.left.searchParent(value);
} else if (value >= this.value && this.right != null){
return this.right.searchParent(value);
} else {
return null;
}
}
}
}
public class BinarySortTree {
public Node root;
/**
* 查找删除结点
*/
public Node searchDelNode(int value){
if (root == null) {
return null;
}
return root.searchDelNode(value);
}
public Node searchParent(int value){
if (root == null) {
return null;
}
return root.searchParent(value);
}
/**
* 添加结点
*/
public void add(Node node){
if (root == null) {
root = node;
} else {
root.add(node);
}
}
/**
* 删除结点
*/
public void delNode(int value) {
if (root == null) {
return;
}
Node targetNode = searchDelNode(value);
if (targetNode == null) {
return;
}
//如果二叉排序树只有一个结点
if (root.left == null && root.right == null) {
root = null;
return;
}
//找到要删除结点的父结点
Node parent = searchParent(value);
//判断要删除的结点是不是叶子结点
if (targetNode.left == null && targetNode.right == null) {
//删除的结点是父结点的左子结点
if (parent != null && parent.left != null && parent.left.value == value) {
parent.left = null;
//删除的结点是父结点的右子结点
} else if (parent != null && parent.right != null && parent.right.value == value) {
parent.right = null;
}
} else if (targetNode.left != null && targetNode.right != null) {
//删除的结点存在左右子树
//找到右子树的最小值结点
int needToReplace = delTreeMin(targetNode.left);
targetNode.value = needToReplace;
} else {
//删除的结点只有一棵子树
if (targetNode.left != null) {
if (parent != null) {
if (parent.left != null && parent.left.value == value) {
parent.left = targetNode.left;
} else {
parent.right = targetNode.left;
}
} else {
root = targetNode.left;
}
//删除的结点只有右子树
} else{
if (parent != null) {
if (parent.left != null && parent.left.value == value) {
parent.left = targetNode.right;
} else {
parent.right = targetNode.right;
}
} else {
root = targetNode.right;
}
}
}
}
public int delTreeMin(Node node){
Node targetNode = node;
while (targetNode.left != null) {
targetNode = targetNode.left;
}
delNode(targetNode.value);
return targetNode.value;
}
}
5.1.12 平衡二叉树(AVL树)
如果将数列{1,2,3,4,5,6},创建一颗二叉排序树,那么形成的二叉排序树如图

上述二叉排序树存在的问题
-
左子树全部为空,从形式上看,更像一个单链表。
-
插入速度没有影响
-
查询速度明显降低(因为需要依次比较),不能发挥BST的优势,因为每次还需要比较左子树,其查询速度比单链表还慢
平衡二叉树
平衡二叉树也叫平衡二叉搜索树(Self-balancing binary search tree),又被称为AVL树。它可以保证查询效率较高
具有以下特点:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。平衡二叉树的常用实现方法有红黑树、替罪羊树等
左旋

当插入8时,如果 rightHeight() - leftHeight() > 1 成立,此时需要进行左旋转操作(降低右子树的高度)
步骤:
1、创建一个新的节点 newNode(以当前节点的值创建)
2、创建一个新的节点 left(值等于当前节点的左子树)
3、把新结点的左子树设置为当前结点的左子树
newNode.right = right.left
4、把新结点的右子树设置为当前结点的右子树的左子树
newNode.left = left
5、把当前结点的值换为右子结点的值
value = right.value
6、把当前结点的右子树设置为右子树的右子树
right = right.right
7、 把当前结点设置为新结点
left = newNode
/**
* 左旋转
* Node类里面的方法
*/
public void leftRotate(){
//创建新的结点,以当前根结点的值
Node newNode = new Node(value);
//新结点的左子树就是当前结点的左子树
newNode.left = left;
//新结点的右子树就是当前结点的右子树的左子树
newNode.right = right.left;
//将当前结点的值替换为右子结点的值
this.value = right.value;
//将新结点的右子树设置成当前结点的右子树的右子树
this.right = right.right;
//将新结点设置成当前结点的左子结点
left = newNode;
}
右旋

当插入8时,如果 leftHeight() - rightHeight() > 1 成立,此时需要进行右旋转操作(降低左子树的高度)
步骤:
1、创建一个新的节点 newNode(以当前节点的值创建)
2、把新结点的右子树设置为当前结点的右子树
newNode.right = right
3、把新结点的左子树设置为当前结点的左子树的右子树
newNode.left = left.right
4、把当前结点的值换为左子结点的值
value = left.value
5、把当前结点的左子树设置为左子树的左子树
left = left.left
6、把当前结点的右子树设置为新结点
right = newNode
/**
* 右旋转
* Node类里面的方法
*/
public void rightRotate(){
//创建新的结点,以当前根结点的值
Node newNode = new Node(value);
//新结点的右子树就是当前结点的右子树
newNode.right = right;
//新结点的左子树就是当前结点的左子树的右子树
newNode.left = left.right;
//将当前结点的值替换为左子结点的值
this.value = left.value;
//将新结点的左子树设置成当前结点的左子树的左子树
this.left = left.left;
//将新结点设置成当前结点的右子结点
right = newNode;
}
双旋转
在某些情况下,单旋转不能完成平衡二叉树的旋转,例如数列{10,11,7,6,8,9}

观察上图可以发现,新插入一个结点导致最先失衡的结点的情况有四种,分别是LL,RR,LR,RL
LL失衡只需进行一次右旋转,RR失衡只需一次左旋转,LR失衡需要先进行一次左旋转再进行一次右旋转,RL失衡先进行一次右旋转再进行一次左旋转
//Node类里面的方法
public void add(Node node){
if (node == null) {
return;
}
if (node.value < this.value){
//往左子结点放
if (this.left == null) {
this.left = node;
} else {
this.left.add(node);
}
} else {
if (this.right == null) {
this.right = node;
} else {
this.right.add(node);
}
}
if (rightHeight() - leftHeight() > 1) {
if (this.right != null && this.right.leftHeight() > this.right.rightHeight()){
right.rightRotate();
leftRotate();
} else {
leftRotate();
}
return;
}
if (leftHeight() - rightHeight() > 1) {
if (this.left != null && this.left.rightHeight() > this.left.leftHeight()){
left.leftRotate();
rightRotate();
}else {
rightRotate();
}
return;
}
}
//Node类里面的方法
public int height(){
return Math.max((this.left == null ? 0 : this.left.height()), this.right == null ? 0 : this.right.height()) + 1;
}
/**
* 返回右子树的高度
*/
public int rightHeight(){
if (this.right == null){
return 0;
} else {
return this.right.height();
}
}
/**
* 返回左子树的高度
*/
public int leftHeight(){
if (this.left == null){
return 0;
} else {
return this.left.height();
}
}
5.1.13 二叉树问题分析
二叉树操作数据效率比较高,但是如果细心想下它也存在着一定的问题:
1、二叉树需要加载到内存的,如果二叉树的节点少, 没有什么问题,但是如果二叉树的节点很多(比如10亿),就存在问题了。
2、在构建二叉树时,需要多次进行i/o操作(海量数据存在数据库或文件中),节点海量,构建二叉树时,速度有影响。
因此提出了多叉树,允许每个节点可以有更多的数据项和更多的结点
5.1.14 2-3树


B树通过重新组织节因此点,降低树的高度,并且减少io次数来提高

2-3树基本介绍
2-3树是最简单的B树结构
- 2-3树的所有叶子结点都在同一层(只要是B树都满足这个条件)
- 二结点含有一个键和两条链,左链指向的键都小于该结点,右链指向的键都大于该结点
- 三结点含有两个键和三条链,左链指向的键都小于该结点,右链指向的键都大于该结点,中链指向的的键介于该结点的两个键之间
- 2-3树是由二结点和三节点构成的树
5.1.14.1 插入结点
将数列{16,24,12,32,14,26,34,10,8,28,38,20}构成2-3树












5.2 哈希表
5.2.1 概述
基本思想:记录的存储位置与关键字之间存在对应关系
-
散列方法(杂凑法):选取某个函数,依该函数按关键字计算元素的存储位置,并按此存放;查找时,由同一个函数对给定值k计算地址,将k与地址单元中元素关键码进行比,确定查找是否成功。
-
散列函数:散列方法中使用的转换函数
-
散列表:按上述思想构造的表
-
哈希冲突:不同的关键码映射到同一个散列地址

5.2.2 哈希函数
1、构造好的散列函数
(a) 所选函数尽可能简单,以便提高转换速度
(b)所选函数对关键码计算出的地址,应在散列地址集中致均匀分布,以减少空间浪费
2、制定一个好的解决冲突的方案
查找时,如果从散列函数计算出的地址中查不到关键码,则应当依据解决冲突的规则,有规律地查询其它相关单元。
除留余数法
Hash(key) = key mod p
p是一个整数
设表长为m,取p <= m 且为质数
5.2.3 解决哈希冲突的方法
5.2.3.1 开放定址法
有冲突时就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将数据元素存入
例如除留余数法 H i = ( H a s h ( k e y ) + d i ) m o d m H_i = (Hash(key) + d_i) \bmod m Hi=(Hash(key)+di)modm
其中 d i d_i di为增量序列
常用方法有
①线性探测法
d i d_i di为1,2,…,m-1 线性序列
【例】
关键码集为 {47, 7, 29, 11, 16, 92, 22, 8, 3},散列表长为m=11,散列函数为Hash(key)=key mod 11;采用线性探测法处理冲突。建散列表如下:

47、7均是由散列函数得到的没有冲突的散列地址;
Hash(29)=7,散列地址有冲突,需寻找下一个空的散列地址:由H₁=(Hash(29)+1) mod 11=8,散列地址8为空,因此将29存入。
11、16、92均是由散列函数得到的没有冲突的散列地址;
另外,22、8、3同样在散列地址上有冲突,也是由H₁找到空的散列地址的。
平均查找长度ASL = (1 + 2 + 1 +1 + 1 + 4 + 1 + 2 + 2)/ 9 = 1.67
②二次探测法
d i d_i di为1²,-1²,2²,-2²,…,q²二次序列
【例】
关键码集为 {47, 7, 29, 11, 16, 92, 22, 8, 3},散列表长为m=11,散列函数为Hash(key)=key mod 11;采用二次探测法处理冲突。建散列表如下:

Hash(3)=3,散列地址冲突,由H₁=(Hash(3)+1²) mod 11=4,仍然冲突;H₂=(Hash(3)-1²) mod 11=2,找到空的散列地址,存入。
③伪随机探测法
d i d_i di为伪随机序列
public class HashTable {
/**
* 散列表
*/
private int[] table;
/**
* 表长
*/
private int size;
/**
* 当前元素个数
*/
private int count;
public HashTable(int size) {
this.size = size;
this.table = new int[size];
this.count = 0;
// 初始化散列表,用-1表示空位
for(int i = 0; i < size; i++) {
table[i] = -1;
}
}
/**
* 除留余数法
*/
private int hash(int key) {
return key % size;
}
/**
* 线性探测法插入
*/
public boolean insertLinear(int key) {
if(count == size) {
System.out.println("散列表已满!");
return false;
}
int index = hash(key);
// 如果发生冲突则线性探测
while(table[index] != -1) {
// 线性探测下一个位置
index = (index + 1) % size;
}
table[index] = key;
count++;
return true;
}
/**
* 二次探测法插入
*/
public boolean insertQuadratic(int key) {
if(count == size) {
System.out.println("散列表已满!");
return false;
}
int index = hash(key);
int i = 1;
// 如果发生冲突则二次探测
while(table[index] != -1) {
if (i % 2 == 1) {
//正向探测
index = (index + i * i) % size;
} else {
// 逆向探测
index = (index - i * i) % size;
// 处理负数情况
if(index < 0) {
index += size;
}
}
i++;
// 避免无限循环
if(i > size) {
System.out.println("无法找到可用位置!");
return false;
}
}
table[index] = key;
count++;
return true;
}
/**
* 查找元素 - 线性探测
*/
public int searchLinear(int key) {
int index = hash(key);
int initIndex = index;
while(table[index] != key) {
index = (index + 1) % size;
// 如果回到起始位置,说明未找到
if(index == initIndex || table[index] == -1) {
return -1;
}
}
return index;
}
/**
* 查找元素 - 二次探测
*/
public int searchQuadratic(int key) {
int index = hash(key);
int i = 1;
while(table[index] != key) {
if (i % 2 == 1) {
index = (hash(key) + i * i) % size;
} else {
index = (hash(key) - i * i) % size;
// 处理负数情况
if(index < 0) {
index += size;
}
}
i++;
// 如果探测次数超过表长或遇到空位,说明未找到
if(i > size || table[index] == -1) {
return -1;
}
}
return index;
}
public void display() {
System.out.println("散列表内容:");
for(int i = 0; i < size; i++) {
System.out.print(table[i] + " ");
}
System.out.println();
}
}
5.2.3.2 拉链法
相同的散列地址的记录链成一单链表
m个散列地址就设m个单链表,然后用一个数组将m个单链表的表头指针存储起来,形成一个动态的结构
例如一组关键字为{19,14,23,1,68,20,84,27,55,11,10,79}
散列函数为Hash(key) = key mode 13

public HashTable(int size) {
this.size = size;
hashTable = new LinkList[size];
for(int i = 0; i < size; i++) {
hashTable[i] = new LinkList<Integer>();
}
}
/**
* 拉链法插入哈希表
*/
public void insertHashTable(int key) {
int index = hash(key);
hashTable[index].insert(key);
}
/**
* 拉链法查找元素
*/
public int searchHashTable(int key) {
int index = hash(key);
int result = hashTable[index].indexOf(key);
if(result != -1) {
return index;
} else {
return -1;
}
}
注:上面代码中使用的单链表是4.4.1中实现的
5.3 图
5.3.1 图的概念
图是一种数据结构,其中结点可以具有零个活多个相邻元素,两个结点之间的连接称为边,结点也可以称作顶点
图的常用概念
- 顶点
- 边
- 路径



5.3.2 图的表示方式
图的表示方式有两种:邻接矩阵(二维数组);邻接表(链表)
5.3.2.1 邻接矩阵
邻接矩阵是表示图形中顶点之间相邻关系的矩阵,对于 n 个顶点的图而言,矩阵的 row 和 col 表示的是 1…n 个点。

public class Graph {
/**
* 存储顶点集合
*/
private ArrayList<String> vertexList;
/**
* 存储图对应的邻接矩阵
*/
private int[][] edges;
/**
* 边的数目
*/
private int numOfEdges;
public Graph(int n) {
vertexList = new ArrayList<>(n);
edges = new int[n][n];
numOfEdges = 0;
}
/**
* 插入顶点
*/
public void insertVertex(String vertex){
vertexList.add(vertex);
}
/**
* 添加边
*/
public void insertEdge(int v1, int v2, int weight) {
edges[v1][v2] = weight;
edges[v2][v1] = weight;
numOfEdges++;
}
/**
* 返回结点的个数
*/
public int getNumOfVertex() {
return vertexList.size();
}
/**
* 返回边的数目
*/
public int getNumOfEdges() {
return numOfEdges;
}
/**
* 返回v1对应的下标
*/
public String getValueByIndex(int i) {
return vertexList.get(i);
}
/**
* 返回V1和V2的权值
*/
public int getWeight(int v1, int v2){
return edges[v1][v2];
}
/**
* 显示邻接矩阵
*/
public void showGraph() {
for (int[] link : edges) {
for (int link1 : link) {
System.out.printf("%8d", link1);
}
System.out.println();
}
}
}
5.3.2.2邻接表
邻接矩阵需要为每个顶点都分配 n 个边的空间,其实有很多边都是不存在,会造成空间的一定损失。
邻接表的实现只关心存在的边,不关心不存在的边。因此没有空间浪费,邻接表由数组 + 链表组成
若无向图中有n个顶点、e条边,则其邻接表需要n个头结点和2e个表结点
若有向图中有n个顶点、e条边,则其邻接表需要n个头结点和e个表结点
- 顶点 V i V_i Vi的出度为 V i V_i Vi所在单链表中结点个数
- 顶点 V i V_i Vi的入度需要遍历所有单链表计算指向该结点的个数

由上图可以发现使用这种数据结构找一个顶点的出度容易,但是找入度难,所以可以构建一个反邻接表,保存每个顶点的入度信息

- 顶点 V i V_i Vi的入度为 V i V_i Vi所在单链表中结点个数
- 顶点 V i V_i Vi的出度需要遍历所有单链表计算指向该结点的个数
5.3.2.3 邻接矩阵和邻接表的对比
1、联系:邻接表中每个链表对应于邻接矩阵中的一行,链表中结点个数等于一行中非零元素的个数
2、区别:
①对于任一确定的无向图,邻接矩阵是唯一的,但邻接表不唯一(链接次序和顶点编号无关)
②邻接矩阵空间复杂度是O(n²),而邻接表的空间复杂度为O(n+e)
因此,邻接矩阵多用于稠密图,邻接表多用于稀疏图
5.3.2.4 十字链表
十字链表是有向图的另一种链式存储结构。
我们也可以把它看成是将有向图的邻接表和逆邻接表结合起来形成的一种链表。
有向图中的每一条弧对应十字链表中的一个弧结点,同时有向图中的每个顶点在十字链表中对应有一个结点,叫做顶点结点。
十字链表顶点结点和弧结点的定义

- data为数据域
- firstin为第一个入度边
- firstout为第一个出度边

- tailvex为弧的尾顶点在图中的位置序号 ,用于标识这条弧是从哪个顶点出发的
- headvex表示弧的头顶点在图中的位置序号 ,即这条弧指向的顶点
- hlink是一个指针域,指向以该弧的头顶点为弧头的下一条弧结点,用于构建同头顶点的弧链表
- tlink也是一个指针域,指向以该弧的尾顶点为弧尾的下一条弧结点,用于构建同尾顶点的弧链表
【例】

5.3.2.5 邻接多重表
邻接多重表是无向图的一种链式存储结构,在邻接表中,容易求得顶点和边的各种信息,但求两个顶点之间是否存在边而执行删除边等操作时,需要分别在两个顶点的边表中遍历,效率较低。与十字链表类似,在邻接多重表中,每条边用一个结点表示,其结构如下

- ivex和jvex表示存放该边依附的两个顶点的编号
- ilink域指向依附于顶点ivex的下一条边
- jlink域指向依附于顶点jvex的下一条边
- info域存放该边的相关信息
每个顶点也用一个结点表示,如下图所示

- data存放该顶点的相关信息
- firstedge域指向依附于该顶点的第一条边

5.3.3 图的深度优先遍历(DFS)
- 深度优先遍历,从初始访问结点出发,初始访问结点可能有多个邻接结点,深度优先遍历的策略就是首先访问第一个邻接结点,然后再以这个被访问的邻接结点作为初始结点,访问它的第一个邻接结点,可以这样理解:每次都在访问完当前结点后首先访问当前结点的第一个邻接结点。
- 我们可以看到,这样的访问策略是优先往纵向挖掘深入,而不是对一个结点的所有邻接结点进行横向访问。
- 显然,深度优先搜索是一个递归的过程
/**
* 获取第一个邻接结点的下表
*/
public int getFirstNeighbor(int index){
for (int i = 0; i < vertexList.size(); i++) {
if (edges[index][i] > 0) {
return i;
}
}
return -1;
}
/**
* 根据前一个邻接结点的下标来获取下一个邻接结点
*/
public int getNextNeighbor(int v1, int v2){
for (int i = v2 + 1; i < vertexList.size(); i++) {
if (edges[v1][i] > 0) {
return i;
}
}
return -1;
}
/**
* dfs
*/
public void dfs(boolean[] isVisited, int i){
System.out.print(vertexList.get(i) + "->");
isVisited[i] = true;
// 获取第一个邻接结点的下标
int w = getFirstNeighbor(i);
while (w != -1) {
if (!isVisited[w]) {
dfs(isVisited, w);
}
w = getNextNeighbor(i, w);
}
}
public void dfs(){
//辅助方法,考虑到未被连接的孤立结点的情况
for (int i = 0; i < getNumOfVertex(); i++){
if (!isVisited[i]) {
dfs(isVisited, i);
}
}
}
dfs递归和回溯的过程并不好想,我将以下图为例说明整个dfs代码的过程

打印结果是A->B->D->C->E
首先我们调用dfs(isVisited,0),此时isVisited中所有元素都为标记为访问,从A结点开始访问
打印A结点,将A结点设置被访问过,此时isVisited数组情况为[true,false,false,false,false]
接着调用getFirstNeighbor(0),找到第一个邻接结点,返回其下标1;
然后调用dfs(isVisited,1)
再次从B结点开始访问,打印B结点,将B结点设置为被访问过,此时isVisited数组情况为[true,true,false,false,false],找到第一个邻接结点,返回其下标0,但是isVisited[0]已经被访问过了,于是调用getNextNeighbor(1,0),继续寻找下一个邻接结点,返回其下标3
然后调用dfs(isVisted,3)
从D结点开始访问,打印D结点,将D结点设置为被访问过,此时isVisited数组情况为[true,true,false,true,false],找到第一个邻接结点1,但是isVisited[1]已经被访问过了,于是寻找下一个邻接结点,找不到返回-1,开始回溯
回溯到B结点,找不到下一个邻接结点,返回-1,继续回溯回溯到A结点,A结点寻找下一个邻接结点,返回下标2
从C结点开始访问,打印C结点,将C结点设置为被访问过,此时isVisited数组情况为[true,true,true,true,false]
,寻找第一个邻接结点,返回下标4,
从E结点开始访问,打印E节点,将E结点设置为被访问过,此时isVisited数组情况为[true,true,true,true,true]
,寻找第一个邻接结点,已被访问过,寻找下一个邻接结点没找到返回-1开始回溯
回溯到C,没找到回溯到A,再也没有了,结束调用
5.3.4 图的广度优先遍历(BFS)
类似于一个分层搜索的过程,广度优先遍历需要使用一个队列以保持访问过的结点的顺序,以便按这个顺序访问这些结点的邻接结点
public void bfs(boolean[] isVisited, int i){
//u表示队列的头结点的下标,w表示邻接结点
int u, w;
//队列,记录结点访问的顺序
LinkedList<Integer> queue = new LinkedList<>();
System.out.println(vertexList.get(i) + "->");
isVisited[i] = true;
//将结点加入队列
queue.addLast(i);
while (!queue.isEmpty()) {
u = queue.removeFirst();
w = getFirstNeighbor(u);
while (w != -1) {
if (!isVisited[w]) {
System.out.println(vertexList.get(w) + "->");
isVisited[w] = true;
queue.addLast(w);
}
w = getNextNeighbor(u, w);
}
}
}
public void bfs(){
for (int i = 0; i < getNumOfVertex(); i++) {
if (!isVisited[i]) {
bfs(isVisited, i);
}
}
}
BFS相比DFS代码会更容易理解,借助了一个队列记录先后顺序
还是以DFS过程的图为例,首先访问A结点,打印A结点,将A结点下标入队列,接着出对列,u = A, 寻找A结点的第一个邻接结点B,打印B,B入队列,寻找A的下一个邻接结点C,C入队列;
找不到A的邻接结点后,B出队列,寻找B的第一个邻接结点D,打印D,D入队列;
找不到B的邻接结点后,C出队列,寻找C的第一个邻接结点E,打印E,E入队列;
找不到C的邻接结点后,D出队列;
找不到E的邻接结点后,E出队列;
5.3.5 最小生成树(MST)
生成树:所以顶点均由边连接在一起,但不存在回路的图

上图中甲图不是生成树,乙图是生成树
- 生成树是图的极小连通子图,去掉一条边则非连通
- 生成树的顶点个数和图的顶点个数相同
- 一个有n个顶点的连通图的生成树有n-1条边
- 在生成树中再加一条边必然形成回路
- 生成树中任意两个顶点间的路径是唯一的
构造图的生成树,可以通过DFS或者BFS进行构造,将遍历过的边作为生成树的边即可
那么什么是最小生成树,假如给这个无向图每个边一个权值(网),不同生成树边权值之和不同,在所有生成树中,使得各边权值之和最小的那颗生成树称为该网的最小生成树,也叫最小代价生成树

5.3.6 Prim算法
算法思想:
- 设 N=(V, E) 是连通网,TE 是 N 上最小生成树中边的集合。
- 初始令 U={ u 0 u_0 u0},( u 0 u_0 u0∈V),(TE={})。
- 在所有 u ∈U,v ∈ V-U的边 (u, v) ∈E 中,找一条代价最小的边 ( u 0 u_0 u0, v 0 v_0 v0)。
- 将 ( u 0 u_0 u0, v 0 v_0 v0) 并入集合 TE,同时 ( v 0 v_0 v0) 并入 U。
- 重复上述操作直至 (U=V) 为止,则 T=(V, TE)为 N 的最小生成树。
解释一下什么意思,就是有几个集合,V集合存储所有顶点,U集合存储在最小生成树中的顶点,V-U集合是所有不在最小生成树中的顶点,TE是最小生成树上边的集合,找一条从在最小生成树上的顶点到不在最小生成树上的顶点之间代价最小的一个边加入到TE集合,同时将选择的这个不在最小生成树上的顶点加入到U集合中,重复这个过程直到U=V
下图为一个具体示例

public int prim(int index){
//初始化v集合
for (int i = 0; i < getNumOfVertex(); i++){
v.add(i);
}
// 初始化U和V - U集合
for (int i = 0; i < getNumOfVertex(); i++) {
if (i == index) {
u.add(i);
} else {
vDiffU.add(i);
}
}
//从index顶点找一条代价最小的边
int sum = 0;
while (!vDiffU.isEmpty()) {
int min = Integer.MAX_VALUE;
int v1 = -1;
int v2 = -1;
//找最小权值边
for (int i = 0; i < u.size(); i++) {
for (int j = 0; j < vDiffU.size(); j++) {
//i和j为u和v的索引,u和v里面存储的才是真正的顶点索引
int weight = edges[u.get(i)][vDiffU.get(j)];
if (weight < min && weight != 0) {
min = weight;
v1 = i;
v2 = j;
}
}
}
if (v1 != -1 && v2 != -1) {
sum += min;
u.add(vDiffU.get(v2));
vDiffU.remove(v2);
}
if (v1 != -1 && v2 != -1) {
te[u.get(v1)][u.get(u.size() - 1)] = min;
te[u.get(u.size() - 1)][u.get(v1)] = min;
}
}
return sum;
}
5.3.7 Kruskal算法
- 设连通网 N = (V, E),令最小生成树初始状态为只有 n 个顶点而无边的非连通图 T=(V, { }),每个顶点自成一个连通分量。
- 在 E 中选取代价最小的边,若该边依附的顶点落在 T 中不同的连通分量上(即:不能形成环),则将此边加入到 T 中;否则,舍去此边,选取下一条代价最小的边。
- 依此类推,直至 T 中所有顶点都在同一连通分量上为止 。
通俗易懂地解释一下的话就是,在所有顶点中找权重最小的边,直到所有顶点连通

这里使用了并查集来判断添加边(v1,v2)是否会形成回路
/**
*查找操作
*/
private int find(int[] parent, int i) {
if (parent[i] == i) {
return i;
} else {
parent[i] = find(parent,parent[i]);
}
return parent[i];
}
/**
*合并操作
*/
private void union(int[] parent, int x, int y) {
int xset = find(parent, x);
int yset = find(parent, y);
parent[xset] = yset;
}
/**
*判断添加边 (v1, v2) 是否会形成回路
*/
public boolean willFormCycle(int v1, int v2) {
int[] parent = new int[getNumOfVertex()];
for (int i = 0; i < getNumOfVertex(); i++) {
parent[i] = i;
}
int x = find(parent, v1);
int y = find(parent, v2);
if (x == y) {
return true;
}
union(parent, x, y);
return false;
}
因为Kruskal会对边进行排序,而单一的一个边的二维数组不足以进行这个工作,我们创建一个Edge类
public class Edge implements Comparable<Edge>{
/**
* 权重
*/
public int weight;
/**
* 边的起点
*/
public int src;
/**
* 边的终点
*/
public int dest;
@Override
public int compareTo(Edge o) {
return this.weight - o.weight;
}
public Edge(int src, int dest, int weight) {
this.src = src;
this.dest = dest;
this.weight = weight;
}
}
public int kruskal() {
// 存储所有边的列表
List<Edge> allEdges = new ArrayList<>();
int numVertices = getNumOfVertex();
// 遍历邻接矩阵,将所有边添加到列表中
for (int i = 0; i < numVertices; i++) {
for (int j = i + 1; j < numVertices; j++) {
if (edges[i][j] != 0) {
allEdges.add(new Edge(i, j, edges[i][j]));
}
}
}
// 按边的权重对边列表进行排序
Collections.sort(allEdges);
// 存储最小生成树的边
List<Edge> mstEdges = new ArrayList<>();
// 初始化并查集的父数组
int[] parent = new int[numVertices];
for (int i = 0; i < numVertices; i++) {
parent[i] = i;
}
int mstWeight = 0;
int edgeCount = 0;
// 遍历排序后的边列表
for (Edge edge : allEdges) {
int src = edge.src;
int dest = edge.dest;
int weight = edge.weight;
int x = find(parent, src);
int y = find(parent, dest);
// 如果加入这条边不会形成回路
if (x != y) {
mstEdges.add(edge);
mstWeight += weight;
edgeCount++;
// 合并两个连通分量
union(parent, x, y);
}
// 当最小生成树的边数达到顶点数减 1 时,停止
if (edgeCount == numVertices - 1) {
break;
}
}
return mstWeight;
}
| 算法名 | 普里姆算法 | 克鲁斯卡尔算法 |
|---|---|---|
| 算法思想 | 选择点 | 选择边 |
| 时间复杂度 | (O(n^2))(n 为顶点数) | (O(e log e))(e 为边数) |
| 适应范围 | 稠密图 | 稀疏图 |
5.3.8 并查集
在上面的Kruskal算法中,使用到了并查集的这种数据结构,为了更容易的理解,在此处进行详细分析
/**
*查找操作
*/
private int find(int[] parent, int i) {
if (parent[i] == i) {
return i;
} else {
parent[i] = find(parent,parent[i]);
}
return parent[i];
}
/**
*合并操作
*/
private void union(int[] parent, int x, int y) {
int xset = find(parent, x);
int yset = find(parent, y);
parent[xset] = yset;
}
其中find方法是查找操作,union方法是合并操作,parent[]数组是祖宗结点
初始化时将所有结点的祖宗结点设置为自己

首先看find操作,执行find操作就能够找到这个结点的祖宗结点,parent[i] = find(parent,parent[i])这一行代码实现了路径压缩
private int find(int[] parent, int i) {
if (parent[i] == i) {
return i;
} else {
parent[i] = find(parent,parent[i]);
}
return parent[i];
}
什么是路径压缩,我们先来看个未实现路径压缩的find操作
private int find(int[] parent, int i) {
if (parent[i] == i) {
return i;
} else {
return find(parent,parent[i]);
}
}

可以看到随着union的次数增多,查找的次数也会增多,但是如果使用路径压缩的find操作后,会变成

5.3.5 最短路径问题
在有向网中A点(源点)到达B点(终点)的多条路径中,寻找一条各边权值之和最小的路径,即最短路径
第一类问题 两点间最短路径
第二类问题 某源点到其他各点最短路径
5.3.6 Dijistra 算法
Dijistra算法用于解决两点间最短路径问题
1、初始化:先找出从源点 v₀到各终点 vₖ的直达路径(v₀,vₖ),即通过一条弧到达的路径。
2、选择:从这些路径中找出一条长度最短的路径(v₀,u)。
3、更新:然后对其余各条路径进行适当调整:若在图中存在弧(u,vₖ),且(v₀,u)+(u,vₖ)<(v₀,vₖ),则以路径(v₀,u,vₖ)代替(v₀,vₖ)。在调整后的各条路径中,再找长度最短的路径,依此类推。

public static int dijkstra() {
//将距离初始化为最大值
for(int i = 0; i <= n; i++) {
distance[i] = MAX_NUM;
}
distance[1] = 0;
for(int i = 0; i < n - 1; i++) {
int t = -1;
//寻找与永久集合中权值最小的结点
for(int j = 1; j <= n; j++) {
if(used[j] == 0 && (t == -1 || distance[t] > distance[j])) {
t = j;
}
}
//根据t计算从初结点到后序结点和从t结点到后序结点那个值更小
for(int j = 1; j <= n; j++) {
distance[j] = Math.min(distance[j], distance[t] + graph[t][j]);
}
//标记t为用过结点
used[t] = 1;
}
if(distance[n] == MAX_NUM) {
return -1;
} else {
return distance[n];
}
}
5.3.7 Floyd算法
算法原理
Floyd 算法通过一个三层循环来逐步更新图中各顶点对之间的最短路径。设图中有 n 个顶点,用邻接矩阵 (graph[i][j]) 表示顶点 i 到顶点 j 的边权(若 i 和 j 之间没有边,则权值为无穷大)。
定义一个三维数组 (dist[k][i][j]) 表示从顶点 i 到顶点 j 经过编号不超过 k 的顶点的最短路径长度(也可简化为二维数组 (dist[i][j]) ,在每次迭代中直接更新)。
迭代过程分析
- 初始状态:在算法开始时,(dist[0][i][j]=graph[i][j]) ,即不经过任何中间顶点时,顶点 i 到顶点 j 的距离就是它们之间的边权。这是符合实际情况的,因为没有中间节点参与时,两点间距离就是直接相连的边权(若不相连则为无穷大)。
- 第 k 次迭代:在第 k 次迭代中,对于每一对顶点 ((i, j)) ,考虑是否经过顶点 k 会使 i 到 j 的路径更短。即比较 (dist[k - 1][i][j]) (不经过顶点 k 时 i 到 j 的最短路径)和 (dist[k - 1][i][k]+dist[k - 1][k][j]) (经过顶点 k ,从 i 到 k 再从 k 到 j 的路径长度 )的大小。取较小值作为 (dist[k][i][j]) 。
这种比较是合理的,因为如果存在一条从 i 到 j 经过顶点 k 的更短路径,那么必然是由从 i 到 k 的最短路径和从 k 到 j 的最短路径组成。而在第 k 次迭代时,我们已经知道了不经过顶点 k (即经过编号小于 k 的顶点子集 )时从 i 到 k 和从 k 到 j 的最短路径(分别为 (dist[k - 1][i][k]) 和 (dist[k - 1][k][j]) ) 。通过这种比较和更新,我们能得到经过编号不超过 k 的顶点时 i 到 j 的最短路径。
【例题】
给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环,边权可能为负数。
再给定 k 个询问,每个询问包含两个整数 x 和 y,表示查询从点 x 到点 y 的最短距离,如果路径不存在,则输出 impossible。
数据保证图中不存在负权回路。
输入格式
第一行包含三个整数 n,m,k。
接下来 m 行,每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。
接下来 k 行,每行包含两个整数 x,y,表示询问点 xx 到点 y 的最短距离。
输出格式
共kk 行,每行输出一个整数,表示询问的结果,若询问两点间不存在路径,则输出 impossible。
数据范围
1≤n≤200,
1≤k≤n2
1≤m≤20000,
图中涉及边长绝对值均不超过 10000。
输入样例:
3 3 2
1 2 1
2 3 2
1 3 1
2 1
1 3
输出样例:
impossible
1
import java.io.*;
import java.util.*;
public class Main {
static final int MAX_NUM = 2147483647 / 2;
static final int N = 210;
static int n, m;
static int[][] graph = new int[N][N];
public static void main(String[] args) throws Exception {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String[] row1 = br.readLine().split(" ");
n = Integer.parseInt(row1[0]);
m = Integer.parseInt(row1[1]);
int q = Integer.parseInt(row1[2]);
//对邻接矩阵进行初始化
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= n; j++) {
if(i == j) {
graph[i][j] = 0;
} else {
graph[i][j] = MAX_NUM;
}
}
}
for(int i = 1; i <= m; i++) {
String[] data = br.readLine().split(" ");
int a = Integer.parseInt(data[0]);
int b = Integer.parseInt(data[1]);
int c = Integer.parseInt(data[2]);
graph[a][b] = Math.min(graph[a][b], c);
}
floyd();
//q次询问
while(q-- > 0) {
String[] fromTo = br.readLine().split(" ");
int from = Integer.parseInt(fromTo[0]);
int to = Integer.parseInt(fromTo[1]);
if(graph[from][to] > MAX_NUM / 2) {
System.out.println("impossible");
} else {
System.out.println(graph[from][to]);
}
}
}
static void floyd() {
for(int k = 1; k <= n; k++) {
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= n; j++) {
graph[i][j] = Math.min(graph[i][j], graph[i][k] + graph[k][j]);
}
}
}
}
}
5.3.8 KMP算法 字符串匹配
假设原字符串长度是n,模式串(用于匹配的字符串)长度是m
朴素算法
如果按照最朴素的思维来实现字符串匹配算法,那么时间复杂度会是O(mn)的
我们来看最朴素的算法
for(int i = 1; i <= n; i++){
boolen flag = true;
for(int j = 1; j <= m; j++){
if(p[j] != s[i + j - 1]){
flag = false;
break;
}
}
}
第一重循环是遍历原串s[N] 第二重循环遍历模式串p[M]
可能会朋友问,为什么是s[i + j - 1],因为在每次与模式串字符比较的时候,i指向的位置是不变的,为了找到原串的相应字符,需要用i加上一个偏移量
假设外层循环到第二次,此时i的值是2,内层循环j依旧从1开始
第一次是s[2]和p[1]比较,此时偏移量是1 - 1 = 0

第二次是s[3]和p[2]比较,此时偏移量是2 - 1 = 1

第三次是s[4]和p[3]比较,此时偏移量是3 - 1 = 2

可见这种朴素算法时间效率是非常低的,由此引出另一种算法,KMP算法
一个人能走多远不在于他在顺境时能走多快,而在于他在逆境时多久能找到曾经的自己。
上面的朴素算法中明显有很多不必要的比较,那么我们是否可以借助“某个力量”让我们规避掉这些不必要的比较
假设我现在有这样一个数组int[] next,这个数组记录的是当模式串字符和原串字符比较不相同时要回退的位置
我们先不纠结这个数组是如何创建的,我么先感受一下用上了这个数组会怎样

在kmp中数组下标都是从1开始的,这一点很重要,不然后续可能会看不明白
当我们比较到s[6]和p[5 + 1]不相等时,我们根据next数组的位置,将模式串的指针回退,回退到2的位置,此时再次比较s[6]和p[2 + 1]是否相等,如果相等就继续往后比较,如果不相等,就继续回退直到退到0为止

实现代码如下
for (int i = 1, j = 0; i <= n; i++) {
while (j > 0 && p.charAt(j + 1) != s.charAt(i)) {
//回退
j = next[j];
}
if (p.charAt(j + 1) == s.charAt(i)) {
//模式串比较成功的字符个数
j++;
}
if (j == m) {
//打印在主串中的位置
System.out.print((i - m + 1) + " ");
j = ne[j];
}
}
可以看到整个算法的核心是next数组应该如何创建,next数组中存的是在该字符之前部分字符串中最大前缀和后缀相等的长度
比如下图中我们想要求得d对应的next值,我们就看d前面部分的字符串中前缀和后缀相等部分的长度,然后最大长度加一就是应该填入的值

到了这一步一定有朋友想搞明白这是什么原理,为什么要按照这种方式求next值
我们简单证明一下

假如这是13个数,我们正在求第13个数的next值
如果第12个数的next值是6,也就是说前11个字符中最大相等前后缀的长度是5,在图中表现的是蓝框中的部分是相等的

如果6和12字符相等,那么13的next值就是6 + 1 = 7
如果6和12字符不相等,那么我们就继续看6的next值,假如6的next值是3,那么这些红框中的字符串一定相等

如果3和12字符相等,那么13的next值就是3 + 1 = 4
如此操作一直重复下去
代码如下
// 计算 next 数组
int i = 1, j = 0;
while (i < n){
if (j == 0 || p.charAt(i) == p.charAt(j)) {
next[++i] = ++j;
} else {
j = next[j];
}
}
KMP算法的时间复杂度是O(n+m)
5万+

被折叠的 条评论
为什么被折叠?



