Java TreeMap

TreeMap 是一个基于 key 有序的 key value 散列表。

  • map 根据其键的自然顺序排序,或者根据 map 创建时提供的 Comparator 排序
  • 不是线程安全的
  • key 不可以存入null
  • 底层是基于红黑树实现的

TreeMap 的类结构图:
在这里插入图片描述

  1. 实现了 NavigableMap 接口,NavigableMap 又实现了Map接口,提供了导航相关的方法。
  2. 继承了 AbstractMap,该方法实现 Map 操作的骨干逻辑。
  3. 实现了 Cloneable 接口,标记该类支持 clone 方法复制
  4. 实现了 Serializable 接口,标记该类支持序列化

构造方法

TreeMap() // 使用键的自然排序构造一个新的空树映射。
TreeMap(Comparator<? super K> comparator) // 构造一个新的空树映射,根据给定的比较器排序。
TreeMap(Map<? extends K,? extends V> m) // 构造一个新的树映射,包含与给定映射相同的映射,按照键的自然顺序排序。
TreeMap(SortedMap<K,? extends V> m) // 构造一个新的树映射,包含相同的映射,并使用与指定排序映射相同的顺序。

关键方法

SortedMap 接口:

Comparator<? super K> comparator() // 返回用于排序此映射中的键的比较器,如果此映射使用其键的自然排序,则返回 null。
Set<Map.Entry<K,V>> entrySet() // 返回此映射中包含的映射的 Set 视图。
K firstKey() // 返回当前映射中的第一个(最低)键。
K lastKey() // 返回当前映射中的最后(最高)键。

NavigableMap 接口:

Map.Entry<K,V> ceilingEntry(K key) 返回与大于或等于给定键的最小键相关联的键值映射,如果没有这样的键则返回 nullK ceilingKey(K key) 返回大于或等于给定键的最小键,如果没有这样的键,则返回 nullNavigableMap<K,V> descendingMap() 返回此映射中包含的映射的倒序视图。
Map.Entry<K,V> firstEntry() 返回与该映射中最小的键关联的键值映射,如果映射为空,则返回 nullMap.Entry<K,V> floorEntry(K key) 返回与小于或等于给定键的最大键相关联的键值映射,如果没有这样的键则返回 nullSortedMap<K,V> headMap(K toKey) 返回该映射中键严格小于 toKey 的部分的视图。
Map.Entry<K,V> higherEntry(K key) 返回与严格大于给定键的最小键关联的键值映射,如果没有这样的键,则返回 nullMap.Entry<K,V> lastEntry() 返回与此映射中最大键关联的键值映射,如果映射为空,则返回 nullMap.Entry<K,V> lowerEntry(K key) 返回与严格小于给定键的最大键关联的键值映射,如果没有这样的键,则返回 nullMap.Entry<K,V> pollFirstEntry() 删除并返回与该映射中最小的键关联的键值映射,如果映射为空,则返回 nullMap.Entry<K,V> pollLastEntry() 删除并返回与此映射中最大键关联的键值映射,如果映射为空,则返回 nullSortedMap<K,V> subMap(K fromKey, K toKey) 返回该映射中键范围从 fromKey(包含)toKey(独占)的部分的视图。
SortedMap<K,V> tailMap(K fromKey) 返回该映射中键大于或等于 fromKey 的部分的视图。

验证顺序性

@Test
    public void test1() {
        Map<Integer, String> treeMap = new TreeMap<>();
        treeMap.put(16, "a");
        treeMap.put(1, "b");
        treeMap.put(4, "c");
        treeMap.put(3, "d");
        treeMap.put(8, "e");
        // 遍历
        System.out.println("默认排序:");
        treeMap.forEach((key, value) -> {
            System.out.println("key: " + key + ", value: " + value);
        });
 
        // 构造方法传入比较器
        Map<Integer, String> tree2Map = new TreeMap<>((o1, o2) -> o2 - o1);
        tree2Map.put(16, "a");
        tree2Map.put(1, "b");
        tree2Map.put(4, "c");
        tree2Map.put(3, "d");
        tree2Map.put(8, "e");
        // 遍历
        System.out.println("倒序排序:");
        tree2Map.forEach((key, value) -> {
            System.out.println("key: " + key + ", value: " + value);
        });
    }

验证不能存储 null

@Test
    public void test2() {
        Map<Integer, String> treeMap = new TreeMap<>();
        treeMap.put(null, "a");
    }

验证 NavigableMap 相关方法

@Test
    public void test3() {
        NavigableMap<Integer, String> treeMap = new TreeMap<>();
        treeMap.put(16, "a");
        treeMap.put(1, "b");
        treeMap.put(4, "c");
        treeMap.put(3, "d");
        treeMap.put(8, "e");
 
        // 获取大于等于 5 的 key
        Integer ceilingKey = treeMap.ceilingKey(5);
        System.out.println("ceilingKey 5 is " + ceilingKey);
 
        // 获取最大的 key
        Integer lastKey = treeMap.lastKey();
        System.out.println("lastKey is " + lastKey);
    }

核心机制

实现原理
TreeMap 的底层是如何维护 key 的顺序呢?答案就是基于红黑树实现的。

红黑树是一颗 自平衡排序二叉树
二叉树 很容易理解,就是一棵树分俩叉。
在这里插入图片描述
上面这颗就是一颗最普通的二叉树。但是你会发现看起来不那么美观,因为你以 H 为根节点,发现左右两边高低不平衡,高度相差达到了 2。于是出现了平衡二叉树,使得左右两边高低差不多。

平衡二叉树
在这里插入图片描述
不管是从任何一个字母为根节点,左右两边的深度差不了 2,最多是 1。这就是平衡二叉树。把字母变成数字,还要保持这种特性怎么办呢?于是又出现了平衡二叉排序树。

平衡二叉排序树
在这里插入图片描述

不管是从长相(平衡),还是从规律(排序)感觉这棵树超级完美。但是有一个问题,那就是在增加删除节点的时候,你要时刻去让这棵树保持平衡,需要做太多的工作了,旋转的次数超级多,于是乎出现了红黑树。

红黑树
在这里插入图片描述
这就是传说中的红黑树,和平衡二叉排序树的区别就是每个节点涂上了颜色,他有下列五条性质:

每个节点都只能是红色或者黑色
根节点是黑色
每个叶节点(NIL节点,空节点)是黑色的。
如果一个结点是红的,则它两个子节点都是黑的。也就是说在一条路径上不能出现相邻的两个红色结点。
从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
这些性质有什么优点呢?就是插入效率超级高。因为在插入一个元素的时候,最多只需三次旋转,O(1)的复杂度,但是有一点需要说明他的查询效率略微逊色于平衡二叉树,因为他比平衡二叉树会稍微不平衡最多一层,也就是说红黑树的查询性能只比相同内容的avl树最多多一次比较。如何去旋转呢?如下图所示。

首先是左旋:

在这里插入图片描述

然后是右旋:
在这里插入图片描述

红黑树更详细的内容可以参考这篇文章:segmentfault.com/a/119000001…

源码解析
成员变量

//这是一个比较器,方便插入查找元素等操作
private final Comparator<? super K> comparator;
//红黑树的根节点:每个节点是一个Entry
private transient Entry<K,V> root;
//集合元素数量
private transient int size = 0;
//集合修改的记录
private transient int modCount = 0;
comparator是一个排序器,作为key的排序规则
root是红黑树的根节点,说明的确底层用的红黑树作为数据结构。
static final class Entry<K,V> implements Map.Entry<K,V> {
        K key;
        V value;
     	//左子树
        Entry<K,V> left;
     	//右子树
        Entry<K,V> right;
     	//父节点
        Entry<K,V> parent;
     	//每个节点的颜色:红黑树属性。
        boolean color = BLACK;
        Entry(K key, V value, Entry<K,V> parent) {
            this.key = key;
            this.value = value;
            this.parent = parent;
        }
        public K getKey() {
            return key;
        }
        public V getValue() {
            return value;
        }
        public V setValue(V value) {
            V oldValue = this.value;
            this.value = value;
            return oldValue;
        }
 
        public boolean equals(Object o) {
            if (!(o instanceof Map.Entry))
                return false;
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
 
            return valEquals(key,e.getKey()) && valEquals(value,e.getValue());
        }
 
        public int hashCode() {
            int keyHash = (key==null ? 0 : key.hashCode());
            int valueHash = (value==null ? 0 : value.hashCode());
            return keyHash ^ valueHash;
        }
 
        public String toString() {
            return key + "=" + value;
        }
    }

查找get方法
TreeMap基于红黑树实现,而红黑树是一种自平衡二叉查找树,所以 TreeMap 的查找操作流程和二叉查找树一致。二叉树的查找流程是这样的,先将目标值和根节点的值进行比较,如果目标值小于根节点的值,则再和根节点的左孩子进行比较。如果目标值大于根节点的值,则继续和根节点的右孩子比较。在查找过程中,如果目标值和二叉树中的某个节点值相等,则返回 true,否则返回 false。TreeMap 查找和此类似,只不过在 TreeMap 中,节点(Entry)存储的是键值对<k,v>。在查找过程中,比较的是键的大小,返回的是值,如果没找到,则返回null。TreeMap 中的查找方法是get。

public V get(Object key) {
        //调用 getEntry方法查找
        Entry<K,V> p = getEntry(key);
        return (p==null ? null : p. value);
}
 
final Entry<K,V> getEntry(Object key) {
    / 如果比较器为空,只是用key作为比较器查询
    if (comparator != null) 
        return getEntryUsingComparator(key);
    if (key == null)
        throw new NullPointerException();
    Comparable<? super K> k = (Comparable<? super K>) key;
    // 取得root节点
    Entry<K,V> p = root;
   //核心来了:从root节点开始查找,根据比较器判断是在左子树还是右子树
    while (p != null) {
        int cmp = k.compareTo(p.key );
        if (cmp < 0)
            p = p. left;
        else if (cmp > 0)
            p = p. right;
        else
           return p;
    }

插入put方法
我们来看下关键的插入方法,在插入时候是如何维护key的。

public V put(K key, V value) {
        Entry<K,V> t = root;
       // 1.如果根节点为 null,将新节点设为根节点
        if (t == null) {
            compare(key, key); // type (and possibly null) check
 
            root = new Entry<>(key, value, null);
            size = 1;
            modCount++;
            return null;
        }
      //如果root不为null,说明已存在元素 
        int cmp;
        Entry<K,V> parent;
        // split comparator and comparable paths
        Comparator<? super K> cpr = comparator;
    //如果比较器不为null 则使用比较器
        if (cpr != null) {
            //找到元素的插入位置
            do {
                parent = t;
                cmp = cpr.compare(key, t.key);
                 //当前key小于节点key 向左子树查找
                if (cmp < 0)
                    t = t.left;
                    //当前key大于节点key 向右子树查找
                else if (cmp > 0)
                    t = t.right;
                else
                    //相等的情况下 直接更新节点值
                    return t.setValue(value);
            } while (t != null);
        }
            //如果比较器为null 则使用默认比较器
        else {
            //如果key为null  则抛出异常
            if (key == null)
                throw new NullPointerException();
            @SuppressWarnings("unchecked")
                Comparable<? super K> k = (Comparable<? super K>) key;
             //找到元素的插入位置
            do {
                parent = t;
                cmp = k.compareTo(t.key);
                if (cmp < 0)
                    t = t.left;
                else if (cmp > 0)
                    t = t.right;
                else
                    return t.setValue(value);
            } while (t != null);
        }
        Entry<K,V> e = new Entry<>(key, value, parent);
      //根据比较结果决定插入到左子树还是右子树
        if (cmp < 0)
            parent.left = e;
        else
            parent.right = e;
    //保持红黑树性质,进行红黑树的旋转等操作
        fixAfterInsertion(e);
        size++;
        modCount++;
        return null;
    }

比较关键的就是fixAfterInsertion方法, 看懂这个方法需要你对红黑树的机制比较了解。

private void fixAfterInsertion(Entry<K,V> x) {
    // 将新插入节点的颜色设置为红色
    x. color = RED;
    // while循环,保证新插入节点x不是根节点或者新插入节点x的父节点不是红色(这两种情况不需要调整)
    while (x != null && x != root && x. parent.color == RED) {
        // 如果新插入节点x的父节点是祖父节点的左孩子
        if (parentOf(x) == leftOf(parentOf (parentOf(x)))) {
            // 取得新插入节点x的叔叔节点
            Entry<K,V> y = rightOf(parentOf (parentOf(x)));
            // 如果新插入x的父节点是红色
            if (colorOf(y) == RED) {
                // 将x的父节点设置为黑色
                setColor(parentOf (x), BLACK);
                // 将x的叔叔节点设置为黑色
                setColor(y, BLACK);
                // 将x的祖父节点设置为红色
                setColor(parentOf (parentOf(x)), RED);
                // 将x指向祖父节点,如果x的祖父节点的父节点是红色,按照上面的步奏继续循环
                x = parentOf(parentOf (x));
            } else {
                // 如果新插入x的叔叔节点是黑色或缺少,且x的父节点是祖父节点的右孩子
                if (x == rightOf( parentOf(x))) {
                    // 左旋父节点
                    x = parentOf(x);
                    rotateLeft(x);
                }
                // 如果新插入x的叔叔节点是黑色或缺少,且x的父节点是祖父节点的左孩子
                // 将x的父节点设置为黑色
                setColor(parentOf (x), BLACK);
                // 将x的祖父节点设置为红色
                setColor(parentOf (parentOf(x)), RED);
                // 右旋x的祖父节点
                rotateRight( parentOf(parentOf (x)));
            }
        } else { // 如果新插入节点x的父节点是祖父节点的右孩子和上面的相似
            Entry<K,V> y = leftOf(parentOf (parentOf(x)));
            if (colorOf(y) == RED) {
                setColor(parentOf (x), BLACK);
                setColor(y, BLACK);
                setColor(parentOf (parentOf(x)), RED);
                x = parentOf(parentOf (x));
            } else {
                if (x == leftOf( parentOf(x))) {
                    x = parentOf(x);
                    rotateRight(x);
                }
                setColor(parentOf (x), BLACK);
                setColor(parentOf (parentOf(x)), RED);
                rotateLeft( parentOf(parentOf (x)));
            }
        }
    }
    // 最后将根节点设置为黑色
    root.color = BLACK;
}

删除remove方法
删除remove是最复杂的方法。

public V remove(Object key) {
        // 根据key查找到对应的节点对象
        Entry<K,V> p = getEntry(key);
        if (p == null)
            return null;
 
        // 记录key对应的value,供返回使用
        V oldValue = p. value;
        // 删除节点
        deleteEntry(p);
        return oldValue;
}
private void deleteEntry(Entry<K,V> p) {
        modCount++;
        //元素个数减一
        size--;
        // 如果被删除的节点p的左孩子和右孩子都不为空,则查找其替代节
        if (p.left != null && p. right != null) {
            // 查找p的替代节点
            Entry<K,V> s = successor (p);
            p. key = s.key ;
            p. value = s.value ;
            p = s;
        }
        Entry<K,V> replacement = (p. left != null ? p.left : p. right);
        if (replacement != null) { 
            // 将p的父节点拷贝给替代节点
            replacement. parent = p.parent ;
            // 如果替代节点p的父节点为空,也就是p为跟节点,则将replacement设置为根节点
            if (p.parent == null)
                root = replacement;
            // 如果替代节点p是其父节点的左孩子,则将replacement设置为其父节点的左孩子
            else if (p == p.parent. left)
                p. parent.left   = replacement;
            // 如果替代节点p是其父节点的左孩子,则将replacement设置为其父节点的右孩子
            else
                p. parent.right = replacement;
            // 将替代节点p的left、right、parent的指针都指向空
            p. left = p.right = p.parent = null;
            // 如果替代节点p的颜色是黑色,则需要调整红黑树以保持其平衡
            if (p.color == BLACK)
                fixAfterDeletion(replacement);
        } else if (p.parent == null) { // return if we are the only node.
            // 如果要替代节点p没有父节点,代表p为根节点,直接删除即可
            root = null;
        } else {
            // 如果p的颜色是黑色,则调整红黑树
            if (p.color == BLACK)
                fixAfterDeletion(p);
            // 下面删除替代节点p
            if (p.parent != null) {
                // 解除p的父节点对p的引用
                if (p == p.parent .left)
                    p. parent.left = null;
                else if (p == p.parent. right)
                    p. parent.right = null;
                // 解除p对p父节点的引用
                p. parent = null;
            }
        }
    }

最终还是落到了对红黑树节点的删除上,需要维持红黑树的特性,做一系列的工作。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/134742.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

我在Vscode学OpenCV 色彩空间转换

文章目录 色彩【 1 】色彩空间&#xff08;色域&#xff09;&#xff08;1&#xff09;**RGB色彩空间**与xyz色彩空间的转换将 RGB 色彩空间转换为 XYZ 色彩空间将 XYZ 色彩空间转换为 RGB 色彩空间 &#xff08;2&#xff09;**CMYK色彩空间**&#xff08;3&#xff09;**HSV*…

这就是!Python的魅力!

文章目录 前言什么是pythonpython的由来我们为什么要学习python帮助python学习的网站总结 前言 各位朋友们&#xff0c;大家好。龙叔我后台经常收到私信问什么是Python&#xff1f;有必要学习这门语言么&#xff1f;今天&#xff0c;将通过本文告知大家Python是什么&#xf…

使用 Azure 机器学习实现图像分类

图像分类是计算机视觉领域中一个重要的任务。随着深度学习的发展&#xff0c;利用深度神经网络对图像进行分类已经成为一种主流方法。而Azure机器学习平台提供了丰富的工具和功能&#xff0c;使我们能够轻松地搭建和训练图像分类模型&#xff0c;并将其部署到实际应用中。本文将…

DL Homework 7

目录 一、用自己的语言解释以下概念 局部感知、权值共享 池化&#xff08;子采样、降采样、汇聚&#xff09;。会带来那些好处和坏处&#xff1f; 全卷积网络 低级特征、中级特征、高级特征 多通道。N输入&#xff0c;M输出是如何实现的&#xff1f; 11的卷积核有什么作用 二、…

抖音直播矩阵玩法,直播矩阵引流项目,每日精准引流500左右

今天我再分享一个专注于纯直播带货的玩法&#xff0c;这个案例不论是导流还是直播模式&#xff0c;都值得我们深入关注。某音直播矩阵玩法&#xff0c;每日精准引流500 这种直播方式通常会邀请两位模特&#xff0c;一个展示产品&#xff0c;一个递交产品&#xff0c;无需过多的…

傅里叶分析(1)

1 概述 傅里叶分析是信号分析中常用方法之一。傅里叶分析可将信号在时域和频域之间进行转换&#xff0c;从而分析信号在频域上的特点。 傅里叶分析&#xff08;Fourier analysis&#xff09;根据信号的时域数据特征&#xff0c;分为 4 个类别&#xff1a; 傅里叶级数&#x…

《网络协议》04. 应用层(DNS DHCP HTTP)

title: 《网络协议》04. 应用层&#xff08;DNS & DHCP & HTTP&#xff09; date: 2022-09-05 14:28:22 updated: 2023-11-12 06:55:52 categories: 学习记录&#xff1a;网络协议 excerpt: 应用层、DNS、DHCP、HTTP&#xff08;URI & URL&#xff0c;ABNF&#xf…

【PyQt】(自制类)简易的控件画布

说一下标题的意思&#xff0c;就是一个可往上面放QtWidgets控件(例如QLabel、QPushButton)并且画布可拖拽缩放的一个简易画布类。 强调一下的就是&#xff0c;这和涂鸦画布(类比于win自带的画图软件)不是同个东西。 只不过通过这个自制类我明白了一点的就是控件数量太多会造成…

一句话讲明白buck和boost电源电路

大部分教程就是垃圾 虽然buck和boost结构上很像&#xff0c;但是是两个原理完全不一样的东西 BUCK&#xff08;降压&#xff09;电源 buck就是把方波&#xff0c;用LC滤波器后&#xff0c;变成正弦波 滤波&#xff1a;就是让电压缓慢增加&#xff0c;缓慢减少。&#xff08…

《红蓝攻防对抗实战》十二.内网穿透之利用ICMP协议进行隧道穿透

内网穿透之利用ICMP协议进行隧道穿透 一.前言二.前文推荐三.利用ICMP协议进行隧道穿透1.ICMPsh获取反弹shell2.PingTunnel 搭建隧道 四.本篇总结 一.前言 本文介绍了利用ICMP协议进行隧道穿透的方法。ICMP协议不需要开放端口&#xff0c;可以将TCP/UDP数据封装到ICMP的Ping数据…

Gradio App生产环境部署教程

如果机器学习模型没有投入生产供人们使用&#xff0c;就无法充分发挥其潜力。 根据我们的经验&#xff0c;将模型投入生产的最常见方法是为其创建 API。 然而&#xff0c;我们发现这个过程对于 ML 开发人员来说可能相当令人畏惧&#xff0c;特别是如果他们不熟悉 Web 开发的话。…

任正非说:到现在我们终于可以说没有失败,但我们还不能说成功。

你好&#xff01;这是华研荟【任正非说】系列的第36篇文章&#xff0c;让我们聆听任正非先生的真知灼见&#xff0c;学习华为的管理思想和管理理念。 华研荟导语&#xff1a;今天的任正非先生讲话主要节选了他在2001-2004年的几个关于IPD、ISC的论述&#xff0c;可能大家会发现…

【C++】:内存管理:C++内存分布 || C++中动态内存管理(new || delete)

&#x1f4ed;1. C/C内存分布 【说明】 &#x1f0cf;1. 栈又叫堆栈–非静态局部变量/函数参数/返回值等等&#xff0c;栈是向下增长的 &#x1f0cf;2. 内存映射段是高效的I/O映射方式&#xff0c;用于装载一个共享的动态内存库。用户可使用系统接口创建共享共享内存&#xff…

跨域:利用CORS实现跨域访问

跨域知识点&#xff1a;跨域知识点 iframe实现跨域的四种方式&#xff1a;iframe实现跨域 JSONP和WebSocket实现跨域&#xff1a;jsonp和websocket实现跨域 目录 cors介绍 简介 两种请求 简单请求 基本流程 withCredentials 属性 非简单请求 预检请求 预检请求的回应 …

利用OGG实现PostgreSQL实时同步

&#x1f4e2;&#x1f4e2;&#x1f4e2;&#x1f4e3;&#x1f4e3;&#x1f4e3; 哈喽&#xff01;大家好&#xff0c;我是【IT邦德】&#xff0c;江湖人称jeames007&#xff0c;10余年DBA及大数据工作经验 一位上进心十足的【大数据领域博主】&#xff01;&#x1f61c;&am…

postman接口测试—Restful接口开发与测试

开发完接口&#xff0c;接下来我们需要对我们开发的接口进行测试。接口测试的方法比较多&#xff0c;使用接口工具或者Python来测试都可以&#xff0c;工具方面比如之前我们学习过的Postman或者Jmeter &#xff0c;Python脚本测试可以使用Requests unittest来测试。 测试思路…

GPT 写作与改编

GPT 写作与改编 文商科GPT 写作收益 改编技巧【改编一段话】【改编评价】【意识预设】落差&#xff0c;让顾客看到就感性和冲动害怕&#xff0c;让顾客看到就想买和拥有画面&#xff0c;切换空间&#xff0c;瞬间代入&#xff0c;勾人魂魄对比&#xff0c;设置参考物&#xff0…

RT-DETR推理详解及部署实现

目录 前言1. RT-DETR-官方2. RT-DETR-U版2.1 RT-DETR预测2.2 RT-DETR预处理2.3 RT-DETR后处理2.4 RT-DETR推理 3. RT-DETR-C3.1 ONNX导出3.2 RT-DETR预处理3.3 RT-DETR后处理3.4 RT-DETR推理 4. RT-DETR部署4.1 源码下载4.2 环境配置4.2.1 配置CMakeLists.txt4.2.2 配置Makefil…

有奖 | Python 开发者 2023 年度调查

你好&#xff0c;我是 EarlGrey&#xff0c;一名双语学习者&#xff0c;会一点编程&#xff0c;目前已翻译出版《Python 无师自通》、《Python 并行编程手册》等书籍。 点击上方蓝字关注我&#xff0c;持续接收优质好书、高效工具和赚钱机会&#xff0c;一起提升认知和思维。 1…

免费分享一套基于Springboot+Vue的在线考试系统,挺漂亮的

大家好&#xff0c;我是java1234_小锋老师&#xff0c;看到一个不错的SpringbootVue的在线考试系统&#xff0c;分享下哈。 项目视频演示 【免费】springbootvue在线考试系统 Java毕业设计_哔哩哔哩_bilibili【免费】springbootvue在线考试系统 Java毕业设计项目来自互联网&a…