문제

로또 백준 6603

"맞았습니다" 코드

#include <bits/stdc++.h>
using namespace std;

int k, n;
vector<int> v;

void permu(){

  vector<int> tempv(k, 1); // k만큼 1 으로 초기화.
  for(int i = 0; i < 6; i++){ // 선택할 6개만 0으로 초기화
    tempv[i] = 0;
  } 
  do{
    for(int i = 0; i < tempv.size(); i++){
      if(tempv[i]==0){ // 선택한 6개만 출력 
        cout << v[i] << ' ';
      }
    }
    cout << '\n';
  }while(next_permutation(tempv.begin(), tempv.end()));
}

int main(void) {
  ios::sync_with_stdio(0);
  cin.tie(0);

  while(1){
    cin >> k;
    if(k == 0) break;
    v.clear();
    for(int i = 0; i < k; i++){
      cin >> n;
      v.push_back(n);
    }
    permu();
    cout << '\n';
  }
  return 0;
}

리뷰

STL next_permutation() 으로 풀었다.
k개 중에서 6개만 선택한 조합을 출력하는 문제다.

 

next_permutation()은 사전순으로 마지막인 숫자면 false를 반환해서 멈춘다. 

 

전체 길이 k가 8이면 반드시 6개만 선택해야 하니깐 0은 6개인 배열을 만든다.
그래서 벡터 tempv를 { 0,0,0,0,0,0,1,1 } 로 시작해야 한다.


0은 선택이고, 1은 선택안하는 것이다.

 

6개만 0으로 놓고 하면 되겠지 하고 짰는데
{1,1,0,0,0,0,0,0} 이렇게 시작해서 next_permutation()이 아예 동작을 안했었다.

마지막 조합 결과로 시작했기 때문이다.
이거 조심하기!!

728x90

'알고리즘 > 백준' 카테고리의 다른 글

계란으로 계란치기 백준 16987번 c++  (0) 2021.12.11
암호 만들기 백준 1759 c++  (0) 2021.12.11
N과M(9) 백준 15663번 c++  (0) 2021.12.10
하노이탑 이동 순서 백준 11729 c++  (0) 2021.12.09
곱셈 백준 1629번 c++  (0) 2021.12.08

문제

N과M(9) 백준 15663

"맞았습니다" 코드

#include <bits/stdc++.h>
using namespace std;

int n, m;
int num[10];
int arr[10];
bool check[10];

void func(int cnt){ // 이전 수열의 마지막항과 새로 추가할 값이 같으면 중복된 수열이다.
  if(cnt == m){
    for(int i = 0; i < m; i++){
      cout << num[i]<< ' ';
    }
    cout << '\n';
    return;
  }

  int prev_num = -1;

  for(int i = 0; i < n; i++){ // 선택하여 출력할 수를 num에 저장
    if(!check[i] && prev_num != arr[i]){
      check[i] = true;
      num[cnt] = arr[i]; // cnt 번째 숫자를 i로 정한다.
      prev_num = arr[i]; // 방금 선택한 수를 기억해둔다.
      func(cnt+1); // 다음 숫자 탐색 시작
      check[i] = false;
    }
  }
}

int main(void) {
  ios::sync_with_stdio(0);
  cin.tie(0);

  cin >> n >> m;
  for(int i = 0; i <n; i++){
    cin >> arr[i]; // 주어진 수를 arr에 저장
  }

  sort(arr, arr+n); // 정렬. 사전순으로 출력해야하기 때문.
  func(0); // 0번째 수를 선택하는 것으로 시작한다
  return 0;
}

리뷰

백트래킹에 익숙해지기 위한 문제 중 하나다.
수열이 [ 9, 7, 9, 1] 이 주어져도 (1, 9)를 두 번 출력하면 안된다. 하지만 (9, 9)는 출력해야 한다.

일단 수열을 입력받자마자 정렬한다.
이전 선택 값이 현재 선택 값과는 달라야 한다.
그래서 prev_num 변수를 쓴다.

처음에 prev_num 변수를 전역변수로 사용해서 틀렸다.
for문 내부의 함수의 재귀호출이 끝나면 check배열을 false로 돌려놓고, 다음 i번째 숫자를 검사하게 된다.
따라서 방금 종료된 재귀 호출에서 변경된 prev_num이 그대로 적용된다는 문제가 있다.
그래서 동일한 순서(cnt)의 숫자를 선택할 때는 prev_num을 똑같이 바라봐야 한다.
따라서 지역변수로 prev_num을 써야 한다.

728x90

'알고리즘 > 백준' 카테고리의 다른 글

암호 만들기 백준 1759 c++  (0) 2021.12.11
로또 백준 6603번 c++  (0) 2021.12.11
하노이탑 이동 순서 백준 11729 c++  (0) 2021.12.09
곱셈 백준 1629번 c++  (0) 2021.12.08
수 찾기 백준 1920번 c++  (0) 2021.12.08

목차 

0x00 백트래킹 알고리즘 설명 

0x01 연습문제1 - N과 M

0x02 연습문제2 - N-Queen

0x03 연습문제3 - 부분수열의 합 

0x04 STL next_permutation


0x00 백트래킹 알고리즘 설명

현재 상태에서 가능한 모든 선택지를 "따라 들어가며" 탐색하는 알고리즘 

 

탐색하다가 막히면, 왔던 길을 되돌아가 다른 길을 찾는다고 해서 이런 이름이 붙었다고 한다. 

상태공간트리(state-space tree)에서 깊이우선탐색(DFS)을 하다가 막히면 자신의 부모노드로 되돌아간다. 참고

오목을 예로 들어 백트래킹의 쓰임새를 알아보자. 

나는 파랑돌. 상대는 빨강돌이다. 상대는 직전에 D5를 뒀다. 이번에 나는 어디에 둬야할까?

1 ) B5에 둔다.

그러면 상대는 F5에 둬서 4,3 형태를 갖출 것이다. 상대가 4,3이 되면, 내가 G5 또는 F6에 둬도 진다. 

따라서 선택은 틀렸다. 이제 직전상태로 되돌아가서 다른 선택을 해보자. 

2) F5에 둔다.  

이러면 상대는 B5 or E6 or C4 or G8에 두면서 공격할 것 같다. 

 

지금 이 방식은 일종의 백트래킹이다. 우리는 게임을 할 때 이미 백트래킹 개념을 사용하고 있었다.

참고로 이렇게 생긴 트리를 상태공간트리(state-space tree) 라고 한다. 

 

백트래킹은 상당한 구현력을 필요로하고 실수하기도 쉽다.

재귀의 특성상 틀리더라도 실수한 부분을 찾기 힘들기 때문이다. 연습을 많이 하면 된다.


0x01 연습문제1 - N과 M

백준 15649번 N과 M (1)

문제를 읽으면 '8중 for문으로 구현하면 되겠다' 라는 생각이 날 것이다. 

하지만 조금 더 고민해서 백트래킹으로 푸는 방법을 배우자. 

아래는 N=4 이고 (1,2,3,4 숫자 사용가능) M=3 (길이가 3인 수열) 인 경우의 백트래킹 과정이다. 

1) 처음에는 빈 리스트로 시작한다.

2) 첫 칸에 1을 선택했다. 다음 칸에는 (2,3,4)중에 2를 선택할 수 있다. 

이 때 별은 현재 어떤 단계를 탐색하는 중인지 표시한다. 백트래킹은 탐색이 막히면 이전 단계로 되돌아오기 때문에 별 표시가 필요했다. 

3) 세 번째 칸에 3을 선택한다. 

이제 리스트가 꽉 찼으니 이전 단계로 돌아간다. 

4) 세 번째 칸에 4를 선택한다. 

이제 리스트가 꽉  찼으니 이전 단계로 돌아간다.

세 번째 칸에 채울 수 있는 숫자 3,4는 둘다 채워봤다. 여기서 할 수 있는걸 다 했으니 또 이전단계로 돌아간다. 

5) 두 번째 칸에 새롭게 3을 선택해본다. 

 

이러한 흐름으로 백트래킹을 이해할 수 있을 것이다. 당장 혼자 구현하기는 힘드니 코드를 보며 구현방법을 익히자. 

 

구현 코드

코드를 읽어 보면 재귀적으로 구현됬다. 이 모양은 백트래킹의 전형적인 구조니까 주석을 읽고 잘 익혀두자. 

선택한 숫자를 저장하는 int arr 배열
1부터 n까지의 숫자 중에서 '선택 여부'를 저장하는 bool isused 배열
현재 몇 개의 숫자를 선택했는지 인자를 넘기는 재귀 함수 void func(int k )
재귀 함수의 종료 조건(base condition)은 k가 m개를 모두 선택했을 때 이다. 
// http://boj.kr/f36567ec0c9f44b4b460b5b29683c27b
#include <bits/stdc++.h>
using namespace std;

int n,m;
int arr[10];
bool isused[10];

void func(int k){ // 현재 k개까지 수를 택했음.
  if(k == m){ // m개를 모두 택했으면
    for(int i = 0; i < m; i++)
      cout << arr[i] << ' '; // arr에 기록해둔 수를 출력
    cout << '\n';
    return;
  }

  for(int i = 1; i <= n; i++){ // 1부터 n까지의 수에 대해
    if(!isused[i]){ // 아직 i가 사용되지 않았으면
      arr[k] = i; // k번째 수를 i로 정함
      isused[i] = 1; // i를 사용되었다고 표시
      func(k+1); // 다음 수를 정하러 한 단계 더 들어감. 재귀 호출이므로 새로운 스택프레임 생성됨.
      isused[i] = 0; // k번째 수를 i로 정한 모든 경우에 대해 다 확인했으니 i를 이제 사용되지않았다고 명시함.
    }
  }
}

int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);
  cin >> n >> m;
  func(0);
}

func함수의 for문이 핵심이다. 

1부터 n까지의 수에 대해 아직 i가 사용되지 않았으면 다음 수를 정하러 한 단계 더 들어간다. 

  for(int i = 1; i <= n; i++){ // 1부터 n까지의 수에 대해
    if(!isused[i]){ // 아직 i가 사용되지 않았으면
      arr[k] = i; // k번째 수를 i로 정함
      isused[i] = 1; // i를 사용되었다고 표시
      func(k+1); // 다음 수를 정하러 한 단계 더 들어감
      isused[i] = 0; // k번째 수를 i로 정한 모든 경우에 대해 다 확인했으니 i를 이제 사용되지않았다고 명시함.
    }
  }

재귀로 다음 수를 정하러 함수를 호출하고 나서 종료되면, 현재 선택했던 수를 사용하지 않았다고 표시한다. 

즉, 이전 단계로 돌아오는 것이다. 

단 현재 값이 i인 arr[k]는 굳이 0과 같은 값으로 변경할 필요가 없다. 어차피 자연스럽게 다른 값으로 덮힐 예정이기 때문이다.

 

잘 모르겠다면 함수의 중간 중간 arr, isused, k를 출력한다던가 하는 방식으로 확인하고, (영상으로도 확인)

n과 m시리즈 문제집을 풀어보며 백트래킹 구조에 익숙해지도록 시간을 투자하면 된다. 


0x02 연습문제2 - N-Queen

백준 9663번 N-Queen

퀸은 상하좌우 그리고 대각선에 위치한 말을 공격할 수 있다. 

퀸을 서로 공격할 수 없게 n개 놓을 수 있는 방법 개수를 출력하는 문제다. 

즉 퀸이 서로 (1,2) 칸 차이가 있어야 대각선과 열에 걸리지 않는다. 

행 마다 퀸이 딱 1개씩 있어야 한다. 

4X4 배열에서 상태공간트리를 그려보자 

1. A1 칸에 첫 번째 퀸을 하나 놓자.

2. B행에서 가능한 선택지는 2가지다. B3과 B4칸. B3두 번째 퀸을 놓자. 

C행을 보니 모든 열에서 두 개의 퀸한테 공격당하게 된다.  

3. B3은 무르고 A1만 놨던 단계로 되돌리자

4. B행에서 가능한 B4칸에 두 번째 퀸을 놓자. 

5. C행을 보니 가능한 선택지가 C2 뿐이다. C2세 번째 퀸을 놓자. 

6. D행에서는 네 번째 퀸을 놓을 자리가 없다.

A1에 퀸을 뒀을때의 선택지는 모두 시도한 셈이니 아무것도 안놨을 때로 되돌아가자. 

 

이제 A2에 퀸을 놓아볼 차례다. 

이 방식으로 상태공간트리를 채우면 아래의 결과가 나온다. 

D행에 퀸을 놓았다면 4번째 퀸인 셈이니까. N=4일 때 답은 2가지라고 할 수 있다. 

코드로 구현해보자

cur 번째 행에 퀸을 배치한다는 함수를 구현하자.

int cnt = 0; 
//cur 번째 행에 퀸을 배치할 예정이다. 
void func(int cur){
	if(cur == n){ // 마지막행 
    	cnt++; return;
    }
}

퀸 2개가 (0,0)과 (1,3)에 있다고 가정하자. 

다음 행에는 어느 열에 둬야 하는지 구현하려면 대각선에 대해 생각해야 한다. 

1. 먼저 (0,1)과 (2,1)이 같은 열에 있다는걸 어떻게 확인할 수 있을까?

간단히 두 좌표의 y좌표가 일치하는지 확인하면 된다. 

2. (3,0)과 (1,2)가 같은 대각선에 있음을 어떻게 확인할까? 

왼쪽 하단에서 오른쪽 상단을 잇는 좌표가 대각선을 이루는지 판단한다. 

살짝 수학적 관찰이 필요하긴한데, 각 좌표의 x+y가 같으면 대각선에 위치한다. 

3. 좌측 상단과 우측 하단을 잇는 대각선에 대해서는 x-y가 같은지 확인하면 된다. 

(1,1) 과 (3,3) 은 각각 x-y가 0으로 동일하다. 

이렇게 열과 대각선을 일일히 확인하면 O(N)이 추가로 필요하게 된다. 더 나은 방법을 알아보자. 

행과 열을 사용됬는지 여부를 저장하는 배열

구현 할 때 어려운 점은 '이 좌표에 퀸을 둘 수 있는지 여부를 판단'하는 부분이다. 

이미 놓여진 모든 퀸에 대해 대각선 혹은 열이 겹치는게 있나 확인해야 새 퀸을 놓을 수 있다.

이를 위해 행과 열이 사용됬는지 여부를 저장하는 배열을 두면 편하다.

isused1  : 열에 대응되는 값으로 (x,y)에 퀸이 있으면 isused1[y]값을 true로 만든다. 

isused2  : 왼쪽 하단에서 오른쪽 상단을 연결하는 대각선을 검사한다. (x,y)에 퀸이 있으면 isused2[x+y]값을 true로 만든다. 

isused3 : 왼쪽 상단에서 오른쪽 하단을 연결하는 대각선을 검사한다. (x,y)에 퀸이 있으면 isused3[x-y+n-1]값을 true로 만든다. 

아까 예시로 (1,1) 과 (3,3) 은 각각 x-y가 0으로 동일하다. 고 했는데. 

인덱스를 음수로 만들지 않기 위해 n-1이 필요하다. 

 

구현코드

// http://boj.kr/4e7fe4509a3842598592fe4ed79900c0
#include <bits/stdc++.h>
using namespace std;
bool isused1[40]; // column을 차지하고 있는지
bool isused2[40]; // / 방향 대각선을 차지하고 있는지
bool isused3[40]; // \ 방향 대각선을 차지하고 있는지

int cnt = 0;
int n;
void func(int cur) { // cur번째 row에 말을 배치할 예정임
  if (cur == n) { // N개를 놓는데 성공했다면
    cnt++;
    return;
  }
  for (int i = 0; i < n; i++) { // (cur, i)에 퀸을 놓을 예정
    if (isused1[i] || isused2[i+cur] || isused3[cur-i+n-1]) // column이나 대각선 중에 문제가 있다면
      continue;
    isused1[i] = 1;
    isused2[i+cur] = 1;
    isused3[cur-i+n-1] = 1;
    func(cur+1);
    isused1[i] = 0;
    isused2[i+cur] = 0;
    isused3[cur-i+n-1] = 0;
  }
}
int main(void) {
  ios::sync_with_stdio(0);
  cin.tie(0);
  cin >> n;
  func(0);
  cout << cnt;
}

백트래킹에서 조건에 안맞는 것은 걸러내는 과정을 '가지치기'라고 한다. 

가지치기가 여러개면 시간복잡도 계산이 어렵다.

문제를 보고 시간복잡도가 가늠이 안가는데 N이 작아서 백트래킹으로 푸는 문제 같아 보이면,

구현 한 뒤 시간이 가장 오래걸릴 만한 케이스를 돌려봐서 시간 초과가 나는지 확인하자. 이 문제라면 N=14를 넣어보는 것이다.

시간을 로컬에서 측정할 때에는 반드시 Release 모드로 실행을 해야 하고 보통은 서버가 로컬보다 빠르다는 점도 기억하자.

Release 모드 : 프로그램 배포를 위해 디버깅 정보를 실행코드를 넣지 않는다. 
속도나 크기 면에서 월등히 유리하다.
디버깅 모드 크기가 릴리즈 모드 크기의 3~4배 정도 파일 크기 차이가 난다. 

0x03 연습문제3 - 부분수열의 합 

백준 1182번 부분수열의 합

 

미리 알아둘 점 

원소가 n개인 부분 집합의 개수는 2의 n승 개다. 

이 원소를 집합에 포함하냐/안하냐 의 선택 횟수를 곱하기 때문이다. 

 

목표

문제의 표현은 부분수열이지만 부분집합을 구하는 것과 동일한 상황이다.

따라서 공집합은 빼고 2의 n승 -1 개의 모든 부분수열의 합이 S와 일치하는지 확인하자. 

 

수열이 [-2, 5, 3] 인 상태공간트리를 그려보자 

원 안의 값은 현재 내가 택한 수열의 전체 합이다. 

각 상태는 두 갈래로 분기하는데, 왼쪽은 현재 보는 수를 수열에 추가하지 않은 경우이고, 오른쪽은 수열에 추가한 경우다. 

1) 먼저 -2를 수열에 추가하지 않았다. 

2) 5를 수열에 추가하지 않았다.

3) 3을 수열에 추가하지 않았다. 완성된 수열은 공집합과 같은 [ ] 이고 -2, 5, 3 셋 다 추가하지 않았다. 

4) 거슬러 올라가서, 3을 수열에 추가한다. 완성된 수열은 [3] 이고 총합은 3이다.  

5) 거슬러 올라가서, 5를 수열에 추가한다. 

이 상황에서 먼저 3을 추가하지 않고 완성한 수열 [5]의 합을 확인한다. 총합은 5다. 

6) 다음으로 3을 추가해 완성된 수열 [3, 5]의 합을 확인한다. 총합은 8이다. 

 

구현코드

실제 구현을 할 때는 함수의 인자로 현재까지의 합을 넘기도록 했다. 

그리고 문제에서 크기가 양수인 부분수열만 센다고 했으니 공집합은 제외해줘야 한다. 

따라서 목표합s가 0일때 cnt에서 1을 빼줘야 한다. 

// http://boj.kr/792d9af025724ce2912c77e8d56793d1
#include <bits/stdc++.h>
using namespace std;

int n, s;
int arr[30];
int cnt = 0;
void func(int cur, int tot){
  if(cur == n){
    if(tot == s) cnt++;
    return;
  }
  func(cur+1, tot);
  func(cur+1, tot+arr[cur]);
}
int main(void) {
  ios::sync_with_stdio(0);
  cin.tie(0);
  cin >> n >> s;
  for(int i = 0; i < n; i++)
    cin >> arr[i];
  func(0, 0);
  if(s == 0) cnt--;
  cout << cnt;
}

 

0x04 STL next_permutation

next_permutation 함수로 순열과 조합을 깔끔하게 해결할 수 있다. 레퍼런스

 

예시를 보면, [ 1, 2, 3] 으로 만들 수 있는 모든 순열을 쉽게 구할 수 있다. 

순열 

int a[3] = {1, 2, 3};

int main(void) {
  do{
    for(int i = 0; i < 3; i++){
      cout << a[i] << ' ';
    }
    cout << '\n';
  }while(next_permutation(a, a+3));

  return 0;
}

출력 결과 

1 2 3 
1 3 2 
2 1 3 
2 3 1 
3 1 2 
3 2 1

 

유의점

배열 a가 정렬된 상태로 next_permutation()을 돌려야 모든 경우의 수가 출력된다. 

사전 순으로 따졌을 때 제일 마지막일 때 next_permutation()은 false를 반환해서 멈추기 때문이다. 

만약 중복된 수가 있따고 해도 사전 순의 결과를 잘 돌려준다. 

조합

예를 들어 [1 2 3 4]에서 숫자 2개를 순서 없이 뽑는 모든 경우를 출력하려면 어떻게 짜야할까?

선택 or 선택 안함의 모든 경우의 수를 next_permutation()으로 만들 수 있다. 

#include <bits/stdc++.h>
using namespace std;

int ch[4] = {0, 0, 1, 1}; // 4개중에 2개 선택(1)

int main(void) {
  do{
    for(int i = 0; i < 4; i++){
      if(ch[i] == 0){
        cout << i+1 << ' ';
      }
    }
    cout << '\n';
  }while(next_permutation(ch, ch+4));

  return 0;
}

출력 결과

1 2 
1 3 
1 4 
2 3 
2 4 
3 4

N과 M시리즈 문제 중에 1,2,5,6 번은 next_permutation() 으로 다시 풀어보자. 

N과 M 문제 3, 4, 7, 8은 같은 수를 여러번 써도 되니까 next_permutation()을 쓸 순 없다. 


백트래킹 문제집

백준 문제 내 코드 
N과 M (1) N과M(1) 내코드
N-Queen N-Queen내코드
부분수열의 합 부분수열의 합 내코드
N과 M (2) N과M(2) 내코드
N과 M (3) N과M(3) 내코드
N과 M (4) N과M(4) 내코드 
N과 M (5) N과M(5) 내코드
N과 M (6) N과M(6) 내코드
N과 M (7) N과M(7) 내코드
N과 M (8) N과M(8) 내코드
N과 M (9) N과M(9) 내코드   리뷰
N과 M (10) N과M(10) 내코드
N과 M (11) N과M(11) 내코드
N과 M (12) N과M(12) 내코드
로또 로또 내코드
암호 만들기 암호 만들기 내코드
소문난 칠공주 소문난 칠공주 내코드
계란으로 계란치기 계란으로 계란치기 내코드
Gaaaaaaaaaarden  
비숍  

 


다음 시간에는 시뮬레이션을 공부한다.

공부 자료 출처 : 바킹독 알고리즘 백트래킹 

728x90

'알고리즘 > 실전 알고리즘' 카테고리의 다른 글

0x0F강 - 정렬2  (0) 2021.12.18
0x0E강 - 정렬1  (0) 2021.12.11
0x0B강 - 재귀  (0) 2021.12.08
0x0A강 - DFS  (0) 2021.12.07
0x09강 - BFS  (0) 2021.12.07

문제

하노이 탑 이동 순서 백준 11729

"맞았습니다" 코드

#include <bits/stdc++.h>
using namespace std;

int N; // 원반의 개수

void func(int a, int b, int n){
  if(n == 1) {
    cout << a << ' '<< b << '\n'; // a에서 b로 옮긴다.
    return;
  }
  func(a, 6-a-b, n-1);
  cout << a << ' '<< b << '\n'; // a에서 b로 옮긴다.
  func(6-a-b, b, n-1);
}

int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);

  cin >> N;
  cout << (1<<N)-1 << '\n'; // 이동 횟수
  func(1, 3, N);
  return 0;
}

리뷰

재귀를 이용한 하노이탑 이동순서 문제다.
혼자 식 세우기 어려웠다.

기둥 번호가 1,2,3번이 있는데. 번호의 합이 6이다.
그래서 n-1개의 원판을 1번에서 2번으로 옮기고.
n번째 원반을 1번에서 3번으로 옮기는데.
이 때 기둥의 번호를 6에서의 뺄셈을 이용해 표현하는 것이 신기했다.

재귀 공부를 하면서 다시 풀어봐야 할 문제다.

728x90

'알고리즘 > 백준' 카테고리의 다른 글

로또 백준 6603번 c++  (0) 2021.12.11
N과M(9) 백준 15663번 c++  (0) 2021.12.10
곱셈 백준 1629번 c++  (0) 2021.12.08
수 찾기 백준 1920번 c++  (0) 2021.12.08
상범빌딩 백준 6593번 c++  (0) 2021.12.08

문제

곱셈 백준 1629

"맞았습니다" 코드

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll A, B, C;

ll rec(ll a, ll b, ll c){
  if(b == 1) return a % c; // a가 c보다 클 수 있기 때문에 a % c를 반환.
  ll val = rec(a, b/2, c);
  val = val * val % c;
  if(b%2 == 0) return val; 
  return val * a % c; // b가 홀수라면 *a %c 를 한 번 더 수행. 
}

int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);

  cin >> A >> B >> C;
  cout << rec(A, B, C);
  return 0;
}

리뷰

재귀를 공부하면서 푼 문제다.
곱셈을 하면 int의 범위를 넘어서기 때문에 long long 을 써야 한다.
단, b승을 알려면 b/2승을 제곱하면 된다는 것을 기반으로 코드를 짜야 한다.

728x90

'알고리즘 > 백준' 카테고리의 다른 글

N과M(9) 백준 15663번 c++  (0) 2021.12.10
하노이탑 이동 순서 백준 11729 c++  (0) 2021.12.09
수 찾기 백준 1920번 c++  (0) 2021.12.08
상범빌딩 백준 6593번 c++  (0) 2021.12.08
나무 자르기 백준 2805 c++  (0) 2021.12.06

목차 

0x00 재귀 알고리즘 설명 

0x01 연습문제1 - 거듭제곱

0x02 연습문제2 - 하노이 탑 

0x03 연습문제3 - Z


0x00 재귀 알고리즘 설명 

 

재귀를 이해하고 문제를 푸는 것은 익숙해지기에 시간이 좀 걸린다.

순서대로 생각하는 절차지향적 사고와는 차이가 있기 때문이다. 마음 단단히 먹고 시작하기. 

재귀란? 

하나의 함수에서 자기 자신을 다시 호출해 작업하는 알고리즘이다. 

 

재귀로 N부터 1까지 출력하는 함수와, 1부터 N까지 합을 구하는 함수를 혼자 구현해보자. 

[ N부터 1까지 출력하는 함수 ]

void printrec(int num){
  if(num == 0) return;
  cout << num << ' ';
  return printrec(num-1);
}
// 출력: 10 9 8 7 6 5 4 3 2 1

[ 1부터 N까지 합을 구하는 함수 ]

int rec(int num){
  if(num == 1) return num;
  return rec(num-1) + num;
}
// rec(n); 
// 출력: 55

(바킹독님 코드와 똑같아서 내코드 그대로 적어놓음)

 

재귀로 푼다는 것의 의미가 뭘까? 

방금 짠 재귀 코드가 왜 올바른 결과를 주는지 제대로 이해해보자. 

우리가 어떤 문제를 재귀로 푼다는 것은 곧 귀납적인 방식으로 문제를 해결하겠다는 것이다. 

 

아래 사진의 도미노 사진을 보자. 맨 앞의 1번 도미노를 쓰러뜨리면 모든 도미노가 쓰러진다. 

왜 그렇게 되는지 설명하는 방법에는 두 가지 방법이 있다. 

첫 번째 방법
1번 도미노가 쓰러지면 2번 도미노가 쓰러진다. 2번 도미노가 쓰러지면 3번 도미노가 쓰러진다. 3번 도미노가 쓰러지면 ... 

두 번째 방법 (수학적 귀납법)
'1번 도미노가 쓰러진다. '
'k번 도미노가 쓰러지면 k+1번 도미노가 쓰러진다' 
이 두 문장은 참이다. 그러므로 1번 도미노가 쓰러지면 모든 도미노가 쓰러진다. 

즉, 우리에게 익숙한 절차지향적인 사고를 탈피해야 귀납법에 익숙해질 수 있다. 

 

N부터 1까지 출력하는 함수를 귀납법으로 생각해보자

void func1(int n){
  if(n == 0) return;
  cout << n << ' ';
  func1(n-1);
}
// func1(3)  출력: 3 2 1

우선 절차지향적으로 func1(3)의 출력 결과가 왜 3 2 1 인지 생각해보자. 

코드의 흐름을 그대로 따라가면 된다. 

 

func1(3) 이 호출되면 3을 출력한다. 그리고 func1(2) 를 호출한다. 

-> func1(2)이 2를 출력한 후, func1(1) 호출한다. 

-> func1(1)이 1을 출력한 후, func1(0)을 호출한다. 

->  func1(0)이 호출되어 return 함수가 종료된다. 

귀납적 사고로 이해해보자.

첫 째로, func1(1)이 1을 출력한다. 이건 굉장히 자명하다.

그 다음이 관건인데,

func1(k)가 k, k-1, k-2 ... 1을 출력하면, 즉 k부터 1까지 차례로 출력하면 

func1(k+1)은  k+1부터 k, k-1, k-2 ... 1 까지 차례로 출력한다는 것을 보여야한다. 

 

이를 위해 func1(k+1)이 호출될 때 어떻게 되는지 확인하면 된다. 

func1(k+1)을 호출하면, k+1을 출력하고 func1(k)를 호출한다. 

그리고 func1(k)는 k부터 1까지 차례로 출력하는 가정을 했으니  func1(k+1)은 k+1부터 1까지 차례로 출력함을 알 수 있다. 

 

이 두 문장이 참이므로 귀납적으로 func1함수가 n부터 1까지 차례로 출력하는 함수임을 알 수 있다. 

 

재귀 함수의 조건

앞의 예시를 통해 귀납법에 익숙해지길 바라며 재귀 함수의 조건을 알아보자. 

올바른 재귀 함수는 특정 입력에 대해서는 자기 자신을 호출하지 않고 종료해야 한다. 

이러한 입력을 base condition (==base case)라고 한다. 

그리고 모든 입력은 base condition으로 수렴해야 한다. 

int func2(int n){
  if(n == 0) return 0;
  return n+func2(n-1);
}

위 코드는 n==0 일 때, 자기자신을 호출하지 않고 종료된다. 이것이 base condition이다. 

그리고 이 함수에 자연수만 넣을 테니 모든 입력은 결국엔 n==0으로 수렴하게 된다. 

이 두 조건을 지키지 않으면 무한루프에 빠져서 런타임 에러가 발생한다.

재귀 함수의 조건과 정보

1. 재귀 함수의 인자, 로직, 종료 조건을 명확하게 정의해야 한다. 

2. 재귀는 코드를 간결하게 만들어줄 수 있지만, 메모리/시간에서는 손해를 본다는 것을 인지하자. 

굳이 재귀를 쓰지 않아도 구현이 된다면 반복문으로 코드를 짜는 것이 좋다. 

3. 한 함수가 자기 자신을 여러번 호출하게 되면 비효율적일 수 있다. 

아래 코드는 피보나치 함수다.

int fibo(int n){
  if(n <= 1) return 1;
  return fibo(n-1) + fibo(n-2);
}

fibo(5)는 fibo(4)와 fibo(3)을 호출한다. 

fibo(4)는 fibo(3)과 fibo(2)를, fibo(3)은 fibo(2)와 fibo(1)을 호출한다. 

-> 이미 계산한 값을 또 계산하게 된다!

-> 시간 복잡도가 말도안되게 커져버린다. 

-> 나중에 다이나믹 프로그래밍으로 O(n)에 해결하는 방법을 배운다. 

 

4. 재귀 함수가 자기 자신을 부를 때 스택 영역에 계속 누적된다. 

운영 체제의 메모리 공간을 보자. 제일 높은 주소 쪽에 컴파일 타임에 크기가 결정되는 stack 영역이 있다. 

stack영역은 지역변수와 함수의 호출정보(stack frame)가 저장되는데, 함수가 종료 되야 소멸한다. 

 

문제를 풀 때 메모리 제한이라는 것이 있다. 

일부 컴파일 환경에는 stack영역 메모리 제한이 별도로 1MB로 제한되어 있기도 한다. 

BOJ는 스택 메모리의 제한이 없지만 swexpertacademy.com에는 제한이 걸려 있다. 

그래서 이 코드 처럼 재귀를 10만번 정도만 돌아도 스택 1MB를 초과하여 런타임 에러가 발생한다. 

 

BOJ에 제출하면 "맞았습니다."가 뜨는 남의 코드를 로컬에서 돌렸을 때 계속 런타임 에러가 뜬다면, 

가장 의심해 볼만한건 재귀가 너무 깊거나 지역변수로 지나치게 큰 크기의 배열을 잡았을 경우다. 


0x01 연습문제1 - 거듭제곱

a의 b제곱을 m으로 나눈 나머지를 구하자. 

int func1(int a, int b, int m){
  int val = 1;
  while(b--) val *= a;
  return val % m;
}

방법1

a를 b번 곱한다. 그 결과를 m으로 나눈 나머지를 출력한다. 

 

하지만 '방법1'은 제대로 동작하지 않는다. 만약 func1(6, 100, 5)를 넣으면 0이 나온다. 

이유를 고민해보자.

.........

using ll = long long;
ll func1(ll a, ll b, ll m){
  ll val = 1;
  while(b--) val = val * a % m;
  return val;
}

그 이유는 바로 int overflow가 발생하기 때문이다.

6의 100제곱은 int의 범위를 까마득하게 벗어난다. 이것을 해결해주려면 중간 중간 계속 m으로 나눠서 나머지만 챙겨가면 된다. 

 

방법2

a를 1번째 곱한다. m으로 나눈 나머지를 a에 대입한다. 

a를 2번째로 곱한다. m으로 나눈 나머지를을 a에 대입한다. 

.... a를 b번째 곱한다. m으로 나눈 나머지를 출력한다.  -> 이렇게 중간 계속 m으로 나눠서 나머지만 챙기기.

 

a를 b번 곱하는 방식으로는 계산할 수 없다면 어떻게 할까? 

백준 1629번 곱셈 문제를 풀어보자. 

자연수 A, B, C가 주어진다. A를 B번 곱한 수를 C로 나눈 나머지를 출력하라. 

(A, B, C는 모두 2,147,483,647 이하의 자연수이다. 시간제한 0.5초)

 

힌트를 읽어보자. 

a의 n승을 제곱하면 a의 2n승이 된다. 

12의 58승과 12의 116승의 관계를 보자. 

12의 58승을 67로 나눈 나머지가 4라면, 12의 116승을 67로 나눈 나머지는 (4의 제곱) 즉, 16이 된다. 

 

-> 58승을 계산할 수 있으면 116승을 계산할 수 있다. 

-> k승을 계산할 수 있으면 2k승과 2k+1승도 O(1)에 계산할 수 있다. 

이 두 문장은 참이다. 따라서 a의 임의의 지수승을 귀납적으로 계산할 수 있다. 

 

귀납법의 일부를 코드로 바꿔보자. 

func(a, b, c)는 func(a, b/2, c)를 기반으로 구할 수 있다. 
using ll = long long;

ll func(ll a, ll b, ll c){
	val = func(a, b/2, c);
	val = val * a % c; // a를 곱하고 c로 나눈 나머지
}
// func(a, b, c)는 func(a, b/2, c)를 기반으로 구할 수 있다.

만약 b가 짝수라면 val을 그대로 리턴한다. 

만약 b가 홀수라면, "곱하기 a 하고 c로 나눈 나머지"를 한 번 더 수행한다. 

 

아래는 완성된 풀이코드다. 

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll A, B, C;

ll rec(ll a, ll b, ll c){
  if(b == 1) return a % c; // a가 c보다 클 수 있기 때문에 a % c를 반환.
  ll val = rec(a, b/2, c);
  val = val * val % c;
  if(b%2 == 0) return val; 
  return val * a % c; // b가 홀수라면 *a %c 를 한 번 더 수행. 
}

int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);

  cin >> A >> B >> C;
  cout << rec(A, B, C);
  return 0;
}
// http://boj.kr/9c320eecca4b4958bd54977f83371a36

이 함수의 시간 복잡도는 b가 계속 절반씩 깎이니까 O(log b)이다. 

그래서 문제의 시간 제한을 넉넉히 지킬 수 있다. 

 

 

0x02 연습문제2 - 하노이 탑 

두 번째 문제는 재귀 문제 중에 스테디셀러인 하노이탑 문제다. 

온라인 게임으로 원판 3, 4, 5개일 때를 깨고 오자. 하노이탑 문제가 뭔지 감이 올 것이다. 

그리고 백준 11729번 하노이 탑 이동순서 문제를 읽고 오자. 

 

읽고나서 드는 느낌은 '막막하다...?' 일단 n번 원판에만 집중해보자. 

n번 원판에만 집중해보자. 

어찌됬든 원판들을 전부 기둥1에서 기둥3으로 옮기려면, 

제일 아래 있는 n번 원판을 기둥1에서 기둥3으로 옮겨야 한다. 

그런데 n번 원판을 옮기려면 n-1번부터 1번까지의 원판들이 비켜줘야 하고 나아가 그것들이 기둥2에 가있어야 한다.

왜냐하면 작은 원판 위에 큰 원판을 둘 수 없다는 규칙 때문이다. 

n번 원판이 기둥 3에 가야 한다!

n-1개의 원판을 기둥2로 옮긴다. 

그리고 n번 원판을 기둥3으로 옮긴다. 

 

이 다음에는 어떻게 해야 할까? 

n-1개의 원판을 기둥2에서 기둥3으로 옮기면 된다.

과정을 보면, n-1개의 원판을 원하는 곳으로 옮길 수 있으면 n개의 원판을 처리할 수 있다. 

재귀의 성질을 가지고 있다

구현을 위해 단계적으로 생각해보자. 

1. 함수의 정의 

func(n)함수가 func(n-1)함수를 호출하는 방법이 가능할까? 

문제 요구사항은 원판 n개를 기둥a에서 기둥b로 옮기는 방법을 출력하는 것이다. 따라서 func(n-1)은 이 문제에 부적절하다. 

따라서 아래처럼 정의하자. 

void func(int a, int b, int n)

2. base condition

재귀가 자기자신을 호출하지 않고 종료하는 조건을 정의하자. 

원판 1개인 경우를 생각해보자. 

n=1 일 때, a에서 b로 옮기도록 하면 된다. 

n=1 일 때, cout << a << ' ' << b <<'\n';

3. 재귀 식 

기둥 a에서 기둥 6-a-b로 옮긴다. 

이건 바로 이해가 안 갈 수 있다. 기둥 번호 1, 2, 3번의 합이 6이라는 것을 생각하면서 아래의 재귀식을 읽어보자. 

방금 봤던 이동순서 그림을 다시 보고 오면 도움이 된다. 

n번 원판을 기둥 a에서 기둥 b로 옮긴다.                   --> n번 원판을 기둥 1에서 기둥 2로 옮긴다. 
n-1개의 원판을 기둥 a에서 기둥 6-a-b로 옮긴다.  --> n-1개의 원판을 기둥 1에서 기둥 3으로 옮긴다. 

기둥1을 기둥a라는 공통의 출발지로 본다면, 

n-1개의 원판은 6-a-b로 옮겨야 하고, n번 원판은 b로 옮겨야 한다. 

n-1개의 원판을 기둥a에서 기둥 6-a-b로 옮긴다.  func(a, 6-a-b, n-1);
n번 원판을 기둥 a에서 기둥b 로 옮긴다. 		cout << a << ' '<< b << '\n';
n-1개의 원판을 기둥 6-a-b에서 기둥b로 옮긴다.  func(6-a-b, b, n-1);

4. 원반을 옮긴 횟수 

원반 k개를 옮기기 위해 A번 이동이 필요하다고 하자. 

그러면 원반 k+1개를 옮길 때는 몇 번이 필요할까? 

k개의 원판을 빈 곳으로 옮길 때 A번, (이미 알고 있다)
k+1번 원판을 옮길 때 1번,
k개 원판을 다시 빈 곳에서 목적지로 옮길 때 A번이 필요하다. 
->   즉, 2A+1번 이동이 필요하다. 

구현 코드

//바킹독님코드 http://boj.kr/f2440915dca04aaa9aec759080516973
#include <bits/stdc++.h>
using namespace std;

void func(int a, int b, int n){
  if(n == 1){
    cout << a << ' ' << b << '\n';
    return;
  }
  func(a, 6-a-b, n-1);
  cout << a << ' ' << b << '\n';
  func(6-a-b, b, n-1);
}

int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);
  int k;
  cin >> k;
  cout << (1<<k) - 1 << '\n';  // (1<<k)는 2의 k승이다. 
  func(1, 3, k);
}

 

0x03 연습문제3 - Z

하노이탑이 아리송하지만. 백준 1074번 Z문제를 읽어 보면 '또 뭔가..' 싶을 것이다.

꾹 참고 5분만 재귀 관점으로 고민해보자. 

방문 순서가 어떤 식으로 매겨지냐면, 배열을 4등분 한 후에, 1,2,3,4 순으로 진행된다. 

아래 그림에서 N=3일 때, 16x16 배열을 그려놓고. 4등분 해서 영역 1,2,3,4로 나눠놨다. 

 

r=2, c=2 칸은 '영역1'에서' 12번째로 방문한다. 

'영역1' 내부를 4등분으로 나누면, 영역 1, 2, 3에 포함된 0칸~11칸을 전부 방문하고, 12번째로 방문한 셈이다. 

 

r=6, c=2칸은 '영역3'에서 44번째로 방문한다. 

'영역3'에 오기 전에는 이미 '영역1, 영역2'를 방문한다. '영역3'내부의 0칸~11칸을 전부 방문하고, 12번째로 방문한 셈이다. 

뭔가 이전에 방문한 순서를 기반으로 다음 것을 구할 때 써먹을 수 있을 것 같다는 느낌이 조금 온다. (그저 느낌뿐..)

구현을 위해 단계적으로 생각해보자. 

1. 함수의 정의 

직관적으로 보이는 모양 대로 정의하면 된다. 2의 N승 배열에서, (r,c)를 방문하는 순서를 반환하는 함수. 

이 값이 Int범위에 들어오는지 신경써줄 필요가 있는데, n은 15이하니까 int범위를 초과하지 않는다. 

int func(int n, int r, int c)

2. base condition

n=0일 때 0을 반환하도록 하자. 

n=0 일 때, return 0;

3. 재귀 식 

각 상황에 따른 반환 값은 아래와 같다. 

여기서 half는 한 변 길이의 절반, 즉 2의 n-1승이다. 

(r,c)가 1번 영역일 때, return func(n-1, r, c);
(r,c)가 2번 영역일 때, return half*half    + func(n-1, r, c-half);
(r,c)가 3번 영역일 때, return 2* half*half + func(n-1, r-half, c);
(r,c)가 4번 영역일 때, return 3* half*half + func(n-1, r-half, c-half);

아까 그림을 보며 확인한 12번째 방문한다는 것의 의미를 다시 보자.

12번째 방문한다는 것은 0칸~11칸을 전부 방문하고, 12번째로 방문하는 것이다.

2의 2승 을 4등분한 영역에서, 2의 1승(2의 n-1승) 영역의 1,2,3영역을 모두 방문한 다음에 4영역을 방문하고 있는 것이다. 

 

풀이 코드 

// http://boj.kr/fd805e1226e949f9b6b2eff59e5be642
#include <bits/stdc++.h>
using namespace std;

int func(int n, int r, int c){
  if(n == 0) return 0;
  int half = 1<<(n-1);
  if(r < half && c < half) return func(n-1, r, c);
  if(r < half && c >= half) return half*half + func(n-1, r, c-half);
  if(r >= half && c < half) return 2*half*half + func(n-1, r-half, c);
  return 3*half*half + func(n-1, r-half, c-half);
}

int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);
  int n, r, c;
  cin >> n >> r >> c;
  cout << func(n, r, c);
}

재귀 문제집

백준 문제 내 코드
1629번 곱셈 곱셈 내 코드
11729번 하노이 탑 이동 순서 하노이 탑 이동순서 내코드
1074번 Z  
17478 재귀함수가 뭔가요? 재귀함수가 뭔가요? 내코드
1780번 종이의 개수  
2630번 색종이 만들기  

다음 시간에 배울 백트래킹을 잘 해내기 위해서라도 재귀를 능숙하게 다룰 수 있도록 연습을 많이 하자. 반복 또 반복.

문제집에 있는 문제들이 쉽지는 않겠지만,

풀이를 보고 푸는 한이 있더라도 재귀가 두렵지 않을 때 까지 연습을 한 후에 백트래킹으로 넘어가자. 


다음 시간에는 백트래킹을 공부한다.

공부 자료 출처 : 바킹독 알고리즘 재귀 

 

728x90

'알고리즘 > 실전 알고리즘' 카테고리의 다른 글

0x0E강 - 정렬1  (0) 2021.12.11
0x0C강 - 백트래킹  (0) 2021.12.10
0x0A강 - DFS  (0) 2021.12.07
0x09강 - BFS  (0) 2021.12.07
0x08강 - 스택의 활용(수식의 괄호 쌍)  (0) 2021.11.29

문제

수 찾기 백준 1920

"맞았습니다"코드

#include <bits/stdc++.h>
using namespace std;

int n, m, num;
int arr[100010];

void binarySearch(int target){
  int start = 0;
  int end = n-1;
  int mid = 0;

  while(end >= start){
    mid = (start+end)/2;

    if(target == arr[mid]){
      cout << 1 << '\n';
      return;
    }
    else if (arr[mid] < target){
      start = mid+1;
    }
    else{
      end = mid-1;
    }
  }
  cout << 0 << '\n';
  return;
}

int main(void) {
  ios::sync_with_stdio(0);
  cin.tie(0);

  cin >> n;
  for(int i = 0; i < n; i++){
    cin >> arr[i];
  }
  sort(arr, arr+n); // 이분탐색을 위해 미리 정렬 
  cin >> m; 
  while(m--){
    cin >> num;
    binarySearch(num);
  }
  return 0;
}

리뷰

이중 for문으로 검사하면 n, m둘다 10만개가 주어질 수 있기 때문에.
시간초과가 난다.
따라서 첫 번째 배열을 정렬 시키고, 두 번째 숫자가 들어올 때마다 이분 탐색시켰다.

728x90

'알고리즘 > 백준' 카테고리의 다른 글

하노이탑 이동 순서 백준 11729 c++  (0) 2021.12.09
곱셈 백준 1629번 c++  (0) 2021.12.08
상범빌딩 백준 6593번 c++  (0) 2021.12.08
나무 자르기 백준 2805 c++  (0) 2021.12.06
토마토 백준 7569 c++  (0) 2021.12.06

+ Recent posts