1. 程式人生 > 其它 >Java泛型與集合類

Java泛型與集合類

目錄

Java泛型與集合類

在前面我們學習了最重要的類和物件,瞭解了面向物件程式設計的思想,注意,非常重要,面向物件是必須要深入理解和掌握的內容,不能草草結束。在本章節,我們會繼續深入瞭解,從我們的泛型開始,再到我們的資料結構,最後再開始我們的集合類學習。

走進泛型

為了統計學生成績,要求設計一個Score物件,包括課程名稱、課程號、課程成績,但是成績分為兩種,一種是以優秀、良好、合格 來作為結果,還有一種就是 60.0、75.5、92.5

這樣的數字分數,那麼現在該如何去設計這樣的一個Score類呢?現在的問題就是,成績可能是String型別,也可能是Integer型別,如何才能很好的去存可能出現的兩種型別呢?

public class Score {
    String name;
    String id;
    Object score;  //因為Object是所有型別的父類,因此既可以存放Integer也能存放String

  	public Score(String name, String id, Object score) {
        this.name = name;
        this.id = id;
        this.score = score;
    }
}

以上的方法雖然很好地解決了多種型別儲存問題,但是Object型別在編譯階段並不具有良好的型別判斷能力,很容易出現以下的情況:

public static void main(String[] args) {

    Score score = new Score("資料結構與演算法基礎", "EP074512", "優秀");  //是String型別的

    //....

    Integer number = (Integer) score.score;  //獲取成績需要進行強制型別轉換,雖然並不是一開始的型別,但是編譯不會報錯
}

//執行時出現異常!
Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
	at com.test.Main.main(Main.java:14)

使用Object型別作為引用,取值只能進行強制型別轉換,顯然無法在編譯期確定型別是否安全,專案中程式碼量非常之大,進行型別比較又會導致額外的開銷和增加程式碼量,如果不經比較就很容易出現型別轉換異常,程式碼的健壯性有所欠缺!(此方法雖然可行,但並不是最好的方法)

為了解決以上問題,JDK1.5新增了泛型,它能夠在編譯階段就檢查型別安全,大大提升開發效率。

public class Score<T> {   //將Score轉變為泛型類<T>
    String name;
    String id;
    T score;  //T為泛型,根據使用者提供的型別自動變成對應型別

    public Score(String name, String id, T score) {   //提供的score型別即為T代表的型別
        this.name = name;
        this.id = id;
        this.score = score;
    }
}
public static void main(String[] args) {
    //直接確定Score的型別是字串型別的成績
    Score<String> score = new Score<String>("資料結構與演算法基礎", "EP074512", "優秀");

    Integer i = score.score;  //編譯不通過,因為成員變數score型別被定為String!
}

泛型將資料型別的確定控制在了編譯階段,在編寫程式碼的時候就能明確泛型的型別!如果型別不符合,將無法通過編譯!

泛型本質上也是一個語法糖(並不是JVM所支援的語法,編譯後會轉成編譯器支援的語法,比如之前的foreach就是),在編譯後會被擦除,變回上面的Object型別呼叫,但是型別轉換由編譯器幫我們完成,而不是我們自己進行轉換(安全)

//反編譯後的程式碼
public static void main(String[] args) {
        Score score = new Score("資料結構與演算法基礎", "EP074512", "優秀");
        String i = (String)score.score;   //其實依然會變為強制型別轉換,但是這是由編譯器幫我們完成的
    }

像這樣在編譯後泛型的內容消失轉變為Object的情況稱為型別擦除(重要,需要完全理解),所以泛型只是為了方便我們在編譯階段確定型別的一種語法而已,並不是JVM所支援的。

綜上,泛型其實就是一種型別引數,用於指定型別。

泛型的使用

泛型類

上一節我們已經提到泛型類的定義,實際上就是普通的類多了一個型別引數,也就是在使用時需要指定具體的泛型型別。泛型的名稱一般取單個大寫字母,比如T代表Type,也就是型別的英文單詞首字母,當然也可以新增數字和其他的字元。

public class Score<T> {   //將Score轉變為泛型類<T>
    String name;
    String id;
    T score;  //T為泛型,根據使用者提供的型別自動變成對應型別

    public Score(String name, String id, T score) {   //提供的score型別即為T代表的型別
        this.name = name;
        this.id = id;
        this.score = score;
    }
}

在一個普通型別中定義泛型,泛型T稱為引數化型別,在定義泛型類的引用時,需要明確指出型別:

 Score<String> score = new Score<String>("資料結構與演算法基礎", "EP074512", "優秀");

此時類中的泛型T已經被替換為String了,在我們獲取此物件的泛型屬性時,編譯器會直接告訴我們型別:

Integer i = score.score;   //編譯不通過,因為成員變數score明確為String型別

注意,泛型只能用於物件屬性,也就是非靜態的成員變數才能使用:

static T score;   //錯誤,不能在靜態成員上定義

由此可見,泛型是隻有在建立物件後編譯器才能明確泛型型別,而靜態型別是類所具有的屬性,不足以使得編譯器完成型別推斷。

泛型無法使用基本型別,如果需要基本型別,只能使用基本型別的包裝類進行替換!

Score<double> score = new Score<double>("資料結構與演算法基礎", "EP074512", 90.5);  //編譯不通過

那麼為什麼泛型無法使用基本型別呢?回想上一節提到的型別擦除,其實就很好理解了。由於JVM沒有泛型概念,因此泛型最後還是會被編譯器編譯為Object,並採用強制型別轉換的形式進行型別匹配,而我們的基本資料型別和引用型別之間無法進行型別轉換,所以只能使用基本型別的包裝類來處理。

類的泛型方法

泛型方法的使用也很簡單,我們只需要把它當做一個未知的型別來使用即可:

public T getScore() {    //若方法的返回值型別為泛型,那麼編譯器會自動進行推斷
  return score;
}

public void setScore(T score) {   //若方法的形式引數為泛型,那麼實參只能是定義時的型別
  this.score = score;
}
Score<String> score = new Score<String>("資料結構與演算法基礎", "EP074512", "優秀");
score.setScore(10);   //編譯不通過,因為只接受String型別

同樣地,靜態方法無法直接使用類定義的泛型(注意是無法直接使用,靜態方法可以使用泛型)

自定義泛型方法

那麼如果我想在靜態方法中使用泛型呢?首先我們要明確之前為什麼無法使用泛型,因為之前我們的泛型定義是在類上的,只有明確具體的型別才能開始使用,也就是建立物件時完成型別確定,但是靜態方法不需要依附於物件,那麼只能在使用時再來確定了,所以靜態方法可以使用泛型,但是需要單獨定義:

public static <E> void test(E e){   //在方法定義前宣告泛型
  System.out.println(e);
}

同理,成員方法也能自行定義泛型,在實際使用時再進行型別確定:

public <E> void test(E e){
  System.out.println(e);
}

其實,無論是泛型類還是泛型方法,再使用時一定要能夠進行型別推斷,明確型別才行。

注意一定要區分類定義的泛型和方法前定義的泛型!

泛型引用

可以看到我們在定義一個泛型類的引用時,需要在後面指出此型別:

Score<Integer> score;  //宣告泛型為Integer型別

如果不希望指定型別,或是希望此引用型別可以引用任意泛型的Score類物件,可以使用?萬用字元,來表示自動匹配任意的可用型別:

Score<?> score;   //score可以引用任意的Score型別物件了!

那麼使用萬用字元之後,得到的泛型成員變數會是什麼型別呢?

Object o = score.getScore();   //只能變為Object

因為使用了萬用字元,編譯器就無法進行型別推斷,所以只能使用原始型別。

在學習了泛型的界限後,我們還會繼續瞭解萬用字元的使用。

泛型的界限

現在有一個新的需求,現在沒有String型別的成績了,但是成績依然可能是整數,也可能是小數,這時我們不希望使用者將泛型指定為除數字型別外的其他型別,我們就需要使用到泛型的上界定義:

public class Score<T extends Number> {   //設定泛型上界,必須是Number的子類
    private final String name;
    private final String id;
    private T score;

    public Score(String name, String id, T score) {
        this.name = name;
        this.id = id;
        this.score = score;
    }

    public T getScore() {
        return score;
    }
}

通過extends關鍵字進行上界限定,只有指定型別或指定型別的子類才能作為型別引數。

同樣的,泛型萬用字元也支援泛型的界限:

Score<? extends Number> score;  //限定為匹配Number及其子類的型別

同理,既然泛型有上限,那麼也有下限:

Score<? super Integer> score;   //限定為匹配Integer及其父類

通過super關鍵字進行下界限定,只有指定型別或指定型別的父類才能作為型別引數。

圖解如下:

那麼限定了上界後,我們再來使用這個物件的泛型成員,會變成什麼型別呢?

Score<? extends Number> score = new Score<>("資料結構與演算法基礎", "EP074512", 10);
Number o = score.getScore();    //得到的結果為上界型別

也就是說,一旦我們指定了上界後,編譯器就將範圍從原始型別Object提升到我們指定的上界Number,但是依然無法明確具體型別。思考:那如果定義下限呢?

那麼既然我們可以給泛型類限定上界,現在我們來看編譯後結果呢:

//使用javap -l 進行反編譯
public class com.test.Score<T extends java.lang.Number> {
  public com.test.Score(java.lang.String, java.lang.String, T);
    LineNumberTable:
      line 8: 0
      line 9: 4
      line 10: 9
      line 11: 14
      line 12: 19
    LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0      20     0  this   Lcom/test/Score;
          0      20     1  name   Ljava/lang/String;
          0      20     2    id   Ljava/lang/String;
          0      20     3 score   Ljava/lang/Number;   //可以看到score的型別直接被編譯為Number類

  public T getScore();
    LineNumberTable:
      line 15: 0
    LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0       5     0  this   Lcom/test/Score;
}

因此,一旦確立上限後,編譯器會自動將型別提升到上限型別。

鑽石運算子

我們發現,每次建立泛型物件都需要在前後都標明型別,但是實際上後面的型別宣告是可以去掉的,因為我們在傳入引數時或定義泛型類的引用時,就已經明確了型別,因此JDK1.7提供了鑽石運算子來簡化程式碼:

Score<Integer> score = new Score<Integer>("資料結構與演算法基礎", "EP074512", 10);  //1.7之前

Score<Integer> score = new Score<>("資料結構與演算法基礎", "EP074512", 10);  //1.7之後

泛型與多型

泛型不僅僅可以可以定義在類上,同時也能定義在介面上:

public interface ScoreInterface<T> {
    T getScore();
    void setScore(T t);
}

當實現此介面時,我們可以選擇在實現類明確泛型型別或是繼續使用此泛型,讓具體建立的物件來確定型別。

public class Score<T> implements ScoreInterface<T>{   //將Score轉變為泛型類<T>
    private final String name;
    private final String id;
    private T score;

    public Score(String name, String id, T score) { 
        this.name = name;
        this.id = id;
        this.score = score;
    }

    public T getScore() {
        return score;
    }

    @Override
    public void setScore(T score) {
        this.score = score;
    }
}
public class StringScore implements ScoreInterface<String>{   //在實現時明確型別

    @Override
    public String getScore() {
        return null;
    }

    @Override
    public void setScore(String s) {

    }
}

抽象類同理,這裡就不多做演示了。

多型型別擦除

思考一個問題,既然繼承後明確了泛型型別,那麼為什麼@Override不會出現錯誤呢,重寫的條件是需要和父類的返回值型別、形式引數一致,而泛型預設的原始型別是Object型別,子類明確後變為Number型別,這顯然不滿足重寫的條件,但是為什麼依然能編譯通過呢?

class A<T>{
    private T t;
    public T get(){
        return t;
    }
    public void set(T t){
        this.t=t;
    }
}

class B extends A<Number>{
    private Number n;

    @Override
    public Number get(){   //這並不滿足重寫的要求,因為只能重寫父類同樣返回值和引數的方法,但是這樣卻能夠通過編譯!
        return t;
    }

    @Override
    public void set(Number t){
        this.t=t;
    }
}

通過反編譯進行觀察,實際上是編譯器幫助我們生成了兩個橋接方法用於支援重寫:

@Override
public Object get(){
  return this.get();//呼叫返回Number的那個方法
}

@Override
public void set(Object t ){
  this.set((Number)t ); //呼叫引數是Number的那個方法
}

資料結構基礎

學習集合類之前,我們還有最關鍵的內容需要學習,自底向上才是最佳的學習方向,比起直接帶大家認識集合類,不如先了解一下資料結構,只有瞭解了資料結構基礎,才能更好地學習集合類。

同時,資料結構也是你以後深入學習JDK原始碼的必備條件!(學習不要快餐式!)當然,我們主要是講解Java,資料結構作為鋪墊作用,所以我們只會講解關鍵的部分,其他部分可以下去自行了解。

在電腦科學中,資料結構是一種資料組織、管理和儲存的格式,它可以幫助我們實現對資料高效的訪問和修改。更準確地說,資料結構是資料值的集合,可以體現資料值之間的關係,以及可以對資料進行應用的函式或操作。

通俗地說,我們需要去學習在計算機中如何去更好地管理我們的資料,才能讓我們對我們的資料控制更加靈活!

線性表

線性表是最基本的一種資料結構,它是表示一組相同型別資料的有限序列,你可以把它與陣列進行參考,但是它並不是陣列,線性表是一種表結構,它能夠支援資料的插入、刪除、更新、查詢等,同時陣列可以隨意存放在陣列中任意位置,而線性表只能依次有序排列,不能出現空隙,因此,我們需要進一步的設計。

順序表

將資料依次儲存在連續的整塊物理空間中,這種儲存結構稱為順序儲存結構,而以這種方式實現的線性表,我們稱為順序表

同樣的,表中的每一個個體都被稱為元素,元素左邊的元素(上一個元素),稱為前驅,同理,右邊的元素(後一個元素)稱為後驅

我們設計線性表的目標就是為了去更好地管理我們的資料,也就是說,我們可以基於陣列,來進行封裝,實現增刪改查!既然要儲存一組資料,那麼很容易聯想到我們之前學過的陣列,陣列就能夠容納一組同類型的資料。

目標:以陣列為底層,編寫以下抽象類的具體實現

/**
 * 線性表抽象類
 * @param <E> 儲存的元素(Element)型別
 */
public abstract class AbstractList<E> {
    /**
     * 獲取表的長度
     * @return 順序表的長度
     */
    public abstract int size();

    /**
     * 新增一個元素
     * @param e 元素
     * @param index 要新增的位置(索引)
     */
    public abstract void add(E e, int index);

    /**
     * 移除指定位置的元素
     * @param index 位置
     * @return 移除的元素
     */
    public abstract E remove(int index);

    /**
     * 獲取指定位置的元素
     * @param index 位置
     * @return 元素
     */
    public abstract E get(int index);
}

連結串列

資料分散的儲存在物理空間中,通過一根線儲存著它們之間的邏輯關係,這種儲存結構稱為鏈式儲存結構

實際上,就是每一個結點存放一個元素和一個指向下一個結點的引用(C語言裡面是指標,Java中就是物件的引用,代表下一個結點物件)

利用這種思想,我們再來嘗試實現上面的抽象類,從實際的程式碼中感受!

比較:順序表和連結串列的優異?

順序表優缺點:

  • 訪問速度快,隨機訪問效能高
  • 插入和刪除的效率低下,極端情況下需要變更整個表
  • 不易擴充,需要複製並重新建立陣列

連結串列優缺點:

  • 插入和刪除效率高,只需要改變連線點的指向即可
  • 動態擴充容量,無需擔心容量問題
  • 訪問元素需要依次尋找,隨機訪問元素效率低下

連結串列只能指向後面,能不能指向前面呢?雙向連結串列!


棧和佇列實際上就是對線性表加以約束的一種資料結構,如果前面的線性表的掌握已經ok,那麼棧和佇列就非常輕鬆了!

棧遵循先入後出原則,只能在線性表的一端新增和刪除元素。我們可以把棧看做一個杯子,杯子只有一個口進出,最低處的元素只能等到上面的元素離開杯子後,才能離開。

向棧中插入一個元素時,稱為入棧(壓棧),移除棧頂元素稱為出棧,我們需要嘗試實現以下抽象型別:

/**
 * 抽象型別棧,待實現
 * @param <E> 元素型別
 */
public abstract class AbstractStack<E> {

    /**
     * 出棧操作
     * @return 棧頂元素
     */
    public abstract E pop();

    /**
     * 入棧操作
     * @param e 元素
     */
    public abstract void push(E e);
}

其實,我們的JVM在處理方法呼叫時,也是一個棧操作:

所以說,如果玩不好遞迴,就會像這樣:

public class Main {
    public static void main(String[] args) {
        go();
    }

    private static void go(){
        go();
    }
}

Exception in thread "main" java.lang.StackOverflowError
	at com.test.Main.go(Main.java:13)
	at com.test.Main.go(Main.java:13)
	at com.test.Main.go(Main.java:13)
	at com.test.Main.go(Main.java:13)
	at com.test.Main.go(Main.java:13)
	at com.test.Main.go(Main.java:13)
	at com.test.Main.go(Main.java:13)
	at com.test.Main.go(Main.java:13)
  ...

棧的深度是有限制的,如果達到限制,將會出現StackOverflowError錯誤(注意是錯誤!說明是JVM出現了問題)

佇列

佇列同樣也是受限制的線性表,不過佇列就像我們排隊一樣,只能從隊尾開始排,從隊首出。

所以我們要實現以下內容:


/**
 *
 * @param <E>
 */
public abstract class AbstractQueue<E> {

    /**
     * 進隊操作
     * @param e 元素
     */
    public abstract void offer(E e);

    /**
     * 出隊操作
     * @return 元素
     */
    public abstract E poll();
}


二叉樹

本版塊主要學習的是二叉樹,樹也是一種資料結構,但是它使用起來更加的複雜。

我們前面已經學習過連結串列了,我們知道連結串列是單個結點之間相連,也就是一種一對一的關係,而樹則是一個結點連線多個結點,也就是一對多的關係。

一個結點可以有N個子結點,就像上圖一樣,看起來就像是一棵樹。而位於最頂端的結點(沒有父結點)我們稱為根結點,而結點擁有的子節點數量稱為,每向下一級稱為一個層次,樹中出現的最大層次稱為樹的深度(高度)

二叉樹

二叉樹是一種特殊的樹,每個結點最多有兩顆子樹,所以二叉樹中不存在度大於2的結點,位於兩邊的子結點稱為左右子樹(注意,左右子樹是明確區分的,是左就是左,是右就是右)

數學性質:

  • 在二叉樹的第i層上最多有2^(i-1) 個節點。
  • 二叉樹中如果深度為k,那麼最多有2^k-1個節點。

設計一個二叉樹結點類:

public class TreeNode<E> {
    public E e;   //當前結點資料
    public TreeNode<E> left;   //左子樹
    public TreeNode<E> right;   //右子樹
}

二叉樹的遍歷

順序表的遍歷其實就是依次有序去訪問表中每一個元素,而像二叉樹這樣的複雜結構,我們有四種遍歷方式,他們是:前序遍歷、中序遍歷、後序遍歷以及層序遍歷,本版塊我們主要討論前三種遍歷方式:

  • 前序遍歷:從二叉樹的根結點出發,到達結點時就直接輸出結點資料,按照先向左在向右的方向訪問。ABCDEF
  • 中序遍歷:從二叉樹的根結點出發,優先輸出左子樹的節點的資料,再輸出當前節點本身,最後才是右子樹。CBDAEF
  • 後序遍歷:從二叉樹的根結點出發,優先遍歷其左子樹,再遍歷右子樹,最後在輸出當前節點本身。CDBFEA

滿二叉樹和完全二叉樹

滿二叉樹和完全二叉樹其實就是特殊情況下的二叉樹,滿二叉樹左右的所有葉子節點都在同一層,也就是說,完全把每一個層級都給加滿了結點。完全二叉樹與滿二叉樹不同的地方在於,它的最下層葉子節點可以不滿,但是最下層的葉子節點必須靠左排布。

其實滿二叉樹和完全二叉樹就是有一定規律的二叉樹,很容易理解。

快速查詢

我們之前提到的這些資料結構,很好地幫我們管理了資料,但是,如果需要查詢某一個元素是否存在於資料結構中,如何才能更加高效的去完成呢?

雜湊表

通過前面的學習,我們發現,順序表雖然查詢效率高,但是插入刪除有嚴重表更新的問題,而連結串列雖然彌補了更新問題,但是查詢效率實在是太低了,能否有一種折中方案?雜湊表!

不知大家在之前的學習中是否發現,我們的Object類中,定義了一個叫做hashcode()的方法?而這個方法呢,就是為了更好地支援雜湊表的實現。hashcode()預設得到的是物件的記憶體地址,也就是說,每個物件的hashCode都不一樣。

雜湊表,其實本質上就是一個存放連結串列的陣列,那麼它是如何去儲存資料的呢?我們先來看看長啥樣:

陣列中每一個元素都是一個頭結點,用於儲存資料,那我們怎麼確定資料應該放在哪一個位置呢?通過hash演算法,我們能夠瞬間得到元素應該放置的位置。

//假設hash表長度為16,hash演算法為:
private int hash(int hashcode){
  return hashcode % 16;
}

設想這樣一個問題,如果計算出來的hash值和之前已經存在的元素相同了呢?這種情況我們稱為hash碰撞,這也是為什麼要將每一個表元素設定為一個連結串列的頭結點的原因,一旦發現重複,我們可以往後繼續新增節點。

當然,以上的hash表結構只是一種設計方案,在面對大額資料時,是不夠用的,在JDK1.8中,集合類使用的是陣列+二叉樹的形式解決的(這裡的二叉樹是經過加強的二叉樹,不是前面講得簡單二叉樹,我們下一節就會開始講)

二叉排序樹

我們前面學習的二叉樹效率是不夠的,我們需要的是一種效率更高的二叉樹,因此,基於二叉樹的改進,提出了二叉查詢樹,可以看到結構像下面這樣:

不難發現,每個節點的左子樹,一定小於當前節點的值,每個節點的右子樹,一定大於當前節點的值,這樣的二叉樹稱為二叉排序樹。利用二分搜尋的思想,我們就可以快速查詢某個節點!

平衡二叉樹

在瞭解了二叉查詢樹之後,我們發現,如果根節點為10,現在加入到結點的值從9開始,依次減小到1,那麼這個表就會很奇怪,就像下面這樣:

顯然,當所有的結點都排列到一邊,這種情況下,查詢效率會直接退化為最原始的二叉樹!因此我們需要維持二叉樹的平衡,才能維持原有的查詢效率。

現在我們對二叉排序樹加以約束,要求每個結點的左右兩個子樹的高度差的絕對值不超過1,這樣的二叉樹稱為平衡二叉樹,同時要求每個結點的左右子樹都是平衡二叉樹,這樣,就不會因為一邊的瘋狂增加導致失衡。我們來看看以下幾種情況:

左左失衡

右右失衡

左右失衡

右左失衡

通過以上四種情況的處理,最終得到維護平衡二叉樹的演算法。

紅黑樹

紅黑樹也是二叉排序樹的一種改進,同平衡二叉樹一樣,紅黑樹也是一種維護平衡的二叉排序樹,但是沒有平衡二叉樹那樣嚴格(平衡二叉樹每次插入新結點時,可能會出現大量的旋轉,而紅黑樹保證不超過三次),紅黑樹降低了對於旋轉的要求,因此效率有一定的提升同時實現起來也更加簡單。但是紅黑樹的效率卻高於平衡二叉樹,紅黑樹也是JDK1.8中使用的資料結構!

紅黑樹的特性:
(1)每個節點或者是黑色,或者是紅色。
(2)根節點是黑色。
(3)每個葉子節點的兩邊也需要表示(雖然沒有,但是null也需要表示出來)是黑色。
(4)如果一個節點是紅色的,則它的子節點必須是黑色的。
(5)從一個節點到該節點的子孫節點的所有路徑上包含相同數目的黑節點。

我們來看看一個節點,是如何插入到紅黑樹中的:

基本的 插入規則和平衡二叉樹一樣,但是在插入後:

  1. 將新插入的節點標記為紅色
  2. 如果 X 是根結點(root),則標記為黑色
  3. 如果 X 的 parent 不是黑色,同時 X 也不是 root:
  • 3.1 如果 X 的 uncle (叔叔) 是紅色

    • 3.1.1 將 parent 和 uncle 標記為黑色
    • 3.1.2 將 grand parent (祖父) 標記為紅色
    • 3.1.3 讓 X 節點的顏色與 X 祖父的顏色相同,然後重複步驟 2、3
  • 3.2 如果 X 的 uncle (叔叔) 是黑色,我們要分四種情況處理

    • 3.2.1 左左 (P 是 G 的左孩子,並且 X 是 P 的左孩子)
    • 3.2.2 左右 (P 是 G 的左孩子,並且 X 是 P 的右孩子)
    • 3.2.3 右右 (P 是 G 的右孩子,並且 X 是 P 的右孩子)
    • 3.2.4 右左 (P 是 G 的右孩子,並且 X 是 P 的左孩子)
    • 其實這種情況下處理就和我們的平衡二叉樹一樣了

認識集合類

原始碼解析https://www.cnblogs.com/zwtblog/tag/原始碼/

集合表示一組物件,稱為其元素。一些集合允許重複的元素,而另一些則不允許。一些集合是有序的,而其他則是無序的。

集合類其實就是為了更好地組織、管理和操作我們的資料而存在的,包括列表、集合、佇列、對映等資料結構。從這一塊開始,我們會從原始碼角度給大家講解(資料結構很重要!),不僅僅是教會大家如何去使用。

集合類最頂層不是抽象類而是介面,因為介面代表的是某個功能,而抽象類是已經快要成形的型別,不同的集合類的底層實現是不相同的,同時一個集合類可能會同時具有兩種及以上功能(既能做佇列也能做列表),所以採用介面會更加合適,介面只需定義支援的功能即可。

陣列與集合

相同之處:

  1. 它們都是容器,都能夠容納一組元素。

不同之處:

  1. 陣列的大小是固定的,集合的大小是可變的。
  2. 陣列可以存放基本資料型別,但集合只能存放物件。
  3. 陣列存放的型別只能是一種,但集合可以有不同種類的元素。

集合根介面Collection

本介面中定義了全部的集合基本操作,我們可以在原始碼中看看。

我們再來看看List和Set以及Queue介面。

集合類的使用

List列表

首先介紹ArrayList,它的底層是用陣列實現的,內部維護的是一個可改變大小的陣列,也就是我們之前所說的線性表!跟我們之前自己寫的ArrayList相比,它更加的規範,同時繼承自List介面。

先看看ArrayList的原始碼!

基本操作

List<String> list = new ArrayList<>();  //預設長度的列表
List<String> listInit = new ArrayList<>(100);  //初始長度為100的列表

向列表中新增元素:

List<String> list = new ArrayList<>();
list.add("lbwnb");
list.add("yyds");
list.contains("yyds"); //是否包含某個元素
System.out.println(list);

移除元素:

public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    list.add("lbwnb");
    list.add("yyds");
    list.remove(0);   //按下標移除元素
    list.remove("yyds");    //移除指定元素
    System.out.println(list);
}

也支援批量操作:

public static void main(String[] args) {
    ArrayList<String> list = new ArrayList<>();
    list.addAll(new ArrayList<>());   //在尾部批量新增元素
    list.removeAll(new ArrayList<>());   //批量移除元素(只有給定集合中存在的元素才會被移除)
    list.retainAll(new ArrayList<>());   //只保留某些元素
    System.out.println(list);
}

我們再來看LinkedList,其實本質就是一個連結串列!我們來看看原始碼。

其實與我們之前編寫的LinkedList不同之處在於,它內部使用的是一個雙向連結串列:

private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;

    Node(Node<E> prev, E element, Node<E> next) {
        this.item = element;
        this.next = next;
        this.prev = prev;
    }
}

當然,我們發現它還實現了Queue介面,所以LinkedList也能被當做一個佇列或是棧來使用。

public static void main(String[] args) {
    LinkedList<String> list = new LinkedList<>();
    list.offer("A");   //入隊
    System.out.println(list.poll());  //出隊
    list.push("A");
    list.push("B");    //進棧
    list.push("C");
    System.out.println(list.pop());
    System.out.println(list.pop());    //出棧
    System.out.println(list.pop());
}

利用程式碼塊來快速新增內容

前面我們學習了匿名內部類,我們就可以利用程式碼塊,來快速生成一個自帶元素的List

List<String> list = new LinkedList<String>(){{    //初始化時新增
  this.add("A");
  this.add("B");
}};

如果是需要快速生成一個只讀的List,後面我們會講解Arrays工具類。

集合的排序

List<Integer> list = new LinkedList<Integer>(){   //Java9才支援匿名內部類使用鑽石運算子
    {
        this.add(10);
        this.add(2);
        this.add(5);
        this.add(8);
    }
};
list.sort((a, b) -> {    //排序已經由JDK實現,現在只需要填入自定義規則,完成Comparator介面實現
  return a - b;    //返回值小於0,表示a應該在b前面,返回值大於0,表示b應該在a後面,等於0則不進行交換
});
System.out.println(list);

迭代器

集合的遍歷

所有的集合類,都支援foreach迴圈!

public static void main(String[] args) {
    List<Integer> list = new LinkedList<Integer>(){   //Java9才支援匿名內部類使用鑽石運算子
        {
            this.add(10);
            this.add(2);
            this.add(5);
            this.add(8);
        }
    };
    for (Integer integer : list) {
        System.out.println(integer);
    }
}

當然,也可以使用JDK1.8新增的forEach方法,它接受一個Consumer介面實現:

list.forEach(i -> {
    System.out.println(i);
});

從JDK1.8開始,lambda表示式開始逐漸成為主流,我們需要去適應函數語言程式設計的這種語法,包括批量替換,也是用到了函式式介面來完成的。

list.replaceAll((i) -> {
  if(i == 2) return 3;   //將所有的2替換為3
  else return i;   //不是2就不變
});
System.out.println(list);

Iterable和Iterator介面

我們之前學習資料結構時,已經得知,不同的線性表實現,在獲取元素時的效率也不同,因此我們需要一種更好地方式來統一不同資料結構的遍歷。

由於ArrayList對於隨機訪問的速度更快,而LinkedList對於順序訪問的速度更快,

因此在上述的傳統for迴圈遍歷操作中,ArrayList的效率更勝一籌,因此我們要使得LinkedList遍歷效率提升,就需要採用順序訪問的方式進行遍歷,

如果沒有迭代器幫助我們統一標準,那麼我們在應對多種集合型別的時候,就需要對應編寫不同的遍歷演算法,很顯然這樣會降低我們的開發效率,而迭代器的出現就幫助我們解決了這個問題。

我們先來看看迭代器裡面方法:

public interface Iterator<E> {
  //...
}

每個集合類都有自己的迭代器,通過iterator()方法來獲取:

Iterator<Integer> iterator = list.iterator();   //生成一個新的迭代器
while (iterator.hasNext()){    //判斷是否還有下一個元素
  Integer i = iterator.next();     //獲取下一個元素(獲取一個少一個)
  System.out.println(i);
}

迭代器生成後,預設指向第一個元素,每次呼叫next()方法,都會將指標後移,當指標移動到最後一個元素之後,呼叫hasNext()將會返回false,迭代器是一次性的,用完即止,如果需要再次使用,需要呼叫iterator()方法。

//List還有一個更好地迭代器實現ListIterator
ListIterator<Integer> iterator = list.listIterator();   

ListIterator是List中獨有的迭代器,在原有迭代器基礎上新增了一些額外的操作。


Set集合

我們之前已經看過Set介面的定義了,我們發現介面中定義的方法都是Collection中直接繼承的,因此,Set支援的功能其實也就和Collection中定義的差不多,只不過使用方法上稍有不同。

Set集合特點:

  • 不允許出現重複元素
  • 不支援隨機訪問(不允許通過下標訪問)

首先認識一下HashSet,它的底層就是採用雜湊表實現的(我們在這裡先不去探討實現原理,因為底層實質上維護的是一個HashMap,我們學習了Map之後再來討論)

public static void main(String[] args) {
    HashSet<Integer> set = new HashSet<>();
    set.add(120);    //支援插入元素,但是不支援指定位置插入
    set.add(13);
    set.add(11);
    for (Integer integer : set) {
      System.out.println(integer);
    }
}

執行上面程式碼發現,最後Set集合中存在的元素順序,並不是我們的插入順序,這是因為HashSet底層是採用雜湊表來實現的,實際的存放順序是由Hash演算法決定的。

那麼我們希望資料按照我們插入的順序進行儲存該怎麼辦呢?我們可以使用LinkedHashSet:

public static void main(String[] args) {
    LinkedHashSet<Integer> set = new LinkedHashSet<>();  //會自動儲存我們的插入順序
    set.add(120);
    set.add(13);
    set.add(11);
    for (Integer integer : set) {
        System.out.println(integer);
    }
}

LinkedHashSet底層維護的不再是一個HashMap,而是LinkedHashMap,它能夠在插入資料時利用連結串列自動維護順序,因此這樣就能夠保證我們插入順序和最後的迭代順序一致了。

還有一種Set叫做TreeSet,它會在元素插入時進行排序:

public static void main(String[] args) {
    TreeSet<Integer> set = new TreeSet<>();
    set.add(1);
    set.add(3);
    set.add(2);
    System.out.println(set);
}

可以看到最後得到的結果並不是我們插入順序,而是按照數字的大小進行排列。當然,我們也可以自定義排序規則:

public static void main(String[] args) {
    TreeSet<Integer> set = new TreeSet<>((a, b) -> b - a);   //在建立物件時指定規則即可
    set.add(1);
    set.add(3);
    set.add(2);
    System.out.println(set);
}

現在的結果就是我們自定義的排序規則了。

雖然Set集合只是粗略的進行了講解,但是學習Map之後,我們還會回來看我們Set的底層實現,所以說最重要的還是Map。本節只需要記住Set的性質、使用即可。


Map對映

什麼是對映

我們在高中階段其實已經學習過映射了,對映指兩個元素的之間相互“對應”的關係,也就是說,我們的元素之間是兩兩對應的,是以鍵值對的形式存在。

Map介面

Map就是為了實現這種資料結構而存在的,我們通過儲存鍵值對的形式來儲存對映關係。

我們先來看看Map介面中定義了哪些操作。

HashMap和LinkedHashMap

HashMap的實現過程,相比List,就非常地複雜了,它並不是簡簡單單的表結構,而是利用雜湊表存放對映關係,我們來看看HashMap是如何實現的,首先回顧我們之前學習的雜湊表,它長這樣:

雜湊表的本質其實就是一個用於存放後續節點的頭結點的陣列,數組裡面的每一個元素都是一個頭結點(也可以說就是一個連結串列),當要新插入一個數據時,會先計算該資料的雜湊值,找到陣列下標,然後建立一個新的節點,新增到對應的連結串列後面。

而HashMap就是採用的這種方式,我們可以看到原始碼中同樣定義了這樣的一個結構:

/**
 * The table, initialized on first use, and resized as
 * necessary. When allocated, length is always a power of two.
 * (We also tolerate length zero in some operations to allow
 * bootstrapping mechanics that are currently not needed.)
 */
transient Node<K,V>[] table;

這個表會在第一次使用時初始化,同時在必要時進行擴容,並且它的大小永遠是2的倍數!

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

我們可以看到預設的大小為2的4次方,每次都需要是2的倍數,也就是說,下一次增長之後,大小會變成2的5次方。

我們現在需要思考一個問題,當我們表中的資料不斷增加之後,連結串列會變得越來越長,這樣會嚴重導致查詢速度變慢,首先想到辦法就是,我們可以對陣列的長度進行擴容,來存放更多的連結串列,那麼什麼情況下會進行擴容呢?

/**
 * The load factor for the hash table.
 *
 * @serial
 */
final float loadFactor;

我們還發現HashMap原始碼中有這樣一個變數,也就是負載因子,那麼它是幹嘛的呢?

負載因子其實就是用來衡量當前情況是否需要進行擴容的標準。我們可以看到預設的負載因子是0.75

/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

那麼負載因子是怎麼控制擴容的呢?0.75的意思是,在插入新的結點後,如果當前陣列的佔用率達到75%則進行擴容。在擴容時,會將所有的資料,重新計算雜湊值,得到一個新的下標,組成新的雜湊表。

但是這樣依然有一個問題,連結串列過長的情況還是有可能發生,所以,為了從根源上解決這個問題,在JDK1.8時,引入了紅黑樹這個資料結構。

當連結串列的長度達到8時,會自動將連結串列轉換為紅黑樹,這樣能使得原有的查詢效率大幅度降低!當使用紅黑樹之後,我們就可以利用二分搜尋的思想,快速地去尋找我們想要的結果,而不是像連結串列一樣挨個去看。

/**
 * Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
 * extends Node) so can be used as extension of either regular or
 * linked node.
 */
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {

除了Node以外,HashMap還有TreeNode,很明顯這就是為了實現紅黑樹而設計的內部類。不過我們發現,TreeNode並不是直接繼承Node,而是使用了LinkedHashMap中的Entry實現,它儲存了前後節點的順序(也就是我們的插入順序)。

/**
 * HashMap.Node subclass for normal LinkedHashMap entries.
 */
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

LinkedHashMap是直接繼承自HashMap,具有HashMap的全部性質,同時得益於每一個節點都是一個雙向連結串列,儲存了插入順序,這樣我們在遍歷LinkedHashMap時,順序就同我們的插入順序一致。當然,也可以使用訪問順序,也就是說對於剛訪問過的元素,會被排到最後一位。

public static void main(String[] args) {
    LinkedHashMap<Integer, String> map = new LinkedHashMap<>(16, 0.75f, true);  //以訪問順序
    map.put(1, "A");
    map.put(2, "B");
    map.put(3, "C");
    map.get(2);
    System.out.println(map);
}

觀察結果,我們發現,剛訪問的結果被排到了最後一位。

TreeMap

TreeMap其實就是自動維護順序的一種Map,就和我們前面提到的TreeSet一樣:

/**
 * The comparator used to maintain order in this tree map, or
 * null if it uses the natural ordering of its keys.
 *
 * @serial
 */
private final Comparator<? super K> comparator;

private transient Entry<K,V> root;

/**
* Node in the Tree.  Doubles as a means to pass key-value pairs back to
* user (see Map.Entry).
*/

static final class Entry<K,V> implements Map.Entry<K,V> {

我們發現它的內部直接維護了一個紅黑樹,就像它的名字一樣,就是一個Tree,因為它預設就是有序的,所以說直接採用紅黑樹會更好。我們在建立時,直接給予一個比較規則即可。

Map的使用

我們首先來看看Map的一些基本操作:

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "A");
    map.put(2, "B");
    map.put(3, "C");
    System.out.println(map.get(1));    //獲取Key為1的值
    System.out.println(map.getOrDefault(0, "K"));  //不存在就返回K
   	map.remove(1);   //移除這個Key的鍵值對
}

由於Map並未實現迭代器介面,因此不支援foreach,但是JDK1.8為我們提供了forEach方法使用:

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "A");
    map.put(2, "B");
    map.put(3, "C");
    map.forEach((k, v) -> System.out.println(k+"->"+v));
  
  	for (Map.Entry<Integer, String> entry : map.entrySet()) {   //也可以獲取所有的Entry來foreach
      int key = entry.getKey();
      String value = entry.getValue();
      System.out.println(key+" -> "+value);
    }
}

我們也可以單獨獲取所有的值或者是鍵:

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "A");
    map.put(2, "B");
    map.put(3, "C");
    System.out.println(map.keySet());   //直接獲取所有的key
    System.out.println(map.values());   //直接獲取所有的值
}

再談Set原理

通過觀察HashSet的原始碼發現,HashSet幾乎都在操作內部維護的一個HashMap,也就是說,HashSet只是一個錶殼,而內部維護的HashMap才是靈魂!

// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();

我們發現,在新增元素時,其實新增的是一個鍵為我們插入的元素,而值就是PRESENT常量:

/**
 * Adds the specified element to this set if it is not already present.
 * More formally, adds the specified element <tt>e</tt> to this set if
 * this set contains no element <tt>e2</tt> such that
 * <tt>(e==null&nbsp;?&nbsp;e2==null&nbsp;:&nbsp;e.equals(e2))</tt>.
 * If this set already contains the element, the call leaves the set
 * unchanged and returns <tt>false</tt>.
 *
 * @param e element to be added to this set
 * @return <tt>true</tt> if this set did not already contain the specified
 * element
 */
public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

觀察其他的方法,也幾乎都是在用HashMap做事,所以說,HashSet利用了HashMap內部的資料結構,輕鬆地就實現了Set定義的全部功能!

再來看TreeSet,實際上用的就是我們的TreeMap:

/**
 * The backing map.
 */
private transient NavigableMap<E,Object> m;

同理,這裡就不多做闡述了。

JDK1.8新增方法使用

最後,我們再來看看JDK1.8中集合類新增的一些操作(之前沒有提及的)首先來看看compute方法:

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "A");
    map.put(2, "B");
    map.compute(1, (k, v) -> {   //compute會將指定Key的值進行重新計算,若Key不存在,v會返回null
        return v+"M";     //這裡返回原來的value+M
    });
  	map.computeIfPresent(1, (k, v) -> {   //當Key存在時存在則計算並賦予新的值
      return v+"M";     //這裡返回原來的value+M
    });
    System.out.println(map);
}

也可以使用computeIfAbsent,當不存在Key時,計算並將鍵值對放入Map

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "A");
    map.put(2, "B");
    map.computeIfAbsent(0, (k) -> {   //若不存在則計算並插入新的值
        return "M";     //這裡返回M
    });
    System.out.println(map);
}

merge方法用於處理資料:

public static void main(String[] args) {
    List<Student> students = Arrays.asList(
            new Student("yoni", "English", 80),
            new Student("yoni", "Chiness", 98),
            new Student("yoni", "Math", 95),
            new Student("taohai.wang", "English", 50),
            new Student("taohai.wang", "Chiness", 72),
            new Student("taohai.wang", "Math", 41),
            new Student("Seely", "English", 88),
            new Student("Seely", "Chiness", 89),
            new Student("Seely", "Math", 92)
    );
    Map<String, Integer> scoreMap = new HashMap<>();
    students.forEach(student -> scoreMap.merge(student.getName(), student.getScore(), Integer::sum));
    scoreMap.forEach((k, v) -> System.out.println("key:" + k + "總分" + "value:" + v));
}

static class Student {
    private final String name;
    private final String type;
    private final int score;

    public Student(String name, String type, int score) {
        this.name = name;
        this.type = type;
        this.score = score;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

    public String getType() {
        return type;
    }
}

集合的巢狀

既然集合型別中的元素型別是泛型,那麼能否巢狀儲存呢?

public static void main(String[] args) {
    Map<String, List<Integer>> map = new HashMap<>();   //每一個對映都是 字串<->列表
    map.put("卡布奇諾今猶在", new LinkedList<>());
    map.put("不見當年倒茶人", new LinkedList<>());
    System.out.println(map.keySet());
    System.out.println(map.values());
}

通過Key獲取到對應的值後,就是一個列表:

map.get("卡布奇諾今猶在").add(10);
System.out.println(map.get("卡布奇諾今猶在").get(0));

讓套娃繼續下去:

public static void main(String[] args) {
    Map<Integer, Map<Integer, Map<Integer, String>>> map = new HashMap<>();
}

你也可以使用List來套娃別的:

public static void main(String[] args) {
    List<Map<String, Set<String>>> list = new LinkedList<>();
}

流Stream和Optional的使用

Java 8 API添加了一個新的抽象稱為流Stream,可以讓你以一種宣告的方式處理資料。Stream 使用一種類似用 SQL 語句從資料庫查詢資料的直觀方式來提供一種對 Java 集合運算和表達的高階抽象。Stream API可以極大提高Java程式設計師的生產力,讓程式設計師寫出高效率、乾淨、簡潔的程式碼。這種風格將要處理的元素集合看作一種流, 流在管道中傳輸, 並且可以在管道的節點上進行處理, 比如篩選, 排序,聚合等。元素流在管道中經過中間操作(intermediate operation)的處理,最後由最終操作(terminal operation)得到前面處理的結果。

它看起來就像一個工廠的流水線一樣!我們就可以把一個Stream當做流水線處理:

public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    list.add("A");
    list.add("B");
    list.add("C");
  
  	//移除為B的元素
  	Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()){
            if(iterator.next().equals("B")) iterator.remove();
        }
  
  	//Stream操作
    list = list     //鏈式呼叫
            .stream()    //獲取流
            .filter(e -> !e.equals("B"))   //只允許所有不是B的元素通過流水線
            .collect(Collectors.toList());   //將流水線中的元素重新收集起來,變回List
    System.out.println(list);
}

可能從上述例子中還不能感受到流處理帶來的便捷,我們通過下面這個例子來感受一下:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);
  	list.add(3);

    list = list
            .stream()
      			.distinct()   //去重(使用equals判斷)
            .sorted((a, b) -> b - a)    //進行倒序排列
            .map(e -> e+1)    //每個元素都要執行+1操作
            .limit(2)    //只放行前兩個元素
            .collect(Collectors.toList());

    System.out.println(list);
}

當遇到大量的複雜操作時,我們就可以使用Stream來快速編寫程式碼,這樣不僅程式碼量大幅度減少,而且邏輯也更加清晰明瞭(如果你學習過SQL的話,你會發現它更像一個Sql語句)

注意:不能認為每一步是直接依次執行的!

List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(3);

list = list
        .stream()
        .distinct()   //斷點
        .sorted((a, b) -> b - a)
        .map(e -> {
            System.out.println(">>> "+e);   //斷點
            return e+1;
        })
        .limit(2)   //斷點
        .collect(Collectors.toList());
//實際上,stream會先記錄每一步操作,而不是直接開始執行內容,當整個鏈式呼叫完成後,才會依次進行!

接下來,我們用一堆隨機數來進行更多流操作的演示:

public static void main(String[] args) {
    Random random = new Random();  //Random是一個隨機數工具類
    random
            .ints(-100, 100)   //生成-100~100之間的,隨機int型數字(本質上是一個IntStream)
            .limit(10)   //只獲取前10個數字(這是一個無限制的流,如果不加以限制,將會無限進行下去!)
            .filter(i -> i < 0)   //只保留小於0的數字
            .sorted()    //預設從小到大排序
            .forEach(System.out::println);   //依次列印
}

我們可以生成一個統計例項來幫助我們快速進行統計:

public static void main(String[] args) {
    Random random = new Random();  //Random是一個隨機數工具類
    IntSummaryStatistics statistics = random
            .ints(0, 100)
            .limit(100)
            .summaryStatistics();    //獲取語法統計例項
    System.out.println(statistics.getMax());  //快速獲取最大值
    System.out.println(statistics.getCount());  //獲取數量
    System.out.println(statistics.getAverage());   //獲取平均值
}

普通的List只需要一個方法就可以直接轉換到方便好用的IntStream了:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(1);
    list.add(2);
    list.add(3);
    list.add(4);
    list.stream()
            .mapToInt(i -> i)    //將每一個元素對映為Integer型別(這裡因為本來就是Integer)
            .summaryStatistics();
}

我們還可以通過flat來對整個流進行進一步細分:

public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    list.add("A,B");
    list.add("C,D");
    list.add("E,F");   //我們想讓每一個元素通過,進行分割,變成獨立的6個元素
    list = list
            .stream()    //生成流
            .flatMap(e -> Arrays.stream(e.split(",")))    //分割字串並生成新的流
            .collect(Collectors.toList());   //匯成新的List
    System.out.println(list);   //得到結果
}

我們也可以只通過Stream來完成所有數字的和,使用reduce方法:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);
    int sum = list
            .stream()
            .reduce((a, b) -> a + b)   //計算規則為:a是上一次計算的值,b是當前要計算的引數,這裡是求和
            .get();    //我們發現得到的是一個Optional類例項,不是我們返回的型別,通過get方法返回得到的值
    System.out.println(sum);
}

通過上面的例子,我們發現,Stream不喜歡直接給我們返回一個結果,而是通過Optinal的方式,那麼什麼是Optional呢?

Optional類是Java8為了解決null值判斷問題,使用Optional類可以避免顯式的null值判斷(null的防禦性檢查),避免null導致的NPE(NullPointerException)。總而言之,就是對控制的一個判斷,為了避免空指標異常。

public static void main(String[] args) {
    String str = null;
    if(str != null){   //當str不為空時新增元素到List中
        list.add(str);
    }
}

有了Optional之後,我們就可以這樣寫:

public static void main(String[] args) {
    String str = null;
    Optional<String> optional = Optional.ofNullable(str);   //轉換為Optional
    optional.ifPresent(System.out::println);  //當存在時再執行方法
}

就類似於Kotlin中的:

var str : String? = null
str?.upperCase()

我們可以選擇直接get或是當值為null時,獲取備選值:

public static void main(String[] args) {
    String str = null;
    Optional optional = Optional.ofNullable(str);   //轉換為Optional(可空)
    System.out.println(optional.orElse("lbwnb"));
 		// System.out.println(optional.get());   這樣會直接報錯
}

同樣的,Optional也支援過濾操作和對映操作,不過是對於單物件而言:

public static void main(String[] args) {
    String str = "A";
    Optional optional = Optional.ofNullable(str);   //轉換為Optional(可空)
    System.out.println(optional.filter(s -> s.equals("B")).get());   //被過濾了,此時元素為null,獲取時報錯
}
public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    String str = "A";
    Optional optional = Optional.ofNullable(str);   //轉換為Optional(可空)
    System.out.println(optional.map(s -> s + "A").get());   //在尾部追加一個A
}

其他操作自學瞭解。

Arrays和Collections的使用

Arrays是一個用於運算元組的工具類,它給我們提供了大量的工具方法:

/**
 * This class contains various methods for manipulating arrays (such as
 * sorting and searching). This class also contains a static factory
 * that allows arrays to be viewed as lists. <- 注意,這句話很關鍵
 *
 * @author Josh Bloch
 * @author Neal Gafter
 * @author John Rose
 * @since  1.2
 */
public class Arrays {

由於運算元組並不像集合那樣方便,因此JDK提供了Arrays類來增強對陣列操作,比如:

public static void main(String[] args) {
    int[] array = {1, 5, 2, 4, 7, 3, 6};
    Arrays.sort(array);   //直接進行排序(底層原理:進行判斷,元素少使用插入排序,大量元素使用雙軸快速/歸併排序)
    System.out.println(array);  //由於int[]是一個物件型別,而陣列預設是沒有重寫toString()方法,因此無法列印到想要的結果
    System.out.println(Arrays.toString(array));  //我們可以使用Arrays.toString()來像集合一樣直接列印每一個元素出來
}
public static void main(String[] args) {
    int[] array = {1, 5, 2, 4, 7, 3, 6};
    Arrays.sort(array);
    System.out.println("排序後的結果:"+Arrays.toString(array));
    System.out.println("目標元素3位置為:"+Arrays.binarySearch(array, 3));  //二分搜素,必須是已經排序好的陣列!
}
public static void main(String[] args) {
    int[] array = {1, 5, 2, 4, 7, 3, 6};
    Arrays
            .stream(array)    //將陣列轉換為流進行操作
            .sorted()
            .forEach(System.out::println);
}
public static void main(String[] args) {
    int[] array = {1, 5, 2, 4, 7, 3, 6};
    int[] array2 = Arrays.copyOf(array, array.length);  //複製一個一模一樣的陣列
    System.out.println(Arrays.toString(array2));

    System.out.println(Arrays.equals(array, array2));  //比較兩個陣列是否值相同

    Arrays.fill(array, 0);   //將陣列的所有值全部填充為指定值
    System.out.println(Arrays.toString(array));

    Arrays.setAll(array2, i -> array2[i] + 2);  //依次計算每一個元素(注意i是下標位置)
    System.out.println(Arrays.toString(array2));   //這裡計算讓每個元素值+2
}

思考:當二維陣列使用Arrays.equals()進行比較以及Arrays.toString()進行列印時,還會得到我們想要的結果嗎?

public static void main(String[] args) {
    Integer[][] array = {{1, 5}, {2, 4}, {7, 3}, {6}};
    Integer[][] array2 = {{1, 5}, {2, 4}, {7, 3}, {6}};
    System.out.println(Arrays.toString(array));    //這樣還會得到我們想要的結果嗎?
    System.out.println(Arrays.equals(array2, array));    //這樣還會得到true嗎?

    System.out.println(Arrays.deepToString(array));   //使用deepToString就能到列印多維陣列
    System.out.println(Arrays.deepEquals(array2, array));   //使用deepEquals就能比較多維陣列
}

那麼,一開始提到的當做List進行操作呢?我們可以使用Arrays.asList()來將陣列轉換為一個 固定長度的List

public static void main(String[] args) {
    Integer[] array = {1, 5, 2, 4, 7, 3, 6};
    List<Integer> list = Arrays.asList(array);   //不支援基本型別陣列,必須是物件型別陣列
    Arrays.asList("A", "B", "C");  //也可以逐個新增,因為是可變引數

    list.add(1);    //此List實現是長度固定的,是Arrays內部單獨實現的一個型別,因此不支援新增操作
    list.remove(0);   //同理,也不支援移除

    list.set(0, 8);   //直接設定指定下標的值就可以
    list.sort(Comparator.reverseOrder());   //也可以執行排序操作
    System.out.println(list);   //也可以像List那樣直接列印
}

文字遊戲:allows arrays to be viewed as lists,實際上只是當做List使用,本質還是陣列,因此陣列的屬性依然存在!因此如果要將陣列快速轉換為實際的List,可以像這樣:

public static void main(String[] args) {
    Integer[] array = {1, 5, 2, 4, 7, 3, 6};
    List<Integer> list = new ArrayList<>(Arrays.asList(array));
}

通過自行建立一個真正的ArrayList並在構造時將Arrays的List值傳遞。

既然陣列操作都這麼方便了,集合操作能不能也安排點高階的玩法呢?那必須的,JDK為我們準備的Collocations類就是專用於集合的工具類:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    Collections.max(list);
    Collections.min(list);
}

當然,Collections提供的內容相比Arrays會更多,希望大家下去自行了解,這裡就不多做介紹了。