IT_Study/C++

[C++] Bit operation, 구조체를 활용한 Map 사용법 정리

__Vivacé__ 2023. 2. 23. 10:18

Bit Operation(비트 연산)

컴퓨터에서 데이터를 비트(0 또는 1) 단위로 조작하는 연산

주로 컴퓨터에서 비트맵, 마스크(mask), 효율적인 데이터 압축, 암호화 등에 사용

 

*비트(Bit) : "binary digit"의 줄임말로, 0 또는 1의 값을 가지는 이진수(binary number)의 한 자릿수를 나타냄.


비트 연산자(Bit operator)

1. AND 연산(&)

두 비트가 모두 1인 경우에만 1을 반환

#include <iostream>

using namespace std;

int main() {
    int a = 10; // 1010
    int b = 12; // 1100
    
    // AND 연산
    int c = a & b; // 1000
    cout << "a & b = " << c << endl; // a & b = 8
}

2. OR 연산(|)

두 비트 중 하나라도 1인 경우에 1을 반환

#include <iostream>

using namespace std;

int main() {
    int a = 10; // 1010
    int b = 12; // 1100
    
    // OR 연산
    int d = a | b; // 1110
    cout << "a | b = " << d << endl; // a | b = 14
}

3. XOR 연산(^)

두 비트가 서로 다른 경우에만 1을 반환

#include <iostream>

using namespace std;

int main() {
    int a = 10; // 1010
    int b = 12; // 1100

		// XOR 연산
    int e1 = a ^ b; // 0110
    cout << "a ^ b = " << e1 << endl; // a ^ b = 6

		int e2 = a ^ b ^ a ^ b ^ a;

		cout << "a ^ b ^ a ^ b ^ a = " << e2 << endl;
		// a ^ b ^ a ^ b ^ a = 10;
		// a로 돌아온다는 사실을 알 수 있음 -> 암호화에 많이 쓰임
}

4. NOT 연산(~)

비트를 모두 반대로 뒤집음

#include <iostream>

using namespace std;

int main() {
    int a = 10; // 1010

		// NOT 연산
    int f = ~a; // 0101
    cout << "~a = " << f << endl; // ~a = -11
																	// 10진수 : -a - 1;
}

5. 왼쪽 시프트 연산(<<)

비트를 왼쪽으로 이동

#include <iostream>

using namespace std;

int main() {
    int a = 10; // 1010

		// 왼쪽 시프트 연산
    int g = a << 1; // 10100
    cout << "a << 1 = " << g << endl; // a << 1 = 20
}

6. 오른쪽 시프트 연산(>>)

비트를 오른쪽으로 이동

#include <iostream>

using namespace std;

int main() {
    int b = 12; // 1100

		// 오른쪽 시프트 연산
    int h = b >> 1; // 0110
    cout << "b >> 1 = " << h << endl; // b >> 1 = 6
}

 


Hash (해시 함수; hash function)

  1. 임의의 크기의 데이터를 고정된 크기의 데이터로 매핑하는 함수
  2. 특정 값들을 찾아줄수 있는 Key로 변환해주는 함수

 

*해시 값(hash value) : 해시 함수를 통해 입력 데이터를 고정된 크기의 값으로 변환한 결과값을 의미

*버킷(bucket) : 실제 데이터가 저장되는 공간, 슬롯(slot)이라고도 함

 


Hash Collision(해시 충돌)

서로 다른 데이터를 입력해도, 해시 함수를 통해 생성된 해시 밸류가 중복되어 문제가 발생한 경우

#include <iostream>
#include <unordered_map>
using namespace std;

int hF(int key){
	return key%10;
}

int main() {
  unordered_map<int, string> myMap;
  myMap[hF(1)] = "apple";
  myMap[hF(2)] = "banana";
	
  // key 1과 key 11이 같은 해시값을 가지는 충돌이 발생한다.
  myMap[hF(11)] = "strawberry";

  cout << "myMap[1] = " << myMap[hF(1)] << endl;  // "strawberry" 출력
  cout << "myMap[2] = " << myMap[hF(2)] << endl;  // "banana" 출력
}

 


Hash collision의 원인

 

  1. 해시 함수의 크기 제한
    • 입력 데이터의 크기가 클 때
      • 해시 함수를 통해 생성된 해시 밸류가 고르게 분포하지 않을 가능성이 높아짐
    • 해시값의 크기가 매우 작을 때
      • 서로 다른 입력 데이터에 대해 중복될 가능성이 높아짐

 

  1. 입력 데이터의 분포
    • 입력 데이터가 균등하게 분포된 경우에는, 해시 함수를 통해 생성된 해시 밸류들도 균등하게 분포될 가능성이 높아져, 해시 충돌이 발생할 확률이 줄어듦
    • 입력 데이터가 균등하게 분포되지 않은 경우에는 해시 밸류도 균등하게 분포되지 않을 가능성이 있으며, 이로 인해 해시 충돌이 발생할 확률이 높아짐

 

  1. 해시 함수의 설계
    • 해시 함수의 설계가 충분히 좋지 않은 경우, 일부 입력 데이터에 대해 해시 충돌이 발생할 가능성이 높아짐

 


Hash collision이 발생할 시 대표적인 해결 방법

 

  1. Separate Chaining (체이닝) : 충돌이 발생한 버킷에 연결 리스트를 사용하여 데이터를 삽입하는 방법
    • 장점
      • 한정된 메모리를 효율적으로 사용 가능
      • 해시 함수 선정의 중요성이 상대적으로 작음
    • 단점
      • 특정 해시 값이 가리키는 bucket에 데이터가 계속 추가된다면, 검색 효율이 떨어질 수 있음
      • 외부 저장 공간을 사용하기 때문에, 추가적인 공간이 필요

 

 

  1. Open addressing (개방 주소법) : 비어있는 버킷(bucket)을 찾아 데이터를 저장하는 기법
    • 장점
      • 해시테이블 내에서 데이터 저장 및 처리가 가능
    • 단점
      • 해시 함수의 성능에 전체 해시테이블의 성능이 달라짐
      • 데이터의 길이가 늘어나면 그에 해당하는 저장소를 마련해야 함
    • 방식
      • 선형 탐색(Linear Probing): 다음 해시(+1)나 n개(+n)를 건너뛰어 비어있는 해시에 데이터를 저장
      • 제곱 탐색(Quadratic Probing): 충돌이 일어난 해시의 제곱을 한 해시에 데이터를 저장
      • 이중 해시(Double Hashing): 다른 해시함수를 한 번 더 적용한 해시에 데이터를 저장

 


C++ 에서의 Map 활용

1. Unordered map

Hast table 구조를 이용하여 구현

 

  • 장점
    • 시간 복잡도
      • 데이터 삽입, 제거, 탐색 : O(1) ~ O(logN)
      • Hash function도 시간 복잡도에 영향을 미침

 

  • 단점
    • 데이터를 정렬하지 않음 → 범위 검색(~이상 ~이하인 값을 찾아보자 등)에는 부적합
    • Hash collision 발생 확률이 있음

 


메서드 사용법

#include <iostream>
#include <unordered_map>

using namespace std;

unordered_map<int, int>info;

int main() {
    
    // 1. insert() 메서드를 이용한 데이터 추가 - info.insert({ key, value })
    info.insert({ 1, 1 });

    cout << info[1];  // 1
		
    // 1-1. 기존에 key에 값이 있다면 덮어씌워지는 게 아니라 무시됨
    info.insert({ 1, 2 });

    cout << info[1];  // 1

    // 2. [] 연산자를 이용한 데이터 추가 - info[key] = value;
    info[2] = 2;

    cout << info[2];  // 2

    // 2-1. 이 방식은, 기존에 key 값이 존재하더라도 덮어씌워짐
    info[2] = 3;

    cout << info[2];  // 3

    // 2-2. 호출만 되어도 값이 알아서 저장이 됨 - 유의할 것
    if (info[4] != 1)
        cout << info[4];  // 0 <- value가 int type이면 호출되면 알아서 0 저장

    // 2-2. find() 메서드를 이용한 key 탐색
    if (info.find(2) == info.end())
        cout << "입력한 key가 존재하지 않음";
		else
				cout << "입력한 key가 존재함";

    // 3. at() : 주어진 key에 해당하는 value를 반환
    // at() 메서드는 key가 존재하지 않을 경우 예외를 발생 - 예외 처리 필요

    // 4. count() : 주어진 key가 map에 존재하는 지 여부를 반환 - 있으면 1 없으면 0
    // count() 메서드는 예외를 발생시키지 않음

    cout << endl;

    // 5. traversal / iteration - 시간이 많이 걸림
    // first : key, second : value
    for (auto it = info.begin(); it != info.end(); it++) {
        cout << it->first << " " << it->second << '\\n';
    }
}

 


2. Map

Red-Black Tree(레드-블랙 트리)를 이용하여 구현

 

  • 장점
    • 데이터를 정렬된 상태로 유지 → 범위 검색에 용이
    • Binary Search Tree이기 때문에, Hash collision이 일어나지 않음

 

  • 단점
    • 생성, 제거, 탐색이 O(logN)으로, Unordered map보다 느림
    • 데이터 양이 많은 경우 불리

 


메서드 사용법

#include <iostream>
#include <map>

using namespace std;

int main() {
    map<int, string> m;

    // insert() 메서드를 이용한 데이터 추가
    m.insert(make_pair(1, "John"));
    m.insert(make_pair(2, "Jane"));
    m.insert(make_pair(3, "Tom"));

    // [] 연산자를 이용한 데이터 추가
    m[4] = "Alice";
    m[5] = "Bob";
    m[6] = "Charlie";

    // find() 메서드를 이용한 key 탐색
    if (m.find(3) != m.end()) {
        cout << "key 3 is found" << endl;
    }
    if (m.find(7) == m.end()) {
        cout << "key 7 is not found" << endl;
    }

    // 데이터 순회
    for (auto iter = m.begin(); iter != m.end(); iter++) {
        cout << "key: " << iter->first << ", value: " << iter->second << endl;
    }

    // lower_bound()와 upper_bound() 메서드 사용 예시
    // lower_bound() : 특정 key 값 '이상'인 첫 번째 iterator(key, value 쌍) 반환
    // upper_bound() : 특정 key 값 '초과'인 첫 번째 iterator(key, value 쌍) 반환

    auto it1 = m.lower_bound(6); // key: 6, value: Charlie
    auto it2 = m.upper_bound(6); // key-value pair not found

    if (it1 != m.end()) {  // 반복자가 end()를 가리키는지 확인
        cout << "key: " << it1->first << ", value: " << it1->second << endl;
    }
    else {
        cout << "key-value pair not found" << endl;
    }

    // equal_range() 메서드 사용 예시

    // lower_bound()와 upper_bound()를 동시 실행 - 조건에 맞는 모든 key-value 쌍을 반환
    // 보통 중복된 key 값을 가질 수 있는 multimap에서 사용

    auto range = m.equal_range(5);
    for (auto it = range.first; it != range.second; it++) {
        cout << "key: " << it->first << ", value: " << it->second << endl;
    }

    // rbegin()와 rend() 메서드 사용 예시
    // 역방향 반복자를 반환하는 메서드 : rbegin = reverse begin
    for (auto it = m.rbegin(); it != m.rend(); it++) {
        cout << "key: " << it->first << ", value: " << it->second << endl;
    }

    // size()와 max_size() 메서드 사용 예시
    // size() : map 컨테이너에 저장된 key-value 쌍의 수를 반환
    // max_size() : map 컨테이너가 저장할 수 있는 최대 key-value 쌍의 개수를 반환
    cout << "size: " << m.size() << endl;
    cout << "max_size: " << m.max_size() << endl;

    return 0;
}

 

 


Struct를 이용한 map 구현

#include <iostream>
#include <unordered_map>
using namespace std;

struct Node {
    int y, x;
    // == : Node 탐색에 대한 연산자 Overriding
    
		// map은 항상 hash function을 통해 변환된 bucket 안에 
    // (1) 실제 값 (hash function을 통해 변환되기 전의 original 값) 
    // (2) value (key : value pair에서의 value)를 저장하고 있다. 

    // 즉, chaining이 된 상태에서, 지금 내 값과 동일한 Node가 존재하는지를 확인해야 
    // 할때 사용하는 == operator를 overriding 한다

    bool operator == (Node next) const {
        // 만약 나랑 값이 같다면
        if (y == next.y && x == next.x)
            return true; // bucket -> chaining 된 hash function으로 변환 된 값들 중 나를 return 해라
        return false; 
    }
};

struct HashFunc {
    // size_t : 운영 체제에 따라 만들 수 있는 가장 큰 "고정된" bit size
    // -> 32bit 운영체제에서는 32bit (unsigned int)
    // -> 64bit 운영체제에서는 64bit (long long)  

    // (x * y) % 10 = key
    size_t operator()(Node node) const {
        return node.y * node.x % 10;
    }
};

int main() {
    Node nodes[] = { {2, 2}, {2, 3},  {1, 1}, {2, 3}, {4, 5} };

    unordered_map<Node, int, HashFunc>um;

    // 5개의 노드에 대한 개수 저장
    // map에서, 당연히 key가 되는 Node는 고유(unique) 값이여야 한다. 
    for (int i = 0; i < 5; i++) {
        if (um.find(nodes[i]) == um.end())
            um[nodes[i]] = 1;
        else
            um[nodes[i]]++;
    }

    // um 내에 존재하는 iterator(it) 반환
    for (auto it = um.begin(); it != um.end(); it++) {
        cout << "(" << it->first.y << "," << it->first.x << ")" << " : " << it->second << '\\n';
    }
}