블룸 필터는 특정 엘리먼트가 집합에 포함되어 있는지 여부를 알려주는 공간 효율적인 데이터 구조다.
이 데이터 구조는 확률적이다. 블룸 필터에 대한 쿼리는 false
또는 true
를 반환한다. false
는 엘리먼트가 집합에 확실히 포함되지 않음을 의미하고, true
는 엘리먼트가 집합에 있을 수도 있음을 의미한다.
블룸 필터는 작은 확률로 거짓 양성(false positive)을 발생시킬 수 있다. 즉, 쿼리가 true
를 반환했지만 실제로 엘리먼트가 집합에 포함되지 않는 경우가 있다. 하지만 거짓 음성(false negative)은 절대 발생하지 않는다. 쿼리가 false
를 반환하면 엘리먼트가 집합에 포함되지 않음을 보장한다.
따라서 블룸 필터는 "확실히 아니다" 또는 "아마도 있다"라고 알려준다.
처음에는 이 기능이 그다지 유용해 보이지 않을 수 있다. 하지만 캐시 필터링이나 데이터 동기화 같은 애플리케이션에서는 매우 중요한 역할을 한다.
블룸 필터는 해시 테이블과 비교해 일정한 메모리 사용량과 일정한 시간 복잡도의 삽입 및 검색을 유지한다는 장점이 있다. 엘리먼트 수가 많은 집합에서는 해시 테이블과 블룸 필터 간의 성능 차이가 크다. 거짓 양성이 발생하지 않음을 보장할 필요가 없다면 블룸 필터는 유용한 선택지다.
참고: 해시 테이블과 달리 블룸 필터는 실제 객체를 저장하지 않는다. 단지 어떤 객체를 본 적이 있는지(약간의 불확실성을 포함해) 그리고 어떤 객체를 본 적이 없는지만 기억한다.
블룸 필터는 기본적으로 고정 길이의 비트 벡터이다. 이는 비트로 이루어진 배열이다. 객체를 삽입할 때 일부 비트를 1
로 설정하고, 객체를 쿼리할 때 특정 비트가 0
인지 1
인지 확인한다. 두 작업 모두 해시 함수를 사용한다.
필터에 요소를 삽입하려면, 요소를 여러 다른 해시 함수로 해싱한다. 각 해시 함수는 배열의 인덱스에 매핑할 값을 반환한다. 그런 다음 이 인덱스에 해당하는 비트를 1
또는 true로 설정한다.
예를 들어, 다음과 같은 비트 배열이 있다고 가정하자. 총 17개의 비트가 있고, 처음에는 모두 0
또는 false이다:
[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
이제 "Hello world!"
라는 문자열을 블룸 필터에 삽입하려고 한다. 이 문자열에 두 개의 해시 함수를 적용한다. 첫 번째 해시 함수는 값 1999532104120917762를 반환한다. 이 해시 값을 배열 길이로 나눈 나머지를 구해 배열의 인덱스에 매핑한다: 1999532104120917762 % 17 = 4
. 이는 인덱스 4의 비트를 1
또는 true로 설정한다는 의미이다:
[ 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
그런 다음 원래 문자열을 다시 해싱하지만 이번에는 다른 해시 함수를 사용한다. 이 해시 함수는 값 9211818684948223801을 반환한다. 이를 17로 나눈 나머지는 12이므로, 인덱스 12의 비트도 1
로 설정한다:
[ 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 ]
이 두 개의 1 비트는 블룸 필터가 이제 "Hello world!"
문자열을 포함하고 있다는 것을 알려준다. 물론 실제 문자열은 포함하지 않으므로, 블룸 필터에게 "포함하고 있는 모든 객체 목록을 보여줘"라고 요청할 수 없다. 블룸 필터가 가지고 있는 것은 단순히 1과 0의 집합일 뿐이다.
조회 작업은 삽입과 마찬가지로, 먼저 예상 값을 해싱하여 여러 배열 인덱스를 얻은 후, 해당 인덱스의 비트가 모두 1
인지 확인한다. 하나라도 1
이 아닌 비트가 있으면 해당 요소는 삽입되지 않았음을 의미하므로 조회 결과는 false
가 된다. 모든 비트가 1
이면 조회 결과는 true
를 반환한다.
예를 들어, 문자열 "Hello WORLD"
를 조회한다고 가정해보자. 첫 번째 해시 함수는 5383892684077141175를 반환하고, 이를 17로 나눈 나머지는 12다. 이 비트는 1
이다. 그러나 두 번째 해시 함수는 5625257205398334446을 반환하고, 이는 배열 인덱스 9에 매핑된다. 이 비트는 0
이다. 따라서 문자열 "Hello WORLD"
는 필터에 존재하지 않으며, 조회 결과는 false
가 된다.
첫 번째 해시 함수가 1
비트에 매핑된 것은 단순한 우연이다(두 문자열이 모두 "Hello "
로 시작한다는 사실과는 무관하다). 이러한 우연이 너무 많이 발생하면 "충돌"이 생길 수 있다. 충돌이 발생하면 실제로 삽입되지 않은 요소에 대해 조회가 true
를 잘못 반환할 수 있으며, 이는 앞서 언급한 거짓 양성 문제를 일으킨다.
다른 요소 "Bloom Filterz"
를 삽입하면 비트 7과 9가 설정된다. 이제 배열은 다음과 같이 보인다:
[ 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0 ]
이 상태에서 다시 "Hello WORLD"
를 조회하면, 필터는 비트 12와 9가 모두 1
임을 확인한다. 필터는 "Hello WORLD"
가 집합에 존재한다고 보고하지만, 실제로는 그렇지 않다. 해당 문자열은 삽입된 적이 없기 때문이다. 이는 거짓 양성의 예시다. 이 예제는 Bloom Filter가 "확실히 있다"고 말하지 않고 "아마도 있다"고만 말하는 이유를 보여준다.
이러한 문제는 더 많은 비트를 가진 배열과 추가적인 해시 함수를 사용해 해결할 수 있다. 물론 해시 함수를 더 많이 사용할수록 Bloom Filter의 성능은 느려진다. 따라서 적절한 균형을 찾아야 한다.
Bloom Filter에서는 삭제가 불가능하다. 하나의 비트가 여러 요소에 속할 수 있기 때문이다. 한 번 추가된 요소는 영원히 남는다.
Bloom Filter의 성능은 **O(k)**이며, 여기서 k는 해시 함수의 개수다.
코드는 상당히 직관적이다. 내부 비트 배열은 초기화 시 고정된 길이로 설정되며, 한번 초기화되면 변경할 수 없다.
public init(size: Int = 1024, hashFunctions: [(T) -> Int]) {
self.array = [Bool](repeating: false, count: size)
self.hashFunctions = hashFunctions
}
초기화 시 여러 해시 함수를 지정해야 한다. 어떤 해시 함수를 사용할지는 집합에 추가할 요소의 데이터 타입에 따라 달라진다. 플레이그라운드와 테스트에서 몇 가지 예제를 확인할 수 있다. 문자열을 위한 djb2
와 sdbm
해시 함수가 대표적이다.
삽입 연산은 필요한 비트를 true
로 설정한다:
public func insert(_ element: T) {
for hashValue in computeHashes(element) {
array[hashValue] = true
}
}
이 코드는 computeHashes()
함수를 사용한다. 이 함수는 지정된 hashFunctions
를 순회하며 인덱스 배열을 반환한다:
private func computeHashes(_ value: T) -> [Int] {
return hashFunctions.map() { hashFunc in abs(hashFunc(value) % array.count) }
}
쿼리 연산은 해시된 값에 해당하는 비트가 모두 true
인지 확인한다:
public func query(_ value: T) -> Bool {
let hashValues = computeHashes(value)
let results = hashValues.map() { hashValue in array[hashValue] }
let exists = results.reduce(true, { $0 && $1 })
return exists
}
다른 명령형 언어에서 온 개발자라면 exists
할당 부분의 특이한 문법을 눈치챌 수 있다. Swift는 코드를 더 간결하고 가독성 있게 만들기 위해 함수형 패러다임을 활용한다. 이 경우 reduce
를 사용하면 for
루프보다 모든 비트가 true
인지 확인하는 코드를 더 간결하게 작성할 수 있다.
이전 섹션에서는 여러 개의 해시 함수를 사용해 블룸 필터에서 충돌 가능성을 줄이는 방법을 배웠다. 하지만 좋은 해시 함수를 설계하는 것은 쉽지 않다. 여러 해시 함수 대신 사용할 수 있는 간단한 대안은 무작위 숫자 집합을 활용하는 것이다.
예를 들어, 블룸 필터가 삽입 과정에서 각 엘리먼트를 15번 해싱한다고 가정해보자. 15개의 서로 다른 해시 함수를 사용하는 대신, 단 하나의 해시 함수만 사용할 수 있다. 이 해시 값은 15개의 다른 값과 결합해 비트를 뒤집을 인덱스를 생성한다. 이 블룸 필터는 15개의 무작위 숫자를 미리 초기화하고, 각 삽입 과정에서 이 값을 사용한다.
hash("Hello world!") >> hash(987654321) // 비트 8을 뒤집음
hash("Hello world!") >> hash(123456789) // 비트 2를 뒤집음
Swift 4.2부터 Hasher
가 표준 라이브러리에 포함됐다. 이는 여러 해시를 효율적으로 단일 해시로 줄이도록 설계됐다. 이를 통해 해시를 결합하는 작업이 간단해졌다.
private func computeHashes(_ value: T) -> [Int] {
return randomSeeds.map() { seed in
let hasher = Hasher()
hasher.combine(seed)
hasher.combine(value)
let hashValue = hasher.finalize()
return abs(hashValue % array.count)
}
}
이 접근 방식에 대해 더 자세히 알고 싶다면, Hasher 문서나 Soroush Khanlou의 Swift 4.2 Bloom filter 구현을 참고할 수 있다.
Swift Algorithm Club을 위해 Jamil Dhanani가 작성. Matthijs Hollemans가 편집. Bruno Scheele가 업데이트.