Go語言實戰_自定義集合Set
一、Go語言實戰——自定義集合Set
在Go語言中有作為Hash Table實現的字典(Map)型別,但標準資料型別中並沒有集合(Set)這種資料型別。比較 Set 和 Map 的主要特性,有類似特性如下:
它們中的元素都是不可重複的。
它們都只能用迭代的方式取出其中的所有元素。
對它們中的元素進行迭代的順序都是與元素插入順序無關的,同時也不保證任何有序性。
但是,它們之間也有一些區別,如下:
Set 的元素是一個單一的值,而 Map 的元素則是一個鍵值對。
Set 的元素不可重複指的是不能存在任意兩個單一值相等的情況。Map的元素不可重複指的是任意兩個鍵值對中的鍵的值不能相等。
從上面的特性可知,可以把集合型別(Set)作為字典型別(Map)的一個簡化版本。也就是說,可以用 Map 來編寫一個 Set 型別的實現。實際上,在Java語言中,java.util.HashSet 類就是用 java.util.HashMap 類作為底層支援的。所以這裡就從HashSet出發,逐步抽象出集合Set。
1. 定義HashSet
首先,在工作區的 src 目錄的程式碼包 basic/set(可以自行定義,但後面要保持一致)中,建立一個名為 hash_set.go 的原始碼檔案。
根據程式碼包 basic/set 可知,原始碼檔案 hash_set.go 的包宣告語句(關於這個一些規則可以看前面的系列博文)如下:
package set
上面提到可以將集合型別作為字典型別的一個簡化版本。現在我們的 HashSet 就以字典型別作為其底層的實現。HashSet 宣告如下:
type HashSet struct {
m map[interface{}]bool
}
如上宣告 HashSet 型別中的唯一的欄位的型別是 map[interface{}]bool。選擇這樣一個字典型別是因為通過將字典 m 的鍵型別設定為 interface{},讓 HashSet 的元素可以是任何型別的,因為這裡需要使用 m 的值中的鍵來儲存 HashSet 型別的元素值。那使用 bool 型別作為 m
從值的儲存形式的角度看,bool 型別值只佔用一個位元組。
從值的表示形式的角度看,bool 型別的值只有兩個—true 和 false。並且,這兩個值度都是預定義常量。
把 bool 型別作為值型別更有利於判斷字典型別值中是否存在某個鍵。例如:如果在向 m 的值新增鍵值對的時候總是以 true 作為其中的元素的值,則索引表示式 m[“a”] 的結果值總能體現出在m的值中是否包含鍵為“a”的鍵值對。對於 map[interface{}]bool 型別的值來說,如下:
if m["a"] {// 判斷是否m中包含鍵為“a”的鍵值對
//省略其他語句
}
如上 HashSet 型別的基本結構已確定了,現在考慮如何初始化 HashSet 型別值。由於字典型別的零值為 nil,而用 new 函式來建立一個 HashSet 型別值,也就是 new(HashSet).m 的求值結果將會是一個 nil (關於 new 函式可以查閱本人另一篇博文Go語言學習筆記5)。因此,這裡需要編寫一個專門用於建立和初始化 HashSet 型別值的函式,該函式宣告如下:
func NewHashSet() *HashSet {
return &HashSet{m: make(map[interface{}]bool)}
}
如上可以看到,使用make函式對欄位m進行了初始化。同時注意觀察函式 NewHashSet 的結果宣告的型別是 *HashSet 而不是 HashSet,目的是讓這個結果值的方法集合中包含呼叫接收者型別為 HashSet 或 *HashSet 的所有方法。這樣做的好處將在後面編寫 Set 介面型別的時候再予以說明。
2.實現HashSet的基本功能
依據其他程式語言中的 HashSet 型別可知,它們大部分應該提供的基本功能如下:
新增元素值。
刪除元素值。
清除所有元素值。
判斷是否包含某個元素值。
獲取元素值的數量。
判斷與其他HashSet型別值是否相同。
獲取所有元素值,即生成可迭代的快照。
獲取自身的字串表示形式。
現在對這些功能一一實現,讀者可自行實現,以下僅供參考。
(1).新增元素值
//方法Add會返回一個bool型別的結果值,以表示新增元素值的操作是否成功。
//方法Add的宣告中的接收者型別是*HashSet。
func (set *HashSet) Add(e interface{}) bool {
if !set.m[e] {//當前的m的值中還未包含以e的值為鍵的鍵值對
set.m[e] = true//將鍵為e(代表的值)、元素為true的鍵值對新增到m的值當中
return true //新增成功
}
return false //新增失敗
}
這裡使用 *HashSet 而不是 HashSet,主要是從節約記憶體空間的角度出發,分析如下:
- 當 Add 方法的接收者型別為 HashSet 的時候,對它的每一次呼叫都需要對當前 HashSet 型別值進行一次複製。雖然在 HashSet 型別中只有一個引用型別的欄位,但是這也是一種開銷。而且這裡還沒有考慮 HashSet 型別中的欄位可能會變得更多的情況。
- 當 Add 方法的接收者型別為 *HashSet 的時候,對它進行呼叫時複製的當前 *HashSet 的型別值只是一個指標值。在大多數情況下,一個指標值佔用的記憶體空間總會被它指向的那個其他型別的值所佔用的記憶體空間小。無論一個指標值指向的那個其他型別值所需的記憶體空間有多麼大,它所佔用的記憶體空間總是不變的。
(2).刪除元素值
//呼叫delete內建函式刪除HashSet內部支援的字典值
func (set *HashSet) Remove(e interface{}) {
delete(set.m, e)//第一個引數為目標字典型別,第二個引數為要刪除的那個鍵值對的鍵
}
(3).清除所有元素
//為HashSet中的欄位m重新賦值
func (set *HashSet) Clear() {
set.m = make(map[interface{}]bool)
}
如果接收者型別是 HashSet,該方法中的賦值語句的作用只是為當前值的某個複製品中的欄位m賦值而已,而當前值中的欄位 m 則不會被重新賦值。方法 Clear 中的這條賦值語句被執行之後,當前的 HashSet 型別值中的元素就相當於被清空了。已經與欄位 m 解除繫結的那個舊的字典值由於不再與任何程式實體存在繫結關係而成為了無用的資料。它會在之後的某一時刻被Go語言的垃圾回收器發現並回收。
(4).判斷是否包含某個元素值。
//方法Contains用於判斷其值是否包含某個元素值。
//這裡判斷結果得益於元素型別為bool的欄位m
func (set *HashSet) Contains(e interface{}) bool {
return set.m[e]
}
當把一個 interface{} 型別值作為鍵新增到一個字典值的時候,Go語言會先獲取這個 interface{} 型別值的實際型別(即動態型別),然後再使用與之對應的 hash 函式對該值進行 hash 運算,也就是說,interface{} 型別值總是能夠被正確地計算出 hash 值。但是字典型別的鍵不能是函式型別、字典型別或切片型別,否則會引發一個執行時恐慌,並提示如下:
panic: runtime error: hash of unhashable type <某個函式型別、字典型別或切片型別的名稱>
(5).獲取元素值的數量。
//方法Len用於獲取HashSet元素值數量
func (set *HashSet) Len() int {
return len(set.m)
}
(6).判斷與其他HashSet型別值是否相同。
//方法Same用來判斷兩個HashSet型別值是否相同
func (set *HashSet) Same(other *HashSet) bool {
if other == nil {
return false
}
if set.Len() != other.Len() {
return false
}
for key := range set.m {
if !other.Contains(key) {
return false
}
}
return true
}
兩個 HashSet 型別值相同的必要條件是,它們包含的元素應該是完全相同的。由於 HashSet 型別值中的元素的迭代順序總是不確定的,所以也就不用在意兩個值在這方面是否一致。如果要判斷兩個 HashSet 型別值是否是同一個值,就需要利用指標運算進行記憶體地址的比較。
(7).獲取所有元素值,即生成可迭代的快照。
所謂 快照,就是目標值在某一個時刻的映像。對於一個 HashSet 型別值來說,它的快照中的元素迭代順序總是可以確定的,快照只反映了該 HashSet 型別值在某一個時刻的狀態。另外,還需要從元素可迭代且順序可確定的資料型別中選取一個作為快照的型別。這個型別必須是以單值作為元素的,所以字典型別最先別排除。又由於 HashSet 型別值中的元素數量總是不固定的,所以無法用一個數組型別的值來表示它的快照。如上分析可知,Go語言中可以使用的快照的型別應該是一個切片型別或者通道型別。
//方法Elements用於生成快照
func (set *HashSet) Elements() []interface{} {
initialLen := len(set.m)//獲取HashSet中欄位m的長度,即m中包含元素的數量
//初始化一個[]interface{}型別的變數snapshot來儲存m的值中的元素值
snapshot := make([]interface{}, initialLen)
actualLen := 0
//按照既定順序將迭代值設定到快照值(變數snapshot的值)的指定元素位置上,這一過程並不會建立任何新值。
for key := range set.m {
if actualLen < initialLen {
snapshot[actualLen] = key
} else {//m的值中的元素數量有所增加,使得實際迭代的次數大於先前初始化的快照值的長度
snapshot = append(snapshot, key)//使用append函式向快照值追加元素值。
}
actualLen++//實際迭代的次數
}
//對於已被初始化的[]interface{}型別的切片值來說,未被顯示初始化的元素位置上的值均為nil。
//m的值中的元素數量有所減少,使得實際迭代的次數小於先前初始化的快照值的長度。
//這樣快照值的尾部存在若干個沒有任何意義的值為nil的元素,
//可以通過snapshot = snapshot[:actualLen]將無用的元素值從快照值中去掉。
if actualLen < initialLen {
snapshot = snapshot[:actualLen]
}
return snapshot
}
注意:在 Elements 方法中針對併發訪問和修改 m 的值的情況採取了一些措施。但是由於m的值本身並不是併發安全的,所以並不能保證 Elements 方法的執行總會準確無誤。要做到真正的併發安全,還需要一些輔助的手段,比如讀寫互斥量。
(8).獲取自身的字串表示形式。
//這個String方法的簽名算是一個慣用法。
//程式碼包fmt中的列印函式總會使用引數值附帶的具有如此簽名的String方法的結果值作為該引數值的字串表示形式。
func (set *HashSet) String() string {
var buf bytes.Buffer//作為結果值的緩衝區
buf.WriteString("HashSet{")
first := true
for key := range set.m {
if first {
first = false
} else {
buf.WriteString(",")
}
buf.WriteString(fmt.Sprintf("%v", key))
}
//n := 1
//for key := range set.m {
// buf.WriteString(fmt.Sprintf("%v", key))
// if n == len(set.m) {//最後一個元素的後面不新增逗號
// break;
// } else {
// buf.WriteString(",")
// }
// n++;
//}
buf.WriteString("}")
return buf.String()
}
如上已經完整地編寫了一個具備常用功能的Set的實現型別,後面將講解更多的高階功能來完善它。
3.高階功能
集合 Set 的真包含的判斷功能。根據集合代數中的描述,如果集合 A 真包含了集合 B,那麼就可以說集合 A 是集合 B 的一個超集。
// 判斷集合 set 是否是集合 other 的超集
func (set *HashSet) IsSuperset(other *HashSet) bool {
if other == nil {//如果other為nil,則other不是set的子集
return false
}
setLen := set.Len()//獲取set的元素值數量
otherLen := other.Len()//獲取other的元素值數量
if setLen == 0 || setLen == otherLen {//set的元素值數量等於0或者等於other的元素數量
return false
}
if setLen > 0 && otherLen == 0 {//other為元素數量為0,set元素數量大於0,則set也是other的超集
return true
}
for _, v := range other.Elements() {
if !set.Contains(v) {//只要set中有一個包含other中的資料,就返回false
return false
}
}
return true
}
集合的運算包括並集、交集、差集和對稱差集。
並集運算是指把兩個集合中的所有元素都合併起來並組合成一個集合。
交集運算是指找到兩個集合中共有的元素並把它們組成一個集合。
集合 A 對集合 B 進行差集運算的含義是找到只存在於集合 A 中但不存在於集合 B 中的元素並把它們組成一個集合。
對稱差集運算與差集運算類似但有所區別。對稱差集運算是指找到只存在於集合 A 中但不存在於集合 B 中的元素,再找到只存在於集合 B 中但不存在於集合 A 中的元素,最後把它們合併起來並組成一個集合。
實現並集運算
// 生成集合 set 和集合 other 的並集
func (set *HashSet) Union(other *HashSet) *HashSet {
if set == nil || other == nil {// set和other都為nil,則它們的並集為nil
return nil
}
unionedSet := NewHashSet()//新建立一個HashSet型別值,它的長度為0,即元素數量為0
for _, v := range set.Elements() {//將set中的元素新增到unionedSet中
unionedSet.Add(v)
}
if other.Len() == 0 {
return unionedSet
}
for _, v := range other.Elements() {//將other中的元素新增到unionedSet中,如果遇到相同,則不新增(在Add方法邏輯中體現)
unionedSet.Add(v)
}
return unionedSet
}
實現交集運算
// 生成集合 set 和集合 other 的交集
func (set *HashSet) Intersect(other *HashSet) *HashSet {
if set == nil || other == nil {// set和other都為nil,則它們的交集為nil
return nil
}
intersectedSet := NewHashSet()//新建立一個HashSet型別值,它的長度為0,即元素數量為0
if other.Len() == 0 {//other的元素數量為0,直接返回intersectedSet
return intersectedSet
}
if set.Len() < other.Len() {//set的元素數量少於other的元素數量
for _, v := range set.Elements() {//遍歷set
if other.Contains(v) {//只要將set和other共有的新增到intersectedSet
intersectedSet.Add(v)
}
}
} else {//set的元素數量多於other的元素數量
for _, v := range other.Elements() {//遍歷other
if set.Contains(v) {//只要將set和other共有的新增到intersectedSet
intersectedSet.Add(v)
}
}
}
return intersectedSet
}
差集
// 生成集合 set 對集合 other 的差集
func (set *HashSet) Difference(other *HashSet) *HashSet {
if set == nil || other == nil {// set和other都為nil,則它們的差集為nil
return nil
}
differencedSet := NewHashSet()//新建立一個HashSet型別值,它的長度為0,即元素數量為0
if other.Len() == 0 { // 如果other的元素數量為0
for _, v := range set.Elements() {//遍歷set,並將set中的元素v新增到differencedSet
differencedSet.Add(v)
}
return differencedSet//直接返回differencedSet
}
for _, v := range set.Elements() {//other的元素數量不為0,遍歷set
if !other.Contains(v) {//如果other中不包含v,就將v新增到differencedSet中
differencedSet.Add(v)
}
}
return differencedSet
}
對稱差集
// 生成集合 one 和集合 other 的對稱差集
func (set *HashSet) SymmetricDifference(other *HashSet) *HashSet {
if set == nil || other == nil {// set和other都為nil,則它們的對稱差集為nil
return nil
}
diffA := set.Difference(other)//生成集合 set 對集合 other 的差集
if other.Len() == 0 {//如果other的元素數量等於0,那麼other對集合set的差集為空,則直接返回diffA
return diffA
}
diffB := other.Difference(set)//生成集合 other 對集合 set 的差集
return diffA.Union(diffB)//返回集合 diffA 和集合 diffB 的並集
}
4.進一步重構
目前所實現的 HashSet 型別提供了一些必要的集合操作功能,但是不同應用場景下可能會需要使用功能更加豐富的集合型別。當有多個集合型別的時候,應該在它們之上抽取出一個介面型別以標識它們共有的行為方式。依據 HashSet 型別的宣告,可以如下宣告 Set 介面型別:
type Set interface {
Add(e interface{}) bool
Remove(e interface{})
Clear()
Contains(e interface{}) bool
Len() int
Same(other Set) bool
Elements() []interface{}
String() string
}
注意: Set 中的 Same 方法的簽名與附屬於 HashSet型別的 Same 方法有所不同。這裡不能再介面型別的方法的簽名中包含它的實現型別。因此這裡的改動如下:
func (set *HashSet) Same(other Set) bool {
//省略若干語句
}
修改了 Same 方法的簽名,目的是讓 *HashSet 型別成為 Set 介面型別的一個實現型別。
高階功能的方法應該適用於所有的實現型別,完全可以抽離出成為獨立的函式。並且,也不應該在每個實現型別中重複地實現這些高階方法。如下為改造後的 IsSuperset 方法的宣告:
// 判斷集合 one 是否是集合 other 的超集
// 讀者應重點關注IsSuperset與附屬於HashSet型別的IsSuperset方法的區別
func IsSuperset(one Set, other Set) bool {
if one == nil || other == nil {
return false
}
oneLen := one.Len()
otherLen := other.Len()
if oneLen == 0 || oneLen == otherLen {
return false
}
if oneLen > 0 && otherLen == 0 {
return true
}
for _, v := range other.Elements() {
if !one.Contains(v) {
return false
}
}
return true
}
其餘的讀者可以試著對上面附屬於 HashSet 型別的高階方法進行修改,實現更完善的集合Set。