【算法】實現字典API:有序數組和無序鏈表

參考資料
《算法(java)》                           — — Robert Sedgewick, Kevin Wayne
《數據結構》                                  — — 嚴蔚敏
 
這篇文章主要介紹實現字典的兩種方式
  • 有序數組
  • 無序鏈表

(二叉樹的實現方案將在下一篇文章介紹)html

 
【注意】 爲了讓代碼儘量簡單, 我將字典的Key和Value的值也設置爲int類型,而不是對象, 因此在下面代碼中, 處理「操做失敗」的狀況的時候,是返回 -1 而不是返回 null 。 因此代碼默認不能選擇 -1做爲 Key或者Value
(在實際場景中,咱們會將int類型的Key替換爲實現Compare接口的類的對象,同時將「失敗」時的返回值從-1設爲null,這時是沒有這個問題的)
 

字典的定義和相關操做

字典又叫查找表(Search Table), 是由同一類型的數據元素構成的集合, 因爲集合中的數據元素存在着徹底鬆散的關係, 所以查找表是一種很是靈便的數據結構。
 
對查找表常常進行的操做有:
  1. 查詢某個特定的數據是否在查找表中
  2. 檢索某個特定的數據元素的各類屬性
  3. 在查找表中插入一個數據元素
  4. 從查找表中刪除某個數據元素
若對查找表只作1,2兩種查找的操做, 這樣的查找表被稱爲「靜態查找表
若在查找過程當中同時還進行了3,4操做, 這樣的查找表被稱爲「動態查找表
 
 

有序數組實現字典

 

有序數組實現字典思路

字典,有最關鍵的兩個類型的值: KeyValue。 可是一個數組顯然只能存儲一個類型的值呀, 正因如此:
首先咱們須要預備兩個數組;    其次,咱們要在每次操做中同步兩個數組的狀態
 
1. 預備兩個數組,一個存儲Key,  一個存儲Value
 
 

 

2. 在每次操做中同步兩個數組的狀態 以有序數組的插入鍵值對的操做爲例(put)
 
 

 

(int類型的數組初始化後,默認值是0)
 

Key和Value的位置是相同的

雙數組實現字典功能的核心在於: 每一步操做裏,Key和Value在兩個數組裏的位置是相同的, 這意爲着你查找出Key的位置時, 也一併查找出了Value的位置。 例如刪除操做時, 假設Key和Value的數組分別爲a1和a2,  經過對Key的查找得出Key的位置是x, 那麼接下來只要對a1[x]和a2[x] 同時進行操做就能夠了
 

字典長度和數組長度

同時要注意一個簡單卻容易搞混的點:字典長度和數組長度是兩個不同的概念
 
  • 數組長度是建立後固定不變的,例如一開始就是N
  • 字典的長度是可變的, 開始是0, 逐漸遞增到N。
 
以有序數組爲例
 

 

【注意】這裏的「數組長度固定不變」是相對而言的, 下面我會介紹當字典滿溢時擴建數組的操做(resize)
 
 

選擇有序數組的緣由

要實現字典, 使用有序數組和無序數組固然均可以, 讓咱們思考下: 爲何要選擇有序數組呢?
 
 
有序數組相對於無序數組的性能優點
 
在實現上,無序數組有序數組性能差別, 本質上是順序查找二分查找性能差別
 
由於二分查找是基於有序數組的,因此
  • 選擇無序數組實現字典, 也就意味着選擇了順序查找。
  • 而選擇有序數組實現字典, 表明着你能夠選擇二分查找(或插值查找等), 並享受查找性能上的巨大提高
 
關於順序查找和二分查找的區別能夠看下個人上一篇博客
 

三個成員變量,一個核心方法

 
咱們使用的有序數組類的代碼結構以下圖所示:
 
(二分查找字典)
public class BinarySearchST {
  int [] keys;     // 存儲key
  int [] vals;      // 存儲value 
  int N = 0;       // 計算字典長度
  public  BinarySearchST (int n) { // 根據輸入的數組長度初始化keys和vals
    keys = new int[n];
    vals = new int[n];
  }
 
  public int rank (int key) {  // 查找Key的位置並返回
      // 核心方法
  }
 
  public void put (int key, int val) {
      // 經過一些方式調用rank
  }
 
  public int get (int key) {
      // 經過一些方式調用rank
  }
 
  public int delete (int key) {
      // 經過一些方式調用rank
  }
}

 

 
三個成員變量: keys, vals, N
一個核心方法: rank (查找Key的位置),咱們下面介紹的大多數方法都要依賴於調用rank去實現。

無序鏈表實現的字典API

 

1. rank方法

幾乎全部基礎的方法,例如get,  put, delete都要依賴rank的調用來實現, 因此首先讓我來介紹下rank的實現
 
rank方法的代碼和普通的二分查找的代碼基本相同, 但有一點區別。
 
普通的二分查找
  • 查找成功,返回Key的位置
  • 查找失敗(Key不存在),返回 - 1
 
對應rank方法的實現
  • 查找成功,返回Key的位置
  • 查找失敗(Key不存在),返回小於給定Key的元素數量
 
爲何比起普通的二分查找,rank方法在後一點不是返回 -1 而是返回小於給定Key的元素數量呢? 由於對於某些調用rank方法,例如put方法來講,在Key不存在的時候也須要提供插入的位置信息, 因此固然不能只返回 -1了。
 
代碼以下:
 
  public int rank (int key) {
    int mid;
    int low= 0,high = N-1;
    while (low<=high) {
      mid = (low + high)/2;
      if(key<keys[mid]) {
        high = mid - 1;
      }
      else if(key>keys[mid]) {
        low = mid + 1;
      }
      else {
        return mid;  // 查找成功,返回Key的位置
      }
    }
    return low;  //  返回小於給定Key的元素數量
  }

 

 

 
關於普通二分查找的代碼能夠看下個人上一篇文章
 

2. put方法

put方法的參數
 
接收兩個參數key和val, 表示要插入的鍵值對
 
 
put方法的實現思路
 
調用rank方法返回位置下標 i, 而後根據給定的key判斷key == keys[i]是否成立
  • 若是key等於keys[i],說明查找成功, 那麼只要替換vals數組中的vals[i]爲新的val就能夠了,如圖A
  • 若是key不等於keys[i],那麼在字典中插入新的 key-val鍵值對,具體操做是將數組keys和vals中大於給定key和val的元素所有右移一位, 而後使keys[i]=key; vals[i] = val; 如圖B
 
如圖所示:
 
圖A
 

 

圖B
 

 

 
代碼以下:

 

  public void put (int key, int val) {
    int i = rank(key);
    if(i<N&&key == keys[i]) { // 查找到Key, 替換vals[i]爲val
      vals[i] = val;
      return ; // 返回
    }
    for (int j=N;j>i;j-- ) { // 未查找到Key
      keys[j] = keys[j-1]; // 將keys數組中小於key的值所有右移一位
      vals[j] = vals[j-1]; // 將vals數組中小於val的值所有右移一位
    }
    keys[i] = key; // 插入給定的key
    vals[i] = val; // 插入給定的val
    N++;
  }

 

 
 
if(i<N&&key == keys[i])  裏的 i<N的做用是什麼?
 
這個問題等價於: 不能直接用key == keys[i]做爲斷定條件嗎。
 
根據上面rank方法中二分查找的代碼可知, low和high交叉的時候,即恰好使low>high的時候,查找結束,因此查找結束時,low和high的關係多是下面這種狀況:
 

 

 
紅色部分表示現有字典的長度, 圖中low恰好 「越界」了,也即便low=N。(這裏的N是字典的長度)。
keys[0] ~ keys[N-1]是存儲key的元素, 而keys[N]則是還沒有存儲key的元素, 因此被默認初始化爲0。
 
在上面的前提下, 若是這時key又恰好是0的話, key == keys[i]  (i =N)將斷定爲 true, 這樣就會對處在字典以外的vals[N]執行 vals[N] = 0的操做, 這顯然是不正確的。
 
因此要添加i<N這個判斷條件
 
 
for循環裏的判斷條件
 
for循環裏執行的操做是: 將數組keys和vals中大於給定key和val的元素所有右移一位
可是要注意, 右移一位的順序是「從右到左」, 而不是「從左到右」 ,這意味着,咱們不能把
    for (int j=N;j>i;j-- ) {
    }

 

寫成:
    for (int j=i + 1;j<=N;j++ ) {
    }

 

由於這樣作會致使key/val右邊的元素變得徹底同樣的錯誤結果,如圖
 
 

 

 

3. get方法

 
輸入參數爲給定的key, 返回值是給定key對應的value值, 若是沒有查找到key,則返回 -1, 提示操做失敗。
 
要注意一點: 當 N = 0即字典爲空的時候,顯然不須要進行查找了, 能夠直接返回 -1
 
代碼以下:

 

  public boolean isEmpty () {
    return N == 0;
  } // 判斷字典是否爲空(不是數組!)
 
  public int get (int key) {
    if(isEmpty()) return -1; // 當字典爲空時,不須要進行查找,提示操做失敗
    int i = rank(key); 
    if(i<N&&keys[i] == key) {
      return vals[i]; // 當查找成功時候, 返回和key對應的value值
    }
    return -1; // 沒有查找到給定的key,提示操做失敗
  }

 

 

4. delete方法

 
delete方法的實現結合了get方法和put方法部分思路
  • 和get方法同樣, 查找前要經過isEmpty判斷字典是否爲空,是則無需刪除
  • 和put方法相似, 刪除要將keys/vals中大於key/value的元素所有「左移一位」
 
代碼以下:

 

  public int delete (int key) {
    if(isEmpty()) return -1; // 字典爲空, 無需刪除
    int i = rank(key);
    if(i<N&&keys[i] == key) {  // 當給定key存在時候,刪除該key-value對
      for(int j=i;j<=N-1;j++) {
        keys[j] = keys[j+1]; // 刪除key
        vals[j] = keys[j+1]; // 刪除value
      }
      N--; // 字典長度減1
      return key; // 刪除成功,返回被刪除的key
    }
    return -1;  // 未查找到給定key,刪除失敗
  }

 

 
將keys/vals中大於key/value的元素所有「左移一位」的時候, delete方法和put方法的for循環的遍歷方向是相反的。
 
不是

 

for (int j=N;j>i;j-- ) { }

 

而是
  for(int j=i;j<=N-1;j++) { }

 

不要寫錯了, 否則會形成以前提到的「右邊元素變得徹底同樣」的問題(這一點前面已經提過相似的點, 就不贅述了)
 

5. floor方法

輸入key,  返回keys數組小於等於給定key的最大值
 
floor意爲「地板」, 它指的是在字典中小於或等於給定值的最大值, 這聽起來可能有點繞, 例如對字典1,2,3,4,5。 輸入key爲4,則對應的floor值是4; 而輸入key爲3.5,則對應的floor值爲3。
 
實現的思路
 
首先要確認的是key是否存在
1. 若是輸入的key存在, 則返回等於該key的keys元素便可
2. 若輸入的key不存在, 則返回小於key的最大值: keys[rank(key)-1]
3. 在2中要注意一種特殊狀況: 輸入的key比字典中全部的元素都小, 這時顯然找不到它的floor值,因此返回 -1, 表示操做失敗
 
(假設rank = rank(key) ,三種狀況以下圖所示   )
 
 

 

 
  public int floor (int key) {
    int k  = get(key); // 查找key, 返回其value
    int rank = rank(key); // 返回給定key的位置
    if(k!=-1) return key; // 查找成功,返回值爲key
    else if(k==-1&&rank>0) return keys[rank-1]; // 未查找到key,同時給定key並無排在字典最左端,則返回小於key的前一個值
    else return -1; // 未查找到key,給定Key排在字典最左端,沒有floor值
  }

 

 

 

6. ceiling方法

輸入key,  返回keys數組大於等於給定key的最小值
 
ceiling方法的實現思路和floor方法相似
實現的思路
 
首先要確認的是key是否存在
1. 若是輸入的key存在, 則返回等於該key的keys元素便可, 即keys[rank(key)];
2. 若輸入的key不存在, 則返回大於key的最大值: keys[rank(key)];
3. 在2中要注意一種特殊狀況: 輸入的key比字典中全部的元素都大, 這時顯然找不到它的ceiling值,因此返回 -1, 表示操做失敗
 
【注意】1,2中狀況雖然不一樣,返回值卻能夠用同一個表達式,這和rank函數的編碼有關
 
 
(假設rank = rank(key) ,三種狀況以下圖所示   )
 

 

代碼java

 

  public int ceiling (int key) {
    int k = rank(key);
    if(k==N) return -1;
    return keys[k];
  }

 

 

7. size方法

返回字典的大小, 即N
 
代碼很簡單:
public int size () { return N; }

 

之因此能直接返回,是由於咱們在更改字典的操做時, 也相應地維護着N的狀態
  • 在聲明N的時候初始化了: int N = 0;
  • put操做完成時執行了N++
  • delete操做完成時執行了N--;
 

8. max, min,select方法

 

  public int max () { return keys[N-1]; } // 返回最大的key
 
  public int min () { return keys[0]; } // 返回最小的key
 
  public int select (int k) { // 根據下標返回key
    if(k<0||k>N) return -1;
    return keys[k];
  }

 

 

9. resize

在咱們的代碼裏, 字典長度是不斷增加的,而數組長度是固定的, 那麼這不禁得讓咱們心生憂慮:
若是數組滿了怎麼辦呢? 換句話說,從0增加的字典長度遇上了當前數組的長度。
 
由於java的數組長度在建立後不可調,因此咱們要新建一個更大的數組,將原來的數組元素拷貝到新數組裏面去。
 
由於字典涉及兩個數組: keys和vals,  因此這裏新建了兩個新的臨時數組tempKeys和tempVals, 轉移完成後, 使得
    keys = tempKeys;
    vals = tempVals;

 

就能夠了
 
  private void resize (int max) { // 調整數組大小
    int [] tempKeys = new int[max];
    int [] tempVals = new int[max];
    for(int i=0;i<N;i++) {
      tempKeys[i] = keys[i];
      tempVals[i] = vals[i];
    }
    keys = tempKeys;
    vals = tempVals;
  }

 

 

 
而後在put方法里加上:
 
// 字典長度遇上了數組長度,將數組長度擴大爲原來的2倍
if(N == keys.length) { resize(2*keys.length) }

 

 
有序數組實現字典的所有代碼以下:

 

/**
 * @Author: HuWan Peng
 * @Date Created in 11:54 2017/12/10
 */
public class BinarySearchST {
  int [] keys;
  int [] vals;
  int N = 0;
  public  BinarySearchST (int n) {
    keys = new int[n];
    vals = new int[n];
  }
 
  public int size () { return N; }
 
  public int max () { return keys[N-1]; } // 返回最大的key
 
  public int min () { return keys[0]; } // 返回最小的key
 
  public int select (int k) { // 根據下標返回key
    if(k<0||k>N) return -1;
    return keys[k];
  }
 
  public int rank (int key) {
    int mid;
    int low= 0,high = N-1;
    while (low<=high) {
      mid = (low + high)/2;
      if(key<keys[mid]) {
        high = mid - 1;
      }
      else if(key>keys[mid]) {
        low = mid + 1;
      }
      else {
        return mid;
      }
    }
    return low;
  }
 
  public void put (int key, int val) {
    int i = rank(key);
    if(i<N&&key == keys[i]) { // 查找到Key, 替換vals[i]爲val
      vals[i] = val;
      return ; // 返回
    }
    for (int j=N;j>i;j-- ) { // 未查找到Key
      keys[j] = keys[j-1]; // 將keys數組中小於key的值所有右移一位
      vals[j] = vals[j-1]; // 將vals數組中小於val的值所有右移一位
    }
    keys[i] = key; // 插入給定的key
    vals[i] = val; // 插入給定的val
    N++;
  }
 
  public boolean isEmpty () {
    return N == 0;
  } // 判斷字典是否爲空(不是數組!)
 
  public int get (int key) {
    if(isEmpty()) return -1; // 當字典爲空時,不須要進行查找,提示操做失敗
    int i = rank(key);
    if(i<N&&keys[i] == key) {
      return vals[i]; // 當查找成功時候, 返回和key對應的value值
    }
    return -1; // 沒有查找到給定的key,提示操做失敗
  }
 
  public int delete (int key) {
    if(isEmpty()) return -1; // 字典爲空, 無需刪除
    int i = rank(key);
    if(i<N&&keys[i] == key) {  // 當給定key存在時候,刪除該key-value對
      for(int j=i;j<=N-1;j++) {
        keys[j] = keys[j+1]; // 刪除key
        vals[j] = keys[j+1]; // 刪除value
      }
      N--; // 字典長度減1
      return key; // 刪除成功,返回被刪除的key
    }
    return -1;  // 未查找到給定key,刪除失敗
  }
 
  public int ceiling (int key) {
    int k = rank(key);
    if(k==N) return -1;
    return keys[k];
  }
 
  public int floor (int key) {
    int k  = get(key); // 查找key, 返回其value
    int rank = rank(key); // 返回給定key的位置
    if(k!=-1) return key; // 查找成功,返回值爲key
    else if(k==-1&&rank>0) return keys[rank-1]; // 未查找到key,同時給定key並無排在字典最左端,則返回小於key的前一個值
    else return -1; // 未查找到key,給定Key排在字典最左端,沒有floor值
  }
 
}

 

 

無序鏈表

 

字典類的結構

 

public class SequentialSearchST {
  Node first; // 頭節點
  int N = 0;  // 鏈表長度
  private class Node {  // 內部Node類
    int key;
    int value;
    Node next; // 指向下一個節點
    public Node (int key,int value,Node next) {
      this.key = key;
      this.value = value;
      this.next = next;
    }
  }
 
  public void put (int key, int value) {  }
 
  public int get (int key) {  }
 
  public void delete (int key) {  }
}

 

 
鏈表的組成單元是節點, 因此在 SequentialSearchST 類裏面定義了一個匿名內部Node類, 以便在外部類裏可以實例化節點對象。
 
節點對象有三個實例變量:  key,value和next,  key和value分別用來存儲字典的鍵和值, 而next用於創建節點和節點間的引用聯繫。
 
從頭節點first開始, 依次將本節點的next實例變量指向下一個節點, 從而創建一條字典鏈表。
 

 

 

鏈表和數組在實現字典的不一樣點

1. 鏈表節點自己自帶鍵和值屬性, 因此用一條鏈表就能實現字典, 而數組要使用兩個數組才能夠
2. 數組經過增減下標值遍歷元素, 而鏈表是依賴先後節點的引用關係進行迭代,從而實現節點的遍歷
 

無序鏈表實現的字典API

 

1. put 方法

 
代碼以下:

 

  public void put (int key, int value) {
    for(Node n=first;n!=null;n=n.next) { // 遍歷鏈表節點
      if(n.key == key) { // 查找到給定的key,則更新相應的value
        n.value = value;
        return;
      }
    }
    // 遍歷完全部的節點都沒有查找到給定key
   
    // 1. 建立新節點,並和原first節點創建「next」的聯繫,從而加入鏈表
    // 2. 將first變量修改成新加入的節點
    first = new Node(key,value,first);
    N++; // 增長字典(鏈表)的長度
  }

 

 
要理解
first = new Node(key,value,first);

 

這一句代碼, 能夠把它拆分紅兩段代碼來看:
Node newNode = new Node(key,value,first);  // 1. 建立新節點,並和原first節點創建「next」的聯繫
first = newNode  // 2. 將first變量修改成新加入的節點

 

如圖所示
 

 

 

2. get方法

  public int get (int key) {
    for(Node n=first;n!=null;n=n.next) {
      if(n.key==key) return n.value;
    }
    return -1;
  }

 

 

3. delete方法

  public void delete (int key) {
    for(Node n =first;n!=null;n=n.next) {
      if(n.next.key==key) {
        n.next = n.next.next;
        N--;
        return ;
      }
    }
  }

 

 
關鍵代碼
      if(n.next.key==key) {
        n.next = n.next.next;
      }

 

 
的邏輯圖示以下:
 

 

 
所有代碼:
/**
 * @Author: HuWan Peng
 * @Date Created in 17:26 2017/12/10
 */
public class SequentialSearchST {
  Node first; // 頭節點
  int N = 0;  // 鏈表長度
  private class Node {
    int key;
    int value;
    Node next; // 指向下一個節點
    public Node (int key,int value,Node next) {
      this.key = key;
      this.value = value;
      this.next = next;
    }
  }
 
  public int size () {
    return N;
  }
 
  public void put (int key, int value) {
    for(Node n=first;n!=null;n=n.next) { // 遍歷鏈表節點
      if(n.key == key) { // 查找到給定的key,則更新相應的value
        n.value = value;
        return;
      }
    }
    // 遍歷完全部的節點都沒有查找到給定key
 
    // 1. 建立新節點,並和原first節點創建「next」的聯繫,從而加入鏈表
    // 2. 將first變量修改成新加入的節點
    first = new Node(key,value,first);
    N++; // 增長字典(鏈表)的長度
  }
 
  public int get (int key) {
    for(Node n=first;n!=null;n=n.next) {
      if(n.key==key) return n.value;
    }
    return -1;
  }
 
  public void delete (int key) {
    for(Node n =first;n!=null;n=n.next) {
      if(n.next.key==key) {
        n.next = n.next.next;
        N--;
        return ;
      }
    }
  }
 
}

 

 

有序數組和無序鏈表實現字典的性能差別

 
有序數組和無序鏈表的性能差別, 本質上仍是順序查找和二分查找的性能差別。 正因如此, 有序數組的性能表現遠好於無序鏈表
 
下面展現的是《算法》書中的測試結果,成本模型是對小說文本tale.txt中5737個不一樣的鍵執行put操做時,所用的總比較次數。(鍵是不一樣的單詞,值是每一個單詞出現的次數)
 
無序鏈表實現的成本
 

 

 
有序數組實現的成本
 

 

 
做爲測試模型的tale.text的性質以下:

 

 【完】
 
 
相關文章
相關標籤/搜索