블룸 필터(bloom filter)

블룸 필터는 해시 테이블에 비해 공간 효율이 매우 높은 방법이지만, 결정적(deterministic) 솔루션 대신 부정확한 결과를 얻을 수 있다.

  • 거짓-부정(false-negative)dl 이 없다는 것은 보장하지만, 거짓-긍정(false-positive)는 나올 수 있다.
    • 즉, 특정 원소가 존재한다는 긍정적인 답변을 받을 경우, 이 원소는 실제로 있을 수도 있고 없을 수도 있다.
    • 그러나 특정 원소가 존재하지 않는다는 부정적인 답변을 받았다면 이 원소는 확실히 없다
  • 뻐꾸기 해싱과 마찬가지로 블룸 필터도 여러 개의 해시 함수를 사용한다
    • 정확도를 위해 세 개 이상을 사용해야 한다
  • 블룸 필터는 실제 값을 저장하지는 않으며, 대신 특정 값이 있는지 없는지를 나타내는 부울 타입 배열을 사용한다
  • 원소를 삽입할 경우, 모든 해시 함수 값을 계산하고 부울 타입 배열에서 이 해시 값에 대응되는 위치의 비트 값을 1로 설정한다
  • 룩업의 경우, 모든 해시 함수 값을 계산하고 이에 대응되는 위치의 비트 값이 1로 설정되어 있는지를 검사한다
    • 만약 검사한 모든 비트가 1이면 true를 반환한다
    • 1이 아닌 비트가 하나라도 있으면 false를 반환하고, 이는 해당 원소가 없음을 의미한다

블룸 필터가 결정적이지 않은 이유

  • 특정 비트가 다수 원소에 의해 1로 설정될 수 있기 때문이다.
    • 특정 값 x와 연관된 모든 비트가 이전에 삽입된 다른 원소 값들에 의해 모두 1로 설정되어 있을 가능성이 있다는 뜻이다
      • 이러한 경우 x에 대한 룩업 함수는 true를 반환할 것이다
      • 이처럼 특정 원소가 있다고 잘못 판단하는 것을 거짓-긍정이라고 한다
      • 원소의 개수가 많아질수록 거짓-긍정이 발생할 가능성은 증가한다
      • 그러나 x와 연관된 비트 중 하나라도 1로 설정되어 있지 않다면 x가 확실하게 없다고 말할 수 있다.
        • 그러므로 거짓-부정은 발생할 수 없다
  • 부울 배열의 모든 원소가 true 또는 1로 설정될 경우, 이 배열은 포화 상태가 된다
    • 이 상태에서 룩업 함수는 항상 true를 반환하고 삽입함수는 블룸 필터 상태에 아무런 영향을 주지 못한다

Example) 블룸 필터 만들기

#include <iostream>
#include <vector>

class bloom_filter
{
    std::vector<bool> data;
    int nBits;

    int hash(int num, int key)
    {
        switch(num)
        {
            case 0: return key % nBits;
            case 1: return (key / 7) % nBits;
            case 2: return (key / 11) % nBits;
        }

        return 0;
    }

public: 
    bloom_filter(int n) : nBits(n)
    {
        data = std::vector<bool>(nBits, false);
    }

    void lookup(int key)
    {
        bool result = data[hash(0, key)] & data[hash(1, key)] & data[hash(2, key)];

        if(result)
        {
            std::cout << key << " 있을 수 있음" << std::endl;
        }
        else
        {
            std::cout << key << " 없음" << std::endl;
        }
    }

    void insert(int key)
    {
        data[hash(0, key)] = true;
        data[hash(1, key)] = true;
        data[hash(2, key)] = true;

        std::cout << key << " 을(를) 삽입 ";

        for(auto a : data)
            std::cout << a << " ";

        std::cout << std::endl;
    }
};

using namespace std;

int main()
{
    bloom_filter bf(7);

    bf.insert(100);
    bf.insert(54);
    bf.insert(82);

    bf.lookup(5);
    bf.lookup(50);
    bf.lookup(20);
    bf.lookup(54);

    return 0;
}
  • 해시 함수의 인자 num은 내부에서 어떤 해시 함수를 사용할 지 결정하는 역할을 한다
    • 이렇게 함으로써 여러 개의 해시 함수를 따로 만들지 않아도 된다
  • lookup() 함수는 필요한 모든 비트가 1로 설정되어 있는지를 검사한다
  • 위 예제를 보면, 이 프로그램에서 필요한 정보를 저장하기 위해 겨우 7비트만 사용했다.
    • 필터 크기를 좀 더 크게 설정하고 해시 함수를 보완하면 훨씬 더 나은 성능을 얻을 수 있다
  • 블룸 필터는 컨테이너에 실제 데이터를 저장하지 않기 때문에 다양한 타입의 데이터에 대해서도 저장할 수 있다
    • 해시 함수를 잘 만들었다면 하나의 블룸 필터에 정수, 문자열, 상수 등의 데이터를 섞어서 삽입할 수도 있다
  • 블룸 필터를 사용하기에 적합한 실제 상황
    • 데이터 양이 너무 많아서 해시 테이블조차도 사용하기 버겁고, 거짓-양성이 있어도 괜찮은 경우
    • ex) 새로운 이메일 주소 생성 시 중복 검사를 하는 경우
      • 기본적인 중복 검사도 매우 빈번하게 수행해야 한다
      • 사용하고 있지 않은 이메일 주소를 사용중이라고 해도 큰 문제는 없다
    • ex) 새로운 추천 광고 선택 알고리즘

해시 테이블에서 충돌

  • 다수의 키를 저장할 수 없는 문제를 해결해보자

체이닝

  • 앞에서는 하나의 해시 값에 대해 하나의 원소만을 저장했다.
    • 따라서 특정 해시 값 위치에 이미 원소가 존재한다면 새로운 값과 예전 값 중 하나를 버릴 수밖에 없었다.

체이닝(chaining)은 두 값을 모두 저장할 수 있는 여러 방법 중 하나이다

  • 이 방법은 해시 테이블의 특정 위치에서 하나의 키를 저장하는 것이 아니라, 하나의 연결 리스트를 저장한다
  • 새로 삽입된 키에 의해 충돌이 발생하면, 리스트의 맨 뒤에 새로운 키를 추가한다
    • 따라서 다수의 원소를 원하는 만큼 저장할 수 있다
  • 벡터 대신 연결 리스트를 사용하는 이유는, 특정 위치의 원소를 빠르게 삭제하기 위함이다

Example) 체이닝을 사용하는 해시 테이블

#include <iostream>
#include <vector>
#include <list>
#include <algorithm>

using uint = unsigned int;

class hash_map
{
    std::vector<std::list<int>> data;

public:
    // 해시 맵 또는 데이터의 크기를 인자로 받는 생성자
    hash_map(size_t n)
    {
        data.resize(n);
    }

    // 삽입 함수
    void insert(uint value)
    {
        int n = data.size();
        data[value % n].push_back(value);

        std::cout << value << "을(를) 삽입했습니다." << std::endl;
    }

    // 검색 함수
    bool find(uint value)
    {
        int n = data.size();
        auto& entries = data[value % n];

        return std::find(entries.begin(), entries.end(), value) != entries.end();
    }

    // 삭제 함수
    void erase(uint value)
    {
        int n = data.size();
        auto& entries = data[value % n];
        auto iter = std::find(entries.begin(), entries.end(), value);

        if(iter != entries.end())
        {
            entries.erase(iter);

            std::cout << value << "을(를) 삭제했습니다." << std::endl;
        }
    }
};


int main()
{
    hash_map map(7);

    auto print = [&](int value) {
        if(map.find(value))
            std::cout << "해시 맵에서 " << value << "을(를) 찾았습니다.";
        else
            std::cout << "해시 맵에서 " << value << "을(를) 찾지 못했습니다.";

        std::cout << std::endl;
    };

    map.insert(2);
    map.insert(25);
    map.insert(10);

    map.insert(100);
    map.insert(55);

    print(100);
    print(2);

    map.erase(25);

    return 0;
}

 

 

  • 룩업 함수는 아무래도 이전 구현보다는 살짝 느리다.
    • 이 함수는 단순히 n 값에 영향을 받는 것이 아니라, 입력 데이터에도 영향을 받는다
  • 삽입 함수의 시간 복잡도는 O(1)이다.
    • 리스트에 노드를 추가하는 연산이 단순히 값을 설정하는 것 보다는 조금 느릴 수 있지만 심하게 느리지는 않다.
    • 체이닝을 통해 얻을 수 있는 이점에 비하면 무시할 수 있는 수준이다.
  • 룩업과 삭제는 데이터 크기와 헤시 테이블의 크기에 따라 상당히 느릴 수 있다.
    • 예를 들어 모든 키가 같은 해시 값을 가질 경우, 룩업 연산은 연결 리스트에서 선형 검색 수행과 같으므로 O(N)의 시간 복잡도를 갖는다
      • 해시 테이블이 저장할 키 개수에 비해 매우 작다면 충돌이 많이 발생하게 되고, 리스트는 평균적으로 더 길어진다
      • 반대로 너무 큰 해시 테이블을 가지고 있다면 메모리 낭비가 발생한다
        • 응용 프로그램 동작 시나리오를 고려하여 해시 테이블 크기를 적절히 조절해야 한다
  • 부하율(load factor)는 해시 테이블에서 각각의 리스트에 저장되는 키의 평균 개수를 나타내며, 다음 수식을 이용하여 구할 수 있다
    • 부하율 = 전체 키 개수 / 해시 테이블의 크기
    • 만약 키 개수가 해시 테이블의 크기와 같다면 부하율은 1이다.
      • 부하율이 1보다 작으면 리스트 당 키가 하나도 저장되지 않은 경우가 있다는 것이고, 이는 메모리 낭비가 될 수 있다
      • 부하율이 1보다 크면 리스트의 평균 길이가 1보다 크다는 의미이고, 이 경우 검색, 삭제 등의 함수가 약간 느리게 동작할 수 있다
    • 이는 매우 이상적인 상태로, 모든 연산이 O(1)에 가깝게 작동하고 모든 메모리 공간을 적절하게 활용한다
  • 부하율은 항상 O(1)의 시간 복잡도로 계산할 수 있다
  • 몇몇 해시 테이블은 부하율이 1 근방의 특정 값보다 너무 크거나 작아지면 해시 함수를 변경하도록 구현되어 있으며, 이를 재해싱(rehashing)이라고 한다.
    • 즉, 해시 함수를 수정하여 부하율이 1에 가까운 값이 되도록 만드는 것이다.
    • 변경된 해시 함수에 의한 값의 분포와 부하율을 고려하여 해시 테이블의 크기도 변경할 수 있다.
  • 부하율이 해시 테이블의 성능을 결정하는 유일한 지표는 아니다
    • ex) 크기가 7인 해시 테이블에 일곱 개의 원소가 저장되어 있는데, 모든 원소가 같은 해시 값을 가져서 하나의 버킷에 있다.
      • 여기서 버킷이란 해시 테이블의 한 셀 또는 한 셀에 연결되어 있는 하나의 연결 리스트를 나타낸다
    • 검색 연산이 항상 O(1)이 아닌 O(N)의 시간 복잡도를 갖지만, 부하율은 1로 계산되버린다
    • 위 경우 해시 함수가 문제이다.
      • 해시 함수는 서로 다른 키가 가급적이면 겹치지 않고 골고루 분포되도록 해시 값을 만들어야 한다.
    • 기본적으로 버킷의 최대 크기와 최소 크기의 차이가 너무 크면 좋지 않다.
      • 위 예시에서는 차이가 7이다
    • 만약 해시 함수가 전체 일곱 개의 원소에 대해 서로 다른 해시 값을 반환한다면 검색 함수의 시간 복잡도는 O(1)이다.
      • 이 때 최대, 최소 버킷 크기 차이는 0이다
    • 이렇게 만드는 작업은 해시 테이블 구현에서 수행되지 않고, 해시 함수 자체에서 잘 고려해야 한다.
      • 해시 테이블은 해시 함수 구현에 종속적이지 않기 때문이다

열린 주소 지정(open addressing)

충돌을 해결하는 다른 방법으로 열린 주소 지정(open addressing)이 있다.

  • 이 방법은 체이닝처럼 해시 테이블에 추가적인 리스트를 붙여서 데이터를 저장하는 방식이 아니라, 모든 원소를 해시 테이블 내부에 저장하는 방식이다
    • 따라서 해시 테이블의 크기가 반드시 데이터 개수보다 커야 한다
  • 열린 주소 지정 방법의 핵심은 특정 해시 값에 해당하는 위치가 이미 사용되고 있다면, 테이블의 다른 비어 있는 위치를 탐색하는 것이다

선형 탐색(linear probing)

  • 가장 간단한 탐색 방법으로, 특정 해시 값에서 충돌이 발생하면 해당 위치에서 하나씩 다음 셀 위치로 이동하면서 셀이 비어 있는지를 확인하고, 비어 있는 셀을 찾으면 원소를 삽입한다.
    • 즉 hash(x)에 해당하는 셀이 이미 채워져 있다면 hash(x + 1) 위치의 셀을 확인한다.
      • hash(x + 1) 도 사용중이라면 hash(x + 2) 셀을 검사한다

 

  • 해시 테이블이 가득 차서 포화된 경우 재해싱 없이 새 원소를 삽입할 수 없다
  • 위 예시에서 보면 해시 테이블의 서로 인접한 군집 형태로 값이 채워졌다
  • 만약 원소 검색을 할 경우, 해시 함수에서 반환된 위치가 큰 군집의 시작 위치를 가리킨다면 클러스터의 맨 마지막 위치까지 선형 검색을 해야 한다
    • 이 경우 검색 속도가 크게 느려질 수 있다
    • 즉, 데이터가 특정 위치에 군집화(clustering)되는 경우에 문제가 발생하게 된다
      • 특정 해시 값이 너무 자주 발생해서 데이터가 몇 개의 그룹으로 뭉치는 형태로 저장된다는 의미이다

이차함수 탐색(quadratic probing)

  • 군집화 문제를 해결하기 위해 선형 방정식이 아닌 이차 방정식을 사용하여 탐색을 수행할 수 있다.
    • 이를 이차함수 탐색 이라고 한다
  • hash(x) 위치가 이미 사용중이라면, hash(x + 1^2)으로 이동하고, 그 다음은 hash(x + 2^2)의로 이동한다.
    • 이처럼 이동 폭을 이차함수 형태로 증가시키면 데이터 군집이 나타날 확률은 상대적으로 줄어든다
  • 선형 탐색 및 이차함수 탐색은 모두 원소 위치가 기존에 삽입되어 있는 다른 원소들에 의해 영향을 받는다
    • 이 때 기존에 저장되어 있던 원소는 새로 삽입하는 원소와 서로 다른 해시 값을 가질 수 있다

뻐꾸기 해싱(cuckoo hashing)

뻐꾸기 해싱(cuckoo hashing)은 완벽한 해싱 기법 중 하나이다. 앞서 언급했던 방법들은 최악의 상황에서는 O(1)의 시간 복잡도를 보장하지 않지만, 뻐꾸기 해싱은 구현만 제대로 한다면 O(1)을 만족한다

  • 뻐꾸기 해싱은 크기가 같은 두 개의 해시 테이블을 사용하고, 각각의 해시 테이블은 서로 다른 해시 함수를 가진다
  • 모든 원소는 두 해시 테이블 중 하나에 있을 수 있으며, 그 위치는 해당 해시 테이블의 해시 함수에 의해 결정된다

이전 해싱 기법과 다른점

  1. 원소가 두 해시 테이블 중 어디든 저장될 수 있다
  2. 원소가 나중에 다른 위치로 이동할 수 있다
  • 앞서 언급한 해싱 방법에서는 재해싱을 수행하지 않는 한 원소가 최초 삽입된 위치에서 다른 위치로 이동할 수 없다
    • 그러나 뻐구기 해싱 방법에서는 모든 원소가 두 개의 저장 가능한 위치를 가지며, 상황에 따라 이동할 수 있다
    • 상황에 따라 저장 가능한 위치 개수를 증가시킬 수도 있다
  • 룩업의 경우 특정 원소가 존재하는지 알기 위해 저장 가능한 위치 두 군데만 확인해보면 되므로, 룩업 연산의 시간 복잡도는 항상 O(1)이다.
  • 삽입 연산은 좀 더 오래걸릴 수 있다
    • ex) A라는 원소를 삽입하는 경우, A를 삽입할 해당 위치가 비어있다면 그대로 A를 삽입하면 된다
    • 이미 다른 원소 B가 저장되어 있다면, 해당 위치에 A를 저장하고 B를 두 번째 해시 테이블로 옮긴다
    • 만약 B가 이동할 위치에 이미 다른 원소 C가 저장되어 있다면 해당 위치에 B를 저장하고 C를 첫 번째 해시 테이블로 옮긴다
    • 이러한 작업을 완전히 비어 있는 셀이 나타날 때 까지 재귀적으로 반복한다

  • 위와 같은 과정에서 만약 순환이 발생한다면 무한 루프에 빠질 수 있다
  • 순환이 발생했다면 새로운 해시 함수를 이용하여 재해싱을 수행해야 한다
    • 재해싱 하고도 순환이 발생하여, 여러번 재해싱을 수행해야 할 수도 있다.

  • 적절한 해시 함수를 사용하면 높은 확률로 O(1)의 성능을 갖는 해시 테이블을 구성할 수 있다
  • 뻐구기 해싱도 전체 해시 테이블 크기 이상의 원소를 저장할 수는 없다
    • 높은 성능을 보장하려면 부하율이 0.5보다 작게끔 설정해야 한다
    • 즉, 전체 원소 개수가 해시 테이블 크기의 절반보다 작아야 한다

Example) 뻐구기 해싱

#include <iostream>
#include <vector>

using namespace std;

class hash_map
{
    // 두 개의 해시 테이블과 각 테이블의 크기
    std::vector<int> data1;
    std::vector<int> data2;
    int size;

    // 해시 함수 정의
    int hash1(int key) const
    {
        return key % size;
    }

    int hash2(int key) const
    {
        return (key / size) % size;
    }

public:

    // 해시 테이블을 모두 -1로 초기화
    hash_map(int n) : size(n)
    {
        data1 = std::vector<int>(size, -1);
        data2 = std::vector<int>(size, -1);
    }

    // 룩업 함수
    // 양쪽 해시 테이블에서 키를 검색하고 해당 위치에 나타내는 반복자 바노한
    std::vector<int>::iterator lookup(int key)
    {
        auto hash_value1 = hash1(key);

        if(data1[hash_value1] == key)
        {
            std::cout << "1번 테이블에서 " << key << "을(를) 찾았습니다." << std::endl;

            return data1.begin() + hash_value1;
        }

        auto hash_value2 = hash2(key);

        if(data2[hash_value2] == key)
        {
            std::cout << "2번 테이블에서 " << key << "을(를) 찾았습니다." << std::endl;

            return data2.begin() + hash_value2;
        }

        return data2.end();
    }

    void erase(int key)
    {
        auto position = lookup(key);

        if(position != data2.end())
        {
            *position = -1;
            std::cout << key << "에 해당하는 원소를 삭제했습니다." << std::endl;
        }
        else 
        {
            std::cout << key << "키를 찾지 못했습니다." << std::endl;
        }
    }

    // 삽입 함수
    void insert(int key)
    {
        insert_impl(key, 0, 1);
    }

    // 삽입 구현 함수
    // key : 키, cnt : 재귀 호출 횟수, table : 키를 삽입할 테이블 번호
    void insert_impl(int key, int cnt, int table)
    {
        if(cnt >= size)
        {
            std::cout << key << " 삽입 시 순환 발생! 재해싱이 필요합니다!" << std::endl;
            return;
        }

        if(table == 1)
        {
            int hash = hash1(key);
            if(data1[hash] == -1) 
            {
                std::cout << table << "번 테이블에 " << key << " 삽입" << std::endl;
                data1[hash] = key;
            }
            else
            {
                int old = data1[hash];
                data1[hash] = key;

                std::cout << table << "번 테이블에 " << key <<  "삽입 : 기존의 " << old << " 이동 -> ";
                insert_impl(old, cnt + 1, 2);
            }
        }

        else
        {
            int hash = hash2(key);
            if(data2[hash] == -1) 
            {
                std::cout << table << "번 테이블에 " << key << " 삽입" << std::endl;
                data2[hash] = key;
            }
            else
            {
                int old = data2[hash];
                data2[hash] = key;

                std::cout << table << "번 테이블에 " << key <<  "삽입 : 기존의 " << old << " 이동 -> ";
                insert_impl(old, cnt + 1, 1);
            }
        }
    }

    void print()
    {
        std::cout << "Index : ";
        for(int i = 0; i < size; i++)
            std::cout << i << '\t';
        std::cout << std::endl;

        std::cout <<"Data1 : ";
        for(auto i : data1)
            std::cout << i << '\t';
        std::cout << std::endl;

        std::cout <<"Data2 : ";
        for(auto i : data2)
            std::cout << i << '\t';
        std::cout << std::endl;
    }
};

int main()
{
    hash_map map(7);
    map.print();

    std::cout << std::endl;

    map.insert(10);
    map.insert(20);
    map.insert(30);
    std::cout << std::endl;

    map.insert(104);
    map.insert(2);
    map.insert(70);
    map.insert(9);
    map.insert(90);
    map.insert(2);
    map.insert(7);
    std::cout << std::endl;

    map.print();
    std::cout << std::endl;


    // 순환 발생
    map.insert(14);

    return 0;
}

 

  • 룩업 함수는 양쪽 해시 테이블에서 키를 검색하고, 해당 위치를 나타내는 반복자를 반환한다
    • 삭제 함수에서 이 반복자를 사용한다
    • 원소를 찾지 못하면 data2 테이블의 마짐가을 가리키는 반복자가 반환된다
      • O(1)의 시간 복잡도를 갖는다
  • 삭제 함수의 경우 룩업 함수가 반환한 값을 조사하여 실제 삭제 작업을 수행하거나, 단순히 문자열만 출력한다
    • O(1)
  • 삽입은 재귀적으로 동작해야 하기 때문에 삽입 구현 함수를 따로 만든다
    • 삽입 함수에서는 순환 여부를 검색해야 한다
    • 그러나 방문한 위치의 모든 값을 기억하는 것은 부담이 클 수 있다
    • 그러므로 여기서 단순히 재귀 호출 횟수가 몇 회 이상이 되면 순환으로 간주하여 구현한다
    • 이 때 최대 재귀 호출 횟수는 해시 테이블의 크기와 같게 설정하였다
  • 원소 삽엡에서만 어느 정도 시간을 필요로 하므로, 위 구현 코드는 원소 삽입보다 룩업 연산이 훨씬 많은 application에서 적합하다

해시 테이블

  • lookup(룩업, 조회)는 특정 원소가 컨테이너에 있는지 확인하거나 또는 컨테이너에서 특정 키(key)에 해당하는 값(value)를 찾는 작업을 의미한다
    • DB에서 원하는 자료를 찾거나, 사전에서 단어의 뜻을 찾는 문제들이 이에 해당
  • 모든 원소를 선형으로 검토하여 원하는 값을 찾는 작업은 일반적으로 매우 많은 시간이 소요된다
    • O(N) 시간 복잡도를 가짐
  • 이 작업을 훨씬 빠르게 수행할 수 있는 효과적인 알고리즘이 필요하다.
    • 해시 테이블과 블룸 필터라는 효과적인 구조에 대해 살펴본다.

해시 테이블

  • O(N)을 갖는 선형 검색보다 더 나은 검색 방법은 BST와 같은 속성을 갖도록 높이 균형 트리에 데이터를 저장하는 것이다.
    • 시간 복잡도가 O(log N)이므로 선형 검색보다 훨씬 빨라진다
    • 검색 횟수가 증가하면 이 정도 연산 속도도 만족스럽지 않을 수 있다.
  • 해시 테이블(hash table) 이 괜찮은 효율적인 방법 중 하나이다
  • 해시 테이블의 핵심은 해싱(hashing)이다.
    • 해싱(hashing) : 각각의 데이터를 가급적 고유한 숫자 값으로 표현하고, 나중에 같은 숫자 값을 사용하여 데이터의 유뮤룰 확인하거나 또는 해당 숫자에 대응하는 원본 데이터를 추출하는 작업
    • 해시 함수(hash function) : 주어진 데이터로부터 고유한 숫자 값을 계산하는 함수

해싱

정수를 저장하고 있는 컨테이너가 있고, 이 컨테이너에 특정 정수가 들어 있는지 빠르게 판단하고 싶은 상황

  • 가장 간단한 방법은 부울 타입 배열을 하나 만들고, 입력 정수를 이 배열 원소의 인덱스로 취급하는 것
    • data[x] = true
    • 이 방식에서 원소 삽입, 삭제, 검색의 시간 복잡도는 O(1)

  • 위 방법의 문제점
    • 데이터가 실수인 경우 어떻게 저장할건가?
    • 데이터가 숫자가 아닌 경우 어떻게 저장할건가?
    • 데이터 범위가 너무 큰 경우
      • ex) 10억개의 숫자를 사용한다면 10억 크기의 부울 타입 배열이 필요하다
  • 위 문제를 해결하기 위해 어떤 데이터 타입의 값이는 원하는 범위의 정수로 매핑하는 함수를 만들어 사용할 수 있다
    • 이 경우 원하는 정수 범위를 적절히 설정하면 부울 타입 배열을 만드는 것이 부담되지 않음
    • 이러한 역할을 하는 함수가 위에서 언급했던 해시 함수이다.
      • 이 함수는 데이터 원소를 인자로 받고 정해진 범위의 정수를 반환함
  • 가장 간단한 해시 함수는 큰 범위의 정수를 인자로 받아 정해진 정수(n)으로 나눈 나머지를 반환하는 모듈로 함수(modulo function)이며, 보통 % 기호로 표시한다.
    • 이 경우 크기가 n인 배열을 사용할 수 있다
    • 해시 함수에 의해 반환되는 숫자 값을 해시 값(hash value)라고 한다
  • 모듈로 함수의 문제점은, 이 함수가 서로 다른 데이터에 대해 같은 해시 값을 반환할 수 있다는 점이다
    • ex) 9 % 7과 16 % 7은 모두 해시값으로 2를 반환하는데, [2]에 들어 있는 데이터가 9인지 16인지, 또는 이를 만족하는 다른 정수인지를 알 수 없다
    • 이러한 문제를 충돌(collision)이라고 한다.
      • 즉, 해시 함수가 서로 다른 키에 대해 같은 해시 값을 반환함으로써 다수의 키가 같은 값을 갖게 되는 현상을 말한다
  • 해시 테이블에 부울 값 대신 실제 데이터를 저장하면 해당 키에 어떤 데이터가 저장되어 있는지를 바로 알 수 있지만, 여전히 여러 데이터를 저장할 수는 없다
    • 해결 방법은 아래에 다시 나올거에요

Example) 정수 값을 저장하는 간단한 사전

#include <iostream>
#include <vector>

using uint = unsigned int;

class hash_map
{
    std::vector<int> data;

public:
    // 모든 원소를 -1로 초기화
    hash_map(size_t n)
    {
        data = std::vector<int>(n, -1);
    }

    // 삽입 함수
    void insert(uint value)
    {
        int n = data.size();
        data[value % n] = value;

        std::cout << value << "을(를) 삽입했습니다." << std::endl;
    }

    // 특정 원소가 있는지 확인하는 함수
    bool find(uint value)
    {
        int n = data.size();
        return (data[value % n] == value);
    }

    // 삭제 함수
    void erase(uint value)
    {
        int n = data.size();

        if(data[value % n] == value)
        {
            data[value % n] = -1;

            std::cout << value << "을(를) 삭제했습니다." << std::endl;
        }
    }
};

using namespace std;

int main()
{
    hash_map map(7);

    auto print = [&](int value) {
        if(map.find(value))
            std::cout << "해시 맵에서 " << value << "을(를) 찾았습니다.";
        else
            std::cout << "해시 맵에서 " << value << "을(를) 찾지 못했습니다.";

        std::cout << std::endl;
    };

    map.insert(2);
    map.insert(25);
    map.insert(10);
    print(25);

    map.insert(100);
    print(100);
    print(2);

    map.erase(25);

    return 0;
}

 

 

  • 해시 테이블을 사용할 때 단순히 해당 키가 있는 지를 확인하는 것이 아니라 해당 키에 대응하는 값이 있는지를 확인해야 한다
    • 이를 위해 키와 값을 함께 저장하는 방식을 사용해야 한다
  • 삽입, 삭제, 룩업 함수에서 키를 기반으로 해시 값을 계산하여 배열에서의 위치를 알아낸 후 함께 저장된 값을 이용할 수 있어야 한다

다양한 트리 구조(균형 트리, N-항 트리)

균형 트리(Balanced Tree)

만약 BST에 다음과 같은 순서로 삽입한다고 생각해보자

bst tree;
tree.insert(10);
tree.insert(9);
tree.insert(11);
tree.insert(8);
tree.insert(7);
tree.insert(6);
tree.insert(5);
tree.insert(4);

 

다음과 같은 구조를 갖는다

  • 전체 트리가 왼쪽으로 편향되어 있다
  • 이 경우 검색을 한다면 비교 횟수가 원소 개수와 거의 같아진다

만약 다음과 같은 순서로 삽입하여 구성된 트리에서 검색을 한다면

bst tree;
tree.insert(7);
tree.insert(5);
tree.insert(9);
tree.insert(4);
tree.insert(6);
tree.insert(10);
tree.insert(11);
tree.insert(8);

 

  • 단계가 크게 감소한다
  • 위와 같은 트리는 편향되지 않은 상태이며, 이러한 트리를 균형이 잡혔다고 한다
  • 즉, find() 함수의 시간 복잡도는 단순히 원소의 개수에 영향을 받는게 아니라, 트리의 형태에도 영향을 받는다
    • 검색을 하계 되면 항상 트리의 아래쪽으로 한 단계씩 나아가서 리프 노드에서 끝난다
    • 따라서 검색에 필요한 단계의 수는 BST의 최대 레벨 수보다는 작다.
    • 검색의 시간 복잡도는 O(높이)로 표현할 수 있다
  • 원소 검색의 시간 복잡도를 최적화 하려면, 트리의 높이가 최적화 되어야 한다.
    • 이러한 작업을 트리의 균형 잡기라고 한다
  • 트리의 균형을 잡기 위해서는 원소 삽입/삭제 이후 트리 구성을 조정해야 한다
  • 이렇게 조정되어 편향성이 줄어든 이진 검색 트리를 높이-균형 BST(height-balanced BST)라고 한다
    • 이를 통해 AVL 트리, 레드-블랙 트리 같은 다양한 트리를 만들 수 있다.
      • AVL 트리의 기본 아이디어는 BST 속성을 유지하며 트리의 높이 균형을 잡기 위해 약간의 회전을 수행한다

N-항 트리(N-ary tree)

  • 각 노드가 N개의 자식을 가질 수 있다.
  • N개의 자식 노드는 벡터를 이용하여 저장할 수 있다
  • 구조체
struct nTree
{
    int data;
    std::vector<nTree*> children;
};
  • 각각의 노드는 임의 개수의 자식을 거느릴 수 있다.
    • 전체 트리도 임의의 형태를 가지게 됨
  • 평범한 N-항 트리는 그다지 유용하지 않아, 요구사항에 맞는 트리를 만들어 사용해야 한다
  • 컴퓨터 분야에서 N항 트리를 사용하는 대표적인 예
    • 컴퓨터 파일 시스템 구조
    • 컴파일러

다양한 트리 구조(BST)

  • 평범한 이진 트리의 효용성은 그리 높지 않음
  • BST, Balanced Tree 등에 대해 알아보자

이진 검색 트리(BST, Binary Search Tree)

  • 널리 사용되는 형태의 이진 트리
  • 다음과 같은 속성이 있다.
    • 부모 노드의 값 >= 왼쪽 자식 노드의 값
    • 부모 노드의 값 <= 오른쪽 자식 노드의 값
    • 즉, (왼쪽 노드 <= 부모 노드 <= 오른쪽 노드) 관계를 갖는다
    • 부모 노드보다 작거나 같은 모든 원소는 항상 왼쪽에 있다.
    • 부모 노드보다 크거나 같은 원소는 항상 오른쪽에 있다.

완전 이진 트리(complete binary tree)

  • 이진트리에서 마지막 레벨을 제외한 모든 노드에 두개의 자식 노드가 있는 트리를 말함
  • 이 때 트리의 높이는 log_2(N)이 됨

BST에서 원소 검색

  • BST의 속성 때문에 원소 검색을 위해 루트 노드부터 차례대로 값을 비교하는 경우, 각 단계마다 검색 범위가 절반으로 줄어든다
  • 완전 이진 트리의 검색 및 삽입 동작은 O(logN)의 시간 복잡도를 갖는다.
  • 이진 트리의 검색 및 삽입 동작은 정확히는 O(logN)의 시간 복잡도를 갖는다고 보기 어렵다.
    • 정확한 시간 복잡도는 아래에서 다시 다룰거에요

 

EX) 아래와 같이 중복되지 않는 양수를 원소로 갖는 트리가 있으며, 7을 찾아보자

  • 루트 노드 부터 비교하여 어느쪽 하위 노드로 이동해야 할 지 결정하고, 계속 이동하면서 검색하고자 하는 값과 일치하는 노드를 찾는다.
  • BST에서 원소를 검색할 때는 트리의 모든 원소를 방문하지 않아도 됨
  • 현재 노드가 찾고자 하는 노드가 아닐 때마다 검색 범위가 반으로 줄어듬
    • 선형 자료 구조, 특히 분할 정복과 유사함

BST에서 새 원소 삽입하기

  • 먼저 원소가 삽입될 위치의 부모 노드를 찾아야 함
    • 루트 노드부터 시작하여 각 노드를 추가할 원소와 비교하면서 원소가 삽입될 위치로 이동해야 한다

BST에서 원소 삭제하기

  • 단순히 노드 삭제하는 것으로 끝나지 않고, 노드 삭제 후 전체 트리가 BST 속성을 만족하도록 다른 적절한 노드로 삭제된 노드를 대체해야 한다
    • 삽입보다 복잡하다

3가지 Case

  1. 자식 노드가 없는 경우 : 단순히 해당 노드를 삭제한다
  2. 자식 노드가 하나만 있는 경우, 노드 삭제 후, 부모 노드의 포인터가 해당 자식 노드를 가리키도록 조정
  3. 자식 노드가 두 개 있는 경우 : 노드 삭제 후, 현재 노드를 후속 노드(successor)로 대체한다

(여기서 후속 노드란 현재 노드 다음으로 큰 숫자를 가진 노드를 말함)

현재 노드를 후속 노드로 대체하는 과정

  • 현재 노드의 오른쪽 서브 트리로 이동한 후, 여기서 가장 작은 값의 노드를 찾으면 된다
    • 가장 작은 값의 노드를 찾으려면, 서브 트리에서 가장 왼쪽에 위치한 노드로 이동하면 된다
  • 후속 노드는 오른쪽 자식 노드 하나만 가질 수 있다.
    • 왼쪽 자식이 있었다면 그 노드를 후속 노드로 선택했을 것이기 때문

트리 연산의 시간 복잡도

  • 검색
    • 이론적으로는 매번 검색 범위가 절반적으로 줄어듬
    • N개의 노드를 가지고 있는 BST에서 검색에 필요한 시간
      • T(N) = T(N/2) + 1
    • 따라서 T(N) = O(log N)
  • 주의해야 하는 점!
    • 트리의 모양이 원소 삽입 순서에 따라 결정된다
      • 검색 범위가 T(N/2) 형태로 항상 줄어드는 것도 아니다
        • 따라서 시간 복잡도가 O(log N)이라는 것도 항상 정확하다고 볼 수 없다

BST 구현

#include <iostream>

struct node
{
    int data;
    node* left;
    node* right;
};

struct bst
{
    node* root = nullptr;

    node* find(int value)
    {
        return find_impl(root, value);
    }

    void insert(int value)
    {
        if(!root)
            root = new node {value, NULL, NULL };
        else
            insert_impl(root, value);
    }

    void inorder()
    {
        inorder_impl(root);
    }

    node* successor(node* start)
    {
        auto current = start->right;

        while(current && current->left)
            current = current->left;

        return current;
    }

    void deleteValue(int value)
    {
        root = delete_impl(root, value);
    }

private:
    node* find_impl(node* current, int value)
    {
        if(!current)
        {
            std::cout << std::endl;

            return NULL;
        }

        if(current->data == value)
        {
            std::cout << value << "을(를) 찾았습니다." << std::endl;

            return current;
        }

        // value 값이 현재 노드 왼쪽에 있는 경우
        if(value < current->data)
        {
            std::cout << current->data <<"에서 왼쪽으로 이동 : ";

            return find_impl(current->left, value);
        }

        // value 값이 현재 노드 오른쪽에 있는 경우
        std::cout << current->data << "에서 오른쪽으로 이동 : ";

        return find_impl(current->right, value);
    }


    void insert_impl(node* current, int value)
    {
        if(value < current->data)
        {
            if(!current->left)
                current->left = new node {value, NULL, NULL};
            else
                insert_impl(current->left, value);
        }
        else
        {
            if(!current->right)
                current->right = new node {value, NULL, NULL};
            else
                insert_impl(current->right, value);
        }
    }

    void inorder_impl(node* start)
    {
        if(!start)
            return;

        // 왼쪽 서브 트리 방문
        inorder_impl(start->left);

        // 현재 노드 출력
        std::cout << start->data << " ";

        // 오른쪽 서브 트리 방문
        inorder_impl(start->right);
    }

    node* delete_impl(node* start, int value)
    {
        if(!start)
            return NULL;

        if(value < start->data)
            start->left = delete_impl(start->left, value);
        else if(value > start->data)
            start->right = delete_impl(start->right, value);
        else 
        {
            // 자식 노드가 전혀 없거나, 왼쪽 자식 노드만 없는 경우
            if(!start->left)
            {
                auto tmp = start->right;
                delete start;
                return tmp;
            }

            // 오른쪽 자식 노드만 없는 경우
            if(!start->right)
            {
                auto tmp = start->left;
                delete start;
                return tmp;
            }

            // 자식 노드 둘 다 있는 경우
            auto succNode = successor(start);
            start->data = succNode->data;

            // 오른쪽 서브 트리에서 후속(successor)을 찾아 삭제
            start->right = delete_impl(start->right, succNode->data);
        }

        return start;
    }
};

using namespace std;

int main()
{
    bst tree;

    tree.insert(12);
    tree.insert(10);
    tree.insert(20);
    tree.insert(8);
    tree.insert(11);
    tree.insert(15);
    tree.insert(28);
    tree.insert(4);
    tree.insert(2);


    std::cout << "중위 순회 : ";
    tree.inorder();
    std::cout << std::endl;

    tree.deleteValue(12);

    std::cout << "12를 삭제한 후 중위 순회 : ";
    tree.inorder();
    std::cout << endl;

    if(tree.find(12))
        std::cout << "원소 12는 트리에 있습니다." << std::endl;
    else
        std::cout << "원소 12는 트리에 없습니다." << std::endl;

    return 0;
}

비선형 문제

선형 자료 구조로 표현할 수 없는 대표적인 문제

  • 계층적 문제(hierachical problem)
    • 순환 종속성 문제(cyclic dependency)

계층적 문제

  • ex)
    • 회사 조직과 같은 조직 구성은 계층적으로 표현되지만, 선형 자료구조로 표현하기 어렵다
    • 선 이수 체계를 갖는 대학교 과정에 대한 종속 관계를 표현할 때에도 마찬가지다.
  • 트리를 이용하여 해결

순환 종속성

  • ex)
    • SNS에서 사람들과의 친구관계 (그래프)
    • 도시와 도시를 잇는 도로망
  • 그래프를 이용하여 해결

+ Recent posts