參考資料
《算法(java)》 — — Robert Sedgewick, Kevin Wayne
《數據結構》 — — 嚴蔚敏
這篇文章主要介紹實現字典的兩種方式
- 有序數組
- 無序鏈表
(二叉樹的實現方案將在下一篇文章介紹)
【注意】 為了讓代碼盡可能簡單, 我將字典的Key和Value的值也設置為int類型,而不是對象, 所以在下面代碼中, 處理“操作失敗”的情況的時候,是返回 -1 而不是返回 null 。 所以代碼默認不能選擇 -1作為 Key或者Value
(在實際場景中,我們會將int類型的Key替換為實現Compare接口的類的對象,同時將“失敗”時的返回值從-1設為null,這時是沒有這個問題的)
字典的定義和相關操作
字典又叫查找表(Search Table), 是由同一類型的數據元素構成的集合, 由於集合中的數據元素存在着完全松散的關系, 因此查找表是一種非常靈便的數據結構。
對查找表經常進行的操作有:
- 查詢某個特定的數據是否在查找表中
- 檢索某個特定的數據元素的各種屬性
- 在查找表中插入一個數據元素
- 從查找表中刪除某個數據元素
若對查找表只做1,2兩種查找的操作, 這樣的查找表被稱為“靜態查找表”
若在查找過程中同時還進行了3,4操作, 這樣的查找表被稱為“動態查找表”
有序數組實現字典
有序數組實現字典思路
字典,有最關鍵的兩個類型的值: Key和Value。 但是一個數組顯然只能存儲一個類型的值呀, 正因如此:
首先,我們需要預備兩個數組; 其次,我們要在每次操作中同步兩個數組的狀態。
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的性質如下:

【完】

