目錄
(一)Hash
哈希表是一種基本的數據結構,其思想是利用Hash函數來支持快速的【插入和搜索】,這是哈希表的第一個重要概念。本文從哈希表開始說起,是爲數據去重問題提供最原始的思路。該模塊不涉及任何複雜算法,或者是Java中的實現方法,僅從最簡單的角度進行講解,便於初學者快速理解。
既然說起了【插入和搜索】,那麼哈希表的相關原理就離不開這兩個場景,與插入對應的,是哈希函數;與搜索對應的,是存儲桶。哈希表的原理,即是通過哈希函數,將隨機的數字,固定的映射到一個存儲桶裏。例如當我們插入一個新的鍵值時,會通過哈希函數映射,查詢該鍵值應該分配到哪個存儲桶裏,並進行對應的存儲操作;當我們搜索一個鍵值時,使用相同的哈希函數,找到對應的存儲桶,並只在這個存儲桶裏進行搜索。
如下圖所示,假設哈希函數是y = x % 5:
說到這裏,哈希函數是原始的“分而治之”思想的實踐者,天然的應該應用到對應的高性能場景中。哈希函數的設計並沒有標準方法,最完美的情況下,鍵值和存儲桶之間能做到嚴格的一對一映射。
然而,在大多數情況下,哈希函數並不完美,不可避免的會遇到衝突的問題。例如上文提到的y = x % 5,1987和2這兩個鍵值都被分配到了存儲桶2中。在這裏,哈希表就引入了第二個重要概念【衝突解決】。
衝突解決算法需要解決如下三個問題:
(1)同一個存儲桶下的鍵值應該如何組織?
(2)如果某個存儲桶中的鍵值超過了最大存儲數量,應如何解決?
(3)如何在同一個存儲桶中快速定位目標值?
因此,哈希表需要有一個“N”的概念,即每個存儲桶能夠存儲的最大鍵值。
講到這裏,對於基本的哈希表概念就大致瞭解了,接下來我們實現兩個最簡單的數據結構:HashSet和HashMap。
先說HashSet,實現如下:
class MyHashSet {
private final int MAX_LEN = 100000;
private List<Integer>[] set;
private int getIndex(int key) {
return key % MAX_LEN;
}
private int getPos(int key, int index) {
List<Integer> temp = set[index];
if (temp == null) {
return -1;
}
for (int i = 0; i < temp.size(); ++i) {
if (temp.get(i) == key) {
return i;
}
}
return -1;
}
public MyHashSet() {
set = (List<Integer>[])new ArrayList[MAX_LEN];
}
public void add(int key) {
int index = getIndex(key);
int pos = getPos(key, index);
if (pos < 0) {
if (set[index] == null) {
set[index] = new ArrayList<Integer>();
}
set[index].add(key);
}
}
public void remove(int key) {
int index = getIndex(key);
int pos = getPos(key, index);
if (pos >= 0) {
set[index].remove(pos);
}
}
public boolean contains(int key) {
int index = getIndex(key);
int pos = getPos(key, index);
return pos >= 0;
}
}
再說HashMap,實現如下:
import javafx.util.Pair;
class MyHashMap {
private final int MAX_LEN = 100000;
private List<Pair<Integer, Integer>>[] map;
private int getIndex(int key) {
return key % MAX_LEN;
}
private int getPos(int key, int index) {
List<Pair<Integer, Integer>> temp = map[index];
if (temp == null) {
return -1;
}
for (int i = 0; i < temp.size(); ++i) {
if (temp.get(i).getKey() == key) {
return i;
}
}
return -1;
}
public MyHashMap() {
map = (List<Pair<Integer, Integer>>[])new ArrayList[MAX_LEN];
}
public void put(int key, int value) {
int index = getIndex(key);
int pos = getPos(key, index);
if (pos < 0) {
if (map[index] == null) {
map[index] = new ArrayList<Pair<Integer, Integer>>();
}
map[index].add(new Pair(key, value));
} else {
map[index].set(pos, new Pair(key, value));
}
}
public int get(int key) {
int index = getIndex(key);
int pos = getPos(key, index);
if (pos < 0) {
return -1;
} else {
return map[index].get(pos).getValue();
}
}
public void remove(int key) {
int index = getIndex(key);
int pos = getPos(key, index);
if (pos >= 0) {
map[index].remove(pos);
}
}
}
以上內容很基礎,但對於理解接下來的內容很有幫助。
(二)BitMap
理解了Hash的基本概念,接下來就引入第二個概念:BitMap。簡單講:BitMap就是用一個bit位來標記某個元素對應的鍵值。用哈希表的概念看,就是將每個bit位當作存儲桶,位移作哈希函數,“N”值設定爲1。
例如:我們目前有16個bit位:0000000000000000(標號從0開始),還有一個數字集合{1,2,5,7,11},那麼這16個bit位就可以表示爲:0110010100010000。如下圖所示:
再舉例:一臺32位機器上的自然數總共有4294967295個,如果用一個bit來存放一個整數,1代表存在,0代表不存在,那麼把全部自然數存儲在內存只要4294967295 / (8 * 1024 * 1024) ≈ 512MB,如果存儲在文件中,需要約20G的容量。
以下的實現方式需要自行補充Java位運算的相關知識:
因爲在Java中,最小的數據類型爲byte(8位),因此這裏用byte舉例。這裏我們需要拿到兩個位置:一個是數字在每個byte中的位置,另一個是在byte[]數組中的位置。因此計算公式如下:
所處於數組位置:outerIndex = num >> 3 (相當於除以8取整)
在byte中位置:innerIndex = num & 7 (相當於mod8)
在byte中標記位置:0x01 << innerIndex(位移)
更新byte:bitsMap[outerIndex] | (0x01 << innerIndex) (或運算更新bit位)
相關實現代碼如下:
public class BitMap {
private static byte[] bitsMap;
public BitMap(long length) {
bitsMap = new byte[(byte) (length >> 3) + ((length & 7) > 0 ? 1 : 0)];
}
public int get(long num) {
byte data = bitsMap[(byte) (num >> 3)];
byte innerIndex = (byte) (num & 7);
return data >> innerIndex & 0x01;
}
public void put(long num) {
byte outerIndex = (byte) (num >> 3);
byte innerIndex = (byte) (num & 7);
bitsMap[outerIndex] = (byte) (bitsMap[outerIndex] | (0x01 << innerIndex));
}
public static void main(String[] args) {
BitMap bitMap = new BitMap(101);
bitMap.put(33);
System.out.println(bitMap.get(0));
System.out.println(bitMap.get(33));
System.out.println(bitMap.get(100));
}
}
結果:0、1、0
是不是與HashMap的實現方式有雷同呢?
(三)BitSet
理解了BitMap,接下來直接看Java.util中的BitSet方法,就容易多了,基本的原理都是相通的。接下來就拿出一部分源碼來看:
public void set(int bitIndex) {
if (bitIndex < 0)
throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
int wordIndex = wordIndex(bitIndex);
expandTo(wordIndex);
words[wordIndex] |= (1L << bitIndex); // Restores invariants
checkInvariants();
}
以Set方法爲例:先是通過wordIndex方法獲得outerIndex的位置,“>> ADDRESS_BITS_PER_WORD”這裏設定的值是6,也就是2的6次方,對應的就是Java中Long的數據類型。再是通過expandTo方法來判斷是否超過了當前words數組,如果存不下,則新增一個Long數組。最後是“1L << bitIndex”更新下標,與BitMap中的位移方式相同。
Get方法的實現方式類似,詳細的實現代碼可以看一下Java源碼。
(四)BloomFilter
布隆過濾器本質上是一種數據結構,通過巧妙的概率型數據結構,以較小的存儲空間,換取高效的插入和去重查詢操作。但布隆過濾器的結果是概率性的,並不準確。
理解了BitSet的概念,我們就可以通過BitSet的數據結構,來實現基本的BloomFilterInMemory方法:
import java.util.BitSet;
public class BloomFilterInMemory {
protected BitSet bloom;
public BloomFilterInMemory(int size) {
bloom = new BitSet(size);
}
public synchronized boolean addElement(int element) {
boolean added = false;
if (!getBit(element)) {
added = true;
setBit(element, true);
}
return added;
}
public synchronized void clear() {
bloom.clear();
}
public synchronized boolean contains(int element) {
if (!getBit(element)) {
return false;
}
return true;
}
protected boolean getBit(int index) {
return bloom.get(index);
}
protected void setBit(int index, boolean to) {
bloom.set(index, to);
}
public synchronized boolean isEmpty() {
return bloom.isEmpty();
}
}
(五)Flink
實時數據去重是一種比較常見的近似場景,通常有以下三種實現方式:
1. 通過布隆過濾器;
2. 通過內嵌數據庫(如RocksDB);
3. 引入外部數據庫(如Redis)。
第一種方式是近似統計,第二、三種方式統計的就相對準確。假如使用場景有很多額外的因素,例如反作弊會對後續數據進行修正,那麼還是推薦通過布隆過濾器的方式來進行統計,只需要去重的指標是整型(字符串去重對哈希函數的設計要求較高)。
實時統計中,我們最常遇到的是對用戶進行去重,假設用戶是登陸狀態,那麼可以獲得用戶的ID,這種ID通常是數據庫自增型的,那麼就很適合布隆過濾器的使用場景。
以上一篇文章的WordCount程序實例,實現一個新的DeduplicateFlatMapFunction即可:
public class DeduplicateFlatMapFunction implements FlatMapFunction<Integer, Tuple2<Integer, Integer>> {
private static final int DEFAULT_SIZE = 1000000;
private volatile BloomFilterInMemory bloomFilter;
@Override
public void flatMap(Integer value, Collector<Tuple2<Integer, Integer>> out) throws Exception {
if (bloomFilter == null) {
bloomFilter = new BloomFilterInMemory(DEFAULT_SIZE);
}
if (!bloomFilter.contains(value)) {
bloomFilter.addElement(value);
out.collect(new Tuple2<>(value, 1));
}
}
}