前面,我們實現了兩種常見的線性表 —— 順序表 和 連結串列 ,本篇我們來介紹另外一種常用的線性表 —— 棧。
棧
定義
線性表中的一種特殊資料結構,資料只能從固定的一端插入資料或刪除資料,另一端是封死的。
特點
-
FILO(First In Last Out): 先進後出;
-
棧滿還存會“上溢”,棧空再取會“下溢”;
“上溢”:在棧已經存滿資料元素的情況下,如果繼續向棧記憶體入資料,棧儲存就會出錯。
“下溢”:在棧內為空的狀態下,如果對棧繼續進行取資料的操作,就會出錯。
分類
順序棧
特點
- 採用陣列實現,資料在物理結構上保持連續性。
程式碼實現
package one.wangwei.algorithms.datastructures.stack.impl;
import one.wangwei.algorithms.datastructures.stack.IStack;
import java.util.Arrays;
/**
* 順序棧
*
* @param <T>
* @author wangwei
* @date 2018/05/04
*/
public class ArrayStack<T> implements IStack<T> {
/**
* 預設大小
*/
private static final int DEFAULT_SIZE = 10;
/**
* 陣列
*/
private T[] array = (T[]) new Object[DEFAULT_SIZE];
/**
* 大小
*/
private int size;
/**
* 入棧
*
* @param value
* @return
*/
@Override
public boolean push(T value) {
if (size >= array.length) {
grow();
}
array[size] = value;
size++;
return false;
}
/**
* 擴容50%
*/
private void grow() {
int growSize = size + (size << 1);
array = Arrays.copyOf(array, growSize);
}
/**
* 壓縮50%
*/
private void shrink() {
int shrinkSize = size >> 1;
array = Arrays.copyOf(array, shrinkSize);
}
/**
* 出棧
*
* @return
*/
@Override
public T pop() {
if (size <= 0) {
return null;
}
T element = array[--size];
array[size] = null;
int shrinkSize = array.length >> 1;
if (shrinkSize >= DEFAULT_SIZE && shrinkSize > size) {
shrink();
}
return element;
}
/**
* 檢視棧頂值
*
* @return
*/
@Override
public T peek() {
if (size <= 0) {
return null;
}
return array[size - 1];
}
/**
* 刪除元素
*
* @param value
* @return
*/
@Override
public boolean remove(T value) {
if (size <= 0) {
return false;
}
for (int i = 0; i < size; i++) {
T t = array[i];
if (value == null && t == null) {
return remove(i);
}
if (value != null && value.equals(t)) {
return remove(i);
}
}
return false;
}
/**
* 移除 index 處的棧值
*
* @param index
* @return
*/
private boolean remove(int index) {
if (index < 0 || index >= size) {
throw new ArrayIndexOutOfBoundsException("Index: " + index + ", Size: " + size);
}
if (index != --size) {
System.arraycopy(array, index + 1, array, index, size - index);
}
array[size] = null;
int shrinkSize = array.length >> 1;
if (shrinkSize >= DEFAULT_SIZE && shrinkSize > size) {
shrink();
}
return true;
}
/**
* 清空棧
*/
@Override
public void clear() {
if (size <= 0) {
return;
}
for (int i = 0; i < size; i++) {
array[i] = null;
}
size = 0;
array = null;
}
/**
* 是否包含元素
*
* @param value
* @return
*/
@Override
public boolean contains(T value) {
if (size <= 0) {
return false;
}
for (int i = 0; i < size; i++) {
T t = array[i];
if (value == null && t == null) {
return true;
}
if (value != null && value.equals(t)) {
return true;
}
}
return false;
}
/**
* 棧大小
*
* @return
*/
@Override
public int size() {
return size;
}
}
複製程式碼
複雜度
空間複雜度
出棧和入棧的操作,只涉及一兩個臨時變數的儲存空間,所以複雜度為O(1).
時間複雜度
順序棧在出棧和入棧的操作時,最好情況時間複雜度為O(1),當需要擴容或者縮減時,需要遷移資料,此時為最壞複雜度,為O(n). 根據攤還分析法則,它們的均攤時間複雜度還是為O(1).
連結串列棧
特點
- 用線性表的鏈式結構儲存,資料在物理結構上非連續
程式碼實現
package one.wangwei.algorithms.datastructures.stack.impl;
import one.wangwei.algorithms.datastructures.stack.IStack;
/**
* 連結串列棧
*
* @param <T>
* @author wangwei
* @date 2018/05/04
*/
public class LinkedStack<T> implements IStack<T> {
private Node<T> top;
private int size;
public LinkedStack() {
this.top = null;
this.size = 0;
}
/**
* 入棧
*
* @param value
* @return
*/
@Override
public boolean push(T value) {
Node<T> newTop = new Node<>(value);
if (top == null) {
top = newTop;
} else {
Node<T> oldTop = top;
top = newTop;
oldTop.above = top;
top.below = oldTop;
}
size++;
return true;
}
/**
* 出棧
*
* @return
*/
@Override
public T pop() {
if (top == null) {
return null;
}
Node<T> needTop = top;
top = needTop.below;
if (top != null) {
top.above = null;
}
T needValue = needTop.element;
needTop = null;
size--;
return needValue;
}
/**
* 檢視棧頂值
*
* @return
*/
@Override
public T peek() {
return top == null ? null : top.element;
}
/**
* 刪除元素
*
* @param value
* @return
*/
@Override
public boolean remove(T value) {
if (top == null) {
return false;
}
Node<T> x = top;
if (value == null) {
while (x != null && x.element != null) {
x = x.below;
}
} else {
while (x != null && !value.equals(x.element)) {
x = x.below;
}
}
return remove(x);
}
/**
* 刪除一個節點
*
* @param node
* @return
*/
private boolean remove(Node<T> node) {
if (node == null) {
return false;
}
Node<T> above = node.above;
Node<T> below = node.below;
// 刪除中間元素
if (above != null && below != null) {
above.below = below;
below.above = above;
}
// 刪除top元素
else if (above == null && below != null) {
top = below;
top.above = null;
} else if (above != null && below == null) {
above.below = null;
below = null;
} else {
top = null;
}
node = null;
size--;
return true;
}
/**
* 清空棧
*/
@Override
public void clear() {
if (top == null) {
return;
}
for (Node<T> x = top; x != null; ) {
Node<T> below = x.below;
x.element = null;
x.above = null;
x.below = null;
x = below;
}
top = null;
size = 0;
}
/**
* 是否包含元素
*
* @param value
* @return
*/
@Override
public boolean contains(T value) {
if (value == null) {
for (Node<T> x = top; x != null; x = x.below) {
if (x.element == null) {
return true;
}
}
} else {
for (Node<T> x = top; x != null; x = x.below) {
if (x.element.equals(value)) {
return true;
}
}
}
return false;
}
/**
* 棧大小
*
* @return
*/
@Override
public int size() {
return size;
}
/**
* 節點
*
* @param <T>
*/
private static class Node<T> {
private T element;
private Node<T> above;
private Node<T> below;
public Node(T element) {
this.element = element;
}
}
}
複製程式碼
複雜度
空間複雜度
出棧和入棧的操作,只涉及一兩個臨時變數的儲存空間,所以複雜度為O(1).
時間複雜度
出棧和入棧的操作,不涉及資料搬遷,只是頂部元素操作,時間複雜度均為O(1).
棧的應用
接下來,我們看看棧在軟體工程中的實際應用。
函式呼叫
作業系統給每個執行緒分配了一塊獨立的記憶體空間,這塊記憶體被組織成“棧”這種結構, 用來儲存函式呼叫時的臨時變數。每進入一個函式,就會將臨時變數作為一個棧幀入棧,當被呼叫函式執行完成,返回之後,將這個函式對應的棧幀出棧。
示例:
int main() {
int a = 1;
int ret = 0;
int res = 0;
ret = add(3, 5);
res = a + ret;
printf("%d", res);
reuturn 0;
}
int add(int x, int y) {
int sum = 0;
sum = x + y;
return sum;
}
複製程式碼
main() 函式呼叫了 add() 函式,獲取計算結果,並且與臨時變數 a 相加,最後列印 res 的值。這個過程的中函式棧裡的出棧、入棧操作,如下所示:
思考:為什麼函式要用棧來儲存臨時變數呢?用其他資料結構不行嗎?
函式呼叫的區域性狀態之所以用棧來記錄是因為這些狀態資料的存活時間滿足“後入先出”(LIFO)順序,而棧的基本操作正好就是支援這種順序的訪問。
棧是程式設計中的一種經典資料結構,每個程式都擁有自己的程式棧。棧幀也叫過程活動記錄,是編譯器用來實現函式呼叫過程的一種資料結構。C語言中,每個棧幀對應著一個未執行完的函式。從邏輯上講,棧幀就是一個函式執行的環境:函式呼叫框架、函式引數、函式的區域性變數、函式執行完後返回到哪裡等等。棧是從高地址向低地址延伸的。每個函式的每次呼叫,都有它自己獨立的一個棧幀,這個棧幀中維持著所需要的各種資訊。
暫存器ebp(base pointer)指向當前的棧幀的底部(高地址),可稱為“幀指標”或“基址指標”;暫存器esp(stack pointer)指向當前的棧幀的頂部(低地址),可稱為“ 棧指標”。
在C和C++語言中,臨時變數分配在棧中,臨時變數擁有函式級的生命週期,即“在當前函式中有效,在函式外無效”。這種現象就是函式呼叫過程中的引數壓棧,堆疊平衡所帶來的。堆疊平衡是指函式調完成後,要返還所有使用過的棧空間。
函式呼叫其實可以看做4個過程:
- 壓棧: 函式引數壓棧,返回地址壓棧
- 跳轉: 跳轉到函式所在程式碼處執行
- 執行: 執行函式程式碼
- 返回: 平衡堆疊,找出之前的返回地址,跳轉回之前的呼叫點之後,完成函式呼叫
表示式求值
以 3 + 5 x 8 - 6
為這個表示式為例,編譯器是如何利用棧來實現表示式求值的呢?
編譯器會使用兩個棧來實現,一個棧用來儲存運算元,另一個棧用來儲存運算子。從左向右遍歷表示式,遇到數字直接壓入運算元棧,遇到操作符,就與運算子棧頂元素進行比較。
如果比運算子棧頂元素的優先順序高,就將當前運算子壓入棧;如果比運算子棧頂元素的優先順序低或者相同,從運算子棧中取棧頂運算子,從運算元棧的棧頂取 2 個運算元,然後進行計算,再把計算完的結果壓入運算元棧,繼續比較。
如圖所示:
此前,我們在講 比特幣指令碼語言 時,提到過 逆波蘭表示法 ,也是運用了棧這種資料結構特徵。
括號匹配
棧還可以用來檢測表示式中的括號是否匹配。
我們假設表示式中只包含三種括號,圓括號 ()、方括號 [] 和花括號{},並且它們可以任意巢狀。比如,{[{}]}或 [{()}([])] 等都為合法格式,而{[}()] 或 [({)] 為不合法的格式。那我現在給你一個包含三種括號的表示式字串,如何檢查它是否合法呢?
我們用棧來儲存未匹配的左括號,從左到右依次掃描字串。當掃描到左括號時,則將其壓入棧中;當掃描到右括號時,從棧頂取出一個左括號。如果能夠匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,則繼續掃描剩下的字串。如果掃描的過程中,遇到不能配對的右括號,或者棧中沒有資料,則說明為非法格式。
當所有的括號都掃描完成之後,如果棧為空,則說明字串為合法格式;否則,說明有未匹配的左括號,為非法格式。
練習
Leetcode 20. Valid Parentheses
我自己在做這道題時,雖說做對了,但是沒有用Map儲存括號的對應關係,導致程式碼非常臃腫難堪。
class Solution {
// Hash table that takes care of the mappings.
private HashMap<Character, Character> mappings;
// Initialize hash map with mappings. This simply makes the code easier to read.
public Solution() {
this.mappings = new HashMap<Character, Character>();
this.mappings.put(')', '(');
this.mappings.put('}', '{');
this.mappings.put(']', '[');
}
public boolean isValid(String s) {
// Initialize a stack to be used in the algorithm.
Stack<Character> stack = new Stack<Character>();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
// If the current character is a closing bracket.
if (this.mappings.containsKey(c)) {
// Get the top element of the stack. If the stack is empty, set a dummy value of '#'
char topElement = stack.empty() ? '#' : stack.pop();
// If the mapping for this bracket doesn't match the stack's top element, return false.
if (topElement != this.mappings.get(c)) {
return false;
}
} else {
// If it was an opening bracket, push to the stack.
stack.push(c);
}
}
// If the stack still contains elements, then it is an invalid expression.
return stack.isEmpty();
}
}
複製程式碼
瀏覽器的前進、後退功能
使用兩個棧,X 和 Y,把首次瀏覽的頁面依次壓入棧 X,當點選後退按鈕時,再依次從棧 X 中出棧,並將出棧的資料依次放入棧 Y。當我們點選前進按鈕時,我們依次從棧 Y 中取出資料,放入棧 X 中。當棧 X 中沒有資料時,那就說明沒有頁面可以繼續後退瀏覽了。當棧 Y 中沒有資料,那就說明沒有頁面可以點選前進按鈕瀏覽了。
比如你順序檢視了 a,b,c 三個頁面,我們就依次把 a,b,c 壓入棧,這個時候,兩個棧的資料就是這個樣子:
當你通過瀏覽器的後退按鈕,從頁面 c 後退到頁面 a 之後,我們就依次把 c 和 b 從棧 X 中彈出,並且依次放入到棧 Y。這個時候,兩個棧的資料就是這個樣子:
這個時候你又想看頁面 b,於是你又點選前進按鈕回到 b 頁面,我們就把 b 再從棧 Y 中出棧,放入棧 X 中。此時兩個棧的資料是這個樣子:
這個時候,你通過頁面 b 又跳轉到新的頁面 d 了,頁面 c 就無法再通過前進、後退按鈕重複檢視了,所以需要清空棧 Y。此時兩個棧的資料這個樣子:
當然,我們還可以使用雙向連結串列來實現這個功能。
區別
我們都知道,JVM 記憶體管理中有個“堆疊”的概念。棧記憶體用來儲存區域性變數和方法呼叫,堆記憶體用來儲存 Java 中的物件。那 JVM 裡面的“棧”跟本篇說的“棧”是不是一回事呢?如果不是,那它為什麼又叫作“棧”呢?
本篇介紹的棧是一種抽象的資料結構,而JVM中的"堆疊"是一種實際存在的物理結構,有關JVM堆疊的瞭解,可以看我之前的文章:wangwei.one/posts/java7…