最近嘗試在 B 站錄些小視頻,個人 B 站主頁。錄視頻當是爲了完全搞懂某個知識點的最後一步吧,同時也但願能習得一些額外的能力。在講 Go 如何實現 bitset 的時候,發現這塊內容有點難講。思考後,我決定經過文字輔以視頻的方式說明,因而就寫了這篇文章。git
相關代碼已經放在了 github,地址以下:go-set-examplegithub
若是發現有什麼不妥的地方,歡迎大佬們指正,感謝。函數
以前我已經寫過一篇題爲 Go 中如何使用 Set 的文章,其中介紹了 bitset 一種最簡單的應用場景,狀態標誌位,順便還提了下 bitset 的實現思路。性能
狀態標誌和通常的集合有什麼區別呢?學習
個人總結是主要一點,那就是狀態標誌中元素個數一般是固定的。而通常的集合中,元素個數一般是動態變化的。這會致使什麼問題?ui
通常,咱們使用一個整數就足以表示狀態標誌中的全部狀態,最大的 int64 類型,足足有 64 個二進制位,最多能夠包含 64 個元素,徹底足夠使用。但若是是集合,元素數量和值一般都不固定。spa
好比一個 bitset 集合最初可能只包含 一、二、4 幾個元素,只要一個 int64 就能表示。以下:3d
但若是再增長了一個元素,好比 64(一個 int64 的表示範圍是 0-63),這已經超出了一個 int64 能表示的範圍。該怎麼辦?code
一個 int64 沒法表示,那就用多個唄。此時的結構以下:cdn
一個 int64 切片正好符合上面的結構。那咱們就能夠定義一個新的類型 BitSet
,以下:
type BitSet struct {
data []int64
size int
}
複製代碼
data
成員用於存放集合元素,切片的特色就是能動態擴容。
還有,由於 bitset 中元素個數沒法經過 len
函數獲取,而具體的方法相對複雜一點,可增長一個 size 字段記錄集合元素的個數。而後就能夠增長一個 Size
方法。
func (set *BitSet) Size() int {
return set.size
}
複製代碼
定義好了 BitSet
類型,又產生了一個新的問題,如何定位存放元素的位置?在標誌位的場景下,元素的值便是位置,因此這個問題不用考慮。但通用的集合不是如此。
先看下 BitSet
的二進制位的分佈狀況。
相似行列的效果,假設用 index
表示行(索引),pos
表示列(位置)。切片索引從 0 到 n,n 與集合中的最大元素有關。
接下來肯定 index
和 pos
的值。其實,以前的文章已經介紹過了。
index
可經過元素值整除字長,即 value / 64
,轉化爲高效的位運算,即 value >> 6
。
pos
能夠經過元素值取模字長,即 value % 64
,轉化爲高效的位運算,即 value & 0x3f
,獲取對應位置,而後用 1 << uint(value % 0xf)
便可將位置轉化爲值。
理論再多,都不如 show me your code
。開始編寫代碼吧!
先定義一些常量。
const (
shift = 6 // 2^n = 64 的 n
mask = 0x3f // n=6,即 2^n - 1 = 63,即 0x3f
)
複製代碼
就是前面提到的用於計算 index
和 pos
的兩個常量。
提供兩個函數,用於方便 index
和 pos
上對應值的計算,代碼以下:
func index(n int) int {
return n >> shift
}
// 相對於標誌位使用場景中某個標誌的值
func posVal(n int) uint64 {
return 1 << uint(n&mask)
}
複製代碼
提供了一個函數,用於建立初始 BitSet
,且支持設置初始的元素。
函數原型以下:
func NewBitSet(ns ...int) *BitSet {
// ...
}
複製代碼
輸出參數 ns
是一個 int
類型的變長參數,用於設置集合中的初始值。
若是輸入參數 ns
爲空的話,new(BitSet)
返回空集合便可。
if len(ns) == 0 {
return new(BitSet)
}
複製代碼
若是長度非空,則要計算要開闢的空間,經過計算最大元素的 index
可肯定。
// 計算多 bitset 開闢多個空間
max := ns[0]
for _, n := range ns {
if n > max {
max = n
}
}
// 若是 max < 0,直接返回空。
if max < 0 {
return new(BitSet)
}
// 經過 max >> shift+1 計算最大值 max 所在 index
// 而 index + 1 即爲要開闢的空間
s := &BitSet{
data: make([]int64, index(max)+1),
}
複製代碼
如今,能夠向 BitSet
中添加元素了。
for _, n := range ns {
if n >= 0 {
// e >> shift 獲取索引位置,即行,通常叫 index
// e&mask 獲取所在列,通常叫 pos,F1 0 F2 1
s.data[n>>shift] |= posVal(n)
// 增長元素個數
s.size++
}
}
// 返回建立的 BitSet
return s
複製代碼
元素已經所有添加完成!
接下來是重點了,爲 BitSet
增長一些方法。主要是分紅兩類,一是常見的增刪查等基礎方法,二是集合的特有操做,交併差。
主要是幾個方法,分別是 Add
(增長)、Clear
(清除) 、Contains
(檢查)以及返回元素個數。若是要有更好的性能和空間使用率,Add
和 Clear
還有考慮靈活的。
先講 Contains
,即檢查是否存在某個元素。
函數定義以下:
func (set *BitSet) Contains(n int) bool {
...
}
複製代碼
輸入參數便是要檢查的元素,輸出是檢查結果。
實現代碼以下:
// 獲取元素對應的 int64 的位置,若是超出 data 能表示的範圍,直接返回。
i := index(n)
if i >= len(set.data) {
return false
}
return set.data[i]&posVal(n) != 0
複製代碼
核心就是 set.data[i]&posVal(n) != 0
這句代碼,經過它判斷是否存在指定元素。
再談 Clear
,從集合中清除某個元素,
函數定義以下:
func (set *BitSet) Clear(n int) *BitSet {
// ...
}
複製代碼
實現代碼以下:
// 元素不能小於 0
if n < 0 {
return set
}
// 計算切片索引位置,若是超出當前索引表示的範圍,返回便可。
i := index(n)
if i >= len(set.data) {
return set
}
// 檢查是否存在元素
if d[i]&posVal(n) != 0 {
set.data[i] &^= posVal(n)
set.size--
}
複製代碼
經過 &^
實現指定位清除。同時要記得set.size--
更新集合中元素的值。
上面的實現中有個瑕疵,就是若是一些爲被置零後,可能會出現高位所有爲 0,此時應要經過 reslice 收縮 data 空間。
具體怎麼操做呢?
經過對 set.data
執行檢查,從高位檢查首個不爲 0 的 uint64
,以此爲基準進行 reslice
。假設,這個方法名爲 trim
。
實現代碼以下:
func (set *Set) trim() {
d := set.data
n := len(d) - 1
for n >= 0 && d[n] == 0 {
n--
}
set.data = d[:n+1]
}
複製代碼
接着,再說 Add
方法,向集合中添加某個元素。
函數定義以下:
func (set *BitSet) Add(n int) *BitSet {
...
}
複製代碼
增長元素的話,先檢查下是否有足夠空間存放新元素。若是新元素的索引位置不在當前 data
表示的範圍,則要進行擴容。
實現以下:
// 檢測是否有足夠的空間存放新元素
i := index(n)
if i >= len(set.data) {
// 擴容大小爲 i+1
ndata := make([]uint64, i+1)
copy(ndata, set.data)
set.data = ndata
}
複製代碼
一切準備就緒後,接下來就能夠進行置位添加了。在添加前,先檢測下集合是否已經包含了該元素。在添加完成後,還要記得要更新下 size
。
實現代碼以下:
if set.data[i]&posVal(n) == 0 {
// 設置元素到集合中
set.data[i] |= posVal(n)
s.size++
}
複製代碼
好了!基礎的方法就介紹這麼多吧。
固然,這裏的方法還能夠增長更多,好比查找當前元素的下一個元素,將某個範圍值都添加進集合等等等。
介紹完了基礎的方法,再繼續介紹集合一些特有的方法,交併差。
在正式介紹這些方法前,先引入一個輔助方法,用於計算集合中的元素個數。之因此要引入這個方法,是由於交併差沒有辦法像以前在增刪的時候更新 size
,要從新計算一下。
實現代碼以下:
func (set *BitSet) computeSize() int {
d := set.data
n := 0
for i, len := 0, len(d); i < len; i++ {
if w := d[i]; w != 0 {
n += bits.OnesCount64(w)
}
}
return n
}
複製代碼
這是一個不可導出的方法,只能內部使用。遍歷 data
的每一個 uint64
,若是非 0,則統計其中的元素個數。元素個數統計用到了標準庫中的 bits.OnesCount64
方法。
繼續介紹集合的幾個方法,它們的定義相似,都是一個 BitSet
與另外一個 BitSet
的運算,以下:
// 交集
func (set *BitSet) Intersect(other *BitSet) *BitSet {
// ...
}
// 並集
func (set *BitSet) Union(other *BitSet) *BitSet {
// ...
}
// 差集
func (set *BitSet) Difference(other *BitSet) *BitSet {
// ...
}
複製代碼
先介紹 Intersect
,即計算交集的方法。
一個重要前提,由於交集是 與運算
,結果確定位於兩個參與運算的那個小範圍集合中,因此,開闢空間和遍歷能夠縮小到這個範圍進行。
實現代碼以下:
// 首先,獲取這個小範圍的集合的長度
minLen := min(len(set.data), len(other.data))
// 以 minLen 開闢空間
intersectSet := &BitSet{
data: make([]uint64, minLen),
}
// 以 minLen 進行遍歷計算交集
for i := minLen - 1; i >= 0; i-- {
intersectSet.data[i] = set.data[i] & other.data[i]
}
intersectSet.size = set.computeSize()
複製代碼
這裏經過遍歷逐一對每一個 uint64
執行 與運算
實現交集。在完成操做後,記得計算下 intersectSet
中元素個數,即 size
的值。
再介紹並集 Union
方法。
它的計算邏輯和 Intersect
相反。並集結果所佔據的空間和以參與運算的兩個集合的較大集合爲準。
實現代碼以下:
var maxSet, minSet *BitSet
if len(set.data) > len(other.data) {
maxSet, minSet = set, other
} else {
maxSet, minSet = other, set
}
unionSet := &BitSet{
data: make([]uint64, len(maxSet.data)),
}
複製代碼
建立的 unionSet
中,data
分配空間是 len(maxSet.data)
。
由於兩個集合中的全部元素知足最終結果,但 maxSet
的高位部分沒法經過遍歷和 minSet
執行運算,直接拷貝進結果中便可。
minLen := len(minSet.data)
copy(unionSet.data[minLen:], maxSet.data[minLen:])
複製代碼
最後,遍歷兩個集合 data
,經過 或運算
計算剩餘的部分。
for i := 0; i < minLen; i++ {
unionSet.data[i] = set.data[i] | other.data[i]
}
// 更新計算 size
unionSet.size = unionSet.computeSize()
複製代碼
介紹最後一個與集合相關的方法,Difference
,即差集操做。
差集計算結果 differenceSet
的分配空間由被減集合 set
決定。其餘的操做和 Intersect
和 Union
相似,位運算經過 &^
實現。
setLen := len(set.data)
differenceSet := &BitSet{
data: make([]uint64, setLen),
}
複製代碼
若是 set
的長度大於 other
,則須要先將沒法進行差集運算的內容拷貝下。
minLen := setLen
if setLen > otherLen {
copy(differenceSet.data[otherLen:], set.data[otherLen:])
minLen = otherLen
}
複製代碼
記錄下 minLen
用於接下來的位運算。
// 遍歷 data 執行位運算。
for i := 0; i < minLen; i++ {
differenceSet.data[i] = set.data[i] &^ other.data[i]
}
differenceSet.size = differenceSet.computeSize()
複製代碼
單獨說下集合元素的遍歷,以前查看集合元素一直都是經過 Contains
方法檢查是否存在。能不能把集合中的每一個元素所有遍歷出來呢?
再看下 bitset 的結構,以下:
上面的集合中,第一行 int64
的第一個元素是 1,尾部有一位被置零。經過觀察發現,前面有幾個 0,第一個元素就是什麼值。
第二行 int64
的第一元素尾部沒有 0,那它的值就是 0 嗎?固然不是,還有前面一行的 64 位基礎,因此它的值是 64+0。
總結出什麼規律了嗎?笨,理論功底太差,滿腦子明白,就是感受寫不清楚。看代碼吧!
先看函數定義:
func (set *BitSet) Visit(do func(int) (skip bool)) (aborted bool) {
//...
}
複製代碼
輸入參數是一個回調函數,經過它獲取元素的值,否則每次都要寫一大串循環運算邏輯,不太可能。回調函數的返回值 bool
,代表是否繼續遍歷。Visit
的返回值代表是函數是非正常結束的。
實現代碼以下:
d := set.data
for i, len := 0, len(d); i < len; i++ {
w := d[i]
if w == 0 {
continue
}
// 理論功力很差,不知道怎麼描述了。哈哈
// 這小段代碼能夠理解爲從元素值到 index 的逆運算,
// 只不過獲得的值是諸如 0、6四、128 的第一個位置的值。
// 0 << 6,仍是 0,1 << 6 就是 64,2 << 6 的就是 128
n := i << shift
for w != 0 {
// 000.....000100 64~128 的話,表示 66,即 64 + 2,這個 2 能夠由結尾 0 的個數肯定
// 那怎麼獲取結果 0 的個數呢?可使用 bits.TrailingZeros64 函數
b := bits.TrailingZeros64(w)
if do(n + b) {
return true
}
// 將已經檢查的位清零
// 爲了保證尾部 0 的個數能表明元素的值
w &^= 1 << uint64(b)
}
}
複製代碼
使用也很是方便,示例代碼以下:
set := NewBitSet(1, 2, 10, 99)
set.Visit(func(n int) bool {
fmt.Println(n)
return false
})
複製代碼
好了,就說這麼多吧!
本篇文章主要是參考了幾個開源包的基礎上,介紹了 bitset 的實現,好比 bit 和 bitset 等。總的來講,位運算就是沒有那麼直觀,感受腦子不夠用了。
最近在深挖 Go 語言,漸漸發現了本身的一些短板,理論知識的缺失漸漸顯現。好比,我在寫這篇文章的時候,瞭解到 bits 標準庫中用到了 德布魯因序列
,此前並不清楚。前幾天在研究如何進行 JSON 解析時,瞭解到了有限狀態機這個知識,Go 的源碼中簡直完美體現了這個知識的重要性。在學習 Go 語言組成的時候,知道了 擴展巴克斯範式
,不少語言的文檔都是這種方式來表現語法,一門瞭然。
做爲一名電子信息專業畢業的專科生,算不上計算機的科班出生,工做六年才瞭解到這些知識,有點煩悶,也有點興奮。若是說前六年是廣度的提高,那麼,接下來就應該是更加專一的研究了。