이번 포스트에서는 듣기에도 생소한 Parametric Search를 배워볼 것이다.
이게 뭔가 싶어서,, 일단 ChatGPT에 던져봤다.
그랬더니 아예 개념과 예시 문제까지(!) 다 줘버려서 이걸 토대로 큰 그림부터 그려보려고 한다.
Parametric Search란 최적화 문제를 해결하는 데 사용되는 알고리즘 기법이다. 주로 함수의 최적값(최대/최소)를 도출하는 특정 값을 탐색하는데 사용된다.
말이 좀 어려운데 그냥 주어진 조건을 만족하면서 결과가 최적인 매개 변수를 찾아 나가는 알고리즘을 설명하는 것 같다. 근데 이건 대부분의 알고리즘 문제가 그렇지 않나,,?
Parametric search에 대해 이것저것 찾다보니 '최적화 문제를 결정 문제로 바꿔 푸는 알고리즘'이라는 식의 설명을 자주 보게 되는 것 같다. 최적화 문제와 결정 문제 각각의 정의를 좀 확실히 알고 넘어가자.
최적화 문제 (Wikipedia)
수학, 컴퓨터 과학 및 경제학에서 최적화 문제는 가능한 모든 솔루션 중에서 최상의 솔루션을 찾는 문제이다. 최적화 문제는 변수가 연속적인지 불연속적인지에 따라 두 가지 범주로 나눌 수 있다.
- 불연속 변수를 사용하는 최적화 문제를 이산 최적화라고 하며 정수, 순열 또는 그래프와 같은 개체를 셀 수 있는 집합에서 찾아야 한다.
- 연속 변수가 있는 문제를 연속 최적화 문제라 하며, 연속 함수에서 최적 값을 찾아야 한다.
아무래도 자료 구조를 활용한 알고리즘 문제 해결을 주로 하다보니 이산 최적화에 초점을 맞추면 될 것 같다. 셀 수 있는, 이산적인 어떤 입력이 주어질 때 주어진 조건을 만족하는 최상의 솔루션을 찾는 문제를 최적화 문제로 정의할 수 있을 것 같다.
결정 문제 (Wikipedia)
계산 이론에서 결정 문제란 어떤 형식 체계에서 예-아니오 답이 있는 질문을 말한다. 예를 들어 "두 숫자 x와 y가 있을 때, y는 x로 나누어떨어지는가?" 하는 질문이 있다. 답은 x와 y 값에 따라 '예' 또는 '아니오' 중 하나가 될 수 있다. 일반적으로 계산 가능한 문제의 해집합은 열거 가능한 해집합의 유한한 부분 집합이기 때문에, 모든 문제는 결정 문제로 환원될 수 있다.
딱 두 부분이 눈에 들어오는데 이를 바탕으로 오늘 해결할 Parametric Search, '최적화 문제를 결정 문제로 바꿔 푸는 알고리즘'을 풀어 설명하면 '이산적인 입력이 들어왔을 때 최적값을 구하는 문제를 예-아니오 답이 있는 질문으로 바꿔 푸는 알고리즘' 정도인 것 같다.
풀어 쓰긴 했지만 좀 막연하다. Python for CodingTest (feat.ChatGPT)에 의하면, Parametric Search는 아래 두 단계로 진행된다고 한다.
- 결정 문제로 변환 : 원래의 문제를 결정 문제로 바꿉니다. 예를 들어, "어떤 값 x 이하의 최대 값을 찾는 문제"를 "특정 값 x를 만족하는가" 라는 결정 문제로 바꿀 수 있습니다.
- 이분 탐색 적용 : 변환된 결정 문제에 이분 탐색을 적용합니다. 예를 들어, 가능한 모든 값 x에 대해 이분 탐색을 수행하며, 각 단계에서 해당 값 x가 문제의 조건을 만족하는지 확인합니다. 이분 탐색을 통해 최적의 답을 찾아내는 것이 가능해집니다.
음 뭐랄까, 최적해를 구해야 하는 문제에서 '최적해에 도달했는가?'라는 질문을 이분 탐색해가며 최적해를 만드는 입력 값의 범위를 좁혀 나가도록 하는 방법론 정도 될 것 같다.
ChatGPT-Driven-Development. 과연 챗선생께서 주신 문제는 Parametric Search 문제인지, 해결 방법은 옳게 되었는지 고민해보자.
이거 백준에 있는 문제였다! 디게 유명한 문제인가보다 어쩐지 낯이 익더라.
문제는 백준 2805번 S2 난이도 '나무 자르기'이다.
딱 보면 알겠지만 나무의 수가 무지막지하게 많기 때문에 무식하게 가장 큰 나무의 높이부터 하나씩 내려가면서 찾으면 시간 초과가 날 것이다. 그럼 뭐다? 톱의 높이를 이분 탐색해서 찾는다!
톱의 높이를 이분 탐색해서 찾기로 했으니 상한(high
)과 하한(low
)을 정해야 한다.
하한은 뭐, 톱이 땅으로 꺼질 수는 없으니 0으로 하고(높이가 0인 이상한 나무 빼고는 다 벨 수 있다),
상한은,, 굳이 가장 높은 나무보다 높은 위치까지 고려해줄 필요가 없으므로 가장 높은 나무의 높이로 할 수 있겠다(모든 나무를 통째로 벨 수 있다).
문제에서 주어진 예제, 총 7 이상의 목재가 필요할 때 나무의 높이가 각각 {20, 15, 10, 17}
인 상황을 생각해보자.
{10, 15, 17, 20}
[low, high] = [0, max(heightsOf)] = [0, 20]
으로 한다.[low, high] = [0, 20]
, mid = (0+20) / 2 = 10
{5, 7, 10}
, 총 22만큼의 목재를 얻을 수 있다. (최고 높이 갱신)[0, 20] => [10, 20]
)[low, high] = [10, 20]
, mid = (10+20) / 2 = 15
{2, 5}
, 총 7만큼의 목재를 얻을 수 있다. (최고 높이 갱신)[10, 20] => [15, 20]
)[low, high] = [15, 20]
, mid = (15+20) / 2 = 17
[15, 20] => [15, 17]
)[low, high] = [15, 17]
, mid = (15+17) / 2 = 16
{1, 4}
, 총 5만큼의 목재를 얻을 수 있다.[15, 17] => [15, 16]
)[low, high] = [15, 16]
, mid = (15+16) / 2 = 15
{5, 7, 10}
, 총 22만큼의 목재를 얻을 수 있다.[15, 16] => [15, 15]
)15 == 15
) 이제 갱신된 톱의 최고 높이인 15를 반환한다.위 과정을 코드로 표현하면 아래와 같다.
public static void main(String[] args) throws Exception {
st = new StringTokenizer(in.readLine());
// 나무의 개수
final int NUM_OF_TREES = Integer.parseInt(st.nextToken());
// 필요한 최소 목재 길이
final int REQUIRED_WOODS = Integer.parseInt(st.nextToken());
// 나무의 높이
int[] heightOf = new int[NUM_OF_TREES];
st = new StringTokenizer(in.readLine());
for (int tree = 0; tree < NUM_OF_TREES; tree++) {
heightOf[tree] = Integer.parseInt(st.nextToken());
}
// 이분 탐색을 위한 정렬
Arrays.sort(heightOf);
// 최적해 = 가능한 가장 높은 톱의 높이
long resultSawHeight = Long.MIN_VALUE;
// 이분 탐색을 위한 톱 높이의 가능한 구간
int minSawHeight = 0;
int maxSawHeight = heightOf[NUM_OF_TREES - 1];
// 이분 탐색이 가능할 때까지 갱신한다.
while (minSawHeight < maxSawHeight) {
// 톱의 높이를 선택해서
int candidateSawHeight = (minSawHeight + maxSawHeight) >> 1;
// 나무를 자를 수 있다면 목재 양을 누적한다.
long woods = 0;
for (int tree = 0; tree < NUM_OF_TREES; tree++) {
woods += Math.max(heightOf[tree] - candidateSawHeight, 0);
}
// 필요한 목재 이상 얻었다면
if (woods >= REQUIRED_WOODS) {
// 톱의 높이를 갱신해주고
resultSawHeight = candidateSawHeight;
// 최적해를 얻을 수도 있으므로 더 높은 톱 높이를 탐색
minSawHeight = candidateSawHeight + 1;
}
// 그렇지 못했다면
else {
// 더 잘라야 하므로 낮은 톱 높이를 탐색
maxSawHeight = candidateSawHeight;
}
}
System.out.println(resultSawHeight);
}
뭔가 장황한데 그냥 적당한 톱의 높이를 선형 탐색이 아닌 이분 탐색으로 찾는 것뿐이다.
이렇게 풀어도 답은 나온다! 하지만 가장 작은 나무부터 순서대로가 좀 걸리적거리지 않는가?!
여담으로 문제 다 풀어놓고 얻을 수 있는 목재의 총 길이를 누적하는데 int를 써서 오류가 났었다. 항상 입력값의 범위를 잘 보자 잘,,,,,,
자를 수 있는 가장 작은 나무를 구할 때도 이분 탐색을 쓸 수 있지 않을까?
정확히는 저번 포스트에서 배운 lower bound를 써주면 된다.
lower bound는 일정 구간 안에서 찾으려는 값 이상인 가장 첫 위치
이니까, 정렬된 나무 높이 배열에 대해서 톱의 높이(찾으려는 값)로 lower bound를 구한다는 것은 곧 자를 수 있는 나무들 중 가장 낮은 나무의 위치를 찾는 것과 같다고 할 수 있겠다.
Lower bound를 써서 성능을 개선해보자.
public static void main(String[] args) throws Exception {
st = new StringTokenizer(in.readLine());
// 나무의 개수
final int NUM_OF_TREES = Integer.parseInt(st.nextToken());
// 필요한 최소 목재 길이
final int REQUIRED_WOODS = Integer.parseInt(st.nextToken());
// 나무의 높이
int[] heightOf = new int[NUM_OF_TREES];
st = new StringTokenizer(in.readLine());
for (int tree = 0; tree < NUM_OF_TREES; tree++) {
heightOf[tree] = Integer.parseInt(st.nextToken());
}
// 이분 탐색을 위한 정렬
Arrays.sort(heightOf);
// 최적해 = 가능한 가장 높은 톱의 높이
long resultSawHeight = Long.MIN_VALUE;
// 이분 탐색을 위한 톱 높이의 가능한 구간
int minSawHeight = 0;
int maxSawHeight = heightOf[NUM_OF_TREES - 1];
// 이분 탐색이 가능할 때까지 갱신한다.
while (minSawHeight < maxSawHeight) {
// 톱의 높이를 선택해서
int candidateSawHeight = (minSawHeight + maxSawHeight) >> 1;
// 나무를 자를 수 있다면 목재 양을 누적한다.
long woods = 0;
// 주목!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
for (int tree = getLowerBound(heightOf, candidateSawHeight + 1); tree < NUM_OF_TREES; tree++) {
woods += heightOf[tree] - candidateSawHeight;
}
// 필요한 목재 이상 얻었다면
if (woods >= REQUIRED_WOODS) {
// 톱의 높이를 갱신해주고
resultSawHeight = candidateSawHeight;
// 최적해를 얻을 수도 있으므로 더 높은 톱 높이를 탐색
minSawHeight = candidateSawHeight + 1;
}
// 그렇지 못했다면
else {
// 더 잘라야 하므로 낮은 톱 높이를 탐색
maxSawHeight = candidateSawHeight;
}
}
System.out.println(resultSawHeight);
}
public static int getLowerBound(int[] numbers, int targetNum) {
int low = 0;
int high = numbers.length;
while (low != high) {
int mid = (low + high) >> 1;
if (numbers[mid] >= targetNum) {
high = mid;
} else {
low = mid + 1;
}
}
return low;
}
사실 다른건 다 같은데, 잘라서 목재를 얻을 수 있는 첫 나무를 얻는데 lower bound를 활용하는 부분만 다르다. 또 이전까지 Math.max
함수를 사용해서 얻을 수 있는 목재의 양이 음수가 되지 않도록(heightOf[tree] - candidateSawHeight
) 했던 코드를 뺐다. 어차피 톱의 높이와 같았거나(candidateSawHeight
가 배열에 포함되어 있는 경우) 모든 나무의 높이보다 낮았거나, 높았을 것이기 때문이다(lower bound가 0, 또는 heightOf.length
).
그리고 원래 getLowerBound(heightOf, candidateSawHeight)
로 썼다가 두 번째 인자에 1을 더해서 getLowerBound(heightOf, candidateSawHeight+1)
로 써줬는데, lower bound 특성상 candidateSawHeight
와 동일한 높이의 나무가 여러 개 있으면 불필요하게 그 높이의 나무들만큼 0을 더해준다고 생각해 고쳐 썼는데 실행 시간이 유의미하게 차이 나는 것 같진 않다.
그럼 오늘의 주제, Parametric Search에 대한 정의와 엮어서 마무리 지어보자.
이산적인 입력이 들어왔을 때 최적값을 구하는 문제를 예-아니오 답이 있는 질문으로 바꿔 푸는 알고리즘
이분 탐색으로 매개 변수가 있을 수 있는 범위를 좁혀가며 최적해를 구한다, 단 주어진 명제에 대해 항상 참이 되는 매개 변수만 선택한다, 정도로 이해했는데, 다음 문제 풀어보면서 또 어떤 부분이 이렇게 구조화되는지 생각해보면 좋겠다. 빠세! 아휴 힘들다 밥 먹고 해야지 저녁은 육회비빔밥 🤪