資料結構與算法
![]()
基礎夯實:基礎資料結構與算法(一) 資料結構(英語:data structure)是計算機中存儲、組織資料的方式。
資料結構是一種具有一定邏輯關系,在計算機中應用某種存儲結構,并且封裝了相應操作的資料元素集合。它包含三方面的内容,邏輯關系、存儲關系及操作。
不同種類的資料結構适合于不同種類的應用,而部分甚至專門用于特定的作業任務。例如,計算機網絡依賴于路由表運作,B 樹高度适用于資料庫的封裝。
為什麼要學習資料結構和算法?
随着應用程式變得越來越複雜和資料越來越豐富,幾百萬、幾十億甚至幾百億的資料就會出現,
而對這麼大對資料進行搜尋、插入或者排序等的操作就越來越慢,資料結構就是用來解決這些問題的。
常見的10種資料結構
1、數組
數組:數組是一種聚合資料類型,它是将具有相同類型的若幹變量有序地組織在一起的集合。
定義結構體數組的方法很簡單,同定義結構體變量是一樣的,隻不過将變量改成數組。
或者說同普通數組的定義是一模一樣的,如:
struct STUDENT stu[10];
這就定義了一個結構體數組,共有 10 個元素,每個元素都是一個結構體變量,都包含所有的結構體成員。
結構體數組的引用與引用一個結構體變量在原理上是一樣的。隻不過結構體數組中有多個結構體變量,我們隻需利用 for 循 環一個一個地使用結構體數組中的元素。
下面編寫一個程式,程式設計要求:從鍵盤輸入 5 個學生的基本資訊,如姓名、年齡、性别、學号,然後将學号最大的學生的基本資訊輸出到螢幕(如果相同則輸出最後一個)。
#define _CRT_SECURE_NO_WARNINGS //避免scanf報錯
# include <stdio.h>
# include <string.h>
/*
程式設計要求:
從鍵盤輸入 5 個學生的基本資訊,如姓名、年齡、性别、學号,然後将學号最大的學生的基本資訊輸出到螢幕(如果相同則輸出最後一個)。
*/
struct STU
{
char name[20];
int age;
char sex;
char num[20];
};
void OutputSTU(struct STU stu[5]); //函數聲明, 該函數的功能是輸出學号最大的學生資訊
int main(void)
{
int i;
struct STU stu[5];
for (i = 0; i<5; ++i)
{
printf("請輸入第%d個學生的資訊:", i + 1);
scanf("%s%d %c%s", stu[i].name, &stu[i].age, &stu[i].sex, stu[i].num);/*%c前面要加空格, 不然輸入時會将空格賦給%c*/
}
OutputSTU(stu);
system("PAUSE");//結束不退出
}
void OutputSTU(struct STU stu[5])
{
struct STU stumax = stu[0];
int j;
for (j = 1; j<5; ++j)
{
if (strcmp(stumax.num, stu[j].num) < 0) //strcmp函數的使用
{
stumax = stu[j];
}
}
printf("學生姓名:%s 學生年齡:%d 學生性别:%c 學生學号:%s\n", stumax.name, stumax.age, stumax.sex, stumax.num);
}
2、連結清單
連結清單:連結清單是一種資料元素按照鍊式存儲結構進行存儲的資料結構,這種存儲結構具有在實體上存在非連續的特點。
連結清單,别名鍊式存儲結構或單連結清單,用于存儲邏輯關系為 "一對一" 的資料。連結清單不限制資料的實體存儲狀态,換句話說,使用連結清單存儲的資料元素,其實體存儲位置是随機的。
例如,使用連結清單存儲
{1,2,3}
,資料的實體存儲狀态如圖 1 所示:
我們看到,圖 1 根本無法展現出各資料之間的邏輯關系。
對此,連結清單的解決方案是,每個資料元素在存儲時都配備一個指針,用于指向自己的直接後繼元素。如圖 2 所示:
像圖 2 這樣,資料元素随機存儲,并通過指針表示資料之間邏輯關系的存儲結構就是鍊式存儲結構。
連結清單有 單連結清單、雙向連結清單、靜态連結清單,展開說的話比較多,這裡就示範單連結清單,想要學習更多連結清單直接點選連結進入學習。
連結清單的節點
從圖 2 可以看到,連結清單中每個資料的存儲都由以下兩部分組成:
- 資料元素本身,其所在的區域稱為資料域;
- 指向直接後繼元素的指針,所在的區域稱為指針域;
即連結清單中存儲各資料元素的結構如圖 3 所示:
圖 3 所示的結構在連結清單中稱為節點。也就是說,連結清單實際存儲的是一個一個的節點,真正的資料元素包含在這些節點中,如圖 4 所示:
是以,連結清單中每個節點的具體實作,需要使用 C 語言中的結構體,具體實作代碼為:
typedef struct Link{
char elem; //代表資料域
struct Link * next; //代表指針域,指向直接後繼元素
}link; //link為節點名,每個節點都是一個 link 結構體
提示,由于指針域中的指針要指向的也是一個節點,是以要聲明為 Link 類型(這裡要寫成
struct Link*
的形式)。
頭節點,頭指針和首元節點
其實,圖 4 所示的連結清單結構并不完整。一個完整的連結清單需要由以下幾部分構成:
- 頭指針:一個普通的指針,它的特點是永遠指向連結清單第一個節點的位置。很明顯,頭指針用于指明連結清單的位置,便于後期找到連結清單并使用表中的資料;
- 節點:連結清單中的節點又細分為頭節點、首元節點和其他節點:
- 頭節點:其實就是一個不存任何資料的空節點,通常作為連結清單的第一個節點。對于連結清單來說,頭節點不是必須的,它的作用隻是為了友善解決某些實際問題;
- 首元節點:由于頭節點(也就是空節點)的緣故,連結清單中稱第一個存有資料的節點為首元節點。首元節點隻是對連結清單中第一個存有資料節點的一個稱謂,沒有實際意義;
- 其他節點:連結清單中其他的節點;
是以,一個存儲
{1,2,3}
的完整連結清單結構如圖 5 所示:
注意:連結清單中有頭節點時,頭指針指向頭節點;反之,若連結清單中沒有頭節點,則頭指針指向首元節點。
明白了連結清單的基本結構,下面我們來學習如何建立一個連結清單。
連結清單的建立(初始化)
建立一個連結清單需要做如下工作:
- 聲明一個頭指針(如果有必要,可以聲明一個頭節點);
- 建立多個存儲資料的節點,在建立的過程中,要随時與其前驅節點建立邏輯關系;
例如,建立一個存儲
{1,2,3,4}
且無頭節點的連結清單,C 語言實作代碼如下:
link * initLink(){
link * p=NULL;//建立頭指針
link * temp = (link*)malloc(sizeof(link));//建立首元節點
//首元節點先初始化
temp->elem = 1;
temp->next = NULL;
p = temp;//頭指針指向首元節點
//從第二個節點開始建立
for (int i=2; i<5; i++) {
//建立一個新節點并初始化
link *a=(link*)malloc(sizeof(link));
a->elem=i;
a->next=NULL;
//将temp節點與建立立的a節點建立邏輯關系
temp->next=a;
//指針temp每次都指向新連結清單的最後一個節點,其實就是 a節點,這裡寫temp=a也對
temp=temp->next;
}
//傳回建立的節點,隻傳回頭指針 p即可,通過頭指針即可找到整個連結清單
return p;
}
3、堆
堆:堆是一種特殊的樹形資料結構,一般讨論的堆都是二叉堆。
二叉堆是完全二進制樹或者是近似完全二進制樹,它分為兩種:最大堆和最小堆。
最大堆:父結點的鍵值總是大于或等于任何一個子節點的鍵值;
最小堆:父結點的鍵值總是小于或等于任何一個子節點的鍵值。示意圖如下:
假設在最大堆[90,80,70,60,40,30,20,10,50]種添加85,需要執行的步驟如下:
如上圖所示,當向最大堆中添加資料時:先将資料加入到最大堆的最後,然後盡可能把這個元素往上挪,直到挪不動為止!
将85添加到[90,80,70,60,40,30,20,10,50]中後,最大堆變成了[90,85,70,60,80,30,20,10,50,40]。
/*
* 最大堆的向上調整算法(從start開始向上直到0,調整堆)
*
* 注:數組實作的堆中,第N個節點的左孩子的索引值是(2N+1),右孩子的索引是(2N+2)。
*
* 參數說明:
* start -- 被上調節點的起始位置(一般為數組中最後一個元素的索引)
*/
static void maxheap_filterup(int start)
{
int c = start; // 目前節點(current)的位置
int p = (c-1)/2; // 父(parent)結點的位置
int tmp = m_heap[c]; // 目前節點(current)的大小
while(c > 0)
{
if(m_heap[p] >= tmp)
break;
else
{
m_heap[c] = m_heap[p];
c = p;
p = (p-1)/2;
}
}
m_heap[c] = tmp;
}
/*
* 将data插入到二叉堆中
*
* 傳回值:
* 0,表示成功
* -1,表示失敗
*/
int maxheap_insert(int data)
{
// 如果"堆"已滿,則傳回
if(m_size == m_capacity)
return -1;
m_heap[m_size] = data; // 将"數組"插在表尾
maxheap_filterup(m_size); // 向上調整堆
m_size++; // 堆的實際容量+1
return 0;
}
更多詳情請點選:二叉堆(一)之 圖文解析 和 C語言的實作 :https://www.cnblogs.com/skywang12345/p/3610187.html
4、棧
棧:棧是一種特殊的線性表,它隻能在一個表的一個固定端進行資料結點的插入和删除操作。
棧是限制插入和删除隻能在一個位置上進行的線性表。其中,允許插入和删除的一端位于表的末端,叫做棧頂(top),不允許插入和删除的另一端叫做棧底(bottom)。
對棧的基本操作有 PUSH(壓棧)和 POP (出棧),前者相當于表的插入操作(向棧頂插入一個元素),後者則是删除操作(删除一個棧頂元素)。
棧是一種後進先出(LIFO)的資料結構,最先被删除的是最近壓棧的元素。
棧就像是一個箱子,往裡面放入一個小盒子就相當于壓棧操作,往裡面取出一個小盒子就是出棧操作,取盒子的時候,最後放進去的盒子會最先被取出來,最先放進去的盒子會最後被取出來,這即是後入先出。
下面是一個棧的示意圖:
如下入棧出棧示例:
#define _CRT_SECURE_NO_WARNINGS //避免scanf報錯
#include <stdio.h>
//元素elem進棧
int push(int* a, int top, int elem){
a[++top] = elem;
return top;
}
//資料元素出棧
int pop(int * a, int top){
if (top == -1) {
printf("空棧");
return -1;
}
printf("彈棧元素:%d\n", a[top]);
top--;
return top;
}
int main() {
int a[100];
int top = -1;
top = push(a, top, 1);
top = push(a, top, 2);
top = push(a, top, 3);
top = push(a, top, 4);
top = pop(a, top);
top = pop(a, top);
top = pop(a, top);
top = pop(a, top);
top = pop(a, top);
system("PAUSE");//結束不退出
}
5、隊列
隊列:隊列和棧類似,也是一種特殊的線性表。和棧不同的是,隊列隻允許在表的一端進行插入操作,而在另一端進行删除操作。
與棧結構不同的是,隊列的兩端都"開口",要求資料隻能從一端進,從另一端出,如圖 1 所示:
通常,稱進資料的一端為 "隊尾",出資料的一端為 "隊頭",資料元素進隊列的過程稱為 "入隊",出隊列的過程稱為 "出隊"。
不僅如此,隊列中資料的進出要遵循 "先進先出" 的原則,即最先進隊列的資料元素,同樣要最先出隊列。
拿圖 1 中的隊列來說,從資料在隊列中的存儲狀态可以分析出,元素 1 最先進隊,其次是元素 2,最後是元素 3。
此時如果将元素 3 出隊,根據隊列 "先進先出" 的特點,元素 1 要先出隊列,元素 2 再出隊列,最後才輪到元素 3 出隊列。
棧和隊列不要混淆,棧結構是一端封口,特點是"先進後出";而隊列的兩端全是開口,特點是"先進先出"。
是以,資料從表的一端進,從另一端出,且遵循 "先進先出" 原則的線性存儲結構就是隊列。
如下示例:
#define _CRT_SECURE_NO_WARNINGS //避免scanf報錯
#include <stdio.h>
int enQueue(int *a, int rear, int data){
a[rear] = data;
rear++;
return rear;
}
void deQueue(int *a, int front, int rear){
//如果 front==rear,表示隊列為空
while (front != rear) {
printf("出隊元素:%d\n", a[front]);
front++;
}
}
int main() {
int a[100];
int front, rear;
//設定隊頭指針和隊尾指針,當隊列中沒有元素時,隊頭和隊尾指向同一塊位址
front = rear = 0;
//入隊
rear = enQueue(a, rear, 1);
rear = enQueue(a, rear, 2);
rear = enQueue(a, rear, 3);
rear = enQueue(a, rear, 4);
//出隊
deQueue(a, front, rear);
system("PAUSE");//結束不退出
}
6、散清單(哈希表)
散清單:散清單源自于散列函數(Hash function),其思想是如果在結構中存在關鍵字和T相等的記錄,那麼必定在F(T)的存儲位置可以找到該記錄, 這樣就可以不用進行比較操作而直接取得所查記錄。
構造散列函數考慮的因素:
- 執行速度
- 關鍵字長度
- 散清單大小
- 關鍵字的分布情況
- 查找頻率
根據元素集合的特性構造
- 要求一:n 個資料原僅占用 n 個位址,雖然散列查找是以空間換時間,但仍希望散列的位址空間盡量小
- 要求二:無論用什麼方法儲存,目的都是盡量均勻地存放元素,以避免沖突
解決沖突的辦法:
- 直接定址法
- (取關鍵字的某個線性函數值為散列位址:f (key) = a*key + b )
- 簡單、均勻也不會有沖突但需要事先知道關鍵字的排布,适合表較小且連續的情況
- 數字分析法
- 平方取中法
- 折疊法
- 除留取餘法
- Hash(key) = key % p (p 是整數),設表長為 m ,取 p <= m 且為質數
- 随機數法
結構:
#define MAXSIZE 1000
#define NULLKEY -65535
typedef struct
{
int* elem; //資料元素存儲基址,數組
int size; //元素個數
}HashTable;
int m = 0;//散清單表長
散清單的建立:
void IniHashTable(HashTable* H)
{
int i;
m = MAXSIZE;
H->size = m;
H->elem = (int*)malloc(m* sizeof(int));
for (i = 0; i < m; i++)
{
H->elem[i] = NULLKEY;
}
}
散列函數:
根據不同的情況改變算法
int Hash(int key)
{
return key % m; //除留取餘法
}
插入元素:
void InsertHash(HashTable* H, int key)
{
int addr = Hash(key); //求散列位址
while (H->elem[addr] != NULLKEY) //不為空則沖突
{
addr = (addr + 1) % m; //開放位址法的線性探測
}
H->elem[addr] = key; //發現有空位後插入
}
查找元素:
int Search(HashTable* H, int key)
{
int addr = Hash(key); //求散列位址
while (H->elem[addr] != key) //不為空則沖突
{
addr = (addr + 1) % m; //開放位址法的線性探測
if (H->elem[addr] == NULLKEY || addr == Hash(key))
{
return false;
}
}
return true;
}
7、二叉樹
二叉樹:二叉樹是指樹中節點的度不大于2的有序樹,它是一種最簡單且最重要的樹。 二叉樹的遞歸定義為:二叉樹是一棵空樹,或者是一棵由一個根節點和兩棵互不相交的,分别稱作根的左子樹和右子樹組成的非空樹;左子樹和右子樹又同樣都是二叉樹 。
二叉排序樹要麼是空二叉樹,要麼具有如下特點:
- 二叉排序樹中,如果其根結點有左子樹,那麼左子樹上所有結點的值都小于根結點的值;
- 二叉排序樹中,如果其根結點有右子樹,那麼右子樹上所有結點的值都大小根結點的值;
- 二叉排序樹的左右子樹也要求都是二叉排序樹;
例如,圖 1 就是一個二叉排序樹:
使用二叉排序樹查找關鍵字
二叉排序樹中查找某關鍵字時,查找過程類似于次優二叉樹,在二叉排序樹不為空樹的前提下,首先将被查找值同樹的根結點進行比較,會有 3 種不同的結果:
- 如果相等,查找成功;
- 如果比較結果為根結點的關鍵字值較大,則說明該關鍵字可能存在其左子樹中;
- 如果比較結果為根結點的關鍵字值較小,則說明該關鍵字可能存在其右子樹中;
實作函數為:(運用遞歸的方法)
BiTree SearchBST(BiTree T,KeyType key){
//如果遞歸過程中 T 為空,則查找結果,傳回NULL;或者查找成功,傳回指向該關鍵字的指針
if (!T || key==T->data) {
return T;
}else if(key<T->data){
//遞歸周遊其左孩子
return SearchBST(T->lchild, key);
}else{
//遞歸周遊其右孩子
return SearchBST(T->rchild, key);
}
}
使用二叉排序樹在查找表中做查找操作的時間複雜度同建立的二叉樹本身的結構有關。即使查找表中各資料元素完全相同,但是不同的排列順序,建構出的二叉排序樹大不相同。
例如:查找表
{45,24,53,12,37,93}
和表
{12,24,37,45,53,93}
各自建構的二叉排序樹圖下圖所示:
使用二叉排序樹實作動态查找操作的過程,實際上就是從二叉排序樹的根結點到查找元素結點的過程,是以時間複雜度同被查找元素所在的樹的深度(層次數)有關。
為了彌補二叉排序樹構造時産生如圖 5 右側所示的影響算法效率的因素,需要對二叉排序樹做“平衡化”處理,使其成為一棵平衡二叉樹。
更多詳情點選 二叉排序樹(二叉查找樹)及C語言實作
8、跳表
跳表:跳表是一個随機化的資料結構,可以被看做二叉樹的一個變種,它在性能上和紅黑樹,AVL樹不相上下,但是跳表的原理非常簡單,目前在Redis和LeveIDB中都有用到。
跳表使用空間換時間的設計思路,通過建構多級索引來提高查詢的效率,實作了基于連結清單的“二分查找”。
跳表是一種動态資料結構,支援快速的插入、删除、查找操作,時間複雜度都是 O(logn)。跳表的空間複雜度是 O(n)。
不過,跳表的實作非常靈活,可以通過改變索引建構政策,有效平衡執行效率和記憶體消耗。
詳細見:跳表C語言實作詳解
9、圖
圖:圖是另一種非線性資料結構。在圖結構中,資料結點一般稱為頂點,而邊是頂點的有序偶對。
使用圖結構表示的資料元素之間雖然具有“多對多”的關系,但是同樣可以采用順序存儲,也就是使用數組有效地存儲圖。
使用數組存儲圖時,需要使用兩個數組,一個數組存放圖中頂點本身的資料(一維數組),另外一個數組用于存儲各頂點之間的關系(二維數組)。
存儲圖中各頂點本身資料,使用一維數組就足夠了;存儲頂點之間的關系時,要記錄每個頂點和其它所有頂點之間的關系,是以需要使用二維數組。
不同類型的圖,存儲的方式略有不同,根據圖有無權,可以将圖劃分為兩大類:圖和網 。
圖,包括無向圖和有向圖;
網,是指帶權的圖,包括無向網和有向網。
存儲方式的不同,指的是:在使用二維數組存儲圖中頂點之間的關系時,如果頂點之間存在邊或弧,在相應位置用 1 表示,反之用 0 表示;
如果使用二維數組存儲網中頂點之間的關系,頂點之間如果有邊或者弧的存在,在數組的相應位置存儲其權值;反之用 0 表示。
結構代碼表示:
#define MAX_VERtEX_NUM 20 //頂點的最大個數
#define VRType int //表示頂點之間的關系的變量類型
#define InfoType char //存儲弧或者邊額外資訊的指針變量類型
#define VertexType int //圖中頂點的資料類型
typedef enum{DG,DN,UDG,UDN}GraphKind; //枚舉圖的 4 種類型
typedef struct {
VRType adj; //對于無權圖,用 1 或 0 表示是否相鄰;對于帶權圖,直接為權值。
InfoType * info; //弧或邊額外含有的資訊指針
}ArcCell,AdjMatrix[MAX_VERtEX_NUM][MAX_VERtEX_NUM];
typedef struct {
VertexType vexs[MAX_VERtEX_NUM]; //存儲圖中頂點資料
AdjMatrix arcs; //二維數組,記錄頂點之間的關系
int vexnum,arcnum; //記錄圖的頂點數和弧(邊)數
GraphKind kind; //記錄圖的種類
}MGraph;
例如,存儲圖 1 中的無向圖(B)時,除了存儲圖中各頂點本身具有的資料外,還需要使用二維數組存儲任意兩個頂點之間的關系。
由于 (B) 為無向圖,各頂點沒有權值,是以如果兩頂點之間有關聯,相應位置記為 1 ;反之記為 0 。建構的二維數組如圖 2 所示。
在此二維數組中,每一行代表一個頂點,依次從 V1 到 V5 ,每一列也是如此。比如 arcs[0][1] = 1 ,表示 V1 和 V2 之間有邊存在;而 arcs[0][2] = 0,說明 V1 和 V3 之間沒有邊。
對于無向圖來說,二維數組建構的二階矩陣,實際上是對稱矩陣,在存儲時就可以采用壓縮存儲的方式存儲下三角或者上三角。
通過二階矩陣,可以直覺地判斷出各個頂點的度,為該行(或該列)非 0 值的和。例如,第一行有兩個 1,說明 V1 有兩個邊,是以度為 2。
存儲圖 1 中的有向圖(A)時,對應的二維數組如圖 3 所示:
更多詳情點選 圖的順序存儲結構(包含C語言實作)
10、Trie樹
Trie樹:又稱單詞查找樹,Trie樹,字典樹,是一種樹形結構,是一種哈希樹的變種。典型應用是用于統計,排序和儲存大量的字元串(但不僅限于字元串), 是以經常被搜尋引擎系統用于文本詞頻統計。它的優點是:利用字元串的公共字首來減少查詢時間,最大限度地減少無謂的字元串比較,查詢效率比哈希樹高。
如下代碼,定義結構體、初始化Trie樹、插入、查找、删除:
#define _CRT_SECURE_NO_WARNINGS //避免scanf報錯
#include <stdio.h>
#define MAX 26 //26個字母
#define SLEN 100 //節點中存儲的字元串長度
//Trie結構體定義
struct Trie
{
struct Trie *next[MAX];
char s[SLEN]; //節點處存儲的字元串
int isword; //節點處是否為單詞
char val; //節點的代表字元
} *root;
//初始化Trie樹
struct Trie *init()
{
struct Trie *root = (struct Trie *)malloc(sizeof(struct Trie));
int i;
for (i = 0; i < MAX; i++)
{
root->next[i] = NULL;
}
root->isword = 0;
root->val = 0;
return root;
}
//按照指定路徑path 插入字元串 s
void insert(char path[], char s[])
{
struct Trie *t, *p = root;
int i, j, n = strlen(path);
for (i = 0; i < n; i++)
{
if (p->next[path[i] - 'a'] == NULL)
{
t = (struct Trie *)malloc(sizeof(struct Trie));
for (j = 0; j < MAX; j++)
{
t->next[j] = NULL;
t->isword = 0;
}
t->val = path[i];
p->next[path[i] - 'a'] = t;
}
p = p->next[path[i] - 'a'];
}
p->isword = 1;
strcpy(p->s, s);
}
//按照指定路徑 path 查找
char *find(char path[], int delflag)
{
struct Trie *p = root;
int i = 0, n = strlen(path);
while (p && path[i])
{
p = p->next[path[i++] - 'a'];
}
if (p && p->isword)
{
p->isword = delflag;
return p->s;
}
return NULL;
}
//删除整棵Trie樹
void del(struct Trie *root)
{
int i;
if (!root)
return;
for (i = 0; i < MAX; i++)
{
if (root->next[i])
del(root->next[i]);
free(root->next[i]);
}
}
下集預告
基礎夯實:基礎資料結構與算法(二)
參考文獻
單連結清單:http://c.biancheng.net/view/3336.html
雙向連結清單:http://c.biancheng.net/view/3342.html
靜态連結清單:http://c.biancheng.net/view/3339.html
二叉堆(一)之 圖文解析 和 C語言的實作 :https://www.cnblogs.com/skywang12345/p/3610187.html
二叉排序樹(二叉查找樹)及C語言實作:http://c.biancheng.net/view/3431.html
跳表C語言實作詳解:https://blog.csdn.net/m0_37845735/article/details/103691814
圖的順序存儲結構(包含C語言實作):http://c.biancheng.net/view/3407.html
歡迎關注訂閱微信公衆号【熊澤有話說】,更多好玩易學知識等你來取
作者:熊澤-學習中的苦與樂
公衆号:熊澤有話說
QQ群:711838388
出處:https://www.cnblogs.com/xiongze520/p/15812527.html
您可以随意轉載、摘錄,但請在文章内注明作者和原文連結。