1.3 《演算法》之揹包,佇列和棧
阿新 • • 發佈:2019-01-11
文章目錄
概述
許多基礎資料型別(ArrayList)都和物件的集合有關,具體來說,資料型別的值就是一組物件的集合,所有的操作都是關於增刪改查集合中的物件,本章主要學習揹包,佇列和棧,區別在於訪問或刪除物件的順序不同。本節關注以下幾點:
- 集合中物件的表示方式直接影響操作的效率,所以對於集合來說,要設計適合於表示一組物件的資料結構並高效實現所需方法
- 泛型和迭代:簡化程式碼
- 鏈式資料結構的實現,通過連結串列來高效實現背隊棧
- 學習背隊棧的API和用例,討論資料型別的值所有可能的表示方法和各種操作的實現(以後會反覆出現的學習模式)
集合型抽象資料型別
泛型
- 泛型(引數化型別),如Bag:Item為一個型別引數,是象徵性的佔位符,使用時必須例項化為一具體的引用資料型別(不能用primitive資料型別)
- 為了在使用泛型時能處理primitive資料型別,利用封裝型別來自動裝箱和拆箱
可迭代的集合型別
- 用例要求迭代訪問集合中的元素(該模式很重要),且不依賴於集合型別的具體實現(不需要知道集合的表示或實現的任何細節)
- 若集合可迭代(集合要新增實現程式碼),則用增強for迴圈即可以(該語法稱為foreach語句)
- stack和queue的API的唯一不同在於它們名稱和方法名(說明無法簡單地通過一列方法的簽名來說明一個數據型別的所有特點)。在這裡,只有自然語言描述才能說明選擇被刪除的元素(或是foreach語句中下一個被處理的元素)的規則。這些規則的差異是API的重要組成部分,對用例開發至關重要。(?)
揹包
- 不支援刪除元素的集合資料型別
- 目的是幫助用例收集元素並迭代遍歷所有集合中元素(迭代順序不確定且與用例無關)
- 例項(P77):簡單地計算標準輸入中的所有double值的平均值和樣本標準差(數的計算順序和結果無關)
先進先出佇列(queue)
- 基於FIFO策略的集合型別(按照任務產生的順序來完成它們的策略,任何服務型策略的基本原則都是公平)
- 日常現象的自然模型
- 迭代訪問時,元素處理順序即新增的順序(儲存元素同時儲存了它們的相對順序)
- 例項(P77):將檔案中的所有整數讀入一個數組中
下壓棧
- 基於後進先出策略的集合型別(一摞檔案的組織原則)
- 聯絡安卓開發的activity
- 迭代訪問順序與壓入順序相反(儲存元素同時顛倒了它們的相對順序)
棧例項——算術表示式求值
Dijkstra的雙棧算術表示式求值演算法
- 描述為接收一個輸入字串(表示式)並輸出表達式的值(進一步地理解為直譯器,解釋給定字串所表達的運算並得到結果,且展示一種計算模型,將字串解釋為一段程式,將執行該程式得到結果)
- 明確的遞迴定義來簡化問題:未省略括號,即明確說明所有運算子的運算元的算術表示式
- 兩個棧:一個儲存運算子,一個儲存運算元
- 具體的:根據四種情況從左到右逐個將實體送入棧處理,見書P79(待細看)
- 理解為:從左到右逐個等效替代(子表示式的計算值替代子表示式)
集合類資料型別的實現
定容字串棧
- 先討論一簡單而經典的實現:一種表示定容字串棧的抽象資料型別的實現
- 實現API首先要選擇資料的表示方式——選擇string陣列
- 實現API後,用恆等式的方式思考這些條件(定容字串棧滿足的性質)是檢驗實現正常工作的最簡單方式
- 該實現的效能特點在於pop操作和push操作與棧長度獨立,具有簡潔性
- 需要改進為一種適用性更加廣泛的實現來作為通用工具(更強大資料型別的模板)
定容字串棧的改進
泛型
- 採用型別引數來實現一個泛型的棧
- Java使用型別引數來檢測型別不匹配錯誤,即賦給Item型別變數的值必須是Item型別
- 建立泛型陣列是不允許的,需要使用型別轉換
a = (Item[])new Object[num]; %item為型別引數,Java系統庫中類似抽象資料型別的實現也使用相同方式
動態調整陣列的大小
- 用陣列表示棧的內容意味著用例要預先估計棧的最大容量,從而為陣列預先設定大小,但會存在浪費記憶體問題,如一個交易系統涉及上億筆交易和數千個交易的集合,即使該系統限制每筆交易只能出現在一個集合中,但用例必須保證所有集合都有能力儲存所有的交易,此外會出現溢位問題
- 考慮在push前檢查棧是否full,所以API應該含有isFull方法,但在此省略其實現程式碼(將用例從處理isFull問題中解脫出來,如原始stack的API所示)——》修改陣列的實現,實現動態調整陣列大小
- 逐個複製陣列元素,但建立更大空間陣列的resize方法
- 關注pop的檢測條件:判別棧大小是否小於陣列的1/4,若成立則陣列長度減半,達到狀態半滿(0.25-1——》0.25-0.5)
在下次需要改變陣列大小前還能進行多次的push和pop操作
物件的遊離
- 儲存一個不需要物件的引用稱為遊離
- Java垃圾回收策略是回收所有無法被訪問的物件的記憶體
- 在pop實現中,被彈出的元素的引用仍然存在於陣列中(名義上該元素是孤兒了,不再需要被訪問,但垃圾回收器不知道),所以該元素繼續存在,稱為物件遊離
- 避免物件遊離:將被彈出的陣列元素的值設為null,從而覆蓋無用引用,使得系統在用例用完被彈出的元素後回收它的記憶體
迭代的實現(Iterator和Iterable)
- 集合類資料型別的基本操作之一:使用foreach迭代遍歷和處理集合中元素,好處清晰簡潔,不依賴於集合類資料型別的具體實現
- 對於可迭代的集合類資料型別,Java已定義了所需的介面。
- 一個要想可迭代,需要實現一個 iterator()方法並返回一個迭代器Iterator物件(多型性),即實現Iterable介面
- 對於一直使用的陣列表示法,需要逆序迭代遍歷該陣列(棧),所以將迭代器命名為ReverseArrayIterator
- Iterator和Iterable(❤)
- Iterable和Iterator都是介面,集合類要實現Iterable接口才能迭代
- Iterable介面包含一個 iterator()方法,該方法返回一個迭代器物件
- 迭代器物件是一個實現了Iterator介面的類的物件
- Iterator介面包含hasNext方法,remove方法和next方法
- 本書中remove方法總為空,因為希望避免在迭代中穿插能修改資料結構的操作
- 內部類可以訪問外部類的例項變數
- 從技術角度看,為了和Iterator的結構保持一致,應該在兩種情況下丟擲異常(呼叫remove和陣列索引為負),但我們只在foreach中使用迭代器遍歷集合元素,所以不存在這些情況
- foreach效果同for迴圈,但用例無需知道實現細節,即資料的表示方法是陣列(陣列實現了棧內容表示),對於所有類似於集合的資料型別的實現,該特性至關重要。(改變資料表示方法而無需改變用例程式碼)
- 關注演算法1.1(P89),其幾乎達到集合類資料型別實現的最佳效能,即每項操作的用時和集合大小無關(但某些pop會調整陣列大小,該項操作耗時和棧大小成正比),空間需求不超過集合大小乘以一個常數
- 演算法1.1(泛型的可迭代的stack的API的實現)是集合類資料型別實現的模板
連結串列
- 一種基礎資料結構,一種在集合類資料型別實現中表示資料的方式,構造非Java直接支援的資料結構的例子,其實現作為其他複雜資料結構的構造程式碼的模板
- 連結串列是一種遞迴的資料結構,或者為空,或者指向一個node的引用,該節點含有一個泛型的元素和一個指向另一個連結串列的引用
- node是一個抽象實體(可能含有任意型別資料,利用泛型機制),所包含的引用顯示其在構造連結串列中的作用(Node型別例項變數顯示連結串列的鏈式本質),簡潔性賦予其極大價值
節點記錄
private class Node{
Item item;
Node next;
}
- 用巢狀類定義結點這一資料型別,在需要使用Node類的類中指明引數型別tem,並將Node類標記為private,因為其不是為用例準備的
- 為了強調在組織資料時只使用了Node類,在Node類中未定義任何方法且在程式碼中直接引用了例項變數(該型別的類稱為記錄)
- 因為直接引用例項變數,所以不是抽象資料型別,可是由於在我們的實現中node和其用例程式碼會被封裝在相同的類中且無法被該類的用例訪問,in this sense,仍然屬於資料抽象
構造連結串列
- 根據遞迴定義,只需要一個node型別的變數就能表示連結串列,只要保證物件的值為null或指向另一個node物件
- 例子見圖1.3.4(P90):用連結構造一條連結串列
- 連結串列表示的是一列元素(序列),陣列也能表示一列元素,但連結串列更方便
- 指向被引用物件的箭頭來表示引用關係,連結表示對結點的引用,元素值寫在長方形中,而非1.2節中更準確的方式來表示字串物件和字元陣列
- 通過first連結訪問首節點,通過last連結訪問尾節點
表頭插入結點
- 思想:將first儲存為oldfirst,然後加入新的first,賦值並建立指向oldfirst的連結
- 執行時間與連結串列長度無關
Node oldfirst = first
first = new Node()
first.item=?
first.next = oldfirst
表頭刪除結點
- Node first = first.next() %和之前一樣,該操作只含有一條賦值語句
- 一般希望在賦值之前得到該元素的值,因為一旦改變first,則之前其所指向的結點物件稱為孤兒物件,物件所佔記憶體則被垃圾回收
- 執行時間與連結串列長度無關
表尾插入結點
- 需要一個指向連結串列最後一個節點的連結,修改為指向一個新結點、
- 不能在連結串列中草率地決定維護一個額外的連結,因為每個連結串列操作都要檢查是否要修改該變數(以及作出相應修改)的程式碼,如刪除首節點的程式碼可能會改變指向尾節點的引用,如連結串列只有一個節點(既是首節點又是尾節點)或者空連結串列(使用空連結)
Node oldlast = last
last = new Node();
last.item = ?
oldlast.next = last;
其他位置的插入和刪除
若要刪除尾節點,則要尋找前一個節點,將該節點的連結改為null,若為單向連結串列,則要遍歷整條連結串列來找到next域為last的節點,但所需時間與連結串列長度成正比,考慮使用雙向連結串列(具體見練習)
遍歷
訪問連結串列中所有元素的方式:
for(Node x = first ; x !=null;x = x.next){ //first指向連結串列首節點
//deal with x.item
}
該方式同迭代遍歷陣列中的所有元素的標準方式一樣自然,在我們的實現中,它是迭代器使用的基本方式
棧的實現
重新回顧stack的API,定義連結串列資料結構,用連結串列實現,push和pop類比新增在表頭和從表頭刪除,同樣達到最優設計目標:
- 處理任何型別資料
- 所需空間與集合大小成正比
- 操作與集合大小無關
- 具體實現見P94
- 演算法的實現程式碼很簡單,但資料結構的性質並不簡單(資料結構定義和演算法實現的相互作用)
佇列的實現
- 基於連結串列資料結構設計佇列(表示為一條從最早插入元素到最近插入元素的連結串列)
- queue同stack使用相同資料結構連結串列,但實現不同新增和刪除元素的演算法(FIFO和FILO的區別所在)
- 三件套(API及實現和用例)見P96
- 結構化儲存資料集時,連結串列play an important role(組織程式和資料的結構)
- 通過抽象資料型別的使用,將連結串列處理的程式碼封裝在類中
揹包的實現
- 簡單修改stackAPI實現即可,即也通過維護一條連結串列來實現bag的API
- 三件套見P98
- 加粗部分程式碼(P98)使得可以通過遍歷連結串列使stack,queue和bag成為可迭代的
- 巢狀類維護一個例項變數current來記錄連結串列的當前節點
- 迭代器會遍歷連結串列並將當前節點儲存在current變數中,揹包同stack和queue區別在於連結串列訪問順序不同
總結
- 本章所學習的SQB實現所提供的抽象使我們能編寫簡潔用例來操作物件的集合(要深入理解這些抽象資料型別)
- 研究演算法和資料結構的開始:1)基礎資料結構的定位 2)展示資料結構和演算法的關係 3)未來演算法實現中的抽象資料型別需要能夠支援對物件集合的強大操作,本章實現是起點
- 兩種表示集合物件的方式:陣列(Java內建)和連結串列(自己用Java標準方法實現),以後所學的資料結構將以多種方式歸併並拓展基礎資料結構
- 二叉樹為含有多個連結的資料結構,每個節點含有兩個連結
- 複合型資料結構,如用揹包儲存棧,佇列儲存陣列,如“圖”用陣列的揹包表示
- 用上述方式很容易定義任意複雜的資料結構——》研究資料結構以控制複雜度
- 本章所描述資料結構和演算法的方式是全書的原型,在研究一個新應用領域時,按如下步驟使用資料抽象解決問題:
- 定義API
- 開發用例程式碼
- 描述資料結構(一組值的表示),並在API所對應的抽象資料型別的實現中定義例項變數
- 描述演算法(實現一組操作的方式),根據演算法實現例項方法
- 分析效能特點
答疑
- 泛型的替代方案:為每種型別的資料都實現一個不同的集合資料型別或者構造一個object物件的棧並在pop中將得到的物件轉化為所需要的資料型別(但型別不匹配錯誤只能在執行時發現,使用泛型則能在編譯時發現)
- 不需要泛型陣列原因:關注型別擦除和公變陣列問題
- 建立一個字串棧的陣列的方法:使用型別轉換,如
Stack[] a = (Stack[]) new Stack[N]
- 這段型別轉換的用例程式碼不同於1.3.2.2節所示的程式碼(1.3.2.2使用的是Object而非Stack),因為在使用泛型時,Java在編譯時檢查型別的安全性,但執行時拋棄所有這些資訊,則執行時語句右側等價於Stack或者只剩下Stack[],所以要將它們轉化為 Stack[]
- 棧為空呼叫pop方法應該丟擲執行時異常來定位錯誤資訊(null check)
- ResizingArrayStack是控制一些抽象資料型別記憶體使用的模板(不用連結串列結構,而用調整陣列大小方式)
- Node宣告為私有的巢狀類後,將其方法和例項變數的訪問限制在外部類中,從而無需將內部類的例項變數宣告為private
- 非靜態的巢狀類也稱為內部類,從技術上看NODE類是內部類,儘管非泛型的類也可以是靜態的
- Stack 分隔外部類和內部類)
- Java中有一個Stack類,但其添加了額外的方法,可被當做佇列使用,因此其API是寬介面的一個典型例子(應避免此種情況)——我們使用某資料型別,不僅僅為了獲得所需要的操作,也要準確地指定所需操作,從而避免意外操作
- 我們的實現以及Java的棧和queue庫允許插入null值
- 若在迭代中呼叫push(),Stack的迭代器應該立刻丟擲異常(快速出錯的設計理念——》一個快速出錯的迭代器)
- 陣列儘管沒有實現Iterable介面,但能使用foreach語句(String沒實現,所以不能使用)
- main方法的args為命令列引數
- 為什麼不實現一個單獨的collection資料型別並實現我們所需要的各種方法,這樣就能在一個類中實現所有這些方法然後廣泛應用?
- 又是一個寬介面的例子,Java在ArrayList中實現了類似設計,但要避免這樣做,因為無法保證高效實現所有這些方法,且API作為設計高效演算法和資料結構的起點,設計簡單的介面顯示比複雜的更簡單,另一方面要限制用例程式碼行為,使其清晰易懂(FIFO用佇列,FILO用棧)