【Java EE初阶七】多线程案例(生产者消费者模型)

news2024/11/14 20:53:50

1. 阻塞队列

        队列是先进先出的一种数据结构;

        阻塞队列,是基于队列,做了一些扩展,适用于多线程编程中;

阻塞队列特点如下:

        1、是线程安全的

        2、具有阻塞的特性

                2.1、当队列满了时,就不能往队列里放数据,就会阻塞等待,等队列中的数据出队列后,导致队列没满时,才能放数据。

                2.2、当当队列空了时,就不能从队列里拿数据,就会阻塞等待,等有数据进入队列后,导致队列不为空时,才能拿数据。

        由于阻塞队列的用处非常大,基于阻塞队列的功能,我们就可以实现多线程案例的第三种案例~ 生产消费者模型(其实描述的就是一种多线程编程的方法),引入生产者消费者模型(尤其是后端开发),生产者往队列中写入数据,消费者从队列中消费数据;

        阻塞队列总的来说就是由于前后执行顺序的线程由于一方面的速度过快,另外一方面的速度过慢,而导致整体的执行顺序出现不流畅的画面(快的线程为了使自己的产出能被另外一方面合理的消化),该方面线程不得不阻塞,等待另外一方面将产能消化之后,继续执行线程,制造产能;

2. 生产者消费者模型

        生产者消费者模型是一种很朴素的概念,描述的是一种多线程编程的方法。

 2.1 引入生产者消费者模型的意义

        2.1.1 解耦合

        引入该模型,就可以更好的做到“解耦合”(把代码的耦合程度,从高将到低-->就称为解耦合)

        在实际开发中,会涉及到 “分布式系统” ,服务器的整个功能不是由一个服务器实现的,而是由多个服务器组成,各自实现各自的一部分功能,再通过网络通信,把这些服务器联系起来,最终完成整个服务器的功能。典型分布式例子通过下图来进行简单的讲解:

        如上图所示,在该模型中入口服务器A与B、入口服务器A与C服务器的联系是密切相关的,请求要经过入口服务器A,才能传达给B、C服务器,即B、C服务器拿到想要的数据,再返回给入口服务器A,通过入口服务器A,再把响应传给客户端。

        但是如果请求突然骤升,这时超过入口服务器A接收请求的峰值,这时入口服务器A就挂了,入口服务器A挂了后,B、C服务器拿不到请求,也会挂掉,这就体现了入口服务器A和B、C服务器的耦合性比较高。

        当然如果B或C挂了的话,A大概率也会挂;

        当我们在入口服务器A和B、C服务器之间引入阻塞队列时,如下图所示:

        如上图所示,如果入口服务器A挂了,但是阻塞队列中还有请求的数据,至少不会因入口服务器挂A了,B、C服务器也挂了

        故此,入口服务器A和B、C、D服务器的耦合性也就降低了。

        上述描述的阻塞队列,并非是简单的数据结构,而是基于这个数据结构实现的服务器程序,且被部署到单独的主机上来;

2.1.2 削峰填谷

       如上图所示:当客户端这边的请求突然骤增时,入口服务器A一般来说是比较能抗压的,但是也是有极限的,这时我们引入阻塞队列,可以把这些请求数据都放进阻塞队列中,形成一个缓冲区,如此一来,即使外面的请求达到了峰值,也是由阻塞队列来承担,这样就形成了削峰填谷的效果。

        关于阻塞队列和消息队列的区别:

        阻塞队列:数据结构

        消息队列:基于阻塞队列实现服务器程序

3. 手敲代码模拟实现阻塞队列

3.1 了解阻塞队列

      java标准库提供了现成的阻塞队列这一数据结构,如下图所示:

        

        阻塞队列是基于队列扩展而来的,且在阻塞队列中,put是在具备阻塞功能的入队列操作,take方法是带阻塞功能的出队列操作,阻塞队列没有提供带有阻塞功能获取首元素的方法;

        java自带的阻塞队列的代码实现如下:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
        blockingQueue.put("smallye");
        String s1 = blockingQueue.take();
        System.out.println("第一个打印:s1 = " + s1);
        s1 = blockingQueue.take();
        System.out.println("第二个打印:s1 = " + s1);
    }
}

        结果如下:

        问题分析:

        主要是线程卡住了,当进行第二次出队列时,由于当前阻塞队列是空的,所以要等进行阻塞等待,当有元素入队列时,我们才能进行出队列操作。

3.2 实现阻塞队列

        我们尝试实现一个阻塞队列,要求达到与标准库中的队列有着类似的效果;

        步骤如下:

        1、先实现普通队列

        2、再加上线程安全

        3、再加上阻塞功能

3.2.1 先实现普通队列

        代码如下所示:

// 为了简单, 不写作泛型的形式. 考虑存储的元素就是单纯的 String
class MyBlockingQueue {
    private String elems[] = null;
    private int head = 0;//记录头结点
    private  int tail = 0;//记录尾结点
    private int size = 0;//队列元素个数
    //构造方法,定义队列的容量大小
    public MyBlockingQueue(int capacity) {
        this.elems = new String[capacity];
    }
    //入队列
    public void put(String elem) {
        //判断容量满了没,满了就不能入队列,要阻塞等待
        if(size >= this.elems.length) {
            //阻塞等待,先不写,先实现普通功能的队列
            return;
        }
        //入队列
        elems[tail] = elem;
        tail++;
        //因为是循环队列,所以要判断尾巴有没有超过容量大小下标,超过了就要从0开始了
        if(tail > elems.length) {
            tail = 0;
        }
        //队列元素要++
        size++;
    }
    
    //出队列
    public String take() {
        String elem = null;
        //要判断队列是不是空的,空就不能出队列了,要阻塞等待
        if(size == 0) {
            //阻塞等待,因为是先实现普通队列的功能,所以后面再补充
            return null;
        }
        elem = elems[head];
        head++;
        //因为是循环队列,所以要判断头结点有没有超过容量大小下标,超过了就要0开始了
        if(head >= elems.length) {
            head = 0;
        }
        //出队列后,队列元素要--
        size--;
        return elem;
    }
}

        测试代码及结果如下:

public class Main {
    public static void main(String[] args) {
        MyBlockingQueue blockingQueue = new MyBlockingQueue(10);
        blockingQueue.put("smallye");
        String s1 = blockingQueue.take();
        System.out.println("第一个打印:s1 = " + s1);
    }
}

3.2.2 再加上线程安全

        对于不线程安全的代码我们要进行加锁操作,首先针对的就是写操作,该部分的代码块肯定是要加锁的,因为多线程同时执行写操作,会导致线程不安全,如下图所示:

        下面,我们讨论一下这两个代码要不要加锁,以take为例,如下图所示:

        当前代码里面的队列为空,但是依旧执行出队列的逻辑,所以我们判断条件也应该加锁;

        以put为例,如下图所示:

        当前代码里面的队列已经满了,但是依旧执行入队列的逻辑;

        修改后代码如下:

class MyBlockingQueue {
    Object locker = new Object();
    private String elems[] = null;
    private int head = 0;//记录头结点
    private  int tail = 0;//记录尾结点
    private int size = 0;//队列元素个数
    //构造方法,定义队列的容量大小
    public MyBlockingQueue(int capacity) {
        this.elems = new String[capacity];
    }
    //入队列
    public void put(String elem) {
        synchronized (locker) {
            //判断容量满了没,满了就不能入队列,要阻塞等待
            if(size >= this.elems.length) {
                //阻塞等待,先不写,先实现普通功能的队列
                return;
            }
            //因为这些都是写操作,也有读操作,多线程并发执行时,写操作是线程不安全的,要把这些打包成一个原子,加锁
            synchronized (locker) {
                //入队列
                elems[tail] = elem;
                tail++;
                //因为是循环队列,所以要判断尾巴有没有超过容量大小下标,超过了就要从0开始了
                if(tail > elems.length) {
                    tail = 0;
                }
                //队列元素要++
                size++;
            }
        }
    }
 
    //出队列
    public String take() {
        String elem = null;
        //因为这些都是写操作,也有读操作,多线程并发执行时,写操作是线程不安全的,要把这些打包成一个原子,加锁
        synchronized (locker) {
            //要判断队列是不是空的,空就不能出队列了,要阻塞等待
            if(size == 0) {
                //阻塞等待,因为是先实现普通队列的功能,所以后面再补充
                return null;
            }
            elem = elems[head];
            head++;
            //因为是循环队列,所以要判断头结点有没有超过容量大小下标,超过了就要0开始了
            if(head >= elems.length) {
                head = 0;
            }
            //出队列后,队列元素要--
            size--;
            return elem;
        }
    }
}

3.2.3 再加上阻塞功能

        我们给put要加上阻塞功能,就要在这条件判断上加上wait,我们用locker的对象给他wait,而且wait必须要在synchronized内使用,这里的locker正好能对应上;当这个队列满时,就阻塞等待,等take方法拿走一个数据时,才被唤醒,加上阻塞功能后的代码如下:

class MyBlockingQueue {
    Object locker = new Object();
    private String elems[] = null;
    private int head = 0;//记录头结点
    private  int tail = 0;//记录尾结点
    private int size = 0;//队列元素个数
    //构造方法,定义队列的容量大小
    public MyBlockingQueue(int capacity) {
        this.elems = new String[capacity];
    }
    //入队列
    public void put(String elem) throws InterruptedException {
        synchronized (locker) {
            //判断容量满了没,满了就不能入队列,要阻塞等待
            if (size >= this.elems.length) {
                //阻塞等待,先不写,先实现普通功能的队列
                synchronized (locker) {
                    locker.wait();
                }
            }
            //因为这些都是写操作,也有读操作,多线程并发执行时,写操作是线程不安全的,要把这些打包成一个原子,加锁
            synchronized (locker) {
                //入队列
                elems[tail] = elem;
                tail++;
                //因为是循环队列,所以要判断尾巴有没有超过容量大小下标,超过了就要从0开始了
                if(tail > elems.length) {
                    tail = 0;
                }
                //队列元素要++
                size++;
                locker.notify();
            }
        }
    }
 
    //出队列
    public String take() throws InterruptedException {
        String elem = null;
        //因为这些都是写操作,也有读操作,多线程并发执行时,写操作是线程不安全的,要把这些打包成一个原子,加锁
        synchronized (locker) {
            //要判断队列是不是空的,空就不能出队列了,要阻塞等待
            if (size == 0) {
                //阻塞等待,因为是先实现普通队列的功能,所以后面再补充
                synchronized (locker) {
                    locker.wait();
                }
            }
            elem = elems[head];
            head++;
            //因为是循环队列,所以要判断头结点有没有超过容量大小下标,超过了就要0开始了
            if(head >= elems.length) {
                head = 0;
            }
            //出队列后,队列元素要--
            size--;
            locker.notify();
            return elem;
        }
    }
}

        当我们进行阻塞wait时,一定要在适当的条件下notify,如下图所示:

        代码讲解:

        当put时,队列满了时就要阻塞等待,等take队列后,就会唤醒put操作,接着put就能入队列了;

        如果队列不满也不空时,每次put和take都会notify一次,其实不会有影响,因为就算没有其他线程在等待,唤醒也没有事,不会对程序造成啥影响。而且我们的代码,一定是要么满,要么空,要么不满也不空。

        但是,如果有两个线程同时put,现在队列是满的,A线程先阻塞,B线程也阻塞,这时有第三个线程take一次,把A线程的wait唤醒了,等A执行到下面的notify,A线程里put的notify就会唤醒B线程里的wait,但是因为A线程put了,和第三个线程的take一取一放抵消了,此时队列还是满的;因为A线程里的put把B线程里的wait唤醒了,这时已经是满了的队列还往里放元素,就造成了线程安全问题。

        解决方案:把条件判断if换成while循环语句,不是只判断一次,当有其他线程把wait唤醒后,还要再判断一次这个队列是不是满的或者是空的,如果不是满的或者不是空的,才释放这个wait,不然就要继续wait,如此该问题也就解决了。

最终代码:
 

class MyBlockingQueue {
    Object locker = new Object();
    private String elems[] = null;
    private int head = 0;//记录头结点
    private  int tail = 0;//记录尾结点
    private int size = 0;//队列元素个数
    //构造方法,定义队列的容量大小
    public MyBlockingQueue(int capacity) {
        this.elems = new String[capacity];
    }
    //入队列
    public void put(String elem) throws InterruptedException {
        synchronized (locker) {
            //判断容量满了没,满了就不能入队列,要阻塞等待
            while (size >= this.elems.length) {
                //阻塞等待,先不写,先实现普通功能的队列
                synchronized (locker) {
                    locker.wait();
                }
            }
            //因为这些都是写操作,也有读操作,多线程并发执行时,写操作是线程不安全的,要把这些打包成一个原子,加锁
            synchronized (locker) {
                //入队列
                elems[tail] = elem;
                tail++;
                //因为是循环队列,所以要判断尾巴有没有超过容量大小下标,超过了就要从0开始了
                if(tail > elems.length) {
                    tail = 0;
                }
                //队列元素要++
                size++;
                locker.notify();
            }
        }
    }
 
    //出队列
    public String take() throws InterruptedException {
        String elem = null;
        //因为这些都是写操作,也有读操作,多线程并发执行时,写操作是线程不安全的,要把这些打包成一个原子,加锁
        synchronized (locker) {
            //要判断队列是不是空的,空就不能出队列了,要阻塞等待
            while (size == 0) {
                //阻塞等待,因为是先实现普通队列的功能,所以后面再补充
                synchronized (locker) {
                    locker.wait();
                }
            }
            elem = elems[head];
            head++;
            //因为是循环队列,所以要判断头结点有没有超过容量大小下标,超过了就要0开始了
            if(head >= elems.length) {
                head = 0;
            }
            //出队列后,队列元素要--
            size--;
            locker.notify();
            return elem;
        }
    }
}

        在实际开发中,生产者消费者模型,往往是多个生产者,多个消费者;这里的生产者和消费者往往不仅仅是一个线程,也可能是一个独立的服务器,甚至是一组服务器程序。生产者消费者模型,最核心的部分还是阻塞队列,可以使用synchronized和wait / notify 达到线程安全与阻塞。

3.3 实现生产者消费者模型

        代码如下:

package thread;

// 为了简单, 不写作泛型的形式. 考虑存储的元素就是单纯的 String
class MyBlockingQueue {
    private String[] elems = null;
    private int head = 0;
    private int tail = 0;
    private int size = 0;

    // 准备锁对象, 如果使用 this 也可以.
    private Object locker = new Object();

    public MyBlockingQueue(int capacity) {
        elems = new String[capacity];
    }

    public void put(String elem) throws InterruptedException {
        // 锁加到这里和加到方法上本质一样的. 加到方法上是给 this 加锁. 此处是给 locker 加锁.
        synchronized (locker) {
            while (size >= elems.length) {
                // 队列满了.
                // 后续需要让这个代码能够阻塞.
                locker.wait();
            }
            // 新的元素要放到 tail 指向的位置上
            elems[tail] = elem;
            tail++;
            if (tail >= elems.length) {
                tail = 0;
            }
            size++;

            // 入队列成功之后唤醒
            locker.notify();
        }
    }

    public String take() throws InterruptedException {
        String elem = null;
        synchronized (locker) {
            while (size == 0) {
                // 队列空了.
                // 后续也需要让这个代码阻塞
                locker.wait();
            }
            // 取出 head 位置的元素并返回
            elem = elems[head];
            head++;
            if (head >= elems.length) {
                head = 0;
            }
            // 这个代码不要遗漏.
            size--;

            // 元素出队列成功之后, 加上唤醒
            locker.notify();
        }
        return elem;
    }
}

public class ThreadDemo28 {
    public static void main(String[] args) throws InterruptedException {
        MyBlockingQueue queue = new MyBlockingQueue(1000);

        // 生产者
        Thread t1 = new Thread(() -> {
            int n = 1;
            while (true) {
                try {
                    queue.put(n + "");
                    System.out.println("生产元素 " + n);
                    n++;
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        // 消费者
        Thread t2 = new Thread(() -> {
            while (true) {
                try {
                    String n = queue.take();
                    System.out.println("消费元素 " + n);

                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        t1.start();
        t2.start();
    }
}

        结果如下:

        如图所示,生产者消费者模型大抵是生产一个,消费一个,主要是生产之后消费者再消费;

ps:关于阻塞队列和生产着消费者模型的内容就到这里了,如果对你有帮助的话就请一键三连哦!!!

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Yapi安装配置(CentOs)

环境要求 nodejs&#xff08;7.6) mongodb&#xff08;2.6&#xff09; git 准备工作 清除yum命令缓存 sudo yum clean all卸载低版本nodejs yum remove nodejs npm -y安装nodejs,获取资源,安装高版本nodejs curl -sL https://rpm.nodesource.com/setup_8.x | bash - #安装 s…

图片中src属性绑定不同的路径

vue3 需求是按钮disable的时候&#xff0c;显示灰色的icon&#xff1b;非disable状态&#xff0c;显示白色的icon 一开始src写成三元表达式&#xff0c;发现不行&#xff0c;网上说src不能写成三元表达式&#xff0c;vue会识别成字符串 最后的解决方案 同时&#xff0c;发现…

win下持续观察nvidia-smi

简介&#xff1a;在Windows操作系统中&#xff0c;没有与Linux中watch命令直接对应的内置工具&#xff0c;但有1种方法快速简单的方法可以实现类似的效果&#xff0c;尤其是用于监控类似于nvidia-smi的命令输出。 历史攻略&#xff1a; Python&#xff1a;查看windows下GPU的…

美国地质调查局历史地形图

简介 美国地质调查局地形图的历史可以追溯到 19 世纪末&#xff0c;当时美国地质调查局开始着手绘制整个美国的详细地图。1:24,000 比例尺&#xff0c;也称为 7.5 分四边形地图&#xff0c;成为最广泛使用的比例尺之一。每张地图覆盖 7.5 分经纬度的区域&#xff0c;从而详细呈…

在Docker中安装Tomact

目录 前言&#xff1a; 一.安装Tomact 查找指定的tomact版本 下载tomact9.0 查看该镜像是否安装成功 安装成功之后就开始运行镜像了 ps&#xff08;用于列出正在运行的Docker容器&#xff09; ​编辑 测试(虚拟机ip:8080) ​编辑 解决措施 ​编辑 完成以上步骤&…

【Nginx】反向代理和负载均衡

反向代理 nginx 反向代理&#xff0c;就是将前端发送的动态请求由 nginx 转发到后端服务器。 server {listen 80;server_name localhost;# 反向代理,处理管理端发送的请求location /api/ {proxy_pass http://localhost:8080/admin/;#proxy_pass http://webservers/…

【Electron】快速建立Vue3+Vite+Electron Ts项目

git https://github.com/electron-vite/electron-vite-vue 创建项目 npm create electron-vite or pnpm create electron-vite 初始化 pnpm install or pnpm i 启动项目 pnpm dev 打包项目 pnpm build 项目创建成功后默认情况下 窗口是H800 W600 在createWindow 函数…

计算机视觉中的神经网络可视化工具与项目

前言 本文介绍了一些关于神经网络可视化的项目&#xff0c;主要有CNN解释器&#xff0c;特征图、卷积核、类可视化的一些代码和项目&#xff0c;结构可视化工具&#xff0c;网络结构手动画图工具。 CNN解释器 这是一个中国博士发布的名叫CNN解释器的在线交互可视化工具。 主要…

macOS跨进程通信: FIFO(有名管道) 创建实例

一&#xff1a; 简介 在类linux系统中管道分为有名管道和匿名管道。两者都能单方向的跨进程通信。 匿名管道&#xff08;pipe&#xff09;: 必须是父子进程之间&#xff0c;而且子进程只能由父进程fork() 出来的&#xff0c;才能继承父进程的管道句柄&#xff0c;一般mac 开发…

基于多反应堆的高并发服务器【C/C++/Reactor】(中)子线程 WorkerThread的实现 和 线程池ThreadPool的初始化

一、子线程 WorkerThread的实现 &#xff08;1&#xff09;工作线程 线程ID&#xff1a;每个线程都有一个唯一的ID,用于标识线程的名字&#xff1a;非必需&#xff0c;主要用于识别线程互斥锁&#xff1a;线程同步条件变量&#xff1a;线程阻塞EventLoop&#xff1a;在每个子…

WEB:探索开源PDF.js技术应用

1、简述 PDF.js 是一个由 Mozilla 开发的开源 JavaScript 库&#xff0c;用于在浏览器中渲染 PDF 文档。它的目标是提供一个纯粹的前端解决方案&#xff0c;摆脱了依赖插件或外部程序的束缚&#xff0c;使得在任何支持 JavaScript 的浏览器中都可以轻松地显示 PDF 文档。 2、…

解析大语言模型LLM的幻觉问题:消除错觉、提高认知

文章目录 前言一、幻觉介绍二、幻觉产生的原因三、幻觉的现象四、幻觉的分类五、幻觉解决方案六、幻觉待解决问题后记 前言 在人类的感知和认知过程中&#xff0c;幻觉一直是一个被广泛讨论和研究的问题。幻觉指的是一种虚假的感知或认知经验&#xff0c;使我们看到、听到或感…

YOLOv5算法进阶改进(12)— 引入YOLOv8中的C2f模块 | 提升小目标检测精度

前言:Hello大家好,我是小哥谈。YOLOv8是由Ultralytics开发的一个前沿的SOTA模型。它在以前成功的YOLO版本基础上,引入了新的功能和改进,进一步提升了其性能和灵活性。C2f模块和C3模块分别是YOLOv8和YOLOv5中的两种不同的模块设计,它们在结构和功能上有一些区别。本文将YOL…

很实用的ChatGPT网站—在线编程模块增补篇

很实用的ChatGPT网站&#xff08;http://chat-zh.com/&#xff09;——增补篇 今天介绍一个好兄弟开发的ChatGPT网站&#xff0c;网址[http://chat-zh.com/]。这个网站功能模块很多&#xff0c;包含生活、学习、医疗、法律、经济等很多方面。今天跟大家分享一下&#xff0c;新…

Java IO流介绍以及缓冲为何能提升性能

概念&#xff1a; 流是一种抽象概念&#xff0c;它代表了数据的无结构化传递。按照流的方式进行输入输出&#xff0c;数据被当成无结构的字节序或字符序列。从流中取得数据的操作称为提取操作&#xff0c;而向流中添加数据的操作称为插入操作。 Java IO 也称为IO流&#xff0c;…

QT上位机开发(数据库sqlite编程)

【 声明&#xff1a;版权所有&#xff0c;欢迎转载&#xff0c;请勿用于商业用途。 联系信箱&#xff1a;feixiaoxing 163.com】 编写软件的时候&#xff0c;如果用户的数据比较少&#xff0c;那么用json保存是非常方便的。但是一旦数据量大了之后&#xff0c;建议还是用数据库…

计算机网络(8):因特网上的音频/视频服务

概述 计算机网络最初是为传送数据设计的。因特网 IP 层提供的 “尽最大努力交付” 服务以及每一个分组独立交付的策略&#xff0c;对传送数据信息十分合适。因特网使用的 TCP 协议可以很好地解决P层不能提供可靠交付这一问题。 音频/视频常称为多媒体信息 多媒体信息&#xff…

进程与计划任务

什么是程序&#xff1f; 程序&#xff1a;在硬盘上躺着&#xff0c;执行特定任务的一串代码 静态 进程&#xff1a;加载到内存中运行 动态 进程是程序的副本&#xff0c;进程是有生命周期&#xff0c;是硬件资源分配的最小单位 pid号可以通过pstree -p 查到 uid &…

Cytoscape3.8安装下载及安装教程

Cytoscape3.8下载链接&#xff1a;https://docs.qq.com/doc/DUmhZQ1lqTWhuSXJC 1.选中下载好的安装包右键选择“解压到 Cytoscape3.8.0”文件夹 2.打开解压好的”Cytoscape3.8.0“文件夹 3.选中“Cytoscape_3_8_0_windows_64bit.exe“右键以管理员身份运行 4.点击”Download“&…

平衡二叉树,力扣

目录 前序遍历与后续遍历 题目地址&#xff1a; 题目&#xff1a; 我们直接看题解吧&#xff1a; 审题目事例提示&#xff1a; 解题方法&#xff1a; 难度分析&#xff1a; 解题方法分析&#xff1a; 解题分析&#xff1a; 解题思路&#xff1a; 代码实现&#xff1a; 补充说明…