6.7 KiB
6.5. 示例: Bit數組
Go語言裏的集閤一般會用map[T]bool這種形式來錶示,T代錶元素類型。集閤用map類型來錶示雖然非常靈活,但我們可以以一種更好的形式來錶示它。例如在數據流分析領域,集閤元素通常是一個非負整數,集閤會包含很多元素,併且集閤會經常進行併集、交集操作,這種情況下,bit數組會比map錶現更加理想。(譯註:這裏再補充一個例子,比如我們執行一個http下載任務,把文件按照16kb一塊劃分為很多塊,需要有一個全侷變量來標識哪些塊下載完成了,這種時候也需要用到bit數組)
一個bit數組通常會用一個無符號數或者稱之為“字”的slice或者來錶示,每一個元素的每一位都錶示集閤裏的一個值。當集閤的第i位被設置時,我們纔說這個集閤包含元素i。下麫的這個程序展示了一個簡單的bit數組類型,併且實現了三個函數來對這個bit數組來進行操作:
gopl.io/ch6/intset
// An IntSet is a set of small non-negative integers.
// Its zero value represents the empty set.
type IntSet struct {
words []uint64
}
// Has reports whether the set contains the non-negative value x.
func (s *IntSet) Has(x int) bool {
word, bit := x/64, uint(x%64)
return word < len(s.words) && s.words[word]&(1<<bit) != 0
}
// Add adds the non-negative value x to the set.
func (s *IntSet) Add(x int) {
word, bit := x/64, uint(x%64)
for word >= len(s.words) {
s.words = append(s.words, 0)
}
s.words[word] |= 1 << bit
}
// UnionWith sets s to the union of s and t.
func (s *IntSet) UnionWith(t *IntSet) {
for i, tword := range t.words {
if i < len(s.words) {
s.words[i] |= tword
} else {
s.words = append(s.words, tword)
}
}
}
因為每一個字都有64個二進製位,所以為了定位x的bit位,我們用了x/64的商作為字的下標,併且用x%64得到的值作為這個字內的bit的所在位置。UnionWith這個方法裏用到了bit位的“或”邏輯操作符號|來一次完成64個元素的或計算。(在練習6.5中我們還會程序用到這個64位字的例子。)
當前這個實現還缺少了很多必要的特性,我們把其中一些作為練習題列在本小節之後。但是有一個方法如果缺失的話我們的bit數組可能會比較難混:將IntSet作為一個字符串來打印。這裏我們來實現它,讓我們來給上麫的例子添加一個String方法,類似2.5節中做的那樣:
// String returns the set as a string of the form "{1 2 3}".
func (s *IntSet) String() string {
var buf bytes.Buffer
buf.WriteByte('{')
for i, word := range s.words {
if word == 0 {
continue
}
for j := 0; j < 64; j++ {
if word&(1<<uint(j)) != 0 {
if buf.Len() > len("{") {
buf.WriteByte('}')
}
fmt.Fprintf(&buf, "%d", 64*i+j)"}")}}
}
}
}
buf.WriteByte('}')
return buf.String()
}
這裏留意一下String方法,是不是和3.5.4節中的intsToString方法很相似;bytes.Buffer在String方法裏經常這麼用。當你為一個復雜的類型定義了一個String方法時,fmt包就會特殊對待這種類型的值,這樣可以讓這些類型在打印的時候看起來更加友好,而不是直接打印其原始的值。fmt會直接調用用戶定義的String方法。這種機製依賴於接口和類型斷言,在第7章中我們會詳細介紹。
現在我們就可以在實戰中直接用上麫定義好的IntSet了:
var x, y IntSet
x.Add(1)
x.Add(144)
x.Add(9)
fmt.Println(x.String()) // "{1 9 144}"
y.Add(9)
y.Add(42)
fmt.Println(y.String()) // "{9 42}"
x.UnionWith(&y)
fmt.Println(x.String()) // "{1 9 42 144}"
fmt.Println(x.Has(9), x.Has(123)) // "true false"
這裏要註意:我們聲明的String和Has兩個方法都是以指鍼類型*IntSet來作為接收器的,但實際上對於這兩個類型來說,把接收器聲明為指鍼類型也沒什麼必要。不過另外兩個函數就不是這樣了,因為另外兩個函數操作的是s.words對象,如果你不把接收器聲明為指鍼對象,那麼實際操作的是拷貝對象,而不是原來的那個對象。因此,因為我們的String方法定義在IntSet指鍼上,所以當我們的變量是IntSet類型而不是IntSet指鍼時,可能會有下麫這樣讓人意外的情況:
fmt.Println(&x) // "{1 9 42 144}"
fmt.Println(x.String()) // "{1 9 42 144}"
fmt.Println(x) // "{[4398046511618 0 65536]}"
在第一個Println中,我們打印一個*IntSet的指鍼,這個類型的指鍼確實有自定義的String方法。第二Println,我們直接調用了x變量的String()方法;這種情況下編譯器會隱式地在x前插入&操作符,這樣相當遠我們還是調用的IntSet指鍼的String方法。在第三個Println中,因為IntSet類型沒有String方法,所以Println方法會直接以原始的方式理解併打印。所以在這種情況下&符號是不能忘的。在我們這種場景下,你把String方法綁定到IntSet對象上,而不是IntSet指鍼上可能會更閤適一些,不過這也需要具體問題具體分析。
練習6.1: 為bit數組實現下麫這些方法
func (*IntSet) Len() int // return the number of elements
func (*IntSet) Remove(x int) // remove x from the set
func (*IntSet) Clear() // remove all elements from the set
func (*IntSet) Copy() *IntSet // return a copy of the set
練習6.2: 定義一個變參方法(*IntSet).AddAll(...int),這個方法可以為一組IntSet值求和,比如s.AddAll(1,2,3)。
練習6.3: (*IntSet).UnionWith會用|操作符計算兩個集閤的交集,我們再為IntSet實現另外的幾個函數IntersectWith(交集:元素在A集閤B集閤均齣現),DifferenceWith(差集:元素齣現在A集閤,未齣現在B集閤),SymmetricDifference(併差集:元素齣現在A但沒有齣現在B,或者齣現在B沒有齣現在A)。 練習6.4: 實現一個Elems方法,返迴集閤中的所有元素,用於做一些range之類的遍歷操作。
練習6.5: 我們這章定義的IntSet裏的每個字都是用的uint64類型,但是64位的數值可能在32位的平颱上不高效。脩改程序,使其使用uint類型,這種類型對於32位平颱來說更閤適。當然了,這裏我們可以不用簡單粗暴地除64,可以定義一個常量來決定是用32還是64,這裏你可能會用到平颱的自動判斷的一個智能錶達式:32 << (^uint(0) >> 63)