목차 

0x00 다이나믹 프로그래밍 설명 

0x01 연습 문제

0x02 경로 추적 


0x00 다이나믹 프로그래밍 설명 

다이나믹 프로그래밍은 여러 개의 하위 문제를 먼저 푼 후, 그 결과를 활용하여 문제를 해결하는 알고리즘이다. 

앞 글자만 따서 DP라고 부르기도 한다. 

 

재귀를 배울 때 피보나치 문제를 풀어봤다.
재귀적으로 구현하면, fibo(4)를 호출할 때 fibo(3)과 fibo(2)를 호출한다. 

fibo(3)은 또 fibo(2)fibo(1)을 호출하고. fibo(2)fibo(1)과 fibo(0)을 호출한다. 

-> 중복된 연산 발생

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

피보나치 문제를 DP로 해결하면, 연산의 결과를 배열에 저장하는 메모이제이션을 활용한다. 

그래서 fibo(1)을 재호출하지 않고 f[1]의 값을 재활용한다. 

int fibo(int n){
  int f[20];
  f[0] = f[1] = 1;
  for(int i = 2; i <= n; i++)
    f[i] = f[i-1] + f[i-2];
  return f[n];
}

연산의 중간 결과를 저장하여 재활용 하니까. O(N)에 답을 구할 수 있다. 

 

DP를 푸는 과정 

1. 테이블 정의하기 

2. 점화식 찾기

3. 초기값 정하기 

 

DP는 점화식 찾고, 초기값을 채워넣은 후에 반복문을 돌면서 배열을 채우면 구현이 끝나는 단순한 구조다. 

DP에 익숙하지 않으면 DP문제인지도 알아채지 못할 수도 있다. 여러 문제를 풀면서 DP 점화식을 찾는 연습을 하자.


 0x01 연습 문제 : 1로 만들기 BOJ 1463

이 문제는 BFS로도 해결할 수 있다. dist 배열을 두고 초기값을 1로 한 뒤, x2, x3, +1로 뻗어나가면 답이 나온다. 

그런데 DP로 풀면 훨씬 짤막하게 구현할 수 있다.

1. 테이블 정의하기

먼저 테이블은 위와 같이 정의하자. D[i] = i를 1로 만들기 위해 필요한 연산 횟수의 최솟값

배열의 인덱스와 값을 어떻게 구성할지에 관한 건 여러 경험이 쌓이면 감이 온다. 

 

2. 점화식 찾기

구체적인 예시를 들어서 D[12]를 구하는 연산을 생각해보자. 

D[12] = 12를 1로 만들기 위해 필요한 연산 횟수의 최솟값

3으로 나눈다: D[12]  =  D[4] + 1번의 연산(3으로 나누기)

2로 나눈다: D[12] = D[6] + 1번의 연산(2로 나누기)

1을 뺀다 D[12] = D[11] + 1번의 연산(1 빼기)

세 가지 연산 횟수 중에서 최솟값이 답이 된다. 따라서 D[12] = min( D[4] + 1, D[6] + 1, D[11] + 1)  

 

3. 초기값 정의하기 

1을 1로 만드는데 연산은 0번 필요하다. D[1] = 0

2를 1로 만드는데 연산은 1번 필요하다.(2로 나누거나 1을 빼기) D[2] = 1

이 문제에서는 D[1] 초기값만 정해주면 된다.

이제 D[] 배열을 전역에 잡고 for문을 돌면서 주어진 정수x 인덱스까지 채우면 답이 나온다. 

 

1로 만들기 바킹독님 코드, 내 코드

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

int d[1000005];
int n;

int main(void) {
  ios::sync_with_stdio(0);
  cin.tie(0);
  cin >> n;
  d[1] = 0;
  for(int i = 2; i <= n; i++){
    d[i] = d[i-1]+1;
    if(i%2 == 0) d[i] = min(d[i],d[i/2]+1);
    if(i%3 == 0) d[i] = min(d[i],d[i/3]+1);
  }
  cout << d[n];
}

DP는 구현이 이렇게 짤막한 편이다. 점화식을 찾아내는 것이 관건이다. 


0x01 연습 문제 : 1,2,3 더하기 BOJ 9095

1. 테이블 정의하기

정수 4를 1,2,3의 합으로 나타내는 방법의 수는 본문에 나와 있지만 7가지다.

테이블은 D[4] = 7 의 형태로 만들면 된다. D[i] = i를 1,2,3의 합으로 나타내는 방법의 수

7가지 방법을 3줄에 나눠서 써놨다. 규칙이 뭘까? 점화식을 생각해보자. 

첫 번째 줄은 +1로 끝난다.

두 번째 줄은 +2로 끝난다.

세 번째 줄은 +3으로 끝난다. 

2. 점화식 찾기 : 노랑으로 표시된 +1, +2, +3만 제외하고 생각해보자. 

1+1+1, 3, 2+1, 1+2 3을 1,2,3의 합으로 나타내는 방법 == D[3]
1+1, 2 2를 1,2,3의 합으로 나타내는 방법 == D[2]
1 1을 1,2,3의 합으로 나타내는 방법 == D[1]

3을 1,2,3의 합으로 나타내는 방법에 +1, +2, +3 을 붙이면  4가 된다.

1+1+1+1, 3+1, 2+1+1, 1+2+1 3을 1,2,3의 합으로 나타내는 방법 +1
1+1+2, 2+2 2를 1,2,3의 합으로 나타내는 방법 +2
1+3 1을 1,2,3의 합으로 나타내는 방법 +3

이렇게 D[4]=D[3] + D[2] + D[1] 이 성립하게 된다. 

 

3. 초기값 정하기 

D[i] 는 직전 값이 3개는 필요하다. D[i] = D[i-1] + D[i-2] + D[i-3] 

D[1] = 1, D[2] =2, D[3] = 4. 

 

1,2,3 더하기 바킹독님 코드, 내코드

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

// d[i] = i를 1, 2, 3의 합으로 나타내는 방법의 수
int d[20];
int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);
  
  d[1] = 1; d[2] = 2; d[3] = 4;
  for(int i = 4; i < 11; i++)
    d[i] = d[i-1] + d[i-2] + d[i-3];

  int t;
  cin >> t;
  while(t--){
    int n;
    cin >> n;
    cout << d[n] << '\n';
  }
}

0x01 연습 문제 : 계단 오르기 BOJ 2579

만약 N이 20 정도로 그다지 크지 않으면 백트래킹 같은 방법으로 O(N^2)으로 해결이 가능하다. 

지금처럼 N이 300이라면 DP로 해결해야 한다. 

2개의 규칙을 유념하자. 

1) 최종 계단은 반드시 밟아야 한다.

2) 계단을 연속 3개 밟을 수 없다.

 

1. 테이블 정의하기

S[k] = 주어진 계단 점수

D[i] = i 번째 계단까지 최대 점수 

 

2. 점화식 찾기 

최종 계단은 반드시 밟아야 하니까, 그 이전 계단에 대해서만 생각하자.

계단이 4개 있을 때 가능한 경우 2가지를 그림으로 그렸다. 

경우 2 : 4번째 계단이 2연속 밟은 계단이면, 직전 계단은 반드시 밟는다.

경우 1 : 4번째 계단이 1연속 밟은 계단이면, 직전 계단은 반드시 안밟는다. 

 

경우 1 : 최종 계단이 2번째 연속으로 밟은 계단이면, 확실한건 3번은 밟았고 2번은 안밟는다는 것이다. 

따라서 1번째 계단까지의 최대점수(D[1]) + 3번 점수(S[3]) + 4번 점수(S[4]) 가 답이 된다.

계단 번호 1 2 3 4
밟았는지  ? X O1 O2

경우 2 : 최종 계단이 1번째 연속으로 밟은 계단이면, 확실한건 3번은 안밟았다는 것이다.

따라서 2번째 계단까지의 최대점수(D[2]) + 4번 점수(S[4]) 가 답이 된다.

계단 번호 1 2 3 4
밟았는지    ? X O1

점화식은 아래와 같다. 

D[i] = S[i] + max(S[i-1] + D[i-3] , D[i-2]);

 

3. 초기값 정하기 

i를 구할 때 i-3 이 필요하니까 1, 2, 3 까지 초기값을 정해줘야 한다.

D[1] = S[1], D[2] = S[2], D[3] = S[3];

 

계단 오르기 전체 코드 바킹독님 코드, 내 코드 

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

int s[305];
int n;
int d[305];

int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);
  cin >> n;
  int tot = 0;
  for(int i = 1; i <= n; i++){
    cin >> s[i];
    tot += s[i];
  }
  if(n <= 2){
    cout << tot;
    return 0;
  }
  d[1] = s[1];
  d[2] = s[2];
  d[3] = s[3];
  for(int i = 4; i <= n-1; i++) d[i] = min(d[i-2],d[i-3])+s[i];
  cout << tot - min(d[n-1],d[n-2]);
}

0x01 연습 문제 : RGB거리 BOJ 1149

 

 

 

 

 

 

 


0x02 경로 추적 : 1로 만들기 2 BOJ 12852 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


DP 문제집 

백준 문제 번호  내 코드 
1463 1로 만들기 내 코드
9095 1,2,3더하기 내 코드
2579 계단 오르기 내 코드 
1149 RGB거리  내 코드
11726 2xn 타일링 내 코드
11659 구간 합 구하기 4 내 코드
12852 1로 만들기 2 내 코드
1003 피보나치 함수  내 코드

 


다음 시간에는 그리디 알고리즘을 공부한다. 

공부 내용 출처 : 바킹독 알고리즘 다이나믹 프로그래밍

728x90

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

0x0F강 - 정렬2  (0) 2021.12.18
0x0E강 - 정렬1  (0) 2021.12.11
0x0C강 - 백트래킹  (0) 2021.12.10
0x0B강 - 재귀  (0) 2021.12.08
0x0A강 - DFS  (0) 2021.12.07

목차 

0x00 Counting Sort

0x01 Radix Sort 

0x02 STL Sort

0x03 정렬의 응용


0x00 Counting Sort

{ 1, 5, 4, 2, 3, 1, 4, 3} 

이 수열을 숫자 크기를 기준으로 오름차순으로 정렬하려고 한다. 어떻게 할지 생각해보자. 

머지 소트나 퀵 소트를 써도 된다. 

여기서 다시 짚고 넘어갈 점! 

퀵 소트는 NlogN이 보장 된다 안 된다? 

최악의 경우 O(N^2)이다. 꼭 기억하자. 

 

이 수열을 조금 변형한 다음에, 정렬하자. 아래의 표가 어떤 것을 의미할까? 

-> 수열에 숫자가 포함된 횟수를 저장했다. 

1이 2번 나왔다. 2가 1번 나왔다. 3이 2번 나왔다 ... 

이 표만 있으면 각 숫자가 차례로 나온 횟수만 적으면 정렬이 가능하다! 이런 알고리즘이 카운팅 소트다. 

 

이 때, '최솟값과 최댓값을 커버할 수 있는 크기의 배열을 선언해도 되는지' 문제의 메모리 제한을 확인해야 한다. 

512MB 제한이면, int 기준 1.2억인 배열을 잡을 수 있다. 

대략 1000만 이하일 때, 카운팅 소트를 쓸 수 있다고 생각하면 된다. 

 

백준 15688번 수 정렬하기5 문제를 카운팅 소트로 풀어보자. (내 코드, 바킹독님 코드)

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

int freq[2000001];
int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);
  int n;
  cin >> n;
  for(int i = 0; i < n; i++){
    int a;
    cin >> a;
    freq[a+1000000]++;
  }
  for(int i = 0; i <= 2000000; i++){
    while(freq[i]--){ // cnt[i]번 반복
      cout << i-1000000 << '\n';
    }
  }
}

입력 숫자가 음수 양수 모두를 포함하니까 애초에 모든 수에 100만을 더한 코드다. 

시간복잡도를 생각해보자. 

수의 범위가 K라고 할 때.

1) 처음에 N개의 숫자들을 배열에 넣는다. 

2) 정렬할 때 K개의 칸을 확인한다.

따라서 시간복잡도는 O(N+K)가 된다.

 

이렇게 수의 범위가 제한되어 있고 작은 경우에 카운팅 소트가 유용하다. 

0x01 Radix Sort(기수 정렬)

라딕스 소트는 자릿수를 이용해서 정렬하는 알고리즘이다. 

카운팅 소트를 응용한 것으로 이해하면 된다. 

아래 수열을 라딕스 소트로 정렬해보자

012 는 12를 의미하는데. 편의를 위해 앞에 0을 붙였다. 

1. 크기 10의 리스트를 선언한다. 

2. 숫자의 '1의 자리'에 맞춰서 리스트에 넣는다.

012는 1의 자리가 2다. 따라서 인덱스 2에 넣는다. 421은 인덱스 1에 넣자. 

3. 0번 리스트부터 보면서 수를 꺼내서 수열을 재배열한다.

이 과정을 거치면 수열이 '1의 자리'를 기준으로 재배열된다.

1의 자리 기준이고 주어진 수열의 자리가 유지되니까 421이 021보다 앞에 온다. 

4. 다음 단계는 '10의 자리'에 맞춰서 리스트에 넣는다.

100은 10의 자리가 0이니까 인덱스 0에 넣는다. 421은 인덱스 2에 넣는다. 

5. 0번 인덱스 부터 9번 인덱스를 보면서 수를 꺼내서 재배열한다. 

5. 다음 단계는 '100의 자리'에 맞춰서 리스트에 넣는다. 

100은 100의 자리가 1이니까 인덱스 1에 넣는다. 502는 인덱스 5에 넣는다. 

6. 하란대로 하니깐 일단. 1의 자리 -> 10의 자리 -> 100의 자리 순으로 재배열 하니까 정렬이 완료됬다. 

여기서 질문. 502는 왜 421보다 클까? 

왜 이럴까를 생각해보면 라딕스 소트를 이해하는데 도움이 된다. 

502와 421을 비교할 때, 먼저 100의 자리를 비교한다. 100의 자리만 보면, 5 > 4 이니까! 

즉, 수A 가 수B보다 크다는 의미는 더 큰 자릿수에서 A의 자릿수가 B의 자릿수보다 큰 경우가 먼저 생긴다는 것이다. 

이걸 인지한 상태로 라딕스 소트 과정을 보자. 

10의 자리 까지는 502가 421 앞에 위치했다. 

하지만 100의 자리 비교 단계에서는 421는 4번 인덱스에 들어가고. 502는 5번 인덱스에 들어가기 때문에.

결국 421이 502보다 앞에 오도록 올바르게 정렬된다. 

 

라딕스 소트의 시간복잡도 

1의 자리 기준으로 재배열 하면서 리스트 크기 만큼 순회했다. 

10의 자리 기준으로 재배열 할 때도 리스트 크기 만큼 순회했다. 

따라서 자릿수의 최대 개수가 D개라고 할 때, D번에 걸쳐서 카운팅 소트를 하는 것과 똑같다. 

리스트의 크기를 K개 라고 할 때, 엄밀하게 말하면 시간 복잡도는 O(D(N+K))이지만.

보통 리스트의 개수는 N에 비해 무시가 가능할 정도로 작다. 당장 지금 예시로 든 상황도 리스트 갯수 K가 10밖에 안된다.

결론적으로 라딕스 소트의 시간복잡도는 O(DN)이다. 

 

적어도 코딩 테스트에서는 라딕스 소트를 구현해야 하는 상황은 아예 없다. 개념만 이해하자. 

라딕스 소트 구현 코드

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

int n = 15;
int arr[1000001] = {12, 421, 46, 674, 103, 502, 7, 100, 21, 545, 722, 227, 62, 91, 240};

int d = 3; // 자릿수의 최대 길이.
int p10[3]; // p10[i] = 10의 i승

// x의 10^a 자리의 값을 반환하는 함수
int digitNum(int x, int a) {
  return (x / p10[a]) % 10;
}

vector<int> l[10]; // 0부터 9까지 저장.

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

  p10[0] = 1;
  for(int i = 1; i < d; i++) p10[i] = p10[i-1] * 10;

  for(int i = 0; i < d; i++){ // 1의 자리 -> 10의 자리 -> 100의 자리.
    for(int j = 0; j < 10; j++) l[j].clear();
    // 각 수를 list에 대입
    for(int j = 0; j < n; j++) // 0~9
      l[digitNum(arr[j], i)].push_back(arr[j]);
    // 0번 리스트 부터 차례로 다시 arr에 넣어 재배열
    int aidx = 0;
    for(int j = 0; j < 10; j++){
      for(auto x : l[j]) arr[aidx++] = x;
    }
  }
  for(int i = 0; i < n; i++) cout << arr[i] << ' ';

  return 0;
}

 

코드를 간단히 짚고 넘어가면. n은 배열 크기, arr은 정렬할 배열이다.

d는 자릿수의 최댓값이다. (100의자리가 최대니까 여기서는 d가 3이 된다.)

배열 p10은 10의 지수를 저장할 배열이다. p10[0] == 1 , p10[1] = 10, p10[2] = 100 

// x의 10^a 자리의 값을 반환하는 함수
int digitNum(int x, int a){
  return (x / p10[a]) % 10;
}

 

digitNum함수는 x에서 10의 a자리 숫자를 추출한다. ex) digitNum(253, 1) 의 리턴값은 5다. 

vector<int> l 은 0번부터 9까지를 저장하는 리스트다. 

 

p10[i]는 10의 i승을 저장할 변수다. p10배열 초기화 코드인데. 정수 거듭제곱을 계산해야 하면 이렇게 하는 것이 정석이다. 

p10[0] = 1; // 10의 0승이니까 1이다. 
for(int i = 1; i < d; i++) p10[i] = p10[i-1] * 10;

 

pow 함수는 실수형을 반환하기 때문이다. 아래 코드 처럼 쓰면 실수 오차로 꼬일 수 있다. 주의하자. pow함수 레퍼런스

p10[i] = pow(10, i);
// double pow (double base, double exponent);

정렬 과정에 '비교'를 하는지? 

Comparision Sort Non-comparision Sort
버블 소트
머지 소트
퀵 소트 
카운팅 소트
라딕스 소트

카운팅 소트와 라딕스 소트는 정렬 과정에 '비교'를 하지 않는다.   


0x02 STL sort

algorithm 헤더에서 제공하는 sort() 함수는 기본으로 오름차순으로 정렬해준다. 

최악의 경우에도 O(NlogN)이 보장된다. 단 stable sort가 필요하다면 sort()가 아닌 stable_sort()를 쓰자. 

sort() 레퍼런스 

int a[5] = {1, 4, 5, 2, 7};
sort(a, a+5);

vector<int> b = {1, 4, 5, 2, 7};
sort(b.begin(), b.end()); // or sort(b.begin(), b.begin() + 5 );

stable_sort() 레퍼런스

두 원소의 우선순위가 같다면 원소의 위치를 변경하지 않는다. 사용법은 sort()와 똑같다. 

[중요] 비교 함수

sort() 함수의 파라미터로 비교함수를 넘길 수 있다. stable_sort()도 마찬가지다. 

예를 들어, int형을 5로 나눈 나머지 순으로, 5로 나눈 나머지가 같다면 크기 순으로 정렬하고 싶으면 아래 코드처럼 하면 된다.  

비교함수 cmp는 a가 b의 앞에 와야할 때 true를, 그렇지 않을 때 false를 반환한다. 

bool cmp(int a, int b){ // a가 b의 앞에 와야할 때 true, 아니면 false 
  if(a % 5 != b % 5) return a % 5 < b % 5;
  else a < b;
}

int a[7] = {1, 2, 3, 4, 5, 6, 7};
sort(a, a+7, cmp);
// 출력 결과: 5 1 6 2 7 3 4

 

비교함수 구현 시 자주하는 실수 

1. a가 b의 앞에 와야할 때만 true를 반환한다. a==b 라면 false를 반환한다. 

예를 들어, 수열을 크기의 내림차순으로 정렬하고 싶을 때, a와 b가 같은 경우 false를 반환하니까 런타임 에러가 발생할 수 있다. 

bool cmp(int a, int b){ // [런타임 에러 발생]
  if(a >= b) return true;
  return false; 
}

a > b 일 때 true를 반환하도록 수정하자. 

bool cmp(int a, int b){ // 올바른 형태
  return a > b;
}

2. 비교 함수의 인자로 STL 혹은 클래스 객체를 전달시 reference를 사용하자. 

문자열을 받아서 끝자리의 알파벳 순으로 정렬하고 싶어서 비교함수를 작성했다.  

아래의 비교함수는 어떻게 개선하면 좋을까? 

bool cmp(string a, string b){
  return a.back() < b.back();
}
 // 함수의 인자로 STL이나 구조체 객체를 보내면 값의 '복사'가 일어난다!

함수의 인자로 STL이나 구조체 객체를 보내면 값의 '복사'가 일어남을 기억하자!

이 경우, 굳이 복사라는 불필요한 연산이 없어도 비교가 가능하다. 

따라서 복사를 하는 대신 아래처럼 reference를 보내는 것이 더 바람직하다. 

bool cmp(const string& a, const string& b){ // 레퍼런스를 보내서 비교하는 것이 효율적.
  return a.back() < b.back();
}

 

const string& a, const string& b라고 쓰면 a와 b는 변하지 않음을 명시적으로 나타내기 때문에 가독성이 도움이 된다. 

하지만 코딩테스트에서 const를 안써도 크게 상관은 없다. 

비교함수 연습을 위해 백준 1431번 시리얼 번호 문제를 풀고 오자. 1431 내 코드 

 


0x03 정렬의 응용

정렬을 이용해서 시간복잡도를 개선할 수 있는 문제 백준 11652번 카드 문제를 확인해보자. 

O(N^2)의 풀이는 너무 직관적으로 보인다. 그런데 O(N^2)이면 시간초과이고. 수의 범위가 매우 크다. 

map은 아직 배우지 않았고. 또 이진검색트리라는 개념을 사용하는 만큼 map을 이용한 풀이는 배제하겠다. 

어떻게 해결해야 할까? 

 

일단 변수 3개가 필요하다. 

cnt : 현재 보고 있는 수의 등장 횟수. 

mxval : 현재까지 가장 많이 등장한 수의 값.

mxcnt : 현재까지 가장 많이 등장한 수의 등장 횟수. 

초기값으로 cnt = 0, mxval = -2^62-1, mxcnt = 0 을 주자. 

 

이제 2부터 읽으면서 진행하자. 

cnt 는 현재 보고 있는 수의 등장 횟수다. 

현재 보고 있는 수가 제일 앞에 있는 수이거나 현재 보고 있는 수와 바로 직전에 나온 수가 같을 때에는 cnt 를 1 증가시키면 된다. 

그래서 지금 '2'를 바라보고 있으니까 cnt를 1 증가 시키면 끝이다. 

다음 숫자도 '2'니까 직전수와 똑같다. cnt를 1 증가시킨다. 

다음 숫자도 '2'니까 cnt를 1 증가시킨다. 

이제 '3'을 바라본다. 더이상 2가 연속하지 않는다. 

이 때 mxval과 mxcnt를 업데이트해야 한다. 

여태까지 가장 많이 등장한 횟수는 '2'가 '3'번 나온 것이다. mxval은 2로, mxcnt는 3으로 업데이트 하자. 

'3'에 대한 개수를 세야 하니까 cnt=1로 변경해준다. 이제 3이 몇 개 있나 세면 된다. 

 

바킹독님 코드 11652 카드

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

int n;
long long a[100005];

int main(void) {
  ios::sync_with_stdio(0);
  cin.tie(0);
  cin >> n;
  for(int i = 0; i < n; i++)
    cin >> a[i];
  sort(a, a+n);
  int cnt = 0;
  long long mxval = -(1ll << 62) - 1; // 1을 long long으로 형변환하지 않고 1 << 62로 작성시 int overflow 발생
  int mxcnt = 0;
  for(int i = 0; i < n; i++){
    if(i == 0 || a[i-1] == a[i]) cnt++; // i가 0인 경우 앞쪽 식이 true이기 때문에 a[i-1]을 참조하지 않음
    else{
      if(cnt > mxcnt){
        mxcnt = cnt;
        mxval = a[i-1];
      }
      cnt = 1;
    }
  }
  if(cnt > mxcnt) mxval = a[n-1]; // 제일 마지막 수가 몇 번 등장했는지를 별도로 확인
  cout << mxval;
}

코드 마지막 줄에 '제일 마지막 수가 몇 번 등장했는지 별도로 확인'해야 한다. 

이 처리가 빠지면 제일 마지막 수의 등장 횟수를 빠뜨리게 된다. 

이 문제에서 사용한 정렬을 하면, 같은 수는 인접하게 된다는 성질을 이용해 수열에서 중복된 원소를 제거할 수 도 있다. 


정렬2 문제집

문제를 풀 때는 퀵, 머지, 카운팅, 라딕스 소트를 직접 구현할 일은 없고 STL sort만 쓰겠지만, 이렇게 정리하고 나면 기술면접 대비에 도움이 된다. 

특히 7795번 문제를 풀고, 다른 사람의 풀이를 찾아서 비교해보자. 

백준 문제 번호 내 코드 
1431 시리얼 번호  1431 내 코드  , 1431 내 코드2
11652 카드  11652 내 코드 (다시 풀기)
5648 역원소 정렬 5648 내 코드 
1181 단어 정렬 1181 내 코드
2910 빈도 정렬 2910 내코드
10814 나이순 정렬 10814 내 코드
11656 접미사 배열  11656 내 코드
10825 국영수 10825 내 코드
7795 먹을 것인가 먹힐 것인가 7795 내 코드

 


다음 시간에는 다이나믹 프로그래밍을 배운다.

공부 내용 출처 : 바킹독 알고리즘 정렬2

728x90

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

0x10강 - 다이나믹 프로그래밍  (0) 2021.12.19
0x0E강 - 정렬1  (0) 2021.12.11
0x0C강 - 백트래킹  (0) 2021.12.10
0x0B강 - 재귀  (0) 2021.12.08
0x0A강 - DFS  (0) 2021.12.07

목차 

0x00 기초정렬

0x01 Merge Sort 

0x02 Quick Sort


0x00 기초정렬

1. 선택정렬 

책꽂이에 꽂힌 책을 높이 순으로 정렬하려면 어떻게 해야 할까? 

제일 큰 숫자를 찾아서 뒤로 보낸다.

그 다음 큰 것을 찾아서 뒤로 보낸다 ... 이렇게 이어질 테니 계산해보면 시간복잡도는 O(N^2) 이다. 

 

선택정렬 코드

int arr[10] = {3, 2, 7, 116, 62, 235, 1, 23, 55, 77};
int n = 10;
for(int i = n-1; i > 0; i--){ // 최대값을 찾으면 i위치로 보낼것이다. i:맨 마지막부터 앞에서 두번째 까지.
  int mxidx = 0;
  for(int j = 1; j <= i; j++){
    if(arr[mxidx] < arr[j]) mxidx = j;
  }
  swap(arr[mxidx], arr[i]);
}

선택 정렬은 가장 큰 수를 찾으면, 인덱스 i로 이동시킨다. 
내부 반복문에서 mxidx 인덱스와 j인덱스 자리의 숫자를 비교한다. 이 반복문이 종료되면 가장 큰 숫자의 인덱스가 mxidx에 저장된다. 
가장 큰 숫자 인덱스 mxidx와 뒤쪽 인덱스 i를 swap한다. ( == 가장 큰 숫자를 뒤로 보낸다)

max_element 함수로 줄여본 코드 

max_element(arr, arr+k) 
// arr[0], arr[1], arr[2], .. , arr[k-1] 에서 최댓값인 원소의 주소를 반환

arr[0]부터 arr[k-1] 까지 탐색한 후 최댓값인 원소 주소를 반환한다. 

arr[k]가 아니라 arr[k-1]임을 주의하자. 

int arr[10] = {3, 2, 7, 116, 62, 235, 1, 23, 55, 77};
int n = 10;
for(int i = n-1; i > 0; i--){ // 맨 마지막부터 앞에서 두번째 까지.
  swap(*max_element(arr, arr+i+1), arr[i]);
}

최댓값을 찾으면 인덱스 9와 swap, 최댓값을 찾으면 인덱스 8과 swap, 최댓값을 찾으면 인덱스 7과 swap..을 반복한다. 

max_element함수는 범위 내의 원소를 모두 살피니까 O(N^2)의 시간복잡도라는 점을 인지하자. 

2. 버블정렬 

인접한 원소가 자기보다 크면 자리를 바꾼다. 보글보글 인접한 원소끼리만 swap하는 모양 때문에 이름이 버블정렬이다. 

이중 반복문이라서 시간복잡도가 O(N^2)다. 

int arr[5] = {-2, 2, 4, 6, 13};
int n = 5;
for(int i = 0; i < n; i++){
  for(int j = 0; j < n-i-1; j++){
    if(arr[j]> arr[j+1]) swap(arr[j], arr[j+1]);
  }
}

정렬 1회전이 끝나면, 가장 큰 숫자가 맨 뒤에 위치한다. 13이 arr[4]에 가 있을 것이다. 

내부 반복문 j에서는 (인덱스 0과 1) (인덱스 1과 2) 이렇게 인접한 숫자를 비교하니까. j를 0부터 n-1까지 순회해야 한다.

그래야 없는 인덱스를 참조하는 런타임 에러가 안난다. 

내부 반복문 종료 조건을 j < n-i-1 로 해도 정상 동작하는 이유는 뭘까?

버블 정렬에서 정렬 1회전이 끝나면, 가장 큰 숫자가 맨 뒤에 위치한다.

 

정렬 1회전이 끝나면 13이 arr[4]에 있다. 인덱스 4에 있는 숫자를 이제 비교할 필요 없다.

정렬 2회전에서는 인덱스 0, 1, 2, 3 까지만 비교하면 된다! 정렬 2회전이 끝나면 6이 arr[3]에 있다. 인덱스 3부터는 비교할 필요 없다.

이렇게 이미 맨 뒤에 정렬이 끝난 숫자는 비교를 안해도 올바른 자리에 가 있기 때문이다. 


0x01 Merge Sort

재귀적으로 수열을 나눠 정렬한 후 합치는 정렬이다. 시간복잡도는 O(NlogN)이다. 

N이 10만 정도라면, O(N^2) 알고리즘의 경우, 10초~1분 소요된다. O(NlogN)알고리즘이면 눈 감았다 뜨기 전에 종료된다. 

1. 두 정렬된 리스트를 합친다는건 어떻게 할까? 

병합정렬을 배우기 전에, 길이가 N, M인 두 정렬된 리스트를 합쳐서 길이 N+M의 정렬된 리스트를 만드는 방법을 알아야 한다. 

그림을 보고 제일 앞에 와야하는 수(가장 작은 수)는 어떻게 택해야 할지 생각해보자.

빨강 리스트와 파랑리스트의 모든 원소를 확인해야 할까?

아니다. 

-> 두 리스트의 가장 앞에 있는 원소만 비교하면 된다! 

왜냐하면 이미 정렬된 리스트이기 때문에 가장 작은 수가 이미 각 리스트의 맨 앞에 있다. 

"-9와 -7만 비교하면 -9가 제일 작으니까 리스트를 합쳤을 때 -9가 제일 앞에 오게 된다" 는 것을 O(1)에 알 수 있다. 

 

그러면 -9 다음에 오는 수는 어떻게 알까?

두 리스트의 맨 앞을 비교하면 1과 -7이 있다. -7을 선택한다.  

다음은? 두 리스트의 맨 앞을 비교하면 6과 1이 있다. 1을 선택한다.  

이렇게 비교 1번 당 1개의 원소를 제자리에 보낼 수 있으니까 정렬된 두 리스트를 합치는건 O(N+M)에 수행 가능하다. 

 

혼자 힘으로 '정렬된 배열 합치기'를 구현해보자.  백준 11782번 배열 합치기 문제를 풀고 오자.

11782번 문제 내 코드  아래는 바킹독님의 코드 

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

int n, m;
int a[1000005], b[1000005], c[2000005];

int main(void) {
  ios::sync_with_stdio(0);
  cin.tie(0);
  
  cin >> n >> m;
  for(int i = 0; i < n; i++) cin >> a[i];
  for(int i = 0; i < m; i++) cin >> b[i];
  int aidx = 0, bidx = 0;
  for(int i = 0; i < n+m; i++){
    if(bidx == m) c[i] = a[aidx++];
    else if(aidx == n) c[i] = b[bidx++];
    else if(a[aidx] <= b[bidx]) c[i] = a[aidx++];
    else c[i] = b[bidx++]; 
  }
  for(int i = 0; i < n+m; i++) cout << c[i] << ' ';
}

2. Merge Sort 는 어떻게 구현할까? 

병합 정렬은 3단계로 요약할 수 있다.

step1. 주어진 리스트를 2개로 나눈다.
step2. 각각의 리스트를 정렬한다. 
step3. 정렬된 두 리스트를 합친다. 

"나눈 리스트는 어떤 방법으로 정렬할까?"

-> "재귀"에서 아이디어를 얻을 수 있다. 

재귀를 배울 때 도미노가 쓰러지는 것에 비유하여 귀납법을 이해했다. 

"1번 도미노가 쓰러지면 100번 도미노가 쓰러진다." 는 것을 납득시키려면? 

100번 도미노가 쓰러지려면, 99번 도미노가 쓰러져야하고, 99번 도미노가 쓰러지려면 98번 도미노가 쓰러져야 하고, ... 

2번 도미노가 쓰러지려면 1번 도미노가 쓰러져야 한다. 

일단 1번 도미노는 확실히 쓰러지게 되어 있으니까. 따라서 100번 도미노가 쓰러지면 1번 도미노가 쓰러진다.

이러한 귀납법을 병합 정렬에 적용하면. 

길이가 8인 리스트를 정렬하려면, 길이가 4인 리스트를 정렬할 수 있어야 하고. 

길이가 4인 리스트를 정렬하려면 길이가 2인 리스트를 정렬할 수 있어야 하고. 

길이가 2인 리스트를 정렬하려면 길이가 1인 리스트를 정렬할 수 있어야 하는데. 

길이가 1인 리스트는 아주 쉽게 정렬할 수 있으니까. 결국 우리는 {6, -8, 1, 12, 8, 15, 7, -7}를 정렬할 수 있다! 

3. 구현하기 전에 병합 정렬의 시간복잡도를 확인하자

병합 정렬의 전체 과정을 살펴보면, 리스트가 분할하는 과정과 합쳐나가는 과정으로 구분된다. 

1) 분할하는 과정 

분할한다는 것은 관념적으로 분할 되었다고 생각하는 것이다. 

함수 호출의 횟수는 각 줄별로 1, 2, 4, ... , 2의 k승 번이다. 이 횟수는 각 줄에 리스트가 몇 개 있는지 보면 된다. 

예를들어, 3번째 줄을 보자.

리스트가 {6, -8}, {1, 12}, {8, 15}, {7, -7} 총 4개가 있다. 각각에 대해 함수가 호출되니까 3번째 줄은 4번 호출된다. 

 

분할하는 과정에서 함수 호출은 1+2+4+ ... + 2의 k승 == 2N-1 = O(N)번 발생한다

즉, 분할하는 과정의 시간복잡도는 O(N)이다. 

 

2) 합쳐나가는 과정 

합쳐나가는 과정의 시간복잡도는 각 줄에서 모두 N번의 연산이 필요하다

아까 두 정렬된 리스트를 O(A+B)에 구하는 방법을 배웠다. 

그러면 각 줄에서 둘씩 짝지어 합쳐서 다음 단계로 넘어가려면 그냥 전체 원소의 갯수만큼 필요하다는 것을 알 수 있다. 

예를 들어, 5번째 줄을 보자. 

{-8, 6}, {1, 12}, {8,15}, {-7, 7} 길이가 N/4인 4개의 정렬된 리스트들을 2개씩 짝지어 합치는 상황이다. 

필요한 연산의 횟수는 N/4 + N/4 + N/4 + N/4 == N이 된다. 

분할이 완료됬을 때, 리스트의 원소는 1개 였다가 2의 k승 개가 될때까지 매번 2배씩 커지니까 줄은 k개다. 

(1개 -> 2개 -> 4개 -> 8개)

그렇기 때문에 합쳐나가는 과정은 O(Nk) == O(NlogN) 이다. 

분할하는 과정은 O(N)이고 합쳐나가는 과정은 O(NlogN)인데. O(N)보다 O(NlogN)이 더 크다. 

최종적으로 병합 정렬은 O(NlogN)의 시간복잡도를 가진다. 

4. [중요] 이제 직접 Merge Sort를 구현해보자. 

기본 틀 코드를 받아서 스스로 빈칸을 채워보자.

아래는 바킹독님의 정답 코드다. 

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

int n = 10;
int arr[1000001] = {15, 25, 22, 357, 16, 23, -53, 12, 46, 3};
int tmp[1000001]; // merge 함수에서 리스트 2개를 합친 결과를 임시로 저장하고 있을 변수

// mid = (st+en)/2라고 할 때 arr[st:mid], arr[mid:en]은 이미 정렬이 되어있는 상태일 때 arr[st:mid]와 arr[mid:en]을 합친다.
void merge(int st, int en){
  int mid = (st+en)/2;
  int lidx = st; // arr[st:mid]에서 값을 보기 위해 사용하는 index
  int ridx = mid; // arr[mid:en]에서 값을 보기 위해 사용하는 index
  for(int i = st; i < en; i++){
    if(ridx == en) tmp[i] = arr[lidx++];
    else if(lidx == mid) tmp[i] = arr[ridx++];
    else if(arr[lidx] <= arr[ridx]) tmp[i] = arr[lidx++];
    else tmp[i] = arr[ridx++];
  }
  for(int i = st; i < en; i++) arr[i] = tmp[i]; // tmp에 임시저장해둔 값을 a로 다시 옮김
}

// a[st:en]을 정렬하고 싶다.
void merge_sort(int st, int en){
  if(en == st+1) return; // 리스트의 길이가 1인 경우
  int mid = (st+en)/2;
  merge_sort(st, mid); // arr[st:mid]을 정렬한다.
  merge_sort(mid, en); // arr[mid:en]을 정렬한다.
  merge(st, en); // arr[st:mid]와 arr[mid:en]을 합친다.
}

int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);
  merge_sort(0, n);
  for(int i = 0; i < n; i++) cout << arr[i] << ' '; // -53 3 12 15 16 22 23 25 46 357
}

 

merge함수에서 두 리스트를 합친 결과를 임시롤 저장할 곳이 필요해서 tmp 변수가 필요하다. 

merge_sort함수에서 배열의 크기가 1일 때는 아무것도 안해도 정렬이 완료된다.

이후에는 재귀적으로 절반을 나눠 각각 정렬이 되게 한다. 

 

아직 헷갈리더라도 이건 어려운 내용인것이 맞고 시간과 반복시도가 해결해주는 문제니까 계속 가면 된다.

백준 2751번 문제도 풀어보자. (바킹독님 2751번 정답코드

5. [중요] 병합 정렬의 Stable Sort 성질 

사람들을 나이 순으로 정렬하는 상황이 있다. 

검정셔츠 입은 사람만 38살이고, 나머지 색깔의 사람들은 21살이다. 나이순으로 생각하면 3가지가 모두 올바른 정렬이 맞다.

이렇게 우선 순위가 같은 원소가 여러 개일 땐 정렬 결과가 유일하지 않다!

그런데 그 중에서 우선순위가 같은 원소들 끼리는 원래의 순서를 따라가도록 하는 정렬이 Stable Sort 이다.

즉, 정렬 전에 나이가 21살인 사람의 순서가 파랑, 빨강, 초록이니까. 정렬 후에도 이 순서를 같게 두는 것이다. 

 

병합정렬은 Stable Sort인데. 이 성질을 만족시키려면, a와 b의 우선순위가 같을 때, a를 먼저 선택해줘야 한다. 

아까 병합 정렬은 구현했을 때 Stable Sort성질이 드러난 코드다.

  for(int i = st; i < en; i++){
    else if(arr[lidx] <= arr[ridx]) tmp[i] = arr[lidx++]; 
    // <-- arr[lidx] == arr[ridx]인 경우처럼 우선순위가 같다면? 앞에 있는 lidx를 먼저 선택해준다!
  }

Stable Sort를 응용해보자.

1) 파일을 정렬하는 경우 

조건: 파일을 크기 순으로, 크기가 동일하다면 먼저 생성된 순으로 정렬하자. 

병합 정렬은 Stable Sort임을 기억하자!

따라서 생성 시간은 신경쓰지 않고, 파일을 크기를 기준으로 정렬하면 조건을 만족시킬 수 있다. 

 

2) 2차원 좌표를 정렬하는 경우 

조건: x가 작은 순으로, x가 동일하다면 y가 작은 순으로 정렬하자. 

먼저 y 크기를 기준으로 병합 정렬을 수행하고, 그 다음에 x크기를 기준으로 병합 정렬을 수행하면 된다. 

이렇게 하면 Stable Sort성질 때문에 일단 x가 작은게 앞으로 오게 되기 때문이다. 

그리고 이 상태에서 x가 같다면, x를 정렬하기 전에 앞에있었을 원소, 즉 y가 작은 원소가 앞에 온다. 

 

다만, 2차원 좌표의 정렬은 병합 정렬2번이 아니라 a[aidx]와 b[bidx]를 비교하는 부분을 살짝 바꾸면 1번 정렬로도 해결된다. 


0x02 Quick Sort

퀵 소트는 거의 모든 정렬 알고리즘 보다 빠르다. 

[참고]

만약, 코딩테스트에서 STL을 못 쓰고 직접 정렬을 구현해야 한다면, 퀵 소트를 쓰지 말고, 머지 소트를 써야 한다

1. 퀵 소트는 머지 소트 처럼 재귀적으로 구현되는 정렬이다. 

매 단계마다 pivot이라는 원소를 알맞은 자리로 보내는 작업을 반복한다. 

아래 조건을 만족하면 pivot은 올바른 자리에 있는 것이다. 
1) pivot 왼쪽은 전부 pivot 보다 작은 원소들 
2) pivot 오른쪽은 전부 pivot보다 큰 원소들 

퀵 소트의 장점은 추가적인 공간이 필요 없다는 점이다.

배열 안에서 포인트 변수2개를 이용해 자리 바꿈만으로 처리할 수 있다. (그래서 cache hit rate가 높아서 속도 빠름)

참고로 이렇게 추가적인 공간이 필요 없는 정렬을 In-Place Sort라고 부른다. 

pivot을 제외하고, 양쪽 끝에 포인터를 두고 적절하게 스왑하자. 

우선 l, r 이라는 포인터 2개를 둔다. pivot은 처음에 0번째 원소로 정한다. 

l은 1번째에서 오른쪽으로 이동하고, r은 맨 마지막 인덱스에서 왼쪽으로 이동한다. 

l은 pivot보다 큰 값이 나올때 까지 오른쪽으로 이동한다. r은 pivot보다 작은 값이 나올 때 까지 왼쪽으로 이동한다. 

그 다음 두 포인터가 가리키는 원소의 값을 스왑한다. l은 12에 도달했다. r은 -7에 도달했다. 

또 다시 l을 이동시키면 6보다 큰 8에 도달한다. r은 6보다 작은 3에 도달한다. 

그러면 3과 8을 스왑한다. 

l을 옮기려고 보니 r이 l보다 왼쪽에 와있다!  이렇게 r이 l보다 작아진 순간에는 멈춰서 pivot을 r과 스왑하면 된다. 

여기까지가 pivot하나를 올바른 자리에 두는 과정이다. 

이 방법이 왜 pivot을 제자리로 보내는지 이해하려면

"모든 순간에 l의 왼쪽에는 pivot보다 작거나 같은 원소들만 있고, r의 오른쪽에는 pivot보다 큰 원소들만 있다"는 점만 주목하면 된다. 

2. 퀵 소트를 구현한 코드 

인덱스 l 은 pivot보다 큰 원소를 찾을 때 까지 이동한다. 

인덱스 r 은 pivot보다 작은 원소를 찾을 때 까지 이동한다. 

만약 l 과 r이 타겟을 못찾으면 l > r 이 된다. 인덱스 l과 r이 역전되버린다. 이 때는 break 로 탐색을 멈춘다. 

l 과 r이 타겟을 찾으면, swap(arr[l], arr[r]) 스왑 한다! 

스왑이 종료되면, 인덱스 r과 인덱스 pivot을 스왑한다. 

그리고  pivot을 기준으로 왼쪽과 오른쪽을 보면 pivot이 올바른 자리에 있음을 확인할 수 있다. 

나머지 부분은 재귀호출해서 정렬시킨다. 

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

int n = 10;
int arr[1000001] = {15, 25, 22, 357, 16, 23, -53, 12, 46, 3};

void quick_sort(int st, int en) { // arr[st to en-1]을 정렬할 예정
  if(en <= st+1) return; // 수열의 길이가 1 이하이면 함수 종료.(base condition)
  int pivot = arr[st]; // 제일 앞의 것을 pivot으로 잡는다. 임의의 값을 잡고 arr[st]와 swap해도 상관없음.
  int l = st+1; // 포인터 l
  int r = en-1; // 포인터 r
  while(1){
    while(l <= r && arr[l] <= pivot) l++;
    while(l <= r && arr[r] >= pivot) r--;
    if(l > r) break; // l과 r이 역전되는 그 즉시 탈출
    swap(arr[l], arr[r]);
  }
  swap(arr[st], arr[r]);
  quick_sort(st, r);
  quick_sort(r+1, en);
}

int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);
  quick_sort(0, n);
  for(int i = 0; i < n; i++) cout << arr[i] << ' ';
}

 

3. 퀵 소트의 시간복잡도 

pivot 하나를 올바른 자리에 보낼 때 마다 상수번의 연산으로 l과 r이 한 칸씩 가까워진다.

따라서 한 세트의 시간복잡도는 O(N)이다

재귀 종료 조건인 base condition은 구간의 길이가 1 이하일 때다. pivot을 제자리로 보낸 뒤에, 재귀적으로 자기 자신을 호출한다.

pivot 하나를 올바른 자리에 보내는 세트가  O(N)이 걸리는데. 

pivot 을 하나씩 제외하면 N, N-1, N-2 ... 번의 연산이 필요하다. 

4. 퀵 소트의 단점? 

만약 pivot이 매번 완벽하게 중앙에 위치한다면?

리스트를 균등하게 반으로 쪼개서 재귀를 호출하게 된다. 

이 때는 n, n/2, n/4 ... 번의 연산이 필요할 것이다. 그러면 머지 소트때와 같이 세트마다 O(logN)이 걸린다. 

한 세트의 연산횟수 N과 재귀 호출 연산횟수 logN을 곱하면 된다. 

 

물론 항상 pivot이 완벽하게 중앙에 오지 않겠지만 대략 시간복잡도는 O(NlogN)이 된다. 

그리고 앞서 말한 cache hit rate가 높아서 퀵 소트는 속도가 굉장히 빠르다.

 

{1,2,3,4,5,6,7,8} 을 퀵 소트로 정렬하면 시간 복잡도가 얼마일까? 

pivot은 항상 중앙에 있는게 아니라. 제일 왼쪽에 위치하게 된다. 

그래서 한 단계마다 longN개가 아니라 N개를 확인해야 한다. -> 시간복잡도는 O(N^2)이 된다. 

퀵 소트는 평균적으로 O(NlogN)이지만 최악의 경우 O(N^2)이다. 

단순히 제일 왼쪽 값을 pivot으로 선택해보면 지금처럼 리스트가 오름차순 이거나 내림차순일 때 O(N^2)이 된다. 

 

[참고]

STL을 못 쓰는 경우, 정렬을 직접 구현해야 한다면 퀵 소트를 쓰는게 아니라 머지 소트를 구현하라고 한 이유가 여기에 있다. 

최악의 경우 O(N^2)의 시간복잡도인 퀵 소트를 쓸 필요가 없다. 

하지만 대부분의 정렬 라이브러리는 퀵 소트를 쓰는 것도 사실이다. 

pivot을 '잘'고르는 것이 관건인데. 랜덤으로 선택하거나. 후보 3개를 정해서 중앙값을 선택하기도 한다. 

최악의 경우에도 O(NlogN)을 보장하기 위해 일정 깊이 이상의 재귀가 호출되면 힙 소트로 정렬한다.

재귀가 과도하게 호출되면 오버헤드 때문에 성능저하가 오는 데 이를 피하기 위함이다. 이러한 정렬을 Introspective sort라고 한다. 


5. 머지 소트 VS 퀵 소트 

머지소트와 퀵 소트는 둘 다 재귀적 구조로 구현된다. 

먼저 시간복잡도를 보면 머지소트는 무조건 O(NlogN)이고. 퀵 소트는 최악에 O(N^2), 평균적으로 O(NlogN)이다. 

평균적으로 (이름값하는) 퀵 소트가 빠르다. 

퀵 소트는 In-Place Sort 인 점을 기억하자. 

그리고 머지 소트는 우선 순위가 같은 원소들끼리의 원래 순서가 유지되는 Stable Sort이지만 퀵 소트는 아니다. 


정렬1 문제집

백준 문제 내 코드 
2750 수 정렬하기  2750 내 코드
2751 수 정렬하기 2 2751 내 코드(머지소트 구현)
10989 수 정렬하기 3 10989 내 코드 (메모리 제한 주의 )
11931 수 정렬하기 4 11931 내 코드(머지소트 구현)
15688 수 정렬하기 5 15688 내 코드 (머지소트 구현)
10814 나이순 정렬 10814 내 코드
11650 좌표 정렬하기 11650 내 코드
11651 좌표 정렬하기 2 11651 내 코드

 


다음 시간에는 정렬2(Counting Sort, Radix Sort)를 배운다. 

공부 자료 출처 : 바킹독 알고리즘 정렬1

728x90

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

0x10강 - 다이나믹 프로그래밍  (0) 2021.12.19
0x0F강 - 정렬2  (0) 2021.12.18
0x0C강 - 백트래킹  (0) 2021.12.10
0x0B강 - 재귀  (0) 2021.12.08
0x0A강 - DFS  (0) 2021.12.07

목차 

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

목차 

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

목차

0x00 DFS 알고리즘 설명 

0x01 예시 

0x02 BFS vs DFS 


0x00  DFS 알고리즘 

DFS (Depth First Search)

다차원 배열에서 각 칸을 방문할 때 깊이를 우선으로 방문하는 알고리즘 

BFS (Breadth First Search)

다차원 배열에서 각 칸을 방문할 때 너비를 우선으로 방문하는 알고리즘

 

0x01 DFS 예시

BFS에서는 queue를 사용해서 모든 칸을 검사했었다. 

DFS는 큐 대신 stack 을 쓰는 것 뿐이고 나머지는 BFS와 똑같다. 

 

(0,0)에서 시작하여 상하좌우로 인접한 같은 색의 칸을 방문하는 Flood Fill을 구현해보자. 

아래는 좌표 배열이 있고, DFS를 위한 stack이 있다. 

탐색 흐름을 그림으로 확인하자. 

1. (0,0)시작점에 방문표시를 하고, stack에 push한다

스택이 빌 때까지 계속 스택의 top을 확인하여 상하좌우를 살펴보며 미방문한 좌표를 스택에 푸시하는 작업을 반복한다. 

2. 현재 스택의 top은 (0,0)이다.

(0,0)을 pop()해서 (0,0)의 상하좌우를 확인한다. 

파란 칸이면서 방문하지 않은 좌표를 스택에 push한다. -> (1,0)과 (0,1)을 push

3. 현재 스택의 top은 (1,0)이다.

(1,0)을 pop() 해서 (1,0)의 상하좌우를 확인한다. 

(0,0)은 이미 방문했고, (1,1)은 못간다. (2,0)은 방문하지 않은 칸이다. 

(2,0)에 방문표시를 하고 stack에 push한다. 

 

4. 이제 스택의 top은 (2,0)이 된다. (2,0)을 pop한다. 

전체 과정 그림으로 보기

 

0x02 BFS vs DFS 

BFS는 queue를 쓰고 DFS는 stack을 쓴다는 차이가 있지만, 원소 하나를 빼내서 주변을 살핀다는 알고리즘의 흐름은 똑같다. 

이 그림에서 관찰할 점은 방문순서다.

 

BFS

중앙의 1번 칸을 중심으로 주위를 보면, 1방문 후에 상하좌우 2,3,4,5번 방문한다.

마치 냇가에 던진 돌로 인해 동심원이 생기는 것처럼 상하좌우로 퍼져나간다. 

이전 강의에서 배운 '거리 순으로 방문한다'는 성질을 확인할 수 있다. 

 

DFS

갈 수 있을 때 까지 간다.

가능한 깊게 직진하는 모양이다. depth 우선 탐색이라는 성질을 그림으로 확인할 수 있다. 

거리 잴 때 DFS? BFS?

Flood Fill을 구현할 때 DFS, BFS 무엇을 쓰든 상관은 없다.

하지만 BFS에서 거리 잴 때 유용하게 썼던 "현재 보는 칸으로부터 추가되는 인접한 칸은 거리가 현재보다 1만큼 떨어져있다"는 성질이 DFS에서는 성립하지 않는다. 

결국 다차원 배열에서 굳이 DFS를 쓸 일이 없다. 거리측정은 BFS만 가능하기 때문이다. 

DFS는 그래프와 트리라는 자료구조를 쓸 때 짜게 된다. 

이 수업에서는 DFS는 stack을 써서 다차원배열을 순회하는 알고리즘 이라고만 기억하고 넘어가자. 


다음 시간에는 재귀를 공부한다. 

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

728x90

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

0x0C강 - 백트래킹  (0) 2021.12.10
0x0B강 - 재귀  (0) 2021.12.08
0x09강 - BFS  (0) 2021.12.07
0x08강 - 스택의 활용(수식의 괄호 쌍)  (0) 2021.11.29
0x07강 - 덱  (0) 2021.11.24

목차

0x00 BFS 알고리즘 설명 

0x01 예시 

0x02 응용1 - 거리 측정 

0x03 응용2 - 시작점이 여러 개일 때 

0x04 응용3 - 시작점이 두 종류일 때 

0x05 응용4 - 1차원에서의 BFS 


0x00 BFS 알고리즘 

Breadth-First Search 너비 우선 탐색.

다차원 배열에서 각 칸을 방문할 때 너비를 우선으로 방문한다. 

 

하나의 노드를 기준으로, 

깊이가 1인 모든 노드를 방문한다.

깊이가 2인 모든 노드를 방문한다. 

깊이가 3인 모든 노드를 방문한다. 

빠짐 없이 모든 노드를 방문하면, 탐색을 마친다. (예시 그림에서 다시 이해해보자)

 

특징 

재귀적으로 동작하지 않는다. (반면, DFS는 재귀적으로 동작한다)

노드의 방문 여부를 반드시 검사한다. 이를 검사하지 않으면 무한 루프에 빠진다. 

 

0x01 BFS 예시 

목표: (0,0)과 상하좌우로 인접한 모든 파랑 칸을 확인하여 개수를 세자. 

이를 BFS로 풀어본다. BFS는 큐가 필요하다.

BFS알고리즘에서는 좌표를 담을 가 필요하다. 그래서 좌표 옆에 있는 작대기는 큐를 표현한 것이다. 

 

1. (0,0)에 방문했다는 표시(검정색 점)를 남기고, 큐에 (0,0)를 넣는다.  

이 초기 세팅이 끝난 후에는, 큐가 빌 때까지 계속 큐의 front()를 pop()한다. 

front()에서 pop()한 좌표의 상하좌우를 살펴보면서 큐에 넣어주는 작업을 반복하게 된다. 

상하좌우 확인하고 유효좌표인지 방문했었는지 검사한다 

2. 큐에서 뺀 (0,0)의 상하좌우를 확인한다.

그림에서 현재 위치한 칸은 빨강테두리고, 상하좌우 확인하고 있는 칸은 검정 테두리다. 

상하좌우 중에서 (0,1) 과 (1,0)이 유효한 좌표이다. 

파랑색 칸이면서 아직 방문하지 않은 칸(검정 점 표시가 없는 칸)을 큐에 넣자. 

-> (0,1) 과 (1,0) 을 큐에 넣는다. 이제 큐에는 좌표가 2개 쌓였다. 

이미 방문했던 곳이면 지나간다 

3. 다음으로 넘어간다. 큐의 front()는 (0,1)이다.

(0,1)을 pop()뺀다.  참고로 (0,1)에서 (행,열)순이다. 

(0,1)의 상하좌우를 확인하여 방문하지 않고, 파란칸인 것을 큐에 push한다. 

(0,2)는 파란칸이면서 방문하지 않은 칸이니까 방문표시를 남기고 큐에 넣는다. 

 

전체 과정은 여기를 참고하자. 

 

4. 큐가 빈 순간,탐색이 종료된다. 아래는 BFS 과정을 정리한 것이다. 

BFS의 시간 복잡도 

방문 표시를 남기기 때문에 모든 칸은 큐에 1번씩 들어간다. 

그러므로 시간 복잡도는 칸이 N개일 때 O(N)이 된다. 만약 행이 R개이고 열이 C개이면 O(RC)가 된다. 

 

STL pair : 좌표 저장에 유용함

큐에 좌표를 넣을 때 pair로 저장하면 유용하다. 

레퍼런스 링크 

 

방향 배열

상하 좌우를 탐색할 때, 아래의 방향 배열 dx, dy를 정의해두고 사용하면 편리하다. 

만약 3차원이 주어지고 위, 아래를 검사해야한다면, 여기에 dz를 추가하여 x,y,z를 더하고 빼면 된다. 

int dx = {0, 1, 0, -1};
int dy = {1, 0, -1, 0};

코드의 depth 깊게 하지 않기

유효좌표 검사 등 여러 조건 확인 시 continue를 써서 코드의 depth를 덜 깊어지게 하자. 가독성을 위함. 

BFS 기본 코드

#include <bits/stdc++.h>
using namespace std;
#define X first
#define Y second // pair에서 first, second를 줄여서 쓰기 위해서 사용
int board[502][502] =
{{1,1,1,0,1,0,0,0,0,0},
 {1,0,0,0,1,0,0,0,0,0},
 {1,1,1,0,1,0,0,0,0,0},
 {1,1,0,0,1,0,0,0,0,0},
 {0,1,0,0,0,0,0,0,0,0},
 {0,0,0,0,0,0,0,0,0,0},
 {0,0,0,0,0,0,0,0,0,0} }; // 1이 파란 칸, 0이 빨간 칸에 대응
bool vis[502][502]; // 해당 칸을 방문했는지 여부를 저장
int n = 7, m = 10; // n = 행의 수, m = 열의 수
int dx[4] = {1,0,-1,0};
int dy[4] = {0,1,0,-1}; // 상하좌우 네 방향을 의미
int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);
  queue<pair<int,int> > Q;
  vis[0][0] = 1; // (0, 0)을 방문했다고 명시
  Q.push({0,0}); // 큐에 시작점인 (0, 0)을 삽입.
  while(!Q.empty()){
    pair<int,int> cur = Q.front(); Q.pop();
    cout << '(' << cur.X << ", " << cur.Y << ") -> ";
    for(int dir = 0; dir < 4; dir++){ // 상하좌우 칸을 살펴볼 것이다.
      int nx = cur.X + dx[dir];
      int ny = cur.Y + dy[dir]; // nx, ny에 dir에서 정한 방향의 인접한 칸의 좌표가 들어감
      if(nx < 0 || nx >= n || ny < 0 || ny >= m) continue; // 범위 밖일 경우 넘어감
      if(vis[nx][ny] || board[nx][ny] != 1) continue; // 이미 방문한 칸이거나 파란 칸이 아닐 경우
      vis[nx][ny] = 1; // (nx, ny)를 방문했다고 명시
      Q.push({nx,ny});
    }
  }
}

[중요] BFS 구현 시 자주 하는 실수

1. 시작점에 방문했다는 표시를 남기지 않는다. 
방문 배열에 꼭 방문 표시를 남겨야 재방문 하지 않고 무한루프로 빠지지 않는다. 

2. 큐에 넣을 때 방문했다는 표시를 하는 대신 큐에서 빼낼 때 방문 했다는 표시를 남겼다.
같은 칸이 큐에 여러번 들어가서 시간초과나 메모리 초과가 날 수 있다. 

3. 이웃한 원소가 유효범위를 벗어났는지에 대한 체크를 잘못했다. 
배열 인덱스가 음수가 되거나 유효범위를 넘어가면 런타임 에러가 난다. 

0x01 예시 문제 : 기본 BFS 

BFS 기본 예제를 풀어본다.  백준 1926번 그림

해결 과정 

1. 이중 for문으로 그림이 시작되는 칸에서 BFS를 시작한다.

2. 그림이 연결된 칸이 몇개인지 알려면? 큐에서 pop을 몇 번 하는지 센다. 그림 칸이지만, 이미 방문한 칸이라면 BFS를 시작할 수 없다. 

결국 파란 칸은 큐에 딱 한번씩만 들어가니까 시간 복잡도는 칸의 갯수만큼 O(nm)이다. 

 

0x02 응용1 - 거리 측정

백준 2178번 미로탐색

미로 좌측 상단으로부터 우측 하단으로 가는 최단 경로의 길이를 찾는 문제다. 

BFS를 이용해 시작점에서 연결된 다른 모든점으로의 최단 경로를 찾을 수 있다. 

이번에는 방문했다는 표시 대신에, 각 칸들에 (0,0)까지의 거리를 저장한다. 

 

미로를 저장하는 배열을 미로 -1로 초기화해두면 굳이 방문배열을 두지 않아도 방문여부를 확인할 수 있다. 

// http://boj.kr/cd14bec9ecff461ab840f853ed0eb87f
#include <bits/stdc++.h>
using namespace std;
#define X first
#define Y second
string board[102];
int dist[102][102];
int n,m;
int dx[4] = {1,0,-1,0};
int dy[4] = {0,1,0,-1};
int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);
  cin >> n >> m;
  for(int i = 0; i < n; i++)
    cin >> board[i];
  for(int i = 0; i < n; i++) fill(dist[i],dist[i]+m,-1);  // -1로 초기화한다 
  queue<pair<int,int> > Q;
  Q.push({0,0});
  dist[0][0] = 0;
  while(!Q.empty()){
    auto cur = Q.front(); Q.pop();
    for(int dir = 0; dir < 4; dir++){
      int nx = cur.X + dx[dir];
      int ny = cur.Y + dy[dir];
      if(nx < 0 || nx >= n || ny < 0 || ny >= m) continue; // 유효범위 확인 
      if(dist[nx][ny] >= 0 || board[nx][ny] != '1') continue; // 이미 방문했는지. 벽이 아닌지.
      dist[nx][ny] = dist[cur.X][cur.Y]+1;
      Q.push({nx,ny});
    }
  }
  cout << dist[n-1][m-1]+1; // 문제의 특성상 거리+1이 정답
} // 바킹독님 코드

 

0x03 응용2 - 시작점이 여러 개일 때 

백준 7576번 토마토

토마토가 익어가는 것이 익은 것을 중심으로 상하좌우로 퍼져간다. 

토마토가 다 익기까지 필요한 최소 일 수를 구하려면? 

-> 모든 익지 않은 토마토들에 대해 가장 가깝게 위치한 익은 토마토까지의 거리를 구해야한다. 익지 않은 토마토가 있다면 -1 출력.

BFS의 시작점은 '익은 토마토'다. (초록칸)
따라서 BFS의 시작점은 여러개의 익은 토마토 좌표들이다.  

모든 익은 토마토의 좌표를 큐에 전부 넣는다. 

그리고 BFS를 시작한다. 

 

코드를 보며 이해해보자. 

// http://boj.kr/ae38aa7eb7a44aca87e9d7928402d040 
#include <bits/stdc++.h>
using namespace std;
#define X first
#define Y second
int board[1002][1002];
int dist[1002][1002];
int n,m;
int dx[4] = {1,0,-1,0};
int dy[4] = {0,1,0,-1};
int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);
  cin >> m >> n;
  queue<pair<int,int> > Q;
  for(int i = 0; i < n; i++){
    for(int j = 0; j < m; j++){
      cin >> board[i][j];
      if(board[i][j] == 1) // 익은 토마토는 시작점이니까 큐에 넣는다. 
        Q.push({i,j});
      if(board[i][j] == 0) // 아직 안익은 토마토는 -1 로 표시한다. 
        dist[i][j] = -1;
    }
  }
  while(!Q.empty()){
    auto cur = Q.front(); Q.pop();
    for(int dir = 0; dir < 4; dir++){
      int nx = cur.X + dx[dir];
      int ny = cur.Y + dy[dir];
      if(nx < 0 || nx >= n || ny < 0 || ny >= m) continue; // 유효한 범위인지? 
      if(dist[nx][ny] >= 0) continue; // 이미 방문.
      dist[nx][ny] = dist[cur.X][cur.Y]+1;
      Q.push({nx,ny});
    }
  }
  int ans = 0;
  for(int i = 0; i < n; i++){
    for(int j = 0; j < m; j++){
      if(dist[i][j] == -1){ // 아직 익지않은 토마토가 있으면? 실패. 
        cout << -1;
        return 0;
      }
      ans = max(ans, dist[i][j]); // 가장 먼 거리.
    }
  }
  cout << ans;
}

해결 과정 

1. 입력 받으면서 익은토마토만 큐에 넣는다. 

2. 익지 않은 토마토는 dist 배열값을 -1로 둔다. 토마토가 없는 빈칸은 dist 배열 값이 0이다. 

3. BFS를 돌면서 -1인 칸(아직 익지 않은 토마토)의 거리를 갱신해준다. 

4. 마지막에서, 거리가 가장 먼 것을 찾는다. 

 

 

참고) 큐에 쌓이는 거리 

1. 처음 입력을 받을 때, 익은 토마토들은 거리가 0이다. (시작점은 거리가0) 

2. 시작점 주변에서 익혀진 토마토는 거리가 1이 된다. 

3. 1인 칸들을 pop하는 동안 거리가 2인 칸들이 push된다. 

4. 거리가 2인 칸들을 검사하는 동안 거리가 3인 칸들이 push된다. 

즉, 전체적인 큐의 모양을 보면 '큐에 쌓이는 순서는 반드시 거리 순'이게 된다. 

 

이 문제는 2차원  토마토였고, 3차원 토마토 문제도 풀어보자. 


0x04 응용3 - 시작점이 두 종류일 때

백준 4179번 불!

불과 지훈이가 동시에 움직인다. 문제를 읽어보면 막막할 수 있지만 지금 배운 BFS응용이니까 고민해보자. 

지훈이가 이동하다가 미로 맨 가장자리에 도착하면 탈출시켜야 한다. 

 

결론부터 말하면, 불의 BFS와 지훈이의 BFS를 모두 돌림으로써 해결할 수 있다. 

파랑칸은 지훈이 위치고, 빨강칸은 불의 위치다. 

해결 과정 

1. 먼저 지훈이는 신경쓰지 말고, 불의 BFS를 돌려서 미리 각 칸에 불이 전파되는 시간을 구해둔다. 

불 시간을 저장할 배열을 사용한다. 

위 그림에서 '두 번째 맵'이 각 칸에 불이 전파 시간을 의미한다. 

 

2. 그 다음에 지훈이에 대한 BFS를 돌려서 지훈이를 이동시킨다. 

지훈이 이동시간을 저장할 배열을 사용한다. 

이 때, 만약 지훈이가 특정 칸을 x시간에 최초로 방문할 수 있는데, 그 칸에 x시간이나 그 이전에 불이 붙는다면? 그 칸에 못가게 된다. 

 

3. 여태 까지 BFS를 풀 때의 방문 여부 방법은?

큐 안에서 (nx, ny)를 검사할 때 방문여부를 vis[nx][ny]가 true인지 혹은 dist[nx][ny]가 0이상인지 확인했었다. 

 

이 문제에서는 추가로 해당 칸에 불이 붙는 시간을 확인해야 한다. 

바킹독님 불! 코드 

// http://boj.kr/aed4ec552d844acd8853111179d5775d
#include <bits/stdc++.h>
using namespace std;
#define X first
#define Y second
string board[1002];
int dist1[1002][1002]; // 불의 전파 시간
int dist2[1002][1002]; // 지훈이의 이동 시간
int n, m;
int dx[4] = {1,0,-1,0};
int dy[4] = {0,1,0,-1};

int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);
  cin >> n >> m;
  for(int i = 0; i < n; i++){ // 모든 방문배열 -1로 초기화 하여 미방문 표시하기 
    fill(dist1[i], dist1[i]+m, -1);
    fill(dist2[i], dist2[i]+m, -1);    
  }
  for(int i = 0; i < n; i++)
    cin >> board[i];  
  queue<pair<int,int> > Q1;
  queue<pair<int,int> > Q2;
  for(int i = 0; i < n; i++){
    for(int j = 0; j < m; j++){
      if(board[i][j] == 'F'){ // 불이면 큐1에 푸시. 거리도 배열1에 0초기화.
        Q1.push({i,j});
        dist1[i][j] = 0;        
      }
      if(board[i][j] == 'J'){ // 지훈이 시작점은 큐2에 푸시. 거리도 배열2에 0초기화.
        Q2.push({i,j});
        dist2[i][j] = 0;
      }
    }
  }
  // 불에 대한 BFS
  while(!Q1.empty()){
    auto cur = Q1.front(); Q1.pop();
    for(int dir = 0; dir < 4; dir++){
      int nx = cur.X + dx[dir];
      int ny = cur.Y + dy[dir];
      if(nx < 0 || nx >= n || ny < 0 || ny >= m) continue; 
      if(dist1[nx][ny] >= 0 || board[nx][ny] == '#') continue; // 벽이거나 이미 방문했으면 지나감
      dist1[nx][ny] = dist1[cur.X][cur.Y]+1;
      Q1.push({nx,ny});
    }
  }

  // 지훈이에 대한 BFS
  while(!Q2.empty()){
    auto cur = Q2.front(); Q2.pop();
    for(int dir = 0; dir < 4; dir++){
      int nx = cur.X + dx[dir];
      int ny = cur.Y + dy[dir];
      if(nx < 0 || nx >= n || ny < 0 || ny >= m){ // 범위를 벗어났다는 것은 탈출에 성공했다는 의미. 큐에 거리 순으로 들어가므로 최초에 탈출한 시간을 출력하면 됨.
        cout << dist2[cur.X][cur.Y]+1; 
        return 0;
      }
      if(dist2[nx][ny] >= 0 || board[nx][ny] == '#') continue;
      if(dist1[nx][ny] != -1 && dist1[nx][ny] <= dist2[cur.X][cur.Y]+1) continue; // 불의 전파 시간을 조건에 추가
      dist2[nx][ny] = dist2[cur.X][cur.Y]+1;
      Q2.push({nx,ny});
    }
  }
  cout << "IMPOSSIBLE"; // 여기에 도달했다는 것은 탈출에 실패했음을 의미.
}

 

0x05 응용4 - 1차원에서의 BFS 

백준 1697번 숨바꼭질

지금까지는 2차원 배열에서 상하좌우를 검사했었다. 

문제에서 수빈이가 이동하는 것을 보면, 현재 위치 x를 기준으로 x+1, x-1, x*2 의 위치로 이동할 수 있다. 

해결 과정 

1. 시작점이 5라면, BFS의 시작점을 5로 잡고 큐에 push()한다. 

 

2. 큐의 front()는 5다.

5에서 갈 수 있는 거리는 4, 6, 10이 되고 5에서 부터의 거리는 1이 된다. 

따라서 4, 6, 10에 1을 저장한다. 

 

3. 위치 4, 6, 10을 큐에 push() 한다. 

 

4. 큐의 front()는 4다. 

위치 4에서는 3, 5, 8에 갈 수 있다. 거리를 +1해서 거리2를 3,5,8에 저장한다. 

 

5. 이렇게 BFS를 진행 하다가 동생이 있는 위치를 만나면 거리를 출력한다. 

 

주의

-> 0부터 10만의 제한이 있다. 거리 이동범위가 *2가 제일크다. 아무리 멀리 가도 20만이 된다. 

 

바킹독님 숨바꼭질 코드


BFS 문제집

BFS부터는 코딩테스트에 정말 잘나오는 유형이기 때문에 문제집을 꼼꼼하게 풀고, 코드의 흐름을 익히자. 

 

백준 문제 내 코드 
그림  그림 내코드 
미로탐색 미로탐색 내코드
토마토 토마토 내코드
불! 불! 내코드
숨바꼭질 숨바꼭질 내코드
유기농 배추 유기농배추 내코드
적록색약 적록색약 내코드
토마토(3차원) 토마토 내코드
나이트의 이동 나이트의 이동 내코드
 
영역 구하기 영역 구하기 내코드
단지번호붙이기 단지번호붙이기 내코드
스타트링크 스타트링크 내코드
안전 영역 안전 영역 내코드
상범 빌딩 상범 빌딩 내코드
벽 부수고 이동하기  
텀 프로젝트  
빙산 빙산 내코드
다리 만들기  
숨바꼭질3 숨바꼭질3 내코드
말이 되고픈 원숭이  
숨바꼭질4  
확장 게임  
불켜기 불켜기 내코드
숨바꼭질5  
열쇠  

 


다음 시간에는 DFS를 배운다. 

그림 및 공부 자료 출처 : 바킹독 알고리즘 - BFS

728x90

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

0x0B강 - 재귀  (0) 2021.12.08
0x0A강 - DFS  (0) 2021.12.07
0x08강 - 스택의 활용(수식의 괄호 쌍)  (0) 2021.11.29
0x07강 - 덱  (0) 2021.11.24
0x06강 - 큐  (0) 2021.11.23

+ Recent posts