棧和隊列 - Algorithms, Part I, week 2 STACKS AND QUEUES

前言

上一篇:算法分析
下一篇:基本排序html

本篇內容主要是棧,隊列 (和包)的基本數據類型和數據結構
文章裏頭全部的對數函數都是以 2 爲底
關於性能分析,可能仍是須要一些數學知識,有時間能夠回一下
在不少應用中,咱們須要維護多個對象的集合,而對這個集合的操做也很簡單java

基本數據類型

  • 對象的集合
  • 操做:git

    • insert -- 向集合中添加新的對象
    • remove -- 去掉集合中的某個元素
    • iterate -- 遍歷集合中的元素並對他們執行某種操做
    • test if empty -- 檢查集合是否爲空
  • 作插入和刪除操做時咱們要明確以什麼樣的形式去添加元素,或咱們要刪除集合中的哪一個元素。

處理這類問題有兩個經典的基礎數據結構:棧(stack) 和隊列(queue)程序員

圖片描述

二者的區別在於去除元素的方式:github

  • 棧:去除最近加入的元素,遵循後進先出原則(LIFO: last in first out)。算法

    • 插入元素對應的術語是入棧 -- push;去掉最近加入的元素叫出棧 -- pop
  • 隊列:去除最開始加入的元素,遵循先進先出原則(FIFO: first in first out)。編程

    • 關注最開始加入隊列的元素,爲了和棧的操做區分,隊列加入元素的操做叫作入隊 -- enqueue;去除元素的操做叫出隊 -- dequeue

此篇隱含的主題是模塊式編程,也是平時開發須要遵照的原則segmentfault

模塊化編程

這一原則的思想是將接口與實現徹底分離。好比咱們精肯定義了一些數據類型和數據結構(如棧,隊列等),咱們想要的是把實現這些數據結構的細節徹底與客戶端分離。客戶端能夠選擇數據結構不一樣的實現方式,可是客戶端代碼只能執行基本操做。數組

實現的部分沒法知道客戶端需求的細節,它所要作的只是實現這些操做,這樣,不少不一樣的客戶端均可以使用同一個實現,這使得咱們可以用模塊式可複用的算法與數據結構庫來構建更復雜的算法和數據結構,並在必要的時候更關注算法的效率。瀏覽器

Separate client and implementation via API.

圖片描述

API:描述數據類型特徵的操做
Client:使用API​​操做的客戶端程序。
Implementation:實現API操做的代碼。

下面具體看下這兩種數據結構的實現

棧 Stack

棧 API

假設咱們有一個字符串集合,咱們想要實現字符串集合的儲存,按期取出而且返回最後加入的字符串,並檢查集合是否爲空。咱們須要先寫一個客戶端而後再看它的實現。

字符串數據類型的棧

圖片描述

性能要求:全部操做都花費常數時間
客戶端:從標準輸入讀取逆序的字符串序列

測試客戶端

import edu.princeton.cs.algs4.StdIn;
import edu.princeton.cs.algs4.StdOut;

public static void main(String[] args)
{
    StackOfStrings stack = new StackOfStrings();
    while (!StdIn.isEmpty())
    {
    //從標準輸入獲取一些字符串
    String s = StdIn.readString();
    //若是字符串爲"-",則客戶端將棧頂的字符串出棧,並打印出棧的字符串
    if (s.equals("-")) StdOut.print(stack.pop());
    //不然將字符串入棧到棧頂
    else stack.push(s);
    }
}

客戶端輸入輸出:

圖片描述

棧的實現:鏈表

鏈表(linked-list)鏈接待添加...

咱們想保存一個有節點組成的,用來儲存字符串的鏈表。節點包含指向鏈表中下一個元素的引用(first).

維持指針 first 指向鏈表中的第一個節點

  • Push:入棧,在鏈表頭插入一個新的節點
  • Pop:出棧,去掉鏈表頭處第一個節點

圖片描述

Java 實現

public class LinkedStackOfStrings
{
     //棧中惟一的實例變量是鏈表中的第一個節點的引用
     private Node first = null;
     
     //內部類,節點對象,構成鏈表中的元素,由一個字符串和指向另外一個節點的引用組成
     private class Node
     {
         private String item;
         private Node next;
     }

     public boolean isEmpty()
     { return first == null; }
     
     //
     public void push(String item)
     {
         //將指向鏈表頭的指針先保存
         Node oldfirst = first;
         //建立新節點:咱們將要插入表頭的節點
         first = new Node();
         first.item = item;
         //實例變量的next指針指向鏈表oldfirst元素,如今變成鏈表的第二個元素
         first.next = oldfirst;
     }
     
     //出棧
     public String pop()
     {
         //將鏈表中的第一個元素儲存在標量 item 中
         String item = first.item;
         //去掉第一個節點:將原先指向第一個元素的指針指向下一個元素,而後第一個節點就等着被垃圾回收處理
         first = first.next;
         //返回鏈表中原先保存的元素
         return item;
     }
}

圖示

出棧

Pop

入棧

Push

性能分析

經過分析提供給客戶算法和數據結構的性能信息,評估這個實現對以不一樣客戶端程序的資源使用量

Proposition 在最壞的狀況下,每一個操做只須要消耗常數時間(沒有循環)。
Proposition 具備n個元素的棧使用 ~40n 個字節內存
(沒有考慮字符串自己的內存,由於這些空間的開銷在客戶端上)

圖片描述

棧的實現:數組

棧用鏈表是實現花費常數的時間,可是棧還有更快的實現

另外一種實現棧的 natural way 是使用數組儲存棧上的元素
將棧中的N個元素保存在數組中,索引爲 n,n 對應的數組位置即爲棧頂的位置,即下一個元素加入的地方

  • 使用數組 s[] 在棧上存儲n個元素。
  • push():在 s[n] 處添加新元素。
  • pop():從 s[n-1] 中刪除元素。

在改進前使用數組的一個缺點是必須聲明數組的大小,因此棧有肯定的容量。若是棧上的元素個數比棧的容量多,咱們就必須處理這個問題(調整數組)

圖片描述

Java 實現

public class FixedCapacityStackOfStrings
{
     private String[] s;
     //n 爲棧的大小,棧中下一個開放位置,也爲下一個元素的索引
     private int n = 0;
     
    //int capacity:看如下說明
     public FixedCapacityStackOfStrings(int capacity)
     { s = new String[capacity]; }
    
     public boolean isEmpty()
     { return n == 0; }
    
     public void push(String item)
     { 
         //將元素放在 n 索引的位置,而後 n+1
         s[n++] = item; 
     }
    
     public String pop()
     { 
         //而後返回數組n-1的元素
         return s[--n]; 
     }
}

int capacity: 在構造函數中加入了容量的參數,破壞了API,須要客戶端提供棧的容量。不過實際上咱們不會這麼作,由於大多數狀況下,客戶端也沒法肯定須要多大棧,並且客戶端也可能須要同時維護不少棧,這些棧又不一樣時間到達最大容量,同時還有其餘因素的影響。這裏只是爲了簡化。在調整數組中會處理可變容量的問題,避免溢出

對於兩種實現的思考

上述的實現中咱們暫時沒有處理的問題:

Overflow and underflow

  • Underflow :客戶端從空棧中出棧咱們沒有拋出異常
  • Overflow :使用數組實現,當客戶端入棧超過容量發生棧溢出的問題

Null item:客戶端是否能向數據結構中插入空元素,上邊咱們是容許的

Duplicate items: 客戶端是否能向數據結構中重複入棧同一個元素,上邊咱們是容許的

Loitering 對象遊離:即在棧的數組中,咱們有一個對象的引用,但是咱們已經再也不使用這個引用了

圖片描述

數組中當咱們減少 n 時,在數組中仍然有咱們已經出棧的對象的指針,儘管咱們再也不使用它,可是Java系統並不知道。因此爲了不這個問題,有效地利用內存,最好將去除元素對應的項設爲 null,這樣就不會剩下舊元素的引用指針,接下來就等着垃圾回收機制去回收這些內存。這個問題比較細節化,可是卻很重要。

public String pop()
{
 String item = s[--n];
 s[n] = null;
 return item;
}

調整數組

以前棧的基本數組實現須要客戶端提供棧的最大容量,如今咱們來看解決這個問題的技術。

待解決的問題:創建一個可以增加或者縮短到任意大小的棧。
調整大小是一個挑戰,並且要經過某種方式確保它不會頻繁地發生。

怎樣加長數組

反覆增倍法 (repeated doubling):當數組被填滿時,創建一個大小翻倍的新數組,而後將全部的元素複製過去。這樣咱們就不會那麼頻繁地建立新數組。

Java 實現

public class ResizingArrayStackOfStrings {

    private String[] s;

    //n 爲棧的大小,棧中下一個開放位置,也爲下一個元素的索引
    private int n = 0;

    public ResizingArrayStackOfStrings(){
        s = new String[2];
    }

    public boolean isEmpty() {
        return n == 0;
    }

    /**
     * 從大小爲1的數組開始,若是發現數組被填滿,那麼就在插入元素以前,將數組長度調整爲原來的2倍
     * @param item
     */
    public void push(String item) {
        if (n == s.length) resize(2 * s.length);
        s[n++] = item;
    }

    /**
     * 調整數組方法
     * 建立具備目標容量的新數組,而後把當前棧複製到新棧的前一半
     * 而後從新設置和返回實例標量
     * @param capacity
     */
    private void resize(int capacity) {
        System.out.println("resize when insert item "+ (n+1));
        String[] copy = new String[capacity];
        for (int i = 0; i < n; i++)
            copy[i] = s[i];
        s = copy;
    }

    public String pop() {
        return s[--n];
    }
}

性能分析

往棧中插入 n 個元素的時間複雜度是線性相近的,即與 n 成正比 ~n

Q. 假設咱們從一個空的棧開始,咱們執行 n 次入棧, 那麼咱們的 **resize()** 方法被調用了幾回?
A. 是以 2 爲底的對數次。由於咱們只有在棧的大小等於 2 的冪函數的時候,即 2^1,2^2,2^3 ... 2^i 的時候纔會調用 resize().
   在 1 到 n 之間,符合 2 的冪的數字(如 2,4,8,16...) 一共有 logn 個,其中 log 覺得 2 爲底.

咱們在插入 2^i 個元素時,須要複製數組 logn 次,須要花費訪問數組 n + (2 + 4 + 8 + ... + m) ~3n 的時間,其中 m = 2^logn = n

  • n : 不管數組翻不翻倍,對於每一個新元素,入棧須要 Θ(1) 時間。所以,對於 n 個元素,它須要 Θ(n) 時間。即忽略常數項,插入 n 個 就有 n 次入棧的操做,就訪問 n 次數組
  • (2 + 4 + 8 + ... + n):

    • 若是 n = 2^i 個元素入棧,須要數組翻倍 lgn 次。
      從技術上講,總和(2 + 4 + 8 + .. + m)是具備 logN 個元素的幾何級數
      而後:(2 + 4 + 8 + .. + m)= 2 *(2 ^ log N - 1) = 2(N - 1) = 2N - 2 ~2N
  • => N +(2 + 4 + 8 + .. + N)= N + 2N - 2 = 3N - 2 ~3N

舉個栗子~,若是咱們往棧中插入 8 (2^3) 個元素,那麼咱們必須將數組翻倍 lg8 次,即3次。所以,8個元素入棧的開銷爲 8 +(2 + 4 + 8)= 22 次 ≈ 24 次

再舉個栗子~,若是插入 16 (2^4) 個元素,那麼咱們必須將數組翻倍 lg16 次,即4次。所以,16個元素入棧的開銷爲 16 +(2 + 4 + 8 + 16)= 46 次 ≈ 48 次

圖片描述

或者粗略想象一下,若是咱們計算一下開銷,插入前 n (n = 2^i) 個元素,是對 2 的冪從1到N求和。 這樣,總的開銷大約是3N。先要訪問數組一次,對於複製要訪問兩次。因此,要插入元素,大約須要訪問數組三次。
下邊的圖是觀察時間開銷的另外一種方式,表示了入棧操做須要訪問數組的次數。

圖片描述

每次遇到2的冪,須要進行斜線上的數組訪問時間,可是從宏觀上來看,是將那些元素入棧上花去了紅色直線那些時間 這叫作平攤分析。考慮開銷時將總的開銷平均給全部的操做。關於平攤分析就再也不解釋了,有興趣能夠自行了解...

怎樣縮小數組

若是數組翻倍屢次後,又有屢次出棧,那麼若是不調整數組大小,數組可能會變得太空。那數組在什麼狀況下去縮小,縮小多少才合適?
咱們也許這麼考慮,當數組滿了的時候將容量翻倍,那麼當它只有一半滿的時候,將容量縮減一半。可是這樣並不合理,由於有一種現象叫作thrashing:即客戶端恰好反覆交替入棧出棧入棧出棧...

若是數組滿了就會反覆翻倍減半翻倍減半,而且每一個操做都會新建數組,都要花掉正比與N的時間,這樣就會致使thrashing頻繁發生要花費平方時間,這不是咱們想要的。

圖片描述

有效的解決方案是直到數組變爲 1/4 滿的時候纔將容量減半
咱們只要測試數組是否爲 1/4 滿,若是是,則調整大小使其爲 1/2 滿。

不變式:數組老是介於25% 滿與全滿之間

public String pop()
 {
     String item = s[--n];
     //解決以前說的對象引用遊離問題
     s[n] = null;
     if (n > 0 && n == s.length/4) resize(s.length/2);
     return item;
 }

這樣的好處:

  • 由於是半滿的,既能夠插入向棧插入元素,又能夠從棧刪除元素,而不須要再次進行調整數組大小的操做直到數組全滿,或者再次1/4滿。
  • 每次調整大小時, 開銷已經在平攤給了每次入棧和出棧

下圖展現了上邊測試寫的客戶端例子中數組上的操做

圖片描述

能夠看到在開始時,數組大小從1倍增到2又到4,但一旦到8,數組的大小則維持一段時間不變,直到數組中只有2個元素時才縮小到4,等等。

算法分析

運行時間

數組調整大小並不常常發生,但這是實現棧API的一種頗有效的方式,客戶端不須要提供棧的最大容量,但依然保證了咱們使用的內存大小老是棧中實際元素個數的常數倍,因此分析說明對於任意的操做序列,每一個操做的平均運行時間與常數成正比。

這裏,存在最壞狀況(worst case)。當棧容量翻倍時,須要正比於N的時間,因此性能不如咱們想要的那麼好,可是優點在於進行入棧出棧操做時很是快,入棧只須要訪問數組並移動棧頂索引。對於大多數操做都很高效的。對於衆多的客戶端這是個頗有效的權衡。

圖片描述

內存使用

棧的內存用量實際上比鏈表使用更少的內存。

給出的命題. 一個 ResizingArrayStackOfStrings 內存用量在 ~8n bytes~32n bytes 之間,取決於數組有多滿。

只看 「private String[] s;」

圖片描述

・~ 8n 當數組滿時. -- 棧的實際長度 = n,因此內存佔用是 8 乘以棧不爲空的元素個數
・~ 32n 當數組 1/4 滿時. -- 棧的實際長度 = 4n,因此內存佔用是 8 乘以(4 乘以棧的有效元素個數)

這裏只是計算了 Java中數組佔用的空間。一樣地,這個分析只針對棧自己 而不包括客戶端上的字符串。

調整數組實現VS鏈表實現

圖片描述

那麼使用可調整大小的數組與鏈表之間如何取捨呢?

  • 這是兩種 API相同的不一樣的實現,客戶端能夠互換使用。

哪一個更好呢?

  • 不少情形中,咱們會有同一API的多種實現。你須要根據客戶端的性質選擇合適的實現。

Linked-list implementation.

  • 對於鏈表,每一個操做最壞狀況下須要常數時間,這是有保障的
  • 可是爲了處理連接,咱們須要一些額外的時間和空間。因此鏈表實現會慢一些

Resizing-array implementation.

  • 可調大小的數組實現有很好的分攤時間,因此整個過程總的平均效率不錯
  • 浪費更少的空間,對於每一個操做也許有更快的實現

因此對於一些客戶端,也許會有區別。如下這樣的情形你不會想用可調大小數組實現:你有一架飛機進場等待降落,你不想系統忽然間不能高效運轉;或者互聯網上的一個路由器,數據包以很高的速度涌進來,你不想由於某個操做忽然變得很慢而丟失一些數據。

客戶端就能夠權衡,若是想要得到保證每一個操做可以很快完成,就使用鏈表實現
若是不須要保證每一個操做,只是關心總的時間,可能就是用可調大小數組實現。由於總的時間會小得多,若是不是最壞狀況下單個操做很是快。
儘管只有這些簡單的數據結構,咱們都須要作很重要的權衡,在不少實際情形中真的會產生影響。

隊列 Queue

接下來咱們簡要考慮一下使用相同基本底層數據結構的隊列的實現。

隊列的API

這是字符串隊列對應的API,實際上和棧的API是相同的,只是名字不同而已...
入棧換成了入隊(enqueue),出棧換成了出隊(dequeue)。語義是不一樣的。
入隊操做向隊尾添加元素,而出隊操做從隊首移除元素。
就像你排隊買票同樣,入隊時你排在隊列的最後,在隊列裏待的最久的人是下一個離開隊列的人。

數據結構的性能要求:全部操做都花費常數時間。

圖片描述

鏈表實現

隊列的鏈表表示中,咱們須要維護兩個指針引用。一個是鏈表中的第一個元素,另外一個是鏈表最後一個元素。

圖片描述

插入的時候咱們在鏈表末端添加元素;移除元素的時候不變,依然從鏈表頭取出元素。

  • 出隊 dequeue

那麼這就是出隊操做的實現,和棧的出棧操做是同樣的。保存元素,前進指針指向下一個節點,這樣就刪除了第一個節點,而後返回該元素。

圖片描述

  • 入隊 enqueue

入隊操做時,向鏈表添加新節點。咱們要把它放在鏈表末端,這樣它就是最後一個出隊的元素。
首先要作的是保存指向最後一個節點的指針,由於咱們須要將它指向下一個節點的指針從null變爲新的節點。
而後給鏈表末端建立新的節點並對其屬性賦值,將舊的指針從null變爲指向新節點。

圖片描述

實際上如今指針操做僅限於如棧和隊列這樣的少數幾個實現以及一些其餘的基本數據結構了。如今不少操做鏈表的通用程序都封裝在了這樣的基本數據類型裏。

Java 實現

圖片描述

這裏處理了當隊列爲空時的特殊狀況。爲了保證去除最後一個元素後隊列是空的,咱們將last設爲null,還保證first和last始終都是符合咱們預想。
完整代碼: Queue.java 這裏用到了泛型和迭代器的實現

數組實現

用可調大小的數組實現並不難,但絕對是一個棘手的編程練習。

  • 咱們維護兩個指針,分別指向隊列中的第一個元素和隊尾,即下一個元素要加入的地方。
  • 對於入隊操做在 tail 指向的地方加入新元素
  • 出隊操做移除 head 指向的元素

棘手的地方是一旦指針的位置超過了數組的容量,必須重置指針回到0,這裏須要多寫一些代碼,並且和棧同樣實現數據結構的時候你須要加上調整容量的方法。

圖片描述

完整代碼:ResizingArrayQueue.java

額外補充

兩個棧實現隊列的數據結構
實現具備兩個棧的隊列,以便每一個隊列操做都是棧操做的常量次數(平攤次數)。
提示:
1.若是將元素推入棧而後所有出棧,它們將以相反的順序出現。若是你重複這個過程,它們如今又恢復了正常的隊列順序。
2.爲了不不停的出棧入棧,能夠加某個斷定條件,好比當 dequeue 棧爲空時,將 enqueue 棧的元素出棧到 dequeue 棧,而後最後從dequeue 棧出棧,也就實現了出隊的操做。直到 dequeue 棧的元素都出棧了,再次觸發出隊操做時,再從 enqueue 棧導入數據重複上邊的過程

實現參考:QueueWithTwoStacks.java

泛型 -- Generic

接下來咱們要處理的是前面實現裏另外一個根本性的缺陷。前面的實現只適用於字符串,若是想要實現其餘類型數據的隊列和棧怎麼 StackOfURLs, StackOfInts... 嗯。。。
這個問題就涉及泛型的話題了。

泛型的引出

實際上不少編程環境中這一點都是不得不考慮的。

  • 第一種方法:咱們對每個數據類型都實現一個單獨的棧

    這太雞肋了,咱們要把代碼複製到須要實現棧的地方,而後把數據類型改爲這個型那個型,那麼若是咱們要處理上百個不一樣的數據類型,咱們就得有上百個不一樣的實現,想一想就很心累。

    不幸的是Java 推出 1.5 版本前就是陷在這種模式裏,而且很是多的編程語言都沒法擺脫這樣的模式。因此咱們須要採用一種現代模式,不用給每一個類型的數據都分別搞實現。

  • 第二種方法:咱們對 Object 類實現數據結構
    有一個普遍採用的捷徑是使用強制類型轉換對不一樣的數據類型重用代碼。Java中全部的類都是 Object 的子類,當客戶端使用時,就將結果轉換爲對應的類型。可是這種解決方案並不使人滿意。
    這個例子中咱們有兩個棧:蘋果的棧和桔子的棧。接下來,當從蘋果棧出棧的時候須要客戶端將出棧元素強制轉換爲蘋果類型,這樣類型檢查系統纔不會報錯。

圖片描述

這樣作的問題在於:

  • 必須客戶端完成強制類型轉換,經過編譯檢查。
  • 存在一個隱患,若是類型不匹配,會發生運行時錯誤。

第三種方法:使用泛型

這種方法中客戶端程序不須要強制類型轉換。在編譯時就能發現類型不匹配的錯誤,而不是在運行時。

這個使用泛型的例子中棧的類型有一個類型參數(Apple),在代碼裏這個尖括號中。

圖片描述

若是咱們有一個蘋果棧,而且試圖入棧一個桔子,咱們在編譯時就會提示錯誤,由於聲明中那個棧只包含蘋果,桔子禁止入內。

優秀的模塊化編程的指導原則就是咱們應當歡迎編譯時錯誤,避免運行時錯誤。

由於若是咱們能在編譯時檢測到錯誤,咱們給客戶交付產品或者部署對一個API的實現時,就有把握對於任何客戶端都是沒問題的。
有些運行時纔會出現的錯誤可能在某些客戶端的開發中幾年以後纔出現,若是這樣,就必須部署咱們的軟件,這對每一個人都是很困難的。

實際上優秀的泛型實現並不難。只須要把每處使用的字符串替換爲泛型類型名稱。

鏈表棧的泛型實現

如這裏的代碼所示,左邊是咱們使用鏈表實現的字符串棧,右邊是泛型實現。

圖片描述

左邊每處用到字符串類型的地方咱們換成了item。在最上面類聲明的地方咱們用尖括號聲明 item 是咱們要用的泛型類型,這樣的實現很是直截了當,而且出色地解決了不一樣的數據類型單獨實現的問題。

數組棧的泛型實現

基於數組的實現,這種方法無論用。目前不少編程語言這方面都有問題,而對Java尤爲是個難題。咱們想作的是用泛型名稱 item 直接聲明一個新的數組。好比這樣:

public class FixedCapacityStack<Item>
{
 private Item[] s;
 private int n = 0;

     public FixedCapacityStack(int capacity)
     //看這裏看這裏像這樣,可是實際咱們在java當中我卻不能這樣方便的實現
     { s = new Item[capacity]; }

     public boolean isEmpty()
     { return n == 0; }

     public void push(Item item)
     { s[n++] = item; }

     public Item pop()
     { return s[--n]; }
}

若有備註的這行所示。其餘部分都和以前的方法沒區別。不幸的是,Java不容許建立泛型數組。對於這個問題有各類技術方面的緣由,在網上關於這個問題你能看到大量的爭論,這個不在咱們討論的範圍以內。關於協變的內容,能夠自行了解,嗯。。。我一會也去了解了解...

這裏,要行得通咱們須要加入強制類型轉換
咱們建立 Object 數組,而後將類型轉換爲 item 數組。教授的觀點是優秀的代碼應該不用強制類型轉換, 要儘可能避免強制類型轉換,由於它確實在咱們的實現中留下了隱患。但這個狀況中咱們必須加入這個強制類型轉換。

圖片描述

當咱們編譯這個程序的時候,Java會發出警告信息說咱們在使用未經檢查或者不安全的操做,詳細信息須要使用-Xlint=unchecked 參數從新編譯。

圖片描述

咱們加上這個參數從新編譯以後顯示你在代碼中加入了一個未經檢查的強制類型轉換,而後 java 就警告你不該該加入未經檢查的強制類型轉換。可是這麼作並非咱們的錯,由於你不容許咱們聲明泛型數組,咱們纔不得不這麼作。收到這個警告信息請不要認爲是你的代碼中有什麼問題。

自動裝箱 (Autoboxing) 與拆箱 (Unboxing)

接下來,是個跟Java有關的細節問題,
Q. 對基本數據類型,咱們怎樣使用泛型?
咱們用的泛型類型是針對 Object 及其子類的。前面講過,是從 Object 數組強制類型轉換來的。爲了處理基本類型,咱們須要使用Java的包裝對象類型。

如大寫的 Integer 是整型的包裝類型等等。另外,有個過程叫自動打包,自動轉換基本類型與包裝類型,因此處理基本類型這個問題,基本上都是在後臺完成的.

Autoboxing:基本數據類型到包裝類型的自動轉換。
unboxing:包裝器類型到基本數據類型的自動轉換。

綜上所述,咱們能定義適用於任何數據類型的泛型棧的API,並且咱們有基於鏈表和數組兩種實現。咱們講過的使用可變大小數組或者鏈表,對於任何數據類型都有很是好的性能。

額外補充

在 Java 6, 必須在變量聲明(左側)和構造函數調用(右側)中指定具體類型。從Java 7 開始,可使用菱形運算符:
Stack<Integer> stack = new Stack<>();

Q. 爲何Java須要強制轉換(或反射)?
簡短的回答: 向後兼容性。
詳細地回答:須要瞭解類型擦除協變數組

Q. 當我嘗試建立泛型數組時,爲何會出現「沒法建立泛型數組」錯誤?
public class ResizingArrayStack<Item> {Item[] a = new Item[1];}

A. 根本緣由是Java中的數組是協變的,但泛型不是。換句話說,String [] 是 Object [] 的子類型,但 Stack <String> 不是 Stack <Object> 的子類型。

Q. 那麼,爲何數組是協變的呢?

A. 許多程序員(和編程語言理論家)認爲協變數組是 Java 類型系統中的一個嚴重缺陷:它們會產生沒必要要的運行時性能開銷(例如,參見ArrayStoreException)而且可能致使細微的 BUG。在Java中引入了協變數組,以免Java在其設計中最初沒有包含泛型的問題,例如,實現Arrays.sort(Comparable [])並使用 String [] 類型的輸入數組進行調用。

Q. 我能夠建立並返回參數化類型的新數組,例如,爲泛型隊列實現 toArray() 方法嗎?

A. 不容易。若是客戶端將所需具體類型的對象傳遞給 toArray(),則可使用反射來執行此操做。這是 Java 的 Collection Framework 採用的(笨拙)方法。

迭代器 Iterators

Java還提供了另外一種可以使客戶端代碼保持優雅緊湊,絕對值得添加到咱們的基本數據類型的特性 -- 迭代器。

遍歷

對於遍歷功能,大多數客戶端想作的只是遍歷集合中的元素,咱們考慮的任何內部表示,這對於客戶端是不相關的,他們並不關心集合的內部實現。也就是說咱們容許客戶端遍歷集合中的元素,但沒必要讓客戶端知道咱們是用數組仍是鏈表。

Java提供了一個解決方式,就是實現遍歷機制,而後使用 foreach.

Foreach loop

咱們自找麻煩地要讓咱們的數據類型添加迭代器是由於,若是咱們的數據結構是可遍歷的,在Java中咱們就可使用很是緊湊優雅的客戶端代碼,即所謂的for-each語句來進行集合的遍歷。
使用了迭代器後,如下兩種寫法均可以不考慮底層的內部實現而遍歷某個集合,兩種方法是等價的:

Stack<String> stack;
...

// "foreach" loop
for (String s : stack)
    StdOut.println(s);
    
...
// 與上邊的方法等價
Iterator<String> i = stack.iterator();
while (i.hasNext())
{
    String s = i.next();
    StdOut.println(s);
}

因此若是咱們有一個棧 stack 能夠寫(for String s: stack) 表示對棧中每一個字符串,執行打印輸出。
咱們也能夠寫成下邊這種完整形式的代碼,但不會有人這麼作,由於它和上邊的簡寫形式是等價的。
不使用迭代器的話要實現遍歷客戶端代碼中就要執行很是多沒必要要的入棧出棧操做。因此這是可以讓遍歷數據結構中的元素的客戶端代碼變得這麼緊湊的關鍵所在。

要使用戶定義的集合支持 foreach 循環:

  • 數據類型必須具備名爲 iterator() 的方法
  • iterator() 方法返回一個對象,這個對象具備兩個核心方法:

    • hasNext() 方法, 當再也不遍歷到任何元素時,返回false
    • next() 方法, 返回集合中的下一個元素

迭代器

爲了支持 foreach 循環,Java 提供了兩個接口。

  • Iterator 接口:有 next() 和 hasNext() 方法。
  • Iterable 接口:iterator() 方法返回 一個迭代器 Iterator
  • 二者都應該與泛型一塊兒使用

Q. 什麼是 Iterable ?
A. 在 Java 語言中 Iterable 是具備返回迭代器的方法的一種類

來源:jdk 8 java.lang.Iterable 接口

//此處T能夠隨便寫爲任意標識,常見的如T、E、K、V等形式的參數經常使用於表示泛型
//在實例化泛型類時,必須指定T的具體類型
public interface Iterable<T> {
    /**
     * Returns an iterator over elements of type {@code T}.
     *
     * @return an Iterator.
     */
    Iterator<T> iterator();
    ...
}

Q. 那麼什麼是迭代器 iterator ?
A. 迭代器是具備 hasNext() 和 next() 方法的類。

來源:jdk 8 java.util.Iterator 接口

public interface Iterator<E> {
    boolean hasNext();
    E next();
    default void remove()
    default void forEachRemaining(Consumer<? super E> action)
}

Java還容許 remove() 方法,但咱們認爲這不是一個很好的特性,它有可能成爲調試隱患,通常不用。
那麼,只要有 hasNext() 和 next() 方法就使得數據結構是可遍歷的,因此咱們要實現這兩個方法。

下面咱們要作的是看看如何使咱們的棧、隊列和後面要講到的其餘數據結構實現所謂的 Iterable(可遍歷類)接口。

實例

咱們要給咱們全部的基本數據結構提供遍歷機制。實現這個功能並不特別難,並且絕對值得投入精力。這是基於棧的代碼。

棧實現迭代器: 鏈表實現

接下來咱們要實現Iterable接口。
實現Iterable接口意味着什麼呢?這個類須要有iterator()方法返回迭代器。
什麼是迭代器呢?咱們要用一個內部類。這個例子中,命名爲 ListIterator 的內部類實現 Iterator 接口,而且是泛化(generic)的。
ListIterator 這個類主要完成的是實現 hasNext() 和 next() 方法。從名字就能清楚知道它們的語義。

  • hasNext() 在完成遍歷以後會返回 false;若是尚未完成,應該返回 true
  • next() 方法提供要遍歷的下一個元素

圖片描述

因此若是是基於鏈表的數據結構,咱們要從表頭 first 元素開始,這是處於表頭的元素.
咱們要維護迭代器中的實例變量 current 存儲當前正在遍歷的元素。咱們取出current元素,而後將 current 引用指向下一個元素,並返回以前儲存的item,也就將current 移動到了下一個元素上。
客戶端會一直測試 hasNext(),因此當current變成空指針,hasNext 返回 false 終止遍歷。
在咱們的遍歷中,咱們只須要關注實現 next() 和 hasNext()方法,使用一個局部實例變量 current 就能完成。
若是遍歷已經終止,客戶端還試圖調用 next() 或者試圖調用 remove() 時拋出異常,咱們不提供remove()方法。

完整代碼:StackImpIterator

棧實現迭代器: 數組實現

對於基於數組的實現,就更簡單了。使用迭代器咱們能控制遍歷順序,使其符合語義和數據結構。
遍歷棧時你要讓元素以出棧的順序返回,即對於數組是逆序的,那麼這種狀況下next() 就將索引減 1,返回下一個元素。
而咱們的實例變量是數組的索引。
只要該變量爲正,hasNext() 返回 true。要實現這個遍歷機制只須要寫幾行Java代碼,之後遇到涉及對象集合的基本數據類型中咱們都會用這種編程範式。

圖片描述

完整代碼:ResizingArrayStack

實際上不少客戶端並不關心咱們返回元素的順序。咱們常常作的是直接向集合中插入元素,接下來遍歷已有的元素。這樣的數據結構叫作揹包。

咱們來看看它的API。

圖片描述

順序並不重要,因此咱們想要能直接添加元素,也許還想知道集合大小。
咱們想遍歷揹包中全部的元素,這個API更簡單,功能更少,但依然提供了幾個重要的操做。
使用這個API,咱們已經看過實現了,只須要將棧的出棧操做或者隊列的出隊操做去掉,就能得到這個有用的數據結構的良好的實現

完整代碼:Bag--ListIterator Bag--ArrayIterator

棧與隊列的應用

棧的應用

棧確實很是基礎,不少計算基於它運行 由於它能實現遞歸
·Java虛擬機
·解析編譯器 (處理編譯一種編程語言或者解釋爲實際的計算)
·文字處理器中的撤消
·Web瀏覽器中的後退按鈕(當你使用網頁瀏覽器上的後退按鈕時,你去過的網頁就存儲在棧上)
·打印機的PostScript語言
·在編譯器中實現函數的方式 (當有函數被調用時,整個局部環境和返回地址入棧,以後函數返回時, 返回地址和環境變量出棧. 有個棧包含所有信息,不管函數調用的是不是它自己。棧就包含了遞歸。實際上,你總能顯式地使用棧將遞歸程序非遞歸化。)

隊列的應用

應用程序
·數據緩衝區(iPod,TiVo,聲卡...)
·異步數據傳輸(文件IO,sockets...)
·共享資源上分配請求(打印機,處理器...)
...

模擬現實世界
·交通的流量分析
·呼叫中心客戶的等待時間
·肯定在超市收銀員的數量...

前面的一些基本數據結構和實現看起來至關基礎和簡單,但立刻咱們就要涉及這些基本概念的一些很是複雜的應用。
首先要提到的是咱們實現的數據類型和數據結構每每能在 Java 庫中找到,不少編程環境都是如此。好比在 Java 庫中就能找到棧和隊列。
Java 對於集合有一個通用 API,就是所謂的List接口。具體請查看對應版本 jdk 的源碼。
List接口包含從表尾添加,從表頭移除之類的方法,並且它的實現使用的是可變大小數組。

在 Java 庫中

  • java.util.ArrayList 使用調整大小數組實現
  • java.util.LinkedList 使用雙向鏈表實現

咱們考慮的不少原則其實 Java 庫中 LinkedList 接口同樣考慮了。 可是咱們目前仍是要用咱們本身的實現。
問題在於這樣的庫通常是開發組(committee phenomenon)設計的,裏頭加入了愈來愈多的操做,API變得過寬和臃腫。
在API中擁有很是多的操做並很差,等成爲有經驗的程序員之後知道本身在作什麼了,就能夠高效地使用一些集合庫。可是經驗不足的程序員使用庫常常會遇到問題。用這樣包含了那麼多操做的,像瑞士軍刀同樣的實現,很難知道你的客戶端須要的那組操做是不是高效實現的。因此這門算法課上堅持的原則是咱們在課上實現以前,能代表你理解性能指標前,先不要使用 Java 庫.

Q. 在不運行程序的狀況下觀察下面一段將會打印什麼?

int n = 50;

Stack<Integer> stack = new Stack<Integer>();
while (n > 0) {
    stack.push(n % 2);
    n = n / 2;
}

for (int digit : stack) {
    StdOut.print(digit);
}

StdOut.println();

A. 110010

值得注意的是,若是使用的是上邊咱們本身定義的 iterator 去遍歷,那麼獲得的就是符合棧後進先出特色的答案,可是若是直接使用java.util.Stack 中的Stack,在重寫遍歷方式前,他獲得的就是先進先出的答案,這不符合棧的數據類型特色

圖片描述

這是由於 JDK (如下以 jdk8 爲例) 中的 Stack 繼承了 Vector 類

package java.util;

public class Stack<E> extends Vector<E> {
    ...
}

而 Vector 這個類中 Stack 實現的迭代器的默認的遍歷方式是FIFO的,並非棧特色的LIFO

圖片描述

狀態(已關閉,短時間不會修復):讓 JDK 中的棧去繼承 Vector 類並非一個好的設計,可是由於兼容性的問題因此不會去修復。

因此更印證了前面的提議,若是在沒有對 JDK 底層數據結構有熟悉的瞭解前,提交的做業不推薦使用 JDK 封裝的數據結構!

編程練習

Programming Assignment 2: Deques and Randomized Queues

原題地址:裏頭有具體的 API 要求和數據結構實現的性能要求。

使用泛型實現雙端隊列和隨機隊列。此做業的目標是使用數組和鏈表實現基本數據結構,並加深泛型和迭代器的理解。

Dequeue: double-ended queue or deque (發音爲「deck」) 是棧和隊列的歸納,支持從數據結構的前面或後面添加和刪除元素

性能要求:deque 的實現必須在最壞的狀況下支持每一個操做(包括構造函數)在最壞狀況下花費常量時間。一個包含 n 個元素的雙端隊列最多使用 48n + 192 個字節的內存,並使用與雙端隊列當前元素數量成比例的空間。此外,迭代器實現必須支持在最壞的狀況下每一個操做(包括構造函數)都是用常數時間。

Randomized queue: 隨機隊列相似於棧或隊列,除了從數據結構中移除的元素是隨機均勻選擇的。

性能要求:隨機隊列的實現必須支持每一個操做( 包括生成迭代器 )都花費常量的平攤時間。
也就是說,對於某些常數c,任意 m 個隨機隊列操做序列(從空隊列開始)在最壞狀況下最多 c 乘以 m 步。包含n個元素的隨機隊列使用最多48n + 192 個字節的內存。
此外,迭代器實現必須支持在最壞狀況下 next()和 hasNext()操做花費常量時間; 迭代器中的構造函數花費線性時間; 可能(而且將須要)爲每一個迭代器使用線性數量的額外內存。

Permutation: 寫一個叫 Permutation.java 的客戶端,將整數 k 做爲命令行參數; 使用StdIn.readString() 讀取標準輸入的字符串序列; 而且隨機均勻地打印它們中的 k 個。每一個元素從序列中最多打印一次。

好比在輸入端的序列是:
% more distinct.txt
A B C D E F G H I

那麼打印的時候:
% java-algs4 Permutation 3 < distinct.txt
C
G
A

而絕對不出現:
C
G
G
同個元素被屢次打印的狀況

命令行輸入:能夠假設 0≤k≤n,其中 n 是標準輸入上的字符串的個數。

性能要求:客戶端的運行時間必須與輸入的數據大小成線性關係。能夠僅使用 恆定數量的內存 加上 一個最大大小爲 n 的 Deque 或RandomizedQueue 對象的大小。(對於額外的挑戰,最多隻使用一個最大大小爲 k 的 Deque 或 RandomizedQueue 對象)

每一次做業都會有一個 bonus 的分數,就是相似獎勵的分數,本次的做業的額外加分是上分的括號內容,同時還有內存使用之類

Test 3 (bonus): check that maximum size of any or Deque or RandomizedQueue object

created is equal to k
  • filename = tale.txt, n = 138653, k = 5
  • filename = tale.txt, n = 138653, k = 50
  • filename = tale.txt, n = 138653, k = 500
  • filename = tale.txt, n = 138653, k = 5000
  • filename = tale.txt, n = 138653, k = 50000

==> passed

Test 8 (bonus): Uses at most 40n + 40 bytes of memory
==> passed

Total: 3/2 tests passed!

附錄

git 地址 100/100:在此

相關文章
相關標籤/搜索