初阶数据结构:链表(二)
目录
一、前言
二、带头双向循环链表
1.带头双向循环链表的结构
(1)什么是带头?
(2)什么是双向呢?
(3)那什么是循环呢?
2.带头双向循环链表的实现
(1)节点结构
(2)创建链表的头节点,也即哨兵节点
(3)创建其他节点和链表打印
(4)链表尾插和尾删功能的实现
(5)链表的头插和头删
(6)链表的查找
(7)双链表在pos位置前后插入和删除pos位置功能
一、前言
在上一篇博客中,我们实现的是单链表。我们知道链表有8种结构,由单向和双向、有头和无头、循环和非循环组合而成。单链表就是无头单向非循环链表。它是一种结构简单的链表,通常不会用来单独作为存储数据用,实际中更多的是作为其它数据结构的子结构存在,如哈希桶、图的邻接等等。单链表虽然在头插、头删方面很方便,但在尾插和尾删又比不过顺序表。那么有没有一种链表,在头插头删和尾插尾删上都很方便呢?
当然有,那就是链表中的“王者”------带头双向循环链表。它的结构非常复杂,但效率极高。让我们来看看带头双向循环链表的结构吧!
二、带头双向循环链表
1.带头双向循环链表的结构
(1)什么是带头?
指链表中没有存储数据的节点时,头指针仍然指向一个节点,这个节点不存储数据,只起到站位的作用,其后才是链表的实际数据节点,因此它也被成为哨兵节点。
它的作用是什么呢?在单链表中,我们在改变头指针的链接对象时,需要使用二级指针。有了哨兵节点,我们就不需要二级指针。在执行插入、删除等操作时,也不需要对链表是否为空或是否为最后一个节点进行特殊判断,从而使代码更加简洁和统一,也让我们不至于被绕晕。
(2)什么是双向呢?
看图就可以明白。
节点结构体指针域中有两个指针。一个指向上一个节点,一个指向下一个节点。如此就可以由一个链表中的一个节点位置得到所有节点的位置。
(3)那什么是循环呢?
循环链表则是将尾节点的后继指针指向头结点,而头结点的前驱指针指向尾节点,从而形成一个闭环。这样的设计使得从链表的任何一个节点开始都可以很方便地遍历整个链表,无论是向前还是向后。也即:
循环链表的哨兵节点的头指针指向尾节点,尾节点的next指针指向哨兵节点。
不要看带头双向循环链表的结构很复杂,就认为它的实现也很难,正因为结构如此,它的实现也避开了需多难题。相比于单链表的实现,它反而简单。
2.带头双向循环链表的实现
(1)节点结构
为了实现双向,那么作为节点的结构体就需要有两个指针和存储数据的位置。代码如下:
typedef int type;
typedef struct ListNde
{struct ListNde* next;//指向下一个节点struct ListNde* head;//指向上一个节点type data;//存储数据
}ListNode;
(2)创建链表的头节点,也即哨兵节点
先看代码:
ListNode* ListCreate()
{//动态申请一个结构体空间ListNode* head = (ListNode*)malloc(sizeof(ListNode));if (head == NULL){perror("ListCreate::malloc");return NULL;}//使节点不存储数据head->data = NULL;//因为作为头节点存在,因此在没有其他节点时,需要让//前指针和后指针都指向自己head->head = head;head->next = head;//如果不返回,那就需要传二级指针来使头指针和哨兵节点链接return head;
}
为什么哨兵节点的前指针和后指针都要指向自己。看图:

(3)创建其他节点和链表打印
创建其他节点和之前单链表创建节点一样,代码如下:
ListNode* BuyListNode()
{ListNode* ptr = (ListNode*)malloc(sizeof(ListNode));if (ptr == NULL){perror("BuyListNode::malloc");return NULL;}ptr->head = NULL;ptr->next = NULL;return ptr;
}
链表打印和单链表打印大至一样,循环打印即可,但与单链表打印不同的是,它需要有一个循环终止条件,单纯的不为空可不行,带头双向循环链表可没有为空。而且因为哨兵节点没有存储数据,因此要避免打印哨兵节点。代码如下:
void ListPrint(ListNode* plist)
{//不打印哨兵节点,哨兵节点不存储任何数据ListNode* ptr = plist->next;//以这个代表哨兵节点printf("<head>");//循环打印while (ptr){printf("<%d>", ptr->data);//因为带头双向循环链表尾节点和哨兵节点是相链接的//所以需要一个条件来作为循环的终止条件if (ptr->next == plist){//ptr指向尾节点时,ptr->next指向哨兵节点,退出循环break;}ptr = ptr->next;}
}
(4)链表尾插和尾删功能的实现
先看图:


我们想要使新节点和链表链接最好带顺序的去进行指针的互换,不然容易漏掉,或者换错。我们先让尾节点的后指针指向新节点,新节点的前指针指向尾节点,新节点的后指针指向哨兵节点,哨兵节点的前指针指向新节点,这样按顺序来,既不容易漏掉,也具有逻辑美,更容易理解。代码如下
void ListPushBack(ListNode* plist)
{//断言判断plist不为空,因为有哨兵节点存在,那么plist//传过来的必定不为空assert(plist);ListNode* ptr = plist;//由ptr->head得到尾节点ListNode* tail = ptr->head;//申请一个新的节点ListNode* NewNode = BuyListNode();printf("请输入要尾插的数字\n");scanf("%d", &NewNode->data);//尾插:尾节点的next指向新节点tail->next = NewNode;//新节点的前指针指向尾节点NewNode->head = tail;//新节点的后指针指向哨兵节点NewNode->next = ptr;//哨兵节点的前指针指向新节点ptr->head = NewNode;
}
链表的尾删也很简单,只需按上述步骤逆着来然后释放要删除的空间就行了。代码如下:
void ListPopBack(ListNode* plist)
{assert(plist);ListNode* ptr = plist;ListNode* tail = ptr->head;ListNode* tail1 = tail->head;tail1->next = ptr;ptr->head = tail1;free(tail);printf("删除成功\n");
}
(5)链表的头插和头删
链表的头插和头删和链表的尾插尾删差不多,只不过这里的头插和头删是在哨兵节点的下一个节点,不是让你删或者插在哨兵节点后面。并且,在所有带头链表的删除功能里一定不能删除哨兵节点。那会出现野指针的,程序运行也会不安全。代码如下:
// 双向链表头插
void ListPushFront(ListNode* plist)
{assert(plist);ListNode* ptr = plist;ListNode* newnode = BuyListNode();ListNode* ptrnext = ptr->next;ptr->next = newnode;newnode->head = ptr;newnode->next = ptrnext;ptrnext->head = newnode;printf("请输入头插数字\n");scanf("%d", &newnode->data);printf("插入成功\n");
}
// 双向链表头删
void ListPopFront(ListNode* plist)
{assert(plist);ListNode* ptr = plist;ListNode* ptrnext2= ptr->next->next;free(ptr->next);ptr->next = ptrnext2;ptrnext2->head = ptr;printf("删除成功\n");
}
(6)链表的查找
ListNode* ListFind(ListNode* plist)
{assert(plist);int a;printf("请输入要查找的数字\n");scanf("%d", &a);ListNode* ptr = plist->next;while (ptr){//查找到直接返回if (ptr->data == a){printf("查找成功\n");return ptr;}//一定要有这个条件,防止查找不到陷入死循环if (ptr->next == plist){printf("查找失败,未找到\n");return NULL;}ptr = ptr->next;}
}
(7)双链表在pos位置前后插入和删除pos位置功能
这些和头插头删没有多大区别,且双链表在pos位置前插入比单链表简单。代码如下:
// 双向链表在pos的前面进行插入
void ListInsert(ListNode* pos)
{assert(pos);ListNode* front = pos->head;ListNode* ptr = pos;ListNode* newnode = BuyListNode();front->next = newnode;newnode->head = front;newnode->next = ptr;ptr->head = newnode;printf("请输入要在pos前插入的数\n");scanf("%d", &newnode->data);printf("插入成功\n");
}
// 双向链表删除pos位置的节点
void ListErase(ListNode* pos)
{assert(pos);ListNode* front = pos->head;ListNode* posnext = pos->next;free(pos);front->next = posnext;posnext->head = front;printf("删除成功\n");
}
// 双向链表在pos位置之后插入
void ListInsertAfter(ListNode* pos)
{assert(pos);ListNode* posnext = pos->next;ListNode* newnode = BuyListNode();ListNode* ptr = pos;ptr->next = newnode;newnode->head = ptr;newnode->next = posnext;posnext->head = newnode;printf("请输入要插入的数字\n");scanf("%d", &newnode->data);
}
这样一个带头双向循环链表也就完成了。单链表和带头双向循环链表虽然是两个极端,但当我们可以自主实现后,其他的链表结构我们也可以信手拈来。
全部代码如下:
listnode.h:
#pragma once
#pragma warning(disable : 4996)
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
typedef int type;
typedef struct ListNde
{struct ListNde* next;//指向下一个节点struct ListNde* head;//指向上一个节点type data;//存储数据
}ListNode;
// 创建链表的头节点,也即哨兵节点.
ListNode* ListCreate();
//创建其他节点
ListNode* BuyListNode();
// 双向链表销毁
void ListDestory(ListNode* plist);
// 双向链表打印
void ListPrint(ListNode* plist);
// 双向链表尾插
void ListPushBack(ListNode* plist);
// 双向链表尾删
void ListPopBack(ListNode* plist);
// 双向链表头插
void ListPushFront(ListNode* plist);
// 双向链表头删
void ListPopFront(ListNode* plist);
// 双向链表查找
ListNode* ListFind(ListNode* plist);
// 双向链表在pos的前面进行插入
void ListInsert(ListNode* pos);
// 双向链表删除pos位置的节点
void ListErase(ListNode* pos);
// 双向链表在pos位置之后插入
void ListInsertAfter(ListNode* pos);
Flistnode.c:
#include"listnode.h"
ListNode* ListCreate()
{//动态申请一个结构体空间ListNode* head = (ListNode*)malloc(sizeof(ListNode));if (head == NULL){perror("ListCreate::malloc");return NULL;}//使节点不存储数据head->data = NULL;//因为作为头节点存在,因此在没有其他节点时,需要让//前指针和后指针都指向自己head->head = head;head->next = head;//如果不返回,那就需要传二级指针来使头指针和哨兵节点链接return head;
}ListNode* BuyListNode()
{ListNode* ptr = (ListNode*)malloc(sizeof(ListNode));if (ptr == NULL){perror("BuyListNode::malloc");return NULL;}ptr->head = NULL;ptr->next = NULL;return ptr;
}
void ListDestory(ListNode* plist)
{//assert(plist);ListNode* ptr = plist->next;while (ptr){ListNode* ptrnext = ptr->next;free(ptr);if (ptrnext == plist){break;}ptr = ptrnext;}free(plist);printf("销毁成功\n");
}
void ListPrint(ListNode* plist)
{//不打印哨兵节点,哨兵节点不存储任何数据ListNode* ptr = plist->next;//以这个代表哨兵节点printf("<head>");//循环打印while (ptr){printf("<%d>", ptr->data);//因为带头双向循环链表尾节点和哨兵节点是相链接的//所以需要一个条件来作为循环的终止条件if (ptr->next == plist){//ptr指向尾节点时,ptr->next指向哨兵节点,退出循环break;}ptr = ptr->next;}
}void ListPushBack(ListNode* plist)
{//断言判断plist不为空,因为有哨兵节点存在,那么plist//传过来的必定不为空assert(plist);ListNode* ptr = plist;//由ptr->head得到尾节点ListNode* tail = ptr->head;//申请一个新的节点ListNode* NewNode = BuyListNode();printf("请输入要尾插的数字\n");scanf("%d", &NewNode->data);//尾插:尾节点的next指向新节点tail->next = NewNode;//新节点的前指针指向尾节点NewNode->head = tail;//新节点的后指针指向哨兵节点NewNode->next = ptr;//哨兵节点的前指针指向新节点ptr->head = NewNode;
}void ListPopBack(ListNode* plist)
{assert(plist);ListNode* ptr = plist;ListNode* tail = ptr->head;ListNode* tail1 = tail->head;tail1->next = ptr;ptr->head = tail1;free(tail);printf("删除成功\n");
}
// 双向链表头插
void ListPushFront(ListNode* plist)
{assert(plist);ListNode* ptr = plist;ListNode* newnode = BuyListNode();ListNode* ptrnext = ptr->next;ptr->next = newnode;newnode->head = ptr;newnode->next = ptrnext;ptrnext->head = newnode;printf("请输入头插数字\n");scanf("%d", &newnode->data);printf("插入成功\n");
}
// 双向链表头删
void ListPopFront(ListNode* plist)
{assert(plist);ListNode* ptr = plist;ListNode* ptrnext2= ptr->next->next;free(ptr->next);ptr->next = ptrnext2;ptrnext2->head = ptr;printf("删除成功\n");
}ListNode* ListFind(ListNode* plist)
{assert(plist);int a;printf("请输入要查找的数字\n");scanf("%d", &a);ListNode* ptr = plist->next;while (ptr){//查找到直接返回if (ptr->data == a){printf("查找成功\n");return ptr;}//一定要有这个条件,防止查找不到陷入死循环if (ptr->next == plist){printf("查找失败,未找到\n");return NULL;}ptr = ptr->next;}
}
// 双向链表在pos的前面进行插入
void ListInsert(ListNode* pos)
{assert(pos);ListNode* front = pos->head;ListNode* ptr = pos;ListNode* newnode = BuyListNode();front->next = newnode;newnode->head = front;newnode->next = ptr;ptr->head = newnode;printf("请输入要在pos前插入的数\n");scanf("%d", &newnode->data);printf("插入成功\n");
}
// 双向链表删除pos位置的节点
void ListErase(ListNode* pos)
{assert(pos);ListNode* front = pos->head;ListNode* posnext = pos->next;free(pos);front->next = posnext;posnext->head = front;printf("删除成功\n");
}
// 双向链表在pos位置之后插入
void ListInsertAfter(ListNode* pos)
{assert(pos);ListNode* posnext = pos->next;ListNode* newnode = BuyListNode();ListNode* ptr = pos;ptr->next = newnode;newnode->head = ptr;newnode->next = posnext;posnext->head = newnode;printf("请输入要插入的数字\n");scanf("%d", &newnode->data);
}
listnode.c:
#include"listnode.h"
ListNode* pplist = NULL;
int main()
{int a;pplist = ListCreate();ListNode* pos = NULL;do{printf("请输入数字");scanf("%d", &a);switch (a){case 1:// 双向链表打印ListPrint(pplist);break;case 2:// 双向链表尾插ListPushBack(pplist);break;case 3:// 双向链表的头插ListPushFront(pplist);break;case 4:// 双向链表的尾删ListPopBack(pplist);break;case 5:// 双向链表头删ListPopFront(pplist);break;case 6:// 双向链表查找pos = ListFind(pplist);break;case 7:// 双向链表在pos位置之后插入xListInsertAfter(pos);break;case 8:// 在pos的前面插入ListInsert(pos);break;case 9:// 删除pos位置ListErase(pos);break;case 0://链表的销毁ListDestory(pplist);printf("退出\n");break;default:printf("输入数字错误,请重新输入\n");break;}} while (a);return 0;
}
至此,链表就完了,大家可以去力扣或者牛客上找一些链表的题做一做来巩固一下。提个建议,如果在链表中,或者说在整个数据结构中,画图永远是你最好的伙伴。
相关文章:
初阶数据结构:链表(二)
目录 一、前言 二、带头双向循环链表 1.带头双向循环链表的结构 (1)什么是带头? (2)什么是双向呢? (3)那什么是循环呢? 2.带头双向循环链表的实现 (1)节点结构 (2…...
Rust:高性能与安全并行的编程语言
引言 在现代编程世界里,开发者面临的最大挑战之一就是如何平衡性能与安全性。在许多情况下,C/C这样的系统级编程语言虽然性能强大,但其内存管理的复杂性导致了各种安全漏洞。为了解决这些问题,Rust 作为一种新的系统级编程语言进入…...
使用openwrt搭建ipsec隧道
背景:最近同事遇到了个ipsec问题,做的ipsec特性,ftp下载ipv6性能只有100kb, 正面定位该问题也蛮久了,项目没有用openwrt, 不过用了开源组件strongswan, 加密算法这些也是内核自带的,想着开源的不太可能有问题ÿ…...
网络安全 | F5-Attack Signatures详解
关注:CodingTechWork 关于攻击签名 攻击签名是用于识别 Web 应用程序及其组件上攻击或攻击类型的规则或模式。安全策略将攻击签名中的模式与请求和响应的内容进行比较,以查找潜在的攻击。有些签名旨在保护特定的操作系统、Web 服务器、数据库、框架或应…...
MATLAB绘图时线段颜色、数据点形状与颜色等设置,介绍
MATLAB在绘图时,设置线段颜色和数据点的形状与颜色是提高图形可读性与美观性的重要手段。本文将详细介绍如何在 MATLAB 中设置这些属性。 文章目录 线段颜色设置单字母颜色表示法RGB 值表示法 数据点的形状与颜色设置设置数据点颜色和形状示例代码 运行结果小结 线段…...
论文速读|Matrix-SSL:Matrix Information Theory for Self-Supervised Learning.ICML24
论文地址:Matrix Information Theory for Self-Supervised Learning 代码地址:https://github.com/yifanzhang-pro/matrix-ssl bib引用: article{zhang2023matrix,title{Matrix Information Theory for Self-Supervised Learning},author{Zh…...
FPGA工程师成长四阶段
朋友,你有入行三年、五年、十年的职业规划吗?你知道你所做的岗位未来该如何成长吗? FPGA行业的发展近几年是蓬勃发展,有越来越多的人才想要或已经踏进了FPGA行业的大门。很多同学在入行FPGA之前,都会抱着满腹对职业发…...
计算机组成原理(2)王道学习笔记
数据的表示和运算 提问:1.数据如何在计算机中表示? 2.运算器如何实现数据的算术、逻辑运算? 十进制计数法 古印度人发明了阿拉伯数字:0,1,2,3,4,5,6&#…...
Spring中的事件和事件监听器是如何工作的?
目录 一、事件(Event) 二、事件发布器(Event Publisher) 三、事件监听器(Event Listener) 四、使用场景 五、总结 以下是关于Spring中的事件和事件监听器的介绍与使用说明,结合了使用场景&…...
3097. 或值至少为 K 的最短子数组 II
3097. 或值至少为 K 的最短子数组 II 题目链接:3097. 或值至少为 K 的最短子数组 II 代码如下: class Solution { public:int minimumSubarrayLength(vector<int>& nums, int k) {int res INT_MAX;for (int i 0;i < nums.size();i) {in…...
简化配置与动态表达式的 Spring EL
1 引言 在现代软件开发中,配置管理和动态逻辑处理是构建灵活、可维护应用程序的关键。Spring 框架以其强大的依赖注入和面向切面编程功能而闻名,而 Spring Expression Language (Spring EL) 则为开发者提供了一种简洁且强大的方式来简化配置并实现动态表达式。 1.1 Spring …...
erase() 【删数函数】的使用
**2025 - 01 - 25 - 第 48 篇 【函数的使用】 作者(Author) 文章目录 earse() - 删除函数一. vector中的 erase1 移除单个元素2 移除一段元素 二. map 中的erase1 通过键移除元素2 通过迭代器移除元素 earse() - 删除函数 一. vector中的 erase vector 是一个动态数组&#x…...
python实现http文件服务器访问下载
//1.py import http.server import socketserver import os import threading import sys# 获取当前脚本所在的目录 DIRECTORY os.path.dirname(os.path.abspath(__file__))# 设置服务器的端口 PORT 8000# 自定义Handler,将根目录设置为脚本所在目录 class MyHTT…...
在php中怎么打开OpenSSL
(点击即可进入聊天助手) 背景 在使用php做一些项目时,有用到用户邮箱注册等,需要开启openssl的能力 在php系统中openssl默认是关闭状态的,在一些低版本php系统中,有的甚至需要在服务器终端后台,手动安装 要打开OpenSSL扩展,需要进行以下步骤 …...
java构建工具之Gradle
自定义任务 任务定义方式,总体分为两大类:一种是通过 Project 中的task()方法,另一种是通过tasks 对象的 create 或者register 方法。 //任务名称,闭包都作为参数println "taskA..." task(A,{ }) //闭包作为最后一个参数可以直接从括号中拿出来println …...
二次封装的方法
二次封装 我们开发中经常需要封装一些第三方组件,那么父组件应该怎么传值,怎么调用封装好的组件原有的属性、插槽、方法,一个个调用虽然可行,但十分麻烦,我们一起来看更简便的方法。 二次封装组件,属性怎…...
基于Springboot用axiospost请求接收字符串参数为null的解决方案
问题 今天在用前端 post 请求后端时发现,由于是以 Json对象的形式传输的,后端用两个字符串形参无法获取到对应的参数值 前端代码如下: axios.post(http://localhost:8083/test/postParams,{a: 1, b:2} ,{Content-Type: application/jso…...
STM32 OLED屏配置
1.OLED简介 OLED(Organic Light Emitting Diode):有机发光二极管 OLED显示屏:性能优异的新型显示屏,具有功耗低、相应速度快、宽视角、轻薄柔韧等特点 0.96寸OLED模块:小巧玲珑、占用接口少、简单易用&a…...
DiffuEraser: 一种基于扩散模型的视频修复技术
视频修复算法结合了基于流的像素传播与基于Transformer的生成方法,利用光流信息和相邻帧的信息来恢复纹理和对象,同时通过视觉Transformer完成被遮挡区域的修复。然而,这些方法在处理大范围遮挡时常常会遇到模糊和时序不一致的问题࿰…...
策略模式 - 策略模式的使用
引言 在软件开发中,设计模式是解决常见问题的经典解决方案。策略模式(Strategy Pattern)是行为型设计模式之一,它允许在运行时选择算法的行为。通过将算法封装在独立的类中,策略模式使得算法可以独立于使用它的客户端…...
Leetcode40: 组合总和 II
题目描述: 给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。 candidates 中的每个数字在每个组合中只能使用 一次 。 注意:解集不能包含重复的组合。 代码思路ÿ…...
STM32完全学习——RT-thread在STM32F407上移植
一、写在前面 关于源码的下载,以及在KEIL工程里面添加操作系统的源代码,这里就不再赘述了。需要注意的是RT-thread默认里面是会使用串口的,因此需要额外的进行串口的初始化,有些人可能会问,为什么不直接使用CubMAX直接…...
React应用深度优化与调试实战指南
一、渲染性能优化进阶 1.1 精细化渲染控制 typescript 复制 // components/HeavyComponent.tsx import React, { memo, useMemo } from react;interface Item {id: string;complexData: {// 复杂嵌套结构}; }const HeavyComponent memo(({ items }: { items: Item[] }) &g…...
QT TLS initialization failed
qt使用QNetworkAccessManager下载文件(给出的链接可以在浏览器里面下载文件),下载失败, 提示“TLS initialization failed”通常是由于Qt在使用HTTPS进行文件下载时,未能正确初始化TLS(安全传输层协议&…...
全面了解 Web3 AIGC 和 AI Agent 的创新先锋 MelodAI
不管是在传统领域还是 Crypto,AI 都是公认的最有前景的赛道。随着数字内容需求的爆炸式增长和技术的快速迭代,Web3 AIGC(AI生成内容)和 AI Agent(人工智能代理)正成为两大关键赛道。 AIGC 通过 AI 技术生成…...
Golang之Context详解
引言 之前对context的了解比较浅薄,只知道它是用来传递上下文信息的对象; 对于Context本身的存储、类型认识比较少。 最近又正好在业务代码中发现一种用法:在每个协程中都会复制一份新的局部context对象,想探究下这种写法在性能…...
VSCode+Continue实现AI辅助编程
Continue是一款功能强大的AI辅助编程插件,可连接多种大模型,支持代码设计优化、错误修正、自动补全、注释编写等功能,助力开发人员提高工作效率与代码质量。以下是其安装和使用方法: 一、安装VSCode 参见: vscode安…...
Python 在Word中添加、或删除超链接
在Word文档中,超链接是一种将文本或图像连接到其他文档、网页或同一文档中不同部分的功能。通过添加超链接,用户可以轻松地导航到相关信息,从而增强文档的互动性和可读性。本文将介绍如何使用Python在Word中添加超链接、或删除Word文档中的超…...
Oracle迁移DM数据库
Oracle迁移DM数据库 本文记录使用达梦官方数据迁移工具DTS,将Oracle数据库的数据迁移至达梦数据库。 1 数据准备 2 DTS工具操作步骤 2.1 创建工程 打开DTS迁移工具,点击新建工程,填写好工程信息,如图: 2.2 新建迁…...
Spring Boot整合JavaMail实现邮件发送
一. 发送邮件原理 发件人【设置授权码】 - SMTP协议【Simple Mail TransferProtocol - 是一种提供可靠且有效的电子邮件传输的协议】 - 收件人 二. 获取授权码 开通POP3/SMTP,获取授权码 授权码是QQ邮箱推出的,用于登录第三方客户端的专用密码。适用…...
