數據結構與算法專題之線性表——棧及其應用

  本文內容是數據結構第二彈——棧及其應用。首先會介紹棧的基本結構和基本操作以及代碼實現,文後會講解幾個棧的典型應用。棧是一個比較簡單但是用途及其廣泛的重要的數據結構,所以對於棧的學習重在理解應用而非實現。在今後的學習中可能會遇到各種依賴棧實現的算法或數據結構,一般那種情況下不需要我們自己實現棧,費時費力,一般直接使用C++ STL內置的stack泛型容器,方便快捷。這裏講解棧主要是針對入門的小夥伴~(ps:下面的棧的實現也均使用泛型)

一、棧的定義及實現

  首先,闡述一下棧的定義。

  定義:棧是限定僅在表頭進行插入和刪除操作的線性表。要搞清楚這個概念,首先要明白”棧“原來的意思,如此才能把握本質。"棧“者,存儲貨物或供旅客住宿的地方,可引申爲倉庫、中轉站,所以引入到計算機領域裏,就是指數據暫時存儲的地方,所以纔有進棧、出棧的說法。

  棧也是一種線性表,只不過棧比較特殊,它的運算和操作受限,不同於鏈表,棧的元素插入和刪除僅限於在其一端進行操作。

  也就是說,棧這個容器,只能操作當前的首元素,其他元素不可見也不可訪問更不可更改。

  我們假想棧是一個頂端開口底部封閉的“容器”,我們只能從棧頂“加入物品”,也只能從棧頂“取出物品”,如下圖,爲了方便展示與理解,我們把線性表“豎着畫”:


  可見,棧有先進後出或後進先出的特點。

  棧的基本運算有以下幾種:

(1) 入棧:Push

(2) 出棧:Pop

(3) 取棧頂:Top

(4) 獲取大小:Size

(5) 棧是否空:Empty

  下面依次介紹這幾種運算的原理及實現。

  首先需要說的是,棧是一種線性表,所以也會有順序棧和鏈式棧,所謂順序棧,與順序表類似,就是使用數組來實現棧的相關功能,不過這種方法侷限性大,且耗費內存,所以在此不推薦使用,以下所有實現均採用鏈式結構。

  與鏈表類似,我們首先定義出棧元素結點的結構,同樣含有一個值域和一個指針域,如下圖所示,粉色框框圈起來的是棧內元素:


  結點代碼如下:

template<class T>
struct Node
{
	T data;
	Node<T> *next;
};

  接下來我們開始抽象出棧的類結構,我們把棧想象成一條鏈表,只不過這條鏈表只能從頭部插入元素,只能從頭部獲取元素,也只能從頭部刪除元素,這個頭部,就是我們棧的棧頂。是不是感覺這其實就是一個操作受限制的鏈表?而且是逆序建立鏈表?而且沒有需要特殊處理的尾指針?對,就是這樣,瞬間就變得簡單了。

  我們對棧的類定義如下:

template<class T>
class Stack
{
private:
	Node<T> *head;
	int cnt;
public:
	Stack()
	{
		head = new Node<T>;
		head->next = NULL;
		cnt = 0;
	}
	void push(T elem); // 將elem元素入棧
	void pop(); // 刪除棧頂元素
	T top(); // 獲取棧頂元素值
	int size(); // 獲取棧內元素數量
	bool empty(); // 判斷是否爲空棧
};
  可以看出,相比較鏈表,我們的棧操作還是很少的,所以實現起來也很簡單,但就是這樣簡單的一個數據結構,卻有着極爲廣泛的應用。下面的講解均無圖,詳情參考單鏈表的相關知識,傳送門>>

1. 入棧操作(push)

  與鏈表頭部插入一樣,首先需要實例化一個新的結點,並給該節點賦初值,使指針p指向該節點,然後通過一下幾步:

  ① 將p指針域指向棧頂結點(首元素),p->next = head->next;

  ② 將head指針域指向p,head->next=p;

  ③ 計數器+1

  代碼如下:

template<class T>
void Stack<T>::push(T elem) // 將elem元素入棧
{
    Node<T> *p = new Node<T>;
    p->data = elem;
    p->next = head->next;
    head->next = p;
    cnt++;
}
  可以看出,棧的入棧實現還是很簡單的,與單鏈表的push_front()是一樣的,而且還不用注意尾指針的特殊情況。

2. 出棧操作(pop)

  同樣,這裏的出棧操作其實就是刪除棧頂元素,放在單鏈表裏說就是刪除首元素,同樣是與單鏈表類似,我們先要獲取待刪元素的前置結點,也就是head結點(這個好像壓根不用獲取……),然後使指針p指向head結點的下一結點,執行下列步驟:

  ① 若p爲NULL,則說明棧內沒有元素,直接返回;否則將head的指針域指向p->next。

  ② 釋放p指向的結點的內存,即delete p;

  ③ 計數器-1

  需要注意的是,如果p爲NULL,說明棧空,此時請求pop操作是非法的,可以根據實際情況拋出異常或者返回特殊值,這裏方法直接返回。

  代碼如下:

template<class T>
void Stack<T>::pop() // 刪除棧頂元素
{
    Node<T> *p = head->next;
    if(p == NULL)
    {
        return;
    }
    head->next = p->next;
    delete p;
    cnt--;
}

3. 獲取棧頂元素(top)

  這裏直接使用一個p指針指向head->next,如果不爲空,則返回p的數據域即可 ;p爲NULL的話,說明棧內無元素,此時請求top操作實際上是非法的,可以根據自身情況拋出異常或者返回特殊值。這裏採用了返回一個實例化的對象,也就是默認值,代碼如下:

template<class T>
T Stack<T>::top() // 獲取棧頂元素值
{
    Node<T> *p = head->next;
    if(p == NULL) // 如果棧內沒有元素,則返回一個新T類型默認值
    {
        return *(new T);
    }
    return p->data;
}

4. 獲取棧的大小(size)

  直接返回內部計數器即可,代碼:
template<class T>
int Stack<T>::size() // 獲取棧內元素數量
{
    return cnt;
}

5. 判斷棧是否爲空(empty)

  此方法經常在循環操作棧的時候用作條件,如果棧空則返回true,非空返回false。代碼如下 :

template<class T>
bool Stack<T>::empty() // 判斷是否爲空棧
{
    return (cnt == 0);
}

**下面是完整的代碼以及簡短測試用例:

#include <bits/stdc++.h>

using namespace std;

template<class T>
struct Node
{
	T data;
	Node<T> *next;
};

template<class T>
class Stack
{
private:
	Node<T> *head;
	int cnt;
public:
	Stack()
	{
		head = new Node<T>;
		head->next = NULL;
		cnt = 0;
	}
	void push(T elem); // 將elem元素入棧
	void pop(); // 刪除棧頂元素
	T top(); // 獲取棧頂元素值
	int size(); // 獲取棧內元素數量
	bool empty(); // 判斷是否爲空棧
};

template<class T>
void Stack<T>::push(T elem) // 將elem元素入棧
{
    Node<T> *p = new Node<T>;
    p->data = elem;
    p->next = head->next;
    head->next = p;
    cnt++;
}
template<class T>
void Stack<T>::pop() // 刪除棧頂元素
{
    Node<T> *p = head->next;
    if(p == NULL)
    {
        return;
    }
    head->next = p->next;
    delete p;
    cnt--;
}
template<class T>
T Stack<T>::top() // 獲取棧頂元素值
{
    Node<T> *p = head->next;
    if(p == NULL) // 如果棧內沒有元素,則返回一個新T類型默認值
    {
        return *(new T);
    }
    return p->data;
}
template<class T>
int Stack<T>::size() // 獲取棧內元素數量
{
    return cnt;
}
template<class T>
bool Stack<T>::empty() // 判斷是否爲空棧
{
    return (cnt == 0);
}

int main()
{
    Stack<int> st;
    st.push(1);
    printf("Top: %d, Size: %d\n", st.top(), st.size());
    st.push(2);
    st.push(666);
    printf("Top: %d, Size: %d\n", st.top(), st.size());
    st.pop();
    printf("Top: %d, Size: %d\n", st.top(), st.size());
    st.pop();
    st.pop();
    printf("Top: %d, Size: %d\n", st.top(), st.size()); // 這裏棧空,top()會返回一個隨機值

    return 0;
}

  附個練習,可以用來練習一下棧的基本操作,傳送門來了:

  SDUT OJ 3335 數據結構實驗之棧八:棧的基本操作

二、棧的典型應用

  這裏是棧的應用,所以需要用到棧的地方我直接使用C++ STL的stack,因爲如果我每次都引用我自己寫的泛型棧的話,代碼太長了……大家可以根據自己的實際情況選擇怎麼寫,編程這個東西,要的就是靈活。

1. 進制轉換

  進制轉換是棧的一個很典型的應用,我們通常說的使用棧解決進制轉換問題是指的十進制轉換成N進制,因爲N進制轉十進制根本不需要什麼輔助手段,直接一個式子算出來就好……

  關於進制轉換的數學求法,就不過多贅述了,我記得高中數學好像學過,就是對於十進制整數X,求其N進製表示,使X不斷地對N取餘數、整除N,如此循環直至X變爲0,則所有餘數值的逆序序列即爲轉換後的N進制數。如我們要將十進制數2017轉換成八進制,計算過程如圖所示:


  紅色爲每步計算的餘數,最後餘數倒置即爲結果。

  所以我們的棧就派上了用場,前面介紹棧的特點是“先進後出”或“後進先出”,也就是說,如果一個序列順序入棧,再全部順序出棧,則出棧後的序列與出棧前恰好相反,所以恰好可以用來實現序列逆置。

  假設這樣一個題目:輸入兩個數X和N,空格間隔,X代表一個十進制正整數,N表示要轉換的進制(2<=N<=16),超過10進制的基數用大寫ABCDEF表示。求X的N進製表示。

  這裏我們需要注意的是,超過十進制的基數,也就是說,如果N大於10,那麼棧內極有可能有大於等於10的數存在,這時候我們不能原樣輸出,而是要轉換成字母來表示超過9的基礎,畢竟數字只有10個,16進制不夠用啊QAQ!

  我們提前定義好各基數就好,代碼如下:

#include<bits/stdc++.h>

using namespace std;

// 按下標定義餘數的輸出字符
const char rep[] = "0123456789ABCDEF"; 

int main()
{
    int n,x;
    while(~scanf("%d %d", &x, &n))
    {
        stack<int> st;
        while(x)
        {
            st.push(x % n); // 餘數入棧
            x /= n; // 整除
        }
        while(!st.empty())
        {
            int tp = st.top();
            st.pop();
            putchar(rep[tp]); // 按照對應的樣式輸出餘數
        }
        puts("");
    }

    return 0;
}

  這就是棧的一個基本應用——進制轉換,實際上就是把一個序列倒序輸出,很好地利用了棧的特點。下面給幾個練習題傳送門:

  SDUT OJ 1252 進制轉換

  SDUT OJ 2131 數據結構實驗之棧一:進制轉換

2. 行編輯器

  我們日常編輯文本文件的時候,一定都會使用退格鍵,用於刪除光標左端的字符,在行編輯器問題上,光標始終在行最右端,不存在移動光標的問題。我們現在引入問題,假設一個簡單的行編輯器,用戶可以鍵入字符,也可以敲擊退格鍵刪除光標左邊的一個字符,也可以敲擊刪除鍵來刪除整行字符。現在我們把用戶鍵入的按鍵(注意是按鍵)轉換成一串按鍵序列,比如abcde#haha##66@qaq代表用戶的鍵入序列,從左至右依次爲用戶按下的鍵,“#”代表退格鍵,刪除一個字符;“@”代表刪除鍵,刪除整行字符,其它的都代表一個正常的可見字符,現在給定你一串用戶的按鍵序列,讓你輸出屏幕上最終的字符串(假設輸入的字符不包含空格)。

  分析這種需求,行編輯器光標始終在右邊,輸入一個字符就相當於在最右端添加一個字符,刪除一個字符也是從最右端刪除,刪除行則是從右端開始刪除所有字符,這正好與棧的運算模式契合,所以使用棧是最好的解決辦法。

  實現上,從左向右掃描鍵入序列,如果是“#”,則彈出棧頂元素(需要注意的是,如果棧空,則這個“#”爲非法操作,應該直接忽略),“@”則循環彈出棧頂元素直至棧爲空,其他情況直接將該字符入棧 。最後將棧內元素依次彈出(此時得到的序列是逆序的,所以需再次逆置,可以再用一個棧輔助逆置),代碼如下:

#include<bits/stdc++.h>

using namespace std;

char line[1010];

int main()
{
    while(~scanf("%s", line))
    {
        stack<char> st;
        for(int i = 0 ; line[i] ; i++)
        {
            if(line[i] == '#')
            {
                if(!st.empty()) // 非空才刪除
                    st.pop();
            }
            else if(line[i] == '@')
            {
                while(!st.empty()) // 循環刪除全部元素
                    st.pop();
            }
            else
            {
                st.push(line[i]);
            }
        }
        stack<char> tmp; // 由於出棧結果是倒置的,所以需要使用另一個棧將結果再次倒置
        while(!st.empty())
        {
            tmp.push(st.top());
            st.pop();
        }
        while(!tmp.empty())
        {
            putchar(tmp.top());
            tmp.pop();
        }
        puts("");
    }

    return 0;
}

  同樣,附上練習題傳送門:

  SDUT OJ 1479 數據結構實驗之棧:行編輯器

3. 表達式轉換及求值

  這裏涉及到兩點,一點是表達式轉換,另一點是表達式求值,下面分別介紹

 (1) 表達式轉換

  我們日常算術中使用的表達式是中綴式,通常是符號位於操作數中間,這種表達方式需要考慮算符的優先級問題,所以引入括號來強行更改算術優先級。這種中綴式適合人腦計算,比較符合人類的運算習慣,但是對計算機卻很不友好,解析難度較大,所以引入了前綴式和後綴式,這兩種表達式均沒有括號,且無優先級限制,只需要按照書寫順序進行計算即可,不同於中綴式,後綴式只需要使用一個棧進行相關操作即可,而中綴式需要兩個棧,一個存符號,一個存操作數。

  下面分析一個表達式轉換問題。

  我們要做的是將一箇中綴表達式轉換成後綴表達式(前綴我們暫不考慮,與後綴大同小異),中綴表達式裏包含+-*/和括號。

  下面給出轉換方法:

  ① 首先構造一個符號棧,此棧的特點是要求遵循棧內元素自底向上優先級嚴格遞增原則,也就是說,上面的元素優先級必須大於下面的元素優先級(前綴式是大於等於,這點區別一定要記清楚)。

  ② 從左向右掃描表達式(前綴式從右向左),逐步判斷:

a) 如果遇到運算數,則直接輸出

b) 如果是運算符,則比較優先級,如果當前運算符優先級大於棧頂運算符優先級(棧空或棧頂爲括號時,直接入棧 ),則將運算符入棧;否則彈出並輸出棧頂運算符,直至滿足當前運算符大於棧頂運算符優先級(或者棧變空、棧頂爲括號)爲止,然後再將當前運算符入棧。

c) 如果是括號,則特殊處理。左括號的話,直接入棧;右括號的話,則不斷彈出並輸出棧頂符號,直到棧頂符號是左括號,然後彈出棧頂左括號(不輸出,因爲後綴式沒有括號),並忽略當前右括號,繼續掃描。

d) 由以上步驟可知,括號具有特殊的優先級,左括號在棧外的時候,是最高的優先級,在棧內有最低優先級。

  ③ 重複上述步驟,直到表達式掃描結束,此時若棧內有剩餘符號,則將符號依次出棧並輸出。

  例如:將a*b+(c-d/e)*f轉換成後綴表達式,分解步驟如下:


  上表即爲表達式轉換的分解步驟,利用了棧的特性完成轉換,轉換後的後綴表達式不需要考慮算符優先級,只要按照符號出現的順序進行計算即可,下一小節就會講利用棧求後綴式的計算結果值。

  中綴式轉後綴式代碼如下(簡化版,假設運算數是小寫字母,運算符只有四則運算和括號):

#include<bits/stdc++.h>

using namespace std;

char line[1010]; // 輸入的中綴式
char res[1010]; // 保存結果
int level[128]; // 定義運算符的優先級

// 比較兩運算符優先級,如果a>b返回true,否則false
bool operatorCmp(char a, char b)
{
    return level[(int)a] > level[(int)b];
}

int main()
{
    // 初始化定義運算符優先級
    level['+'] = level['-'] = 1;
    level['*'] = level['/'] = 2;
    while(~scanf("%s", line))
    {
        stack<char> st;
        int ptr = 0; // 初始化結果字符串指針
        for(int i = 0 ; line[i] ; i++)
        {
            if(isalpha(line[i])) // 如果是字母
            {
                res[ptr++] = line[i]; // 暫存到結果字符數組
            }
            else // 否則,運算符或括號
            {
                // 可以直接入棧的情況:棧空、棧頂括號、當前是括號
                // 認真思考一下這個短路條件的內涵
                if(st.empty() || st.top() == '(' || line[i] == '(')
                {
                    st.push(line[i]);
                }
                else if(line[i] == ')') // 右括號,彈出棧內符號直至遇到左括號
                {
                    // 這裏也有一個短路條件,如果彈出過程遇到棧空,說明括號不匹配,應該報錯
                    while(!st.empty() && st.top() != '(')
                    {
                        res[ptr++] = st.top();
                        st.pop();
                    }
                    // 彈出左括號
                    if(!st.empty())
                        st.pop();
                }
                else
                {
                    // 遇到符號,如果棧非空且當前符號小於等於棧頂符號,則一直彈出並輸出棧頂符號
                    while(!st.empty() && !operatorCmp(line[i], st.top()))
                    {
                        res[ptr++] = st.top();
                        st.pop();
                    }
                    // 循環結束,可以入棧了
                    st.push(line[i]);
                }
            }
        }
        // 掃描結束,依次彈出並輸出棧內剩餘元素
        while(!st.empty())
        {
            res[ptr++] = st.top();
            st.pop();
        }
        // 爲結果字符串添加結束標誌'\0'
        res[ptr] = 0;
        // 打印轉換結果
        puts(res);
    }

    return 0;
}

  同樣附上練習題,傳送門:

  SDUT OJ 2132 數據結構實驗之棧二:一般算術表達式轉換成後綴式(此題與上面例子很像,但是有細節差異哦)

  SDUT OJ 2484 算術表達式的轉換(此題要求前綴,與後綴類似,相信你可以觸類旁通~)

 (2) 後綴表達式計算

  前面說到,後綴式很適合計算機識別,所以後綴式求值只需要利用一個棧即可快速求值。而且實現起來也很簡單。

  由於後綴式不存在優先級,所以計算起來也沒那麼麻煩,下面給出計算步驟。

  步驟如下:

  ① 構造一個操作數棧,用於存放操作數。

  ② 從左向右掃描後綴式,如果遇到操作數,則將操作數入棧;

  ③ 如果遇到運算符,根據運算符需要的運算數的個數,從棧中取出對應個數的操作數,先出棧的作爲操作數,後出棧的爲操作數,計算出結果後,將計算結果入棧。

  ④ 掃描結束後,如果後綴式合法,則過程中不會產生錯誤且最終棧內一定有且僅有一個元素,輸出棧頂元素即爲最終結果。

  我們以後綴式59*684/-3*+爲例,這裏的操作數均爲個位數(前面不是59而是5和9),當然有更復雜的後綴式,比如多位數,這裏不研究。

  解析如下:


  利用一個數棧,即可完成後綴表達式的計算,且不用考慮優先級,需要注意的是不遵循交換律的運算(-和/)要注意左右操作數,先出棧的爲右操作數

  上例後綴式求值代碼如下:

#include<bits/stdc++.h>

using namespace std;

char line[1010];

int calc(int a, int b, char op)
{
    if(op == '+')
        return a + b;
    else if(op == '-')
        return a - b;
    else if(op == '*')
        return a * b;
    else // '/'
        return a / b;
}

int main()
{
    while(~scanf("%s", line))
    {
        stack<int> st; // 構造操作數棧
        for(int i = 0 ; line[i] ; i++)
        {
            if(isdigit(line[i])) // 如果是數字(操作數),入棧
            {
                st.push(line[i] - '0');
            }
            else // 如果是符號
            {
                int b = st.top(); // 取出第一個運算數,也就是右操作數
                st.pop();
                int a = st.top(); // 取出第二個運算數,也就是左操作數
                st.pop();
                st.push(calc(a, b, line[i])); // 計算結果併入棧
            }
        }
        printf("%d\n", st.top()); // 輸出結果
    }

    return 0;
}

  附練習題傳送門:

  SDUT OJ 2133 數據結構實驗之棧三:後綴式求值

4. 括號匹配

  下面就是本章棧的另一個重要的應用了——括號匹配,這也是一個比較常見的應用。簡單點說,就是給你一串括號序列,讓你判斷此序列是否是合法的括號匹配序列。比如,(())就是一個合法序列,而)()(就不是合法序列,同樣(][)也不是合法序列,諸如此類……

  接下來,我們引入一個問題,給定一串帶各種括號的表達式序列或括號序列,可能包括括號、數字、字母、標點符號、空格,括號包括圓括號(),方括號[],花括號{},讓你判斷給定序列是不是括號匹配的,匹配輸出yes,否則輸出no。

  例如,sin(20+10)是匹配的,{[}]是不匹配的。

  我們先分析算法:

  首先,判斷一對括號匹配,一定是右括號匹配最近的左括號。也就是說,每遇到一個右括號,左邊都會有一個唯一的左括號與之匹配,當所有右括號與所有左括號匹配完成,沒有多餘的左括號,則匹配成功,否則失敗。

  所以我們可以利用一個棧來存儲括號:我們從左向右掃描序列,遇到左括號,就直接入棧;遇到右括號,我們就檢查棧頂是不是與之對應的左括號,如果是,則匹配,彈出棧頂元素,繼續掃描;否則,說明該右括號無匹配的左括號,則失敗,該序列不匹配。

  實現起來還是很容易的,直接上代碼了:

#include<bits/stdc++.h>

using namespace std;

// 給定左符號a和右符號b判斷是否匹配
bool match(char a, char b)
{
    if( (a == '(' && b == ')') ||
		(a == '[' && b == ']') ||
		(a == '{' && b == '}'))
			return true;
	return false;
}

// 判斷給定字符串是否是括號匹配的
bool check(char *s)
{
	stack<char> st;
	for(int i = 0 ; s[i] ; i++)
	{
        if(s[i] == '(' || s[i] == '[' || s[i] == '{')
		{
			// 左括號直接入棧
			st.push(s[i]);
		}
		else if(s[i] == ')' || s[i] == ']' || s[i] == '}')
		{
			// 如果遇到右括號時,棧空,或者棧頂元素與這個右括號不匹配,直接false
			if(st.empty() || !match(st.top(), s[i]))
				return false;
			// 執行到這裏說明上一步匹配成功,彈出棧頂左括號
			st.pop();
		}
		// else 掃描到其他情況,非括號直接不考慮
	}
	// 掃描結束後,只有棧爲空纔是括號匹配的,否則說明棧內有左括號未被匹配
	return st.empty();
}

int main()
{
	char str[110];
	while(gets(str))
	{
		// 檢查是否匹配
		puts(check(str) ? "yes" : "no");
	}

    return 0;
}
  練習題傳送門:

  SDUT OJ 2134 數據結構實驗之棧四:括號匹配 (此題與上例一樣)

5. 出棧序列的判定

  我們通過先前的學習知道,棧是先進後出結構,正是這種特性決定了棧在計算機領域的重要地位。現在有這樣一個問題:有一個待入棧序列,你需要將個序列中的元素依次入棧,你可以隨時將某元素出棧,這樣可以得到一個出棧序列。例如有入棧序列1,2,3,4,5,依次執行push,push,push,pop,pop,pop,push,pop,push,pop,可得到出棧序列3,2,1,4,5。

  現在給定你一個待入棧的序列,再給定若干個待判定序列,你需要判斷這些待判定序列是否是待入棧序列的合法出棧序列。

  例如序列1,2,3,4,5是某棧的壓入順序,序列4,5,3,2,1是該入棧序列對應的一個出棧序列,但4,3,5,1,2就不可能是該序列的出棧序列。假設壓入棧的所有數字均不相等。

  提出問題,假設第一行輸入n,代表序列長度,隨後一行n個整數,代表序列,第三行輸入m,代表待匹配出棧序列的個數,接下來m行,每行n個數代表出棧序列。

  其實此題可以根據棧的特性設計算法來快速解決,但是我們這裏不考慮,我們只用棧模擬解決 。我們假設給定出棧序列合法,設置一個指針指向出棧序列首端,表示待匹配元素,那麼我們將待入棧序列依次入棧,每入棧一個元素,判斷一下當前棧頂是不是與指針指向的出棧序列元素匹配,匹配的話,說明該棧頂元素需要出棧,然後還需將指針後移,然後繼續判斷棧頂和指針元素是否匹配,如此循環直至不匹配;否則繼續將待入棧序列入棧。當待入棧序列全部入棧完畢,檢查此時棧是否爲空。若棧爲空,說明入棧序列與出棧序列全部匹配,該出棧序列合法,否則說明不匹配,出棧序列不合法。代碼如下:

#include <bits/stdc++.h>

using namespace std;

int arr[10010];
int chk[10010];

int main()
{
    int n, m;
    while(~scanf("%d", &n))
    {
        for(int i = 0 ; i < n ; i++)
            scanf("%d", &arr[i]);
        scanf("%d", &m);
        while(m--)
        {
            for(int i = 0 ; i < n ; i++)
                scanf("%d", &chk[i]);
            stack<int> st;
            int ptr = 0; // 初始化出棧序列匹配元素的指針
            for(int i = 0 ; i < n ; i++)
            {
                // 將入棧序列的元素依次入棧
                st.push(arr[i]);
                while(!st.empty() && st.top() == chk[ptr])
                {
                    // 如果棧不空且棧頂與指針所指匹配,就彈出棧頂並移動指針
                    st.pop();
                    ptr++;
                }
                // 不匹配了,就繼續入棧序列元素
            }
            if(st.empty()) // 棧空,說明所有元素匹配,序列合法
                puts("yes");
            else // 否則,說明存在未匹配元素,序列不合法
                puts("no");
        }
    }

    return 0;
}

  附練習題傳送門,其實就是這個題……

  SDUT OJ 3334 數據結構實驗之棧七:出棧序列判定

6. 迷宮問題

  迷宮問題的求解,我們通常使用兩種最經典的搜索方式——深度優先搜索(Depth First Search, DFS)或廣度優先搜索(Breadth First Search, BFS)。我們本章是棧,所以暫且不講這兩種搜索,後面二叉樹的章節會學到這兩種搜索方式。先透露一下,深度優先搜索,是函數遞歸式的,是利用了函數的特性;廣度優先搜索的實現 ,需要依賴隊列來維護一個搜索序列。所以棧和隊列這兩種數據結構的重要性不言而喻。

  迷宮求解問題,我們假設有如下問題:

  一個由n * m 個格子組成的迷宮,起點是(1, 1), 終點是(n, m),每次可以向上下左右四個方向任意走一步,並且有些格子是不能走動,求從起點到終點經過每個格子至多一次的走法數(也就是路線的種數)。

  我們迴歸到問題,假設啊,我們用人類的思維方式走迷宮,我不知道你們是怎麼做的,反正我是遵循“不撞南牆不回頭”的策略,按照一條路走下去,碰壁了再原路返回,這樣逐步地嘗試,就會找到出口。我們的深度優先搜索就是“撞南牆”式的搜索方式,按照一個方向不斷地找下去,直至無法繼續,再改變方向繼續尋找,再直至所有方向都嘗試完,搜索結束。

  那麼用棧如何解決呢?其實函數遞歸就是棧的典型應用,我們知道,遞歸其實就是把函數的各參數和入口地址等信息保存到函數棧中,執行的始終是棧頂函數,結束後彈出函數棧執行上一層函數……如此下去,所以這裏深搜的遞歸問題完全可以用棧來解決,我們先圖解一個4*4迷宮,假設我們定義方向的優先順序爲右下左上順時針,即RDLU,如下圖(圖很大,我可以自己用Windows畫圖畫了好久,綠色起點,粉色終點,看不清請點大圖):


  根據上圖可以看出,我們在“撞南牆”後需要回退到上一步的操作,這就涉及到保存先前走迷宮的狀態,包括走過的格子和每個格子當時所朝向的方向,所以恰好可以用棧來保存狀態,因爲每次回退的時候,其實就是彈出棧頂元素,這樣棧頂元素始終是最後一次操作的狀態。

  我們使用一個三元組(x,y,d)來保存迷宮路徑狀態,x和y分別代表迷宮格子的行列位置,d代表這個格子當前所朝向的方向。注意我們的方向只有四個,且每個方向最多枚舉一次(方向不能無限地來回轉啊,這樣永遠沒完沒了了)。比如我們將上面的迷宮圖按照步驟畫出棧內狀態(又要祭出我的大Windows畫圖了,假設左上角是1,1):


  這樣按照步驟一步一步來,是不是就清晰明瞭了?下面給出代碼,代碼中有註釋幫助你們自己分析思考,dfs_recursive是遞歸形式的解法,比較簡單,你們可以自己實現一下~上代碼:

#include <bits/stdc++.h>

using namespace std;

template<class T1, class T2, class T3>
struct tuple // 自定義一個泛型三元組
{
    T1 first;
    T2 second;
    T3 third;
    tuple(T1 a, T2 b, T3 c):first(a), second(b), third(c){} // 三元組的構造
};
typedef tuple<int, int, int> tpl; // 重定義以下寫起來方便……

const int dx[] = {0,1,0,-1}; // 行上的方向
const int dy[] = {1,0,-1,0}; // 列上的方向
// dx dy取相同下標時可表示爲一個方向,例如dx[0]和dy[0]表示的是右

int mp[10][10]; // 地圖數據
bool vis[10][10]; // 標記數組

int dfs_stack(int n, int m) // 非遞歸(棧實現)的深度搜索
{
    int res = 0;
    memset(vis, 0, sizeof(vis)); // 多組數據一定記得初始化這些有標記意義的數組或對象
    stack<tpl> st;
    st.push(tpl(1, 1, -1)); // 將起點入棧這裏初始方向寫成-1
    // 是因爲每次取出棧頂均要通過+1來更改方向,
    // 所以初始-1的話,+1纔會變成0
    vis[1][1] = true; // 標記此點已在棧中,代表該點是當前已走路徑上的點
    while(!st.empty())
    {
        /**
        * 此題求路徑數,所以就算找到終點也要回退
        * 繼續找其他可能的路徑,所以條件是棧空
        * (說明已經退到底了)才結束
        **/
        tpl tmp = st.top(); // 取棧頂元素開始枚舉下一步能達到的點
        st.pop(); // 這裏先彈出,因爲要更改方向,方向更改完成後還要放回棧中,除非無路可走要退回
        int x = tmp.first;
        int y = tmp.second;
        vis[x][y] = false; // 同樣先取消標記,因爲已經出棧了
        if(x == n && y == m)
        {
            res++;  // 當前點是終點,計數器加1,表示已經搜尋到一條路徑
            continue;
        }
        int d;
        for(d = tmp.third + 1 ; d < 4 ; d++) // 順時針更改方向
        {
            // 根據方向得到下一步嘗試(劃重點,嘗試)到達的點
            int px = tmp.first + dx[d];
            int py = tmp.second + dy[d];
            if(px > 0 && px <= n && py > 0 && py <= m && !vis[px][py] && mp[px][py] == 0)
            {
                // 如果該嘗試的點未出界且未在棧內且不是障礙物,則可通行
                tmp.third = d; // 更改棧頂元素的方向值
                st.push(tmp); // 放回棧內
                vis[x][y] = true; // 做上標記,表示在棧中
                st.push(tpl(px, py, -1)); // 把可通行的點入棧,方向初始化爲-1
                vis[px][py] = true; // 做上入棧標記
                break; // 跳出循環,一定要跳出,因爲已經找到下一步路了,不要再枚舉方向了
            }
        }
        // 如果循環可以正常結束(非break),那麼說明當前棧頂點已經枚舉完所有的方向了,可以回退
        // 在這裏回退表現的是沒經過修改方向後再次入棧這一步,因爲沒走break,仔細看
    }
    return res;
}

int dfs_recursive(int n, int m) // 遞歸的深度搜索(自己實現)
{
    return 0;
}

int main()
{
    int t,n,m;
    while(~scanf("%d", &t))
    {
        while(t--)
        {
            scanf("%d %d", &n, &m);
            for(int i = 1 ; i <= n ; i++)
                for(int j = 1 ; j <= m ; j++)
                    scanf("%d", &mp[i][j]);
            printf("%d\n", dfs_stack(n, m));
        }
    }

    return 0;
}
  深度優先搜索的非遞歸還是挺麻煩也挺難想的,不過這很能鍛鍊對棧思想的認識,所以學會非遞歸模式的深度搜索相當有必要,上述代碼需要仔細理解,如果困難的話可以使用單步調試來觀察程序的運行,下面給出這道題的傳送門:

  SDUT OJ 2449 走迷宮

  

  以上就是本章全部內容了,棧還是一個比較簡單的數據結構,但是對於計算機的意義是相當重大的。我們日常使用的操作系統都離不開棧,線程棧、任務棧、進程棧、各種棧……包括我們編程接觸的函數遞歸在運行時也裏不開棧,總之,學習並深刻理解棧是相當重要的。如果文中有描述不當或者講解錯誤的地方,歡迎大家留言指正~


  下集預告&傳送門:數據結構與算法專題之線性表——隊列及其應用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章