算法与数据结构

概述

数据结构和算法的重要性

  1. 算法是程序的灵魂,优秀的程序可以在海量数据计算时,依然保持高速计算
  2. 一般来讲 程序会使用了内存计算框架(比如Spark)和缓存技术(比如Redis等)来优化程序,再深入的思考一下,这些计算框架和缓存技术,它的核 心功能是哪个部分呢?
  3. 拿实际工作经历来说, 在Unix下开发服务器程序,功能是要支持上千万人同时在线,在上线前,做内测,一切OK,可上线后,服务器就支撑不住了,公司的CTO对代码进行优化,再次上线,坚如磐石。你就能感受到程序是有灵魂的,就是算法。
  4. 目前程序员面试的门槛越来越高,很多一线IT公司(大厂),都会有数据结构和算法面试题(负责的告诉你,肯定有的)
  5. 如果你不想永远都是代码工人,那就花时间来研究下数据结构和算法

数据结构和算法的关系

  1. 数据data结构(structure)是一门研究组织数据方式的学科,有了编程语言也就有了数据结构学好数据结构可以编写出更加漂亮,更加有效率的代码。
  2. 要学习好数据结构就要多多考虑如何将生活中遇到的问题,用程序去实现解决.
  3. 程序=数据结构+算法
  4. 数据结构是算法的基础,换言之,想要学好算法,需要把数据结构学到位。

线性结构

  1. 线性结构作为最常用的数据结构,其特点是数据元素之间存在一对一的线性关系。
  2. 线性结构有两种不同的存储结构,即**顺序存储结构(数组)链式存储结构(链表)**。
    • 顺序存储的线性表称为顺序表,顺序表中的存储元素是连续的。
    • 链式存储的线性表称为链表,链表中的存储元素不一定是连续的,元素节点中存放数据元素以及相邻元素的地址信息。
  3. 线性结构常见的有:数组、队列、链表和栈

非线性结构

非线性结构包括:二维数组,多维数组,广义表,树结构图结构

稀疏数组和队列

稀疏矩阵

概述

当一个数组中大部分元素为0,或者为同一个值的数组时,可以使用稀疏数组来保存该数组。

稀疏数组的处理方法是:

  1. 记录数组一共有几行几列,有多少个不同的值
  2. 把具有不同值的元素的行列及值记录在一个小规模的数组中。从而缩小程序的规模

image-20211207224323105

实例

  1. 使用稀疏数组,用于保留二维数组(棋盘、地图……)
  2. 把稀疏数组存盘,并且可以重新恢复原来的二维数组

思路分析

image-20211207225716880

二维数组转稀疏数组

  1. 遍历原始的二维数组,得到有效数据的个数sum
  2. 根据sum就可以建立稀疏数组sparseArr int [sum+1] [3]
  3. 将二维数组的有效数据存入到稀疏数组

稀疏数组转二维数组

  1. 先读取稀疏数组的第一行,根据第一行的数据,创建原始的二维数组,比如上面的 chessArr2 = int [11] [11]

  2. 在读取稀疏数组后几行的数据,并赋给 原始的二维数组即可.

代码实现

public class SparseArray {

public static void main(String[] args) throws IOException {

String fileName = "E:\\Program\\AlgorithmStudy\\src\\com\\algorithm\\_1_1sparsearray\\sparseArray.txt";


//创建一个原始的二维数组 11*11
//0表示没有棋子 1表示黑子 2表示白子
int chessArr1[][] = new int[11][11];
chessArr1[1][2] = 1;
chessArr1[2][3] = 2;
//输出原始的二维数组
System.out.println("原始的二维数组:");
/*foreach方式遍历数组
for(数据类型 变量名:数组名)
{}
首先遍历chessArr1[],得到的都是一些地址值,然后再次遍历chessArr1[][],这才能得到所存储的值
*/
for (int[] arr : chessArr1) {
for (int data : arr) {
System.out.printf("%d\t", data);
}
System.out.println();
}
System.out.println();

//将二维数组转稀疏数组
//1.先遍历二维数组得到非0数据的个数
int sum = 0;
for (int i = 0; i < chessArr1.length; i++) {
for (int j = 0; j < chessArr1[i].length; j++) {
if (chessArr1[i][j] != 0) {
sum++;
}
}
}
System.out.println("稀疏数组中非零元素的个数:" + sum);

//2.创建对应的稀疏数组
int sparseArr[][] = new int[sum + 1][3];
//给稀疏数组赋值
sparseArr[0][0] = 11;
sparseArr[0][1] = 11;
sparseArr[0][2] = sum;
//遍历二维数组,将非0的值放入稀疏数组
int count = 0;//计数器用于记录第几个非0数据
for (int i = 0; i < 11; i++) {
for (int j = 0; j < 11; j++) {
if (chessArr1[i][j] != 0) {
count++;
sparseArr[count][0] = i;
sparseArr[count][1] = j;
sparseArr[count][2] = chessArr1[i][j];
}

}
}
//输出稀疏数组
System.out.println();
System.out.println("得到的稀疏数组:");
for (int i = 0; i < sparseArr.length; i++) {
for (int j = 0; j < 3; j++) {
System.out.printf("%d\t", sparseArr[i][j]);
}
System.out.println();
}
System.out.println();

//加入IO流
System.out.println("保存稀疏数组到本地");
//保存到本地
SaveToLocal(fileName, sparseArr);
System.out.println();

System.out.println("正在读取本地稀疏数组文件");
int[][] readArray = ReadByLocal(fileName);
printArray(readArray);
System.out.println();

//读取到的稀疏数组
System.out.println("读取到的稀疏数组:");
for (int i = 0; i < readArray.length; i++) {
for (int j = 0; j < 3; j++) {
System.out.printf("%d\t", readArray[i][j]);
}
System.out.println();
}
System.out.println();

//将稀疏数组==>恢复成 原始数组
/*
1.先读取稀疏数组的第一行,根据第一行的数据,创建原始的二维数组
2.在读取稀疏数组后几行的数据,并赋值原始数据即可
*/
int chessArr2[][] = new int[readArray[0][0]][readArray[0][1]];
for (int i = 1; i < sparseArr.length; i++) {
chessArr2[readArray[i][0]][readArray[i][1]] = readArray[i][2];
}

//输出恢复后的二维数组
for (int[] arr : chessArr2) {
for (int data : arr) {
System.out.printf("%d\t", data);
}
System.out.println();
}
}

//将稀疏数组保存到本地
private static void SaveToLocal(String fileName, int[][] sparseArray) throws IOException {
//输出流
BufferedWriter bw = new BufferedWriter(new FileWriter(new File(fileName), false));
StringBuffer sb = new StringBuffer();
//拼接字符
for (int[] row : sparseArray) {
for (int i : row) {
sb.append(i + "\t");
}
sb.append("\r\n");
}
bw.write(sb.toString());
bw.flush();
bw.close();
}

//读取本地稀疏数组文件
private static int[][] ReadByLocal(String fileName) throws IOException {
//定义行列
int row = 0, col = 0;
List<int[]> readList = new ArrayList<>();
//文件
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(new File(fileName))));
String line = null;
while ((line = bufferedReader.readLine()) != null) {
//将读取到的String转换为int数组存数在ArrayList中
int[] array = Arrays.stream(line.split("\\t")).mapToInt(Integer::parseInt).toArray();
System.out.println("读取到的稀疏数组:" + Arrays.toString(array));
//用List保存
readList.add(array);
//列
col = array.length;
}
//行
row = readList.size();
bufferedReader.close();

// ArrayList内部一维数组转化为二维数组
int[][] readArray = new int[row][col];
for (int i = 0; i < readArray.length; i++) {
readArray[i] = readList.get(i);
}
return readArray;
}

}

运行结果

image-20211208130652891

image-20211208130801446

队列

概述

  1. 队列是一个有序列表,可以用数组或是链表来实现。

  2. 遵循先入先出的原则。即:先存入队列的数据,要先取出后存入的要后取出

  3. 示意图: (使用数组模拟队列示意图)

    • rear表示队尾
    • front表示队首
    • 存入时从队尾加入数据,取出时从队首取出数据

    image-20211208132539487

实例1

利用数组模拟队列

思路分析

队列本身是有序列表,若使用数组的结构来存储队列的数据,则队列数组的声明如下图,其中maxSize是该队列的最大容量

因为队列的输出、输入是分别从前后端来处理,因此需要两个变量front及rear分别记录队列前后端的下标front会随着数据输出而改变,而rear则是随着数据输入而改变,如图所示:

image-20211208131212948

  • 当我们将数据存入队列时称为”addQueue”,addQueue的处理需要有两个步骤:
    1. 将尾指针往后移:rear+1,当front==rear[空]
    2. 若尾指针rear小于队列的最大下标maxSize-1, 则将数据存入rear所指的数组元素中,否则无法存入数据。(**rear==maxSize-1[队列满]**)

代码实现

public class ArrayQueueDemo {
public static void main(String[] args) {
//测试,创建一个队列
ArrayQueue queue = new ArrayQueue(3);
char key = ' '; //接受用户输入
Scanner scanner = new Scanner(System.in);
boolean loop = true;
//输出一个菜单
while (loop){
System.out.println("show(s):显示队列");
System.out.println("exit(e):退出程序");
System.out.println("add(a):添加数据到队列");
System.out.println("get(g):从队列取出数据");
System.out.println("head(h):查看队列头的数据");
key = scanner.next().charAt(0);//接收一个字符
switch (key){
case 's':
queue.showQueue();
break;
case 'a':
System.out.println("输入一个数据");
int value = scanner.nextInt();
queue.addQueue(value);
break;
case 'g'://取出数据
try{
int res =queue.getQueue();
System.out.println("取出数据是"+res);
}catch (Exception e){
System.out.println(e.getMessage());
}
break;
case 'h'://查看队列头的数据
try{
int res =queue.headQueue();
System.out.println("队列头的数据是"+res);
}catch (Exception e){
System.out.println(e.getMessage());
}
break;
case 'e'://退出
scanner.close();
loop = false;
break;
default:
break;
}
}
System.out.println("程序退出");

}
}

//使用数组模拟队列-编写一个ArrayQueue类
class ArrayQueue {
private int maxSize;//表示数组的最大容量
private int front;//队列头
private int rear;//队列尾
private int[] arr;//该数据用于存放数据,模拟队列

//创建队列的构造器
public ArrayQueue(int arrMaxSize) {
maxSize = arrMaxSize;
arr = new int[maxSize];
front = -1;//指向队列首,分析出front是指向队列头的前一个位置
rear = -1;//指向队列尾,指向队列尾的数据
}

//判断队列是否满
public boolean isFull() {
return rear == maxSize - 1;
}

//判断队列是否为空
public boolean isEmpty() {
return rear == front;
}

//添加数据到队列
public void addQueue(int n) {
//判断队列是否满
if (isFull()) {
System.out.println("队列满,不能加入数据");
return;
}
rear++;//让rear后移
arr[rear] = n;
System.out.println("添加成功");
}

//取出队列,获取队列的数据
public int getQueue(){
//判断是否是空
if (isEmpty()) {
//通过抛出异常
throw new RuntimeException("队列空,不能取数据");
}
front++;//font后移
System.out.println("已取出");
return arr[front];
}

//显示队列的所有数据
public void showQueue(){
//遍历
if(isEmpty()){
System.out.println("队列空,没有数据");
return;
}
for (int i = 0;i < arr.length;i++){
System.out.println("队列元素为:");
System.out.println("arr["+i+"]="+arr[i]);
}
}

//显示队列的头数据,注意不是取数据
public int headQueue(){
//判断
if(isEmpty()){
throw new RuntimeException("队列空,没有数据");
}
return arr[front+1];
}
}

存在问题

  1. 目前的队列使用一次就不能能再使用了
  2. 解决方案:将此队列改进为环形队列

实例2

对前面的数组模拟队列的优化,充分利用数组.。因此将数组看做是一个环形的。(通过取模的方式来实现即可)

思路分析

  1. 尾索引的下一个为头索引时表示队列满,即将队列容量空出一个作为约定,这个在做判断队列满的时候需要注意(rear+1)%maxSize==front[满]
  2. rear=front[空]

思路调整

  1. front 变量的含义做一个调整:front 就指向队列的第一个元素,也就是说 arr[front] 就是队列的第一个元素 front 的初始值 = 0

  2. rear变量的含义做一个调整:rear 指向队列的最后一个元素的后一个位置。因为希望空出一个空间做为约定。rear 的初始值 = 0

  3. 队列满时的条件:(rear+1)% maxSize==front【满】

  4. 队列为空的条件: rear == front【空】

  5. 当我们这样分析, 队列中有效的数据的个数 ==(rear + maxSize - front) % maxSize==

  6. 我们就可以在原来的队列上修改得到,一个环形队列

代码实现

public class CircleArrayQueueDemo {

public static void main(String[] args) {
//测试环形队列
System.out.println("测试数组模拟环形队列的案例~~~");
CircleArray queue = new CircleArray(4);//设置4,但是阵列存放数据最大为3,其中一个只是预留空间
char key = ' ';//接受用户输入
Scanner scanner = new Scanner(System.in);
boolean loop = true;
//输出一个菜单
while (loop) {
System.out.println("s(show):显示队列");
System.out.println("e(exit):退出程序");
System.out.println("a(add):添加数据到队列");
System.out.println("g(get):从队列取出数据");
System.out.println("h(head):查看队列头的数据");
key = scanner.next().charAt(0);//接收一个字符
switch (key) {
case 's':
queue.showQueue();
break;
case 'a':
System.out.println("输入一个数据");
int value = scanner.nextInt();
queue.addQueue(value);
break;
case 'g'://取出数据
try {
int res = queue.getQueue();
System.out.println("取出数据是" + res);
} catch (Exception e) {
System.out.println(e.getMessage());
}
break;
case 'h'://查看队列头的数据
try {
int res = queue.headQueue();
System.out.println("队列头的数据是" + res);
} catch (Exception e) {
System.out.println(e.getMessage());
}
break;
case 'e'://退出
scanner.close();
loop = false;
break;
default:
break;
}
}
System.out.println("程序退出");
}
}

class CircleArray {
private int maxSize;//表示数组的最大容量
private int front;//front指向数组索引为0的第一个元素
private int rear;//rear指向最后一个数据的后一个位置
private int[] arr;//该数据用于存放数据,模拟队列

//创建队列的构造器
public CircleArray(int arrMaxSize) {
maxSize = arrMaxSize;
arr = new int[maxSize];
}

//判断队列是否满
public boolean isFull() {
return (rear + 1) % maxSize == front;
}

//判断队列是否为空
public boolean isEmpty() {
return rear == front;
}

//添加数据到队列
public void addQueue(int n) {
//判断队列是否满
if (isFull()) {
System.out.println("队列满,不能加入数据");
return;
}
//直接将数据加入
arr[rear] = n;
//将rear后移这里必须考虑取余
rear = (rear + 1) % maxSize;
}

//取出队列,获取队列的数据
public int getQueue() {
//判断是否是空
if (isEmpty()) {
//通过抛出异常
throw new RuntimeException("队列空,不能取数据");
}
//这里需要分析front是指向队列的第一个元素
//1.先把front对应的值保存到一个临时变量中
//2.将front后移,考虑取余
//3.将临时变量返回
int value = arr[front];
front = (front + 1) % maxSize;
return value;
}

//显示队列的所有数据
public void showQueue() {
//遍历
if (isEmpty()) {
System.out.println("队列空,没有数据");
return;
}
//思路:从front开始遍历,遍历多少个元素
for (int i = front; i < front + size(); i++) {
System.out.println("arr[" + (i % maxSize) + "]=" + arr[i % maxSize]);
}
}

//求出当前阵列有效数组的个数
public int size() {
return (rear + maxSize - front) % maxSize;
}

//显示队列的头数据,注意不是取数据
public int headQueue() {
//判断
if (isEmpty()) {
throw new RuntimeException("队列空,没有数据");
}
return arr[front];
}

}

链表

概述

链表是有序的列表,但是它在内存中是存储如下

image-20211208184127950

小结上图:

  1. 链表是以节点的方式来存储,是链式存储
  2. 每个节点包含 data 域, next 域:指向下一个节点。
  3. 如图:发现链表的各个节点不一定是连续存储
  4. 链表分带头节点的链表没有头节点的链表,根据实际的需求来确定。
  • 单链表(带头结点) 逻辑结构示意图如下

image-20211208184147853

单向链表

使用带 head 头的单向链表实现——水浒英雄排行榜管理完成对英雄人物的增删改查操作, 注: 删除和修改

实例1

思路分析

1、第一种方法在添加英雄时,直接添加到链表的尾部思路分析示意图:

image-20211208191751625

//思路,当不考虑编号顺序时
//1.找到当前链表的最后节点
//2.将最后这个节点的next指向新的节点
public void add(HeroNode heroNode) {
//因为head节点不能动,因此我们需要一个辅助变量temp
HeroNode temp = head;
//遍历链表,找到最后
while (true) {
//找到链表的最后 1
if (temp.next == null) {
break;
}
//如果没有找到,将temp后移
temp = temp.next;
}
//当退出循环时,temp就指向了链表的最后
//将最后的这个节点的next 指向新的节点 2
temp.next = heroNode;
}

2、第二种方式在添加英雄时,根据排名将英雄插入到指定位置(如果有这个排名,则添加失败,并给出提示) 思路的分析示意图:

image-20211208193312599

// 第二种方式在添加英雄时,根据排名将英雄插入到指定位置
//在内存中将数据排序
//(如果有这个排名,则添加失败,并给出提示)
//要将新添加的heroNode存储到temp.next的位置,所以要和原先的temp.next作比较。将heroNode.next指向原先的temp.next.no,再将temp.next指向新的heroNode
public void addByOrder(HeroNode heroNode) {
//因为头节点不能动,因此我们仍然通过一个辅助指针(变量)来帮助找到添加的位置
//因为单链表,找到的temp是位于添加位置的前一个节点,否则插入不了
HeroNode temp = head;
boolean flag = false;//flag标志添加的编号是否存在,默认为false
while (true) {
if (temp.next == null) {//说明temp已经在链表的最后
break;
}
if (temp.next.no > heroNode.no) {//位置找到,就在temp的后面
break;
} else if (temp.next.no == heroNode.no) {//说明希望添加的heroNode的编号已然存在
flag = true;
break;
}
temp = temp.next;//后移,遍历当前链表
}
//判断flag的值
if (flag) {//不能添加,说明编号存在
System.out.println("准备插入的英雄的编号"+heroNode.no+"已经存在了,不能加入");
} else {
//插入到链表中,temp的后面(不能反!!!)
//将heroNode.next指向原先的temp.next
heroNode.next = temp.next;
//temp.next指向新的heroNode
temp.next = heroNode;
}

}

3、修改节点功能

思路:

  1. 先找到该节点,通过遍历

  2. ```
    temp.name = newHeroNode.name;
    temp.nickname= newHeroNode.nickname;


    ```java
    //修改节点的信息,根据no编号来修改,即no编号不能改
    //说明
    //1.根据newHeroNode的no来修改即可
    public void update(HeroNode newHeroNode) {
    //判断是否为空
    if (head.next == null) {//链表的有效节点并不包括头指针,头指针通常用head定义,他只是指向第一个有效结点;无论链表是否为空,头指针都不为空;头指针并不存放有效数据!
    System.out.println("链表为空");
    return;
    }
    //找到需要修改的节点,根据no编号
    //先定义一个辅助变量
    HeroNode temp = head.next;
    boolean flag = false;//表示是否找到节点
    while (true) {
    if (temp == null) {
    break;//已经遍历完链表
    }
    if (temp.no == newHeroNode.no) {
    //找到
    flag = true;
    break;
    }
    temp = temp.next;
    }
    //根据flag是否找到要修改的节点
    if (flag) {
    temp.name = newHeroNode.name;
    temp.nickname = newHeroNode.nickname;
    } else {
    System.out.printf("没有找到编号%d的节点,不能修改\n", newHeroNode.no);
    }
    }

4、删除节点

思路分析的示意图:

image-20211208193404692

//删除节点
//思路
//1.head不能动,因此我们需要一个temp辅助节点找到待删除节点的前一个节点
//2.说明我们在比较时,是temp.next.no和需要删除的节点的no的比较
public void del(int no) {
HeroNode temp = head;
boolean flag = false;//标志是否找到待删除节点的
while (true) {
if (temp.next == null) {
break;
}
if (temp.next.no == no) {
//找到的待删除节点的前一个节点temp
flag = true;
break;
}
temp = temp.next;//temp后移,遍历
}
//判断flag
if (flag) {//找到
//可以删除
//让当前的下一个节点指向当前的下一个的下一个
temp.next = temp.next.next;
} else {
System.out.printf("要删除的%d 节点不存在\n", no);
}
}

代码实现

public class SingleLinkedListDemo {

public static void main(String[] args) {

//进项测试
//先创建节点
HeroNode hero1 = new HeroNode(1, "宋江", "及时雨");
HeroNode hero2 = new HeroNode(2, "卢俊义", "玉麒麟");
HeroNode hero3 = new HeroNode(3, "吴用", "智多星");
HeroNode hero4 = new HeroNode(4, "林冲", "豹子头");
//创建链表
SingleLinkedList singleLinkedList = new SingleLinkedList();
//加入
//singleLinkedList.add(hero1);
//singleLinkedList.add(hero4);
//singleLinkedList.add(hero3);
// singleLinkedList.add(hero2);
//加入按照编号的顺序
singleLinkedList.addByOrder(hero1);
singleLinkedList.addByOrder(hero4);
singleLinkedList.addByOrder(hero2);
singleLinkedList.addByOrder(hero3);
//显示一把
singleLinkedList.list();
//测试修改节点的代码
HeroNode newHeroNode = new HeroNode(4, "冲冲", "豹豹头");
singleLinkedList.update(newHeroNode);
System.out.println("修改后的链表情况:");
singleLinkedList.list();
//删除一个节点
singleLinkedList.del(1);
singleLinkedList.del(4);
singleLinkedList.del(2);
singleLinkedList.del(3);
System.out.println("删除后的链表情况:");
singleLinkedList.list();

}

}

//定义SingleLinkedList管理我们的英雄
class SingleLinkedList {
//先初始化一个头节点,头节点不能动,不存放具体的数据
private HeroNode head = new HeroNode(0, "", "");

//添加节点到单向链表
//思路,当不考虑编号顺序时
//1.找到当前链表的最后节点
//2.将最后这个节点的next指向新的节点
public void add(HeroNode heroNode) {
//因为head节点不能动,因此我们需要一个辅助变量temp
HeroNode temp = head;
//遍历链表,找到最后
while (true) {
//找到链表的最后 1
if (temp.next == null) {
break;
}
//如果没有找到,将temp后移
temp = temp.next;
}
//当退出循环时,temp就指向了链表的最后
//将最后的这个节点的next 指向新的节点 2
temp.next = heroNode;
}

// 第二种方式在添加英雄时,根据排名将英雄插入到指定位置
//在内存中将数据排序
//(如果有这个排名,则添加失败,并给出提示)
//要将新添加的heroNode存储到temp.next的位置,所以要和原先的temp.next作比较。将heroNode.next指向原先的temp.next.no,再将temp.next指向新的heroNode
public void addByOrder(HeroNode heroNode) {
//因为头节点不能动,因此我们仍然通过一个辅助指针(变量)来帮助找到添加的位置
//因为单链表,找到的temp是位于添加位置的前一个节点,否则插入不了
HeroNode temp = head;
boolean flag = false;//flag标志添加的编号是否存在,默认为false
while (true) {
if (temp.next == null) {//说明temp已经在链表的最后
break;
}
if (temp.next.no > heroNode.no) {//位置找到,就在temp的后面
break;
} else if (temp.next.no == heroNode.no) {//说明希望添加的heroNode的编号已然存在
flag = true;
break;
}
temp = temp.next;//后移,遍历当前链表
}
//判断flag的值
if (flag) {//不能添加,说明编号存在
System.out.println("准备插入的英雄的编号"+heroNode.no+"已经存在了,不能加入");
} else {
//插入到链表中,temp的后面(不能反!!!)
//将heroNode.next指向原先的temp.next
heroNode.next = temp.next;
//temp.next指向新的heroNode
temp.next = heroNode;
}

}

//修改节点的信息,根据no编号来修改,即no编号不能改
//说明
//1.根据newHeroNode的no来修改即可
public void update(HeroNode newHeroNode) {
//判断是否为空
if (head.next == null) {//链表的有效节点并不包括头指针,头指针通常用head定义,他只是指向第一个有效结点;无论链表是否为空,头指针都不为空;头指针并不存放有效数据!
System.out.println("链表为空");
return;
}
//找到需要修改的节点,根据no编号
//先定义一个辅助变量
HeroNode temp = head.next;
boolean flag = false;//表示是否找到节点
while (true) {
if (temp == null) {
break;//已经遍历完链表
}
if (temp.no == newHeroNode.no) {
//找到
flag = true;
break;
}
temp = temp.next;
}
//根据flag是否找到要修改的节点
if (flag) {
temp.name = newHeroNode.name;
temp.nickname = newHeroNode.nickname;
} else {
System.out.printf("没有找到编号%d的节点,不能修改\n", newHeroNode.no);
}
}

//删除节点
//思路
//1.head不能动,因此我们需要一个temp辅助节点找到待删除节点的前一个节点
//2.说明我们在比较时,是temp.next.no和需要删除的节点的no的比较
public void del(int no) {
HeroNode temp = head;
boolean flag = false;//标志是否找到待删除节点的
while (true) {
if (temp.next == null) {
break;
}
if (temp.next.no == no) {
//找到的待删除节点的前一个节点temp
flag = true;
break;
}
temp = temp.next;//temp后移,遍历
}
//判断flag
if (flag) {//找到
//可以删除
//让当前的下一个节点指向当前的下一个的下一个
temp.next = temp.next.next;
} else {
System.out.printf("要删除的%d 节点不存在\n", no);
}
}

//显示链表[遍历]
public void list() {
//判断链表是否为空
if (head.next == null) {
System.out.println("链表为空");
return;
}
//因为头节点不能动,因此我们需要一个辅助变量来遍历
HeroNode temp = head.next;
while (true) {
//判断是否到链表最后
if (temp == null) {
break;
}
//输出节点信息
System.out.println(temp);
//将temp后移,一定小心
temp = temp.next;

}
}

}

//定义HeroNode,每个HeroNode对象就是一个节点
class HeroNode {
public int no;
public String name;//名字
public String nickname;//昵称
public HeroNode next;//指向下一个节点

//构造器
public HeroNode(int no, String name, String nickname) {
this.no = no;
this.name = name;
this.nickname = nickname;
}

//为了显示方法,我们重新toString
@Override
public String toString() {
return "HeroNode{" +
"no=" + no +
", name='" + name + '\'' +
", nickname='" + nickname + '\'' +
'}';
}
}

面试1

==求单链表中有效节点的个数==

具体实现

//方法:获取到单链表的节点的个数(如果是带头结点的链表,需求不统计头节点)
/**
* 求单链表中有效节点的个数
* @param head 链表的头结点
* @return 返回的就是有效结点的个数
*/
public static int getLength(HeroNode head){
int length = 0;
if(head.next==null){
return 0;
}
HeroNode heroNode = head.next;//没有统计头结点
while (heroNode!=null){
length++;
heroNode = heroNode.next;
}
return length;
}

测试类

public static void main(String[] args) {

SingleLinkedList singleLinkedList = new SingleLinkedList();

HeroNode hero1 = new HeroNode(1, "宋江", "及时雨");
HeroNode hero2 = new HeroNode(2, "卢俊义", "玉麒麟");

singleLinkedList.addByOrder(hero1);
singleLinkedList.addByOrder(hero2);

System.out.println("有效节点的个数:"+getLength(singleLinkedList.getHead()));

}

运行结果

image-20211208213647363

面试2

==查找单链表中的倒数第 k 个结点==

具体实现

/**
* 查找单链表中的倒数第k个结点
* 思路
* 1. 编写一个方法,接收 head 节点,同时接收一个 index
* 2. index 表示是倒数第 index 个节点
* 3. 先把链表从头到尾遍历,得到链表的总的长度 getLength
* 4. 得到 size 后,我们从链表的第一个开始遍历 (size-index)个,就可以得到
* 5. 如果找到了,则返回该节点,否则返回 null
*
* @param head
* @param index
* @return
*/
public static HeroNode findLastIndexNode(HeroNode head, int index) {
//第一个遍历得到链表的长度(节点个数)
int size = getLength(head);
//判断如果链表为空,返回 null
if (head.next == null) {
return null;
}

//index 的校验,确定index符合范围
if (index < 0 || index > size) {
return null;
}

//第二次遍历 size-index 位置,就是我们倒数的第 K 个节点
//定义一个辅助变量
HeroNode heroNode = head.next;
for (int i = 0; i < size - index; i++) {
heroNode = heroNode.next;
}
//返回
return heroNode;
}

测试类

public static void main(String[] args) {

SingleLinkedList singleLinkedList = new SingleLinkedList();

HeroNode hero1 = new HeroNode(1, "宋江", "及时雨");
HeroNode hero2 = new HeroNode(2, "卢俊义", "玉麒麟");

singleLinkedList.addByOrder(hero1);
singleLinkedList.addByOrder(hero2);

System.out.println("倒数第1个节点:" +findLastIndexNode(singleLinkedList.getHead(),1) );

}

运行结果

image-20211208215721405

面试3

==单链表的反转【腾讯面试题,有点难度】==

思路:

1、 先定义一个节点 reverseHead = new HeroNode();

2.、从头到尾遍历原来的链表,每遍历一个节点,就将其取出,并放在新的链表reverseHead 的最前端.

3、原来的链表的head.next = reverseHead.next

image-20211208230720001

具体实现

//将单链表反转
public static void reversetList(HeroNode head) {
//如果当前链表为空,或者只有一个节点,无需反转,直接返回
if(head.next == null || head.next.next == null) {
return ;
}
//定义一个辅助的指针(变量),帮助我们遍历原来的链表
HeroNode cur = head.next;//用于遍历原先链表的
HeroNode next = null;//指向当前节点的下一个节点。
HeroNode reverseHead = new HeroNode(0, "", "");//创建的新链表

//遍历原来的链表,每遍历一个节点,就将其取出,并放在新的链表 reverseHead 的最前端
//动脑筋
while(cur != null) {
next = cur.next;//暂时保存cur当前节点的下一个节点,因为要留给下一次循环使用
cur.next = reverseHead.next;//将当前结点的下一个节点指向新链表的最前端,即新链表的头结点的下一个节点
reverseHead.next = cur;//再将新链表头结点的下一个节点指向cur,也就是当前链表
cur = next;//cur后移,下一个循环使用
}
//将 head.next 指向 reverseHead.next , 实现单链表的反转
head.next = reverseHead.next;
}

测试类

public class Test {
public static void main(String[] args) {

SingleLinkedList singleLinkedList = new SingleLinkedList();

HeroNode hero1 = new HeroNode(1, "宋江", "及时雨");
HeroNode hero2 = new HeroNode(2, "卢俊义", "玉麒麟");
HeroNode hero3 = new HeroNode(3, "吴用", "智多星");
HeroNode hero4 = new HeroNode(4, "林冲", "豹子头");


singleLinkedList.addByOrder(hero1);
singleLinkedList.addByOrder(hero2);
singleLinkedList.addByOrder(hero3);
singleLinkedList.addByOrder(hero4);

System.out.println("原本的链表");
singleLinkedList.list();

System.out.println("反转的链表");
reversetList(singleLinkedList.getHead());
singleLinkedList.list();

}

运行结果

image-20211208235530352

面试4

==从尾到头打印单链表==

思路

1.、上面的题的要求就是逆序打印单链表.

2、方式1: 先将单链表进行反转操作,然后再遍历即可,这样的做的问题是会破坏原来的单链表的结构,不建议

3、**方式2:可以利用栈这个数据结构,将各个节点压入到栈中,然后利用栈的先进后出的特点,就实现了逆序打印的效果.
举例演示栈的使用 Stack **

image-20211209000108713

具体实现

//方式2:
//可以利用栈这个数据结构,将各个节点压入到栈中,然后利用栈的先进后出的特点,就实现了逆序打印的效果
public static void reversePrint(HeroNode head) {
if (head.next == null) {
return;//空链表,不能打印
}
//创建要给一个栈,将各个节点压入栈
Stack<HeroNode> stack = new Stack<HeroNode>();
HeroNode cur = head.next;
//将链表的所有节点压入栈
while (cur != null) {
stack.push(cur);
cur = cur.next; //cur后移,这样就可以压入下一个节点
}
//将栈中的节点进行打印,pop 出栈
while (stack.size() > 0) {
System.out.println(stack.pop()); //stack的特点是先进后出
}
}

测试类

public class Test {
public static void main(String[] args) {

SingleLinkedList singleLinkedList = new SingleLinkedList();

HeroNode hero1 = new HeroNode(1, "宋江", "及时雨");
HeroNode hero2 = new HeroNode(2, "卢俊义", "玉麒麟");
HeroNode hero3 = new HeroNode(3, "吴用", "智多星");
HeroNode hero4 = new HeroNode(4, "林冲", "豹子头");

singleLinkedList.addByOrder(hero1);
singleLinkedList.addByOrder(hero2);
singleLinkedList.addByOrder(hero3);
singleLinkedList.addByOrder(hero4);

System.out.println("原本的链表");
singleLinkedList.list();

System.out.println("反向输出");
reversePrint(singleLinkedList.getHead());

}

运行结果

image-20211209001355649

面试5

合并两个有序的单链表,合并之后的链表依然有序

具体实现

public static HeroNode merge(HeroNode head1, HeroNode head2) {
//创建的新链表
HeroNode reverseHead = new HeroNode(0, "", "");

//定义一个辅助的指针,帮助我们遍历原来的链表 reverseHead
HeroNode cur = reverseHead;
//定义一个辅助的指针,帮助我们遍历原来的链表1
HeroNode l1 = head1.next;
//定义一个辅助的指针,帮助我们遍历原来的链表2
HeroNode l2 = head2.next;

//通过循环,比较两个链表值的大小,将其中较小的节点添加到新链表的结尾
while (l1 != null && l2 != null) {

if (l2.no > l1.no) {
cur.next = l1;
l1 = l1.next;
cur = cur.next;
}

if (l1.no > l2.no) {
cur.next = l2;
l2 = l2.next;
cur = cur.next;
}
}

//当其中一个遍历完了之后,就将另一个剩余的节点全部添加到新链表的结尾
if (l1 == null) {
cur.next = l2;
}
if (l2 == null) {
cur.next = l1;
}
return reverseHead.next;
}

测试类

public class Test {    
public static void main(String[] args) {
SingleLinkedList singleLinkedList1 = new SingleLinkedList();
SingleLinkedList singleLinkedList2 = new SingleLinkedList();


HeroNode hero1 = new HeroNode(1, "宋江", "及时雨");
HeroNode hero2 = new HeroNode(2, "卢俊义", "玉麒麟");
HeroNode hero3 = new HeroNode(3, "吴用", "智多星");
HeroNode hero4 = new HeroNode(4, "林冲", "豹子头");

HeroNode hero5 = new HeroNode(5, "老五", "智多星");
HeroNode hero6 = new HeroNode(6, "老六", "豹子头");
HeroNode hero7 = new HeroNode(7, "老七", "智多星");
HeroNode hero8 = new HeroNode(8, "老八", "豹子头");
HeroNode hero9 = new HeroNode(9, "老九", "智多星");




singleLinkedList1.addByOrder(hero3);
singleLinkedList1.addByOrder(hero1);
singleLinkedList1.addByOrder(hero7);
singleLinkedList1.addByOrder(hero5);
singleLinkedList1.addByOrder(hero9);


singleLinkedList2.addByOrder(hero2);
singleLinkedList2.addByOrder(hero6);
singleLinkedList2.addByOrder(hero8);
singleLinkedList2.addByOrder(hero4);

System.out.println("链表1:");
singleLinkedList1.list();

System.out.println("链表2:");
singleLinkedList2.list();

HeroNode merge = merge(singleLinkedList1.getHead(), singleLinkedList2.getHead());

System.out.println("合并后:");
while (merge !=null){
System.out.println(merge);
merge = merge.next;
}

}
}

运行结果

image-20211209122051254

双向链表

概述

管理单向链表的缺点分析:

  1. 单向链表,查找的方向只能是一个方向,而双向链表可以向前或者向后查找。
  2. 单向链表不能自我删除,需要靠辅助节点 ,而双向链表,则可以自我删除,所以前面我们单链表删除时节点,总是找到 temp,temp是待删除节点的前一个节点(认真体会)。
  3. 分析了双向链表如何完成遍历,添加,修改和删除的思路

image-20211209141508844

实例

使用带 head 头的双向链表实现——水浒英雄排行榜

思路分析

双向链表的遍历,添加,修改,删除的操作思路

  1. 遍历 方和 单链表一样,只是可以向前,也可以向后查找
  2. 添加 (默认添加到双向链表的最后)
    1. 先找到双向链表的最后这个节点
    2. temp.next = newHeroNode
    3. newHeroNode.pre = temp;
  3. 修改 思路和 原来的单向链表一样.
  4. 删除
    1. 因为是双向链表,因此,我们可以实现自我删除某个节点
    2. 直接找到要删除的这个节点,比如temp
    3. temp.pre.next = temp.next
    4. temp.next.pre = temp.pre;

代码

public class DoubleLinkedListDemo {
public static void main(String[] args) {
// 测试
System.out.println("双向链表的测试");
// 先创建节点
HeroNode2 hero1 = new HeroNode2(1, "宋江", "及时雨");
HeroNode2 hero2 = new HeroNode2(2, "卢俊义", "玉麒麟");
HeroNode2 hero3 = new HeroNode2(3, "吴用", "智多星");
HeroNode2 hero4 = new HeroNode2(4, "林冲", "豹子头");
// 创建一个双向链表
DoubleLinkedList doubleLinkedList = new DoubleLinkedList();
doubleLinkedList.add(hero1);
doubleLinkedList.add(hero2);
doubleLinkedList.add(hero3);
doubleLinkedList.add(hero4);

doubleLinkedList.Forwardlist();

System.out.println("双向链表的反向输出");
doubleLinkedList.Rsvsrselist();

// 修改
HeroNode2 newHeroNode = new HeroNode2(4, "公孙胜", "入云龙");
doubleLinkedList.update(newHeroNode);
System.out.println("修改后的链表情况");
doubleLinkedList.Forwardlist();

// 删除
doubleLinkedList.del(3);
System.out.println("删除后的链表情况");
doubleLinkedList.Forwardlist();
}
}

//创建一个双向链表的类
class DoubleLinkedList {
// 先初始化一个头节点, 头节点不要动, 不存放具体的数据
private HeroNode2 head = new HeroNode2(0, "", "");

// 返回头节点
public HeroNode2 getHead() {
return head;
}

// 遍历双向链表的方法
// 显示链表[遍历]
public void Forwardlist() {
// 判断链表是否为空
if (head.next == null) {
System.out.println("链表为空");
return;
}
// 因为头节点,不能动,因此我们需要一个辅助变量来遍历
HeroNode2 temp = head.next;
while (true) {
// 判断是否到链表最后
if (temp == null) {
break;
}
// 输出节点的信息
System.out.println(temp);
// 将temp后移, 一定小心
temp = temp.next;
}
}

//反向遍历
public void Rsvsrselist() {
// 判断链表是否为空
if (head.next == null) {
System.out.println("链表为空");
return;
}
// 因为头节点,不能动,因此我们需要一个辅助变量来遍历
HeroNode2 temp1 = head;
HeroNode2 temp2;
//遍历到链表的尾节点
while (true) {
if (temp1.next == null) {
temp2 = temp1;
break;
}
temp1 = temp1.next;
}
//从尾到头开始遍历
while (true) {
// 判断是否到链表最后
if (temp2.pre == null) {
break;
}
// 输出节点的信息
System.out.println(temp2);
// 将temp后移, 一定小心
temp2 = temp2.pre;
}
}

/// 添加一个节点到双向链表的最后.
public void add(HeroNode2 heroNode) {

//因为head节点不能动,因此我们需要一个辅助遍历 temp
HeroNode2 temp = head;
//遍历链表,找到最后
while (true) {
//找到链表的最后
if (temp.next == null) {//
break;
}
//如果没有找到最后, 将将temp后移
temp = temp.next;
}
//当退出while循环时,temp就指向了链表的最后
//形成一个双向链表
temp.next = heroNode;
heroNode.pre = temp;
}

// 第二种方式在添加英雄时,根据排名将英雄插入到指定位置
//在内存中将数据排序
//(如果有这个排名,则添加失败,并给出提示)
//要将新添加的heroNode存储到temp.next的位置,所以要和原先的temp.next作比较。将heroNode.next指向原先的temp.next.no,再将temp.next指向新的heroNode
public void addByOrder(HeroNode2 heroNode) {
//因为头节点不能动,因此我们仍然通过一个辅助指针(变量)来帮助找到添加的位置
//因为单链表,找到的temp是位于添加位置的前一个节点,否则插入不了
HeroNode2 temp = head;
boolean flag = false;//flag标志添加的编号是否存在,默认为false
while (true) {
if (temp.next == null) {//说明temp已经在链表的最后
break;
}
if (temp.next.no > heroNode.no) {//位置找到,就在temp的后面
break;
} else if (temp.next.no == heroNode.no) {//说明希望添加的heroNode的编号已然存在
flag = true;
break;
}
temp = temp.next;//后移,遍历当前链表
}
//判断flag的值
if (flag) {//不能添加,说明编号存在
System.out.println("准备插入的英雄的编号" + heroNode.no + "已经存在了,不能加入");
} else {
//插入需要四步
//插入的节点在链表的中间
if (temp.next != null) {
heroNode.next = temp.next;
temp.next.pre = heroNode;
heroNode.pre = temp;
temp.next = heroNode;
} else {
//在链表末端
heroNode.pre = temp;
temp.next = heroNode;
}
}
}


// 修改一个节点的内容, 可以看到双向链表的节点内容修改和单向链表一样
// 只是 节点类型改成 HeroNode2
public void update(HeroNode2 newHeroNode) {
// 判断是否空
if (head.next == null) {
System.out.println("链表为空~");
return;
}
// 找到需要修改的节点, 根据no编号
// 定义一个辅助变量
HeroNode2 temp = head.next;
boolean flag = false; // 表示是否找到该节点
while (true) {
if (temp == null) {
break; // 已经遍历完链表
}
if (temp.no == newHeroNode.no) {
// 找到
flag = true;
break;
}
temp = temp.next;
}
// 根据flag 判断是否找到要修改的节点
if (flag) {
temp.name = newHeroNode.name;
temp.nickname = newHeroNode.nickname;
} else { // 没有找到
System.out.printf("没有找到编号%d的节点,不能修改\n", newHeroNode.no);
}
}

//从双向链表中删除一个节点
//1.对于双向链表,我们可以直接找到要删除的这个节点
//2.找到后,自我删除即可
public void del(int no) {

// 判断当前链表是否为空
if (head.next == null) {// 空链表
System.out.println("链表为空,无法删除");
return;
}

HeroNode2 temp = head.next; // 辅助变量(指针)
boolean flag = false; // 标志是否找到待删除节点的
while (true) {
if (temp == null) { // 已经到链表的最后
break;
}
if (temp.no == no) {
// 找到的待删除节点的前一个节点temp
flag = true;
break;
}
temp = temp.next; // temp后移,遍历
}
// 判断flag
if (flag) { // 找到
// 可以删除
// temp.next = temp.next.next;[单向链表]
temp.pre.next = temp.next;
// 这里我们的代码有问题?
// 如果是最后一个节点,就不需要执行下面这句话,否则出现空指针
if (temp.next != null) {
temp.next.pre = temp.pre;
}
} else {
System.out.printf("要删除的" + no + "节点不存在");
}
}

}

//定义HeroNode , 每个HeroNode 对象就是一个节点
class HeroNode2 {
public int no;
public String name;
public String nickname;
public HeroNode2 next; //指向下一个节点,默认为null
public HeroNode2 pre; //指向前一个节点,默认为null

//构造器
public HeroNode2(int no, String name, String nickname) {
this.no = no;
this.name = name;
this.nickname = nickname;
}

//为了显示方法,我们重新toString
@Override
public String toString() {
return "HeroNode [no=" + no + ", name=" + name + ", nickname=" + nickname + "]";
}

}

约瑟夫问题

概述

Josephu问题

设编号为1,2,…n的n个人围坐一圈,约定编号为k (1<=k<=n) 的人从1开始报数,数到m的那个人出列,它的下一位又从1开始报数,数到m的那个人又出列,依次类推,直到所有人出列为止,由此产生一个出队编号的序列。

提示

用一个不带头结点的循环链表来处理Josephu问题:先构成一个有n个结点的单循环链表,然后由k结点起从1开始计数,计到m时,对应结点从链表中删除,然后再从被删除结点的下一个结点又从1开始计数,直到最后一个结点从链表中删除算法结束。

image-20211209214812167

实例

思路分析

image-20211209220539194

约瑟夫问题——小孩出圈的思路分析图

image-20211209220626919

代码

public class Josepfu {
public static void main(String[] args) {
//测试一把构建环形链表,和遍历是否成功
CircleSingleLinkedList circleSingleLinkedList = new CircleSingleLinkedList();
circleSingleLinkedList.addBoy(5);//加入5个小孩节点
circleSingleLinkedList.showBoy();
//测试一把小孩出圈是否正确
circleSingleLinkedList.countBoy(1, 2, 5); // 2->4->1->5->3
}
}
//创建一个环形的单向链表
class CircleSingleLinkedList{
//创建一个first节点,当前没有编号
private Boy first =null;
//添加小孩节点,构建一个环形链表
public void addBoy(int nums){
//nums做一个数据校验
if(nums < 1){
System.out.println("num的值不正确");
return;
}
Boy curBoy = null;//辅助指针,帮助构建环形链表
//使用for来创建我们的环形链表
for (int i = 1;i <= nums;i++){
//根据编号,创建小孩节点
Boy boy = new Boy(i);
//如果是第一个小孩
if (i == 1){
first = boy;
first.setNext(first);//构成环
curBoy = first;//让curBoy指向第一个小孩
}else{
curBoy.setNext(boy);//最后一个跟新节点相连
boy.setNext(first);//跟头相连
curBoy = boy;//curBoy后移

}
}
}
// 根据用户的输入,计算出小孩出圈的顺序
/**
*
* @param startNo
* 表示从第几个小孩开始数数
* @param countNum
* 表示数几下
* @param nums
* 表示最初有多少小孩在圈中
*/
public void countBoy(int startNo, int countNum,int nums) {
// 先对数据进行校验
if (first == null || startNo < 1 || startNo > nums) {
System.out.println("参数输入有误, 请重新输入");
return;
}
// 创建要给辅助指针,帮助完成小孩出圈
Boy helper = first;
// 需求创建一个辅助指针(变量) helper , 事先应该指向环形链表的最后这个节点
while (true) {
if (helper.getNext() == first) { // 说明 helper 指向最后小孩节点
break;
}
helper = helper.getNext();
}
//小孩报数前,先让 first 和 helper 移动 k - 1 次
for(int j = 0; j < startNo - 1; j++) {
first = first.getNext();
helper = helper.getNext();
}
//当小孩报数时,让 first 和 helper 指针同时 的移动 m - 1 次, 然后出圈
//这里是一个循环操作,知道圈中只有一个节点
while(true) {
if(helper == first) { //说明圈中只有一个节点
break;
}
//让 first 和 helper 指针同时 的移动 countNum - 1
for(int j = 0; j < countNum - 1; j++) {
first = first.getNext();
helper = helper.getNext();
}
//这时 first 指向的节点,就是要出圈的小孩节点
System.out.printf("小孩%d 出圈\n", first.getNo());
//这时将 first 指向的小孩节点出圈
first = first.getNext();
helper.setNext(first); //
}
System.out.printf("最后留在圈中的小孩编号%d \n", first.getNo());
}

//遍历当前的环形链表
public void showBoy() {
//判断链表是否为空
if (first == null) {
System.out.println("没有任何小孩");
return;
}
//因为first不能动,因此我们仍然使用一个辅助指针完成遍历
Boy curBoy = first;
while (true) {
System.out.printf("小孩的编号 %d \n", curBoy.getNo());
if (curBoy.getNext() == first) {//说明已经遍历完毕
break;
}
curBoy = curBoy.getNext();// curBoy后移
}
}
}
//创建一个Boy类,表示一个节点
class Boy{
private int no;//编号
private Boy next;//指向下一个节点
public Boy(int no){
this.no = no;
}

public int getNo() {
return no;
}

public void setNo(int no) {
this.no = no;
}

public Boy getNext() {
return next;
}

public void setNext(Boy next) {
this.next = next;
}
}

概述

介绍

  1. 栈的英文为(stack)
  1. 栈是一个==先入后出==(FILO-First In Last Out)的有序列表。
  2. 栈(stack)是限制线性表中元素的插入和删除只能在线性表的同一端进行的一种特殊线性表。允许插入和删除的一端,为==变化的一端,称为栈顶(Top)==,==另一端为固定的一端, 称为栈底(Bottom)==。
  3. 根据栈的定义可知,最先放入栈中元素在栈底,最后放入的元素在栈项,而删除元素刚好相反,最后放入的元素最先删除,最先放入的元素最后删除。
  4. 图解方式说明出栈(pop)和入栈(push)的概念。

image-20211209234749954

image-20211209234814682

应用场景

  1. 子程序的调用:在跳往子程序前,会先将下个指令的地址存到堆栈中,直到子程序执行完后再将地址取出,以回到原来的程序中。
  2. 处理递归调用:和子程序的调用类似,只是除了储存下一个指令的地址外,也将参数、区域变量等数据存入堆栈中。
  3. 表达式的转换【中缀表达式转后缀表达式】与求值(实际解决)。
  4. 二叉树的遍历。
  5. 图形的深度优先(depth一first)搜索法。

快速入门

用数组模拟栈的使用

由于栈是一种有序列表,当然可以使用数组的结构来储存栈的数据内容,下面我们就用数组模拟栈的出栈,入栈等操作。

思路分析

image-20211210000656796

实现栈的思路分析:

  1. 使用数组来模拟栈。
  2. 定义一个top来表示栈顶,初始化为-1。
  3. 入栈的操作,当有数据加入到栈时,top++;stack[top] = data;
  4. 出栈的操作,int value = stack[top];top–,return value

代码实现

数组实现

class ArrayStack{
private int maxSize;//栈的大小
private int[] stack;//数组,模拟栈,用来存放数据
private int top = -1; //栈顶元素,初始时指向栈地的上一位

//构造器
public ArrayStack(int maxSize) {
this.maxSize = maxSize;
stack = new int[this.maxSize];
}

//栈满
public boolean isFull(){
return top == maxSize-1;
}

//栈空
public boolean isEmpty(){
return top == -1;
}

//入栈
public void push(int value){
//先判断是否栈满
if(isFull()){
System.out.println("栈已满,不能再放数据");
return;
}
top ++;
stack[top] = value;
}

//出栈
public int pop(){
if(isEmpty()){
throw new RuntimeException("栈空,没有数据");
}
int tmp = stack[top];
top--;
return tmp;
}

//遍历栈,遍历时从栈底开始遍历
public void list(){
if(isEmpty()){
System.out.println("栈空");
return;
}
for(int i = top; i >= 0; i--){
System.out.printf("stack["+i+"]="+stack[i]);
}
}
}

队列实现

class DListStack {
private Node top = new Node(0); //栈顶指针
private int maxSize; //最大长度
private Node tail; //指向栈底


//构造器
public DListStack(int maxsize) {
this.maxSize = maxsize;
tail = top;
for (int i = 0; i < maxsize; i++) {
Node tmp = new Node(0);//规定0表示没有值
tail.next = tmp;
tmp.prev = tail;
tail = tail.next;
}
}

//栈空
public boolean isEmpty() {
if (top.no == 0) {
return true;
}
return false;
}

//栈满
public boolean isFull() {
return tail == top;
}

//遍历打印
public void list() {
if (isEmpty()) {
System.out.println("栈为空");
return;
}
Node tmp = top;
while (tmp.no != 0) {
System.out.println(tmp.no);
tmp = tmp.prev;
}
}

//入栈
public void push(int value){
if(isFull()){
System.out.println("栈已满");
return;
}
top = top.next;
top.no = value;
}

//出栈
public int pop() {
if (isEmpty()) {
System.out.println("栈已空");
return 0;
}
int no = top.no;
top = top.prev;
return no;
}

}


class Node {
public int no;
public Node next;
public Node prev;

public Node(int no) {
this.no = no;
}

@Override
public String toString() {
return "" + no;
}
}

方法类

public class StackTestDemo {
public static void main(String[] args) {
ArrayStack arrayStack = new ArrayStack(4);
DListStack dListStack = new DListStack(4);
String key = "";
boolean loop = true;
Scanner scanner = new Scanner(System.in);
while (loop) {
System.out.println("show:表示显示栈");
System.out.println("exit:表示退出程序");
System.out.println("push:表示入栈");
System.out.println("pop:表示出栈");
System.out.println("请输入你的选择");
key = scanner.next();
switch (key) {
case "show":
//arrayStack.list();
dListStack.list();
break;
case "exit":
if (scanner != null) {
scanner.close();
}
loop = false;
break;
case "push":
System.out.println("请输入一个数");
int value = scanner.nextInt();
//arrayStack.push(value);
dListStack.push(value);
break;
case "pop":
try {
//int res = arrayStack.pop();
int res = dListStack.pop();
System.out.println("出栈的元素是" + res);
} catch (Exception e) {
e.printStackTrace();
}
break;
}
}
}
}

综合计算器(中缀)

使用栈完成表达式的计算

中缀表达式 : 或中缀记法)是一个通用的算术或逻辑公式表示方法, 操作符是以中缀形式处于操作数的中间(例:3 + 4),中缀表达式是人们常用的算术表示方法。

思路分析

  1. 通过一个index值(索引),来遍历我们的表达式;

  2. 如果我们发现是一个数字,就直接入数栈;

  3. 如果发现扫描到是一个符号,就分如下情况:

    1. 如果发现当前的符号栈为空,就直接入栈

    2. 如果符号栈有操作符,就进行比较

    • 如果当前的操作符的优先级小于或者等于栈中的操作符,就需要从数栈中pop出两个数,在从符号栈中pop出一个符号,进行运算,将得到结果,入数栈,然后将当前的操作符入符号栈,

    • 如果当前的操作符的优先级大于栈中的操作符,就直接入符号栈.

  4. 当表达式扫描完毕,就顺序的从数栈和符号栈中pop出相应的数和符号,并运行

  5. 最后在数栈只有一个数字,就是表达式的结果。

  6. 补充:当运算式中有多位数时,先判断具体数字是多少,再判断数字

代码实现

public class ComputerTest {
public static void main(String[] args) {

String expression = "70+2*6-4"; //8
//创建两个栈,数栈,操作符栈
NumStack numStack = new NumStack(10);
NumStack operStack = new NumStack(10);
//定义相关变量
int index = 0;//用于扫描
int num1 = 0;//第一个数字
int num2 = 0;//第二个数组
int oper = 0;//接收操作符
int res = 0;//保存结果
char ch = ' ';//保存每次扫描的操作符
String keepNum = "";
//循环扫描计算式
while (true) {
ch = expression.substring(index, index + 1).charAt(0);
//判断ch是什么(符号/数字),然后做响应的处理
if (operStack.isOper(ch)) {//如果是运算符
if (!operStack.isEmpty()) {
//与符号栈栈顶元素比较,如果优先级 < = 栈顶元素优先级,就从数栈里面弹出两个数,再从符号栈里弹出一个符号运算,
// 运算,后将结果压入数栈
if (operStack.priority(ch) <= operStack.priority(operStack.peek())) {
num1 = numStack.pop();
num2 = numStack.pop();
oper = operStack.pop();
res = operStack.cal(num1, num2, oper);
numStack.push(res);
//然后将当前操作符入栈
operStack.push(ch);
} else {//当前操作符优先级>栈顶操作符优先级,直接入栈
operStack.push(ch);
}
} else {
//符号栈为null,直接入栈
operStack.push(ch);
}
} else {
//接收数字时,可能接收到多位数,因此不能发现是一个数字就入栈,因为可能是多位数
//在处理数时,需要向expression的表达式的index后再看一位,如果是符号才入栈
//因此我们需要定义一个变量,字符串,用于拼接

//处理多位数
keepNum += ch;

//判断下一位是不是数字,,如果是数字,就继续扫描,如果是运算符就入栈
//注意看后一位,不是index++
if (index + 1 == expression.length()) {
//如果是最后一位,直接如数栈
numStack.push(ch - 48);
} else {
if (operStack.isOper(expression.substring(index + 1, index + 2).charAt(0))) {
//如果最后一位是运算符,则入栈 keepNum="1"或者”123“
numStack.push(Integer.parseInt(keepNum));
//清空keepNum
keepNum = "";
}
}
/* //如果是数字,直接入栈
numStack.push(ch-48);//字符转数字*/
}
//让index+1,并判断是否扫描到expression的结尾
index++;
if (index >= expression.length()) {
break;//扫描完毕,跳出循环
}
}
while (true) {
//如果符号栈为空,则计算到最后结果,数栈中只有一个数栈【结果】
if (operStack.isEmpty()) {
break;
}
num1 = numStack.pop();
num2 = numStack.pop();
oper = operStack.pop();
res = numStack.cal(num1, num2, oper);
numStack.push(res);
}
res = numStack.pop();
System.out.printf("表达式%s = %d", expression, res);
}
}

class NumStack {
private int maxSize;//栈的大小
private int[] stack;//数组,模拟栈,用来存放数据
private int top = -1; //栈顶元素,初始时指向栈地的上一位

//构造器
public NumStack(int maxSize) {
this.maxSize = maxSize;
stack = new int[this.maxSize];
}

//栈满
public boolean isFull() {
return top == maxSize - 1;
}

//获取栈顶元素,但不pop
public int peek() {
return stack[top];
}

//栈空
public boolean isEmpty() {
return top == -1;
}

//入栈
public void push(int value) {
//先判断是否栈满
if (isFull()) {
System.out.println("栈已满,不能再放数据");
return;
}
top++;
stack[top] = value;
}

//出栈
public int pop() {
if (isEmpty()) {
throw new RuntimeException("栈空,没有数据");
}
int tmp = stack[top];
top--;
return tmp;
}

//遍历栈,遍历时从栈底开始遍历
public void list() {
if (isEmpty()) {
System.out.println("栈空");
return;
}
for (int i = top; i >= 0; i--) {
System.out.printf("stack[%d]=%d\n", i, stack[i]);
}
}

//返回运算符的优先级,优先级由程序员来确定,优先级使用数字表示
//数字越大,优先级越高
public int priority(int oper) {
if (oper == '*' || oper == '/') {
return 1;
} else if (oper == '+' || oper == '-') {
return 0;
} else {
return -1;
}
}

//辨别运算符
public boolean isOper(char val) {
return val == '+' || val == '-' || val == '*' || val == '/';
}

//计算方法
public int cal(int num1, int num2, int oper) {
int res = 0; //用来存放结果
switch (oper) {
case '+':
res = num1 + num2;
break;
case '-':
res = num2 - num1;
break;
case '*':
res = num1 * num2;
break;
case '/':
res = num2 / num1;
break;
default:
break;
}
return res;
}
}

前缀、中缀、后缀表达式

前缀表达式(波兰表达式)

概述

  1. 前缀表达式又称波兰式,前缀表达式的运算符位于操作数之前
  2. 举例说明:(3+4)X5-6 对应的前缀表达式就是 - X + 3 4 5 6

思路

前缀表达式的计算机求值

丛右至左扫描表达式,遇到数字时,将数字压入堆栈,遇到运算符时,弹出栈项的两个数,用运算符对它们做相应的计算(栈顶元素和次顶元素),并将结果入栈;重复上述过程直到表达式最左端,最后运算得出的值即为表达式的结果

例如:(3+4)X 5-6对应的前缀表达式就是 - X + 3 4 5 6,针对前缀表达式求值步骤如下:

  1. 从右至左扫描,将6、5、4、3压入堆栈;
  2. 遇到+运算符,因此弹出3和4 (3为栈项元素,4为次顶元素),计算出3+4的值,得7,再将7入栈;
  3. 接下来是X运算符,因此弹出7和5,计算出7X5=35,将35入栈;
  4. 最后是 - 运算符,计算出35-6的值,即29, 由此得出最终结果。

中缀表达式

  1. 中缀表达式就是常见的运算表达式,如(3+4)X5-6;
  2. 中缀表达式的求值是我们人最熟悉的,但是对计算机来说却不好操作(前面我们讲的案例就能看的这个问题),因此,在计算结果时,往往会将中缀表达式转成其它表达式来操作(一般转成后缀表达式)。

后缀表达式

  1. 后缀表达式又称逆波兰表达式与前缀表达式相似,只是运算符位于操作数之后;
  2. 中举例说明:(3+4)X5-6对应的后缀表达式就是3 4 + 5 X 6 -

后缀表达式的计算机求值

从左至右扫描表达式,遇到数字时,将数字压入堆栈,遇到运算符时,弹出栈项的两个数,用运算符对它们做相应的计算(次项元素和栈项元素),并将结果入栈;重复上述过程直到表达式最右端,最后运算得出的值即为表达式的结果

例如:(3+4)X5-6对应的前缀表达式就是 3 4 + 5 X 6 -,针对后缀表达式求值步骤如下:

  1. 从左至右扫描,将3和4压入堆栈;
  2. 遇到+运算符, 因此弹出4和3 (4为栈项元素,3为次顶元素),计算出3+4的值,得7,再将7入栈;
  3. 将5入栈;
  4. 接下来是X运算符,因此弹出5和7,计算出7X5=35,将35入栈;
  5. 将6入栈;
  6. 最后是 - 运算符,计算出35-6的值,即29,由此得出最终结果。

逆波兰计算器

我们完成-一个逆波兰计算器,要求完成如下任务:

  1. 输入一个逆波兰表达式,使用栈(Stack),计算其结果
  2. 支持小括号和多位数整数,因为这里我们主要讲的是数据结构,因此计算器进行简化,只支持对整数的计算。
  3. 思路分析
  4. 代码完成

思路分析

详见后缀表达式

实现

public class PolandNotation {

public static void main(String[] args) {
//(3+4)×5-6 --> 3 4 + 5 × 6 -
//将逆波兰表达式的数字和符号用空格分开
String suffExpression = "3 4 + 5 x 6 -";
//String suffExpression = "4 5 x 8 - 60 + 8 2 / +";//4*5-8+60+8/2
//将逆波兰表达式存放在ArrayList中
List<String> lst = getListString(suffExpression);
System.out.println(lst);
//计算逆波兰表达式
int result = calculate(lst);
System.out.println("逆波兰表达式的结果为:" + result);
}

//将一个逆波兰表达式存放在ArrayList中
public static List<String> getListString(String suffixExpression) {
//将suffixExpression分割
String[] arr = suffixExpression.split(" ");
List<String> lst = new ArrayList<>();
for (String item : arr) {
lst.add(item);
}
return lst;
}

//对逆波兰表达式的计算
public static int calculate(List<String> lst) {
//定义一个栈
Stack<String> stack = new Stack<>();
for (String lst1 : lst) {
if (lst1.matches("\\d+"))//利用正则表达式来取值,匹配的是多位数
{
stack.push(lst1);
} else {
//弹出两个数进行运算
int num2 = Integer.parseInt(stack.pop());
int num1 = Integer.parseInt(stack.pop());
int res = 0;
if (lst1.equals("+")) {
res = num1 + num2;
} else if (lst1.equals("-")) {
res = num1 - num2;
} else if (lst1.equals("x")) {
res = num1 * num2;
} else if (lst1.equals("/")) {
res = num1/ num2;
} else {
throw new RuntimeException("运算符有误");
}
//res入栈
stack.push("" + res);
}

}
return Integer.parseInt(stack.pop());
}

}

中缀表达式转换为后缀表达式

后缀表达式适合计算机式进行运算,但是却不太容易写出来,尤其是表达式很长的情况下,因此在开发中,我们需要将中缀表达式转成后缀表达式。

思路分析

  1. 初始化两个栈:运算符栈s1和储存中间结果的栈s2;
  2. 从左至右扫描中缀表达式;
  3. 遇到操作数时,将其压s2;
  4. 遇到运算符时,比较其与s1栈顶运算符的优先级:
    1. 如果s1为空,或栈顶运算符为左括号“(”,则直接将此运算符入栈;
    2. 否则,若优先级比栈顶运算符的高,也将运算符压入s1;
    3. 否则,将s1栈顶的运算符弹出并压入到s2中,再次转到(4.1)与s1中新的栈顶运算符相比较;
  5. 遇到括号时:
    1. 如果是左括号“(”,则直接压入s1;
    2. 如果是右括号“)”,则依次弹出s1栈顶的运算符,并压入s2,直到遇到左括号为止,此时将这一对括号丢弃;
  6. 重复步骤2至5,直到表达式的最右边;
  7. 将s1中剩余的运算符依次弹出并压入s2;
  8. 依次弹出s2中的元素并输出,结果的逆序即为中缀表达式对应的后缀表达式;

image-20211210201315199

核心方法实现

将中缀表达式转成对应的list

public static List<String> toInfixExpressionList(String s) {
//定义一个list,存放中缀表达式对应的内容
List<String> list = new ArrayList<String>();
int i = 0;//相当于一个指针,用于遍历中缀表达式字符串
String string;//用于多位数拼接
char c;//每遍历到一个字符,就放到c中
do {
//如果是一个非数字,就需要加入到ls中
if ((c = s.charAt(i)) < 48 || (c = s.charAt(i)) > 57) {
list.add("" + c);
i++;
} else {
//如果是一个数字,需要考虑多位数的问题
string = "";//将string置空
while (i < s.length() && (c = s.charAt(i)) > 48 && (c = s.charAt(i)) < 57) {
string += c;//拼接
i++;
}
list.add(string);
}
} while (i < s.length());
return list;
}

将得到的中缀表达式对应的list转换为对应的后缀表达式

public static List<String> ParseSuffixExpressionList(List<String> list) {
//定义两个栈
//定义一个栈用来装符号
Stack<String> stack1 = new Stack<String>();//符号栈
//因为在整个过程中,s2没有pop操作,并且在最终需要逆序输出,所以直接用List来代替
//再定义一个List,用来存储中间结果
List stack2 = new ArrayList<String>();//存储中间结果的List

//遍历list
for (String item : list) {
//如果是一个数字,加入S2
if (item.matches("\\d+")) {
stack2.add(item);
} else if (item.equals("(")) {
//如果是左括号“(”,则直接压入s1
stack1.push(item);
} else if (item.equals(")")) {
//如果是右括号“)”,则依次弹出s1栈顶的运算符,并压入s2,直到遇到左括号为止,此时将这一对括号丢弃
while (!stack1.peek().equals("(")) {
stack2.add(stack1.pop());
}
stack1.pop();//将“(”弹出
} else {
//当item的优先级小于等于s1栈顶的运算符,将s1栈顶的元素运算符弹出并压到s2中,在此转到(4,1)与s1中新的栈顶运算符相比较
//问题:我们缺少一个比较优先级高低的方法
//Operation:用于比较运算符优先级高低
while (stack1.size() != 0 && Operation.getValue(stack1.peek()) >= Operation.getValue(item)) {
stack2.add(stack1.pop());
}
//还需要将item压入栈
stack1.push(item);
}
}
//将s1中剩余的运算符一次弹出并加入到s2
while (stack1.size() != 0) {
stack2.add(stack1.pop());
}
return stack2;
}

编写一个Operation类,可以返回一个运算符对应的优先级

class Operation {
private static int ADD = 1;
private static int SUB = 1;
private static int MUL = 2;
private static int DIV = 2;

//写一个方法,返回对应的优先级数字
public static int getValue(String operation) {
int result = 0;
switch (operation) {
case "+":
result = ADD;
break;
case "-":
result = SUB;
break;
case "x":
result = MUL;
break;
case "/":
result = DIV;
break;
default:
System.out.println("不存在该运算符");
break;
}
return result;
}
}

测试类

public static void main(String[] args) {

//将中缀表达式转成对应的list
String expression = "(3+4)x5-6";
List<String> infisExpressionList = toInfixExpressionList(expression);

List<String> parseSuffixExpressionList = ParseSuffixExpressionList(infisExpressionList);

System.out.println(parseSuffixExpressionList);

int result = calculate(parseSuffixExpressionList);
System.out.println("结果为:" + result);

}

完整代码

import java.util.ArrayList;
import java.util.List;
import java.util.Stack;

public class PolandNotation {

public static void main(String[] args) {

//将中缀表达式转成对应的list
String expression = "(3+4)x5-6";
List<String> infisExpressionList = toInfixExpressionList(expression);

List<String> parseSuffixExpressionList = ParseSuffixExpressionList(infisExpressionList);

System.out.println(parseSuffixExpressionList);

int result = calculate(parseSuffixExpressionList);
System.out.println("逆波兰表达式的结果为:" + result);


//(3+4)×5-6 --> 3 4 + 5 × 6 -
//将逆波兰表达式的数字和符号用空格分开
//String suffExpression = "3 4 + 5 x 6 -";
//String suffExpression = "4 5 x 8 - 60 + 8 2 / +";//4*5-8+60+8/2

//将逆波兰表达式存放在ArrayList中
//List<String> lst = getListString(suffExpression);
//System.out.println(lst);
//计算逆波兰表达式
//int result = calculate(parseSuffixExpressionList);
//System.out.println("逆波兰表达式的结果为:" + result);
}

//将得到的中缀表达式对应的list转换为对应的后缀表达式
public static List<String> ParseSuffixExpressionList(List<String> list) {
//定义两个栈
//定义一个栈用来装符号
Stack<String> stack1 = new Stack<String>();//符号栈
//因为在整个过程中,s2没有pop操作,并且在最终需要逆序输出,所以直接用List来代替
//再定义一个List,用来存储中间结果
List stack2 = new ArrayList<String>();//存储中间结果的List

//遍历list
for (String item : list) {
//如果是一个数字,加入S2
if (item.matches("\\d+")) {
stack2.add(item);
} else if (item.equals("(")) {
//如果是左括号“(”,则直接压入s1
stack1.push(item);
} else if (item.equals(")")) {
//如果是右括号“)”,则依次弹出s1栈顶的运算符,并压入s2,直到遇到左括号为止,此时将这一对括号丢弃
while (!stack1.peek().equals("(")) {
stack2.add(stack1.pop());
}
stack1.pop();//将“(”弹出
} else {
//当item的优先级小于等于s1栈顶的运算符,将s1栈顶的元素运算符弹出并压到s2中,在此转到(4,1)与s1中新的栈顶运算符相比较
//问题:我们缺少一个比较优先级高低的方法
//Operation:用于比较运算符优先级高低
while (stack1.size() != 0 && Operation.getValue(stack1.peek()) >= Operation.getValue(item)) {
stack2.add(stack1.pop());
}
//还需要将item压入栈
stack1.push(item);
}
}
//将s1中剩余的运算符一次弹出并加入到s2
while (stack1.size() != 0) {
stack2.add(stack1.pop());
}
return stack2;
}

//将中缀表达式转成对应的list
public static List<String> toInfixExpressionList(String s) {
//定义一个list,存放中缀表达式对应的内容
List<String> list = new ArrayList<String>();
int i = 0;//相当于一个指针,用于遍历中缀表达式字符串
String string;//用于多位数拼接
char c;//每遍历到一个字符,就放到c中
do {
//如果是一个非数字,就需要加入到ls中
if ((c = s.charAt(i)) < 48 || (c = s.charAt(i)) > 57) {
list.add("" + c);
i++;
} else {
//如果是一个数字,需要考虑多位数的问题
string = "";//将string置空
while (i < s.length() && (c = s.charAt(i)) > 48 && (c = s.charAt(i)) < 57) {
string += c;//拼接
i++;
}
list.add(string);
}
} while (i < s.length());
return list;
}

//对逆波兰表达式的计算
public static int calculate(List<String> lst) {
//定义一个栈
Stack<String> stack = new Stack<>();
for (String lst1 : lst) {
if (lst1.matches("\\d+"))//利用正则表达式来取值,匹配的是多位数
{
stack.push(lst1);
} else {
//弹出两个数进行运算
int num2 = Integer.parseInt(stack.pop());
int num1 = Integer.parseInt(stack.pop());
int res = 0;
if (lst1.equals("+")) {
res = num1 + num2;
} else if (lst1.equals("-")) {
res = num1 - num2;
} else if (lst1.equals("x")) {
res = num1 * num2;
} else if (lst1.equals("/")) {
res = num1 / num2;
} else {
throw new RuntimeException("运算符有误");
}
//res入栈
stack.push("" + res);
}

}
return Integer.parseInt(stack.pop());
}

}

//编写一个Operation类,可以返回一个运算符对应的优先级
class Operation {
private static int ADD = 1;
private static int SUB = 1;
private static int MUL = 2;
private static int DIV = 2;

//写一个方法,返回对应的优先级数字
public static int getValue(String operation) {
int result = 0;
switch (operation) {
case "+":
result = ADD;
break;
case "-":
result = SUB;
break;
case "x":
result = MUL;
break;
case "/":
result = DIV;
break;
default:
System.out.println("不存在该运算符");
break;
}
return result;
}
}

递归

递归就是方法自己调用自己,每次调用时传入不同的变量,递归有助于编程者解决复杂的问题,同时可以让代码变得简洁。

image-20211210214042841

递归调用规则:

  1. **当程序执行到一个方法时,就会开辟一个独立的空间(栈)**。

  2. 每个空间的数据(局部变量),是独立的。

递归能解决什么样的问题

  1. 各种数学问题如:8 皇后问题,汉诺塔,阶乘问题,迷宫问题,球和篮子的问题(google编程大赛)。
  2. 各 种算法中也会使用到递归,比如快排,归并排序,二分查找,分治算法等。
  3. 将用栈解决的问题转换为用归代码,比较简洁。

递归需要遵守的重要规则

  1. 执行一个方法时,就创建一个新的受保护的独立空间(栈空间);
  2. 方法的局部变量是独立的,不会相互影响,比如n变量;
  3. 如果方法中使用的是引用类型变量(比如数组),就会共享该引用类型的数据;
  4. 递归必须向退出递归的条件逼近, 否则就是无限递归,出现栈溢出(StackOverflowError),死龟了:)
  5. 当一个方法执行完毕,或者遇到return,就会返回,遵守谁调用,就将结果返回给谁,同时当方法执行完毕或者返回时,该方法也就执行完毕。

迷宫回溯

  1. 小球得到的路径,和程序员设置的找路策略有关即:找路的上下左右的顺序相关
  2. 再得到小球路径时,可以先使用(下右上左),再改成(上右下左),看看路径是不是有变化
  3. 测试回溯现象
  4. 思考: 如何求出最短路径?
    • 采取不同策略,将结果用list集合存起来,然后将不同策略算出的不同结果进行比较。

思路分析

image-20211211000004332

  1. map 表示地图;
  2. i,j 表示从地图的哪个位置开始出发 (1,1);
  3. 如果小球能到 map[6][5] 位置,则说明通路找到;
  4. 约定: 当map[i][j] 为 0 表示该点没有走过; 当为 1 表示墙 ; 2 表示通路可以走 ; 3 表示该点已经走过,但是走不通;
  5. 在走迷宫时,需要确定一个策略(方法) 下->右->上->左 , 如果该点走不通,再回溯;

代码实现

public class MiGong {

public static void main(String[] args) {
// 先创建一个二维数组,模拟迷宫地图。
int[][] map = new int[8][7];
// 使用 1 表示墙。
// 第一行和最后一行全部置为 1 。
for (int i = 0; i < 7; i++) {
map[0][i] = 1;
map[7][i] = 1;
}
// 第一列和最后一列全部置为 1 。
for (int i = 1; i < 7; i++) {
map[i][0] = 1;
map[i][6] = 1;
}
// 设置挡板, 用 1 表示。
map[3][1] = 1;
map[3][2] = 1;

// 输出地图。
System.out.println("地图的情况~~");
for (int i = 0; i < 8; i++) {
for (int j = 0; j < 7; j++) {
System.out.print(map[i][j] + "\t");
}
System.out.println();
}

// 使用递归回溯给小球找路。
setWay(map, 1, 1);
// 输出新的地图, 小球走过,并标识过的递归。
System.out.println("迷宫问题路径地图的情况~~");
for (int i = 0; i < 8; i++) {
for (int j = 0; j < 7; j++) {
System.out.print(map[i][j] + "\t");
}
System.out.println();
}
}

//使用递归回溯来给小球找路。
//说明:
//1. map 表示地图
//2. i,j 表示从地图的哪个位置开始出发 (1,1)
//3. 如果小球能到 map[6][5] 位置,则说明通路找到
//4. 约定: 当map[i][j] 为 0 表示该点没有走过; 当为 1 表示墙 ; 2 表示通路可以走 ; 3 表示该点已经走过,但是走不通。
//5. 在走迷宫时,需要确定一个策略(方法) 下->右->上->左 , 如果该点走不通,再回溯

/**
* 下右上左
*
* @param map 表示地图
* @param i 从哪个位置开始找
* @param j
* @return 如果找到通路,就返回true, 否则返回false
*/
public static boolean setWay(int[][] map, int i, int j) {
if (map[6][5] == 2) { // 通路已经找到,返回true。
return true;
} else {
if (map[i][j] == 0) { // 如果当前这个点还没有走过, 按照策略 下->右->上->左 走。
map[i][j] = 2; // 假定该点是可以走通。
if (setWay(map, i + 1, j)) { // 向下走。
return true;
} else if (setWay(map, i, j + 1)) { // 向右走。
return true;
} else if (setWay(map, i - 1, j)) { // 向上走。
return true;
} else if (setWay(map, i, j - 1)) { // 向左走。
return true;
} else { // 说明该点是走不通,是死路。
map[i][j] = 3;
return false;
}
} else { // 如果map[i][j] != 0 , 可能是 1, 2, 3。
return false;
}
}
}
// 上->右->下->左 的策略

/**
* @return 如果找到通路,就返回true, 否则返回false
*/
public static boolean setWay2(int[][] map, int i, int j) {
if (map[6][5] == 2) { // 通路已找到
return true;
} else {
if (map[i][j] == 0) { // 如果当前这个点还没有走过
// 按照策略 上->右->下->左
map[i][j] = 2; // 假定该点是可以走通.
if (setWay2(map, i - 1, j)) { // 向上走
return true;
} else if (setWay2(map, i, j + 1)) { // 向右走
return true;
} else if (setWay2(map, i + 1, j)) { // 向下走
return true;
} else if (setWay2(map, i, j - 1)) { // 向左走
return true;
} else {
// 说明该点是走不通,是死路
map[i][j] = 3;
return false;
}
} else { // 如果map[i][j] != 0 , 可能是 1, 2, 3
return false;
}
}
}
}

运行结果

image-20211210235853548

八皇后问题

八皇后问题,是一个古老而著名的问题,是回溯算法的典型案例。该问题是:在8×8格的国际象棋上摆放八个皇后,使其不能互相攻击,即:任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法

image-20211211000353208

思路分析

  1. 第一个皇后先放第一行第一列
  2. 第二个皇后放在第二行第一列、然后判断是否发生冲突。 如果有冲突,就把它放在第二列、第三列、依次把所有列都放完,找到一个合适的位置
  3. 继续第三个皇后,还是第一列、第二列……直到第8个皇后也能放在一个不冲突的位置。这就算是找到了一个正确解
  4. 当得到一个正确解时,就继续改变皇后的位置(即:除第一个皇后外的每一个皇后,把在每一个位置上的所有情况全部运行一遍),继续回溯。直到将第一个皇后,在第一列上时,所有出现的正确解,全部得到
  5. 然后从头重新开始,将第一个皇后放在第二列,后面继续循环执行 1,2,3,4的步骤

说明:理论上应该创建一个二维数组来表示棋盘,但是实际上可以通过算法,用一个一维数组即可解决问题。

arr[8]={0,4,7,5,2,6,1,3}//对应arr下标,表示第i+1个皇后在第i+1行的a[i]列

代码实现

public class EightQueue {
//定义一个max:表示共有多少个皇后
int max = 8;
//定义数组array, 保存皇后放置位置的结果
int[] array = new int[max];
static int count = 0;
static int judgeCount = 0;

public static void main(String[] args) {
//运行
EightQueue queue8 = new EightQueue();
queue8.check(0);
System.out.println("一共有" + count + "解法");
System.out.println();
System.out.println("一共判断冲突的次数" + judgeCount + "次");

}

/**
* 编写一个方法,放置第n个皇后
* @param n 表示第几个皇后
*/
private void check(int n) {
if (n == max) { //n = 8 时,证明8个皇后都放好位置
print();
return;
}
//依次放入皇后,并判断是否冲突
for (int i = 0; i < max; i++) {
//先把当前这个皇后 n , 放到该行的第1列
array[n] = i;
//判断当放置第n个皇后到i列时,是否冲突
if (judge(n)) { // 不冲突
//接着放下一个皇后,即开始递归
check(n + 1);
}
//如果冲突,就继续执行 array[n] = i; 即将第n个皇后,放置在本行得 后移的一个位置
}
}

//

/**
* 当我们放置第n个皇后, 就去检测该皇后是否和前面已经摆放的皇后冲突
* @param n
* @return
*/
private boolean judge(int n) {
judgeCount++;
for (int a = 0; a < n; a++) {
//说明
//array[i] == array[n] 判断第n个皇后是否和前面的皇后在同一列
//Math.abs(n-i) == Math.abs(array[n] - array[i]) 判断第n个皇后是否和第a个皇后在同一斜线
//横坐标相减是否等于纵坐标相减
//一维数组, 判断是否在同一行, 没有必要
if (array[a] == array[n] || Math.abs(n - a) == Math.abs(array[n] - array[a])) {
return false;
}
}
return true;
}

/**
* 写一个方法,可以将皇后摆放的位置输出
*/
private void print() {
count++;
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + " ");
}
System.out.println();
}

}

运行结果

0 4 7 5 2 6 1 3 
0 5 7 2 6 3 1 4
0 6 3 5 7 1 4 2
0 6 4 7 1 3 5 2
1 3 5 7 2 0 6 4
1 4 6 0 2 7 5 3
1 4 6 3 0 7 5 2
1 5 0 6 3 7 2 4
1 5 7 2 0 3 6 4
1 6 2 5 7 4 0 3
1 6 4 7 0 3 5 2
1 7 5 0 2 4 6 3
2 0 6 4 7 1 3 5
2 4 1 7 0 6 3 5
2 4 1 7 5 3 6 0
2 4 6 0 3 1 7 5
2 4 7 3 0 6 1 5
2 5 1 4 7 0 6 3
2 5 1 6 0 3 7 4
2 5 1 6 4 0 7 3
2 5 3 0 7 4 6 1
2 5 3 1 7 4 6 0
2 5 7 0 3 6 4 1
2 5 7 0 4 6 1 3
2 5 7 1 3 0 6 4
2 6 1 7 4 0 3 5
2 6 1 7 5 3 0 4
2 7 3 6 0 5 1 4
3 0 4 7 1 6 2 5
3 0 4 7 5 2 6 1
3 1 4 7 5 0 2 6
3 1 6 2 5 7 0 4
3 1 6 2 5 7 4 0
3 1 6 4 0 7 5 2
3 1 7 4 6 0 2 5
3 1 7 5 0 2 4 6
3 5 0 4 1 7 2 6
3 5 7 1 6 0 2 4
3 5 7 2 0 6 4 1
3 6 0 7 4 1 5 2
3 6 2 7 1 4 0 5
3 6 4 1 5 0 2 7
3 6 4 2 0 5 7 1
3 7 0 2 5 1 6 4
3 7 0 4 6 1 5 2
3 7 4 2 0 6 1 5
4 0 3 5 7 1 6 2
4 0 7 3 1 6 2 5
4 0 7 5 2 6 1 3
4 1 3 5 7 2 0 6
4 1 3 6 2 7 5 0
4 1 5 0 6 3 7 2
4 1 7 0 3 6 2 5
4 2 0 5 7 1 3 6
4 2 0 6 1 7 5 3
4 2 7 3 6 0 5 1
4 6 0 2 7 5 3 1
4 6 0 3 1 7 5 2
4 6 1 3 7 0 2 5
4 6 1 5 2 0 3 7
4 6 1 5 2 0 7 3
4 6 3 0 2 7 5 1
4 7 3 0 2 5 1 6
4 7 3 0 6 1 5 2
5 0 4 1 7 2 6 3
5 1 6 0 2 4 7 3
5 1 6 0 3 7 4 2
5 2 0 6 4 7 1 3
5 2 0 7 3 1 6 4
5 2 0 7 4 1 3 6
5 2 4 6 0 3 1 7
5 2 4 7 0 3 1 6
5 2 6 1 3 7 0 4
5 2 6 1 7 4 0 3
5 2 6 3 0 7 1 4
5 3 0 4 7 1 6 2
5 3 1 7 4 6 0 2
5 3 6 0 2 4 1 7
5 3 6 0 7 1 4 2
5 7 1 3 0 6 4 2
6 0 2 7 5 3 1 4
6 1 3 0 7 4 2 5
6 1 5 2 0 3 7 4
6 2 0 5 7 4 1 3
6 2 7 1 4 0 5 3
6 3 1 4 7 0 2 5
6 3 1 7 5 0 2 4
6 4 2 0 5 7 1 3
7 1 3 0 6 4 2 5
7 1 4 2 0 6 3 5
7 2 0 5 1 4 6 3
7 3 0 2 5 1 6 4
一共有92解法

一共判断冲突的次数15720次

排序算法

排序也称排序算法(Sort Algorithm), 排序是将一组数据,依指定的顺序进行排列的过程。

排序的分类

  1. 内部排序:

    • 指将需要处理的所有数据都加载到内部存储器(内存)中进行排序。
  2. 外部排序法:

    • 数据量过大,无法全部加载到内存中,需要借助外部存储进行排序。
  3. 常见的排序算法分类

    image-20211212130931507

算法的时间复杂度

度量一个程序(算法)执行时间的两种方法

  1. 事后统计的方法
    • 这种方法可行,但是有两个问题:
      • 一是要想对设计的算法的运行性能进行评测,需要实际运行该程序;
      • 二是所得时间的统计量依赖于计算机的硬件、软件等环境因素,这种方式,要在同一台计算机的相同状态下运行,才能比较那个算法速度更快。
  2. 事前估算的方法
    • 通过分析某个算法的时间复杂度来判断哪个算法更优。

时间频度

一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为语句频度或时间频度。记为T(n)。

举例说明

计算1-100所有数字之和,我们设计两种算法:

image-20211212132705753

特点

随着n的增大,可以忽略常数项、忽略低次项、忽略系数

时间复杂度

  1. 一般情况下,算法中的基本操作语句的重复执行次数是问题规模n的某个函数,用T(n)表示,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n) /f(n)的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数。 记作T(n)= 0(f(n)),称O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度。
  2. T(n)不同,但时间复杂度可能相同。如: T(n)=n2+7n+6与T(n)=3n2+2n+2它们的T(n)不同,但时间复杂度相同,都为0(n2)。
  3. 计算时间复杂度的方法:
    • 用常数1代替运行时间中的所有加法常数T(n)=n2+7n+6 =>T(n)=n2+7n+1
    • 修改后的运行次数函数中,只保留最高阶项T(n)=n2+7n+1=>T(n)=n22
    • 去除最高阶项的系数T(n)=n2=> T(n)= n2 => O(n2)

平均时间复杂度和最坏时间复杂度

  1. 平均时间复杂度是指所有可能的输入实例均以等概率出现的情况下,该算法的运行时间。
  2. 最坏情况下的时间复杂度称最坏时间复杂度。一般讨论的时间复杂度均是最坏情况下的时间复杂度。这样做的原因是:最坏情况下的时间复杂度是算法在任何输入实例上运行时间的界限,这就保证了算法的运行时间不会比最坏情况更长。
  3. 平均时间复杂度和最坏时间复杂度是否一致,和算法有关。

image-20211212135106225

常见的时间复杂度

常数阶O(1)

对数阶O(log2n)

线性阶O(n)

线性对数阶0(nlog2n)

平方阶O(n2)

立方阶O(n3)

k次方阶O(nk)

指数阶0(2n)

image-20211212141133300

  • 常见的算法时间复杂度由小到大依次为: O(1)<O(log2n)<O(n) <O(nlog2n)<O(n2)<O(n3)< O(nk) <O(2n), 随着问题规模n的不断增大, 上述时间复杂度不断增大,算法的执行效率越低

  • 从图中可见,我们应该尽可能避免使用指数阶的算法

时间复杂度举例

常数阶O(1)

image-20211212141705536

对数阶O(log2n)

image-20211212142108281

线性阶O(n)

image-20211212142141613

线性对数阶0(nlog2n)

image-20211212142008959

平方阶O(n2)

image-20211212142312809

立方阶O(n3)、k次方阶O(nk)

说明:参考上面的O(n2)去理解就好了,O(n3)相当于三层n循环,其它的类似

指数阶0(2n)

算法的空间复杂度

  1. 类似于时间复杂度的讨论,一个算法的空间复杂度(Space Complexity)定义为该算法所耗费的存储空间,它也是间题规模n的函数。
  2. 空间复杂度(Space Complexity)是对一个 算法在运行过程中临时占用存储空间大小的量度。有的算法需要占用的临时工作单元数与解决问题的规模n有关,它随着n的增大而增大,当n较大时,将占用较多的存储单元,例如快速排序和归并排序算法就属于这种情况。
  3. 在做算法分析时,主要讨论的是时间复杂度。从用户使用体验上看,更看重的程序执行的速度。一些缓存产品(redis, memcache)和算法(基数排序)本质就是用空间换时间。

冒泡排序

排序思想

通过对待排序序列从前向后(从下标较小的元素开始),依次比较相邻元素的值,若发现逆序则交换,使值较大的元素逐渐从前移向后部,就象水底下的气泡一样逐渐向上冒。

因为排序的过程中,各元素不断接近自己的位置,如果一趟比较下来没有进行过交换,就说明序列有序,因此要在排序过程中设置一个标志flag判断元素是否进行过交换。从而减少不必要的比较。

排序规则

  1. 一共进行 数组的大小-1 次的循环
  2. 每一趟排序的次数在逐渐的减少
  3. 如果我们发现在某趟排序中,没有发生一次交换, 可以提前结束冒泡排序。

代码实现

//冒泡排序      时间复杂度为O(n^2)
public static void bubbleSort(int[] arr) {
int temp = 0;//临时变量
boolean flag = false;//标识变量 表示没发生交换
for (int i = 0; i < arr.length - 1; i++) {//i表示当前是第i+1趟排序
//第i+1趟排序就是把第i+1大的数放在倒数第i+1的位置
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
flag = true;
temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
if (!flag) {
break;//在一趟排序中一次交换也没发生 则说明数组已经有序 break即可
} else {
flag = false;//重置flag 进行下一次标识
}
}
}

选择排序

排序思想

选择式排序也属于内部排序法,是从欲排序的数据中,按指定的规则选出某一元素,再依规定交换位置后达到排序的目的。

第一次从arr[0] 到arr[n-1]中选取最小值,与arr[0]交换,第二次从arr[1]到arr[n-1]中选取最小值,与arr[1]交换,第三次从arr[2]到arr[n-1]中选取最小值,与arr[2]交换,…,第i次从arr[i-1]到arr[n-1]中选取最小值,与arr[i-1]交换,…, 第n-1次从arr[n-2]~arr[n-1]中选取最小值,与arr[n-2]交换,总共通过n-1次,得到一个按排序码从小到大排列的有序序列。

排序规则

  1. 选择排序一共有 数组大小 -1 次的排序
  2. 每1轮排序,又是一个循环,循环的规则
    1. 先假定当前这个数是最小数
    2. 然后和后面的每个数进行比较,如果发现有比当前数更小的数,就重新确定最小数,并得到下标
    3. 当遍历到数组的最后时,就得到本轮最小数和下标
    4. 交换

代码实现

//选择排序      时间复杂度为O(n^2)
public static void selectSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
int minIndex = i;//第一轮默认最小值下标是0
int min = arr[i];//第一轮默认最小值为第一个数
for (int j = i + 1; j < arr.length; j++) {
if (min > arr[j]) { //如果按从大到小排序则改成min < arr[j]即可
min = arr[j];//重置最小值
minIndex = j;//重置最小值下标
}
}
//交换 优化:如果最小值没变化就不必交换
if (minIndex != i) {
arr[minIndex] = arr[i]; //arr[0]=101->arr[3]=101
arr[i] = min;//arr[0]=101->arr[0]=1
}
}
}

插入排序

排序思想

把n个待排序的元素看成为一个有序表和一个无序表,开始时有序表中只包含一个元素,无序表中包含有n-1个元素,排序过程中每次从无序表中取出第一个元素,把它的排序码依次与有序表元素的排序码进行比较,将它插入到有序表中的适当位置,使之成为新的有序表。

image-20211216145845379

代码实现

 //插入排序
public static void insertSort(int[] arr) {
int insertVal = 0;//待插入的数
int insertIndex = 0;
for (int i = 1; i < arr.length; i++) {//开始操作无序表
insertVal = arr[i];//待插入的数
insertIndex = i - 1;//带插入位置的索引 插入到前一位 所以-1(有序表的下标)
//说明:1、insertIndex >= 0 保证在给insertIndex找插入位置时不越界
// 2、insertVal < arr[insertIndex] 说明还没找到插入位置
// 3、需要将arr[insertIndex]后移
while (insertIndex >= 0 && insertVal < arr[insertIndex]) {
arr[insertIndex + 1] = arr[insertIndex];
insertIndex--;
}//退出循环时 表示已经找到要插入的位置
if (insertIndex + 1 != i) {//优化:如果找的的要插入位置就是其本身所在位置 则无需再赋值
arr[insertIndex + 1] = insertVal;
}

// System.out.println("第" + i + "轮插入排序后:" + Arrays.toString(arr));
}
}

希尔排序

排序思想

希尔排序也是一种插入排序,他是简单插入排序经过改进后的一个更高效的版本,也被称为缩小增量排序

希尔排序是把记录按下标的一定增量分组,对每组使用直接插入排序算法排序;随着增量逐渐减少,每组包含的关键词越来越多,当增量减至1时,整个文件恰被分成一组,算法便终止
思路分析:

image-20211212225301053

代码实现——交换法

//希尔排序-----交换法
public static void shellSort(int[] arr) {
int temp = 0;
int count = 0;
//增量gap 逐渐缩小增量
for (int gap = arr.length / 2; gap > 0; gap /= 2) {
//遍历各组中所有的元素,共gap组,每一组有2^n/2^n+1个元素.步长为gap
for (int i = gap; i < arr.length; i++) {
for (int j = i - gap; j >= 0; j -= gap) {
//a[j]表示要和a[i]比较的那个数字,例如:10个数字时。i=5,j=0,下面就是a[0]和a[5]比较
//如果当前元素大于加上步长后的那个元素交换
if (arr[j] > arr[j + gap]) {
temp = arr[j];
arr[j] = arr[j + gap];
arr[j + gap] = temp;
}
}
}
//System.out.println("第" + (++count) + "轮希尔排序的后:" + Arrays.toString(arr));
}
}

代码实现——位移法(改进,引入了插入排序)

//希尔排序-----移位法(对交换法进行优化  速度快了)
public static void shellSort2(int[] arr) {
for (int gap = arr.length / 2; gap > 0; gap /= 2) {
//从第gap个元素开始 逐个对其所在组进行直接插入排序
for (int i = gap; i < arr.length; i++) {
//直接插入排序
int j = i;//保存待插入位置的下标 类似insertIndex
int temp = arr[j];//保存要插入的数据 类似insertVal
if (arr[j] < arr[j - gap]) {
while (j - gap >= 0 && temp < arr[j - gap]) {
//移动
arr[j] = arr[j - gap];
j -= gap;//步长为gap
}
//退出while循环后就表示找到了插入的位置
arr[j] = temp;
}
}
}
}

快速排序

排序思想

快速排序(Quicksort)是对冒泡排序的一种改进。基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。

image-20211213000009658

排序规则

  1. 选定Pivot中心轴
  2. 将大于Pivot的数字放在Pivot的右边
  3. 将小于Pivot的数字放在Pivot的左边
  4. 分别对左右子序列重复前三步操作

代码实现1

public static void quickSort2(int[] arr, int left, int right) {
int l = left;//左下标
int r = right;//右下标
int pivot = arr[(left + right) / 2];//中缀
int temp = 0;//用于交换的临时变量
//while循环的目的是让比prvot值小的放到左边,比prvot值大的放到右边
while (l < r) {
//l和r交替移动

//在pivot的左边一直找,找到大于等于pivot的值,才退出(左边的数字要确保小于pivot)
while (arr[l] < pivot) {
l += 1;
}
//在pivot的右边一直找,找到小于等于pivot的值,才退出(右边的数字要确保大于pivot)
while (arr[r] > pivot) {
r -= 1;
}
//如果l >= r,说明pivot左右两边的值已经被划分开
//左边的都是小于pivot的值,右边的都是大于pivot的值
if (l >= r) {
break;
}
//在满足l >= r之前,就继续进行左右交换
temp = arr[l];
arr[l] = arr[r];
arr[r] = temp;

//当左右两边中,任意一边有值等于pivot时,确保另一边继续进行,直到循环结束
//如果交换完后,发现arr[l] == pivot,就对r进行--操作(前移一步)
if (arr[l] == pivot) {
r -= 1;
}

//如果交换完后,发现arr[r] == pivot,就对l进行++操作(后移一步)
if (arr[r] == pivot) {
l += 1;
}
}
//如果l==r,必须l++,r--,否则会栈溢出(当l==r时,相当于两个数都指向pivot,所以一直在原值交换)
if (l == r) {
l += 1;
r -= 1;
}
//向左递归
if(left<r){
quickSort2(arr,left,r);
}
//向右递归
if(l>right){
quickSort2(arr,l,right);
}
}

代码实现2

//快速排序
public static void quickSort(int[] arr, int start, int end) {
if (start < end) {
//将数组中的第0个数字 设置为基准数
int pivot = arr[start];
//记录需要排序的下标
int left = start;
int right = end;
while (left < right) {
//右边的数比基准数大 则向左移动right指针
while (left < right && arr[right] >= pivot) {
right--;
}//退出此while循环说明当前arr[right]<pivot
arr[left] = arr[right];//用right位的数覆盖left位的数
//左边的数比基准数小 则向右移动low指针
while (left < right && arr[left] <= pivot) {
left++;
}//退出此while循环说明当前arr[left]>pivot
arr[right] = arr[left];//用left位的数覆盖right位的数
}
//left和right指针相遇时 将基准数赋给此下标处
arr[left] = pivot;
//System.out.println("第一次快速排序后的数组为:" + Arrays.toString(arr));
//递归
//处理所有比基准数小的数字
quickSort(arr, start, left);
//处理所有比基准数大的数字
quickSort(arr, left + 1, end);//因为left是基准数,所以需要从+1项开始递归
}
}

归并排序

排序思想

归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题====(divide)成一些小的问题然后递归求解,而====(conquer)的阶段则将分的阶段得到的各答案”修补”在一起,即分而治之)。

image-20211213004254111

说明

可以看到这种结构很像一棵完全二叉树,本文的归并排序我们采用递归去实现(也可采用迭代的方式去实现)。分阶段可以理解为就是递归拆分子序列的过程。

image-20211213004320936

image-20211213004345110

代码实现

 //归并排序
public static void mergeSort(int[] arr, int left, int right, int[] temp) {
if (left < right) {
int mid = (left + right) / 2;
//向左递归分解
mergeSort(arr, 0, mid, temp);
//向右递归分解
mergeSort(arr, mid + 1, right, temp);
//合并
merge(arr, left, right, mid, temp);
}
}

/**
* 合并
* @param arr 需要排序的原始数组
* @param left 左边有序序列的初始索引
* @param right 右边有序序列的初始索引
* @param mid 中间索引
* @param temp 做中转的数组
* @return void
*/
public static void merge(int[] arr, int left, int right, int mid, int[] temp) {
//
int i = left;//初始化i,左边有序序列的初始索引
int j = mid + 1;//初始化k,右边有序序列的初始索引
int t = 0;//指向temp数组的当前索引
//1、先把左右两边数组按照规则填充到temp数组
// 直到左右两边的有序序列有一边处理完毕
while (i <= mid && j <= right) {
//若左边序列的当前元素<=右边序列的当前元素
if (arr[i] <= arr[j]) {//将左边的填充到temp中
temp[t] = arr[i];
t+=1;
i+=1;
} else {//将右边的填充到temp中
temp[t] = arr[j];
t+=1;
j+=1;
}
}//两种情况退出当前while循环
//2、把有剩余元素的一边的数据依次全部填充到temp
while (i <= mid) {//左边的有序序列还有剩余的元素,就全部填充到temp中
temp[t] = arr[i];
t+=1;
i+=1;
}
while (j <= right) {//右边的有序序列还有剩余的元素,就全部填充到temp中
temp[t] = arr[j];
t+=1;
j+=1;
}

//3、将temp数组的元素拷贝到arr
//注意:并不是每次都拷贝所有
t = 0;
int tempLeft = left;
while (tempLeft <= right) {//根据合并的次数,每一次的都是不一样的。
//第一轮 0,1 2,3 4,5 6,7 第二轮 0,3 4,7 第三轮 0,7
arr[tempLeft] = temp[t];
t+=1;
tempLeft+=1;
}
}

基数排序

排序思想

基数排序(radix sort)属于“分配式排序”(distribution sort),又称“桶子法”(bucket sort)或bin sort,顾名思义,它是通过键值的各个位的值,将要排序的元素分配至某些“桶”中,达到排序的作用,是桶排序的扩展

基数排序法是属于稳定性的排序,基数排序法的是效率高的稳定性排序法。

基本思想:将所有待比较数值统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。

举例图示:将数组 {53, 3, 542, 748, 14, 214} 使用基数排序, 进行升序排序。

image-20211213191920934

代码实现

package com.datastructures.tree.heap;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;

public class HeapSort {
public static void main(String[] args) {
//对该数组进行升序排列
//int[] arr = {4, 6, 8, 5, 9,0,-1,22,-88};
//heapSort(arr);
//测试堆排序的速度 随机给80000个数 ---
int[] arr=new int[800000];
for (int i=0;i<800000;i++){
arr[i]=(int)(Math.random()*800000);//[0,800000)
}
Date date1 = new Date();
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String date1Str = simpleDateFormat.format(date1);
System.out.println("排序前的时间是:"+date1Str);
heapSort(arr);
Date date2 = new Date();
String date2Str = simpleDateFormat.format(date2);
System.out.println("排序后的时间是:"+date2Str);
}

//编写方法实现堆排序
public static void heapSort(int[] arr) {
int temp = 0;
//1)将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
for (int i = arr.length / 2 - 1; i >= 0; i--) {
adjustHeap(arr, i, arr.length);
}
// System.out.println("调整后的堆为:" + Arrays.toString(arr));
//2)将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
//3)重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
for (int j = arr.length - 1; j > 0; j--) {
//交换
temp = arr[j];
arr[j] = arr[0];//调整好的堆 最大值肯定在数组的第一位
arr[0] = temp;
adjustHeap(arr, 0, j);
}
// System.out.println("堆排序的结果:" + Arrays.toString(arr));


}

//将一个数组(二叉树)调整成一个大顶堆

/**
* 功能:将以i为父节点的树 调整成大顶堆
*
* @param arr 待调整的数组
* @param i i表示非叶子节点在数组中的索引
* @param length 对length个数据进行调整 没调整一次length-1
*/
public static void adjustHeap(int arr[], int i, int length) {
int temp = arr[i];//保存当前元素的值
//调整
for (int k = i * 2 + 1; k < length; k = k * 2 + 1) {
if (k + 1 < length && arr[k] < arr[k + 1]) {//左子节点的值小于右子节点的值
k++;
}
if (arr[k] > temp) {
//把两者中较大的值赋给当前节点arr[i]
arr[i] = arr[k];
i = k;//i指向k继续循环比较
} else {
break;
}
}//for循环结束后 以i为父节点的树的最大值已经放在了最顶部
arr[i] = temp;//将temp的值放在调整后的位置
}

}


基数排序的说明

  1. 基数排序是对传统桶排序的扩展,速度很快.
  2. 基数排序是经典的空间换时间的方式,占用内存很大, 当对海量数据排序时,容易造成 OutOfMemoryError 。
  3. 基数排序是稳定的。
    • 注:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
  4. 有负数的数组,一般不用基数排序来进行排序

总结和对比

image-20211214110818446

  • 稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面;
  • 不稳定:如果a原本在b的前面,而a=b, 排序之后a可能会出现在b的后面;
  • 内排序:所有排序操作都在内存中完成;
  • 外排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行;
  • 时间复杂度:一个算法执行所耗费的时间。
  • 空间复杂度:运行完一个程序所需内存的大小。
  • n:数据规模
  • k:“桶”的个数
  • In-place:不占用额外内存
  • Out-place:占用额外内存

查找算法

在java中,我们常用的查找有四种:

  • 顺序(线性)查找
  • 二分查找/折半查找
  • 插值查找
  • 斐波那契杏找

线性查找

代码实现

public class SeqSearch {
public static void main(String[] args) {
int arr[] = {1, 9, 11, -1, 34, 89};
int index = seqSearch(arr, 11);
if (index == -1) {
System.out.println("没有找到");
} else {
System.out.println("找到数字的第一个位置是" + index);
}
}

//只有一个查找项
public static int seqSearch(int[] arr, int value) {
//线性查找是逐一比对,发现有相同值,就返回下标
for (int i = 0; i < arr.length; i++) {
if (arr[i] == value) {
return i;
}
}
return -1;
}

//有多个相同的查找项
public static List<Integer> seqSearch2(int[] arr, int value) {
ArrayList<Integer> list = new ArrayList<>();
for (int i = 0; i < arr.length; i++) {
if (arr[i] == value) {
list.add(i);
}
}
for (Integer index : list) {
System.out.println("您要找的数据在数组中的下标为:" + index);
}
return null;
}
}

二分查找

思路分析

  1. 首先确定该数组的中间的下标 mid = (left + right) / 2
  2. 然后让需要查找的数 findVal 和 arr[mid] 比较
    1. findVal > arr[mid],说明你要查找的数在mid 的右边, 因此需要递归的向右查找
    2. findVal < arr[mid],说明你要查找的数在mid 的左边, 因此需要递归的向左查找
    3. findVal == arr[mid] 说明找到,就返回
  • 什么时候我们需要结束递归.
    • 找到就结束递归
    • 递归完整个数组,仍然没有找到findVal ,也需要结束递归 当 left > right 就需要退出

代码实现

public class BinarySearch {
public static void main(String[] args) {
int arr[] = {1, 8, 10, 89, 1000, 1234};
int index = binarySearch(arr, 0, arr.length - 1, 1000);
if (index == -1) {
System.out.println("没有找到");
} else {
System.out.println("找到数字的第一个位置是" + index);
}
}

//单个
public static int binarySearch(int[] arr, int left, int right, int findVal) {
int mid = (left + right) / 2;
int midVal = arr[mid];

if (left > right) { //在端点的时候就是left==right
return -1;
}

if (findVal > midVal) { //向右递归
return binarySearch(arr, mid + 1, right, findVal);
} else if (findVal < midVal) { //向左递归
return binarySearch(arr, left, mid - 1, findVal);
} else {
return mid;
}
}

//多个相同值
public static ArrayList<Integer> binarySearch2(int[] arr, int left, int right, int findVal) {
if (left > right) { //递归结束条件
return new ArrayList<Integer>();
}

int mid = (left + right) / 2;
int midVal = arr[mid];

if (findVal > midVal) { //向右递归
return binarySearch2(arr, mid + 1, right, findVal);
} else if (findVal < midVal) { //向左递归
return binarySearch2(arr, left, mid - 1, findVal);
} else {
//有相同值
/*
1、当查找到mid索引值后 向左右两边扫描看是否有多个相同查找值
2、向mid索引值的左边扫描,若存在满足findVal的值则将其加入集合中
3、向mid索引值的右边扫描,若存在满足findVal的值则将其加入集合中
4、不要忘了把mid索引处的数据也加入集合中 最后返回集合即可*/
ArrayList<Integer> list = new ArrayList<>();
int temp = mid - 1;
while (true) {
if (temp < 0 || arr[temp] != findVal) {
break;
} else {
list.add(temp);
temp--;
}
}
list.add(mid);
temp = mid + 1;
while (true) {
if (temp > arr.length - 1 || arr[temp] != findVal) {
break;
} else {
list.add(temp);
temp++;
}
}
return list;
}
}
}

插值查找

思路分析

  1. 插值查找算法类似于二分查找,不同的是插值查找每次从自适应mid处开始查找。

  2. 将折半查找中的求mid索引的公式,low表示左边索引left,high表示右边索引right。

    image-20211214120022075

  3. int midIndex = low + (high - low) * (key - arr[low])/(arr[high]-arr[low]) ;/* 插值 索引 */

  4. 举例说明插值查找算法1-100的数组

    • 数组 arr = [1, 2, 3, ……., 100]

    • 假如我们需要查找的值 1

    • 使用二分查找的话,我们需要多次递归,才能找到 1

    • 使用插值查找算法

      • ==int mid = left + (right – left) * (findVal – arr[left]) / (arr[right] – arr[left])==
      • int mid = 0 + (99 - 0) * (1 - 1)/ (100 - 1) = 0 + 99 * 0 / 99 = 0
    • 比如我们查找的值 100

      • int mid = 0 + (99 - 0) * (100 - 1) / (100 - 1) = 0 + 99 * 99 / 99 = 0 + 99 = 99

代码实现

public class InsertValueSearch {
public static void main(String[] args) {
/* int[] arr = new int[100];
for (int i = 0; i < 100; i++) {
arr[i] = i + 1;
}*/
int[] arr = {1, 8, 10, 89, 1000,1000,1000, 1234};
int index = insertValSearch(arr, 0, arr.length-1, 1234);
System.out.println("index="+index);
}

//插值查找 要求数组有序
public static int insertValSearch(int[] arr, int left, int right, int findVal) {
//findVal < arr[0] || findVal > arr[arr.length - 1] 必须有 防止mid越界
if (left > right || findVal < arr[0] || findVal > arr[arr.length - 1]) {
return -1;
}
int mid = left + (right - left) * (findVal - arr[left]) / (arr[right] - arr[left]) ;
int midVal = arr[mid];
if (findVal > midVal) {
return insertValSearch(arr, mid + 1, right, findVal);//向右递归
} else if (findVal < midVal) {
return insertValSearch(arr, left, mid - 1, findVal);//向左递归
} else {
return mid;
}
}
}

注意事项

  1. 对于数据量较大,关键字分布比较均匀的查找表来说,采用插值查找,速度较快.
  2. 关键字分布不均匀的情况下,该方法不一定比折半查找要好。

斐波那契查找

基本介绍

  1. 黄金分割点是指把一条线段分割为两部分,使其中一部分与全长之比等于另一部分与这部分之比。取其前三位数字的近似值是0.618。由于按此比例设计的造型十分美丽,因此称为黄金分割,也称为中外比。这是一个神奇的数字,会带来意向不大的效果。
  2. 斐波那契数列{1,1,2,3,5,8,13,21,34,55}发现斐波那契数列的两个相邻数的比例,无限接近黄金分割值0.618。

原理

斐波那契查找原理与前两种相似,仅仅改变了中间结点(mid) 的位置,mid不再是中间或插值得到,而是位于黄金分割点附近,即mid=low+F(k-1)-1(F代表斐波那契数列),如下图所示

image-20211214123746482

对F(k-1)-1的理解:

  1. 由斐波那契数列 F[k]=F[k-1]+F[k-2]的性质,可以得到**(F[k]-1) = (F[k-1]-1) + (F[k-2]-1) +1**。
    • 该式说明:只要顺序表的长度为F[k]-1,则可以将该表分成长度为F[k-1]-1F[k-2]-1的两段,即如上图所示。从而中间位置为mid=low+F(k-1)-1
  2. 类似的,每一子段也可以用相同的方式分割
  3. 但顺序表长度n不一定刚好等于F[k]-1,所以需要将原来的顺序表长度n增加至F[k]-1。这里的k值只要能使得F[k]-1恰好大于或等于n即可,由以下代码得到顺序表长度增加后,新增的位置(从n+1到F[k}-1位置),都赋为n位置的值即可。
while(n>fib(k)-1)
k++;

代码实现

public class FibonacciSearch {
public static int maxSzie = 20;

public static void main(String[] args) {
int[] arr = {1, 8, 10, 89, 1000, 1234};
int index = fibSearch(arr, 1234);
System.out.println("您要找的数据的下标是:" + index);
}

//获得斐波那契数列
//非递归方式
public static int[] fib() {
int[] fib = new int[maxSzie];
fib[0] = 1;
fib[1] = 1;
for (int i = 2; i < maxSzie; i++) {
fib[i] = fib[i - 1] + fib[i - 2];
}
return fib;
}

public static int fibSearch(int[] arr, int value) {
int low = 0;
int high = arr.length - 1;
int k = 0;//表示斐波那契分割数值的下标
int mid;
int fib[] = fib();//获取到斐波那契数列
//获取到斐波那契额分割数值的下标
while (arr.length > fib[k] - 1) {
k++;
}
//System.out.println(k);//k=5 表示fib[5]-1=8-1=7 7>6
//退出循环时说明已经找到斐波那契分割的数值即fib[k] fib[k]的值表示 要使用此方法所需的 元素的个数
//因为fib[k]可能大于arr的长度,所以这里需要构建一个新数组补齐所需的元素
// copyOf(oringinal, int newlength) oringinal:原数组 newlength:复制数组的长度
int[] temp = Arrays.copyOf(arr, fib[k]);//不足的地方自动填充0
/*for(int x=0;x<temp.length;x++) {
System.out.println("temp=" + temp[x]);//[1, 8, 10, 89, 1000, 1234,0,0]
}*/
//此方法实际应该在不足的地方填充arr的最后一个数据的值
for (int i = high + 1; i < fib[k]; i++) {
temp[i] = arr[high];////[1, 8, 10, 89, 1000, 1234,1234,1234]
}
//开始查找(非递归)
while (low <= high) {
mid = low + fib[k - 1] - 1;//前半段
if (value < temp[mid]) {//向数组左边查找
high = mid - 1;
//fib[k]=fib[k-1]+fib[k-2] 因为要找的值小于temp[mid] 所以去fib[k-1]这部分继续找
//所以k-- 即 下次循环mid=low+fib[k-1-1]-1
k--;
} else if (value > temp[mid]) {//向数组右边查找
low = mid + 1;
//fib[k]=fib[k-1]+fib[k-2] 因为要找的值大于于temp[mid] 所以去fib[k-2]这部分继续找
//所以k-=2 即 下次循环mid=low+fib[k-2-1]-1
k -= 2;
} else {//找到的话需要确定返回的下标
if (mid <= high) return mid;//在右边
else return high;//因为temp填充过,所以mid会大于high
}
}
return -1;
}
}

哈希表

概述

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

image-20211214144131687

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表

image-20211214144546900

+++

有一个公司,当有新的员工来报道时,要求将该员工的信息加入(id,性别,年龄,住址……),当输入该员工的id时,要求查找到该员工的所有信息。

要求:不使用数据库,尽量节省内存,速度越快越好=>哈希表(散列)

思路分析

image-20211214153026969

代码实现

方法类

import java.util.Scanner;

/**
* 方法类
*/
public class HashTableDemo {
public static void main(String[] args) {

HashTab hashTable = new HashTab(7);
String key = "";
Scanner scanner = new Scanner(System.in);
while (true){
System.out.println("add: 添加雇员");
System.out.println("list: 显示雇员");
System.out.println("find: 查找雇员");
System.out.println("del: 删除雇员");
System.out.println("exit: 退出系统");
System.out.println("请输入相关指令:");
key = scanner.next();
switch (key){
case "add":
System.out.print("输入id:");
int id = scanner.nextInt();
System.out.print("输入姓名:");
String name = scanner.next();
Emp emp = new Emp(id, name);
hashTable.add(emp);
break;
case "list":
hashTable.list();
break;
case "find":
System.out.print("输入员工id:");
int no = scanner.nextInt();
hashTable.findEmpById(no);
break;
case "del":
System.out.print("输入员工id:");
int no1 = scanner.nextInt();
hashTable.deleteEmpById(no1);
break;
case "exit":
scanner.close();
System.exit(0);
default:
break;
}
}

}
}

**HashTab **

/**
* 创建HashTab,管理多条链表
*/
public class HashTab {
private EmpLinkedList[] empLinkedList;
private int size;//表示有多少条链表

//构造器。创建size条链表
public HashTab(int size) {
this.size = size;
//初始化empLinkedList
this.empLinkedList = new EmpLinkedList[size];
//要初始化hashTab中的每一个链表
for (int i = 0; i < size; i++) {
empLinkedList[i] = new EmpLinkedList();
}
}

//编写散列函数,使用一个简单的取模法
public int hashFun(int id) {
return id % size;
}

//增加
public void add(Emp emp) {
//根据员工id,得到该员工应该添加到哪条链表
int empLinkedListNo = hashFun(emp.id);
//将emp添加到对应的链表中
empLinkedList[empLinkedListNo].add(emp);
}

//遍历所有
public void list() {
for (int i = 0; i < size; i++) {
empLinkedList[i].list(i);
}
}

//查询
public void findEmpById(int id) {
int empLinkedListNo = hashFun(id);
Emp emp = empLinkedList[empLinkedListNo].findEmpById(id);
if (emp == null) {
System.out.println("未找到");
} else {
System.out.println("第" + empLinkedListNo + "条链表中,id为" + emp.id + "雇员的信息" + emp);
}
}

//删除
public void deleteEmpById(int id){
int empLinkedListNo = hashFun(id);
boolean flag = empLinkedList[empLinkedListNo].deleteById(id);
if (!flag) {
System.out.println("未找到");
} else {
System.out.println("第" + empLinkedListNo + "条链表中的雇员的信息已删除");
}
}

}

EmpLinkedList

/**
* 创建EmpLinkedList,表示链表
*/
public class EmpLinkedList {
public Emp head;//头结点,默认为null

//有序添加
public void add(Emp emp) {
if (head == null) {//如果头结点为空,直接加入
head = emp;
return;
}
//如果不是第一个就在链表中添加
Emp curEmp = head;//辅助指针
if (curEmp.id > emp.id) {//输入的值小于现在的头结点,则和头结点互换
emp.next = head;
head = emp;
return;
}
while (true) {
if (curEmp.next == null) {//到达链表末尾
break;
}
if (curEmp.next.id > emp.id) {//下一个比输入的大
break;
}
curEmp = curEmp.next;//后移
}
//
emp.next = curEmp.next;
curEmp.next = emp;
}

//遍历全部
public void list(int no) {
if (head == null) {
System.out.println("第" + no + "条链表为空");
return;
}
System.out.println("第" + no + "条链表:");
Emp curEmp = head;//辅助指针
while (true) {
System.out.println(curEmp);
if (curEmp.next == null) {//说明指到了最后
break;
}
curEmp = curEmp.next;
}
}

//查询
public Emp findEmpById(int id) {
if (head == null) {
System.out.println("链表为空");
return null;
}
Emp curEmp = head;
while (true) {
if (curEmp.id == id) {//找到
break;
}
if (curEmp.next == null) {//到最后都没找到,返回null
curEmp = null;
break;
}
curEmp = curEmp.next;//后移
}
return curEmp;
}

//删除
public boolean deleteById(int id) {
if (head == null) {
System.out.println("链表为空");
return false;
}
if (head.id == id) {//如果删除的为头结点
head = head.next;
return true;
}
Emp curEmp = head;
while (true) {
if (curEmp.next == null) {//没找到找到
break;
}
if (curEmp.next.id == id) {//找到了
if (curEmp.next.next == null) {//如果需要删除的是当前链表的尾节点
curEmp.next = null;
} else {//如果是链表中间
curEmp.next = curEmp.next.next;
}
break;
}
curEmp = curEmp.next;//后移
}
return false;
}

}

Emp

/**
* 雇员类
*/
public class Emp {
public int id;
public String name;
public Emp next;

public Emp(int id, String name) {
super();
this.id = id;
this.name = name;
}

@Override
public String toString() {
return "Emp{" +
"id=" + id +
", name='" + name + '\'' +
'}';
}
}

树结构

概述

数组存储方式的分析

优点:通过下标方式访问元素,速度快。对于有序数组,还可使用二分查找提高检索速度。

缺点:如果要检索具体某个值,或者插入值(按一定顺序)会整体移动, 效率较低

操作示意图:

image-20211214195730478

ArrayList集合的扩容

ArrayList底层仍然是数组扩容,利用grow方式进行扩容

  1. ArrayList中维护了一个Object类型的数组elementData。 [debug看源码]
  2. 当创建对象时,如果使用的是无参构造器,则初始elementData容量为0 (jdk7 是10)
  3. 如果使用的是指定容量capacity的构造器,则初始elementData容量为 capacity。
  4. 当添加元素时:先判断是否需要扩容,如果需要扩容,则调用grow方法,否则直接添加元素到合适位置
  5. 如果使用的是无参构造器,如果第一次添加, 需要扩容的话,则扩容elementData为10,如果需要再次扩容的话,则扩容elementData为1.5倍。
  6. 如果使用的是指定容量capacity的构造器,如果需要扩容,则直接扩容elementData为1.5倍。

image-20211214200100339

链式存储方式的分析

优点:在一定程度上对数组存储方式有优化(比如:插入一个数值节点,只需要将插入节点,链接到链表中即可,删除效率也很好)。

缺点:在进行检索时,效率仍然较低,比如(检索某个值,需要从头节点开始遍历)

操作示意图:

image-20211214200324337

树存储方式的分析

能提高数据存储,读取的效率,比如利用 二叉排序树(Binary Sort Tree),既可以保证数据的检索速度,同时也可以保证数据的插入,删除,修改的速度。

案例: [7, 3, 10, 1, 5, 9, 12]

image-20211214201704387

基础部分

二叉树

概述

image-20211214203504732

  1. 树有很多种,每个节点最多只能有两个子节点的一种形式称为二叉树。
  2. 二叉树的子节点分为左节点和右节点。

image-20211214203620268

  1. 如果该二叉树的所有叶子节点都在最后一层,并且结点总数= 2^n -1 , n 为层数,则我们称为满二叉树。
  2. 如果该二叉树的所有叶子节点都在最后一层或者倒数第二层,而且最后一层的叶子节点在左边连续,倒数第二层的叶子节点在右边连续,我们称为完全二叉树。

image-20211214202636963

二叉树的遍历

前序遍历:先输出父节点,再遍历左子树和右子树

中序遍历:先遍历左子树,再输出父节点,再遍历右子树

后序遍历:先遍历左子树,再遍历右子树,最后输出父节点

小结:看输出父节点的顺序,就确定是前序,中序还是后序

思路分析

分析二叉树的前序,中序,后序的遍历步骤

  • 创建一颗二叉树
  • 前序遍历(中、左、右)
    1. 先输出当前节点(初始的时候是root节点);
    2. 如果左子节点不为空,则递归继续前序遍历;
    3. 如果右子节点不为空,则递归继续前序遍历。
  • 中序遍历(左、中、右)
    1. 如果当前节点的左子节点不为空,则递归中序遍历;
    2. 输出当前节点;
    3. 如果当前节点的右子节点不为空,则递归中序遍历。
  • 后序遍历(左、右、中)
    1. 如果当前节点的左子节点不为空,则递归后序遍历;
    2. 如果当前节点的右子节点不为空,则递归后序遍历;
    3. 输出当前节点。
核心代码实现
//前序遍历的方法
public void preOrder() {
//先输出当前节点,不用判断是否为空,因为都进来了肯定不为空
System.out.println(this);//先输出父节点,不能写root,后面无法递归,第一次this=root
//递归向左子树前序遍历
if (this.left != null) {
this.left.preOrder();
}
//向左递归结束后,返回到root,递归向右子树进行遍历
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);
}

二叉树的查找

  1. 请编写前序查找,中序查找和后序查找的方法。
  2. 并分别使用三种查找方式,查找 heroNO = 5 的节点
  3. 并分析各种查找方式,分别比较了多少次
思路分析

使用前序、中序、后序的方式来查询指定的节点

  • 前序查找思路
    1. 先判断当前id是否等于要查找的id
    2. 如果是相等,则返回当前节点
    3. 如果不等,则判断当前节点的左子节点是否为空,如果不为空,则递归前序查找
    4. 如果左递归前序查找,找到了,则返回;否则继续判断,当前节点的右子节点是否为空,如果不为空,则继续向右递归前序查找
  • 中序查找思路
    1. 先判断当前节点的左子节点是否为空,如果不为空,则递归中序查找
    2. 如果如果找到,就返回,如果没有找到,就和当前节点进行比较,如果是(找到)则返回当前节点,否则继续进行右递归的中序查找。
    3. 如果右递归中序查找,找到了就返回,找不到就返回null
  • 后序查找思路
    1. 先判断当前的左子节点是否为空,如果不为空,则递归后序查找
    2. 如果找到就返回,如果没有找到就判断当前节点的右子节点是否为空,如果不为空,则右递归进行后序查找,如果找到,就返回
    3. 如果没有找到,就和当前节点进行比较,如果是(找到)就返回,否则返回null
核心代码实现
/**
* 前序遍历查找的方法
*
* @param no 英雄的编号(id)
* @return 找到返回该英雄node,没找到返回null
*/
public HeroNode preOrderSearch(int no) {
System.out.println("进入前序遍历查找的方式");
//比较当前节点是不是
if (this.no == no) {
return this;//
}
//如果不是,判断左子节点是否为空,不为空就左递归;
//如果左递归找到,节点,则返回
HeroNode resNode = null;//返回的值
if (this.left != null) {
resNode = this.left.preOrderSearch(no);
}
if (resNode != null) {//这个说明左子树我们已经找到了
return resNode;
}
//左递归没有找到的话,需要继续判断
//判断当前节点的右子节点是否为空,如果不为空,则继续向右递归查找,找到返回,没有返回null
if (this.right != null) {
resNode = this.right.preOrderSearch(no);
}
return resNode;//不管找没找到,就可以直接返回
}

//中序遍历查找
public HeroNode infixOrderSearch(int no) {
//先判断当前节点的左子节点是否为空,如果不为空,则递归中序查找
HeroNode resNode = null;
if (this.left != null) {
resNode = this.left.infixOrderSearch(no);
}
if (resNode != null) {//这个表示左节点找到
return resNode;
}
System.out.println("进入中序遍历查找");
//如果没有找到,就和当前节点比较,如果是,则返回当前节点
if (this.no == no) {
return this;//说明找到,并返回了当前节点
}
//如果没有找到,就向右继续递归中序查找
if (this.right != null) {
resNode = this.right.infixOrderSearch(no);
}
return resNode;//不管是否为空,直接返回即可
}

//后序遍历查找
public HeroNode postOrderSearch(int no) {
//判断当前节点的左子节点是否为空,如果不为空,则递归后序查找
HeroNode resNode = null;
if (this.left != null) {
resNode = this.left.postOrderSearch(no);
}
//判断
if (resNode != null) {
return resNode;//说明找到
}
//如果左子树没有找到,则向右子树进行后序遍历
if (this.right != null) {
resNode = this.right.postOrderSearch(no);
}
if (resNode != null) {
return resNode;
}
System.out.println("进入后序查找");//查看遍历的次数,得写在比较语句(this.no==no)的前面,不能写在前面
//如果左右子树都没有找到,就比较当前节点是不是
if (this.no == no) {
return this;
}
return resNode;
}

二叉树的删除

  1. 如果删除的节点是叶子节点,则删除该节点
  2. 如果删除的节点是非叶子节点,则删除该子树.
  3. 测试,删除掉 5号叶子节点 和 3号子树.
思路分析
  1. 如果删除的节点是叶子节点,则删除该节点
  2. 如果删除的节点是非叶子节点,则删除该子树

+++

  1. 考虑如果树是空树root,如果只有一个root节点,则等价将二叉树置空。
  2. 因为我们的二叉树是单向的,所以我们是判断当前节点的子节点是否是需要删除的节点;而不是去判断当前这个节点是不是需要删除的节点。(也就是说,如果指针指向5号,就删除不了5号,只能删除其左右子节点,找不到5号的父节点)
  3. 如果当前节点的左子节点不为空,并且左子节点的编号就是要删除的节点,那么就直接,将this.left=null,直接置空即可。并且就返回(结束递归删除)
  4. 如果当前节点的右子节点不为空,并且右子节点就是要删除的节点,那么直接将右边置空即可,this.right=null,并且就返回(结束递归删除)
  5. 如果第2步和第3步,都没有删除节点,那么我们就需要向左子树进行递归删除。(当然也得判断左子树是否为null空)
  6. 如果第4步也没有删除节点,则应当向右子树进行递归删除。
核心代码实现

调用方法:考虑如果树是空树root,如果只有一个root节点,则等价将二叉树置空。

//删除节点的操作
public void delNode(int no) {
//首先判断root是否为空
if (root != null) {
//如果只有一个root节点,这里需要立即判断root是不是要删除的节点
if (root.getNo() == no) {
root = null;//恰好是根节点,直接置空即可
} else {
//如果root不是,则就递归删除
root.delNode(no);
}
} else {
System.out.println("空树,不能删除~~");
}
}

核心代码:2、3、4、5、6

//递归删除节点
//规定:1.如果是叶子节点则删除该节点,2如果删除的是非叶子节点,则删除该子树
public void delNode(int no) {
//先判断左子节点是否为空,再判断是否是要删除的节点
if (this.left != null && this.left.no == no) {
this.left = null;
return;
}
//如果左子节点不是,判断右子节点是否为空或者是否是要删除的节点
if (this.right != null && this.right.no == no) {
this.right = null;
return;
}
//如果前两个都没有,则需要先向左递归删除
if (this.left != null) {
this.left.delNode(no);
}
//左递归没有结束的话,就进行右递归进行删除
if (this.right != null) {
this.right.delNode(no);
}
}

完整代码

public class BinaryTreeDemo01 {

public static void main(String[] args) {
//先需要一个二叉树
BinaryTree binaryTree = new BinaryTree();
//创建需要的节点
HeroNode root = new HeroNode(1, "老大");
HeroNode node2 = new HeroNode(2, "老二");
HeroNode node3 = new HeroNode(3, "老三");
HeroNode node4 = new HeroNode(4, "老四");
HeroNode node5 = new HeroNode(5, "老五");//再加一个


//说明:先手动创建该二叉树,后面我们学习递归的方式创建二叉树
root.setLeft(node2);//root左边的节点挂上了node2,之前是root.left=node2;但是这个方法变成私有的了
root.setRight(node3);
node3.setRight(node4);
node3.setLeft(node5);//
binaryTree.setRoot(root);

//遍历
//测试
System.out.println("前序遍历");//先输出1宋江,2吴用,3卢俊义,4林冲,正好是顺序
binaryTree.preOrder();//再加一个5,是12354

//测试
System.out.println("中序遍历");
binaryTree.infixOrder();//2,1,3,4;//再加一个5,是21534

//后序遍历
System.out.println("后序遍历");//此时需要注意
binaryTree.postOrder();//2,4,3,1//再加一个5,是254311

//查找
//前序查找遍历
//前序遍历的次数4
System.out.println("前序遍历查找的方式~~");
HeroNode resNode = binaryTree.preOrderSearch(5);
//System.out.println(resNode);直接输出即可,毕竟都重写了toString,但是老师是按照下面写的
if (resNode != null) {
System.out.printf("找到了,信息为id=%d name=%s\n", resNode.getNo(), resNode.getName());
} else {
System.out.printf("没有找到id=%d的英雄", 5);
}

//中序查找遍历
//中序遍历的次数3
System.out.println("中序遍历查找的方式~~");
HeroNode resNode1 = binaryTree.infixOrderSearch(5);
if (resNode1 != null) {
System.out.printf("找到了,信息为id=%d name=%s", resNode1.getNo(), resNode1.getName());
} else {
System.out.printf("没有找到id=%d的英雄", 5);
}

//后序查找遍历
//后序遍历的次数3
System.out.println("后序遍历查找的方式~~");
HeroNode resNode2 = binaryTree.postOrderSearch(5);
if (resNode2 != null) {
System.out.printf("找到了,信息为id=%d name=%s", resNode2.getNo(), resNode2.getName());
} else {
System.out.printf("没有找到id=%d的英雄", 5);
}


//删除
//测试,删除节点的操作
System.out.println("删除前,前序遍历的操作");
binaryTree.preOrder();
binaryTree.delNode(5);//1234
//binaryTree.delNode(3);//12 删除子树,等价与把那棵子树全部干掉
System.out.println("删除后,前序遍历的操作");
binaryTree.preOrder();//1234

}
}

//定义BinaryTree 二叉树
class BinaryTree {
//只需要一个根节点即可
private HeroNode root;

public void setRoot(HeroNode root) {
this.root = root;
}

//前序遍历;真正的调用是从根节点来调的,就想前面所讲的哈希表
public void preOrder() {
if (this.root != null) {
this.root.preOrder();//谁调用指向谁preOrder()指向root
} else {
System.out.println("当前二叉树为空,无法遍历");
}
}

//中序遍历
public void infixOrder() {
if (this.root != null) {
this.root.infixOrder();
} else {
System.out.println("当前二叉树为空,无法遍历");
}
}

//后序遍历
public void postOrder() {
if (this.root != null) {
this.root.postOrder();
} else {
System.out.println("当前二叉树为空,无法遍历");
}
}


//前序查找遍历
public HeroNode preOrderSearch(int no) {
if (root != null) {
return root.preOrderSearch(no);
} else {
return null;//如果为空的话直接返回即可
}
}

//中序查找遍历
public HeroNode infixOrderSearch(int no) {
if (root != null) {
return root.infixOrderSearch(no);
} else {
return null;
}
}

//后序遍历查找
public HeroNode postOrderSearch(int no) {
if (root != null) {
return root.postOrderSearch(no);
} else {
return null;
}
}


//删除节点的操作
public void delNode(int no) {
//首先判断root是否为空
if (root != null) {
//如果只有一个root节点,这里需要立即判断root是不是要删除的节点
if (root.getNo() == no) {
root = null;//恰好是根节点,直接置空即可
} else {
//如果root不是,则就递归删除
root.delNode(no);
}
} else {
System.out.println("空树,不能删除~~");
}
}

}


//先创建HeroNode 节点
class HeroNode {
private int no;//英雄的编号
private String name;//英雄的名字
private HeroNode left;//指向左边的索引,默认null
private HeroNode right;//指向右边的索引,默认null

public HeroNode(int no, String name) {
this.no = no;
this.name = name;
}

public void setNo(int no) {
this.no = no;
}

public void setName(String name) {
this.name = name;
}

public void setLeft(HeroNode left) {
this.left = left;
}

public void setRight(HeroNode right) {
this.right = right;
}

public int getNo() {
return no;
}

public String getName() {
return name;
}

public HeroNode getLeft() {
return left;
}

public HeroNode getRight() {
return right;
}

//需要输出当前节点的内容,所以需要重写toString方法
@Override
public String toString() {
return "HeroNode{" + "no=" + no + ", name='" + name + '\'' + '}';
}

//前序遍历的方法
public void preOrder() {
//先输出当前节点,不用判断是否为空,因为都进来了肯定不为空
System.out.println(this);//先输出父节点,不能写root,后面无法递归,第一次this=root
//递归向左子树前序遍历
if (this.left != null) {
this.left.preOrder();
}
//向左递归结束后,返回到root,递归向右子树进行遍历
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);
}


/**
* 前序遍历查找的方法
*
* @param no 英雄的编号(id)
* @return 找到返回该英雄node,没找到返回null
*/
public HeroNode preOrderSearch(int no) {
System.out.println("进入前序遍历查找的方式");
//比较当前节点是不是
if (this.no == no) {
return this;//
}
//如果不是,判断左子节点是否为空,不为空就左递归;
//如果左递归找到,节点,则返回
HeroNode resNode = null;//返回的值
if (this.left != null) {
resNode = this.left.preOrderSearch(no);
}
if (resNode != null) {//这个说明左子树我们已经找到了
return resNode;
}
//左递归没有找到的话,需要继续判断
//判断当前节点的右子节点是否为空,如果不为空,则继续向右递归查找,找到返回,没有返回null
if (this.right != null) {
resNode = this.right.preOrderSearch(no);
}
return resNode;//不管找没找到,就可以直接返回
}

//中序遍历查找
public HeroNode infixOrderSearch(int no) {
//先判断当前节点的左子节点是否为空,如果不为空,则递归中序查找
HeroNode resNode = null;
if (this.left != null) {
resNode = this.left.infixOrderSearch(no);
}
if (resNode != null) {//这个表示左节点找到
return resNode;
}
System.out.println("进入中序遍历查找");
//如果没有找到,就和当前节点比较,如果是,则返回当前节点
if (this.no == no) {
return this;//说明找到,并返回了当前节点
}
//如果没有找到,就向右继续递归中序查找
if (this.right != null) {
resNode = this.right.infixOrderSearch(no);
}
return resNode;//不管是否为空,直接返回即可
}

//后序遍历查找
public HeroNode postOrderSearch(int no) {
//判断当前节点的左子节点是否为空,如果不为空,则递归后序查找
HeroNode resNode = null;
if (this.left != null) {
resNode = this.left.postOrderSearch(no);
}
//判断
if (resNode != null) {
return resNode;//说明找到
}
//如果左子树没有找到,则向右子树进行后序遍历
if (this.right != null) {
resNode = this.right.postOrderSearch(no);
}
if (resNode != null) {
return resNode;
}
System.out.println("进入后序查找");//查看遍历的次数,得写在比较语句(this.no==no)的前面,不能写在前面
//如果左右子树都没有找到,就比较当前节点是不是
if (this.no == no) {
return this;
}
return resNode;
}


//递归删除节点
//规定:1.如果是叶子节点则删除该节点,2如果删除的是非叶子节点,则删除该子树
public void delNode(int no) {
//先判断左子节点是否为空,再判断是否是要删除的节点
if (this.left != null && this.left.no == no) {
this.left = null;
return;
}
//如果左子节点不是,判断右子节点是否为空或者是否是要删除的节点
if (this.right != null && this.right.no == no) {
this.right = null;
return;
}
//如果前两个都没有,则需要先向左递归删除
if (this.left != null) {
this.left.delNode(no);
}
//左递归没有结束的话,就进行右递归进行删除
if (this.right != null) {
this.right.delNode(no);
}
}

}

顺序存储二叉树

从数据存储来看,数组存储方式和树的存储方式可以相互转换,即数组可以转换成树,树也可以转换成数组,看右面的示意图。

image-20211214234221162

  1. 右图的二叉树的结点,要求以数组的方式来存放arr:[1,2,3,4,5,6,7]
  2. 要求在遍历数组arr时,仍然可以以前序遍历,中序遍历和后序遍历的方式完成结点的遍历

思路分析

顺序存储二叉树的特点:

  1. 顺序二叉树通常只考虑完全二叉树
  2. 第n个元素的左子节点为 2*n+1
    • 注意:这个n指的是原数组的下标,数组是从0开始的
    • 所以,这个也是从0开始的,这个计算的左子节点也是对应元素在数组中的下标。特别注意
  3. 第n个元素的右子节点为 2*n+2
  4. 第n个元素的父节点为 (n-1)/2
  5. n:表示二叉树中的第几个元素(按0开始编号如图所示)

代码实现

public class ArrBinaryTreeDemo {
public static void main(String[] args) {
int[] arr = new int[]{1, 2, 3, 4, 5, 6, 7};
//创建一个ArrBinaryTree对象
ArrBinaryTree arrBinaryTree = new ArrBinaryTree(arr);
//arrBinaryTree.preOrder(0);//按照根节点,应该先传0进去即可,但是这样写比较麻烦,一般使用重载的方法

//前序操作
System.out.println("前序遍历的操作");
arrBinaryTree.preOrder();
//中序操作
System.out.println("中序遍历的操作");
arrBinaryTree.midOrder();
//后序操作
System.out.println("后序遍历的操作");
arrBinaryTree.postOrder();

}
}

//编写一个ArrayBinaryTree,实现顺序存储二叉树的遍历
class ArrBinaryTree {
private int[] arr;//存储数据节点的数组(就是存储二叉树节点的数组)

//需要一个构造器,待会把数组传给我即可
public ArrBinaryTree(int[] arr) {
this.arr = arr;
}


//重载前序遍历的方法
public void preOrder() {
this.preOrder(0);
}

//编写一个方法完成顺序存储二叉树前序遍历操作

/**
* @param index 这个表示数组的下标,类似刚才分析的n
*/
public void preOrder(int index) {
//如果数组为空,或者arr.length=0
if (arr == null || arr.length == 0) {//arr=null,表示arr不指向任何对象,length=0表示指针指向一个长度为0的对象,判断null需要在前防止空指针异常
System.out.println("数组为空,不能按照二叉树的前序进行遍历");
}
//进行前序遍历
//输出当前的这个元素
//中间
System.out.println(arr[index]);//0
//先向左递归遍历
if ((index * 2 + 1) < arr.length) {//加上这个判断是为了防止角标越界
preOrder(2 * index + 1);//1
}
//然后向右递归
if ((index * 2 + 2) < arr.length) {
preOrder(2 * index + 2);//2
}
}


//中序方法的重载操作
public void midOrder() {
midOrder(0);
}

//完成顺序存储二叉树的中序操作
public void midOrder(int index) {
//先判断数组是否为空或者长度为0
if (arr == null || arr.length == 0) {
System.out.println("数组为空,不能进行二叉树中序遍历操作");
}
//先向左递归
if ((2 * index + 1) < arr.length) {//1
midOrder(2 * index + 1);
}
//中间
System.out.println(arr[index]);//0
//向右递归
if ((2 * index + 2) < arr.length) {//2
midOrder(2 * index + 2);
}
}

//后序方法的重载操作
public void postOrder() {
postOrder(0);
}

//完成顺序存储二叉树的后序操作
public void postOrder(int index) {
//先判断数组是否为空或者长度为0
if (arr == null || arr.length == 0) {
System.out.println("数组为空,不能进行二叉树后序遍历操作");
}
//先向左递归
if ((2 * index + 1) < arr.length) {
postOrder(2 * index + 1);//1
}
//向右递归
if ((2 * index + 2) < arr.length) {
postOrder(2 * index + 2);//2
}
//中间
System.out.println(arr[index]);//0
}
}

线索化二叉树

image-20211215165734067

  1. 当我们对上面的二叉树进行中序遍历时,数列为 {8, 3, 10, 1, 14, 6 }
  2. 但是 6, 8, 10, 14 这几个节点的 左右指针,并没有完全的利用上.
  3. 如果我们希望充分的利用 各个节点的左右指针, 让各个节点可以指向自己的前后节点。怎么办?
  4. 解决方案——线索二叉树

概述

  1. n个结点的二叉链表中含有n+1 【公式 2n-(n-1)=n+1】 个空指针域。利用二叉链表中的空指针域,存放指向该结点某种遍历次序下的前驱和后继结点的指针(这种附加的指针称为”线索”);

  2. 这种加上了线索的二叉链表称为线索链表,相应的二叉树称为线索二叉树(Threaded BinaryTree)。根据线索性质的不同,线索二叉树可分为前序线索二叉树、中序线索二叉树后序线索二叉树三种;

  3. 一个结点的前一个结点,称为前驱结点;

  4. 一个结点的后一个结点,称为后继结点。

实例

应用案例说明:将下面的二叉树,进行中序线索二叉树。

中序遍历的数列为 {8, 3, 10, 1, 14, 6}

前序遍历的数列为{1, 3, 8, 10, 6, 14}

后续遍历的数列为{8, 10, 3, 14, 6, 1}

image-20211215171333239

思路分析

当线索化二叉树后,Node节点的 属性 left 和 right ,有如下情况:

  1. left 指向的是左子树,也可能是指向的前驱节点
    • 比如 ① 节点 left 指向的左子树, 而 ⑩ 节点的 left 指向的就是前驱节点。
  2. right指向的是右子树,也可能是指向的后继节点
    • 比如 ① 节点 right 指向的是右子树,而⑩ 节点的 right 指向的是后继节点。
代码实现

image-20211215181505318

public class ThreadeBinaryTreeDemo {
public static void main(String[] args) {
//测试一把中序线索化二叉树的功能
HeroNode root = new HeroNode(1, "Tom");
HeroNode node2 = new HeroNode(3, "jack");
HeroNode node3 = new HeroNode(6, "smith");
HeroNode node4 = new HeroNode(8, "marry");
HeroNode node5 = new HeroNode(10, "king");
HeroNode node6 = new HeroNode(14, "dim");
//二叉树,后面我们要递归的创建,仙子啊简单处理手动创建
root.setLeft(node2);
root.setRight(node3);
node2.setLeft(node4);
node2.setRight(node5);
node3.setLeft(node6);

//测试中序线索化
ThreadBinaryTree threadBinaryTree = new ThreadBinaryTree();
threadBinaryTree.setRoot(root);
threadBinaryTree.threadNodes();

//测试,找10这个节点,看其前驱或者后驱节点是否变成了3和1
HeroNode leftNode = node5.getLeft();
System.out.println("十号节点的前驱节点是" + leftNode);
System.out.println("十号节点的后继节点是" + node5.getRight());

}
}
//定义ThreadBinaryTree 实现了线索化功能的二叉树
class ThreadBinaryTree {
//只需要一个根节点即可
private HeroNode root;
//为了实现线索化,需要创建要给指向当前节点的前驱节点的指针
//在递归进行线索化时,pre总是保留前一个节点
private HeroNode pre = null;

public void setRoot(HeroNode root) {
this.root = root;
}

//重载threadNodes
public void threadNodes() {
this.threadeNodes(root);
}

/**
* 编写对二叉树进行中序线索化的方法
* @param node 这个就是当前需要线索化的节点
* 跟着树走一遍就懂了
*/
public void threadeNodes(HeroNode node) {
//如果node==null,就不能进行线索化
if (node == null) {
return;
}
//中序线索化的步骤
//一、先线索化左子树
threadeNodes(node.getLeft());

//二、线索化当前节点

//处理当前节点的前驱节点
//以8节点来理解,8节点.left=null,8节点的.leftType=1
if (node.getLeft() == null) {
//让当前节点的左指针指向前驱节点
node.setLeft(pre);
//修改当前节点的左指针类型,指向前驱节点
node.setLefType(1);
}

//处理后继节点;后继节点处理需要返回递归上一层,8是上层的左节点。。
//此时node指向3,而pre指向8
if (pre != null && pre.getRight() == null) {
//让前驱节点的右指针指向当前节点
pre.setRight(node);
//修改前驱节点的右指针类型
pre.setRightType(1);
}

//!!这句话尤其重要,每处理一个节点后,让当前节点是下一个节点的前驱节点
pre = node;

//三、索化右子树
threadeNodes(node.getRight());

}

}
遍历线索化二叉树

说明

对前面的中序线索化的二叉树, 进行遍历

分析

因为线索化后,各个结点指向有变化,因此原来的遍历方式不能使用,这时需要使用新的方式遍历线索化二叉树,各个节点可以通过线型方式遍历,因此无需使用递归方式,这样也提高了遍历的效率。 遍历的次序应当和中序遍历保持一致

核心代码

//遍历线索化二叉树的方法
public void threadList() {
//定义一个变量,存储当前遍历的节点,root开始
HeroNode node = root;
while (node != null) {
//先循环的找到leftType==1的节点,第一个找到的应该是8这个节点
//后边随着遍历而变化,因为当left==1时,说明该节点是按照线索化
//处理后的有效节点
while (node.getLefType() == 0) {
node = node.getLeft();//就一直找,直到找到node.getLeftType==1,的时候停下
}
//找到之后,打印当前节点
System.out.println(node);
//如果当前节点的右指针指向的是后继节点,就一直输出
while (node.getRightType() == 1) {
//获取到当前节点的后继节点
node = node.getRight();
System.out.println(node);
}
//while循环结束,说明就找到不等于1的,替换遍历的节点
node = node.getRight();
}
}
public class ThreadeBinaryTreeDemo {
public static void main(String[] args) {
//测试一把中序线索化二叉树的功能
HeroNode root = new HeroNode(1, "Tom");
HeroNode node2 = new HeroNode(3, "jack");
HeroNode node3 = new HeroNode(6, "smith");
HeroNode node4 = new HeroNode(8, "marry");
HeroNode node5 = new HeroNode(10, "king");
HeroNode node6 = new HeroNode(14, "dim");
//二叉树,后面我们要递归的创建,仙子啊简单处理手动创建
root.setLeft(node2);
root.setRight(node3);
node2.setLeft(node4);
node2.setRight(node5);
node3.setLeft(node6);

//测试中序线索化
ThreadBinaryTree threadBinaryTree = new ThreadBinaryTree();
threadBinaryTree.setRoot(root);
threadBinaryTree.threadNodes();

//当线索化二叉树后,不能在使用原来的遍历方法了
//threadBinaryTree.infixOrder();
System.out.println("使用线索化的方式遍历线索化二叉树");
threadBinaryTree.threadList();//8.3.10.1.14.6

}
}
运行结果

image-20211215191032770

完整代码
public class ThreadeBinaryTreeDemo {
public static void main(String[] args) {
//测试一把中序线索化二叉树的功能
HeroNode root = new HeroNode(1, "Tom");
HeroNode node2 = new HeroNode(3, "jack");
HeroNode node3 = new HeroNode(6, "smith");
HeroNode node4 = new HeroNode(8, "marry");
HeroNode node5 = new HeroNode(10, "king");
HeroNode node6 = new HeroNode(14, "dim");
//二叉树,后面我们要递归的创建,仙子啊简单处理手动创建
root.setLeft(node2);
root.setRight(node3);
node2.setLeft(node4);
node2.setRight(node5);
node3.setLeft(node6);

//测试中序线索化
ThreadBinaryTree threadBinaryTree = new ThreadBinaryTree();
threadBinaryTree.setRoot(root);
threadBinaryTree.threadNodes();

//测试,找10这个节点,看其前驱或者后驱节点是否变成了3和1
HeroNode leftNode = node5.getLeft();
System.out.println("十号节点的前驱节点是" + leftNode);
System.out.println("十号节点的后继节点是" + node5.getRight());

//当线索化二叉树后,不能在使用原来的遍历方法了
//threadBinaryTree.infixOrder();
System.out.println("使用线索化的方式遍历线索化二叉树");
threadBinaryTree.threadList();//8.3.10.1.14.6

}
}


//定义ThreadBinaryTree 实现了线索化功能的二叉树
class ThreadBinaryTree {
//只需要一个根节点即可
private HeroNode root;
//为了实现线索化,需要创建要给指向当前节点的前驱节点的指针
//在递归进行线索化时,pre总是保留前一个节点
private HeroNode pre = null;

public void setRoot(HeroNode root) {
this.root = root;
}

//重载threadNodes
public void threadNodes() {
this.threadeNodes(root);
}

//遍历线索化二叉树的方法
public void threadList() {
//定义一个变量,存储当前遍历的节点,root开始
HeroNode node = root;
while (node != null) {
//先循环的找到leftType==1的节点,第一个找到的应该是8这个节点
//后边随着遍历而变化,因为当left==1时,说明该节点是按照线索化
//处理后的有效节点
while (node.getLefType() == 0) {
node = node.getLeft();//就一直找,直到找到node.getLeftType==1,的时候停下
}
//找到之后,打印当前节点
System.out.println(node);
//如果当前节点的右指针指向的是后继节点,就一直输出
while (node.getRightType() == 1) {
//获取到当前节点的后继节点
node = node.getRight();
System.out.println(node);
}
//while循环结束,说明就找到不等于1的,替换遍历的节点
node = node.getRight();
}
}

/**
* 编写对二叉树进行中序线索化的方法
*
* @param node 这个就是当前需要线索化的节点
*/
public void threadeNodes(HeroNode node) {
//如果node==null,就不能进行线索化
if (node == null) {
return;
}
//中序线索化的步骤
//一、先线索化左子树
threadeNodes(node.getLeft());

//二、线索化当前节点

//处理当前节点的前驱节点
//以8节点来理解,8节点.left=null,8节点的.leftType=1
if (node.getLeft() == null) {
//让当前节点的左指针指向前驱节点
node.setLeft(pre);
//修改当前节点的左指针类型,指向前驱节点
node.setLefType(1);
}

//处理后继节点;(在递归中在下一轮才处理)
// 后继节点处理需要返回递归上一层,8是上层的左节点。。
//此时node指向3,而pre指向8
if (pre != null && pre.getRight() == null) {
//让前驱节点的右指针指向当前节点
pre.setRight(node);
//修改前驱节点的右指针类型
pre.setRightType(1);
}
//!!这句话尤其重要,每处理一个节点后,让当前节点是下一个节点的前驱节点
pre = node;

//三、线索化右子树
threadeNodes(node.getRight());

}

//前序遍历;真正的调用是从根节点来调的,就想前面所讲的哈希表
public void preOrder() {
if (this.root != null) {
this.root.preOrder();//谁调用指向谁preOrder()指向root
} else {
System.out.println("当前二叉树为空,无法遍历");
}
}

//中序遍历
public void infixOrder() {
if (this.root != null) {
this.root.infixOrder();
} else {
System.out.println("当前二叉树为空,无法遍历");
}
}

//后序遍历
public void postOrder() {
if (this.root != null) {
this.root.postOrder();
} else {
System.out.println("当前二叉树为空,无法遍历");
}
}

//前序查找遍历
public HeroNode preOrderSearch(int no) {
if (root != null) {
return root.preOrderSearch(no);
} else {
return null;//如果为空的话直接返回即可
}
}

//中序查找遍历
public HeroNode infixOrderSearch(int no) {
if (root != null) {
return root.infixOrderSearch(no);
} else {
return null;
}
}

//后序遍历查找
public HeroNode postOrderSearch(int no) {
if (root != null) {
return root.postOrderSearch(no);
} else {
return null;
}
}

//删除节点的操作
public void delNode(int no) {
//首先判断root是否为空
if (root != null) {
//如果只有一个root节点,这里需要立即判断root是不是要删除的节点
if (root.getNo() == no) {
root = null;//恰好是根节点,直接置空即可
} else {
//如果root不是,则就递归删除
root.delNode(no);
}
} else {
System.out.println("空树,不能删除~~");
}
}

}


//先创建HeroNode 节点
class HeroNode {
private int no;//英雄的编号
private String name;//英雄的名字
private HeroNode left;//指向左边的索引,默认null
private HeroNode right;//指向右边的索引,默认null

//说明
//1.如果leftType==0,表示指向左子树,如果为1则表示指向前驱节点
//2.如果rightType==0,表示指向右子树,如果为1表示指向后继节点
private int lefType;
private int rightType;

public void setLefType(int lefType) {
this.lefType = lefType;
}

public void setRightType(int rightType) {
this.rightType = rightType;
}

public int getLefType() {
return lefType;
}

public int getRightType() {
return rightType;
}

public HeroNode(int no, String name) {
this.no = no;
this.name = name;
}

public void setNo(int no) {
this.no = no;
}

public void setName(String name) {
this.name = name;
}

public void setLeft(HeroNode left) {
this.left = left;
}

public void setRight(HeroNode right) {
this.right = right;
}

public int getNo() {
return no;
}

public String getName() {
return name;
}

public HeroNode getLeft() {
return left;
}

public HeroNode getRight() {
return right;
}

//需要输出当前节点的内容,所以需要重写toString方法


@Override
public String toString() {
return "HeroNode{" +
"no=" + no +
", name='" + name + '\'' +
'}';
}

//编写,前序遍历的方法
public void preOrder() {
//先输出当前节点,不用判断是否为空,因为都进来了肯定不为空
System.out.println(this);//先输出父节点,不能写root,后面无法递归,第一次this=root
//递归向左子树前序遍历
if (this.left != null) {
this.left.preOrder();
}
//向左递归结束后,返回到root,递归向右子树进行遍历
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);
}

//前序遍历查找的方法

/**
* @param no 英雄的编号(id)
* @return 找到返回该英雄node,没找到返回null
*/
public HeroNode preOrderSearch(int no) {
System.out.println("进入前序遍历查找的方式");
//比较当前节点是不是
if (this.no == no) {
return this;//
}
//如果不是,判断左子节点是否为空,不为空就左递归;
// 如果左递归找到,节点,则返回
HeroNode resNode = null;//假定我们找到的节点就是它,因为我们需要判断左递归找到要返回
if (this.left != null) {
resNode = this.left.preOrderSearch(no);
}
if (resNode != null) {//这个说明左子树我们已经找到了
return resNode;
}
//左递归没有找到的话,需要继续判断
//判断当前节点的右子节点是否为空,如果不为空,则继续向右递归查找,找到返回,没有返回null
if (this.right != null) {
resNode = this.right.preOrderSearch(no);
}
return resNode;//不管找没找到,就可以直接返回

}

//中序遍历查找
public HeroNode infixOrderSearch(int no) {
//先判断当前节点的左子节点是否为空,如果不为空,则递归中序查找
HeroNode resNode = null;
if (this.left != null) {
resNode = this.left.infixOrderSearch(no);
}
if (resNode != null) {//这个表示左节点找到
return resNode;
}

System.out.println("进入zhong序遍历查找");

//如果没有找到,就和当前节点比较,如果是,则返回当前节点
if (this.no == no) {
return this;//说明找到,并返回了当前节点
}
//如果没有找到,就向右继续递归中序查找
if (this.right != null) {
resNode = this.right.infixOrderSearch(no);
}
return resNode;//不管是否为空,直接返回即可
}

//后序遍历查找
public HeroNode postOrderSearch(int no) {
//判断当前节点的左子节点是否为空,如果不为空,则递归后序查找
HeroNode resNode = null;
if (this.left != null) {
resNode = this.left.postOrderSearch(no);
}
//判断
if (resNode != null) {
return resNode;//说明找到
}
//如果左子树没有找到,则向右子树进行后序遍历
if (this.right != null) {
resNode = this.right.postOrderSearch(no);
}
if (resNode != null) {
return resNode;
}

System.out.println("进入后序查找");//查看遍历的次数,得写在比较语句(this.no==no)的前面,不能写在前面

//如果左右子树都没有找到,就比较当前节点是不是
if (this.no == no) {
return this;

}
return resNode;
}

//递归删除节点
//规定:1.如果是叶子节点则删除该节点,2如果删除的是非叶子节点,则删除该子树
public void delNode(int no) {
//先判断左子节点是否为空,再判断是否是要删除的节点
if (this.left != null && this.left.no == no) {
this.left = null;
return;
}
//如果左子节点不是,判断右子节点是否为空或者是否是要删除的节点
if (this.right != null && this.right.no == no) {
this.right = null;
return;
}
//如果前两个都没有,则需要先向左递归删除
if (this.java != null) {
this.left.delNode(no);
}
//左递归没有结束的话,就进行右递归进行删除
if (this.right != null) {
this.right.delNode(no);
}
}

}

作业:多种线索化

import lombok.Data;

public class ThreadedBinaryTreeDemo {

public static void main(String[] args) {
ThreadedTreeNode root = getTree();
ThreadedTree tree = new ThreadedTree(root);

//tree.midThreadedNodes(root);
//tree.listMidThreadedNodes();

//tree.preThreadedNodes(root);
//tree.listPreThreadedNodes();

tree.postThreadedNodes(root);
tree.listPostThreadedNodes(root);
}

public static ThreadedTreeNode getTree(){
ThreadedTreeNode node1 = new ThreadedTreeNode(1);
ThreadedTreeNode node2 = new ThreadedTreeNode(2);
ThreadedTreeNode node3 = new ThreadedTreeNode(3);
ThreadedTreeNode node4 = new ThreadedTreeNode(4);
ThreadedTreeNode node5 = new ThreadedTreeNode(5);
ThreadedTreeNode node6 = new ThreadedTreeNode(6);
ThreadedTreeNode node7 = new ThreadedTreeNode(7);

node1.setLeft(node2);
node1.setRight(node3);

node2.setLeft(node4);
node2.setRight(node5);

node3.setLeft(node6);
node3.setRight(node7);

return node1;
}
}

@Data
class ThreadedTree{
//当前树的根节点
private ThreadedTreeNode root;
//当前树的上一个节点
private ThreadedTreeNode pre;

public ThreadedTree(ThreadedTreeNode root) {
this.root = root;
}

/**
* 二叉树的线索化(前序)
* @param node 需要线索化的当前节点
*/
public void preThreadedNodes(ThreadedTreeNode node){
if (node == null) {
return;
}
//1.线索化当前节点
//1.1线索化前驱节点
if (node.getLeft() == null) {
node.setLeft(pre);
node.setLeftType(1);
}
//1.2线索化后继节点
if (pre != null && pre.getRight() == null) {
pre.setRight(node);
pre.setRightType(1);
}
pre = node;
//2.线索化左节点
if (node.getLeftType() == 0) {
preThreadedNodes(node.getLeft());
}
//3.线索化右节点
if (node.getRightType() == 0) {
preThreadedNodes(node.getRight());
}
}

/**
* 二叉树的线索化(中叙)
* @param node 需要线索化的当前节点
*/
public void midThreadedNodes(ThreadedTreeNode node){
if (node == null) {
return;
}
//1.线索化左节点
midThreadedNodes(node.getLeft());
//2.线索化当前节点
//2.1处理前驱节点
if (node.getLeft() == null) {
node.setLeft(pre);
node.setLeftType(1);
}
//2.2处理后继节点
if (pre != null && pre.getRight() == null) {
pre.setRight(node);
pre.setRightType(1);
}
pre = node;
//3.线索化右节点
midThreadedNodes(node.getRight());
}

/**
* 二叉树的线索化(后续)
* @param node 需要线索化的当前节点
*/
public void postThreadedNodes(ThreadedTreeNode node){
if (node == null) {
return;
}
//1.线索化左节点
if (node.getLeftType() == 0) {
postThreadedNodes(node.getLeft());
}
//2.线索化右节点
if (node.getRightType() == 0) {
postThreadedNodes(node.getRight());
}
//3.线索化当前节点
//3.1处理前驱节点
if (node.getLeft() == null) {
node.setLeft(pre);
node.setLeftType(1);
}
//3.2处理后继节点
if (pre != null && pre.getRight() == null) {
pre.setRight(node);
pre.setRightType(1);
}
pre = node;
}

/**
* 前序遍历(线索化二叉树)
*/
public void listPreThreadedNodes(){
//定义一个临时变量,用于存储遍历的当前节点
ThreadedTreeNode node = root;
while(node != null){
System.out.println(node.getVal());
while(node.getLeftType() == 0){
node = node.getLeft();
System.out.println(node.getVal());
}
node = node.getRight();
}
}

/**
* 中叙遍历(线索化二叉树)
*/
public void listMidThreadedNodes(){
//定义一个临时变量,用于存储遍历的当前节点
ThreadedTreeNode node = root;
while(node != null){
//循环找到leftType==1的节点
while(node.getLeftType() == 0){
node = node.getLeft();
}
//打印当前节点
System.out.println(node.getVal());
//如果当前节点的右节点是后继节点,则继续向下遍历
while(node.getRightType() == 1){
node = node.getRight();
System.out.println(node.getVal());
}
//如果当前节点的右节点不是后继节点,则替换当前节点
node = node.getRight();
}
}

/**
* 后序遍历(和普通遍历一样,这样写很傻逼,但是就是简单,[苦涩])
* @param node
*/
public void listPostThreadedNodes(ThreadedTreeNode node){
if (node.getLeft () != null && node.getLeftType() == 0) {
listPostThreadedNodes(node.getLeft());
}
if (node.getRight() != null && node.getRightType() == 0) {
listPostThreadedNodes(node.getRight());
}
System.out.println(node.getVal());
}

}

@Data
class ThreadedTreeNode {
private Integer val;
private ThreadedTreeNode left;
private ThreadedTreeNode right;
//type:0(未线索化)
//type:1(线索化)
private Integer leftType = 0;
private Integer rightType = 0;

public ThreadedTreeNode(Integer val) {
this.val = val;
}

@Override
public String toString() {
return "TreeNode{" +
"val=" + val +
'}';
}

}
线索化比较
  1. 前序线索化二叉树
    • 遍历相对最容易理解,实现起来也比较简单。由于前序遍历的顺序是:根左右,所以从根节点开始,沿着左子树进行处理,当子节点的left指针类型是线索时,说明到了最左子节点,然后处理子节点的right指针指向的节点,可能是右子树,也可能是后继节点,无论是哪种类型继续按照上面的方式(先沿着左子树处理,找到子树的最左子节点,然后处理right指针指向),以此类推,直到节点的right指针为空,说明是最后一个,遍历完成。
  2. 中序线索化二叉树
    • 中序遍历的顺序是:左根右,因此第一个节点一定是最左子节点,先找到最左子节点,依次沿着right指针指向进行处理(无论是指向子节点还是指向后继节点),直到节点的right指针为空,说明是最后一个,遍历完成。
  3. 后序线索化二叉树
    • 最为复杂,通用的二叉树数节点存储结构不能够满足后序线索化,因此我们扩展了节点的数据结构,增加了父节点的指针。后序的遍历顺序是:左右根,先找到最左子节点,沿着right后继指针处理,当right不是后继指针时,并且上一个处理节点是当前节点的右节点,则处理当前节点的右子树,遍历终止条件是:当前节点是root节点,并且上一个处理的节点是root的right节点。

实际应用

堆排序

概述

  • 堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序

  • 堆是具有以下性质的完全二叉树

    • 每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆

      • image-20211215203104513

      • image-20211215203118348

        大顶堆特点:

        arr[i] >= arr[2*i+1] && arr[i] >= arr[2*i+2]  // i 对应第几个节点,i从0开始编号
    • 每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆

      • image-20211215203135360

        小顶堆特点:

        arr[i] <= arr[2*i+1] && arr[i] <= arr[2*i+2] // i 对应第几个节点,i从0开始编号
  • 注意 : 没有要求结点的左孩子的值和右孩子的值的大小关系。

  • 一般升序采用大顶堆,降序采用小顶堆。

排序思路

步骤一 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。

  1. .假设给定无序序列结构如下

image-20211219204046674

  1. .此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点 arr.length/2-1=5/2-1=1,也就是下面的6结点),从左至右,从下至上进行调整。

image-20211219204147281

  1. .找到第二个非叶节点4,由于[4,9,8]中9元素最大,4和9交换。

image-20211219204201784

  1. 这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6最大,交换4和6。

image-20211219204212472

此时,我们就将一个无序序列构造成了一个大顶堆。

步骤二 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。

  1. .将堆顶元素9和末尾元素4进行交换

image-20211219204325333

  1. .重新调整结构,使其继续满足堆定义

image-20211219204335755

  1. .再将堆顶元素8与末尾元素5进行交换,得到第二大元素8.

image-20211219204355530

  1. 后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序

image-20211219204406676

排序规则

  1. 将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆,整个序列的最大值/最小值就是堆顶的根节点。
  2. 将堆顶元素与末尾元素交换,将最大元素”沉”到数组末端;
  3. 重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素(然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。),反复执行调整+交换步骤,直到整个序列有序。
  4. 代码实现:升序

代码实现

import java.text.SimpleDateFormat;
import java.util.Date;

public class HeapSort {
public static void main(String[] args) {
//对该数组进行升序排列
//int[] arr = {4, 6, 8, 5, 9,0,-1,22,-88};
//heapSort(arr);
//测试堆排序的速度 随机给80000个数 ---
int[] arr = new int[800000];
for (int i = 0; i < 800000; i++) {
arr[i] = (int) (Math.random() * 800000);//[0,800000)
}
Date date1 = new Date();
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String date1Str = simpleDateFormat.format(date1);
System.out.println("排序前的时间是:" + date1Str);
heapSort(arr);
Date date2 = new Date();
String date2Str = simpleDateFormat.format(date2);
System.out.println("排序后的时间是:" + date2Str);
}

//编写方法实现堆排序
public static void heapSort(int[] arr) {
int temp = 0;
//分步完成

//存进大顶堆
//1)将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
for (int i = arr.length / 2 - 1; i >= 0; i--) {// arr.length / 2 - 1分叶子节点个数,i表示非叶子节点的下标
adjustHeap(arr, i, arr.length);
}

//排序
// System.out.println("调整后的堆为:" + Arrays.toString(arr));
//2)将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
//3)重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
for (int j = arr.length - 1; j > 0; j--) {
//交换
temp = arr[j];
arr[j] = arr[0];//调整好的堆 最大值肯定在数组的第一位
arr[0] = temp;
adjustHeap(arr, 0, j);
}
// System.out.println("堆排序的结果:" + Arrays.toString(arr));
}

/**
* 将一个数组(二叉树)调整成一个大顶堆
* 功能:将以i为父节点的树 调整成大顶堆
*
* @param arr 待调整的数组
* @param i i表示非叶子节点在数组中的索引
* @param length 对length个数据进行调整 没调整一次length-1
*/
public static void adjustHeap(int arr[], int i, int length) {
int temp = arr[i];//保存当前元素的值
//调整
// k = i * 2 + 1是i节点的左子结点
for (int k = i * 2 + 1; k < length; k = k * 2 + 1) {
if (k + 1 < length && arr[k] < arr[k + 1]) {//k + 1 < length为了提高效率 arr[k] < arr[k + 1]左子节点的值小于右子节点的值
k++;//k指向右子结点
}
if (arr[k] > temp) {//子结点大于当前值
arr[i] = arr[k]; //把两者中较大的值赋给当前节点arr[i]
i = k;//i指向k继续循环比较
} else {
break;
}
}//for循环结束后 以i为父节点的树的最大值已经放在了最顶部
arr[i] = temp;//将temp的值放在调整后的位置
}

}

优先队列

普通的队列是一种先进先出的数据结构,元素在队列尾追加,而从队列头删除。在某些情况下,我们可能需要找出队列中的最大值或者最小值,例如使用一个队列保存计算机的任务,一般情况下计算机的任务都是有优先级的,我们需要在这些计算机的任务中找出优先级最高的任务先执行,执行完毕后就需要把这个任务从队列中移除。普通的队列要完成这样的功能,需要每次遍历队列中的所有元素,比较并找出最大值,效率不是很高,这个时候,我们就可以使用一种特殊的队列来完成这种需求,优先队列。

image-20211219183323285

优先队列按照其作用不同,可以分为以下两种:

  • 最大优先队列:
    • 可以获取并删除队列中最大的值
  • 最小优先队列:
    • 可以获取并删除队列中最小的值

最大优先队列(大顶堆实现)

我们前面学习堆的时候,堆中存放数据元素的数组要满足都满足如下特性:

  1. 最大的元素放在数组的索引1处。
  2. 每个结点的数据总是大于等于它的两个子结点的数据。

image-20211219195903190

我们之前学习过堆,而堆这种结构是可以方便的删除最大的值,所以,接下来我们可以基于堆区实现最大优先队列。

image-20211219185354845

代码实现
public class MaxPriorityQueue<T extends Comparable<T>> {

//存储队中的元素
private T[] items;

//存储堆中元素的个数
private int N;

//构造方法,获取队列中元素的个数
public MaxPriorityQueue(int capacity) {
this.items = (T[]) new Comparable[capacity];
this.N = 0;
}

//判断队列个数
public int size() {
return N;
}

//判断队列是否为空
public boolean isEmpty() {
return N == 0;
}

//判断堆中索引处的元素是否小于索引j处的元素
private boolean less(int i, int j) {
return items[i].compareTo(items[j]) < 0;
}

//交换堆中i索引和j索引处的值
private void exch(int i, int j) {
T tmp = items[i];
items[i] = items[j];
items[j] = tmp;
}

//往堆中植入一个元素
public void insert(T t) {
items[++N] = t;//添加至队尾
swim(N);
}

//删除堆中最大的元素,并返回这个最大元素
public T delMax() {
T max = items[1];
exch(1, N);//将索引为1和索引为N的元素交换
N--;//移除
sink(1);
return max;
}

//使用上浮算法,使索引k处的元素能在堆中处于一个正确的位置
//将添加至队尾的结点,上浮至正确的位置
public void swim(int k) {
while (k > 1) {//通过while循环比较当前结点和父元素的
if (less(k / 2, k)) {//k/2处的值小于k处的值
exch(k / 2, k);//交换k/2处、k处的值
}
k = k / 2;//k指向k/2
}
}

//使用下沉算法,使索引k处的元素能在堆中处于一个正确的位置
//删除时,将队尾的值移动至k=1处,该方法用于将此值下沉至正确的位置
public void sink(int k) {
while (2 * k <= N) {//通过while循环比较当前结点和子结点中较大结点的值
int max;//索引,记录子节点
if (2 * k + 1 <= N) {//有右子结点,左右节点作比较
if (less(2 * k, 2 * k + 1)) {//左子结点的值小于右子结点的值
max = 2 * k + 1;//max记录右子结点的值
} else {
max = 2 * k;//max记录左子结点的值
}
} else {//直接返回左子结点
max = 2 * k;
}

if (!less(k, max)) {//比较大小,如果当前结点大于子结点中的较大值
break;//跳出循环
}

//如果当前结点小于子结点中的较大值
exch(k, max);//交换

k = max;//k指向max
}
}
}

class MaxPriorityQueueDemo {
public static void main(String[] args) {
//创建优先队列
MaxPriorityQueue<Integer> queue = new MaxPriorityQueue<>(10);

//往队列中存储元素
queue.insert(1);
queue.insert(2);
queue.insert(3);
queue.insert(4);
queue.insert(5);
queue.insert(6);
queue.insert(7);
queue.insert(8);

//通过循环从队列中获取最大的元素
while (!queue.isEmpty()) {
Integer i = queue.delMax();
System.out.println(i);
}

}
}

最小优先队列(小顶堆)

最小优先队列实现起来也比较简单,我们同样也可以基于堆来完成最小优先队列。

实我们之前实现的堆可以把它叫做最大堆,我们可以用相反的思想实现最小堆,让堆中存放数据元素的数组满足如下特性:

  1. 最小的元素放在数组的索引1处。
  2. 每个结点的数据总是小于等于它的两个子结点的数据。

image-20211219204711628

image-20211219205149992

代码实现
public class MinPriorityQueue<T extends Comparable<T>> {
//存储队中的元素
private T[] items;

//存储堆中元素的个数
private int N;

//构造方法,获取队列中元素的个数
public MinPriorityQueue(int capacity) {
this.items = (T[]) new Comparable[capacity + 1];
this.N = 0;
}

//判断队列个数
public int size() {
return N;
}

//判断队列是否为空
public boolean isEmpty() {
return N == 0;
}

//判断堆中索引处的元素是否小于索引j处的元素
private boolean less(int i, int j) {
return items[i].compareTo(items[j]) < 0;
}

//交换堆中i索引和j索引处的值
private void exch(int i, int j) {
T tmp = items[i];
items[i] = items[j];
items[j] = tmp;
}

//往堆中植入一个元素
public void insert(T t) {
items[++N] = t;//添加至队尾
swim(N);
}

//删除堆中最小的元素,并返回这个最小元素
public T delMax() {
T min = items[1];
exch(1, N);//将索引为1和索引为N的元素交换
N--;//移除
sink(1);
return min;
}

//使用上浮算法,使索引k处的元素能在堆中处于一个正确的位置
//将添加至队尾的结点,上浮至正确的位置
public void swim(int k) {
while (k > 1) {//通过while循环比较当前结点和父元素的大小
if (less(k, k / 2)) {//k/2处的值大于k处的值
exch(k, k / 2);//交换k/2处、k处的值,把较小的值往前放
}
k = k / 2;//k指向k/2
}
}

//使用下沉算法,使索引k处的元素能在堆中处于一个正确的位置
//删除时,将队尾的值移动至k=1处,该方法用于将此值下沉至正确的位置
public void sink(int k) {
while (2 * k <= N) {//通过while循环比较当前结点和子结点中较大结点的值
int min;//索引,记录子节点
if (2 * k + 1 <= N) {//有右子结点,左右节点作比较
if (less(2 * k, 2 * k + 1)) {//左子结点的值小于右子结点的值
min = 2 * k;//max记录左子结点的值
} else {
min = 2 * k + 1;//max记录右子结点的值
}
} else {//直接返回左子结点
min = 2 * k;
}

if (less(k, min)) {//比较大小,如果当前结点小于子结点中的较小值
break;//跳出循环
}

//如果当前结点大于子结点中的较小值
exch(k, min);//交换

k = min;//k指向min
}
}
}

class MinPriorityQueueDemo {
public static void main(String[] args) {
//创建优先队列
MinPriorityQueue<Integer> queue = new MinPriorityQueue<>(10);

//往队列中存储元素
queue.insert(1);
queue.insert(2);
queue.insert(3);
queue.insert(4);
queue.insert(5);
queue.insert(6);
queue.insert(7);
queue.insert(8);

//通过循环从队列中获取最大的元素
while (!queue.isEmpty()) {
Integer i = queue.delMax();
System.out.println(i);
}

}
}

索引优先队列

概述

在之前实现的最大优先队列和最小优先队列,他们可以分别快速访问到队列中最大元素和最小元素,但是他们有一个缺点,就是没有办法通过索引访问已存在于优先队列中的对象,并更新它们。为了实现这个目的,在优先队列的基础上,学习一种新的数据结构,索引优先队列。接下来我们以最小优先队列举列

思路分析

步骤一:

存储数据时,给每一个数据元素关联一个整数,例如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]即可。

image-20211219211028260

步骤二:

步骤一完成后的结果,虽然我们给每个元素关联了一个整数,组可以使用这个整数快速的获取到该元素,但是, items数组中的元素顺序是随机的,并环是堆有序的,所以,为了完成这个求,我们可以增加一个数组int]pq,来保存每个元素在items数组中的索引,pq数组需要堆有序,也就是说,pq[1 ]对应的数据元素items[pq[1]要小于等于pq[2]和pq[3]对应的数据元素items[pq[]]和items[pq[3]]。

image-20211219211235969

步骤三
通过步骤二的分析,我们可以发现,实我们通过上浮和下沉做堆调整的时候,其实调整的是pg数组。如果需要对items中的元素进行修改,比如让items[0]=”H”,那么很显然,我们需要对pg中的数据做堆调整,且是调整pg[9]中元素的位置。但现在就会遇到一个问题,我们修改的是items数组中0索引处的值,如何才能快速的知道需要挑中pg[9]中元素的位置呢?

最直观的想法就是遍历pq数组,让每一个元素和0做比较,如果当前元索是0,那么调整该索引处的元索即可,但是效率很低。

我们可以另外增加一个数组,int[] qp,用来存储pq的逆序。例如:

  • 在pq数组中:pq[1]=6;

  • 那么在qp数组中;把6作为索引,1作为值,结果是:qp[6]=1;

image-20211219212317243

当有了pq数组后,如果我们修改item[0]=”H”,那么就可以想通过索引0,在qp数组中找到qp的索引:qp[0]=9,那么直接调整pq[9]即可。

image-20211219214153244

代码实现
public class IndexMinPriorityQueue<T extends Comparable<T>> {
//存储堆中的元素
private T[] items;
//存储每个元素在items数组中的索引,pq数组需要堆有序
private int[] pq;
//保存qp的逆序,pq的值作为索引,pq的索引作为值
private int[] qp;
//记录堆中元素的个数
private int N;

//构造方法
public IndexMinPriorityQueue(int capacity) {
this.items = (T[]) new Comparable[capacity + 1];
this.pq = new int[capacity + 1];
this.qp = new int[capacity + 1];
this.N = 0;

//默认情况下,队列中没有存储任何数据,让qp的元素都为-1
for (int i = 0; i < qp.length; i++) {
qp[i] = -1;
}
}

//获取队列元素的个数
public int size() {
return N;
}

//判断队列是否为空
public boolean inEmpty() {
return N == 0;
}

//判断堆中索引为i,j两处元素的大小
private boolean less(int i, int j) {
return items[pq[i]].compareTo(items[pq[j]]) < 0;
}

//交换堆中元素值
private void exch(int i, int j) {
//交换pq中的数据
int tmp = pq[i];
pq[i] = pq[j];
pq[j] = tmp;

//此时,更新qp中的数据
qp[pq[i]] = i;
qp[pq[j]] = j;
}

//判断k对应的元素是否存在
public boolean contains(int k) {
return qp[k] != -1;
}

//最小元素关联的索引
public int minIndex() {
return pq[1];
}

//往队列中插入一个元素,并关联索引
public void insert(int i, T t) {
// 判断i是否已经被关联,如果已经被关联,不让插入
if (contains(i)) {
return;
}
//元素个数+1
N++;
//把数据存储到items对应的i位置处
items[i] = t;
//把i存储到pq中
pq[N] = i;
//用qp来记录pq的i
qp[i] = N;

//保持堆有序
swim(N);
}

// 删除队列中最小的元素,并返回该元素关联的索引
public int delMin() {
int minIndex = pq[1];
// 交换pq
exch(1, N);
// 删除qp中对应的内容
qp[pq[N]] = -1;
// 删除pq最大索引处的内容
pq[N] = -1;
// 删除items中对应的内容
items[minIndex] = null;
// 元素个数-1
N--;
// 下沉调整
sink(1);

return minIndex;
}

// 删除索引i关联的元素
public void delete(int i) {
//找出i在pq中的索引
int k = pq[i];
exch(k, N);
qp[pq[N]] = -1;
pq[N] = -1;
items[k] = null;
N--;
// 因为元素k是在数组中的中间,所以需要先下沉再上浮,或者先上浮再下沉
sink(k);
swim(k);
}

// 修改索引i关联的元素为t
public void changeItem(int i, T t) {
items[i] = t;
int k = qp[i];
sink(k);
swim(k);
}

// 上浮算法
private void swim(int k) {
while (k > 1) {
if (less(k, k / 2)) {
exch(k, k / 2); //这里面已经更新了pq[]和qp[]
}
k = k / 2;
}
}

//下沉算法
private void sink(int k) {
while (2 * k <= N) {
int min;
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)) {
break;
}
exch(k, min);
k = min; //把最小值赋值给当前结点
}
}
}

class IndexMinPriorityQueueDemo {

public static void main(String[] args) {
IndexMinPriorityQueue queue = new IndexMinPriorityQueue(10);

queue.insert(0, "A");
queue.insert(1, "B");
queue.insert(2, "C");
queue.insert(3, "D");
queue.insert(4, "E");
queue.insert(5, "F");
queue.insert(6, "G");
queue.insert(7, "H");
queue.insert(8, "I");

queue.changeItem(0, "S");

while (!queue.inEmpty()) {
int index = queue.delMin();
System.out.println(index+ " ");
}
}

}

赫尔曼树

概述

  1. 给定n个权值作为n个叶子结点,构造一棵二叉树,若该树的带权路径长度(wpl)达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree),还有的书翻译为霍夫曼树

  2. 赫夫曼树是带权路径长度最短的树,权值较大的结点离根较近。

重要概念

  1. 路径和路径长度:在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为1,则从根结点到第L层结点的路径长度为L-1
  2. 结点的权及带权路径长度:若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积
  3. 树的带权路径长度:树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为WPL(weighted path length) ,权值越大的结点离根结点越近的二叉树才是最优二叉树。
  4. WPL最小的就是赫夫曼树

image-20211216141821681

实例

给你一个数列 {13, 7, 8, 3, 29, 6, 1},要求转成一颗赫夫曼树。

构成赫夫曼树的四个步骤
  1. 从小到大进行排序, 将每一个数据,每个数据都是一个节点 , 每个节点可以看成是一颗最简单的二叉树。
  2. 取出根节点权值最小的两颗二叉树
  3. 组成一颗新的二叉树, 该新的二叉树的根节点的权值是前面两颗二叉树根节点权值的和
  4. 再将这颗新的二叉树,以根节点的权值大小再次排序,不断重复1、2、3、4的步骤,直到数列中,所有的数据都被处理,就得到一颗赫夫曼树。

{13, 7, 8, 3, 29, 6, 1}=>{1,3,6,7,8,13,29}

image-20211216175130270

代码实现
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class HuffmanTree {

public static void main(String[] args) {
int arr[] = {13, 7, 8, 3, 29, 6, 1};

Node node = creatHuffmanTree(arr);

preOrder(node);

}

//编写一个前序遍历的方法
public static void preOrder(Node root) {
if (root != null) {
root.preOrder();
} else {
System.out.println("空树");
}
}

//创建赫夫曼树的方法
public static Node creatHuffmanTree(int[] arr) {
//第一步为了操作方便
//1.遍历arr数组
//2.将arr的每个元素构成一个Node
//3.将Node放入到ArrayList中
List<Node> nodes = new ArrayList<Node>();
for (int value : arr) {
nodes.add(new Node(value));
}

while (nodes.size() > 1) {//最终只剩下一个最大的结点
//排序(从小到大),必须让Node实现Comparable接口
Collections.sort(nodes);

//取出根节点权值最小的两颗二叉树
//1.取出权值最小的结点(二叉树)
Node leftNode = nodes.get(0);
//2.取出权值第二小的结点(二叉树)
Node rightNode = nodes.get(1);
//3.构建一颗新的二叉树
Node parent = new Node(leftNode.value + rightNode.value);
parent.left = leftNode;
parent.right = rightNode;
//4.从ArrayList删除处理过的二叉树
nodes.remove(leftNode);
nodes.remove(rightNode);
//5.将parent加入到nodes
nodes.add(parent);
}
//返回哈夫曼树的root结点
return nodes.get(0);
}

}

//创建结点类
//为了让Node对象持续排序Collections结合排序
//让Node实现Comparable接口
class Node implements Comparable<Node> {
int value;//结点权值
Node left;//指向左子结点
Node right;//指向右子结点

//前序遍历
public void preOrder() {
System.out.println(this);
if (this.left != null) {
this.left.preOrder();
}
if (this.right != null) {
this.right.preOrder();
}

}

public Node(int value) {
this.value = value;
}

@Override
public String toString() {
return "Node{" +
"value=" + value +
'}';
}

@Override
public int compareTo(Node o) {
//表示从小到大
return this.value - o.value;
//return -(this.value - o.value);//表示从大到小
}
}

image-20211216175313977

赫尔曼编码

二叉树排序

给你一个数列 (7, 3, 10, 12, 5, 1, 9),要求能够高效的完成对数据的查询和添加。

概述

二叉排序树:BST: (Binary Sort(Search) Tree),对于二叉排序树的任何一个非叶子节点,要求左子节点的值比当前节点的值小右子节点的值比当前节点的值大

特别说明:如果有相同的值,可以将该节点放在左子节点或右子节点

比如针对前面的数据 (7, 3, 10, 12, 5, 1, 9) ,对应的二叉排序树为:

image-20211216181043914

思路分析

创建

左子节点的值比当前节点的值小右子节点的值比当前节点的值大。

删除

image-20211216194145225

二叉排序树的删除情况比较复杂,有下面三种情况需要考虑:

  1. 第一种情况:删除叶子节点 (比如:2,5,9,12)
    1. 需求先去找到要删除的结点 targetNode
    2. 找到 targetNode 的 父结点 parent
    3. 确定 targetNode 是 parent的左子结点 还是右子结点
    4. 根据前面的情况来对应删除
      • 左子结点 parent.left = null
      • 右子结点 parent.right = null;
  2. 第二种情况:删除只有一颗子树的节点 (比如 1)
    1. 需求先去找到要删除的结点 targetNode
    2. 找到 targetNode 的 父结点 parent
    3. 确定 targetNode 的子结点是左子结点还是右子结点
    4. 确定 targetNode 是 parent 的左子结点还是右子结点
    5. 如果 targetNode 有左子结点
      1. 如果 targetNode 是 parent 的左子结点
        • parent.left = targetNode.left;
      2. 如果 targetNode 是 parent 的右子结点
        • parent.right = targetNode.left;
    6. 如果 targetNode 有右子结点
      1. 如果 targetNode 是 parent 的左子结点
        parent.left = targetNode.right;
      2. 如果 targetNode 是 parent 的右子结点
        parent.right = targetNode.right
  3. 情况三:删除有两颗子树的节点 (比如:7,3,10 )
    1. 需求先去找到要删除的结点 targetNode
    2. 找到 targetNode 的父结点 parent
    3. 从 targetNode 的右子树找到最小的结点
    4. 用一个临时变量 temp ,将最小结点的值保存 (temp=11)
    5. 删除该最小结点
    6. targetNode.value = temp

代码实现

public class BinarySortTreeDemo {

public static void main(String[] args) {

int arr[] = {7, 3, 10, 12, 5, 1, 9};
BinarySortTree binarySortTree = new BinarySortTree();
for (int i = 0; i < arr.length; i++) {
binarySortTree.add(new Node(arr[i]));
}

//中序输出
binarySortTree.infisOrder();


//测试删除叶子节点
binarySortTree.delNode(2);
binarySortTree.delNode(5);
binarySortTree.delNode(9);
binarySortTree.delNode(10);
System.out.println("测试删除节点后");
binarySortTree.infisOrder();
}

}

//二叉排序树
class BinarySortTree {

private Node root;//根节点

//查找想要删除的结点
public Node search(int value) {
if (root == null) {
return null;
} else {
return root.search(value);
}
}

//查找要结点的父节点
public Node searchParent(int value) {
if (root == null) {
return null;
} else {
return root.searchParent(value);
}
}

/**
* 功能:返回以node为根节点的二叉排序树的最小节点的值 并删除以node为根节点的二叉排序树的最小节点
*
* @param node 传入的当做二叉树根节点的节点
* @return 返回以node为根节点的二叉排序树的最小节点的值
*/
public int delRightTreeMin(Node node) {
Node target = node;
//循环的查找左节点 直到找到最小值
while (target.left != null) {
target = target.left;
}//退出循环时说明找到了 target指向的就是最小值
//删除该最小值节点
delNode(target.value);
return target.value;//将最小结点的值返回
}

/**
* 功能:返回以node为根节点的二叉排序树的最大节点的值 并删除以node为根节点的二叉排序树的最大节点
*
* @param node 传入的当做二叉树根节点的节点
* @return 返回以node为根节点的二叉排序树的最大节点的值
*/
public int delLeftTreeMax(Node node) {
Node target = node;
//循环的查找右节点 直到找到最大值
while (target.right != null) {
target = target.right;
}//退出循环时说明找到了 target指向的就是最大值节点
//删除该最大值节点
delNode(target.value);
return target.value;//将最大结点的值返回
}

//删除节点的方法
public void delNode(int value) {
if (root == null) {
return;
} else {
//1.需要先找到要删除的节点 targetNode
Node targetNode = search(value);
//如果没有找到要删除的节点
if (targetNode == null) {
return;//直接结束即可
}
//如果我们发现当这个二叉排序树只有一个节点,而且这个节点就是要查找删除的节点,直接置空删除即可
if (root.left == null && root.right == null) {
root = null;
return;
}
//2.去找targetNode的父节点
Node parent = searchParent(value);
//情况一:如果删除的节点是叶子节点
if (targetNode.left == null && targetNode.right == null) {
//判断targetNode是父节点的左子节点还是右子节点
if (parent.left != null && parent.left.value == value) {//是左子节点
//说明要删除的targetNode就是要删除的
parent.left = null;
} else if (parent.right != null && parent.right.value == value) {//是右子节点
parent.right = null;

}
} else if (targetNode.left != null && targetNode.right != null) {//这个是情况三
//删除的节点有两个子树
int minValue = delRightTreeMin(targetNode.right);//用个临时变量将最小结点的值保存
targetNode.value = minValue;//重置value值

} else {//情况二,删除只有一颗子树的节点,因为前两种情况都排除了
//如果要删除的节点有左子节点
if (targetNode.left != null) {//表示要删除的节点有左节点
if (parent != null) {//父节点存在
if (parent.left.value == value) {
//如果targetNode是parent的左子节点
parent.left = targetNode.left;//删除
} else {
//说明targetNode是parent的右子节点
parent.right = targetNode.left;//删除
}
} else {//父节点不存在,且目前只有一颗左子树
root = targetNode.left;////删除
}
} else {//表示要删除的节点有右节点
if (parent != null) {//父节点存在
if (parent.left.value == value) {
//如果targetNode是parent的左子节点
parent.left = targetNode.right;//删除
} else {//如果targetNode是parent的右子节点
parent.right = targetNode.right;//删除
}
} else {//父节点不存在,且目前只有一颗右子树
root = targetNode.right;////删除
}
}
}
}
}

//添加结点的方法
public void add(Node node) {
if (root == null) {
root = node;
} else {
root.add(node);
}
}

//遍历方法
public void infisOrder() {
if (root != null) {
root.infisOrder();
} else {
System.out.println("空树,不可遍历");
}
}

}

//创建node节点
class Node {
int value;
Node left;
Node right;

public Node(int value) {
this.value = value;
}

@Override
public String toString() {
return "Node{" + "value=" + value + '}';
}

//添加结点
//递归的形式添加结点,需要满足二叉排序树的要求
public void add(Node node) {
if (node == null) {
return;
}
//判断传入的节点的值,和当前子树的节点的值关系
if (node.value < this.value) {//添加的节点小于当前节点
//如果当前节点左子结点为null
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 void infisOrder() {
if (this.left != null) {
this.left.infisOrder();
}
System.out.println(this);
if (this.right != null) {
this.right.infisOrder();
}
}

/**
* 查找要删除的结点
*
* @param value 希望删除的节点的值
* @return 如果找到了返回该结点,否则返回null
*/
public Node search(int value) {
if (value == this.value) {//找到就返回
return this;
} else if (value < this.value) {//如果查找的值,小于当前节点,向左子树递归查找
if (this.left == null) {//如果左子树为空
return null;
}
return this.left.search(value);//向左递归
} else {//如果查找的值不小于当前结点,享有字数递归查找
if (this.right == null) {
return null;
}
return this.right.search(value);//向右递归
}
}

/**
* 查找要删除结点的父节点
*
* @param value 要查找的值
* @return 要删除结点的父节点
*/
public Node searchParent(int value) {
//如果当前结点就是要删除结点的父节点,就返回
if ((this.left != null && this.left.value == value) || (this.right != null && this.right.value == value)) {
return this;
} else {
if (this.value <= value && this.right != null) {//如果查找的值大于当前结点的值,且当前结点的左子结点不为空
return this.right.searchParent(value);//向右子树递归查找
}
if (this.value > value && this.left != null) {//如果查找的值小于当前结点的值,且当前结点的右子结点不为空
return this.left.searchParent(value);//向左子树递归查找
}
return null;//没有找到父节点,就返回空
}
}
}

平衡二叉树(AVL树)

引出问题

给你一个数列{1,2,3,4,5,6},要求创建一颗二叉排序树(BST), 并分析问题所在.

image-20211218204644523

左边BST 存在的问题分析:

  1. 左子树全部为空,从形式上看,更像一个单链表。
  2. 插入速度没有影响
  3. 查询速度明显降低(因为需要依次比较),不能发挥BST的优势,因为每次还需要比较左子树,其查询速度比单链表还慢
  4. 解决方案——平衡二叉树(AVL)

基本介绍

  1. 平衡二叉树也叫平衡二叉搜索树(Self-balancing binary search tree)又被称为AVL树,可以保证查询效率较高
  2. 具有以下特点:它是一 棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。平衡二叉树的常用实现方法有红黑树AVL替罪羊树Treap伸展树等。
  3. 举例说明,看看下面哪些AVL树,为什么?

image-20211218210230774

左旋转

要求

给你一个数列,创建出对应的平衡二叉树,数列 {4,3,6,5,7,8}

思路分析

image-20211219141210314

代码实现
//左旋转
public void leftRotate(){
//创建一个新的节点,以当前节点为值
Node newNode = new Node(value);
//把新节点的左子树设置为当前节点的左子树
newNode.left = left;
//把新节点的右子树设置成当前结点右子树的左子树
newNode.right = right.left;
//把当前节点的值替换成右子树的值
value = right.value;
//把当前节点的右子树设置成指向当前结点右子树的右子树
right = right.right;
//把当前节点的左子树(结点)指向新生成的结点
left = newNode;
}

之前:

image-20211219140912638

之后:

image-20211219140851428

右旋转

要求

给你一个数列,创建出对应的平衡二叉树.数列 {10,12, 8, 9, 7, 6}

思路分析

image-20211219142008187

代码实现
//右旋转
public void lightRotate(){
//创建一个新的节点,以当前节点为值
Node newNode = new Node(value);
//把新节点的右子树设置为当前节点的右子树
newNode.right = right;
//把新节点的左子树设置成当前结点左子树的右子树
newNode.left = left.right;
//把当前节点的值替换成左子树的值
value = left.value;
//把当前节点的左子树设置成指向当前结左子树的左子树
left = left.left;
//把当前节点的右子树(结点)指向新生成的结点
right = newNode;
}

旋转前:

image-20211219142818308

旋转后:

image-20211219142752812

双旋树

问题

前面的两个数列,进行单旋转(即一次旋转)就可以将非平衡二叉树转成平衡二叉树,但是在某些情况下,单旋转不能完成平衡二叉树的转换。比如数列

int[] arr = { 10, 11, 7, 6, 8, 9 }; 运行原来的代码可以看到,并没有转成 AVL树.

int[] arr = {2,1,6,5,7,3}; // 运行原来的代码可以看到,并没有转成 AVL树

原因

image-20211219144705124

思路分析
  1. 当符号右旋转的条件时
  2. 如果它的左子树的右子树高度大于它的左子树的高度
  3. 先对当前这个结点的左节点进行左旋转(7)
  4. 在对当前结点进行右旋转的操作即可
代码实现
//双旋转
if (rightHeight() - leftHeight() > 1) {//右大于左
if (right != null && right.rightHeight() < right.leftHeight()) {//如果当前结点的右子树的左子树大于右子树的高度
right.rightRotate();//对象前结点的右子树进行右旋转
leftRotate();//再对当前节点进行左旋转
}
//如果没有上述情况,直接左旋转
leftRotate();
} else if (leftHeight() - rightHeight() > 1) {//左大于右
if (left != null && left.leftHeight() < left.rightHeight()) {//如果当前节点的左子树的右子树大于左子树的高度
left.leftRotate();//对当前节点的左子树进行左旋转
rightRotate();//再对当前结点进行右旋转
}
//如果没有上述情况,直接右旋转
rightRotate();
}

总结

方法类:

public class AVLTreeDemo {

public static void main(String[] args) {
int arr1[] = {4, 3, 6, 5, 7, 8};
//创建以一个AVLTree对象
AVLTree leftavlTree = new AVLTree();
//添加结点
for (int i = 0; i < arr1.length; i++) {
leftavlTree.add(new Node(arr1[i]));
}

//遍历
leftavlTree.infisOrder();

//子树高度
System.out.println("树的高度:" + leftavlTree.getRoot().height());
//左子树高度
System.out.println("左子树高度:" + leftavlTree.getRoot().leftHeight());
//右子树高度
System.out.println("右子树高度:" + leftavlTree.getRoot().rightHeight());

int arr2[] = {10, 12, 8, 9, 7, 6};
//创建以一个AVLTree对象
AVLTree rightavlTree = new AVLTree();
//添加结点
for (int i = 0; i < arr2.length; i++) {
rightavlTree.add(new Node(arr2[i]));
}

//遍历
rightavlTree.infisOrder();

//子树高度
System.out.println("树的高度:" + rightavlTree.getRoot().height());
//左子树高度
System.out.println("左子树高度:" + rightavlTree.getRoot().leftHeight());
//右子树高度
System.out.println("右子树高度:" + rightavlTree.getRoot().rightHeight());


int arr3[] = {10, 12, 8, 9, 7, 6};
//创建以一个AVLTree对象
AVLTree avlTree = new AVLTree();
//添加结点
for (int i = 0; i < arr3.length; i++) {
avlTree.add(new Node(arr3[i]));
}

//遍历
avlTree.infisOrder();

//子树高度
System.out.println("树的高度:" + avlTree.getRoot().height());
//左子树高度
System.out.println("左子树高度:" + avlTree.getRoot().leftHeight());
//右子树高度
System.out.println("右子树高度:" + avlTree.getRoot().rightHeight());
}
}

输出结果:

Node{value=3}
Node{value=4}
Node{value=5}
Node{value=6}
Node{value=7}
Node{value=8}
树的高度:3
左子树高度:2
右子树高度:2
Node{value=6}
Node{value=7}
Node{value=8}
Node{value=9}
Node{value=10}
Node{value=12}
树的高度:3
左子树高度:2
右子树高度:2
Node{value=6}
Node{value=7}
Node{value=8}
Node{value=9}
Node{value=10}
Node{value=12}
树的高度:3
左子树高度:2
右子树高度:2

右大于左,左旋转

左大于右,右旋转

add方法:

//添加结点
//递归的形式添加结点,需要满足二叉排序树的要求
public void add(Node node) {
if (node == null) {
return;
}
//判断传入的节点的值,和当前子树的节点的值关系
if (node.value < this.value) {//添加的节点小于当前节点
//如果当前节点左子结点为null
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);
}
}

//当添加完一个节点后,右子树与左子树的高度差>1,就是用AVL
if (rightHeight() - leftHeight() > 1) {//右大于左
leftRotate();
} else if (leftHeight() - rightHeight() > 1) {//左大于右
rightRotate();
}

//双旋转
if (rightHeight() - leftHeight() > 1) {//右大于左
if (right != null && right.rightHeight() > right.leftHeight()) {//如果当前结点的右子树的左子树大于右子树的高度
right.rightRotate();//对象前结点的右子树进行右旋转
leftRotate();//再对当前节点进行左旋转
}
//如果没有上述情况,直接左旋转
leftRotate();
} else if (leftHeight() - rightHeight() > 1) {//左大于右
if (left != null && left.leftHeight() > left.rightHeight()) {//如果当前节点的左子树的右子树大于左子树的高度
left.leftRotate();//对当前节点的左子树进行左旋转
rightRotate();//再对当前结点进行右旋转
}
//如果没有上述情况,直接右旋转
rightRotate();
}
}

多路查找树

二叉树与B树

二叉树

二叉树的操作效率较高,但是也存在问题, 请看下面的二叉树

image-20211219151929265

  • 二叉树需要加载到内存的,如果二叉树的节点少,没有什么问题,但是如果二叉树的节点很多(比如1亿),就存在如下问题:
    • 问题1:在构建二叉树时,需要多次进行I/O操作(海量数据存在数据库或文件中),节点海量,构建二叉树时,速度有影响
    • 问题2:节点海量,也会造成二叉树的高度很大,会降低操作速度。

多叉树

  1. 在二叉树中,每个节点有数据项,最多有两个子节点。如果允许每个节点可以有更多的数据项和更多的子节点,就是多叉树(multiway tree)
  2. 后面我们讲解的2-3树,2-3-4树就是多叉树,多叉树通过重新组织节点,减少树的高度,能对二叉树进行优化
  3. 举例说明(下面2-3树就是一颗多叉树)

image-20211219153313282

B数

B树通过重新组织节点,降低树的高度,并且减少i/o读写次数来提升效率。

image-20211219153543425

  1. 如图B树通过重新组织节点, 降低了树的高度
  2. 文件系统及数据库系统的设计者利用了磁盘预读原理,将一个节点的大小设为等于一个页(页得大小通常为4k),这样每个节点只需要一次I/O就可以完全载入
  3. 将树的度M设置为1024,在600亿个元素中最多只需要4次I/O操作就可以读取到想要的元素, B树(B+)广泛应用于文件存储系统以及数据库系统中

2-3树

2-3树是最简单的B树结构,为了保证查找树的平衡性,我们需要一些灵活性,因此在这里我们允许树中的一个结点保存多个键。确切的说,我们将一棵标准的二叉查找树中的结点称为2-结点(含有一个键和两条链),而现在我们引入3-结点,它含有两个键和三条链。2-结点和3-结点中的每条链都对应着其中保存的键所分割产生的一个区间。

  • 2-结点:
    • 含有一个键(及其对应的值)和两条链,左链接指向2-3树中的键都小于该结点,右链接指向的2-3树中的键都大于该结点。
  • 3-结点:
    • 含有两个键(及其对应的值)和三条链,左链接指向的2-3树中的键都小于该结点,中链接指向的2-3树中的键都位于该结点的两个键之间,右链接指向的2-3树中的键都大于该结点。

特点

  1. 2-3树的所有叶子节点都在同一层.(只要是B树都满足这个条件)
  2. 有两个子节点的节点叫二节点,二节点要么没有子节点,要么有两个子节点.
  3. 有三个子节点的节点叫三节点,三节点要么没有子节点,要么有三个子节点.
  4. 2-3树是由二节点和三节点构成的树。

查找

将二叉查找树的查找算法一般化我们就能够直接得到2-3树的查找算法。要判断一个键是否在树中,我们先将它和根结点中的键比较。如果它和其中任意一个相等,查找命中;否则我们就根据比较的结果找到指向相应区间的连接,并在其指向的子树中递归地继续查找。如果这个是空链接,查找未命中。

image-20211219233401095

插入

  1. 2-3树的所有叶子节点都在同一层。(只要是B树都满足这个条件)
  2. 有两个子节点的节点叫二节点,二节点要么没有子节点,要么有两个子节点。
  3. 有三个子节点的节点叫三节点,三节点要么没有子节点,要么有三个子节点。
  4. 当按照规则插入一个数到某个节点时,不能满足上面三个要求,就需要拆,先向上拆,如果上层满,则拆本层,拆后仍然需要满足上面3个条件。
  5. 对于三节点的子树的值大小仍然遵守。(BST 二叉排序树)的规则)

1.向2-结点中插入新键

往2-3树中插入元素和往二叉查找树中插入元素一样,首先要进行查找,然后将节点挂到未找到的节点上。2-3树之所以能够保证在最差的情况下的效率的原因在于其插入之后仍然能够保持平衡状态。如果查找后未找到的节点是一个2-结点,那么很容易,我们只需要将新的元素放到这个2-结点里面使其变成一个3-结点即可。但是如果查找的节点结束于一个3-结点,那么可能有点麻烦。

image-20211219235107064

2.向一棵只含有一个3-结点的树中插入新键

假设2-3树只包含一个3-结点,这个结点有两个键,没有空间来插入第三个键了,最自然的方式是我们假设这个结点能存放三个元素,暂时使其变成一个4结点,同时他包含四条链接。然后,我们将这个4结点的中间元素提升,左边的键作为左子结点,右边的键作为其右子结点。插入完成,变为平衡2-3查找树,树的高度从0变为1。

image-20211219235141319

3.向一个父节点为2-结点的3-结点中插入新键

和上面的情况一样,我们也可以将新的元素插入到3-结点中,使其成为一个临时的4-结点,然后,将该结点中的中间元素提升到父结点即2结点中,使其父结点成为一个3-结点,然后将左右结点分别挂在这个3-结点的恰当位置。

image-20211220000544831

4.向一个父节点为3-结点的3-结点中插入新键

当我们插入的结点是3结点的时候,我们将该结点拆分,中间元素提升至父结点,但是此时父结点是一个3结点 ,插入之后,父结点变成了4结点,然后继续将中间元素提升至其父结点,直至遇到一个父结点是2-结点,然后将其变为3结点,不需要继续进行拆分。

image-20211220001414749

5.分解根节点

当插入结点到根结点的路径上全部是3结点的时候,最终我们的根结点会编程一个临时的4-结点,此时,就需要将根结点拆分为两个2-结点,树的高度加1。

image-20211220002030847

性质

通过对2-3树插入操作的分析,我们发现在插入的时候, 2-3树需要做一些局部的变换来保持2-3树的平衡。

一棵完全平衡的2-3树具有以下性质:

  1. 任意空链接到根结点的路径长度都是相等的。
  2. 4-结点变换为3-结点时,树的高度不会发生变化(4),只有当根结点是临时的4-结点。分解根结点时,树高+1(5)。
  3. 2-3树与普通二叉查找树最大的区别在于,普通的二叉查找树是自顶向下生长,而2-3树是自底向上生长。

总结

直接实现2-3树比较复杂,因为:

  • 要处理不同的结点类型,非常繁琐;
  • 需要多次比较操作来将结点下移;
  • 要上移来拆分4-结点;
  • 拆分4结点的情况有很多种;

2-3查找树实现起来比较复杂,在某些情况插入后的平衡操作可能会使得效率降低。但是2-3查找树作为一种比较重要的概念和思路对于我们后面要讲到的红黑树、B树和B+树非常重要。

红黑树

概述

我们前面介绍了2-3树,可以看到2-3树能保证在插入元素之后,树依然保持平衡状态,它的最坏情况下所有子结点都是2-结点,树的高度为IogN,相比于我们普通的二叉查找树,最坏情况下树的高度为N,确实保证了最坏情况下的时间复杂度,但是2-3树实现起来过于复杂,所以我们介绍一种2-3树思想的简单实现:红黑树。
红黑树主要是对2-3树进行编码,红黑树背后的基本思想是用标准的二叉查找树(完全由2-结点构成)和一些额外的信息(替换3-结点)来表示2-3树。我们将树中的链接分为两种类型:

红链接:将两个2-结点连接起来构成一个3-结点

黑链接:则是2-3树中的普遍链接

确切的说,我们将3-结点表示为由一条左斜的红色链接(两个2结点其中之一是另一个的左子结点相连的两个2结点。这种表示法的一个优点是,我们无需修改就可以直接使用标准的二叉查找树的get方法。

image-20211220085822472

定义

红黑树是含有红黑链接并满足下列条件的二叉查找树:

  1. 红链接均为左链接;
  2. 没有任何一个结点同时和两条红链按相连;(存在四链接)
  3. 该树是完美黑色平衡的,即任意空链按到根结点的路径上的黑链接数量相同;

下面是红黑树与2-3树的对应关系:

image-20211220090433898

思路分析

用一个boolean值来表示颜色,true为红链接,false为黑链接。

image-20211220091340979

image-20211220091454829

树的操作

平衡化

在对红黑树进行一些增删改查的操作后,很有可能会出现红色的右链接或者两条连续红色的链接,而这些都不满足红黑树的定义,所以我们需要对这些情况通过旋转进行修复,让红黑树保持平衡。

左旋

==当某个结点的左子结点为黑色,右子结点为红色,此时需要左旋 。(必须保证左子结点为红色)==

前提: 当前结点为 h ,它的右子结点为 x ;

左旋过程:

  1. 让 x 的左子结点变为 h 的右子结点: h.right=x.left;
  2. 让 h 成为 x 的左子结点: x.left=h;
  3. 让x 的 color 属性变为h 的 color 属性值: x.color=h.color;
  4. 让 h 的 color 属性变为红色 : h.color=true;

image-20211220093454129

右旋

==当某个结点的左子结点是红色,且左子结点的左子结点也是红色,需要右旋。==

前提: 当前结点为 h ,它的左子结点为 x ;

右旋过程:

  1. 让 x 的右子结点成为 h 的左子结点: h.left = x.right;
  2. 让 h 成为 x 的右子结点: x.right=h;
  3. 让 x 的 color 变为 h 的 color 属性值: x.color = h.color;
  4. 让h 的 color 为红色 ;

image-20211220093406092

但是右旋后,还是不符合红黑树的定义,这时候还需要一个==颜色反转==的操作。

插入

1.向单个2-结点中插入新键
  1. 如果新键小于当前结点的键,我们只需要新增一个红色结点即可。

    image-20211220094135825

  2. 如果新键大于当前结点的键,那么新增的红色结点将会产生一条红色的右链接,此时我们需要通过左旋,把红色右链接变成左链接,插入操作才算完成。

    image-20211220094114350

2.向底部的2-结点插入新键

我们会用红链接将新结点和它的父结点相连。如果它的父结点是一个2-结点,那么刚才讨论的两种方式仍然适用。

image-20211220094425480

3.颜色反转

当一个结点的左子结点和右子结点的 color 都为 RED 时,也就是出现了临时的 4- 结点,此时只需要把左子结点和右子点的颜色变为BLACK ,同时让当前结点的颜色变为 RED 即可。

image-20211220094522060

4.向一个双键树中插入新键
  1. 新键大于原树中的两个键

    image-20211220094621838

  2. 新键小于原树中的两个键

    image-20211220095657623

  3. 新键介于原数中两个键之间

    image-20211220100250376

5.根结点的颜色总是黑色

在结点 Node 对象中 color 属性表示的是父结点指向当前结点的连接的颜色,由于根结点不存在父结点, 所以每次插入操作后,我们都需要把根结点的颜色设置为黑色

6.向树底部的3-结点插入新键

假设在树的底部的一个 3- 结点下加入一个新的结点。前面我们所讲的 3 种情况都会出现。指向新结点的链接可能是 3-结点的右链接(此时我们只需要转换颜色即可),或是左链接 ( 此时我们需要进行右旋转然后再转换 ) ,或是中链 接( 此时需要先左旋转然后再右旋转,最后转换颜色 ) 。颜色转换会使中间结点的颜色变红,相当于将它送入了父结点。这意味着父结点中继续插入一个新键,我们只需要使用相同的方法解决即可,直到遇到一个2- 结点或者根结点为止。

image-20211220100718754

代码实现

image-20211220101107115

//红黑树
public class RedBlackTree<K extends Comparable<K>, V> {

//根节点
private Node<K, V> root;
//结点个数
private int N;
//直接true、false可读性不高
private static final boolean RED = true;//红
private static final boolean BLACK = false;//黑

//结点类
private class Node<K, V> {
//存储键
private K key;
//存储值
private V value;
//记录左子结点
private Node<K, V> left;
//记录右子结点
private Node<K, V> right;
//我们将指向该结点的连接定义成颜色。
//在红黑树中,根节点是一个比较特殊的结点,它的连接一定是黑色的,因为没有任何结点指向它/
//由其父节点指向它的链接的颜色
private boolean color;

//构造方法
public Node(K key, V value, Node<K, V> left, Node<K, V> right, boolean color) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
this.color = color;
}
}

//构造方法
public RedBlackTree() {
root = null;
N = 0;
}

//获取树中元素的个数
public int size() {
return N;
}

//判断树是否为空
public boolean isEmpty() {
return N == 0;
}

/**
* @param node 结点
* @return 判断指向该结点的连接是否为红色
*/
public boolean isRed(Node<K, V> node) {
//如果为null,我们认为其连接是黑色的
if (node == null) return false;
return node.color == RED;
}

//因为红黑树本身一个种平衡树。所以我们要进行树的平衡化。使整个树平衡(意思就是:在数进行增删改查操作时不破坏红黑树的规则):

/**
* @param node h结点的右连接为红,我们需要将该红链接改到左面
* @return Node 返回更改后满足条件的结点(更改后的结点并不是h了,是h的右子节点)
* 左旋目的:是为了有4-结点时操作
* <p>
* 由于传进来的这个结点应该还是其父节点的,所以我们要返回修改后的结点作为其原来父节点的左/右结点。
*/
private Node<K, V> rotateLeft(Node<K, V> node) {
//获取node结点的右子节点rightnode
Node<K, V> rightnode = node.right;
//将rightnode的左子节点作为当前结点的右子节点(因为rightnode的左子节点必比node大)
node.right = rightnode.left;
//将node作为rightnode的左子节点
rightnode.left = node;
//将node的颜色复制给rightnode颜色,因为之前与上面连接的是h,该后就变成rightnode了
rightnode.color = node.color;
//node的颜色为红色
node.color = RED;
//返回新的结点
return rightnode;
}

/**
* @param node node结点和其左子结点(leftnode)的连接和 leftnode和leftnode的左子结点的左子树连接都为红,我们需要进行右旋操作
* @return Node 返回更改后的结点(更改后,返回为:node与其左子结点和其右子结点的连接都为红。但是显然,这样也不行,因为还是构成了一个4-结点,所以我们好需要一步操作:”颜色反转“)
* 右旋目的:当出现当出现一个结点的两个连接都为红链接时,说明这是一个4-结点,但是红黑树/2-3树不允许4-结点的存在
* 函数作用:“将node结点和其左子结点(leftnode)的连接和 leftnode和leftnode的左子结点的左子树连接都为红” 修改为: “该结点与其左子结点和其右子结点的连接都为红”
*/
private Node<K, V> rotateRight(Node<K, V> node) {
//获取h的左子节点leftnode
Node<K, V> leftnode = node.left;
//leftnode的右子节点作为node的左子节点
node.left = leftnode.right;
//node作为leftnode的右子节点
leftnode.right = node;
//把node的颜色赋值给leftnode的颜色
leftnode.color = node.color;
//让node的颜色为红色
node.color = RED;
//返回修改后满足条件的结点
return leftnode;
}

/**
* 反转颜色:即将右旋后的结果在进行处理,使其恢复红黑树的规则
* 这个方法就比较简单了
*
* @param node node结点的左右子树连接都为红色时,进行修改
*/
private void flipColors(Node<K, V> node) {
//让node的左子节点和右子结点的颜色变成黑色
node.left.color = BLACK;
node.right.color = BLACK;
//node结点的颜色变为红色
node.color = RED;
}

//由于我们想要实现平衡树,所以每次插入都让其连接为红色,然后使整颗树平衡
public void put(K key, V value) {
root = put(root, key, value);
//因为红黑树结构可能发生改变,根节点的颜色可能发生改变
//根节点的颜色总是黑色的
root.color = BLACK;
}

/**
* insert重载方法,向指定树中插入元素
*
* @param node 指定树
* @param key 插入的键
* @param value 插入的值
* @return 返回的结点是插入后的根节点(因为插入后可能会引起树根节点的变化)
*/
private Node<K, V> put(Node<K, V> node, K key, V value) {
//如果node为空,那么直接返回一个红色的结点即可
if (node == null) {
N++;
return new Node<>(key, value, null, null, RED);
}

//判断插入元素与当前结点key值的大小关系,(cmp<0说明前面的小)
int cmp = node.key.compareTo(key);//比较
//如果比当前元素小
if (cmp > 0) {//大于,继续左递归查找
node.left = put(node.left, key, value);//递归寻找
} else if (cmp < 0) {//小于,继续右递归查找
node.right = put(node.right, key, value);//同理
} else {//key相等,说明执行的不是增加,是修改
node.value = value;
return node;
}

//我们需要判断插入后,如果不满足红黑树的定义,我们要把它改成满足红黑树的样子
if (isRed(node.right) && !isRed(node.left)) {//当当前结点的左子结点为黑色,右子结点为红色
node = rotateLeft(node);//左旋
}

if (isRed(node.left) && isRed(node.left.left)) {//当当前结点的左子结点为红色,左子结点的左子结点还是红色
node = rotateRight(node);//右旋
}

if (isRed(node.left) && isRed((node.right))) {//当当前节点的左子节点和右子结点都为红色时
flipColors(node);//颜色反转
}

return node;
}

/**
* 获取整个树的key所对应value
*
* @param key 键
* @return 找到的结果
*/
public V get(K key) {
return get(root, key);
}

/**
* get的重载方法,找到某棵树上的键所对应的值
*
* @param node 某个结点/树的根节点
* @param key 键
* @return 找到的结果
*/
private V get(Node<K, V> node, K key) {
if (node == null) return null;//为空

int cmp = node.key.compareTo(key);
if (cmp > 0) {//大于,向左递归,继续查找
return get(node.left, key);
} else if (cmp < 0) {//小于,向右递归,继续查找
return get(node.right, key);
} else {//找到,输出value值
return node.value;
}
}

}
class RedBlackTreeDemo {
public static void main(String[] args) {

//创建树
RedBlackTree<Integer, String> tree = new RedBlackTree<>();

//存入元素
tree.put(1, "A");
tree.put(2, "B");
tree.put(3, "C");
tree.put(4, "D");
tree.put(5, "E");
tree.put(6, "F");
tree.put(7, "G");
tree.put(8, "H");
tree.put(9, "I");

//获取元素
for (int i = 1; i < tree.size(); i++) {
System.out.println(tree.get(i));
}

}
}

image-20211220113037434

B树

B树是一种树状数据结构,它能够存储数据、对其进行排序并允许以O(logn)的时间复杂度进行查找、 顺序读取、插入和删除等操作。

前面已经介绍了2-3树和2-3-4树,他们就是B树(英语:B-tree 也写成B-树),这里我们再做一个说明,我们在学习Mysql时,经常听到说某种类型的索引是基于B树或者B+树的,如图:

image-20211219173715288

特性

B树中允许一个结点中包含多个key,可以是3个、4个、5个甚至更多,并不确定,需要看具体的实现。现在我们选择一个参数M,来构造一个B树,我们可以把它称作是M阶的B树,那么该树会具有如下特点:

  • 每个结点最多有M-1个key,并组以升排列:
  • 每个结点最多能有M个子结点:
  • 根结点至少有两个子结点:

image-20211220115724811

在实际应用中B树的阶数一般都比较大(通常大于100 ) ,所以,即使存储大量的数据,B树的高度仍然比较小,这样在某些应用场景下,就可以体现出它的优势。

存储

若参数M选择为5,那么每个结点最多包含4个键值对,我们以5阶B树为例,看看B树的数据存储。

image-20211220120829657

B树的说明:

  1. B树的阶:节点的最多子节点个数。比如2-3树的阶是3,2-3-4树的阶是4
  2. B树的搜索,从根结点开始,对结点内的关键字(有序)序列进行二分查找,如果命中则结束,否则进入查询关键字所属范围的儿子结点;重复,直到所对应的儿子指针为空,或已经是叶子结点
  3. 关键字集合分布在整颗树中, 即叶子节点和非叶子节点都存放数据.
  4. 搜索有可能在非叶子结点结束
  5. 其搜索性能等价于在关键字全集内做一次二分查找

应用

在我们的程序中,不可避免的需要通过IO操作文件,而我们的文件是存储在磁盘上的。计算机操作磁盘上的文件是通过文件系统进行操作的,在文件系统中就使用到了B树这种数据结构。

磁盘

磁盘能够保存大量的数据,从GB-直到TB级 ,但是他的读取速度比较慢,因为涉及到机器操作,读取速度为毫秒级。

image-20211220121117356

磁盘由盘片构成,每个盘片有两面,又称为盘面。盘片中央有一个可以旋转的主轴,他使得盘片以固定的旋转速率旋转,通常是5400rpm或者是7200rpm,一个磁盘中包含了多个这样的盘片并封装在一个密封的容器内 。盘片的每个表面是由一组称为磁道同心圆组成的,每个磁道被划分为了一组扇区,每一个扇区包含相等数量的数据位,通常是512个子节,扇区之间由一些间隙隔开,这些间隙中不存储数据。

磁盘IO

image-20211220121237801

磁盘用磁头来读写存储在盘片表面的位,而磁头连接到一个移动臂上,移动臂沿着盘片半径前后移动,可以将磁头定位到任何磁道上,这称之为寻道操作。一旦定位到磁道后,盘片转动,磁道上的每个位经过磁头时,读写磁头就可以感知到该位的值,也可以修改值。对磁盘的访问时间分为寻道时间旋转时间,以及传送时间

由于存储介质的特性,磁盘本身存取就比主存慢很多,再加上机械运动耗费,因此为了提高效率,要尽量减少磁盘I/O,减少读写操作。为了到这个目的,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。这样做的理论依据是计算机科学中著名的局部性原理:当一个数据被用到时,其附近的数据也通常会马上被使用。由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间),因此预读可以提高I/O效率。

页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(1024个字节或其整数倍),预读的长度一般为页的整倍数。

文件系统的设计者利用了磁盘预读原理,将一个结点的大小设为等于一个页(1024个字节或其整数倍),这样每个结点只需要一次I/O就可以完全载入。那么3层的B树可以容纳1024 *1024 *1024差不多10亿个数据,如果换成二叉查找树,则需要30层!假定操作系统一次读取一个节点,并且根节点保留在内存中,那么B树在10亿个数据中查找目标值,只需要小于3次硬盘读取就可以找到目标值,但红黑树需要小于30次,因此B树大大提高了IO的操作效率

小结

  1. B树在插入和删除节点的时候如果导致树不平衡,就通过自动调整节点的位置来保持树的自平衡。
  2. 关键字集合分布在整棵树中,即叶子节点和非叶子节点都存放数据。搜索有可能在非叶子节点结束
  3. 其搜索性能等价于在关键字全集内做一次二分查找。

再举例

image-20220130163734135

B+数

B+树也是一种多路搜索树,基于B树做出了改进,主流的DBMS都支持B+树的索引方式,比如MySQL。相比于B-Tree,B+Tree适合文件索引系统

B+树是对B树的一种变形树,它与B树的差异在于:

  1. 非叶结点仅具有索引作用,也就是说,非叶子结点只存储key,不存储value;
  2. 树的所有叶结点构成一个有序链表,可以按照key排序的次序遍历全部数据。

image-20211219173823083

存储

若参数M选择为5,那么每个结点最多包含4个键值对,我们以5阶B+树为例,看看B+树的数据存储。

image-20211220123952003

B+树的说明:

  1. B+树的搜索与B树也基本相同,区别是B+树只有达到叶子结点才命中(B树可以在非叶子结点命中),其性能也等价于在关键字全集做一次二分查找
  2. ==所有关键字都出现在叶子结点的链表中==(即数据只能在叶子节点【也叫稠密索引】),且链表中的关键字(数据)恰好是有序的。
  3. 不可能在非叶子结点命中
  4. 非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层
  5. 更适合文件索引系统
  6. ==B树和B+树各有自己的应用场景,不能说B+树完全比B树好,反之亦然==.

应用

数据库的操作中,查询操作可以说是最频繁的一种操作,因此在设计数据库时,必须要考虑到查询的效率问题, 在很多数据库中,都是用到了B+树来提高查询的效率;

在操作数据库时,我们为了提高查询效率,可以基于某张表的某个字段建立索引,就可以提高查询效率,那其实这个索引就是 B+ 树这种数据结构实现的

未建立主键索引的查询

image-20211220124945572

执行 ==select * from user where id=18== 需要从第一条数据开始,一直查询到第6条,发现id=18,此时才能查询出目标结果,共需要比较6次;

建立主键索引的查询

image-20211220125235822

执行 ==select * from user where id=18== 时,从索引开始查,效率提高;

区间查询

执行==select * from user where id>=12 and id<=18== ,如果有了索引,由于B+树的叶结点形成了一个有序链表,所以我们只需要找到id为12的叶子结点,按照遍历链表的方式顺序往后查即可,效率非常高。

B树和B+树的对比

B+树的优点:

  1. 由于 B+ 树在非叶子结点上不包含真正的数据,只当做索引使用,因此在内存相同的情况下, 能够存放更多的 key 。
  2. B+树的叶子结点都是相连的,因此对整棵树的遍历只需要一次线性遍历叶子结点即可。而且由于数据顺序排列并且相连,所以便于区间查找和搜索。而B树则需要进行每一层的递归遍历。

B树的优点:

由于 B 树的每一个节点都包含 key 和 value,因此我们根据 key查找 value时,只需要找到 key 所在的位置,就能找到 value,但 B+ 树只有叶子结点存储数据,索引每一次查找,都必须一次一次,一直找到树的最大深度处,也就是叶子结点的深度,才能找到value

B+树和B树的差异在于以下几点:

  1. B+树有k个孩子的节点就有k个关键字。也就是孩子数量=关键字数,而B树中,孩子数量=关键字数+1。
  2. B+树非叶子节点的关键字也会同时存在在子节点中,并且是在子节点中所有关键字的最大(或最小)。
  3. B+树的非叶子节点仅用于索引,不保存数据记录,跟记录有关的信息都放在叶子节点中。而B树中,非叶子节点既保存索引,也保存数据记录。
  4. B+树所有关键字都在叶子节点出现,叶子节点构成一个有序链表,而且叶子节点本身按照关键字的大小从小到大顺序链接。

B树

image-20220130164535884

B+树

image-20220130164503945

B+树和B树的查询过程差不多,但是B+树和B树有个根本的差异在于,B+树的中间节点并不直接存储数据。这样的好处都有什么呢?

首先,B+树查询效率更稳定。因为B+树每次只有访问到叶子节点才能找到对应的数据,而在B树中,非叶子节点也会存储数据,这样就会造成查询效率不稳定的情况,有时候访问到了非叶子节点就可以找到关键字,而有时需要访问到叶子节点才能找到关键字。

其次,B+树的查询效率更高。这是因为通常B+树比B树更矮胖(阶数更大, 深度更低),查询所需要的磁盘I/O也会更少。同样的磁盘页大小,B+ 树可以存储更多的节点关键字。

不仅是对单个关键字的查询上,在查询范围上,B+ 树的效率也比B树高。这是因为所有关键字都出现在B+树的叶子节点中,叶子节点之间会有指针,数据又是递增的,这使得我们范围查找可以通过指针连接查找。而在B树中则需要通过中序遍历才能完成查询范围的查找,效率要低很多。

B*树

B*树是B+树的变体,在B+树的非根和非叶子结点再增加指向兄弟的指针。

image-20211219173900243

B*树的说明:

  1. B 树定义了非叶子结点关键字个数至少为(2/3) M,即块的最低使用率为2/3,而B+树的块的最低使用率为B+树的1/2。**
  2. 从第1个特点我们可以看出,B*树分配新结点的概率比B+树要低,空间使用率更高

并查集

并查集是一种树型的数据结构,并查集可以高效地进行如下操作:

  • 查询元素p和元素q是否属于同一组
  • 合并元索p和元素q所在的组

image-20211220133541170

并查集结构

并查集也是一种树型结构,但这棵树跟我们之前讲的二叉树、红黑树、B树等都不一样,这种树的要求比较简单:

  1. 每个元索都唯一的对应一个结点;

  2. 每一组数据中的多个元素都在同一颗树中;

  3. 一个组中的数据对应的树和另外一个组中的数据对应的树之间没有任何联系;

  4. 元素在树中并没有子父级关系的硬性要求;

image-20211220134001266

代码实现

image-20211220134312011

构造方法

  1. 初始情况下,每个元素都在-个独立的分组中 ,所以,初始情况下,并查集中的数据默认分为N个组;
  2. 初始化数组eleAndGroup;
  3. 把eleAndGroup数组的索引看做是每个结点存储的元素,把eleAndGroup数组每个索引处的值看做是该结点所在的分组,那么初始化情况下,i 索引处存储的值就是 i。

image-20211220135507161

合并方法

  1. 如果p和q已经在同一个分组中,则无需合并
  2. 如果p和q不在同一个分组,则只需要将p元素所在组的所有的元素的组标识符修改为q元素所在组的标识符即可
  3. 分组数量

image-20211220142312931

代码

//并查集
public class UF {

//记录并差好几种数据的分组个数
private int[] eleAndGroup;
//记录并吃噶合计中数据的分组个数
private int count;

//初始化并查集
public UF(int N) {
//初始化分组的数量
this.count = N;

//初始化eleAndGroup数组
this.eleAndGroup = new int[N];

//初始化eleAndGroup中元素及其所在数组的标识符
//让eleAndGroup数组的索引作为变成嘎哈及的每个节点的元素
//并且让每个索引处的值(该元素所在的组的标识符)就是该索引
for (int i = 0; i < eleAndGroup.length; i++) {
eleAndGroup[i] = i;
}
}

//获取当前并查集中的数据有多少分组
public int getCount() {
return count;
}

//元素p所在分组的标识符
public int find(int p) {
return eleAndGroup[p];
}

//判断并查集中元素p和元素q是否在同一分组中(判断两个元素的标识符是否相等)
public boolean connected(int p, int q) {
return find(p) == find(q);
}

//把p元素所在分组和q元素所在分组合并
public void union(int p, int q) {
//判断p、q是不是已经在同一分组中,如果已经在同一分组中,直接结束方法
if (connected(p, q)) {
return;
}

//不在同一分组中
//找到p所在分组的标识符
int pGroup = find(p);
//找到q所在分组的标识符
int qGroup = find(q);
//合并:让p所在组的所有元素的组标识符变为q所在分组的标识符
for (int i = 0; i < eleAndGroup.length; i++) {
if (eleAndGroup[i] == pGroup) eleAndGroup[i] = qGroup;
}
//分组个数-1
this.count--;
}
}

应用

如果我们并查集存储的每一个整数表示的是一个大型计算机网络中的计算机 ,则我们就可以通过connected(int p,int g)来检测,该网络中的某两台计算机之间是否连通?如果连通,则他们之间可以通信,如果不连通,则不能通信,此时我们又可以调用union(int p,int q)使得p和q之间连通,这样两台计算机之间就可以通信了。

一般像计算机这样网络型的数据,我们要求网络中的每两个数据之间都是相连通的,也就是说,我们需要调用很多次union方法,使得网络中所有数据相连,其实我们很容易可以得出,如果要让网络中的数据都相连,则我们至少要调用N-1次union方法才可以,但由于我们的union方法中使用for循环遍历了所有的元素,所以很明显,我们之前实现的合并算法的时间复杂度是0(N^2),如果要解决大规模问题,它是不合适的,所以我们需要对算法进行优化。

YF_Tree算法优化

为了提升union算法的性能,我们需要重新设计find方法和union方法的实现,此时我们先需要对我们的之前数据结构中的eleAndGourp数组的含义进行重新设定:

  1. 我们仍然让eleAndGroup数组的索引作为某个结点的元素;
  2. eleAndGroup[i]的值不再是当前结点所在的分组标识,而是该结点的==父结点==;

image-20211220143506071

image-20211220144347086

查询方法优化

  1. 判断当前元素p的父结点eleAndGroup[p]是不是自己,如果是自己则证明已经是根结点了;
  2. 如果当前元素p的父结点不是自己,则让p=eleAndGroup[p],继续找父结点的父结点,直到找到根结点为止;

image-20211220144729036

//元素p所在分组的标识符
public int find(int p) {
while (true) {
if (p == eleAndGroup[p]) {
return p;
}else {
p = eleAndGroup[p];
}
}
}

合并方法优化

  1. 找到p元素所在树的根结点
  2. 找到q元素所在树的根结点
  3. 如果p和q已经在同一个树中,则无需合并;
  4. 如果p和q不在同一个分组,则只需要将p元素所在树根结点的父结点设置为q元素的根结点即可;
  5. 分组数量-1。

image-20211220145355301

//把p元素所在分组和q元素所在分组合并
public void union(int p, int q) {
//p的根节点
int pRoot = find(p);
//q的根节点
int qRoot = find(q);
if (pRoot == qRoot ) {
return;
}
//让p所在树的根节点的父节点变为q所在树的根节点即可
eleAndGroup[pRoot] = qRoot ;

//组的数量-1
this.count--;
}

优化后的性能分析

我们优化后的算法union,如果要把并查集中所有的数据连通,仍然至少要调用N-1次union方法,但是,我们发现union方法中已经没有了for循环,所以union算法的时间复杂度由0(N^2)变为了O(N)。

但是这个算法仍然有问题,因为我们之前不仅修改了union算法,还修改了find算法。我们修改前的find算法的时间复杂度在任何情况下都为0(1),但修改后的find算法在最坏情况下是0(N) :

image-20211220150109345

在union方法中调用了find方法,所以在最坏情况下union算法的时间复杂度仍然为O(N^2)。

路径压缩优化

UF_ Tree中最坏情况下union算法的时间复杂度为0(N^2),其最主要的问题在于最坏情况下,树的深度和数组的大小一样,如果我们能够通过一些算法让合并时,生成的树的深度尽可能的小,就可以优化find方法。

之前我们在union算法中,合并树的时候将任意的一棵树连接到了另外一棵树,这种合并方法是比较暴力的,如果我们把并查集中每一棵树的大小记录下来,然后在每次合并树的时候,把较小的树连接到较大的树上,就可以减小树的深度。

image-20211220150439018

只要我们保证每次合并,都能把小树合并到大树上,就能够压缩合并后新树的路径,这样就能提高find方法的效率。为了完成这个需求,我们需要另外一个数组来记录存储每个根结点对应的树中元素的个数,并且需要一些代码调整数组中的值。

代码实现

image-20211220150705566

//把p元素所在分组和q元素所在分组合并
public void union(int p, int q) {
//p的根节点
int pRoot = find(p);
//q的根节点
int qRoot = find(q);
if (pRoot == qRoot) {
return;
}

//比较pRoot和qRoot对应的树大小,将较小的树合并到较大的树之中
if (sz[pRoot] < sz[qRoot]) {
eleAndGroup[pRoot] = qRoot;//让p所在树的根节点的父节点变为q所在树的根节点即可
sz[qRoot] += sz[pRoot];//更新qRoot中的元素个数
} else {
eleAndGroup[qRoot] = pRoot;//让q所在树的根节点的父节点变为p所在树的根节点即可
sz[pRoot] += sz[qRoot];//更新pRoot中的元素个数
}

//组的数量-1
this.count--;
}

案例——畅通工程

问题

某省调查城镇交通状况,得到现有城镇道路统计表,表中列出了每条道路直接连通的城镇。省政府”畅通工程’的目标是使全省任何两个城镇间都可以实现交通(不一定有直接的道路相连,只要互相间接通过道路可达即可)。问最少还需要建设多少条道路?

20
7
0 1
6 9
3 8
5 11
2 12
6 10
4 8

城镇道路统计表,下面是对数据的解释:

image-20211220152242607

总共有20个城市,目前已经修改好了7条道路,问还需要修建多少条道路,才能让这20个城市之间全部相通?

解题思路

  1. 创建一个并查集UF _ Tree _ Weighted(20);
  2. 分别调用union(0, 1),union(6,9),union(3,8),union(5,11),union(2,12),union(6,10),union(4,8),表示已经修建好的道路把对应的城市连接起来;
  3. 如果城市全部连接起来,那么并查集中剩余的分组数目为1,所有的城市都在一个树中,所以,只需要获取当前并查集中剩余的数目,减去1,就是还需要修建的道路数目。

代码实现

import java.io.BufferedReader;
import java.io.InputStreamReader;

public class RoadUF {

public static void main(String[] args) throws Exception {

//读取文件
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(RoadUF.class.getClassLoader().getResourceAsStream("com/algorithm/_8_2Tree/UF/Test/Test.txt")));

//读取到第一行数据20
int totalNumber = Integer.parseInt(bufferedReader.readLine());

//构建一个并查集
UFTree_Weighted uf = new UFTree_Weighted(totalNumber);

//读取第二行数据
int roadNumber = Integer.parseInt(bufferedReader.readLine());

//循环读取7条数据
for (int i = 0; i < roadNumber; i++) {
//逐行读取数据
String line = bufferedReader.readLine();
String[] str = line.split(" ");
int p = Integer.parseInt(str[0]);
int q = Integer.parseInt(str[1]);

uf.union(p, q);
}

int roads = uf.getCount() - 1;
System.out.println("还需要修建"+roads+"条道路");
}

}

image-20211220154135860

基本介绍

图是一种数据结构,其中结点可以具有零个或多个相邻元素。两个结点之间的连接称为边。结点也可以称为顶点。

概念

顶点、边、路径

无向图:顶点之间的连接没有方向

有向图:顶点之间的连接带有方向

带权图(网):边带权值

image-20211220163302023

表示方式

图的表示方式有两种:二维数组表示(邻接矩阵);链表表示(邻接表

邻接矩阵

邻接矩阵是表示图形中顶点之间相邻关系的矩阵,对于n个顶点的图而言,矩阵是的row和col表示的是1…n个点。

image-20211220163455102

邻接表

邻接矩阵需要为每个顶点都分配n个边的空间,其实有很多边都是不存在,会造成空间的一定损失

邻接表的实现只关心存在的边,不关心不存在的边。因此没有空间浪费,邻接表由数组+链表组成

image-20211220163752150

说明:

  1. 标号为0的节点的相关联的结点为1 2 3 4
  2. 标号为1的结点的相关联结点为0 4
  3. 标号为2的结点的相关联结点为0 4 5
  4. ……

案例

image-20211220164455460

思路分析

  • 存储顶点 String,使用ArrayList
  • 保存矩阵int[] [] edges

代码实现

import java.util.ArrayList;
import java.util.Arrays;

public class Grap {

private ArrayList<String> vertexList;//存储顶点集合
private int[][] edges;//存储树对应的邻结矩阵
private int numOfEdges;//表示边的数目

public static void main(String[] args) {
int n = 5;
String VertexValue[] = {"A", "B", "C", "D", "E"};
//创建图对象
Grap grap = new Grap(n);
//循环添加顶点
for (String vertex : VertexValue) {
grap.insertVertex(vertex);
}
//添加边
//A-B A-C B-C B-D B-E
grap.insertEdges(0,1,1);
grap.insertEdges(0,2,1);
grap.insertEdges(1,2,1);
grap.insertEdges(1,3,1);
grap.insertEdges(0,4,1);

//显示
grap.show();

}

//构造器
public Grap(int n) {
//初始化矩阵和vertexList
edges = new int[n][n];
vertexList = new ArrayList<String>(n);
numOfEdges = 0;
}

//返回节点的个数
public int getNumOfVertex() {
return vertexList.size();
}

//得到变的数目
public int getNumOfEdges() {
return numOfEdges;
}

//返回结点i下标(对应的数据 0->"A" 1->"B" 2->"C")
public String getValueByIndex(int i) {
return vertexList.get(i);
}

//返回v1和v2的权值
public int getWeight(int v1, int v2) {
return edges[v1][v2];
}

//显示图对应的矩阵
public void show() {
for (int[] link : edges) {
System.out.println(Arrays.toString(link));
}
}

//插入节点
public void insertVertex(String vertex) {
vertexList.add(vertex);
}

/**
* 添加边
*
* @param v1 表示点的下标即是第几个顶点:"A"-"B" "A"->"0" "B"->1
* @param v2 表示第二个顶点对应的下标
* @param weight 表示权值
*/
public void insertEdges(int v1, int v2, int weight) {
edges[v1][v2] = weight;
edges[v2][v1] = weight;
numOfEdges++;
}

}

图的遍历

所谓图的遍历,即是对结点的访问。一个图有那么多个结点,如何遍历这些结点,需要特定策略,一般有两种访问策略:

  • 深度优先遍历
  • 广度优先遍历

深度优先遍历

思路分析

图的深度优先搜索(Depth First Search) 。

  1. 深度优先遍历,从初始访问结点出发,初始访问结点可能有多个邻接结点,深度优先遍历的策略就是首先访问第一个邻接结点,然后再以这个被访问的邻接结点作为初始结点,访问它的第一个邻接结点, 可以这样理解:每次都在访问完当前结点后首先访问当前结点的第一个邻接结点
  2. 我们可以看到,这样的访问策略是优先往纵向挖掘深入,而不是对一个结点的所有邻接结点进行横向访问。
  3. 显然,深度优先搜索是一个递归的过程

算法步骤

  1. 访问初始结点v,并标记结点v为已访问。
  2. 查找结点v的第一个邻接结点w。
  3. 若w存在,则继续执行4,如果w不存在,则回到第1步,将从v的下一个结点继续。
  4. 若w未被访问,对w进行深度优先遍历递归(即把w当做另一个v,然后进行步骤123)。
  5. 查找结点v的w邻接结点的下一个邻接结点,转到步骤3。

image-20211220181029054

核心代码实现

/**
* 获取第一个邻接结点的下标
*
* @param index 当前访问的结点
* @return 如果存在就返回对应的下标,否则返回-1
*/
public int getFirstNeighbor(int index) {
for (int i = 0; i < vertexList.size(); i++) {
if (edges[index][i] > 0) {
return i;
}
}
return -1;
}

/**
* 根据前一个邻接结点的下标来获取下一个邻接结点
* 查找结点v1的第一个邻接结点v2的下一个邻接结点
*
* @param v1 当前被访问结点的下标
* @param v2 当前结点的第一个邻接结点
* @return
*/
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;
}

//深度优先

/**
* @param isVisited 用来判断当前结点i是否被访问的boolean数组
* @param i 被访问的结点下标
*/
private void dfs(boolean[] isVisited, int i) {
//首先访问该结点,输出
System.out.print(getValueByIndex(i) + "->");
//将结点设置为已经访问
isVisited[i] = true;
//查找结点i的第一个邻接结点
int firstNeighbor = getFirstNeighbor(i);
while (firstNeighbor != -1) {//说明存在邻接结点
if (!isVisited[firstNeighbor]) {//没有被访问过,
dfs(isVisited, firstNeighbor);
}
//如果firstNeighbor已经被访问过
//查找结点i的第一个邻接结点的下一个邻接结点
firstNeighbor = getNextNeighbor(i, firstNeighbor);
}
}

//对dfs进行重载,遍历我们所有的结点,并进行dfs
public void dfs() {
isVisited = new boolean[vertexList.size()];
//便利所有的结点,进行dfs[回溯]
for (int i = 0; i < getNumOfVertex(); i++) {
if (!isVisited[i]) {
dfs(isVisited, i);
}
}
}

广度优先遍历

思路分析

图的广度优先搜索(Broad First Search) 。

类似于一个分层搜索的过程,广度优先遍历需要使用一个队列以保持访问过的结点的顺序,以便按这个顺序来访问这些结点的邻接结点

算法步骤

  1. 访问初始结点v并标记结点v为已访问。
  2. 结点v入队列。
  3. 当队列非空时,继续执行,否则算法结束。
  4. 出队列,取得队头结点u。
  5. 查找结点u的第一个邻接结点w。
  6. 若结点u的邻接结点w不存在,则转到步骤3;否则循环执行以下三个步骤:
    1. 若结点w尚未被访问,则访问结点w并标记为已访问。
    2. 结点w入队列 。
    3. 查找结点u的继w邻接结点后的下一个邻接结点w,转到步骤6。

image-20211220181631283

核心代码实现

/**
* 获取第一个邻接结点的下标
*
* @param index 当前访问的结点
* @return 如果存在就返回对应的下标,否则返回-1
*/
public int getFirstNeighbor(int index) {
for (int i = 0; i < vertexList.size(); i++) {
if (edges[index][i] > 0) {
return i;
}
}
return -1;
}

/**
* 根据前一个邻接结点的下标来获取下一个邻接结点
* 查找结点v1的第一个邻接结点v2的下一个邻接结点
*
* @param v1 当前被访问结点的下标
* @param v2 当前结点的第一个邻接结点
* @return
*/
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;
}

//广度优先
private void bfs(boolean[] isVisited, int i) {
int u;//表示队列的头结点对应的下标
int w;//邻接结点w
//队列,记录结点访问的顺序
LinkedList queue = new LinkedList();
//返回结点,输出结点信息
System.out.print(getValueByIndex(i) + "->");
//标记为已访问
isVisited[i] = true;
//将节点加入队列
queue.addLast(i);

while (!queue.isEmpty()) {
//取出队列的头结点小标
u = (Integer) queue.removeFirst();
//得到第一个邻接点的下标
w = getFirstNeighbor(u);
while (w != -1) {//找到了
//是否访问过
if (!isVisited[w]) {//没有访问过
System.out.print(getValueByIndex(w) + "->");
//标记已经访问
isVisited[w] = true;
//入队列
queue.addLast(w);
}
//已经访问过
//以u为前驱点,找w后面的下一个邻接点
w = getNextNeighbor(u, w);//体现出广度优先
}
}

}

//对dfs进行重载,遍历我们所有的结点,并进行dfs
public void bfs() {
isVisited = new boolean[vertexList.size()];
//便利所有的结点,进行dfs[回溯]
for (int i = 0; i < getNumOfVertex(); i++) {
if (!isVisited[i]) {
bfs(isVisited, i);
}
}
}

代码汇总

深度优先

import java.util.ArrayList;
import java.util.Arrays;

//深度优先
public class DFSGrap {

private ArrayList<String> vertexList;//存储顶点集合
private int[][] edges;//存储树对应的邻结矩阵
private int numOfEdges;//表示边的数目
//定义给数组boolean[],记录某个结点是否被访问
private boolean[] isVisited;


//构造器
public DFSGrap(int n) {
//初始化矩阵和vertexList
edges = new int[n][n];
vertexList = new ArrayList<String>(n);
numOfEdges = 0;
isVisited = new boolean[0];
}

//返回节点的个数
public int getNumOfVertex() {
return vertexList.size();
}

//得到变的数目
public int getNumOfEdges() {
return numOfEdges;
}

//返回结点i下标(对应的数据 0->"A" 1->"B" 2->"C")
public String getValueByIndex(int i) {
return vertexList.get(i);
}

//返回v1和v2的权值
public int getWeight(int v1, int v2) {
return edges[v1][v2];
}

//显示图对应的矩阵
public void show() {
for (int[] link : edges) {
System.out.println(Arrays.toString(link));
}
}

//插入节点
public void insertVertex(String vertex) {
vertexList.add(vertex);
}

/**
* 添加边
*
* @param v1 表示点的下标即是第几个顶点:"A"-"B" "A"->"0" "B"->1
* @param v2 表示第二个顶点对应的下标
* @param weight 表示权值
*/
public void insertEdges(int v1, int v2, int weight) {
edges[v1][v2] = weight;
edges[v2][v1] = weight;
numOfEdges++;
}

/**
* 获取第一个邻接结点的下标
*
* @param index 当前访问的结点
* @return 如果存在就返回对应的下标,否则返回-1
*/
public int getFirstNeighbor(int index) {
for (int i = 0; i < vertexList.size(); i++) {
if (edges[index][i] > 0) {
return i;
}
}
return -1;
}

/**
* 根据前一个邻接结点的下标来获取下一个邻接结点
* 查找结点v1的第一个邻接结点v2的下一个邻接结点
*
* @param v1 当前被访问结点的下标
* @param v2 当前结点的第一个邻接结点
* @return
*/
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;
}

//深度优先

/**
* @param isVisited 用来判断当前结点i是否被访问的boolean数组
* @param i 被访问的结点下标
*/
private void dfs(boolean[] isVisited, int i) {
//首先访问该结点,输出
System.out.print(getValueByIndex(i) + "->");
//将结点设置为已经访问
isVisited[i] = true;
//查找结点i的第一个邻接结点
int firstNeighbor = getFirstNeighbor(i);
while (firstNeighbor != -1) {//说明存在邻接结点
if (!isVisited[firstNeighbor]) {//没有被访问过,
dfs(isVisited, firstNeighbor);
}
//如果firstNeighbor已经被访问过
//查找结点i的第一个邻接结点的下一个邻接结点
firstNeighbor = getNextNeighbor(i, firstNeighbor);
}
}

//对dfs进行重载,遍历我们所有的结点,并进行dfs
public void dfs() {
isVisited = new boolean[vertexList.size()];
//便利所有的结点,进行dfs[回溯]
for (int i = 0; i < getNumOfVertex(); i++) {
if (!isVisited[i]) {
dfs(isVisited, i);
}
}
}

}

class DFSGrapDemo {
public static void main(String[] args) {
int n = 5;
String VertexValue[] = {"A", "B", "C", "D", "E"};
//创建图对象
DFSGrap grap = new DFSGrap(n);
//循环添加顶点
for (String vertex : VertexValue) {
grap.insertVertex(vertex);
}
//添加边
//A-B A-C B-C B-D B-E
grap.insertEdges(0, 1, 1);
grap.insertEdges(0, 2, 1);
grap.insertEdges(1, 2, 1);
grap.insertEdges(1, 3, 1);
grap.insertEdges(1, 4, 1);

//显示
grap.show();

System.out.println("深度遍历");
grap.dfs();

}
}

image-20211220193903313

广度优先

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;

//广度优先
public class BFSGrap {

private ArrayList<String> vertexList;//存储顶点集合
private int[][] edges;//存储树对应的邻结矩阵
private int numOfEdges;//表示边的数目
//定义给数组boolean[],记录某个结点是否被访问
private boolean[] isVisited;

//构造器
public BFSGrap(int n) {
//初始化矩阵和vertexList
edges = new int[n][n];
vertexList = new ArrayList<String>(n);
numOfEdges = 0;
isVisited = new boolean[0];
}

//返回节点的个数
public int getNumOfVertex() {
return vertexList.size();
}

//得到变的数目
public int getNumOfEdges() {
return numOfEdges;
}

//返回结点i下标(对应的数据 0->"A" 1->"B" 2->"C")
public String getValueByIndex(int i) {
return vertexList.get(i);
}

//返回v1和v2的权值
public int getWeight(int v1, int v2) {
return edges[v1][v2];
}

//显示图对应的矩阵
public void show() {
for (int[] link : edges) {
System.out.println(Arrays.toString(link));
}
}

//插入节点
public void insertVertex(String vertex) {
vertexList.add(vertex);
}

/**
* 添加边
*
* @param v1 表示点的下标即是第几个顶点:"A"-"B" "A"->"0" "B"->1
* @param v2 表示第二个顶点对应的下标
* @param weight 表示权值
*/
public void insertEdges(int v1, int v2, int weight) {
edges[v1][v2] = weight;
edges[v2][v1] = weight;
numOfEdges++;
}

/**
* 获取第一个邻接结点的下标
*
* @param index 当前访问的结点
* @return 如果存在就返回对应的下标,否则返回-1
*/
public int getFirstNeighbor(int index) {
for (int i = 0; i < vertexList.size(); i++) {
if (edges[index][i] > 0) {
return i;
}
}
return -1;
}

/**
* 根据前一个邻接结点的下标来获取下一个邻接结点
* 查找结点v1的第一个邻接结点v2的下一个邻接结点
*
* @param v1 当前被访问结点的下标
* @param v2 当前结点的第一个邻接结点
* @return
*/
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;
}

//广度优先
private void bfs(boolean[] isVisited, int i) {
int u;//表示队列的头结点对应的下标
int w;//邻接结点w
//队列,记录结点访问的顺序
LinkedList queue = new LinkedList();
//返回结点,输出结点信息
System.out.print(getValueByIndex(i) + "->");
//标记为已访问
isVisited[i] = true;
//将节点加入队列
queue.addLast(i);

while (!queue.isEmpty()) {
//取出队列的头结点小标
u = (Integer) queue.removeFirst();
//得到第一个邻接点的下标
w = getFirstNeighbor(u);
while (w != -1) {//找到了
//是否访问过
if (!isVisited[w]) {//没有访问过
System.out.print(getValueByIndex(w) + "->");
//标记已经访问
isVisited[w] = true;
//入队列
queue.addLast(w);
}
//已经访问过
//以u为前驱点,找w后面的下一个邻接点
w = getNextNeighbor(u, w);//体现出广度优先
}
}

}

//对dfs进行重载,遍历我们所有的结点,并进行dfs
public void bfs() {
isVisited = new boolean[vertexList.size()];
//便利所有的结点,进行dfs[回溯]
for (int i = 0; i < getNumOfVertex(); i++) {
if (!isVisited[i]) {
bfs(isVisited, i);
}
}
}

}

class BFSGrapDemo {
public static void main(String[] args) {
int n = 5;
String VertexValue[] = {"A", "B", "C", "D", "E"};
//创建图对象
BFSGrap grap = new BFSGrap(n);
//循环添加顶点
for (String vertex : VertexValue) {
grap.insertVertex(vertex);
}
//添加边
//A-B A-C B-C B-D B-E
grap.insertEdges(0, 1, 1);
grap.insertEdges(0, 2, 1);
grap.insertEdges(1, 2, 1);
grap.insertEdges(1, 3, 1);
grap.insertEdges(1, 4, 1);

//显示
grap.show();
System.out.println("深度遍历");
grap.bfs();

}
}

image-20211220202109156

深度优先和广度优先

实例

image-20211220202947941

深度优先

image-20211220204212483

广度优先

image-20211220204243324

代码实现

public class Test {
public static void main(String[] args) {

//广度优先
int n = 8;
String VertexValue[] = {"1", "2", "3", "4", "5","6","7","8"};
//创建图对象
BFSGrap bfsGrap = new BFSGrap(n);
//循环添加顶点
for (String vertex : VertexValue) {
bfsGrap.insertVertex(vertex);
}
//添加边
//A-B A-C B-C B-D B-E
bfsGrap.insertEdges(0, 1, 1);
bfsGrap.insertEdges(0, 2, 1);
bfsGrap.insertEdges(1, 3, 1);
bfsGrap.insertEdges(1, 4, 1);
bfsGrap.insertEdges(3, 7, 1);
bfsGrap.insertEdges(4, 7, 1);
bfsGrap.insertEdges(2, 5, 1);
bfsGrap.insertEdges(2, 6, 1);
bfsGrap.insertEdges(5, 6, 1);

System.out.println("广度优先:");
bfsGrap.bfs();

System.out.println();

//深度优先
//创建图对象
DFSGrap dfsGrap = new DFSGrap(n);
//循环添加顶点
for (String vertex : VertexValue) {
dfsGrap.insertVertex(vertex);
}
//添加边
//A-B A-C B-C B-D B-E
dfsGrap.insertEdges(0, 1, 1);
dfsGrap.insertEdges(0, 2, 1);
dfsGrap.insertEdges(1, 3, 1);
dfsGrap.insertEdges(1, 4, 1);
dfsGrap.insertEdges(3, 7, 1);
dfsGrap.insertEdges(4, 7, 1);
dfsGrap.insertEdges(2, 5, 1);
dfsGrap.insertEdges(2, 6, 1);
dfsGrap.insertEdges(5, 6, 1);

System.out.println("深度优先:");
dfsGrap.dfs();

}
}

image-20211220203814068

常用算法

二分查找(非递归)

  1. 前面我们讲过了二分查找算法,是使用递归的方式,下面我们讲解二分查找算法的非递归方式;
  2. 二分查找法只适用于从有序的数列中进行查找(比如数字和字母等),将数列排序后再进行查找;
  3. 二分查找法的运行时间为对数时间O(log2n) ,即查找到需要的目标位置最多只需要log2n步,假设从[0,99]的队列(100个数,即n=100)中寻到目标数30,则需要查找步数为log2100 , 即最多需要查找7次( 2^6 < 100 < 2^7)

实例

问题

数组 {1,3, 8, 10, 11, 67, 100}, 编程实现二分查找, 要求使用非递归的方式完成。

代码实现

//非递归实现二分查找
public class _1BinarySearch {

/**
* 二分查找的非递归实现
*
* @param arr 待查找的数组,arr是升序排列
* @param target 需要查找的数
* @return 返回对应下标
*/
public static int binarySearch(int[] arr, int target) {
int left = 0;//左头
int right = arr.length - 1;//右头
while (left <= right) {
int mid = (left + right) / 2;//中间值
if (arr[mid] == target) {//找到
return mid+1;
} else if (arr[mid] > target) {
right = mid - 1;//需要向左边查找
} else {
left = mid + 1;//需要向右边查找
}
}
return -1;
}

//递归实现
public static int binarySearch(int[] arr, int left, int right, int findVal) {
int mid = (left + right) / 2;
int midVal = arr[mid];

if (left > right) { //在端点的时候就是left==right
return -1;
}

if (findVal > midVal) { //向右递归
return binarySearch(arr, mid + 1, right, findVal);
} else if (findVal < midVal) { //向左递归
return binarySearch(arr, left, mid - 1, findVal);
} else {
return mid;
}
}

}

class BinarySearchDemo {

public static void main(String[] args) {

int[] arr = {1, 3, 8, 10, 11, 67, 100};

int i = _1BinarySearch.binarySearch(arr, 10);

System.out.println(i);

}

}

分治算法

概述

  1. 分治法是一种很重要的算法。字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)……
  2. 分治算法可以求解的一些经典问题
    • 二分搜索
    • 大整数乘法
    • 棋盘覆盖
    • 合并排序
    • 快速排序
    • 线性时间选择
    • 最接近点对问题
    • 循环赛日程表
    • 汉诺塔

步骤

分治法在每一层递归上都有三个步骤:

  1. 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
  2. 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题;
  3. 合并:将各个子问题的解合并为原问题的解。;

设计模式

分治(Divide-and-Conquer(P))算法设计模式如下:

image-20211221183513873

  • 其中|P|表示问题P的规模;
  • n0为一阈值,表示当问题P的规模不超过n0时,问题已容易直接解出,不必再继续分解。
  • ADHOC(P)是该分治法中的基本子算法,用于直接解小规模的问题P。

因此,当P的规模不超过n0时直接用算法ADHOC(P)求解。

  • 算法MERGE(y1,y2,…,yk)是该分治法中的合并子算法,用于将P的子问题P1 ,P2 ,…,Pk的相应的解y1,y2,…,yk合并为P的解。

实例

问题

汉罗塔

大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。

思路分析

  1. 如果是有一个盘, A->C
  2. 如果我们有 n >= 2 情况,我们总是可以看做是两个盘:最下边的一个盘上面的所有盘
    1. 先把 最上面的盘 A->B
    2. 把最下边的盘 A->C
    3. 把B塔的所有盘 从 B->C

代码实现

//汉罗塔
public class _2Hanoitower {

//记步数
private static int i = 0;

public static void main(String[] args) {
hanoitower(6, 'A', 'B', 'C');
System.out.println("共" + i + "步");
}

public static void hanoitower(int num, char a, char b, char c) {

//如果只有一个盘
while (true) {
if (num == 1) {
i++;
System.out.println("第1个盘子:" + a + "->" + c);
} else {//如果我们有 n >= 2 情况,我们总是可以看做是两个盘 1.最下边的一个盘 2. 上面的所有盘
i++;
//1.先把上面的所有盘 A->B,移动过程会使用到 C
hanoitower(num - 1, a, c, b);
//2.把最下边的盘 A->C
System.out.println("第" + num + "个盘子:" + a + "->" + c);
//3.把B塔的所有盘 从 B->C ,移动过程会使用到 A
hanoitower(num - 1, b, a, c);
}
return;
}
}
}

动态规划

  1. 动态规划(Dynamic Programming)算法的核心思想是:将大问题划分为小问题进行解决,从而一步步获取最优解的处理算法
  2. 动态规划算法与分治算法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。
  3. 与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。 ( 即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解 )
  4. 动态规划可以通过填表的方式来逐步推进,得到最优解.

实例

问题

背包问题:有一个背包,容量为4磅 , 现有如下物品

image-20211221202057683

  1. 要求达到的目标为装入的背包的总价值最大,并且重量不超出
  2. 要求装入的物品不能重复

思路分析

  • 背包问题主要是指一个给定容量的背包、若干具有一定价值和重量的物品,如何选择物品放入背包使物品的价值最大。其中又分01背包和完全背包(完全背包指的是:每种物品都有无限件可用)

  • 这里的问题属于01背包,即每个物品最多放一个。而无限背包可以转化为01背包。

  • 算法的主要思想,利用动态规划来解决。每次遍历到的第i个物品,根据w[i]和v[i]来确定是否需要将该物品放入背包中。即对于给定的n个物品,设v[i]、w[i]分别为第i个物品的价值和重量,C为背包的容量。再令v[i] [j]表示在前i个物品中能够装入容量为j的背包中的最大价值。则我们有下面的结果:

    image-20211221205212112

    1. v[i][0]=v[0][j]=0; //表示填入表第一行和第一列是0
      

      2. ```java
      当w[i]> j 时:v[i][j]=v[i-1][j] // 当准备加入新增的商品的容量大于 当前背包的容量时,就直接使用上一个单元格的装入策略
    2. ```java
      当j>=w[i]时: v[i][j]=max{v[i-1][j], v[i]+v[i-1][j-w[i]]}
      // 当准备加入的新增的商品的容量小于等于当前背包的容量。
      // 比较 上一个单元格能装入的最大价值 和 表示当前商品的价值+能加入的上一个商品最大数量的价值 的大小,那种情况价值大,取哪种情况。
      // 装入的方式:
      // v[i-1][j]: 就是上一个单元格的装入的最大值
      // v[i] : 表示当前商品的价值
      // v[i-1][j-w[i]] : 装入i-1商品,到剩余空间j-w[i]的最大值
      // j-w[i] j:当前容量,w[i]当前商品的重量


      #### 代码实现

      ```java
      //01背包
      public class KnapsackProblem {

      public static void main(String[] args) {

      int[] weight = {1, 4, 3};//物品的重量
      int[] value = {1500, 3000, 2000};//物品的价值,这里的value[i]就是v[i]
      int m = 4;//背包的容量
      int n = value.length;//物品的个数

      //创建二维数组,表示背包
      //v[i][j] 表示在前i个物品中能装入容量为j的背包中的最大价值
      int[][] v = new int[n + 1][m + 1];

      //为了记录放入商品的情况,我们定一个二维数组
      int[][] path = new int[n + 1][m + 1];

      //初始化第一行和第一列,在本程序中可以不写,因为默认为0
      for (int i = 0; i < v.length; i++) {
      v[i][0] = 0;//将第一列设置为0
      }
      for (int i = 0; i < v[0].length; i++) {
      v[0][i] = 0;//将第一行设置为0
      }

      //根据前面得到的公式来动态规划处理
      for (int i = 1; i < v.length; i++) {//不处理第一行,i是从1开始的
      for (int j = 1; j < v[0].length; j++) {//不处理第一列,j是从1开始的
      //公式
      if (weight[i - 1] > j) {//当准备加入的商品的容量大于当前背包的容量时,因为我们程序i是从1开始的,因此原来公式中的w[i]修改为w[i-1]
      v[i][j] = v[i - 1][j];//直接使用上一个单元格的装入策略
      } else {// 当准备加入的新增的商品的容量小于等于当前背包的容量。
      //比较 上一个单元格能装入的最大价值 和 表示当前商品的价值+能加入的上一个商品最大数量的价值 的大小,那种情况价值大,取哪种情况。
      //因为我们程序i是从1开始的,因此原来公式中的value[i]修改为value[i-1],weight[i]该我weight[i-1]

      //v[i][j] = Math.max(v[i - 1][j], value[i - 1] + v[i ][j - weight[i]]);//Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: -3
      //v[i][j] = Math.max(v[i - 1][j], value[i - 1] + v[i - 1][j - weight[i - 1]]);

      //为了记录商品存放到背包的情况,我们不能兼得使用上面的工具
      if (v[i - 1][j] < value[i - 1] + v[i - 1][j - weight[i - 1]]) {
      v[i][j] = value[i - 1] + v[i - 1][j - weight[i - 1]];
      //把当前的情况记录到path
      path[i][j] = 1;
      } else {
      v[i][j] = v[i - 1][j];
      }
      }
      }
      }

      //遍历v,查看一下当前情况
      for (int i = 0; i < v.length; i++) {
      for (int j = 0; j < v[i].length; j++) {
      System.out.print(v[i][j] + " ");
      }
      System.out.println();
      }

      //输出最后我们放入的商品
      //遍历path

      // 这样每更新一次最大值,对应情况的path就会被标记为1,而输出
      // 会输出会把所有放入情况都得到,而我们只需要最后的放入
      // for (int i = 0; i < path.length; i++) {
      // for (int j = 0; j < path[i].length; j++) {
      // if (path[i][j] == 1) {
      // System.out.print("第" + i + "个商品放入到背包");
      // j -= weight[i - 1];
      // }
      // }
      // }

      System.out.println("价值最大的情况:");
      int i = path.length - 1;//行的最大下标
      int j = path[0].length - 1;//列的最大下标
      while (i > 0 && j > 0) {//从path的最后开始找
      if (path[i][j] == 1) {
      System.out.println("第" + i + "个商品放入到背包");
      j -= weight[i - 1];//包的重量-当前商品的重量
      }
      i--;//已经找到一个商品,寻找下一个商品
      }

      }

      }

KMP算法

暴力匹配算法实现

问题

字符串匹配问题:

  1. 有一个字符串 str1= “”硅硅谷 尚硅谷你尚硅 尚硅谷你尚硅谷你尚硅你好””,和一个子串 str2=”尚硅谷你尚硅你”
  2. 现在要判断 str1 是否含有 str2, 如果存在,就返回第一次出现的位置, 如果没有,则返回-1

思路分析

如果用暴力匹配的思路,并假设现在str1匹配到 i 位置,子串str2匹配到 j 位置,则有:

  1. 如果当前字符匹配成功(即str1[i] == str2[j]),则i++,j++,继续匹配下一个字符
  2. 如果失配(即str1[i]! = str2[j]),令i = i - (j - 1),j = 0。相当于每次匹配失败时,i 回溯,j 被置为0。
  3. 用暴力方法解决的话就会有大量的回溯,每次只移动一位,若是不匹配,移动到下一位接着判断,浪费了大量的时间。(不可行!)
  4. 暴力匹配算法实现.

代码实现

//字符匹配
public class ViolenceMatch {

public static void main(String[] args) {

String str1 = "adsadasdasjkdbaskjdasj";
String str2 = "ask";

int i = violenceMatch(str1, str2);

System.out.println(i);

}

//暴力匹配实现
public static int violenceMatch(String str1, String str2) {
char[] s1 = str1.toCharArray();
char[] s2 = str2.toCharArray();

int len1 = s1.length;
int len2 = s2.length;

int i = 0;//i索引指向s1
int j = 0;//j索引指向s2

while (i < len1 && j < len2) {
if (s1[i] == s2[j]) {
i++;
j++;
} else {
i = i - (j - 1);//移动到i的下一位
j = 0;
}
}

//判断是否匹配成功
if (j == len2) {
return i - j;
} else {
return -1;
}
}

}

KMP算法实现

  1. KMP是一个解决模式串在文本串是否出现过,如果出现过,最早出现的位置的经典算法
  2. Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP算法”,常用于在一个文本串S内查找一个模式串P 的出现位置,这个算法由Donald Knuth、Vaughan Pratt、James H. Morris三人于1977年联合发表,故取这3人的姓氏命名此算法.
  3. KMP方法算法就利用之前判断过信息,通过一个next数组,保存模式串中前后最长公共子序列的长度,每次回溯时,通过next数组找到,前面匹配过的位置,省去了大量的计算时间

问题

  1. 有一个字符串 str1= “BBC ABCDAB ABCDABCDABDE”,和一个子串 str2=”ABCDABD”
  2. 现在要判断 str1 是否含有 str2, 如果存在,就返回第一次出现的位置, 如果没有,则返回-1

思路分析

彻底理解KMP

“部分匹配值”就是”前缀”和”后缀”的最长的共有元素的长度。以”ABCDABD”为例,

-”A”的前缀和后缀都为空集,共有元素的长度为0;

-”AB”的前缀为[A],后缀为[B],共有元素的长度为0;

-”ABC”的前缀为[A, AB],后缀为[BC, C],共有元素的长度0;

-”ABCD”的前缀为[A, AB, ABC],后缀为[BCD, CD, D],共有元素的长度为0;

-”ABCDA”的前缀为[==A==, AB, ABC, ABCD],后缀为[BCDA, CDA, DA,==A==],共有元素为”A”,长度为1;

-”ABCDAB”的前缀为[A,==AB==, ABC, ABCD, ABCDA],后缀为[BCDAB, CDAB, DAB, ==AB==, B],共有元素为”AB”,长度为2;

-”ABCDABD”的前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀为[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为0。

当前缀和后缀中有相同的元素时,就可以把后缀中的元素当做是新的起点。

到达这个后缀的步长恰巧是:(已匹配的字符总长度 - 后缀的长度)

代码实现

//kmp算法
public class KMPAlgorithm {

public static void main(String[] args) {

String str1 = "BBC ABCDAB ABCDABCDABDE";
String str2 = "ABCDABD";

int[] next = kmpNext(str2);

System.out.println(Arrays.toString(next));

int index = kmpSearch(str1, str2, next);

System.out.println(index);

}

/**
* kmp搜索算法的实现
*
* @param str1 愿字符串
* @param str2 子串
* @param next 子串对应的部分匹配表
* @return
*/
public static int kmpSearch(String str1, String str2, int[] next) {
//遍历
for (int i = 0, j = 0; i < str1.length(); i++) {

//需要处理str1.charAt(i) != str2.charAt(j)
//KMP核心
while (j > 0 && str1.charAt(i) != str2.charAt(j)) {
j = next[j - 1];
}

if (str1.charAt(i) == str2.charAt(j)) {
j++;
}
if (j == str2.length()) {//找到了
return i - j + 1;
}
}
return -1;
}

/**
* 获取字符串的部分匹配值表的方法
*
* @param dest 当前字符串
* @return 部分匹配表
*/
public static int[] kmpNext(String dest) {
//创建一个数组,保存部分匹配值
int[] next = new int[dest.length()];

next[0] = 0;//如果字符串长度是1,部分匹配值就是0
//i表示后缀缀(一直在移动),j表示前缀
for (int i = 1, j = 0; i < dest.length(); i++) {
//当dest.charAt(i) != dest.charAt(j)成立时
//我们需要从next[j-1]处获取新的j
//直到dest.charAt(i) == dest.charAt(j)成立时,才退出

//这是kmp算法的核心
//加入这句话之前:[0, 0, 0, 0, 1, 2, 2]
//加入这句话之后:[0, 0, 0, 0, 1, 2, 0]
while (j > 0 && dest.charAt(i) != dest.charAt(j)) {//当j>0时,并且后续的值不相等
j = next[j - 1];
//回溯到与当前值相等的前缀下标
//"A B C D A B D A B C C";
//[0, 0, 0, 0, 1, 2, 0, 1, 2, 3, 0]
}

//当dest.charAt(i) == dest.charAt(j)成立时,部分匹配值就是++
if (dest.charAt(i) == dest.charAt(j)) {//值相等时
j++;
}
next[i] = j;
}
return next;
}

}

贪心算法

  1. 贪婪算法(贪心算法)是指在对问题进行求解时,在每一步选择中都采取最好或者最优(即最有利)的选择,从而希望能够导致结果是最好或者最优的算法(局部最优解
  2. 贪婪算法所得到的结果不一定是最优的结果(有时候会是最优解),但是都是相对近似(接近)最优解的结果

实例

问题

集合覆盖问题

假设存在如下表的需要付费的广播台,以及广播台信号可以覆盖的地区。 如何选择最少的广播台,让所有的地区都可以接收到信号

广播台 覆盖地区
K1 “北京”, “上海”, “天津”
K2 “广州”, “北京”, “深圳”
K3 “成都”, “上海”, “杭州”
K4 “上海”, “天津”
K5 “杭州”, “大连”

思路分析

穷举法

如何找出覆盖所有地区的广播台的集合呢,使用穷举法实现,列出每个可能的广播台的集合,这被称为幂集。假设总的有n个广播台,则广播台的组合总共有2n -1 个,假设每秒可以计算10个子集, 如图:

广播台数量n 子集总数2n 需要的时间
5 32 3.2秒
10 1024 102.4秒
32 4294967296 13.6年
100 1.26*10030 4x1023

这种方法,效率低下

贪心算法

使用贪婪算法,效率高:

目前并没有算法可以快速计算得到准备的值, 使用贪婪算法,则可以得到非常接近的解,并且效率高。选择策略上,因为需要覆盖全部地区的最小集合:

  1. 遍历所有的广播电台, 找到一个覆盖了最多未覆盖的地区的电台(此电台可能包含一些已覆盖的地区,但没有关系)
  2. 将这个电台加入到一个集合中(比如ArrayList), 想办法把该电台覆盖的地区在下次比较时去掉。
  3. 重复第1步直到覆盖了全部的地区。

image-20211222164102489

代码实现

import java.util.*;

//贪心算法
public class GreedyAlgorithm {

public static void main(String[] args) {
GreedyAlgorithm ga = new GreedyAlgorithm();

//创建广播电台,放入到Map中
Map<String, Set<String>> broadcasts = new HashMap<String, Set<String>>();
//将各个电台放入broadcasts
Set<String> set1 = new HashSet<String>();
set1.add("杭州");
set1.add("北京");
set1.add("上海");
set1.add("天津");

Set<String> set2 = new HashSet<String>();
set2.add("广州");
set2.add("北京");
set2.add("上海");
set2.add("天津");

Set<String> set3 = new HashSet<String>();
set3.add("成都");
set3.add("武汉");
set3.add("杭州");

Set<String> set4 = new HashSet<String>();
set4.add("上海");
set4.add("天津");

Set<String> set5 = new HashSet<String>();
set5.add("广州");
set5.add("杭州");
set5.add("大连");

//加入到map中
broadcasts.put("K1", set1);
broadcasts.put("K2", set2);
broadcasts.put("K3", set3);
broadcasts.put("K4", set4);
broadcasts.put("K5", set5);

List<String> selects = ga.greedy(broadcasts);

System.out.println(selects);

}

/**
* @param broadcasts 输入进来的电台信息列表
* @return
*/
public List<String> greedy(Map<String, Set<String>> broadcasts) {

//遍历存储所有地区到allAreas中
Set<String> allAreas = new HashSet<String>();
for (Map.Entry<String, Set<String>> entry : broadcasts.entrySet()) {
Set<String> area = entry.getValue();
Iterator<String> itArea = area.iterator();
while (itArea.hasNext()) {
allAreas.add(itArea.next());
}
}

System.out.println(allAreas);

//创建ArrayList集合,用来存放每一轮maxKey所指电台覆盖的地区
// 当前选择的电台的集合
List<String> selects = new ArrayList<String>();

//定义一个临时的集合,在遍历过程中,用来存储遍历过程中当前电台所覆盖地区和当前allAreas集合中所剩的电台覆盖的所有地区的交集
Set<String> tempSet = new HashSet<String>();

//定义一个maxKey,用来保存一次遍历过程中,覆盖地区最多的key(占所有地区)
//如果maxKey不为null,则加入到selects中
String maxKey = null;

//定义一个maxKey临时的集合,在遍历过程中,存放遍历过程中电台覆盖的地区和当前还没有覆盖的地区的交集
Set<String> tempMaxKeySet = new HashSet<String>();

while (!allAreas.isEmpty()) {//如果allAreas不为空,则表示还没有覆盖到所有地区
maxKey = null;//每新的一轮开始,都要将maxKey置空
tempMaxKeySet.clear();//并将存储maxKey的集合清空

//遍历broadcasts,取出对应的key
for (String key : broadcasts.keySet()) {
tempSet.clear();//每一次都需要清空临时集合

//当前key所能覆盖的地区
Set<String> area = broadcasts.get(key);

//加入
tempSet.addAll(area);

//求出tempSet和allAreas的交集并赋给tempSet
tempSet.retainAll(allAreas);// retainAll 求交集

// 如果当前这个集合包含未覆盖地区的数量比maxKey指向的集合包含未覆盖地区的数量还多
// 就需要重置maxKey和它指向的集合包含未覆盖地区集合
// tempSet.size() > tempMaxKeySet.size()体现贪心算法的特点,每次都选择最优的
if (tempSet.size() > 0 && (maxKey == null || tempSet.size() > tempMaxKeySet.size())) {
//清空,防止数据间影响
tempMaxKeySet.clear();

maxKey = key;

//maxKey覆盖的地区和当前还没有覆盖的地区的交集
Set<String> area2 = broadcasts.get(maxKey);//获取当前集合中广播覆盖的城市
tempMaxKeySet.addAll(area2);//加入
tempMaxKeySet.retainAll(allAreas);//求交集
//此时tempMaxKeySet中存放的这就这一轮的maxkey所代表的广播包含的城市
}
}

//maxKey != null,就应该将maxKey加入selects中
if (maxKey != null) {
selects.add(maxKey);
//将maxKey指向的广播电台覆盖的地区从allAreas中去掉
allAreas.removeAll(broadcasts.get(maxKey));
}
}
return selects;
}

}

注意事项

  1. 贪婪算法所得到的结果不一定是最优的结果(有时候会是最优解),但是都是相对近似(接近)最优解的结果
  2. 比如上题的算法选出的是K1, K2, K3, K5,符合覆盖了全部的地区
  3. 但是我们发现 K2, K3, K4, K5 也可以覆盖全部地区,如果K2 的使用成本低于K1,那么我们上题的 K1, K2, K3, K5 虽然是满足条件,但是并不是最优的。

普里姆算法

  1. 普利姆(Prim)算法求最小生成树,也就是在包含n个顶点的连通图中,找出只有(n-1)条边包含所有n个顶点的连通子图,也就是所谓的极小连通子图
  2. 普利姆的算法如下:
    1. 设G=(V,E)是连通网,T=(U,D)是最小生成树,V,U是顶点集合,E,D是边的集合
    2. 若从顶点u开始构造最小生成树,则从集合V中取出顶点u放入集合U中,标记顶点v的visited[u]=1
    3. 若集合U中顶点ui与集合V-U中的顶点vj之间存在边,则寻找这些边中权值最小的边,但不能构成回路,将顶点vj加入集合U中,将边(ui,vj)加入集合D中,标记visited[vj]=1
    4. 重复步骤②,直到U与V相等,即所有顶点都被标记为访问过,此时D中有n-1条边
    5. 提示: 单独看步骤很难理解,我们通过代码来讲解,比较好理解.

问题

image-20211223113322234

  1. 有胜利乡有7个村庄(A, B, C, D, E, F, G) ,现在需要修路把7个村庄连通
  2. 各个村庄的距离用边线表示(权) ,比如 A – B 距离 5公里
  3. 问:如何修路保证各个村庄都能连通,并且总的修建公路总里程最短?
    • 思路: 将10条边,连接即可,但是总的里程数不是最小;
    • 正确的思路:就是尽可能的选择少的路线,并且每条路线最小,保证总里程数最少。

思路分析

最小生成树

修路问题本质就是就是最小生成树问题, 先介绍一下最小生成树(Minimum Cost Spanning Tree),简称MST。

  1. 给定一个带权的无向连通图,如何选取一棵生成树,使树上所有边上权的总和为最小,这叫最小生成树;
  2. N个顶点,一定有N-1条边;
  3. 包含全部顶点;
  4. N-1条边都在图中;
  5. 举例说明,如图:
    • image-20211223115752323
  6. 求最小生成树的算法主要是普里姆算法克鲁斯卡尔算法

图解

image-20211223113322234

  1. 从 < A > 顶点开始处理 =》 < A , G > 2
    • A-C [7]、A-G[2]、A-B[5]
  2. < A , G > 开始,将A 和 G 顶点和他们相邻的还没有访问的顶点进行处理 =》< A , G , B >
    • A-C[7]、A-B[5]、G-B[3]、G-E[4]、G-F[6]
  3. < A , G , B > 开始,将 A , G , B 顶点和他们相邻的还没有访问的顶点进行处理=》 < A , G , B , E >
    • A-C[7]、G-E[4]、G-F[6]、B-D[9]
      …..

完整步骤

image-20211223125331149

  1. {A}->G //第一 次大循环,对应边<A,G> 权值:2
  2. {A, G}->B //第2次大循环,确定{A,G}中的节点和没有走过的节点哪个时最近的,对应边<G, B>权值:3
  3. {A, G, B}->E//第3次大循环,对应,边<G, E>权值:4
  4. {A, G, B, E}->F//第4次大循环,对应边<E,F>权值:5
  5. {A, G, B, E, F}->D//第5次大循环,对应边<F,D>权值:4
  6. {A, G, B, E, F, D}->C//第6次大循环,对应 边<A,C>权值:7
  7. {A, G, B, E, F, D, C}

代码实现

import java.util.Arrays;

public class PrimAlogrithm {
public static void main(String[] args) {

char[] data = new char[]{'A', 'B', 'C', 'D', 'E', 'F', 'G'};

int verxs = data.length;

//邻接矩阵的关系使用二维数组表示,10000这个大数,表示两个点不联通
int[][] weight = new int[][]{
{10000, 5, 7, 10000, 10000, 10000, 2},
{5, 10000, 10000, 9, 10000, 10000, 3},
{7, 10000, 10000, 10000, 8, 10000, 10000},
{10000, 9, 10000, 10000, 10000, 4, 10000},
{10000, 10000, 8, 10000, 10000, 5, 4},
{10000, 10000, 10000, 4, 5, 10000, 6},
{2, 3, 10000, 10000, 4, 6, 10000},};


//创建MGraph对象
MGraph graph = new MGraph(verxs);

//创建一个MinTree对象
MinTree minTree = new MinTree();
minTree.createGraph(graph, verxs, data, weight);

//输出
minTree.show(graph);

minTree.prim(graph, 0);
}
}

//创建最小生成树=》村庄的图
class MinTree {

/**
* 创建图的邻接矩阵
*
* @param graph 图对象
* @param verxs 图对应的顶点个数
* @param data 图的各个顶点的值
* @param weight 图的邻接矩阵——边
*/
public void createGraph(MGraph graph, int verxs, char data[], int[][] weight) {
for (int i = 0; i < verxs; i++) {
graph.data[i] = data[i];
for (int j = 0; j < verxs; j++) {
graph.weight[i][j] = weight[i][j];
}
}
}

//显示图的方法
public void show(MGraph graph) {
for (int[] link : graph.weight) {
System.out.println(Arrays.toString(link));
}
}

/**
* 编写prim算法,得到最小生成树
*
* @param graph 图
* @param v 表示从图的第几个顶点开始生成“A”->0 "B"->1
*/
public void prim(MGraph graph, int v) {

//创建一个一维数组,用来标记结点(顶点),是否被访问
int visited[] = new int[graph.verxs];
//默认元素的值都是0
for (int i = 0; i < graph.verxs; i++) {
visited[i] = 0;
}

//把当前这个结点标记为已访问
visited[v] = 1;
//用h1,h2记录两个顶点的下标
int h1 = -1;
int h2 = -1;
int minWeight = 10000;//将minWeight初始成一个大数,后面在遍历过程中,会被替代

for (int k = 1; k < graph.verxs; k++) {//因为有graph.verxs顶点间就有graph.versx-1边

//这个是确定每一次生成的子图
for (int i = 0; i < graph.verxs; i++) {//i结点表示被访问过的结点
for (int j = 0; j < graph.verxs; j++) {//j表示还没有访问过的结点
if (visited[i] == 1 && visited[j] == 0 && graph.weight[i][j] < minWeight) {
//替换minWeight;(寻找已经访问过的点和未访问过的节点间权值最小的边)
minWeight = graph.weight[i][j];
h1 = i;
h2 = j;
}
}
}

//找到一条边是最小
System.out.println("边<" + graph.data[h1] + "," + graph.data[h2] + ">权值:" + minWeight);
//将当前这个结点标记为已经访问
visited[h2] = 1;//h2=j;
//将minWeight重新设置为最大值
minWeight = 10000;

}
}

}

//图对象
class MGraph {
int verxs;//表示图的节点
char[] data;//存放结点的数据
int[][] weight;//存放边,就是我们的邻接矩阵

public MGraph(int verxs) {
this.verxs = verxs;
data = new char[verxs];
weight = new int[verxs][verxs];
}
}

克鲁斯卡尔算法

  1. 克鲁斯卡尔(Kruskal)算法,是用来求加权连通图的最小生成树的算法。
  2. 基本思想:按照权值从小到大的顺序选择n-1条边,并保证这n-1条边不构成回路
  3. 具体做法:首先构造一个只含n个顶点的森林,然后依权值从小到大从连通网中选择边加入到森林中,并使森林中不产生回路,直至森林变成一棵树为止。

问题

image-20211223152654756

  1. 某城市新增7个站点(A, B, C, D, E, F, G) ,现在需要修路把7个站点连通;
  2. 各个站点的距离用边线表示(权) ,比如 A – B 距离 12公里;
  3. 问:如何修路保证各个站点都能连通,并且总的修建公路总里程最短?

思路分析

在含有n个顶点的连通图中选择n-1条边,构成一棵极小连通子图,并使该连通子图中n-1条边上权值之和达到最小,则称其为连通网的最小生成树。

image-20211223153518021

例如,对于如上图G4所示的连通网可以有多棵权值总和不相同的生成树。

image-20211223153559502

图解

image-20211223153802302

文字描述

  1. 将边<E,F>加入R中。
    • 边<E,F>的权值最小,因此将它加入到最小生成树结果R中。
  2. 将边<C,D>加入R中。
    • 上一步操作之后,边<C,D>的权值最小,因此将它加入到最小生成树结果R中。
  3. 将边<D,E>加入R中。
    • 上一步操作之后,边<D,E>的权值最小,因此将它加入到最小生成树结果R中。
  4. 将边<B,F>加入R中。
    • 上一步操作之后,边<C,E>的权值最小,但<C,E>会和已有的边构成回路;因此,跳过边<C,E>。同理,跳过边<C,F>。将边<B,F>加入到最小生成树结果R中。
  5. 将边<E,G>加入R中。
    • 上一步操作之后,边<E,G>的权值最小,因此将它加入到最小生成树结果R中。
  6. 将边<A,B>加入R中。
    • 上一步操作之后,边<F,G>的权值最小,但<F,G>会和已有的边构成回路;因此,跳过边<F,G>。同理,跳过边<B,C>。将边<A,B>加入到最小生成树结果R中。

**此时,最小生成树构造完成!它包括的边依次是:<E,F> <C,D> <D,E> <B,F> <E,G> <A,B>**。

算法分析

根据前面介绍的克鲁斯卡尔算法的基本思想和做法,我们能够了解到,克鲁斯卡尔算法重点需要解决的以下两个问题:

  • 问题一:对图的所有边按照权值大小进行排序。

    • 解决方法:很好解决,采用排序算法进行排序即可。
  • 问题二:将边添加到最小生成树中时,怎么样判断是否形成了回路。

    • 解决方法:记录顶点在”最小生成树”中的终点,顶点的终点是”在最小生成树中与它连通的最大顶点”。然后每次需要将一条边添加到最小生存树时,判断该边的两个顶点的终点是否重合,重合的话则会构成回路。
重合判断思路

image-20211223155437910

在将<E,F> <C,D> <D,E>加入到最小生成树R中之后,这几条边的顶点就都有了终点:

  1. C的终点是F。
  2. D的终点是F。
  3. E的终点是F。
  4. F的终点是F。

关于终点的说明:

  1. 就是将所有顶点按照从小到大的顺序排列好之后;某个顶点的终点就是”与它连通的最大顶点”。
  2. 因此,接下来,虽然<C,E>是权值最小的边。但是C和E的终点都是F,即它们的终点相同,因此,将<C,E>加入最小生成树的话,会形成回路。这就是判断回路的方式。也就是说,==我们加入的边的两个顶点不能都指向同一个终点,否则将构成回路。==【后面有代码说明】

代码实现

import java.util.Arrays;

public class KruskalAlgorithm {

private int edgeNum;//边的个数
private char[] verstexs;//顶点数组——顶点
private int[][] matrix;//邻接矩阵——边
//使用INF表示两个顶点不能连通
private static final int INF = Integer.MAX_VALUE;

public static void main(String[] args) {

char[] vertexs = {'A', 'B', 'C', 'D', 'E', 'F', 'G'};

//克鲁斯卡尔算法的邻接矩阵

int matrix[][] = {
/*A*//*B*//*C*//*D*//*E*//*F*//*G*/
/*A*/ {0, 12, INF, INF, INF, 16, 14},
/*B*/ {12, 0, 10, INF, INF, 7, INF},
/*C*/ {INF, 10, 0, 3, 5, 6, INF},
/*D*/ {INF, INF, 3, 0, 4, INF, INF},
/*E*/ {INF, INF, 5, 4, 0, 2, 8},
/*F*/ {16, 7, 6, INF, 2, 0, 9},
/*G*/ {14, INF, INF, INF, 8, 9, 0}};

KruskalAlgorithm kruskalAlgorithm = new KruskalAlgorithm(vertexs, matrix);
kruskalAlgorithm.show();

System.out.println("排序前:");//没有排序
for (int i = 0; i < kruskalAlgorithm.getEdges().length; i++) {
System.out.println(kruskalAlgorithm.getEdges()[i]);
}

System.out.println("排序后:");//没有排序
for (int i = 0; i < kruskalAlgorithm.getEdges().length; i++) {
System.out.println(kruskalAlgorithm.sortEdges(kruskalAlgorithm.getEdges())[i]);
}

kruskalAlgorithm.kruskal();

}

//构造器
public KruskalAlgorithm(char[] verstexs, int[][] matrix) {
//初始化定点数和边的个数
int vlen = verstexs.length;

//初始化顶点
this.verstexs = new char[vlen];//新建数组
for (int i = 0; i < verstexs.length; i++) {
this.verstexs[i] = verstexs[i];
}

//初始化边
this.matrix = new int[vlen][vlen];//新建数组
for (int i = 0; i < vlen; i++) {
for (int j = 0; j < vlen; j++) {
this.matrix[i][j] = matrix[i][j];
}
}

//统计边
for (int i = 0; i < vlen; i++) {
for (int j = i + 1; j < vlen; j++) {
if (this.matrix[i][j] != INF) {
edgeNum++;
}
}
}
}

//打印邻接矩阵
public void show() {
System.out.println("邻接矩阵为:");
for (int i = 0; i < verstexs.length; i++) {
for (int j = 0; j < verstexs.length; j++) {
System.out.printf("%-12d", matrix[i][j]);
}
System.out.println();
}
}

/**
* 功能:对边进行排序处理
*
* @param edges 边的集合
*/
private EData[] sortEdges(EData[] edges) {
for (int i = 0; i < edges.length - 1; i++) {
for (int j = 0; j < edges.length - 1 - i; j++) {
if (edges[j].weight > edges[j + 1].weight) {//交换
EData tmp = edges[j];
edges[j] = edges[j + 1];
edges[j + 1] = tmp;
}
}
}
return edges;
}

/**
* 返回队形下标
*
* @param ch 顶点的值,比如'A','B'
* @return 返回ch对应的下标,如果找不到,返回-1
*/
private int getPosition(char ch) {
for (int i = 0; i < verstexs.length; i++) {
if (verstexs[i] == ch) {
return i;
}
}
//找不到,返回-1
return -1;
}

/**
* 获取图中的边,放到EData[]数组中,后面我们需要遍历该数组
* 是通过matrix邻接矩阵来获取
* EData[] 形式[['A','B',12],['B','F',7],……]
*
* @return
*/
private EData[] getEdges() {
int index = 0;
EData[] edges = new EData[edgeNum];
for (int i = 0; i < verstexs.length; i++) {
for (int j = i + 1; j < verstexs.length; j++) {
if (matrix[i][j] != INF) {
edges[index++] = new EData(verstexs[i], verstexs[j], matrix[i][j]);
}
}
}
return edges;
}

/**
* 获取下标为i的顶点的终点,用于后面判断两个顶点的终点是否相同
*
* @param ends 数组记录了各个顶点对应的终点是哪个,ends 数组是在遍历过程中逐步判断形成的
* @param i 传入的顶点对应的下标
* @return 返回的是下表为i这个顶点对应的重点的下标
*/
private int getEnd(int[] ends, int i) {
while (ends[i] != 0) {
i = ends[i];
}
return i;
}

public void kruskal() {
int index = 0;//表示最后结果数组的索引
int[] ends = new int[edgeNum];//用于保存已有的最小生成树中每一个顶点在最小生成树中的终点
//创建结果数组,保存最后的最小生成树
EData[] rets = new EData[edgeNum];

//获取图中所有的边的集合,一共有十二条边
EData[] edges = getEdges();

System.out.println("图的边的集合如下,共" + edges.length + "条");
for (int i = 0; i < edges.length; i++) {
System.out.println(edges[i]);
}

//按照边的权值大小进行排序(从小到大)
sortEdges(edges);

//遍历edges数组,将边添加到最小生成树中是,判断准备加入的边是否生成了回路,如果没有,就加入rets,否则不能加入
for (int i = 0; i < edgeNum; i++) {
//获取到第i条边的第一个顶点(起点)
int p1 = getPosition(edges[i].start);
//获取到第i跳变的第2个顶点
int p2 = getPosition(edges[i].end);

//获取p1顶点在已有最小生成树中的终点
int m = getEnd(ends, p1);
//获取p1顶点在已有最小生成树中的终点
int n = getEnd(ends, p2);
//是否构成回路
if (m != n) {//没有构成回路
ends[m] = n;//设置m在已有最小生成树中的终点
rets[index++] = edges[i];//有一条边加入到rets数组
}
System.out.println("ends:" + Arrays.toString(ends));
//ends:[A,B, C, D, E, F, G, 0, 0, 0, 0, 0]
//ends:[0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0]
//ends:[0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
//ends:[0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
//ends:[0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
//ends:[0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
//ends:[0, 5, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
//ends:[0, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
//ends:[0, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
//ends:[0, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
//ends:[6, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
//ends:[6, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
//ends:[6, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
}


//统计并打印“最小生成树”,输出rest数组
System.out.println("最小生成树为:");
for (int i = 0; i < index; i++) {
System.out.println(rets[i]);
}

}

}

//创建一个Data,他的对象实例就表示一条边
class EData {
char start;//边的一个点
char end;//边的另外一个点
int weight;//边的权值

//构造器
public EData(char start, char end, int weight) {
this.start = start;
this.end = end;
this.weight = weight;
}

//重写toSstring,便于输出边的信息
public String toString() {
return "EDATA [<" + start + "," + end + ">=" + weight + "]";
}

}

运行结果

邻接矩阵为:
0 12 2147483647 2147483647 2147483647 16 14
12 0 10 2147483647 2147483647 7 2147483647
2147483647 10 0 3 5 6 2147483647
2147483647 2147483647 3 0 4 2147483647 2147483647
2147483647 2147483647 5 4 0 2 8
16 7 6 2147483647 2 0 9
14 2147483647 2147483647 2147483647 8 9 0

排序前:
EDATA [<A,B>=12]
EDATA [<A,F>=16]
EDATA [<A,G>=14]
EDATA [<B,C>=10]
EDATA [<B,F>=7]
EDATA [<C,D>=3]
EDATA [<C,E>=5]
EDATA [<C,F>=6]
EDATA [<D,E>=4]
EDATA [<E,F>=2]
EDATA [<E,G>=8]
EDATA [<F,G>=9]

排序后:
EDATA [<E,F>=2]
EDATA [<C,D>=3]
EDATA [<D,E>=4]
EDATA [<C,E>=5]
EDATA [<C,F>=6]
EDATA [<B,F>=7]
EDATA [<E,G>=8]
EDATA [<F,G>=9]
EDATA [<B,C>=10]
EDATA [<A,B>=12]
EDATA [<A,G>=14]
EDATA [<A,F>=16]

图的边的集合如下,共12
EDATA [<A,B>=12]
EDATA [<A,F>=16]
EDATA [<A,G>=14]
EDATA [<B,C>=10]
EDATA [<B,F>=7]
EDATA [<C,D>=3]
EDATA [<C,E>=5]
EDATA [<C,F>=6]
EDATA [<D,E>=4]
EDATA [<E,F>=2]
EDATA [<E,G>=8]
EDATA [<F,G>=9]

ends:
ends:[0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0]
ends:[0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
ends:[0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
ends:[0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
ends:[0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
ends:[0, 5, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
ends:[0, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
ends:[0, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
ends:[0, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
ends:[6, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
ends:[6, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
ends:[6, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]

最小生成树为:
EDATA [<E,F>=2]
EDATA [<C,D>=3]
EDATA [<D,E>=4]
EDATA [<B,F>=7]
EDATA [<E,G>=8]
EDATA [<A,B>=12]

迪杰斯特拉算法

迪杰斯特拉(Dijkstra)算法是典型最短路径算法,用于计算一个结点到其他结点的最短路径。 它的主要特点是以起始点为中心向外层层扩展**(广度优先搜索思想**),直到扩展到终点为止。

算法过程

设置出发顶点为v,顶点集合V{v1,v2,vi…},v到V中各顶点的距离构成距离集合Dis,Dis{d1,d2,di…},Dis集合记录着v到图中各顶点的距离(到自身可以看作0,v到v 距离对应为di )

  1. 从Dis中选择值最小的di并移出Dis集合,同时移出V集合中对应的顶点vi。此时被移出的部分,v到vi即为最短路径。
  2. 更新Dis集合,更新规则为:比较v到V集合中顶点的距离值,与v通过vi到V集合中顶点的距离值,保留值较小的一个(同时也应该更新顶点的前驱节点为vi,表明是通过vi到达的)。
  3. 重复执行两步骤,直到最短路径顶点为目标顶点即可结束。

问题

image-20211224085815011

  1. 战争时期,胜利乡有7个村庄(A, B, C, D, E, F, G) ,现在有六个邮差,从G点出发,需要分别把邮件分别送到 A, B, C , D, E, F 六个村庄
  2. 各个村庄的距离用边线表示(权) ,比如 A – B 距离是5公里
  3. 问:如何计算出G村庄到其它各个村庄的最短距离?
  4. 如果从其它点出发到各个点的最短距离又是多少?

思路分析

image-20211224105124038

代码实现

完整代码

//迪杰特斯拉算法
public class DijkstraAlgorithm {

public static void main(String[] args) {

char[] vertexs = {'A', 'B', 'C', 'D', 'E', 'F', 'G'};
int[][] martex = new int[vertexs.length][vertexs.length];
final int N = 65535;
martex[0] = new int[]{N, 5, 7, N, N, N, 2};
martex[1] = new int[]{5, N, N, 9, N, N, 3};
martex[2] = new int[]{7, N, N, N, 8, N, N};
martex[3] = new int[]{N, 9, N, N, N, 4, N};
martex[4] = new int[]{N, N, 8, N, N, 5, 4};
martex[5] = new int[]{N, N, N, 4, 5, N, 6};
martex[6] = new int[]{2, 3, N, N, 4, 6, N};

//创建一个图对象
Graph graph = new Graph(vertexs, martex);
graph.show();
//测试
graph.dijkstra(6);

graph.showDijkstra();
}

}

//已访问的顶点结合
class VisitedVertex {
//记录这个顶点是否被访问过,1表示访问过、0表示没访问过,动态更新
public int[] already_arr;
//每个下标对应的值为前一个顶点下标,会动态更新
public int[] pre_visited;
//记录出发顶点到其他顶点的距离
//以G为出发点为例,会记录G到其他顶点的距离,并动态更新,求出最端距离时就会放到dis
public int[] dis;

/**
* 构造器
*/
public VisitedVertex(int length, int index) {
this.already_arr = new int[length];
this.pre_visited = new int[length];
this.dis = new int[length];

//初始化dis数组
Arrays.fill(dis, 65535);
this.already_arr[index] = 1;//设置出发顶点为被访问
this.dis[index] = 0;//出发顶点到自己的距离为0
}

/**
* 判断index顶点是否被访问过
*
* @param index 当前结点的下标
* @return 如果访问过就返回true, 否则就返回false
*/
public boolean in(int index) {
return already_arr[index] == 1;
}

/**
* 更新出发顶点到结点index结点的距离
*
* @param index
* @param len
*/
public void updateDis(int index, int len) {
dis[index] = len;
}

/**
* 更新顶点pre的前驱结点为index结点
*
* @param pre
* @param index
*/
public void updatePre(int pre, int index) {
pre_visited[pre] = index;
}

/**
* 返回出发顶点到index顶点的距离
*
* @param index
*/
public int getDis(int index) {
return dis[index];
}

/**
* 遍历修改
*
* @return
*/
public int updateArr() {
int min = 65535, index = 0;
for (int i = 0; i < already_arr.length; i++) {
if (already_arr[i] == 0 && dis[i] < min) {//未访问、求出最短路径
min = dis[i];
index = i;
}
}
//更新index顶点 被访问过
already_arr[index] = 1;
return index;
}

/**
* 显示最后的结果,即,输出三个数组
*/
public void show() {
System.out.println("already_arr:");
for (int i : already_arr) {
System.out.print(i + " ");
}
System.out.println();
System.out.println("pre_visited:");
for (int i : pre_visited) {
System.out.print(i + " ");
}
System.out.println();
System.out.println("dis:");
for (int i : dis) {
System.out.print(i + " ");
}
System.out.println();

char[] vertexs = {'A', 'B', 'C', 'D', 'E', 'F', 'G'};
int count = 0;
for (int i : dis) {
if (i != 65535) {
System.out.print(vertexs[count] + "(" + i + ")" + " ");
} else {
System.out.println("N");
}
}
}
}

//创建图
class Graph {
private char[] vertex;//顶点数组
private int[][] matrix;//邻接矩阵——边
private VisitedVertex visitedVertex;//已经访问的顶点的集合

//构造器
public Graph(char[] vertex, int[][] matrix) {
this.vertex = vertex;
this.matrix = matrix;
}

//显示图
public void show() {
for (int[] link : matrix) {
System.out.println(Arrays.toString(link));
}
}

/**
* 迪杰斯特拉算法
*
* @param index 出发顶点对应的下标
*/
public void dijkstra(int index) {
visitedVertex = new VisitedVertex(vertex.length, index);
update(index);//更新index顶点到周围顶点的距离和前驱结点
//遍历
for (int i = 1; i < vertex.length; i++) {
index = visitedVertex.updateArr();//选择并返回新的访问顶点
update(index);//更新index顶点到周围顶点的 距离和前置结点
}
}

/**
* 更新index下标顶点到周围顶点的距离 和 周围顶点的前驱结点
*
* @param index
*/
private void update(int index) {
int len = 0;//距离
//遍历邻接矩阵的 matrix[index] 行 ,遍历当前顶点到周围顶点的距离
for (int i = 0; i < matrix[index].length; i++) {
//len :出发顶点到index顶点的距离 + 从index结点到i结点距离
len = visitedVertex.getDis(index) + matrix[index][i];
//如果i顶点没有被访问过,并且len小于出发顶点到i顶点的距离,就需要更新
if (!visitedVertex.in(i) && len < visitedVertex.getDis(i)) {
visitedVertex.updatePre(i, index);//更新i结点的前置结点为index结点
visitedVertex.updateDis(i, len);//更新出发点到i结点的距离
}
}
}

//展示
public void showDijkstra() {
visitedVertex.show();
}

}

运行结果:

[65535, 5, 7, 65535, 65535, 65535, 2]
[5, 65535, 65535, 9, 65535, 65535, 3]
[7, 65535, 65535, 65535, 8, 65535, 65535]
[65535, 9, 65535, 65535, 65535, 4, 65535]
[65535, 65535, 8, 65535, 65535, 5, 4]
[65535, 65535, 65535, 4, 5, 65535, 6]
[2, 3, 65535, 65535, 4, 6, 65535]

already_arr:
1 1 1 1 1 1 1

pre_visited:
6 6 0 5 6 6 0

dis:
2 3 9 10 4 6 0
A(2) A(3) A(9) A(10) A(4) A(6) A(0)

部分代码解释

/**
* 更新index下标顶点到周围顶点的距离 和 周围顶点的前驱结点
*
* @param index
*/
private void update(int index) {
int len = 0;//距离
//遍历邻接矩阵的 matrix[index] 行 ,遍历当前顶点到周围顶点的距离
for (int i = 0; i < matrix[index].length; i++) {
//len :出发顶点到index顶点的距离 + 从index结点到i结点距离
len = visitedVertex.getDis(index) + matrix[index][i];
//如果i顶点没有被访问过,并且len小于出发顶点到i顶点的距离,就需要更新
if (!visitedVertex.in(i) && len < visitedVertex.getDis(i)) {
visitedVertex.updatePre(i, index);//更新i结点的前置结点为index结点
visitedVertex.updateDis(i, len);//更新出发点到i结点的距离
}
}
}

修改前:

image-20211224133658663

修改后:

image-20211224133724677

佛洛伊德算法

概述

  1. 和Dijkstra算法一样,弗洛伊德(Floyd)算法也是一种用于寻找给定的加权图中顶点间最短路径的算法。该算法名称以创始人之一、1978年图灵奖获得者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德命名
  2. 弗洛伊德算法(Floyd)计算图中各个顶点之间的最短路径
  3. 迪杰斯特拉算法用于计算图中某一个顶点到其他顶点的最短路径。
  4. 弗洛伊德算法 VS 迪杰斯特拉算法:迪杰斯特拉算法通过选定的被访问顶点,求出从出发访问顶点到其他顶点的最短路径;弗洛伊德算法中每一个顶点都是出发访问点,所以需要将每一个顶点看做被访问顶点,求出从每一个顶点到其他顶点的最短路径

问题

image-20211224152540451

  1. 胜利乡有7个村庄(A, B, C, D, E, F, G)
  2. 各个村庄的距离用边线表示(权) ,比如 A – B 距离 5公里
  3. 问:如何计算出各村庄到 其它各村庄的最短距离?

算法分析

  1. 设置顶点vi到顶点vk的最短路径已知为Lik,顶点vk到vj的最短路径已知为Lkj,顶点vi到vj的路径为Lij,则vi到vj的最短路径为:min((Lik+Lkj),Lij),vk的取值为图中所有顶点,则可获得vi到vj的最短路径

  2. 至于vi到vk的最短路径Lik或者vk到vj的最短路径Lkj,是以同样的方式获得

  3. 弗洛伊德(Floyd)算法图解分析-举例说明

image-20211224154232820

image-20211224154244995

佛洛依德算法的步骤:

第一轮循环中,以A(下标为:0)作为中间顶点,距离表和前驱关系更新为:

image-20211224154259783

分析如下:

  1. 以A顶点作为中间顶点是,B->A->C的距离由N->9,同理C到B;C->A->G的距离由N->12,同理G到C
  2. 更换中间顶点,循环执行操作,直到所有顶点都作为中间顶点更新后,计算结束

image-20211224161713506

A A A F G G A
(A到A的最短路径是0) (A到B的最短路径是5) (A到C的最短路径是7) (A到D的最短路径是12) (A到E的最短路径是6) (A到F的最短路径是8) (A到G的最短路径是2)

B B A B G G B
(B到A的最短路径是5) (B到B的最短路径是0) (B到C的最短路径是12) (B到D的最短路径是9) (B到E的最短路径是7) (B到F的最短路径是9) (B到G的最短路径是3)

C A C F C E A
(C到A的最短路径是7) (C到B的最短路径是12) (C到C的最短路径是0) (C到D的最短路径是17) (C到E的最短路径是8) (C到F的最短路径是13) (C到G的最短路径是9)

G D E D F D F
(D到A的最短路径是12) (D到B的最短路径是9) (D到C的最短路径是17) (D到D的最短路径是0) (D到E的最短路径是9) (D到F的最短路径是4) (D到G的最短路径是10)

G G E F E E E
(E到A的最短路径是6) (E到B的最短路径是7) (E到C的最短路径是8) (E到D的最短路径是9) (E到E的最短路径是0) (E到F的最短路径是5) (E到G的最短路径是4)

G G E F F F F
(F到A的最短路径是8) (F到B的最短路径是9) (F到C的最短路径是13) (F到D的最短路径是4) (F到E的最短路径是5) (F到F的最短路径是0) (F到G的最短路径是6)

G G A F G G G
(G到A的最短路径是2) (G到B的最短路径是3) (G到C的最短路径是9) (G到D的最短路径是10) (G到E的最短路径是4) (G到F的最短路径是6) (G到G的最短路径是0)

代码实现

import java.util.Arrays;

public class FloydAlgorithm {

public static void main(String[] args) {
//测试看看图是否创建成功
char[] vertex = {'A', 'B', 'C', 'D', 'E', 'F', 'G'};
//创建邻接矩阵
int[][] matrix = new int[vertex.length][vertex.length];
final int N = 65535;
matrix[0] = new int[]{0, 5, 7, N, N, N, 2};
matrix[1] = new int[]{5, 0, N, 9, N, N, 3};
matrix[2] = new int[]{7, N, 0, N, 8, N, N};
matrix[3] = new int[]{N, 9, N, 0, N, 4, N};
matrix[4] = new int[]{N, N, 8, N, 0, 5, 4};
matrix[5] = new int[]{N, N, N, 4, 5, 0, 6};
matrix[6] = new int[]{2, 3, N, N, 4, 6, 0};
Graph graph = new Graph(7, matrix, vertex);

System.out.println("佛洛依德算法执行前:");
graph.show();

graph.floyd();
System.out.println("佛洛依德算法执行后:");
graph.show();
}

}

class Graph {

private char[] vertex;//存放顶点的数组
private int[][] dis;//存放从各个顶点出发到其他顶点的距离(最后的结果也保留在该数组中)
private int[][] pre;//存放各个顶点的前驱结点
final int N = 65535;

/**
* 构造器
*
* @param length 大小
* @param matrix 邻接矩阵
* @param vertex 顶点数组
*/
public Graph(int length, int[][] matrix, char[] vertex) {
this.vertex = vertex;
this.dis = matrix;
this.pre = new int[length][length];

//对pre数组进行初始化,存放前驱结点的下标
for (int i = 0; i < pre.length; i++) {
Arrays.fill(pre[i], i);
}
}

//显示
public void show() {
//pre
for (int i = 0; i < dis.length; i++) {
//先输出pre数组
for (int j = 0; j < pre.length; j++) {
System.out.print(vertex[pre[i][j]] + " ");
}
System.out.println();
//再输出dis数组
for (int j = 0; j < dis.length; j++) {
System.out.print("(" + vertex[i] + "—>" + vertex[j] + ",min:" + dis[i][j] + ") ");//i到j的最短路径是dis
}
System.out.println();
System.out.println();

}
}

//弗洛伊德算法

/**
* 弗洛伊德算法
*/
public void floyd() {
int len = 0;//变量保存距离
//i 通过 k中间节点 到 j
//对中间顶点的遍历,k就是中间顶点的下标
for (int k = 0; k < dis.length; k++) {
//从i顶点开始出发:[A,B,C,D,E,F,G]
for (int i = 0; i < dis.length; i++) {
//到达j顶点
for (int j = 0; j < dis.length; j++) {
if (k != i && k != j && dis[i][k] != N && dis[k][j] != N) {
len = dis[i][k] + dis[k][j];//=>求出从i顶点出发,经过k中间顶点,到达j顶点的距离
if (len < dis[i][j]) {
//如果len小于dis[i][j]
dis[i][j] = len;//更新距离
pre[i][j] = pre[k][j];//更新前驱顶点
}
}
}
}
}
}

}
佛洛依德算法执行前:
A A A A A A A
(A—>A,min:0) (A—>B,min:5) (A—>C,min:7) (A—>D,min:65535) (A—>E,min:65535) (A—>F,min:65535) (A—>G,min:2

B B B B B B B
(B—>A,min:5) (B—>B,min:0) (B—>C,min:65535) (B—>D,min:9) (B—>E,min:65535) (B—>F,min:65535) (B—>G,min:3

C C C C C C C
(C—>A,min:7) (C—>B,min:65535) (C—>C,min:0) (C—>D,min:65535) (C—>E,min:8) (C—>F,min:65535) (C—>G,min:65535

D D D D D D D
(D—>A,min:65535) (D—>B,min:9) (D—>C,min:65535) (D—>D,min:0) (D—>E,min:65535) (D—>F,min:4) (D—>G,min:65535

E E E E E E E
(E—>A,min:65535) (E—>B,min:65535) (E—>C,min:8) (E—>D,min:65535) (E—>E,min:0) (E—>F,min:5) (E—>G,min:4

F F F F F F F
(F—>A,min:65535) (F—>B,min:65535) (F—>C,min:65535) (F—>D,min:4) (F—>E,min:5) (F—>F,min:0) (F—>G,min:6

G G G G G G G
(G—>A,min:2) (G—>B,min:3) (G—>C,min:65535) (G—>D,min:65535) (G—>E,min:4) (G—>F,min:6) (G—>G,min:0

佛洛依德算法执行后:
A A A F G G A
(A—>A,min:0) (A—>B,min:5) (A—>C,min:7) (A—>D,min:12) (A—>E,min:6) (A—>F,min:8) (A—>G,min:2

B B A B G G B
(B—>A,min:5) (B—>B,min:0) (B—>C,min:12) (B—>D,min:9) (B—>E,min:7) (B—>F,min:9) (B—>G,min:3

C A C F C E A
(C—>A,min:7) (C—>B,min:12) (C—>C,min:0) (C—>D,min:17) (C—>E,min:8) (C—>F,min:13) (C—>G,min:9

G D E D F D F
(D—>A,min:12) (D—>B,min:9) (D—>C,min:17) (D—>D,min:0) (D—>E,min:9) (D—>F,min:4) (D—>G,min:10

G G E F E E E
(E—>A,min:6) (E—>B,min:7) (E—>C,min:8) (E—>D,min:9) (E—>E,min:0) (E—>F,min:5) (E—>G,min:4

G G E F F F F
(F—>A,min:8) (F—>B,min:9) (F—>C,min:13) (F—>D,min:4) (F—>E,min:5) (F—>F,min:0) (F—>G,min:6

G G A F G G G
(G—>A,min:2) (G—>B,min:3) (G—>C,min:9) (G—>D,min:10) (G—>E,min:4) (G—>F,min:6) (G—>G,min:0