해시 테이블의 연산은 key 를 이용해 이런 작업들을 한다.
(1) insert: add new record
(2) delete: delete existing record
(2) lookup: check for a particular record
가끔 사람들이 dictionary 라 부르기도 하는데, 해시테이블은 알파벳 순서같은 특정 order 로 데이터를 저장하진 않는다.
이 3가지 연산이 거의 O(1)
라 보면 된다. 물론 이건 해시테이블을 잘 설계 했을때다. 슬프게도, 해시테이블은 잘못 구현하기 쉽다. 해시테이블이 O(1)
성능이 나오려면
여기서 non-pathological 이란 collision 을 만들지 않는 데이터를 말한다.
(1) 주어진 object stream 을 de-duplication 하기 위해 해시테이블을 쓸 수 있다. 해시테이블에 들어오는 객체를 lookup 해 보고 없으면 채워 넣고, 있으면 무시한다.
(2) 2-Sum Problem 에도 해시테이블을 쓸 수 있다. 2-Sum 을 푸는 O(n logn)
방법은, (x + y = t
)
먼저 정렬 후 A
의 원소 x
에 대해 t - x
를 이진탐색하는 방법이다. 이렇게 하면 O(n logn)
으로 해결할 수 있다.
해시테이블을 이용하면 정렬 할 필요도 없고, 이진탐색 대신 lookup 으로 O(1)
시간에 원소를 검색할 수 있으므로 더 빨라진다.
해시 테이블을 만드는데 O(n)
, 탐색에 O(1 * n)
에서, O(n)
만에 2-Sum 을 해결할 수 있다.
(3) 이외에도
모든 집합을 의미하는 universe u
에 대해 a reasonable size* 의 evolving set s <= u
* 을 유지하면 된다.
O(1)
이지만 메모리가 O(|u|)
다.O(|s|)
의 메모리를 차지하지만, lookup 이 O(|s|)
다.더 나은 방법은 없을까?
(1) bucket size 인 n ~ |s|
인 n
을 고른다. 이 때 |s|
는 그렇게 많이 안 변한다고 가정하자.
(2) 그 후 hash function h: u -> {0, 1, ..., n-1}
인 h
를 고르면 된다.
(3) 길이 n
의 배열 A
에, A[h(x)]
위치에 x
를 저장하면 된다.
이제 충돌 문제를 고민해 보자. 한 방에 23
명만 있어도, 생일이 같은 2명이 존재할 확률이 50%
가 넘으므로,
n
에 비해 그리 크지 않은 input size 에 대해서도 충돌이 발생할 확률이 꽤 높다.
Collision: dinstinct
x, y in u
such thath(x) = h(y)
충돌을 해결하기 위한 첫 번째 방법은
(1) Chaining:
A[h(x)]
을 리스토로 만들어 충돌이 발생하는 원소를 리스트에 저장한다.
(2) Open Addressing:
충돌이 발생하면 새로운 bucket 을 찾도록 해 하나의 bucket 당 하나의 원소만 들어갈 수 있도록 한다.
hash function now specifies probe sequence
h_1(x), h_2(x), ...
keep trying til find open slot.
open addressing 에서는 probing, 탐사 방식을 통해 비어있는 bucket 을 찾는다. 몇 가지 방법이 있는데
key k
에 대한 최초의 해쉬 함수 값 h(k)
home position 이라 부르는데, 같은 home position 를 갖는 key 들을 모아 cluster 라 부른다. cluster 가 커지면 커질수록, 클러스터의 중간을 home position 으로 하는 키가 들어올 확률도 높아지고, 인접한 클러스터와 합쳐지는 속도도 빨라진다.
결국 linear probing 의 경우 load factor 가 높아질수록 해쉬 테이블의 성능이 O(n)
으로 떨어진다.
여기를 인용하면
chaining 은 open addressing 에 비해 다음의 장점을 가진다.
삭제 작업이 간단하다. 삭제 작업이 빈번하다면 open addressing 보다는 chaining 이 낫다.
chaining 은 클러스터링에 거의 영향을 받지 않아 충돌의 최소화만 고려하면 된다. 반면 open addressing 은 클러스터링까지 피해야 하므로 해쉬함수를 구현하기가 쉽지 않다.
load factor 가 높아져도 성능 저하가 선형적이다. 아래 그림에서 볼 수 있듯이, open-addressing 방법처럼 급격히 lookup time 이 늘지 않는다. 따라서 테이블 확장을 상당히 늦출 수 있다.
데이터의 크기가 5 words and more 이면, open addressing 보다 메모리 사용량이 적다.
반면 open addressing 은
어떠한 포인터도 저장할 필요가 없고, 테이블 외부에 추가적인 공간이 필요 없으므로 메모리 효율이 높다.
특히 linear probing 에서 뛰어난 locality 때문에 데이터가 캐쉬라인을 채울 정도로 크지 않다면 좋은 성능을 낼 수 있다.
정리하자면,
open-addressing 방식은 테이블에 모두 저장될 수 있고 캐쉬 라인에 적합할 수 있을 정도로 데이터의 크기가 작을수록 성능이 더 좋아진다. 메모리 비용을 아끼려면, 이 방법이 적합하다.
반면 테이블의 높은 load factor가 예상되거나, 데이터가 크거나, 데이터의 길이가 가변일 때 chained 해쉬 테이블은 open-addressing 방식보다 적어도 동등하거나 훨씬 더 뛰어난 성능을 보인다. 삭제가 중요하고, 빈번한 연산이라면 chianing 이 더 낫다.
chaining 을 생각해 보자.
O(1)
O(list length in the bucket)
이때 하나의 버켓에 들어있는 list length 는 m/n
부터 m
까지 일 수 있기 때문에 (m
개의 오브젝트에 대해), 해쉬 함수에 따라 성능이 정말 달라진다.
이로부터 좋은 해쉬함수의 기준을 알 수 있다.
- Should lead to good performance => “spread data out”
(gold standard: completely random hashing)- Should be easy to store / very fast to evaluate
좋은 해쉬함수를 설계할 수 있다면 좋겠지만, 시간이 없을때 객체 u
를 받아 정수 n
으로 만들어 bucket 을 찾는 해쉬함수를 이렇게 디자인할 수 있다.
u -> n
: hash coden -> bucket
: compression function using mod
여기서 n
은 어떻게 고를까?
(1) 우리가 compression function 으로 mod
를 사용하기 때문에 소수여야 한다. 소수가 아니라면, n
으로 나누어지는 모든 수는 mod n == 0
이 되어, 같은 bucket 에 할당될 것이다. 물론 이 수는 너무 커서는 안되고, 객체를 담을 수 있을만한 적당한 숫자여야 한다.
(2) input data 의 패턴을 고려해 n
을 정해야 한다. 예를 들어 memory location 이 4의 배수일 때, 테이블 사이즈 n
을 2^j
로 정해버리면, mod n == 0
이 되는 경우가 많아 empty bucket 이 많이 생길 것이다.
그리고 n
을 2^k, 10^k
로 정해버리는 경우 mod
연산이 시프팅으로 쉽게 구현되는데 이는 나머지 데이터를 고려하지 않고 일부의 데이터만으로 버킷을 찾아가므로 별로 좋은 선택이 아니다.
evenly spread out 에 대해 고민해 보았으니, 이제 non-pathological 을 생각해 보자.
용어부터 정의하고 가면 load factor 는 해시테이블에 들어있는 오브젝트 수를, 버킷 수로 나눈 것이다.
open addressing 의 경우에는 load factor 가 1보다 클 수 없지만 chaining 은 가능하다.
(1) load factor a = O(1)
이어야 연산이 constant time 이다.
(2) open addressing 이라면 x << 1
이어야 한다.
따라서 해시 테이블의 성능을 위해서는 load factor 를 조절해야 한다.
모든 데이터에 대해 evenly spread out 할 수 있는 해시함수가 있다면 좋겠지만, 그런 해시 함수는 없다.
모든 해시 함수는 자신만의 pathological data set 이 있다. 이는 쉽게 보일 수 있는데, universe u
와 대해 버켓 수 n
에 대해 해시함수 h: u -> {0, 1, ..., n-1}
이 있다고 하자.
비둘기 집 원리에 의해 모든 bucket 은 적어도 |u|/n
개의 데이터를 담고 있다. 따라서 u
중에서 어느 한 bucket 에만 담을 수 있는 데이터 셋을 고르면, 그것이 바로 pathological data set 이다.
이런 pathological data set 은 service attack 에 쓰이기도 한다. 따라서 오픈소스라면 리버스엔지니어링 하기 쉽지 않게끔 해시함수를 설계하는 것도 필요하다.
그럼 모든 해시 함수가 이런 데이터 셋을 가지고 있고, 심지어 공격에도 이용할 수 있다면 어떻게 해시함수를 설계해야 이런 문제를 조금이나마 피할 수 있을까?
(1) use a cryptographic hash function (e.g., SHA-2)
infeasible to reverse engineer a pathological data set
(2) use randomization
design a family H
of hash funcitons such that data sets S
, “almost all” functions h in H
spread S
out “pretty evenly” (compare to quicksort guarantee)
이제 universal hashing 이 무엇인지 알아보자
Let
H
be a set of hash functions fromu
to{0, 1, ..., n-1}
H
is universal if and only if,for all
x, y in u (x != y)
P[h(x) = h(y)] <= 1/n
whenh
is chosen unifomly at random fromH
wheren
is the number of buckets.
IP Address 를 예로 들어 설명해보면 IP 를 (x1, x2, x3, x4)
(xi = 0 to 255
), bucket 수 n
을 소수라 하자.
tuple a = (a1, a2, a3, a4), where ai in {0, ..., n-1}
에 대해서
h_a
를 이렇게 정의하자. 이러면 h_a
는 n^4
개 존재한다.
h_a(x1, x2, x3, x4) = (a1x2 + a2x2 + a3x3 + a4x4) mod n
이제 h_a
의 집합 H
는 universal 이다.
H = { h_a | a1, a2, a3, a4 in {0, 1, ..., n-1} }
서로 다른 IP (x1, x2, x3, x4), (y1, y2, y3, y4)
를 생각해보자.
만약 x4 != y4
라면, 충돌이 일어날 확률은 얼마일까? 충돌에 대한 식을 좀 정리하면
이 때 a1, a2, a3
를 고정하면 얼마나 많은 a4
에 대해 아래 식이 성립할까?
xi, yi, a1, a2, a3
가 fixed 기 때문에 우변은 {0, ..., n-1}
사이의 숫자고 a4
만 랜덤이다.
이 때
x4 != y4
이므로 x4 - y4 != 0
이다n
이 ai
의 최대값보다 큰수이면서 동시에 소수인데다가a4
가 uniform at random 이기 때문에left-hand side equally likely to be any of
{0, 1, ..., n-1}
.
따라서 좌변이 특정 숫자인 우변과 같을 확률은 1/n
이다.
universal hash functions 의 정의를 한번 더 보고 넘어가면,
H
가 해시함수 u -> {0, ..., n-1}
의 집합일때 H
가 다음을 만족하면 universal 하다.
x != y
인 u
내의 x, y
에 대해 충돌이 일어날 확률 P <= 1/n
이고H
내에서 h
가 uniformly at random 하게 선택될때만약 해시 테이블이 chaining 을 이용해 구현되었을때, universal family H
로부터 해시함수 h
가 uniformly a random 하게 선택되면 모든 연산이 O(1)
이다.
그리고, |S| = O(n)
다시 말해 load factor alpha = |S| / n = O(1)
임을, 해시 함수를 평가하는데 O(1)
임을 가정한다.
unsuccessful lookup 을 분석할건데, 다른 연산이 이보다는 항상 더 빠르므로 다른 연산의 upper bound 라 보면 된다.
S
를 |S| = O(n)
인 데이터셋이라 하자. x not in S
인 x
를 lookup 한다 하면 running time 은
O(1) + O(list length in A[h(x)])
다. 즉 h(x)
를 평가하는데 걸리는 시간과 해당 버킷 내의 리스트를 순회하는 시간의 합이다.
그런데 여기서 A[h(x)]
버킷의 리스트 길이를 L
이라 하면 이 L
은 h
선택에 따라 달라지는 random variable, 확률변수 다.
그럼 average list length 를 구해, O(L)
을 구해보자. 기대값의 선형성을 이용할건데, E(L)
을 위한 1 or 0
의 확률변수를 도입하자.
x != y
인 y in S
에 대해 z_y
를 충돌이 날경우 1
로, 아닐 경우를 0
으로 하면
이 때 해시함수가 무엇이든, 충돌이 날 경우에만 같은 버킷으로 들어가므로 버킷의 길이는
따라서
중간에 H
가 universal 이므로 P[h(y) = h(x)] <= 1/n
이다.
open addressing 퍼포먼스를 계산할건데, quick and dirty idealized analysis 를 위해 heuristic assumtion 을 도입하면,
All
n!
probe sequences equally likely
이상적인 경우를 가정하면 얻어지는 것은
expected insertion time ~=
1 / (1 - a)
wherea = load factor
다시 말해서, a = 0.5
라면 새로운 데이터를 집어넣기 위해 2
만큼 probe 해야한다는 소리다. 반면 a ~= 1
이면 (1
에 가까워지면) insertion 타임은 어마어마하게 커진다.
A random probe finds an empty slot with probability
1 - a
이 문제를 “head 를 얻기 위해 동전을 몇번 뒤집어야 하는가” 로 치환할 수 있다. 여기서 Pr[heads] = 1 - a
라 보면
head 를 얻기 위해 동전을 뒤집는 수 N
에 대해 기대값 E[N]
은
식을 풀면
open addressing 방법으로 linear probing 을 사용할 경우, 아까의 heuristic assumption 자체가 성립하지 않는다.
따라서 다른 가정으로
initial probe uniformly random, independent for different keys.
그러면 가정아래, expected insertion time 은 1 / (1 - a)^2
에 가까워진다. (D.E Knuth 가 발견했다고 한다.)
하던대로 supported operation 부터 이야기 하자.
블룸 필터는 해시테이블과 비슷하게 빠른 삽입, 탐색을 지원한다. 해시테이블과 비교했을때 메모리가 덜 든다. 반면 단점은
(1) Can’t store an associated object
(2) No deletions
(3) small false positive pobability (but no false negative)
블룸 필터는 다양한 곳에 사용한다.
만약 메모리가 아주 비싸고, false positive 를 참을만 하다면 블룸필터는 좋은 선택이다. 연산도 아주 빠르다.
블룸 필터의 구성요소를 보자.
(1) 자료구조는 n
비트의 배열이다.
(2) k
개의 해시 함수가 필요하다. (k
는 small constant)
insertion 은 i = 1, ..., k
에 대해 A[h_i(x)] = 1
로 세팅하면 된다. 이미 1
이어도 덮어쓴다. 참고로 덮어쓰기때문에 false positive 는 있어도 false negative 는 없다. 자그마한 종양만 보여도 무조건 암이라 주장하는 소심한 의사라 보면 이해가 쉽다.
lookup 은 i = 1, ..., k
에 대해 모든 A[h_i(x)] = 1
이면 찾으려는 x
가 존재한다.
false positive 는 A[h_i(x)]
가 다른 insertion 에 의해 1
로 세팅 되었을때 발생한다.
블룸필터를 이미지로 보면
블룸필터를 쓰는 것이 합리적인 선택이 되려면
(1) n / |S|
즉, 오브젝트당 비트 수가 충분히 작아야 한다.
(2) false positive, 즉 에러 확률이 작아야한다.
동시에 두 조건을 작은 값으로, 모두 만족시키지 못한다면 그냥 해시 테이블을 쓰는 것이 더 낫다.
근데, 자세히 살펴보면 space 와 error prob, 이 두 조건은 trade-off 다.
heuristic assumption 은
all
h_i(x)
’ is uniformly random and independent (across differenti
’s andx
’s
k
개의 해시함수를 가지는 n
비트 블룸 필터에 데이터셋 S
를 먼저 넣어놓자. 이제 블룸필터 A
의 각 비트가 1일 확률은,
인데 이것은 한 비트가 0
일 확률을 1
에서 뺀 것이다. 0
일 확률은 k
개의 해쉬 함수를 |S|
개의 모든 원소를 다 집어 넣은 후에도 0
인 확률이므로
이 때 e^x
가 1 + x
의 upper bound 임을 이용하면, 각 비트가 1일 확률은
이 때 n / |S| = b
, b
는 오브젝트당 비트 수 이므로
이제 블룸필터에 한번도 입력되지 않은 데이터에 대해 false positive 확률 P[FP]
를 계산하면,
이 때 고정된 수 b
에 대해 에러일 확률 P[FP]
를 최소화 하는 k
를 찾으면
k ~ (ln2) * b
다. 로그를 계산하면, k ~ 0.693 * b
따라서
이므로 오브젝트당 비트수 b
에 따라서 false positive, 즉 에러 확률이 exponentially 작아진다.
식을 거꾸로 풀면
이 두 식은 오브젝트당 비트수 b
와 false positive 의 trade off 를 보여준다.
만약 b = 8
이고 k = 5, 6
이면 에러 확률은 2%
정도다.
(1) Algorithms: Design and Analysis, Part 1 by Tim Roughgarden
(2) Hash Table
(3) http://www.slideshare.net/tanmaytan21
(4) Wikipedia: Hash Table
(5) http://www.slideshare.net/sajidmarwatt
(6) Wikipedia: Primary Clustering
(7) Wikipedia: Bloom Filter