From 8b344d24ee6034d44995c9d736c52905d0051c58 Mon Sep 17 00:00:00 2001 From: Jeongan Lee <84510455+fkdl0048@users.noreply.github.com> Date: Fri, 8 Dec 2023 02:41:10 +0900 Subject: [PATCH] Docs: Add(Temp) Algorithmic_Problem_Solving_Strategies/Chapter06.md --- .../Chapter06.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 Algorithmic_Problem_Solving_Strategies/Chapter06.md diff --git a/Algorithmic_Problem_Solving_Strategies/Chapter06.md b/Algorithmic_Problem_Solving_Strategies/Chapter06.md new file mode 100644 index 0000000..96d332c --- /dev/null +++ b/Algorithmic_Problem_Solving_Strategies/Chapter06.md @@ -0,0 +1,195 @@ +## 6장 무식하게 풀기 + +알고리즘을 고안하는 것은 까다로운 작업이다. + +복잡한 요구사항을 프로그램으로 작성해야 할 때 감도 오지 않은 채로 멍하니 모니터만 쳐다본 경험이 있거나 요구사항을 생각하지 않고 시작했다가 엉망으로 꼬인 코드를 만들어본 경험이 있을 것이다. + +흔히 상상하는 것과 달리 알고리즘을 설계하는 작업은 한순간의 영감보다는 여러 전력적인 선택에 따라 좌우된다. + +알고리즘을 고안하기 위해서는 해결할 문제의 특성을 이해하고, 동작 시간과 사용하는 공간 사이의 상충 관계를 이해해야 하며, 적절한 자료 구조를 선택할 줄 알아야 한다. + +알고리즘 설계 패러다임이란, 주어진 문제를 해결하기 위해 알고리즘이 채택한 전략이나 관점을 말한다. + +어떤 알고리즘들은 문제를 해결하기 위한 가장 중요한 깨달음을 공유하는데, 이와 같은 깨달음들을 모아 보면 일종의 패턴을 확인할 수 있다. + +이런 의미에서 이들은 같은 전략, 혹은 같은 설계 패러다임을 통해 문제를 해결했다고 말한다. + +알고리즘 설계 패러다임들은 알고리즘 설계를 위한 좋은 틀이 되기 때문에, 이들에 대해 공부하는 것은 알고리즘 설계 능력을 키우는 좋은 방법이다. + +### 도입 + +알고리즘을 푸는 사람들의 대부분의 실수가 쉬운 문제를 어렵게 푸는 것이다. + +공부를 열심히 할수록 복잡하지만 우아한 답안을 만들고 싶은 마음이 커지기 마련이고, 그래서 바로 앞에 보이는 쉽고 간단하며 틀릴 가능성이 낮은 답안을 간과한다. + +*꼭 알고리즘에만 해당하는 것은 아닌 것 같다.* + +이런 실수를 피하기 위해 문제를 마주하고 나면 가장 먼저 무식하게 풀 수 있는지 검증하는 것이 좋다. + +전산학에서 다루는 brute-force(무식하게 푼다)는 말은 컴퓨터의 빠른 계산능력을 이용해 가능한 경우의 수를 일일이 나열하면서 답을 찾는 방법을 의미한다. + +두 점 사이의 최단 경로를 찾는 문제라면 두 점 사이의 경로들을 하나 하나 전부 만들어서 그중 가장 짧은 것을 찾는 방법일 테고, 자원을 배분할 수 있는 경우의 수를 세는 문제라면 한 가지씩 분배 방법을 전부 만들어 보는 **무식한 알고리즘**이 좋은 예라고 할 수 있다. + +이렇게 가능한 방법을 전부 만들어 보는 알고리즘들을 가리켜 흔히 완전 탐색(exhaustive search)이라고 부른다. + +정말 간단한 방법이지만 사실 완전 탐색이야 말로 컴퓨터의 장점을 가장 잘 이용하는 방법이다. + +컴퓨터의 최대 장점은 단순하게 속도가 빠르다는 것이기 때문이다. + +### 재귀 호출과 완전 탐색 + +#### 재귀 호출 + +컴퓨터가 수행하는 많은 작업들은 대개 작은 조각들로 나누어 볼 수 있다. + +이런 작업들을 조각을 내면 낼수록 각 조각들의 형태가 유사해지는 작업들을 볼 수 있다. + +완전히 같은 코드를 반복해 실행하는 for 같은 반복문이 좋은 예로 이런 작업을 구현할 때 유용하게 사용되는 개념이 바로 재귀 호출(recursive call) 혹은 재귀 함수(recursive function)이다. + +재귀 함수란 자신이 수행할 작업을 유사한 형태의 조각으로 쪼갠 뒤 그 중 한 조각을 수행하고, 나머지를 자기 자신을 호출해 실행하는 함수를 가리킨다. + +*모든 반복문은 재귀 호출로 구현할 수 있고, 모든 재귀 호출은 반복문으로 구현할 수 있다.* + +```c++ +// 1부터 n까지의 합을 계산하는 반복 함수와 재귀 함수 +int sum(int n) { + int ret = 0; + for (int i = 1; i <= n; ++i) + ret += i; + return ret; +} + +int recursiveSum(int n) { + if (n == 1) return 1; + return n + recursiveSum(n - 1); +} +``` + +n개의 숫자의 합을 구하는 작업을 n개의 조각으로 쪼개, 더할 각 숫자가 하나의 조각이 되도록 한다. + +재귀 호출을 이용하기 위해선 조각 중 하나를 떼내어 자신이 해결하고, 나머지 조각들은 자기 자신을 호출해 해결해야 한다. + +이 조각 중 n만 따로 빼내기로 하고 1부터 n - 1까지의 조각들이 남는데, 이들을 모두 처리한 결과는 다름아닌 1부터 n - 1까지의 합이다. + +따라서 자기 자신을 호출해 n - 1까지의 합을 구한 뒤, 여기에 n을 더하면 우리가 원하는 답이 된다. + +재귀 호출의 `n == 1`인 **기저 사례(base case)**를 잘 처리해야 한다. + +n이 1이라면 조각이 하나뿐이기 때문에 '더이상 쪼개지지 않는' 최소한의 작업에 도달했을 때 답을 곧장 반환하는 조건문을 포함해야 한다. + +이때 쪼개지지 않는 가장 작은 작업을 가리켜 **기저 사례(base case)**라고 부른다. + +기저 사례를 선택할 때는 존재하는 모든 입력이 항상 기저 사례의 답을 이용해 계산될 수 있도록 신경써야 한다. + +만약 n이 2인 경우를 확인하는 경우라면 2이상이라면 문제가 없지만, 1이 입력으로 주어졌을 때는 기저 사례를 처리하지 않았기 때문에 재귀 호출이 무한히 반복될 것이다. + +재귀 호출은 반복문을 사용해 작성하던 코드를 다르게 짤 수 있는 방법을 제공해준다. + +이 함수에서는 기존 코드에 비해 재귀 호출을 통해 얻을 수 있는 별다른 이득이 없다. + +문제의 특성에 따라 재귀 호출은 코딩을 훨씬 간편하게 해 줄 수 있는 강력한 무기가 된다. + +#### 예제: 중첩 반목문 대체하기 + +0번부터 차례대로 번호 매겨진 n개의 원소 중 네 개를 고르는 모든 경우를 출력하는 코드를 작성해보자. + +예를 들어 n = 7인 경우 (0, 1, 2, 3), (0, 1, 2, 4), (0, 1, 2, 5), ..., (3, 4, 5, 6)을 모두 출력해야 한다. + +```c++ +// 중첩 반복문으로 구현한 코드 +for (int i1 = 0; i1 < n; ++i1) + for (int i2 = i1 + 1; i2 < n; ++i2) + for (int i3 = i2 + 1; i3 < n; ++i3) + for (int i4 = i3 + 1; i4 < n; ++i4) + printf("%d %d %d %d\n", i1, i2, i3, i4); +``` + +만약 다섯 개를 골라야 하는 경우라면 다섯 개의 반복문을 중첩해야 하고, 여섯 개를 골라야 하는 경우라면 여섯 개의 반복문을 중첩해야 한다. + +재귀는 이런 작업을 더 간결하고 유연한 코드를 작성할 수 있게 해준다. + +위 코드 조각이 하는 작업은 네 개의 조각으로 나눌 수 있다. + +각 조각에서 하나의 원소를 고르고, 남은 원소들을 고르는 작업을 자기 자신을 호출하여 떠넘기는 재귀 호출을 수행한다. + +이 때 남은 원소들을 고르는 작업을 다음과 같은 입력들로 정의할 수 있다. + +- 남은 원소들의 총 개수 +- 더 골라야 할 원소들의 개수 +- 지금까지 고른 원소들의 번호 + +```c++ +// 재귀 호출로 구현한 코드 +void pick(int n, vector& picked, int toPick) { + // 기저 사례: 더 고를 원소가 없을 때 고른 원소들을 출력한다. + if (toPick == 0) { + printPicked(picked); + return; + } + // 고를 수 있는 가장 작은 번호를 계산한다. + int smallest = picked.empty() ? 0 : picked.back() + 1; + // 이 단계에서 원소 하나를 고른다. + for (int next = smallest; next < n; ++next) { + picked.push_back(next); + pick(n, picked, toPick - 1); + picked.pop_back(); + } +} +``` + +이와 같이 재귀 호출을 이용하면 특정 조건을 만족하는 조합을 모두 생성하는 코드를 쉽게 작성할 수 있다. + +때문에 재귀 호출은 완전 탐색을 구현할 때 아주 유용한 도구다. + +#### 예제: 보글 게임 + +보글 게임은 5x5 크기의 알파벳 격자를 가지고 하는 게임이다. + +이 게임의 목적은 상하좌우/대각선으로 인접한 칸들의 글자들을 이어서 단어를 찾아내는 것이다. + +> hasWord(y, x, word) = 보글 게임판의 (y, x)에서 시작하는 단어 word의 존재 여부를 반환한다. + +이 문제를 풀 때 까다로운 점은 다음 글자가 될 수 있는 칸이 여러 개 있을 때 이 중 어느 글자를 선택해야 할지 미리 알 수 없다는 것이다. + +가장 간단한 방법은 완전 탐색을 이용해 단어를 찾아낼 때까지 모든 인접한 칸을 하나씩 시도해 보는 것이다. + +그중 한 칸에서라도 단어를 찾을 수 있다면 성공이고, 어느 칸을 선택하더라도 답이 없다면 실패가 된다. + +##### 문제의 분할 + +`hasWord()`가 하는 일을 가장 자연스럽게 조각내는 방법은 각 글자를 하나의 조각으로 만드는 것이다. + +함수 호출시에 단어의 시작 위치를 정해 주기 때문에, 문제의 조각들 중 첫 번째 글자에 해당하는 조각을 간단하게 해결할 수 있다. + +시작 위치에 쓰여 있는 글자가 단어의 첫 글자와 다르다면 곧장 false를 반환하고 종료하면 된다. + +아니라면 원래 단어에서 첫 글자를 땐 단어 word[1..]에 대해 `hasWord()`를 재귀 호출한다. (8방향을 다 시도) + +##### 기저 사례의 선택 + +더 이상의 탐색 없이 간단히 답을 낼 수 있는 다음 경우들을 기저 사례로 선택한다. + +- 위치(y, x)에 있는 글자가 원하는 단어의 첫 글자가 아닌 경우 항상 실패 +- 위 사례에 해당하지 않을 경우 원하는 단어가 한 글자인 경우 항상 성공 + +*두 조건간의 순서가 바뀌면 안 된다.* + +간결한 코드를 작성하는 유용한 팁은 입력이 잘못되거나 범위에서 벗어난 경우도 기저 사례로 택해서 맨 처음에 처리하는 것이다. + +그러면 함수를 호출하는 시점에서 이런 오류를 검사할 필요가 없다. + +재귀 함수는 항상 한군데 이상에서 호출되기 때문에 이 습관은 반복적인 코드를 제거하는 데 큰 도움이 된다. + +따라서 위 두 가지 기저 사례외에도 현재 위치가 보글 게임 판을 벗어난 경우, 첫 글자가 일치하지 않는 경우도 기저 사례로 선택한다. + +##### 구현 + +- [Algorithm](https://github.com/fkdl0048/Algorithm/issues/14) + +해당 이슈에 바인딩된 PR참고 + +실제 알고스팟의 문제는 예제문제와 살짝 다르게 시작 위치가 아닌 단어로 주어져서 장 이름에 맞게 더 무식하게 풀어봤다. + +오버로딩으로 두 가지 기능을 할 수 있도록 구현했다. + +##### 시간 복잡도 분석 \ No newline at end of file