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


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

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

 
【注意】 為了讓代碼盡可能簡單, 我將字典的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) ,三種情況如下圖所示   )
 

 

代碼

 

  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的性質如下:

 

 【完】
 
 
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM