詳細介紹 Go 中如何實現 bitset

最近嘗試在 B 站錄些小視頻,個人 B 站主頁。錄視頻當是爲了完全搞懂某個知識點的最後一步吧,同時也但願能習得一些額外的能力。在講 Go 如何實現 bitset 的時候,發現這塊內容有點難講。思考後,我決定經過文字輔以視頻的方式說明,因而就寫了這篇文章。git

相關代碼已經放在了 github,地址以下:go-set-examplegithub

若是發現有什麼不妥的地方,歡迎大佬們指正,感謝。函數

bitset 結構

以前我已經寫過一篇題爲 Go 中如何使用 Set 的文章,其中介紹了 bitset 一種最簡單的應用場景,狀態標誌位,順便還提了下 bitset 的實現思路。性能

狀態標誌和通常的集合有什麼區別呢?學習

個人總結是主要一點,那就是狀態標誌中元素個數一般是固定的。而通常的集合中,元素個數一般是動態變化的。這會致使什麼問題?ui

通常,咱們使用一個整數就足以表示狀態標誌中的全部狀態,最大的 int64 類型,足足有 64 個二進制位,最多能夠包含 64 個元素,徹底足夠使用。但若是是集合,元素數量和值一般都不固定。spa

好比一個 bitset 集合最初可能只包含 一、二、4 幾個元素,只要一個 int64 就能表示。以下:code

但若是再增長了一個元素,好比 64(一個 int64 的表示範圍是 0-63),這已經超出了一個 int64 能表示的範圍。該怎麼辦?視頻

一個 int64 沒法表示,那就用多個唄。此時的結構以下:索引

一個 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 與集合中的最大元素有關。

接下來肯定 indexpos 的值。其實,以前的文章已經介紹過了。

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
)

就是前面提到的用於計算 indexpos 的兩個常量。

提供兩個函數,用於方便 indexpos 上對應值的計算,代碼以下:

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 的方法

接下來是重點了,爲 BitSet 增長一些方法。主要是分紅兩類,一是常見的增刪查等基礎方法,二是集合的特有操做,交併差。

基礎方法

主要是幾個方法,分別是 Add(增長)、Clear(清除) 、Contains(檢查)以及返回元素個數。若是要有更好的性能和空間使用率,AddClear 還有考慮靈活的。

contains

先講 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

再談 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

接着,再說 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++
}

好了!基礎的方法就介紹這麼多吧。

固然,這裏的方法還能夠增長更多,好比查找當前元素的下一個元素,將某個範圍值都添加進集合等等等。

集合方法

介紹完了基礎的方法,再繼續介紹集合一些特有的方法,交併差。

computeSize

在正式介紹這些方法前,先引入一個輔助方法,用於計算集合中的元素個數。之因此要引入這個方法,是由於交併差沒有辦法像以前在增刪的時候更新 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

先介紹 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

再介紹並集 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

介紹最後一個與集合相關的方法,Difference,即差集操做。

差集計算結果 differenceSet 的分配空間由被減集合 set 決定。其餘的操做和 IntersectUnion 相似,位運算經過 &^ 實現。

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 的實現,好比 bitbitset 等。總的來講,位運算就是沒有那麼直觀,感受腦子不夠用了。

感悟

最近在深挖 Go 語言,漸漸發現了本身的一些短板,理論知識的缺失漸漸顯現。好比,我在寫這篇文章的時候,瞭解到 bits 標準庫中用到了 德布魯因序列,此前並不清楚。前幾天在研究如何進行 JSON 解析時,瞭解到了有限狀態機這個知識,Go 的源碼中簡直完美體現了這個知識的重要性。在學習 Go 語言組成的時候,知道了 擴展巴克斯範式,不少語言的文檔都是這種方式來表現語法,一門瞭然。

做爲一名電子信息專業畢業的專科生,算不上計算機的科班出生,工做六年才瞭解到這些知識,有點煩悶,也有點興奮。若是說前六年是廣度的提高,那麼,接下來就應該是更加專一的研究了。

相關文章
相關標籤/搜索