본문 바로가기
알고리즘/BAEKJOON

[백준] 15829번 Hashing - C++

by 거북이 코딩 2024. 2. 14.

백준

Hashing

문제


 APC에 온 것을 환영한다. 만약 여러분이 학교에서 자료구조를 수강했다면 해시 함수에 대해 배웠을 것이다. 해시 함수란 임의의 길이의 입력을 받아서 고정된 길이의 출력을 내보내는 함수로 정의한다. 해시 함수는 무궁무진한 응용 분야를 갖는데, 대표적으로 자료의 저장과 탐색에 쓰인다.


 이 문제에서는 여러분이 앞으로 유용하게 쓸 수 있는 해시 함수를 하나 가르쳐주고자 한다. 먼저, 편의상 입력으로 들어오는 문자열에는 영문 소문자(a, b,..., z)로만 구성되어 있다고 가정하자. 영어에는 총 26개의 알파벳이 존재하므로 a에는 1, b에는 2, c에는 3,..., z에는 26으로 고유한 번호를 부여할 수 있다. 결과적으로 우리는 하나의 문자열을 수열로 변환할 수 있다. 예를 들어서 문자열 "abba"은 수열 1, 2, 2, 1로 나타낼 수 있다.

 해시 값을 계산하기 위해서 우리는 문자열 혹은 수열을 하나의 정수로 치환하려고 한다. 간단하게는 수열의 값을 모두 더할 수도 있다. 해시 함수의 정의에서 유한한 범위의 출력을 가져야 한다고 했으니까 적당히 큰 수 M으로 나눠주자. 짜잔! 해시 함수가 완성되었다. 이를 수식으로 표현하면 아래와 같다.

 

 해시 함수의 입력으로 들어올 수 있는 문자열의 종류는 무한하지만 출력 범위는 정해져 있다. 다들 비둘기 집의 원리에 대해서는 한 번쯤 들어봤을 것이다. 그 원리에 의하면 서로 다른 문자열이더라도 동일한 해시 값을 가질 수 있다. 이를 해시 충돌이라고 하는데, 좋은 해시 함수는 최대한 충돌이 적게 일어나야 한다. 위에서 정의한 해시 함수는 알파벳의 순서만 바꿔도 충돌이 일어나기 때문에 나쁜 해시 함수이다. 그러니까 조금 더 개선해 보자.

 어떻게 하면 순서가 달라졌을 때 출력값도 달라지게 할 수 있을까? 머리를 굴리면 수열의 각 항마다 고유한 계수를 부여하면 된다는 아이디어를 생각해 볼 수 있다. 가장 대표적인 방법은 항의 번호에 해당하는 만큼 특정한 숫자를 거듭제곱해서 곱해준 다음 더하는 것이 있다. 이를 수식으로 표현하면 아래와 같다. 


 보통 r과 M은 서로소인 숫자로 정하는 것이 일반적이다. 우리가 직접 정하라고 하면 힘들 테니까 r의 값은 26보다 큰 소수인 31로 하고 M의 값은 1234567891(놀랍게도 소수이다!!)로 하자.

 이제 여러분이 할 일은 위 식을 통해 주어진 문자열의 해시 값을 계산하는 것이다. 그리고 이 함수는 간단해 보여도 자주 쓰이니까 기억해 뒀다가 잘 써먹도록 하자.


 

입력

 

첫 줄에는 문자열의 길이 L이 들어온다. 둘째 줄에는 영문 소문자로만 이루어진 문자열이 들어온다.

입력으로 주어지는 문자열은 모두 알파벳 소문자로만 구성되어 있다.

 

출력

 

문제에서 주어진 해시함수와 입력으로 주어진 문자열을 사용해 계산한 해시 값을 정수로 출력한다.


Small (50점)

1 ≤ L ≤ 5


Large (50점)

1 ≤ L ≤ 50

예제 입력 1 

5
abcde


예제 출력 1

4739715


예제 입력 2 

3
zzz


예제 출력 2 

25818


예제 입력 3 

1
i


예제 출력 3 

9

풀이 과정

 

 저처럼 해시함수를 기존에 알지 못하셨던 분들이나 문제이해를 잘못한 분들을 위해서 (저 포함..) 오답 풀이부터 하겠습니다. 혹시라도 문제를 잘못 읽으신 것 같은 분은 지금이라도 다시 한번 천천히 읽어보시고 풀기 바랍니다.

 

 우선 이 문제를 풀기 위해선 mod연산법칙을 알아야 합니다.

 쉽게 말하면 어떤 수를 임의로 쪼개서(더하기 혹은 곱하기) 각 각 mod연산을 한 후 다시 합쳐서 mod연산을 하면 어떤 수를 mod연산한 값과 같다는 뜻입니다. 그럼 위 1번, 2번 법칙을 활용하여 코드를 짜보겠습니다.

#include <iostream>
#include <cstring>

using namespace std;

int main(void) {
	int L;
	unsigned long long r = 1;    //가장 범위가 넓은 정수형으로 선언
	unsigned long long sum = 0;
	char arr[51];
	cin >> L;
	cin >> arr;
	for (int i = 0; i < L; i++) {
		sum = sum + (((arr[i] - 'a' + 1) * r)%1234567891);
		r = r * 31;
		r = r % 1234567891;    //r의 값이 초과되어 오버플로우 나는것을 방지
	}
	cout << sum;
	return 0;
}

 

 언뜻 보면 잘 작동될 것 같아 보입니다. 하지만 왜인지 Small(50점)은 달성되어도 Large(50점)은 받아지지가 않았습니다. 위 코드와 비슷한 코드로 계속 50점이 나와서 100점을 위해 열심히 도전했습니다. 오버플로우 날만한 곳에 mod연산처리를 하고, 문자열 저장방식을 바꿔보고, 자료형을 바꿔보고.. 하지만 원인은 다른 곳에 있었습니다.

 

 제가 해시함수를 잘못 이해하고 있었던 겁니다. 밑에 그림에서 저는 mod연산이 시그마 연산 안쪽에 들어가 있는 것으로 착각했습니다. 하지만 저와 달리 문제를 잘 읽으신 분들은, 해시함수는 일정한 출력을 낸다는 부분을 읽고 시그마 밖에 mod연산이 있다는 것을 알았을 거라고 생각합니다.

처음에 이해한 형태 나중에 바르게 이해한 상태

 

 해결법은 해시함수 전체를 M으로 mod연산해 주면 해결되는 것이었습니다.

#include <iostream>
#include <cstring>

using namespace std;

int main(void) {
	int L;
	unsigned long long r = 1;
	unsigned long long sum = 0;
	char arr[51];
	cin >> L;
	cin >> arr;
	for (int i = 0; i < L; i++) {
		sum = sum + (((arr[i] - 'a' + 1) * r)%1234567891);
		r = r * 31;
		r = r % 1234567891;
		sum = sum % 1234567891;//hash함수 mod연산
	}
	cout << sum;
	return 0;
}

 

마치며

 

 역시 가장 기본 중에 기본은 문제를 잘 읽고 잘 이해한 상태로 푸는 것이라 걸 다시 한번 느끼게 된 문제였습니다. mod연산법칙을 알고 있다면 문제이해와 오버플로우 문제만 해결하면 쉽게 풀 수 있는 문제였던 것 같습니다.