2023. 12. 20. 01:59ㆍAlgorithm & PS/Algorithm
제가 코드포스 블루를 달았을 때 사용했던 알고리즘이기도 하고, 이걸 변형해서 제가 문제도 냈었기 때문에 한 번 설명해보고자 합니다. (정확한 설명은 아닐 수 있지만, 최대한 직관적이게 적어볼려고 하니 컨셉만 잘 전달되었으면 합니다.)
Trie(트라이)는 문자열을 트리 형태로 저장하는 자료구조입니다. 아래 사진을 보면 이해하기 쉬우실 겁니다.
보면은 해당 트라이에 삽입된 단어들은 "A", "to", "tea", "ted", "ten", "i", "in", and "inn" 인데, prefix가 겹친다면 같은 노드를 타고 내려가도록 트리가 생성되어 있습니다. 트라이의 특징으로는 Insertion / Deletion / Search 하는데 전부 O(|S|) 만에 가능합니다.
그런데 이러한 자료구조의 특징을 통해서 우리는최대/최소 XOR 문제들도 풀 수 있습니다. 이런 XOR 문제를 풀 때 사용하는 Trie를 편의상 XOR Trie라고 부르겠습니다. 그러면 이제 문제를 예시로 하나 들어 이 문제를 해결해가면서 XOR Trie를 설명하도록 하겠습니다.
BOJ 13505 : 두 수 XOR - https://www.acmicpc.net/problem/13505
13505번: 두 수 XOR
N개의 수가 주어졌을 때, XOR한 값이 가장 큰 두 수를 찾는 프로그램을 작성하시오. 즉, A1, A2, ..., AN 중에서 i ≠ j이면서 Ai XOR Aj 가 가장 큰 것을 찾아야 한다.
www.acmicpc.net
문제에서 요구하는 바는 수열이 주어져 있을 때 두 수의 XOR이 제일 큰 결과값이 무엇인지를 물어보고 있습니다. N의 범위가 작다면 쉽게 O(N^2)으로 브루트포싱해서 풀 수는 있겠지만 N의 범위가 너무커서 시간내로 돌아가지 않습니다. 이 문제를 어떻게 Trie로 풀 수 있을까요?
먼저, XOR 연산의 특징을 생각해봅시다. XOR는 같은 자리의 비트끼리만 연산을 합니다. 자리가 다르다면 연산의 결과가 서로 독립적으로 계산이 되어 영향을 서로주지 않죠. 이러한 특징은 이 문제를 푸는데 키가 됩니다. 왜냐하면, 특정 수와의 XOR한 결과가 제일 커지는 값은 최상위 비트부터 1이 되는지를 판단해서 찾아볼 수 있기 때문이죠.
어느 수 X가 있다고 가정합시다. 그리고 두 수 Y, Z가 있다고 합시다. 그리고 XOR한 값의 결과가 아래와 같다고 합시다. (1번째 비트를 제일 큰 비트라고 표현합시다. 즉, k번째 비트가 k+1번째 비트보다 표현하는 값이 큰 걸로 해석해주세요.)
- X ^ Y = k 번째 비트만 1로 설정됨.
- X ^ Z = k+1번째 비트부터 전부 1로 설정됨.
그렇다면 어느 수가 X와 XOR 했을 때 더 값이 클까요? 당연히 Y입니다. 왜냐면 k번째 비트가 1로 켜진 결과가 k번째 비트 아래의 비트들이 전부 1로 켜진 결과보다 더 크기 때문입니다. 상위 비트가 1로 켜진다면 그 하위의 비트들이 아무리 1로 많이 켜진다 해도 해당 값을 넘을 수가 없습니다. 그래서 이 문제는 좀 더 그리디한 접근으로 찾을 수 있습니다. 특정 값과 XOR를 한 결과가 상위 비트에서 1이 되는 수들과 0으로 되는 수들이 있으면 1로 켜지는 값을 우선적으로 찾아보는 겁니다. 그리고 그 하위 비트에서도 1이 되는 수들을 최대한 찾아보고요.
위 과정을 Trie를 활용해서 한다면 검색이 O(logA_i)만에 가능해집니다. 왜냐면 우리는 이진법으로 각 숫자를 표현해서 상위 비트부터 순서대로 Trie에 저장할 것이기 때문입니다. 근데 주의해야할 점은 비트를 몇 개 볼지는 항상 통일해야합니다. 그래야 제대로 비교가 가능하니깐요. 나올 수 있는 수들중에 제일 큰 수가 10^9이므로 대강 깊이를 40이라고 생각해서 구해봅시다. (즉 40개의 비트들을 전부 저장할 겁니다.)
일단 Trie를 위한 구조체부터 먼저 선언하겠습니다. 그리고 각 수를 처리할 수 있는 전역변수들도 같이 선언해보죠.
deque<ll> bi;
ll val = 0;
// 전역 변수
// bi는 각 수를 이진법으로 표현한 값들을 저장하기 위한 배열
// val은 특정 값과의 XOR한 값 중 최댓값을 저장할 용도의 변수
struct node{
node *childs[2];
node(){
childs[0] = nullptr;
childs[1] = nullptr;
}
}; // Trie를 위한 node 구조체
그리고 Insertion도 같이 구현해봅시다. 그 전에 우리는 각 숫자들을 이진수로 만들어서 저장하는 함수도 만들겁니다.
void add_n(ll n){
bi.clear();
while(n > 0){
bi.push_front(n%2);
n/=2;
}
while(bi.size() < 40)
bi.push_front(0);
} // 숫자들을 이진법으로 표현해서 bi 배열에 넣어주는 함수, 2^39에 해당되는 비트까지 본다.
void add(node * tree, int idx){
if(idx == 40) return; // 최대 깊이에 들어가면 더 이상 X
ll x = bi[idx]; // 해당 비트의 값
if(!tree -> childs[x]) // 한 번도 해당 비트에서 x값이 나온 적이 없었으면 생성
tree->childs[x] = new node();
add(tree->childs[x], idx+1); // 해당 비트에 대한 노드에서 다음 비트값 탐색
} // Trie Insertion
그리고 이제 최댓값을 구하는 함수도 만들어봅시다. 만약에 특정 비트 위치에서 XOR 결과가 1이 될려면 두 비트의 값은 서로 달라야 합니다. 그래서 우리가 기준으로 잡은 값의 특정 비트에서 서로 다른 값인 수들이 존재한다면 해당 수들을 찾으면 되는 거고, 기준으로 잡은 값과 XOR한 결과들 중 최댓값은 해당 비트에 1이 들어오게 됩니다. 이 과정을 최상위 비트부터 찾아보면 최상위 비트들이 최대한 1이 되는 방향으로 찾게됩니다. (아마 코드로 이해하시는게 더 편할 것 같긴 합니다.)
void get(node* tree, int idx){
if(idx == 40) return; // 최대 깊이까지 왔으면 그만.
int x = bi[idx]; // 기준이 되는 값의 idx번째 비트의 값 (1 아니면 0)
if(!tree->childs[x]){ // 만약에 반대인 값이 존재한다면
val+=(1LL<<(long long)(39-idx)); // 최댓값에 해당 비트는 1이 들어오는 것을 명시
get(tree->childs[1-x], idx+1); // 그리고 비트가 반대인 값들 중에서 찾음.
}
else{
get(tree->childs[x], idx+1); // 아니면 최댓값에는 해당 비트가 0일꺼고, 그냥 같은 값들 중에서 찾음.
}
}
위 함수들을 전부 합쳐서 예제 문제를 풀어보면 아래와 같은 코드로 풀 수 있습니다.
#include<bits/stdc++.h>
#define ll long long
using namespace std;
// Default
//////////////////// XOR TRIE ////////////////////
deque<ll> bi;
ll val = 0;
struct node{
node *childs[2];
node(){
childs[0] = nullptr;
childs[1] = nullptr;
}
};
void add(node * tree, int idx){
if(idx == 40) return;
ll x = bi[idx];
if(!tree -> childs[x])
tree->childs[x] = new node();
add(tree->childs[x], idx+1);
}
void add_n(ll n){
bi.clear();
while(n > 0){
bi.push_front(n%2);
n/=2;
}
while(bi.size() < 40)
bi.push_front(0);
}
void get(node* tree, int idx){
if(idx == 40) return;
int x = bi[idx];
if(!tree->childs[1-x]){
get(tree->childs[x], idx+1);
}
else{
val+=(1LL<<(long long)(39-idx));
get(tree->childs[1-x], idx+1);
}
}
//////////////////// XOR TRIE ////////////////////
int main(){
ios_base::sync_with_stdio(0);
cin.tie(0);
ll N;
cin >> N;
vector<ll> v(N);
node * root = new node();
for(int i =0; i<N; i++){
cin >> v[i];
add_n(v[i]);
add(root, 0);
} // 각 값을 입력 받고 이를 이진수로 표현해서 Trie에 저장.
ll maxx = 0;
for(int i =0; i<N; i++){
val = 0; // 값 초기화
add_n(v[i]); // 각 값을 기준으로
get(root , 0); // XOR 최댓값을 찾음 (val이라는 변수에 저장됨.)
maxx = max(maxx , val); // 해당 결과를 다른 값들을 기준으로 삼을 때의 XOR 최댓값과 비교
}
cout << maxx;
return 0;
} // O(40N)
이렇게 문제를 풀게되면 O(40*N)만에 풀 수 있습니다. (별개로 이 문제의 다른 해로는 이분 탐색이 있긴 합니다. 그런데 사용되는 아이디어는 동일해요.)
이런식으로 Trie를 사용해서 XOR 문제도 풀 수 있습니다. 제 설명이 뭔가 정확한 증명을 토대로 하는게 아니라 직관적으로 이해가 되도록 최대한 설명을 했는데, 이게 제대로 전달이 되었을 지 모르겠습니다. (정확한 증명은 이 글에서 잘 설명이 되있는 것 같습니다.) 그래도 문제를 푸는 아이디어라도 어느정도 전달되었으면 좋겠습니다. 이제 아래에 추천 문제들을 남기면서 글을 끝내도록 하겠습니다. 다음에 다뤄볼 알고리즘은 뭐가 될지는 저도 궁금하네요.
추천문제
- BOJ 13505 : 두 수 XOR - 위에서 언급한 연습 문제입니다.
- BOJ 13504 : XOR 합 - BOJ 13505과 대표적인 XOR Trie 문제입니다. 구간 XOR를 활용해서 쉽게 풀 수가 있어요.
- BOJ 20919 : XOR 자료구조 - XOR Trie인데 삭제 등등도 구현해야합니다. 또한 XOR 결과가 최소가 되는 값도 찾아야 합니다.
- BOJ 30210 : BX 내기 - 제가 만들었던 문제인데, 위 아이디어를 십진수에 적용해서도 풀 수 있습니다. 한 번 풀어보세요 :)
- CP Round 882(Div.2) C번 : Vampiric Powers, anyone? - 제가 처음으로 XOR Trie로 풀었던 문제인데, 정해는 아닙니다. (대회 끝나고보니 좀 더 쉬운 정해가...)
- CP Round 888(Div.3) F번 : Lisa and the Martians - 좀 관찰이 필요한 XOR Trie 문제입니다.
'Algorithm & PS > Algorithm' 카테고리의 다른 글
Mo's 알고리즘을 내가 이해한 방식대로 설명해보기 (0) | 2024.02.26 |
---|---|
[C++] Dijkstra 역추적하는 방법 (0) | 2024.02.07 |
Dijkstra(다익스트라)를 내가 이해한 방식대로 설명해보기 (feat. BFS) (0) | 2024.02.07 |