Skip to content

Commit

Permalink
Docs: Add(Temp) Algorithmic_Problem_Solving_Strategies/Chapter06.md
Browse files Browse the repository at this point in the history
  • Loading branch information
fkdl0048 committed Dec 7, 2023
1 parent 8dc4f46 commit 8b344d2
Showing 1 changed file with 195 additions and 0 deletions.
195 changes: 195 additions & 0 deletions Algorithmic_Problem_Solving_Strategies/Chapter06.md
Original file line number Diff line number Diff line change
@@ -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<int>& 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참고
실제 알고스팟의 문제는 예제문제와 살짝 다르게 시작 위치가 아닌 단어로 주어져서 장 이름에 맞게 더 무식하게 풀어봤다.
오버로딩으로 두 가지 기능을 할 수 있도록 구현했다.
##### 시간 복잡도 분석

0 comments on commit 8b344d2

Please sign in to comment.