diff --git a/Algorithmic_Problem_Solving_Strategies/Chapter06.md b/Algorithmic_Problem_Solving_Strategies/Chapter06.md index 2d71db2..9eca0df 100644 --- a/Algorithmic_Problem_Solving_Strategies/Chapter06.md +++ b/Algorithmic_Problem_Solving_Strategies/Chapter06.md @@ -141,7 +141,7 @@ void pick(int n, vector& picked, int toPick) { 때문에 재귀 호출은 완전 탐색을 구현할 때 아주 유용한 도구다. -#### 예제: 보글 게임 +#### 예제: [보글 게임](https://www.algospot.com/judge/problem/read/BOGGLE) 보글 게임은 5x5 크기의 알파벳 격자를 가지고 하는 게임이다. @@ -192,4 +192,392 @@ void pick(int n, vector& picked, int toPick) { 오버로딩으로 두 가지 기능을 할 수 있도록 구현했다. -##### 시간 복잡도 분석 \ No newline at end of file +##### 시간 복잡도 분석 + +완전 탐색 알고리즘의 시간 복잡도를 계산하는 것은 비교적 단순하다. + +완전 탐색은 가능한 답 후보들을 모두 만들어 보기 때문에, 시간 복잡도를 계산하기 위해서는 가능한 후보의 수를 전부 세어 보기만 하면 된다. + +즉, 후보의 최대 수를 계산하면 된다. (단순) + +만약 단어가 없는 경우라면 모든 경우를 탐색해야 하기에 마지막 글자에 도달하기 전에는 주변의 모든 칸에 대해 재귀 호출을 하게 된다. + +각 칸에는 최대 여덟 개의 이웃이 있고, 탐색은 단어의 길이 N에 대해 N-1단계가 진행된다. + +$$ 8^{N-1} = O(8^N) $$ + +이 문제에서 후보의 수는 단어의 길이에 따라 지수적으로 증가하기에 단어의 길이가 짧은 경우에만 완전 탐색으로 해결할 수 있다. + +단어의 길이가 조금이라도 길어질 경우 3부의 다른 설계 패러다임을 사용해야 한다.(동적 계획법) + +##### 완전 탐색 레시피 + +*모든 문제에 항상 적용되는 것은 아니지만, 어떤 식으로 문제에 접근해야 하는지 대략적인 지침은 될 것이다.* + +- 완전 탐색은 존재하는 모든 답을 하나씩 검사하므로, 걸리는 시간은 가능한 답의 수에 정확히 비례한다. + - 최대 크기의 입력을 가정했을 때 갑의 개수를 계산하고 이들을 모두 제한 시간 안에 생성할 수 있는지 가늠한다. (적용할 수 없으면 동적 계획법을 사용한다.) +- 가능한 모든 답의 후보를 만드는 과정을 여러 개의 선택으로 나눈다. + - 각 선택은 답의 후보를 만드는 과정의 한 조각이 된다. +- 그중 하나의 조각을 선택해 답의 일부를 만들고, 나머지 답을 재귀 호출을 통해 완성한다. +- 조각이 하나밖에 남지 않은 경우, 혹은 하나도 남지 않은 경우에는 답을 생성했으므로, 이것을 기저 사례로 선택해 처리한다. + +##### 이론적 배경: 재귀 호출과 부분 문제 + +재귀 호출을 공부한다면 짚고 넘어가야 할 중요한 개념이 있다. + +**문제(problem)**와 **부분 문제(subproblem)**의 정의가 있다. + +이 정의는 3부에서 다룰 동적 계획법이나 분할 정복과 같은 디자인 패러다임을 설명하는 데 사용된다. + +- 문제: 주어진 자연수 수열을 정렬하라. +- 문제: {16, 7, 9, 1, 31}을 정렬하라. + +얼핏 보면 같은 문제라고 할 수 있지만 두 정의 사이에는 큰 차이가 있다. + +전자는 특정한 입력을 지정하지 않는 반면, 후자는 특정한 입력을 지정하기 때문이다. + +재귀 호출을 논의할 때 `문제`란 항상 수행해야 할 작업과 그 작업을 적용할 자료의 조합을 의미한다. + +예를 들어 {1, 2, 3}을 정렬하는 문제와 {3, 2, 1}을 정렬하는 문제는 서로 다른 문제다. + +따라서 앞의 두 정의 중 엄밀하게는 후자만을 문제의 정의라고 할 수 있다. + +보글 게임의 경우 '게임판에서의 현재 위치 (y, x) 그리고 단어 word가 주어질 때 해당 단어를 이 칸에서부터 시작해서 찾을 수 있는가?'로 정의된다. + +그러면 해당 단어를 이 위치에서 찾을 수 있는지 알기 위해 최대 아홉 가지 정보를 알아야 한다. + +- 현재 위치 (y, x)에 단어 첫 글자가 있는가? +- 윗 칸 (y - 1, x)에서 시작해서, 단어의 나머지 글자들을 찾을 수 있는가? +- 왼쪽 위 칸 (y - 1, x - 1)에서 시작해서 단어의 나머지 글자들을 찾을 수 있는가? +- ... (반복) + +이 중 2번 이후의 항목은 원래 문제에서 한 조각을 떼어냈을 뿐, 형식이 같은 또 다른 문제를 푼 결과다. + +문제를 구성하는 조각들 중 하나를 뺐기 때문에, 이 문제들은 원래 문제의 일부라고 할 수 있다. + +이런 문제들을 원래 문제의 **부분 문제**라고 한다. + +#### 예제: [소풍](https://www.algospot.com/judge/problem/read/PICNIC) + +안드로메다 유치원 익스프레스반에서는 다음 주에 율동공원으로 소풍을 간다. + +원석 선생님은 소풍 때 학생들을 두 명씩 짝을 지어 행동하게 하려고 한다. + +그런데 서로 친구가 아닌 학생들끼리 짝을 지어 주면 서로 싸우거나 돌아다니지 않기 때문에, 항상 서로 친구인 학생들끼리만 짝을 지어야 한다. + +각 학생들의 쌍에 대해 이들이 서로 친구인지 여부가 주어질 때, 학생들을 짝 지을 수 있는 방법의 수를 계산하는 프로그램을 작성하시오. + +짝이 되는 학생들이 일부만 다르더라도 다른 방법이라고 본다. + +- (태연, 제시카)(써니, 티파니)(효연, 유리) +- (태연, 제시카)(써니, 유리)(효연, 티파니) + +**시간 및 메모리 제한** + +프로그램은 1초 내에 실행되어야 하고, 64MB 이하의 메모리만을 사용해야 한다. + +**입력** + +입력의 첫 줄에는 테스트 케이스의 수 C가 주어진다. + +각 테스트 케이스의 첫 줄에는 학생의 수 n(2 <= n <= 10)과 친구 쌍의 수 m이 주어진다. + +$$ n : (2 \le n \le 10 ) $$ +$$ m : (0 \le m \le \frac{n(n-1)}{2}) $$ + +그 다음 줄에 m개의 정수 쌍으로 서로 친구인 두 학생의 번호가 주어진다. + +번호는 모두 0부터 n-1 사이의 정수이고, 같은 쌍은 입력에 두 번 주어지지 않는다. + +학생들의 수는 짝수다. + +**출력** + +각 테스트 케이스마다 한 출에 모든 학생을 친구끼리만 짝지어줄 수 있는 방법의 수를 출력한다. + +예제 입력 +``` +3 +2 1 +0 1 +4 6 +0 1 1 1 2 2 3 3 0 0 2 1 3 +6 10 +0 1 0 2 1 2 1 3 1 4 2 3 2 4 3 4 3 5 4 5 +``` + +예제 출력 +``` +1 +3 +4 +``` + +##### 입출력 설명 + +가장 먼저 테스트 케이스의 수가 주어진다. + +첫 번째 입력은 (2 1)로 학생의 수와 친구 쌍의 수로 n과 m에 해당된다. + +m이 1이기 때문에 다음 줄에 오는 쌍은 하나뿐이다. + +따라서 0번 학생과 1번 학생이 서로 친구인 상태이다. + +두 번째 입력은 (4 6)으로 그 아래줄에 6개의 쌍이 주어진다. + +이 경우 4명의 학생 모두 서로 친구인 상태이다. + +따라서 4명의 학생을 짝지어 주는 방법은 3가지가 된다. + +##### 완전 탐색 + +이렇게 가능한 조합의 수를 계산하는 문제를 푸는 가장 간단한 방법은 완전 탐색을 이용하여 조합을 모두 만들어보는 것이다. + +재귀 호출을 이용해 풀이하기 위해 각 답을 만드는 과정을 여러 조각으로 나눠보자. + +여기서는 전체 문제를 n/2개의 조각으로 나누어 한 조각마다 두 학생을 짝지어 주는 것으로 정의한다. + +이때 문제의 형태는 '아직 짝을 찾지 못한 학생들의 명단이 주어질 때 친구끼리 둘씩 짝짓는 경우의 수를 계산하라'가 된다. + +명단에서 서로 친구인 두 학생을 찾아 이들을 짝지어 주고 나면 남는 학생들을 짝지어 주는 문제도 원래 문제와 같은 형태가 된다. + +##### 중복으로 세는 문제 + +위 아이디어를 코드로 그대로 옮기면 전혀 다른 답이 나오는 것을 알 수 있다. (2, 24, 192) + +```c++ +int n; +bool areFriends[10][10]; +// taken[i] = i번째 학생이 짝을 이미 찾았으면 true, 아니면 false +int countPairings(bool taken[10]) { + // 기저 사례: 모든 학생이 짝을 찾았으면 한 가지 방법을 찾았으니 종료한다. + bool finished = true; + for (int i = 0; i < n; ++i) + if (!taken[i]) + finished = false; + if (finished) return 1; + int ret = 0; + // 서로 친구인 두 학생을 찾아 짝을 지어 준다. + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) + if (!taken[i] && !taken[j] && areFriends[i][j]) { + taken[i] = taken[j] = true; + ret += countPairings(taken); + taken[i] = taken[j] = false; + } + return ret; +} +``` + +이 코드에서 두 가지 문제점이 있다. + +- 같은 학생을 두 번 짝지어 준다. (0,1) 과 (1,0)은 같은 경우로 센다. +- 다른 순서로 학생들을 짝지어 주는 것과 (2,3) 후에 (0,1)을 짝지어주는 것은 완전히 같은 방법인데 다른 경우로 세고 있다. + +실질적으로 같은 답을 중복으로 세는 이런 상황은 경우의 수를 다룰 때 굉장히 흔하게 마주치게 된다. + +이 상황을 해결하기 위해 선택할 수 있는 좋은 방법은 항상 특정 형태를 갖는 답만을 세는 것이다. + +흔히 사용하는 방법으로는 같은 답 중에서 사전순으로 가장 먼저 오는 답 하나만을 세는 것이 있습니다. + +예를 들어 (2,3),(0,1)이나 (1,0)(2,3)은 세지 않지만 (0,1), (2,3)은 세는 것이다. + +이 속성을 강제하기 위해서는 각 단계에서 남아 있는 학생들 중 **가장 번호가 빠른 학생의 짝**을 찾아 주면 된다. + +이렇게 하면 앞의 두가지 문제를 해결할 수 있다. + +가장 번호가 빠른 학생의 짝은 그보다 번호가 뒤일 수밖에 없기 때문에 (1,0)은 나올 수 없다. + +또한 항상 번호가 빠른 학생부터 짝을 짓기 때문에 (2,3),(0,1)의 순서로 짝을 지어줄 일도 없다. + +```c++ +int n; +bool areFriends[10][10]; +// taken[i] = i번째 학생이 짝을 이미 찾았으면 true, 아니면 false +int countPairings(bool taken[10]) { + // 남은 학생들 중 가장 번호가 빠른 학생을 찾는다. + int firstFree = -1; + for (int i = 0; i < n; ++i) + if (!taken[i]) { + firstFree = i; + break; + } + // 기저 사례: 모든 학생이 짝을 찾았으면 한 가지 방법을 찾았으니 종료한다. + if (firstFree == -1) return 1; + int ret = 0; + // 이 학생과 짝지을 학생을 결정한다. + for (int pairWith = firstFree + 1; pairWith < n; ++pairWith) + if (!taken[pairWith] && areFriends[firstFree][pairWith]) { + taken[firstFree] = taken[pairWith] = true; + ret += countPairings(taken); + taken[firstFree] = taken[pairWith] = false; + } + return ret; +} +``` + +##### 답의 수의 상한 + +모든 답을 생성해 가며 답의 수를 세는 알고리즘은 답의 수에 정비례하는 시간이 걸린다. + +따라서 실제로 프로그램을 짜기 전에 답의 수가 얼마나 될지 예측해 보고 모든 답을 만드는 데 시간이 얼마나 걸리는지 확인해야 한다. + +이 문제에서 가장 많은 답을 가질 수 있는 입력은 열 명의 학생이 모두 서로 친구인 경우다. + +이때 가장 번호가 빠른 학생이 선택할 수 있는 짝은 아홉 명이고, 그 다음 학생이 선택할 수 있는 짝은 일곱 명이다. + +이런 식으로 계속 진행하면 답의 수는 9 x 7 x 5 x 3 x 1 = 945가 된다. + +##### 구현 + +- [Algorithm: 피크닉 문제](https://github.com/fkdl0048/Algorithm/pull/19) + +#### 예제: [게임판 덮기](https://algospot.com/judge/problem/read/BOARDCOVER) + +H x W 크기의 게임판이 있다. + +게임판은 검은 칸과 흰 칸으로 구성된 격자 모양을 하고 있는데 이 중 모든 흰 칸을 세 칸짜리 L자 모양의 블록으로 덮으려고 한다. + +이때 블록들은 자유롭게 회전해서 놓을 수 있지만, 서로 겹치거나, 검은 칸을 덮거나, 게임판 밖으로 나가서는 안된다. + +게임판이 주어질 때 이를 덮는 방법의 수를 계산하는 프로그램을 작성해라 + +- **시간 및 메모리 제한** + - 2초 안에 실행되어야 하며, 64MB 이하의 메모리를 사용해야만 한다. +- **입력** + - 첫 줄에는 테스트 케이스의 수가 주어진다. + - 각 테스트 케이스의 첫 줄에는 두 개의 정수 H, W가 주어진다. + - 다음 H 줄에 각 W 글자로 게임판의 모양이 주어진다. + - `#`은 검은 칸, `.`은 흰 칸을 나타낸다. + - 입력에 주어지는 게임판에 있는 흰 칸의 수는 50을 넘지 않는다. +- **출력** + - 한 줄에 하나씩 흰 칸을 모두 덮는 방법의 수를 출력한다. + +(자세한 내용은 문제 링크 참고) + +이 문제도 **경우의 수**를 세는 것이니만큼, 게임판을 덮을 수 있는 모든 경우를 생성하는 완전 탐색을 이용해 문제를 해결할 수 있다. + +우선 입력으로 주어진 판에서 흰 칸의 수가 3의 배수가 아닐 경우에는 무조건 답이 없다. + +이 외의 경우에는 흰 칸의 수를 3으로 나눠서 내려놓을 블록의 수를 N을 얻은 뒤, 문제의 답을 생성하는 과정을 N조각으로 나눠 한 조각에서 한 블록을 내려놓도록 한다. + +재귀 함수는 주어진 게임판에 블록을 한 개 내려놓고 남은 흰 칸들을 재귀 호출을 이용해 덮도록 하면 된다. + +##### 중복으로 세는 문제 + +그러나 이런 식으로 덮을 수 있는 방법의 수를 셀 수 없다는 것을 알 수 있다. + +블록을 놓는 순서는 이 문제에서 중요하지 않지만 같은 배치도 블록을 놓는 순서에 따라서 여러 번 세기 때문에 특정한 순서대로 답을 생성하도록 강제할 필요가 있다. + +가장 간편한 방법으론 재귀 호출의 각 단계마다 아직 빈 칸 중에서 가장 윗 줄, 그 중에서도 가장 왼쪽에 있는 칸을 덮도록 하는 것이다. + +*이렇게 하면 한 답을 한 가지 방법으로밖에 생성할 수 없으므로 중복으로 세는 문제를 해결할 수 있다.* + +항상 가장 위, 그 중에서도 가장 왼쪽에 있는 칸을 처음 채운다고 가정하기 때문에 그 왼쪽과 위에 있는 칸은 항상 채워져 있다고 가정할 수 있다. + +L블록이기 때문에 총 4가지의 경우로 정리된다. + +이 방법으로 달라질 때마다 서로 다른 배치가 되므로 남은 게임판에 대해서 재귀호출로 넘겨서 얻은 경우의 수를 구할 수 있다. + +##### 답의 수의 상한 + +이 문제의 답의 최대는 한 블록당 4가지의 선택지와 최대 블록의 개수가 50개이기 때문에 가능한 답의 상한은 다음과 같다. + +$$ \frac{50}{3} = 16 $$ + +$$ 4^{16} = 2^{32} $$ + +이것만 봐서는 도저히 시간 내에 모두 생성할 수 없을 것 같지만, 실제 입력을 손으로 풀어본다면 각 단계에서 선택할 수 있는 블록의 배치가 제한됨을 알 수 있다. + +*전 배치가 다음 배치의 영향을 주기 때문에* + +##### 구현 + +- [BOARDCOVER 구현](https://github.com/fkdl0048/Algorithm/pull/20/files) + +#### 최적화 문제 + +지금까지 다뤘던 문제와는 달리 문제의 답이 하나가 아닌 여러개이고, 그 중에서 어떤 기준에 따라 가장 좋은 답을 찾아 내는 문제들을 통칭 **최적화 문제**라고 한다. + +- n개의 원소 중에서 r개를 순서 없이 골라내는 방법의 수를 계산하는 것은 최적화 문제가 아니다. +- n개의 사과 중에서 r개를 골라서 무게의 합을 최대화하는 문제는 최적화 문제에 해당한다. + +*사과를 골라내는 방법은 중 특정 기준에 의해 가장 좋은 답을 고르는 문제이기 때문이다.* + +최적화 문제를 해결하는 방법을 여러 가지 다루고 있는데, 그 중 가장 기초적인 것이 **완전 탐색**이다. + +완전 탐색은 최적화 문제를 풀기 위한 가장 직관적인 방법으로 가능한 답을 모두 생성해 보고 그중 가장 좋은 것을 찾아내면 되기 때문이다. + +##### 예제: 여행하는 외판원 + +가장 유명한 최적화 문제로 여행하는 외판원 문제가 있다. + +어떤 나라에 n(2 <= n <= 10)개의 큰 도시가 있다. + +한 영업 사원이 한 도시에서 출발해 다른 도시들을 전부 한 번씩 방문한 뒤 시작 도시로 돌아오려고 한다. + +*문제를 간단하게 하기 위해 모든 도로는 직선으로 연결되어 있다고 가정한다.* + +이때 영업 사원이 여행해야 할 거리는 어느 순서로 각 도시들을 방문하느냐에 따라 달라진다. + +##### 무식하게 풀 수 있을까? + +**완전 탐색으로 문제를 풀기 위한 첫 번째 단계는 시간 안에 답을 구할 수 있을지 확인하는 것이다.** + +시작한 도시로 돌아오는 경로를 찾기 때문에, 경로의 시작점은 신경 쓰지 않고 무조건 0번 도시에서 출발한다고 가정해도 길이는 다르지 않다. + +n - 1개의 도시를 나열하는 방법은 모두 (n - 1)!가지가 있다. + +도시가 열개라면 총 경로의 수는 9! = 362,880개가 된다. + +사람이 보기엔 큰 수이지만, 여전히 컴퓨터는 1초 안에 가볍게 처리가 가능하다. + +#### 예제: [시계 맞추기](https://www.algospot.com/judge/problem/read/CLOCKSYNC) + +![image](https://github.com/fkdl0048/Algorithm/assets/84510455/7b5640a0-433c-4de7-9c99-f08700f0bb23) + +그림과 같이 4x4개의 격자 형태로 배치된 열여섯 개의 시계가 있다. + +이 시계는 모두 12시, 3시, 6시 혹은 9시를 가리키는데, 이 시계들을 모두 12시를 가리키도록 하고 싶다. + +시계의 시간을 조작하는 유일한 방법은 열개의 스위치를 조작하는 것으로, 각 스위치들을은 모두 적게는 세 개에서 많게는 다섯 개의 시계에 연결되어 있다. + +한 스위치를 누를 때마다, 해당 스위치와 연결된 시계들의 시간은 3시간씩 앞으로 움직인다. + +번호와 연결된 시계에 따라 최소 몇번의 스위치 조작을 통해 모든 시계를 12시로 돌릴 수 있는지 작성하라 + +- 시간 및 메모리 제한 + - 10초안에 실행되어야 하며 64MB 이하의 메모리를 사용해야 한다. +- 입력 + - 첫 줄에 테스트 케이스의 개수가 주어진다. 각 테스트 케이스는 한 줄에 16개의 정수로 주어지며, 각 정수는 0번부터 15번까지 각 시계가 가리키고 있는 시간을 12, 3, 6, 9중 하나로 표현한다. +- 출력 + - 각 테스트 케이스당 정수 하나를 한 줄에 출력한다. + - 이 정수는 시계들을 모두 12시로 돌려놓기 위해 스위치를 눌러야 할 최소 횟수이며, 만약 불가능하다면 -1을 출력해야 한다. + +##### 문제 변형하기 + +이 문제는 있는 그대로 풀면 꽤나 복잡하다. + +그러나 문제의 특성을 이용해 적절히 단순화하면 완전 탐색으로 해결할 수 있다. + +이 문제의 중요한 점은 예제 입출력 설명이 유도하는 방향과는 달리 스위치를 누르는 순서는 전혀 중요하지 않다는 것이다. + +두 스위치를 누르는 순서를 바꾼다고 해서 그 결과가 바뀌지 않기 때문이다. + +따라서 우리가 계산해야 할 것은 스위츠를 **몇 번이나 누를 것**이냐 뿐이다. + +문제를 바꾼다고 해서 완전 탐색을 바로 적용할 수는 없고 사용하려면 누르는 횟수의 조합을 하나하나 열거할 수 있어야 하는데, 그 조합의 수는 무한하다. + +하지만 시계는 12시간이 지나면 제자리로 돌아온다는 점을 이용하면 유한하게 바꿀 수 있다. + +어떤 시계든 4번 누른 경우에 누르지 않은 것과 같기 때문에 어떤 스위치던 3번 이상 누를 일이 없어진다. + +따라서 스위치를 누르는 경우의 수는 0~3으로 나오고 이를 계산하면 4^10 = 1,048,576개가 된다. + +##### 구현 + +- [CLOCKSYNC 구현](https://github.com/fkdl0048/Algorithm/pull/21/files) + +##### 많이 등장하는 완전 탐색 유형 + +주어진 원소로 만들 수 있는 모든 순열 만들기, 주어진 원소 중 R개를 골라낼 수 있는 방법 만들기 등은 다른 문제의 부분 문제로도 빈번히 출제된다.