資料結構與演演算法4 -- 棧與佇列
前言
棧和佇列想必大家都不陌生,至少也應該聽過“先進先出,先進後出”這些東西。本篇文章就來講述一下棧和佇列的實現及特點。
在這之前還是要先說明棧和佇列分別對應的特點:
- 棧:先進後出,後進先出。
- 佇列:先進先出,後進後出。
棧
棧是一種線性結構,因為棧中的每個資料元素之間是有先後順序關係的。
棧中儲存的資料符合先進後出,後進先出。
第一個人走進去並且走到了最裡面;接著,第二個人也走了進來。
那麼此時,如果第二個人不先出去則第一個人是無法出去的。
這也就是我們說的先進(第一個人)後出,後進(第二個人)先出。
通過前面的文章我們已經知道了,線性結構是一種邏輯結構。既然棧是一種線性結構,那麼顯然棧也是一種邏輯結構。換句話說,不管資料在實體記憶體/磁碟上是如何儲存的,只要符合了棧邏輯上的特點,那麼它就是棧。
下面就來說一下如何使用物理結構中的順序結構和鏈式結構來儲存一個棧。
順序棧
優點:實現和操作簡單,易於理解。
缺點:大小不易擴充套件。
實現程式碼:
#import <Foundation/Foundation.h>
#define MaxCount 20
// 定義棧
typedef struct _Stack{
int top; // 棧頂元素的位置
int data[MaxCount]; // 棧的預設大小
} Stack;
// 建立一個棧
Stack createStack() {
Stack s;
s.top = -1; // -1表示棧中沒有東西
for (int i = 0; i < MaxCount; i++) {
s.data[i] = 0;
}
return s;
}
// 清空棧
void clearStack(Stack *s) {
s->top = -1;
}
// 獲取棧頂元素
int getStackTopData(Stack s)
{
if (s.top < 0) {
printf("棧中沒有元素");
return 0;
}
if (s.top >= MaxCount) {
printf ("棧溢位了,請確定棧的正確性");
return 0;
}
return s.data[s.top];
}
// 入棧
void inStack(Stack *s,int data) {
if (s->top < -1 || s->top >= MaxCount - 1) {
printf("棧已滿,無法入棧");
return ;
}
s->top++;
s->data[s->top] = data;
}
// 出棧
int outStack(Stack *s) {
if (s->top < -1) {
printf("棧中沒有元素了,無法再出棧");
return 0;
}
return s->data[s->top--];
}
// 遍歷
void foreachStack(Stack s) {
for (int i = 0; i < s.top + 1; i++) {
printf("%-5d",s.data[i]);
}
printf("\n");
}
// 測試程式碼
int main(int argc,const char * argv[]) {
@autoreleasepool {
// 建立棧
Stack s = createStack();
// 入棧
for (int i = 0; i < 10; i++) {
inStack(&s,i);
}
foreachStack(s);
// 出棧
printf("棧頂元素 = %d\n",getStackTopData(s));
outStack(&s);
foreachStack(s);
printf("棧頂元素 = %d\n",getStackTopData(s));
outStack(&s);
foreachStack(s);
}
return 0;
}
複製程式碼
鏈式棧
優點:記憶體足夠,理論上可以無限擴充套件。
缺點:需要對連結串列中的節點和頭指標進行管理。
實現程式碼:
#import <Foundation/Foundation.h>
// 定義節點結構體型別
typedef struct _Node {
int data;
struct _Node *next;
} Node;
// 定義棧型別
typedef struct _Stack {
Node *top;
} Stack;
// 初始化一個鏈式棧
Stack initStack(void) {
Stack st;
st.top = NULL;
return st;
}
// 入棧
void inStack(Stack *st,int data) {
// 建立一個節點
Node *node = malloc(sizeof(Node));
node->data = data;
node->next = NULL;
// 頭插法,入棧
node->next = st->top;
st->top = node;
}
// 出棧
int outStack(Stack *st) {
if (st->top == NULL) {
printf("棧中已經沒有資料咯!\n");
return 0;
}
// 儲存第一個節點的地址
Node *p = st->top;
// 將top指向第二個節點
st->top = st->top->next;
// 出棧並釋放節點
int data = p->data;
p->data = 0;
p->next = NULL;
free(p);
return data;
}
// 獲取棧頂元素
int getTopData(Stack st) {
if (st.top == NULL) {
printf("棧中沒有資料咯!\n");
return 0;
}
return st.top->data;
}
// 遍歷棧
void foreachStack(Stack st) {
while (st.top) {
printf("%-5d",st.top->data);
// 因為沒有傳地址,所以不會改變棧原本的樣子
st.top = st.top->next;
}
printf("\n");
}
int main(int argc,const char * argv[]) {
@autoreleasepool {
// 初始化棧
Stack st = initStack();
// 入棧
for (int i = 0; i < 10; i++) {
inStack(&st,i);
}
foreachStack(st);
// 出棧
for (int i = 0; i < 6; i++) {
printf("%-5d\n",outStack(&st));
}
printf("----------------------------------\n");
foreachStack(st);
}
return 0;
}
複製程式碼
佇列
通過上面說的棧,對比著理解佇列。
佇列和棧的區別也就是具有的特點不同,同樣佇列在邏輯上也是線性結構。
只是佇列相當於一個只能容納一人通過的通道,且入口和出口不是同一個,類似於在食堂排隊買飯,第一個人買好飯了才能輪到第二個人,否則就只能等著。
同樣的,佇列也能使用順序儲存和鏈式儲存分別實現。
順序佇列
順序佇列中,我們可以定義兩個變數,一個用來儲存佇列中下一個要出隊的元素的下標,一個用來儲存佇列下一個入隊的元素的下標。
如下圖,假設佇列長度為6,Q.front表示下一個出隊的下標,Q.rear表示下一個入隊的下標。
此時可以發現,隨著佇列的入隊和出隊(c圖),那麼已經使用過的空間(下標為0和1)就無法再被使用了,同時這個佇列能儲存的最大任務也就只有6個(下標0~5)。這樣的佇列,很明顯,不合理,即不符合我們日常所需。因此就有人提出了迴圈佇列的概念,即 將佇列的首尾進行連線。
但是這樣就又造成了一個問題:
- 隊空的時候front == rear。
- 隊滿的時候front == rear。
也就無法判斷佇列是滿還是空,對於這種情況,我們選擇了浪費一個空間用來區分隊滿和隊空。於是就變成了
- front == rear 的時候認為佇列為空。
- (rear + 1) % MaxCount == front的時候認為隊滿。
下面是程式碼實現:
#import <Foundation/Foundation.h>
#define MaxCount 20
// 建立迴圈佇列
typedef struct _HYQueue {
int data[MaxCount];
int front; // 最先進入的資料對應的下標
int rear; // 下一個進入的資料對應的下標
} HYQueue;
// 初始化一個佇列
HYQueue initQueue(void) {
HYQueue hq;
hq.front = 0;
hq.rear = 0;
return hq;
}
// 清空佇列
BOOL clearQueue(HYQueue *hq) {
hq->front = 0;
hq->rear = 0;
return YES;
}
// 判斷佇列是否為空
BOOL isNull(HYQueue hq) {
return hq.front == hq.rear;
}
// 判斷佇列是否已滿
BOOL isFull(HYQueue hq) {
// 防止當rear == MaxCount - 1,front == 0時的特殊情況,此時佇列滿了,但MaxCount != 0
// rear表示下一個入隊的資料存放的位置,當前rear本身的位置對應的空間被浪費,因為要區分佇列滿和佇列空的判斷
if ((hq.rear + 1) % MaxCount == hq.front) {
return YES;
}
else {
return NO;
}
}
// 入隊
BOOL inQueue(HYQueue *hq,int data) {
// 判斷佇列是否已滿
if (isFull(*hq)) {
printf("佇列已滿,無法入隊,請等待。。。\n");
return NO;
}
// 將資料新增到佇列
hq->data[hq->rear] = data;
hq->rear = (hq->rear + 1) % MaxCount;
return YES;
}
// 出隊
BOOL outQueue(HYQueue *hq,int *outData) {
// 判斷佇列是否為空
if (isNull(*hq)) {
printf("佇列為空\n");
return NO;
}
// 出隊
*outData = hq->data[hq->front];
hq->front = (hq->front + 1) % MaxCount;
return YES;
}
// 遍歷
void foreachQueue(HYQueue hq) {
while (hq.front != hq.rear) {
printf("%-5d",hq.data[hq.front]);
hq.front = (hq.front + 1) % MaxCount;
}
printf("\n");
}
// 測試程式碼
int main(int argc,const char * argv[]) {
@autoreleasepool {
// 建立佇列
HYQueue hq = initQueue();
// 入隊,當只有入隊還沒有出隊時,此時會浪費一個空間,因為front的預設值是0,但0的位置並未出隊過
for (int i = 0; i < 15; i++) {
inQueue(&hq,i);
}
foreachQueue(hq);
// 出隊
for (int i = 0; i < 10; i++) {
int data = 0;
outQueue(&hq,&data);
printf("%-5d出隊了\n",data);
}
// 再入隊
for (int i = 0; i < 20; i++) {
inQueue(&hq,i + 15);
}
foreachQueue(hq);
}
return 0;
}
複製程式碼
鏈式佇列
鏈式佇列相對於順序佇列有以下優點:
- 長度無限,用過的空間直接釋放,不會存在像非迴圈的順序佇列那樣浪費空間的問題。
- 不會有隊滿的情況,也就不存在隊空和隊滿衝突的問題。
- 入隊和出隊簡單,入隊就是在連結串列尾部插入一個節點,出隊就是將頭節點刪除。
缺點:也就是連結串列對應的缺點,不易遍歷、需要維護節點空間的開闢和釋放。
個人認為,綜合考慮,佇列還是使用鏈式儲存來實現比較好。 實現程式碼:
#import <Foundation/Foundation.h>
// 定義節點
typedef struct _Node {
int data;
struct _Node *next;
} Node;
// 定義佇列
typedef struct {
Node *front; // 隊頭
Node *rear; // 隊尾
} HYQueue;
// 初始化佇列
HYQueue initQueue(void) {
HYQueue hq;
hq.front = NULL;
hq.rear = NULL;
return hq;
}
// 判斷隊空
BOOL isNull(HYQueue hq) {
return (hq.rear == NULL) && (hq.front == NULL);
}
// 沒有隊滿,不用判斷
// 入隊
void inQueue(HYQueue *hq,int data) {
// 建立節點
Node *node = malloc(sizeof(Node));
node->next = NULL;
node->data = data;
// 入隊
if (isNull(*hq)) {
hq->rear = node;
hq->front = node;
}
else {
hq->rear->next = node;
hq->rear = node;
}
}
// 出隊
BOOL outQueue(HYQueue *hq,int *data) {
if (isNull(*hq)) {
printf("佇列中沒有元素,無法出隊\n");
return NO;
}
Node *p = hq->front;
*data = p->data;
// 判斷是否只有一個節點
if (hq->front == hq->rear) {
// 指標置空
hq->front = hq->rear = NULL;
}
else {
// 指標指向下一個節點
hq->front = hq->front->next;
}
p->data = 0;
p->next = NULL;
free(p);
return YES;
}
// 遍歷
void foreachQueue(HYQueue hq) {
while (hq.front) {
printf("%-5d",hq.front->data);
hq.front = hq.front->next;
}
printf("\n");
}
int main(int argc,const char * argv[]) {
@autoreleasepool {
// 建立佇列
HYQueue hq = initQueue();
// 入隊
for (int i = 0; i < 20; i++) {
inQueue(&hq,&data);
printf("%d-->出隊了\n",data);
}
foreachQueue(hq);
}
return 0;
}
複製程式碼
---------------------我是一條分割線------------------------
題外問題
上面有關 棧與佇列
相關的概念和實現已經說完了,下面說一些題外話。
遞迴
遞迴是什麼?
說白了,遞迴就是一個函式在內部某些條件下又調了這個函式。
// 如下面兩個函式
// 列印a a-1 a-2 ··· 2 1
void printInt(int a) {
if (a > 0) {
printf("%-5d",a);
printInt(a - 1);
}
else {
printf("\n");
}
}
// 計算1 + 2 + 3 + ··· + a - 1 + a 的值
int sumInt(int a) {
if (a > 0) {
return a + sumInt(a - 1);
}
return 0;
}
複製程式碼
遞迴可以解決的問題
- 問題的定義是遞迴。如斐波拉契數列、階乘、階加等。
- 資料結構是遞迴的。資料結構本身具有遞迴性,如連結串列、樹等。
- 問題的解法是遞迴的。有些問題沒有明顯的遞迴結構,但採用遞迴求解比迭代求解更簡單。
分治法
分治法的設計思路是,將一個難以直接解決的大問題,分割成一些規模比較小的相同或相似的小問題,以便各個擊破,分而治之。
分治法所能解決的問題的一般有以下的特徵:
- 該問題的規模縮小到一定的程度就可以容易解決。
- 該問題可以分解為若干個規模較小的相同的問題。
- 該問題分解出的字問題的解可以合併解決該問題。
- 該問題分解出來的各個子問題是相互獨立的。
分治法的基本步驟:
- 分解:將原問題分解為若干個規模較小、互相獨立、與原問題形式相同的子問題。
- 解決:若干問題規模較小而容易被解決則直接解,否則遞迴地解各個子子問題。
- 合併:將各個子問題的解合併成原問題的解。
動態規劃
簡單的說就是在解決多階決策的過程中動態的選擇最優的過程的方法就是動態規劃。即走一步看一步,隨機應變,動態規劃,保證每一步都必須是最優的,那麼這些子問題最優的解合併在一起就是該問題的最優解。
與分治法的區別:
一般來說子問題之間不是互相獨立的。可以理解為是分治法的一種改進,不需要求解已有解的子問題(可以將已有解的子問題的解記錄起來)
適用條件:
- 最優化原理(最優子結構):一個最優策略的子策略總是最優的,即區域性最優,整體最優。
- 無後效性:每個狀態都是過去歷史的一個完整的總結。
- 子問題的重疊性:高效性 (最優子結構:當問題的最優解包含其子問題的最優解時,稱該問題具有最有子結構;重疊子問題是一個遞迴解決方案裡包含的子問題雖然很多,但不同子問題很少。少量的子問題被重複解決很多次。)
- 動態規劃的最優化原理:作為整個過程策略具有的性質,無論過去的決策如何,對於前面的決策形成的狀態而言,餘下的諸多決策必須過程最優策略。
總結
本篇文章記述了棧和佇列的概念和特點,以及如何使用順序儲存和鏈式儲存分別來實現這兩種資料結構。
另外,就是記錄總結了遞迴的概念以及遞迴能夠解決的問題有哪些?
還有就是了解了以下分治法和動態規劃法各自的特點,以及他們之間的區別和聯絡。(這一塊我自己理解的也不是太深刻,都是根據我看別人文章的講解加上我自己的理解寫下來,如果有理解不到位的地方,歡迎各位大佬來指正,以後再有更深的理解時再來更新這一塊內容)。
本文地址https://juejin.im/post/5eb3c08e6fb9a0438222871a