코딩테스트 코드 작성 팁

  • 코딩테스트와 개발은 다르다.

  • 남들이 보기 편한 클린코드가 아닌 가장 빠르게 풀 수 있도록 짜는 것이 훨씬 중요하다.

  • 출력 맨 마지막 공백 or 줄바꿈이 추가로 있어도 상관이 없다.

  • 디버거는 사용하지 않는다. 차라리 중간에 변수를 확인하고 싶다면 printf나 cout으로 확인한다. 

  • endl은 죽어도 쓰지 마라!!!
    endl은 개행문자('\n')을 출력하고 출력 버퍼를 비우라는 명령어이다. 
    온라인 저지 채점은 출력값만 확인하기 때문에 버퍼를 매번 비워줄 필요가 없다. (시간낭비)

  • #include<bits/stdc++.h> 선언
    vscode에서만 쓸 수 있는 구현에 필요한 헤더들을 다 인클루드해 놓은 헤더이다.
    #include<iostream>
    #include<string>
    #include<map>
    #include<set>
    #include<stack>
    #include<vector>
    #include<functional>
    #include<algorithm>

 

표준 입출력

scanf, printf에서 단 한가지 아쉬운 점이라면, C++ string을 처리할 수 없다는 것이다.

 

printf()로 string 출력 예시

#include<stdio.h>
#include<iostream>
using namespace std;

int main(){
    string s = "baaa";
    printf("s is %s\n", s);
}

//실행 결과 => s is ?

 

scanf, printf를 쓰면서 C++ string을 활용한 예시

  ☞ 일단 char*로 입력을 받고 string으로 형변환해서 원하는 작업을 다 끝낸 후 다시 c_str()을 이용해 출력

#include<stdio.h>

int main(){
    char a[10];
    printf("input : ");
    scanf("%s", a);
    string s(a);  // 혹은 string s = a;
    printf("a is %\n", a);
    printf("s is %s\n", s.c_str());
}

/*
실행 결과
input : test
a is test
s is test
*/

 

 

cin, cout을 쓸 때 주의사항

scanf, printf를 쓸 때는 신경쓰지 않아도 되는데 cin,cout을 쓸 때는 주의해야 할 부분이 있다.
scanf, printf와는 달리 cin, cout은 입출력으로 인한 시간초과를 막기 위해
ios::sync_with_stdio(0), cin.tie(0)이라는 두 명령을 실행시켜야 한다. 
위 두개를 실행해주지 않으면 입출력의 양이 많을 때 시간초과가 날 수 있다.

기본적으로 scanf, printf에 쓰는 C stream과 cin, cout에서 쓰는 C++ stream은 분리되어있다.

하지만 코드의 흐름과 실제 출력이 동일하도록 하기 위해 기본적으로 프로그램은 C와 C++ stream을
동기화하고 있다. 아래와 같은 코드가 순서대로 잘 출력되는 이유는 이처럼 동기화가 되어 있기 때문이다.

#include<stdio.h>
#include<iostream>
using namespace std;

int main(){
    cout << "11111\n";
    printf("22222\n");
    cout << "33333\n";
}

 

 

만약 내가 C++ stream만 쓸거라면 굳이 쓸데없는 시간을 잡아먹으면서 두 stream을 동기화 할 필요가 있을까?
=> 그래서 필요한 것이 sync_with_stdio(0)이다.

 

ios::syn_with_stdio(0)

  • C stream과 C++ stream의 동기화를 끊는다.
  • 이걸 사용하면 동기화를 끊었기 때문에 절대 cout과 printf()를 섞어쓰면 안 된다.
  • 동기화 작업이 없으므로 프로그램 수행 시간에서 이득
  • 인자를 엄밀히 하자면 bool type이라 false가 맞는데 0이 더 짧으니 0으로 써도 된다

 

스트림 버퍼

 

 

스트림은 키보드로 받은 입력을 바로 콘솔에 넘기지 않고 버퍼에서 어느 정도 모았다가 보낸다.

하지만 입력과 출력이 번갈아 가면서 나오고 그 값들이 화면에 다 보여질 경우 버퍼의 존재로 인해 화면에 출력되는
순서가 꼬일 수 있다. 
=> 그래서 필요한 것이 cin.tie(0)이다.

오른쪽 콘솔 => 출력 순서가 꼬인 경우

 

#include<iostream>
using namespace std;

int main(){
    for(int i = 0; i < 3; i++){
    	int a,b;
        cin >> a >> b;
        cout << a + b << "\n";
    }
}

위 코드를 보면 3번에 걸쳐 두 수를 입력받고 합을 출력한다. 우리는 당연히 순서대로 출력되길 바란다.
하지만 입력 두개를 받은 후 바로 출력되지 않고 버퍼에 들어있다가 다음 숫자 2개를 입력한 후에 출력이 되버리면
순서가 꼬일 것이다. 이런 현상을 막기 위해 프로그램은 기본적으로 cin 명령을 수행하기 전 cout 버퍼를 자동으로
비워준다. 하지만 온라인 저지 사이트의 채점 형태는 입력이랑 출력이 순서가 꼬이게 콘솔에 나타나더라도 출력들만
확인하기 때문에 틀리지 않는다. 그래서 cin 명령을 수행하기 전에 cout버퍼를 비울 필요가 없다. 
이 때 cout버퍼를 비우지 않도록 하는 코드가 cin.tie(0)인 것이다.

 

 

STL을 함수 인자로 넘길 때

#include<iostream>
using namespace std;

void func1(vector<int> v){
	v[10] = 7;
}

int main(){
    vector<int> v(100);
    func1(v);
    cout << v[10];
}

STL을 함수 인자로 그대로 보내면 복사본을 만들어서 보낸다. 그렇기 때문에 원본에 영향이 없다.

 

또 다른 경우를 보자.

bool cmp1(vector<int> v1, vector<int> v2, int idx){
	return v1[idx] > v2[idx];
}

위의 cmp1()의 시간 복잡도는 어떻게 될까? 바로 O(N)이다. 응? 함수 내부에서 연산이 1번 뿐인데 어떻게 O(N)이냐고
할 수 있는데, 그것은 인자를 보낼 때 원본의 복사본을 만드는 비용을 간과한 것이다.
=> 즉, 위에서 v1의 원소는 n개이고 v2의 원소도 n개라고 한다면 총 2n만큼 복사되므로 시간복잡도가 O(N)인 것이다.

 

이런 경우 STL을 참조를 사용해 함수 인자로 전달하면 시간낭비 문제가 해결된다.

bool cmp2(vector<int>& v1, vector<int>& v2, int idx){
	return vi[idx] > v2[idx];
}

 이렇게 참조로 전달하면 시간복잡도가 O(1)이 된다.

 

 

예시) 전달받은 두 수의 값을 바꾸는 함수

 

시간복잡도

주어진 입력 N의 허용 시간복잡도

 

공간복잡도

  • 입력의 크기와 문제를 해결하는데 필요한 공간의 상관관계

예를 들어 크기 N짜리 2차원 배열이 필요하면 O(N²)이고, 따로 배열이 필요 없으면 O(1)이 된다.
하지만 코딩테스트에서 대부분의 경우 공간복잡도의 문제보다는 시간복잡도 때문에 문제를 많이 틀린다.
그러므로 공간복잡도는 크게 신경쓰지 않아도 된다.

 

But!! 이거 하나만 기억해두자 
메모리 제한이 512MB일 때 1.2억개의 int를 사용할 수 있다는 것

 

 

Integer Overflow

 

Integer Overflow가 발생하는 경우

#include<iostream>
using namespace std;

int main(){
    int a = 2000000000 * 2;
    cout << a; // -294967296
}

약 21억정도 까지만 표현할 수 있으므로 40억이라는 값은 a에 저장이 안 된다.
하지만 -294967296 같은 이상한 값은 어떻게 해서 나오게 된 것일까?

 

이유 : 컴퓨터는 그냥 시킨대로만 계산을 하기 때문이다.

 

integer overflow를 막는 방법은 각 자리에 맞는 값을 가지게끔 연산을 시키면 된다. 하지만 실제로 코드를
짜 보면 integer overflow는 아주 빈번하고 찾기도 정말 힘들다.

 

 

실수를 이진수로 표현하는 방법 

3을 이진수로 나타내면 11(₂) 이다. 이렇게 정수를 이진수로 바꾸는 방식을 실수에도 적용하여 실수를 이진수로 바꿀 수있다.

 

실수의 이진수 표현

예를 들어 3.75를 이진수로 나타내고 싶다. 어떻게 해야 할까?

3.75  =  2  +   1   +   0.5   +  0.25  =  2^1 + 2^0 + 2^(-1) + 2^(-2) = 11.11(₂) 
위와 같이 바꿀 수 있다. 즉, 실수는 이진수로 바꿀 때 0.5, 0.25, 0.125. . . 등의 합으로 나타낸다.

 

이진수에서도 무한소수가 나타난다.
= 2^(-2) + 2^(-4) + 2^(-6). . . = 0.010101. . .(₂) 이런식으로 끝없이 이어지는 무한소수가 발생한다.

0.1도 0.5, 0.25, 0.125. . . 등을 아무리 조합해봐야 나오지 않기 때문에 무한소수로써 끝없이 이어진다.

 

 

실수가 저장되는 방식

 

우선 이걸 확인하고 가자.

3561.234 = 3.561234 x 10³ 로 바꿀 수 있듯이
11101.001(₂) = 1.1101001(₂) x 2⁴ 로 바꿀 수 있다.

 

실수는 float와 double에 담을 수 있다. float는 32비트, double은 64비트이다.

-6.75 = -1.1011(₂) x 2² 
위 실수를 저장해보자.

sign field

  • 해당 수가 음수인지 양수인지 저장하는 필드 ( 음수면 1, 양수면 0 )

exponent field

  • 과학적 표기법에서의 지수를 저장하는 필드 ( 2²가 뒤에 붙었으므로 이 필드에는 2가 저장됨)
  • 지수값을 그대로 넣는 것이 아닌 지수값 2에 127을 더한 값을 여기에 저장한다.
    127을 더하는 이유는?  이렇게 해야 음수 지수승도 exponent 필드 안에 넣을 수 있기 때문 

fraction field

  • 유효숫자 부분을 저장하는 필드
  • -1.1011(₂) x 2²에서 유효숫자 부분은 1.1011인데 맨 앞 1은 뺀 1011이 여기에 저장된다.
    맨 왼쪽부터 2^(-1), 2^(-2) . . . 자리인 셈이라 필드에는 1011000. . . 000이 적힌다.
    즉, 맨 왼쪽부터 비트를 채운다.

이런 식으로 실수를 저장하는 방식을 IEEE-754 format이라고 한다.

 

 

실수의 성질

1. 실수의 저장/연산 과정에서 반드시 오차가 발생할 수 밖에 없다.

#include<iostream>
using namespace std;

int main(){
    if(0.1 + 0.1 + 0.1 == 0.3){
    	cout << "true";
    }
    else{
    	cout << "no no";
    }
}

실행 결과 : no no 

 

true가 아닌 no no가 출력된 이유는?

  • 유효숫자가 들어가는 fraction field는 유한하기 때문에 어쩔 수 없이 float는 앞 23bit, double은 앞 52bit까지만
    잘라서 저장할 수 밖에 없다. 0.1은 이진수로 나타내면 무한소수라서 애초에 오차가 있는 채로 저장이 됐고
    오차가 있는 상태로 3번 더하다 보니 오차가 더 커져서 위의 코드처럼 말도 안되는 일이 벌어지게 된 것이다.

float과 double의 fraction field의 유효숫자의 안전한 범위

float    : 유효숫자 6자리    =>  상대오차 10^(-6)까지 안전
double : 유효숫자 15자리  =>  상대오차 10^(-15)까지 안전

 

그렇기 때문에 실수 자료형이 필요하면 float 대신 double을 쓰자!!

 

2. double에 long long 범위의 정수를 함부로 담으면 안 된다.

- double은 유효숫자가 15자리인데 long long은 최대 19자리라서 10^18 + 1과 10^18을 구분 못하고
  같은 값이 저장된다. 
- int는 약 21억까지 저장하기 때문에 double에 담아도 오차가 발생하지 않는다.

 

3. 실수를 비교할 때는 등호를 사용하면 안 된다.
위에서 0.1 + 0.1 + 0.1 == 0.3의 결과가 이상하게 나오는 것에서 보면 알 수 있다.

 

#include <stdio.h>

factorial(int n)
{
    if(n == 1) return 1;        //1
    return n * factorial(n-1);  //2
}

시간복잡도 : T(n) = T(n-1) + c        ( c는 "1의 수행시간 + 2에서의 곱셈의 수행시간" )

 

위의 factorial() 함수의 시간복잡도를 반복대치를 이용해 구해보겠다.

 

반복대치

T(n) = T(n-1) + c

      = T(n-2) + 2c

      = T(n-3) + 3c

      . . .

      = T(1) + (n-1) * c

 

이때, n = 1일 때 T(1)은 1의 수행시간 밖에 들지 않으므로  1의 수행시간 + 2의 수행시간의 값을 가지는 c보다 작다.
     <= c + (n-1) * c
     <= nc

 

즉, 빅오의 정의에 의해 T(n) = O(n)이 된다.

 

=> 재귀함수의 시간복잡도 : O(n)

다이나믹 프로그래밍 문제는 종류가 굉장히 많으며 컴퓨터적인 사고력을 물어보기에 적합하다는 점에서 자주
출제되는 문제이다. 

다이나믹 프로그래밍

  • 하나의 문제는 단 한 번만 풀도록 하는 알고리즘
    => 한 번 푼 것을 여러 번 다시 푸는 비효율적인 알고리즘을 개선시키는 방법
  • dp같은 경우 문제에서 규칙성을 찾아서 점화식을 세우는 게 가장 중요하다.

 

다이나믹 프로그래밍을 쓰는 이유

  • 일반적으로 상당수의 분할 정복 기법은 동일한 문제를 다시 푼다는 단점을 가지고 있다.
    ( 단, 퀵 정렬이나 병합 정렬은 동일한 문제를 다시 푸는 단점이 없다. 그러므로 매우 빠르다. )

 

단순 분할 정복으로 풀게 되면 심각한 비효율성을 낳는 대표적인 예시

   ' 피보나치 수열 '

   피보나치 수열은 특정한 숫자를 구하기 위해 그 앞에 있는 숫자와 두 칸 앞에 있는 숫자의 합을 구해야 한다.

     피보나치 수열의 점화식 : D[i] = D[i - 1] + D[i - 2]

 

  위 공식에 따라서 1, 1, 2, 3, 5, 8, 13, 21, 34 ,55, ... 와 같이 나아간다. 만약 단순하게 분할 정복 기법을 이용해
  15번째 피보나치 수열을 구한다고 가정해보자. 그러면 아래와 같이 그래프의 형태로 진행 상황을 확인할 수 있다.
  D[15]를 구하려면 D[14]와 D[13]을 알아야 한다. 다만 D[14]를 알려면 D[13]과 D[12]를 알아야만 한다. 이런 식으로
  진행이 된다.


 

  따라서 이런 경우 병합 정렬을 할 때처럼 단순한 분할 정복 기법을 사용하면 안 된다. 왜냐하면 이미 해결한 문제를
  다시 반복적으로 해결하여 비효율적이기 때문이다. 위 예제를 보면  D[12]가 벌써 3번이나 반복적으로 계산되었다.
  => 이럴 경우에 동적 프로그래밍 기법을 사용해야 한다!!

 

다이나믹 프로그래밍의 사용 조건

  1.  큰 문제를 작은 문제로 나눌 수 있다.
  2.  작은 문제에서 구한 정답은 그것을 포함하는 큰 문제에서도 동일하다.

   쉽게 말해 크고 어려운 문제가 있으면 그것을 먼저 잘게 나누어서 해결한 후에 처리하여 나중에 전체의 답을 
   구하는 것이다. 다만 이 과정에서 '메모이제이션(Memoization)'이 사용된다는 점이 분할 정복과 다르다.
   메모이제이션이란 이미 계산한 결과를 배열에 저장함으로써 나중에 동일한 계산을 해야 할 때는 저장된 값을
   단순히 반환하기만 하면 되도록 해주는 것이다. 

 

   피보나치 수열을 예로 들어 문제를 확인해보자.

#include <stdio.h>

int d(int x){
    if(x == 1) return 1;
    if(x == 2) return 1;
    return d(x - 1) + d(x - 2);
}

int main(){
	printf("%d", d(10));
}

  위 경우 답이 55로 잘 출력된다. 하지만 50번째 피보나치 수열을 구하려고 하면 어떻게 될까?

#include <stdio.h>

int d(int x){
    if(x == 1) return 1;
    if(x == 2) return 1;
    return d(x - 1) + d(x - 2);
}

int main(){
	printf("%d", d(50));
}

  실행해보면 아래와 같이 실행은 되지 않고 계속 CPU만 돌아가는 것을 알 수 있다.

 

  그래서 위와 같은 문제를 해결하기 위해 메모이제이션 기법을 활용하면 된다.

#include <stdio.h>

int d[100];

int fibonacci(int x){
	if(x == 1) return 1;
	if(x == 2) return 1;
	if(d[x] != 0) return d[x];
	return d[x] = fibonacci(x - 1) + fibonacci(x - 2);
}

int main(){
	printf("%d", fibonacci(30));
}

위와 같이 해주면 이미 계산된 결과는 배열 d에 저장되기 때문에 한 번 구한 값을 다시 구하는 일은 발생하지 않는다.
이렇게 작업하면 순식간에 계산된 결과가 출력되는 것을 확인할 수 있다. 다이나믹 프로그래밍의 개념을 이해한 뒤에는
많은 문제를 접하여 감을 잡는게 중요하다.

깊이 우선 탐색 ( DFS )

  • 탐색을 함에 있어서 보다 깊은 것을 우선적으로 탐색하는 알고리즘
  • 미로를 탐색할 때 한 방향으로 갈 수 있을 때까지 계속 가다가 더 이상 갈 수 없게 되면 다시 가장 가까운
    갈림길로 돌아와서 이곳으로부터 다른 방향으로 다시 탐색을 진행하는 방법과 유사하다.
  • 깊이 우선 탐색(DFS)이 너비 우선 탐색(BFS)보다 좀 더 간단
  • 단순 검색 속도 자체는 너비 우선 탐색(BFS)에 비해 느리다.

 

깊이 우선 탐색 특징

  • 넓게(wide) 탐색하기 전에 깊게(deep) 탐색하는 것
  • 스택(Stack)을 사용
    - 사실 스택을 사용하지 않아도 구현이 가능하다. 왜냐하면 컴퓨터는 구조적으로 항상 스택의 원리를
      사용하기 때문이다.
  • 자기 자신을 호출하는 재귀 알고리즘의 형태이다.
  • 탐색 과정이 시작 노드에서 한없이 깊게 진행되는 걸 막기 위해 깊이 제한(depth bound)을 사용
  • 깊이 제한에 도달할 때까지 목표노드가 발견되지 않으면 최근에 첨가된 노드의 부모노드로 돌아온다.
    -> 이 과정을 백트래킹(backtracking)이라고 한다.

깊이 우선 탐색 장점과 단점

< 장점 > 

  • 단지 현 경로상의 노드들만을 기억하면 되므로 저장공간의 수요가 비교적 적음
  • 목표노드가 깊은 단계에 있을 경우 해를 빨리 구할 수 있음

< 단점 >

  • 해가 없는 경로에 깊이 빠질 가능성 존재.
    -> 실제의 경우 미리 지정한 임의의 깊이까지만 탐색하고 목표노드를 발견하지 못하면 다음 경로를 따라 탐색하는 방법
         이 유용할 수 있음
  • 얻어진 해가 최단 경로가 된다는 보장이 없음.
    -> 목표에 이르는 경로가 다수인 문제에 대해 깊이우선 탐색은 해에 다다르면 탐색을 끝내버리므로, 
         이때 얻어진 해는 최적이 아닐 수 있다.

 

깊이 우선 탐색을 사용하는 경우

  • 모든 노드를 방문하고자 하는 경우 사용

 

동작 방식

DFS는 다음과 같은 간단한 알고리즘에 따라서 동작한다.

  1. 스택의 최상단 노드를 확인합니다.
  2. 최상단 노드에게 방문하지 않은 인접 노드가 있으면    =>   그 노드를 스택에 넣고 방문처리합니다.
                                        방문하지 않은 인접 노드가 없으면   =>   스택에서 최상단 노드를 뺍니다.

초기 상태

- 스택그래프 준비

 

1. 

DFS는 맨 처음에 시작 노드(Start Node)를 스택에 삽입하면서 시작한다. 또한 그와 동시에 시작 노드를 
방문했다고 알리는 '방문 처리' 를 해주도록 한다. ( 방문 처리 = 빨간색 )

 

 

위의 1,2번을 반복 수행한다.

 

2.

스택에 있던 최상단 노드가 1번 노드이므로 1번과 인접한 노드 중에서 방문하지 않은 2번 노드를 스택에 넣는다.

 

3.

최상단 노드가 2번 노드이므로 2번과 인접한 노드 중에서 방문하지 않은 노드인 3번 노드를 스택에 넣는다.

 

4.

최상단 노드가 3번 노드이므로 3번의 인접 노드 중 방문하지 않은 노드인 6번 노드를 스택에 넣는다.

 

5.

이어서 6번 노드가 최상단 노드이므로 6번의 인접 노드 중 방문하지 않은 7번 노드를 스택에 넣는다.

 

6.

7번 노드, 6번 노드, 3번 노드는 스택에서 빠져나온다. => 인접 노드를 전부 방문했기 때문이다.

이후에 스택에서 최상위 노드가 2번이므로 2번의 인접 노드 중 방문하지 않은 4번 노드를 스택에 넣는다.

 

7.

4번 노드의 인접 노드 중 방문하지 않은 5번 노드가 스택에 들어가고, 이후에 스택에서 하나씩 다 노드들이
빠져나오게 된다. 

 

최종 상태

따라서 방문 경로는 1 - 2 - 3 - 6 - 7 - 4 - 5 이다.

 

DFS 구현 코드

#include<iostream>
#include<vector>

using namespace std;

int number = 7;
int c[7];
vector<int> a[8];

void dfs(int x){
  if(c[x]) return; // 현재 그 노드가 방문한 노드라면 바로 함수 종료
  c[x] = true;
  cout << x << ' ';
  for(int i = 0; i < a[x].size(); i++){
    int y = a[x][i];
    dfs(y);
  }
}

int main(){
  //1과 2를 연결
  a[1].push_back(2);
  a[2].push_back(1);
  
  //1과 3을 연결
  a[1].push_back(3);
  a[3].push_back(1);

  //2와 3을 연결
  a[2].push_back(3);
  a[3].push_back(2);

  //2와 4를 연결
  a[2].push_back(4);
  a[4].push_back(2);
  
  //2와 5를 연결
  a[2].push_back(5);
  a[5].push_back(2);

  //3과 6을 연결
  a[3].push_back(6);
  a[6].push_back(3);

  //3과 7을 연결
  a[3].push_back(7);
  a[7].push_back(3);

  //4와 5를 연결
  a[4].push_back(5);
  a[5].push_back(4);

  //6과 7을 연결
  a[6].push_back(7);
  a[7].push_back(6);

  dfs(1);
}
기본적으로 재귀 호출 자체가 컴퓨터의 내부적인 구조인 스택 구조에 차곡차곡 쌓이는 형태이기 때문에
간단히 재귀함수로 구현을 하는 것 만으로도 사실 스택에 담고 빼는 것과 같은 효과가 나온다. 그렇기 때문에
스택을 따로 쓰지 않고 재귀만 구현해서 DFS를 구현하는게 알고리즘 대회에서 자주 쓰인다.

너비 우선 탐색 ( Breath First Search )

  • 데이터를 탐색할 때 너비를 우선으로 하여 탐색을 수행하는 탐색 알고리즘
  • 현재 노드에서 가까운 것을 먼저 탐색하는 방식
  • 깊게(deep) 탐색하기 전에 넓게(wide) 탐색하는 것

 

너비 우선 탐색 특징

  • 재귀적으로 동작 X
  • FIFO 원칙으로 탐색 ( Queue가 필요함 )
  • 그래프 탐색 알고리즘은 방문한 노드를 꼭 방문 처리 해줘야 한다. ( 하지 않을 경우 무한 루프에 빠질 수 있음 )

너비 우선 탐색 장점과 단점

< 장점 >

  • 출발노드에서 목표노드까지의 최단 길이 경로 보장

< 단점 >

  • 경로가 매우 길 경우 탐색 가지가 급격히 증가함에 따라 보다 많은 저장 공간이 필요함
  • 해가 존재하지 않는다면 유한 그래프의 경우 모든 그래프를 탐색한 후 실패로 끝남
  • 무한 그래프의 경우 해가 없는 경우 해를 찾지도 못하고 끝내지도 못하게 됨

 

너비 우선 탐색을 사용하는 경우

  • 두 노드 사이의 최단경로 즉, 최단 길이를 보장해야 할 때 많이 사용된다.
     ex) 미로 찾기 같은 문제를 풀 때 많이 사용된다.

 

너비 우선 탐색 ( BFS ) 동작 방식

BFS는 다음과 같은 간단한 방식으로 작동한다.

1. 큐에서 하나의 노드를 꺼낸다.

2. 해당 노드에 연결된 노드 중 방문하지 않은 노드를 방문하고, 차례대로 큐에 삽입한다.

-> 위의 1번과 2번을 계속해서 반복한다.

 

초기 상태

- 그래프를 준비

 

1. 

시작 노드(Start Node)를 큐에 삽입하면서 시작한다. 그리고 시작 노드를 '방문 처리' 해준다. ( 방문 처리 = 빨간색 )

 

 

2.

1. 시작 노드 1을 큐에서 꺼낸다.

2. 꺼낸 노드 1의 주변 노드인 2와 3이 모두 방문된 적이 없으므로 큐에 넣어준다.

 

 

3. 

1. 큐에서 2를 꺼낸다.

2. 꺼낸 이후 2와 인접한 노드 1,3,4,5 중에서 1과 3은 이미 방문한 적이 있으므로 패스하고 
   4와 5를 큐에 삽입한다.

 

 

4. 

1. 노드 3을 큐에서 꺼낸다.

2. 꺼낸 노드 3에 인접한 노드인 1,2,6,7 중에서 1과 2는 방문한 적이 있으므로 패스하고 
   6과 7만 큐에 삽입한다.

 

  => 위와 같이 모든 노드가 방문처리가 된 후에는 남은 노드들을 큐에서 꺼내주기만 하면 된다.

 

 

최종 상태

 차례대로 큐에서 꺼내면 위와 같다. 큐에서 꺼낸 순서를 보면 1,2,3,4,5,6,7이다.
 아무렇게나 막 탐색하는 것이 아니라 1부터 시작해서 '가까운 노드'들부터 탐색이 이루어진다는 점에서
 너비 우선 탐색이라고 한다. 거리가 먼 노드인 4,5,6,7은 가장 마지막에 탐색이 이루어지게 된다.

 

BFS 구현 코드

 

#include<iostream>
#include<queue>
#include<vector>

using namespace std;

int number = 7; // 노드의 갯수
int c[7]; // 방문처리를 위한 배열 생성
vector<int> a[8]; // 이차원 배열

void bfs(int start){
  queue<int> q;
  q.push(start);
  c[start] = true;
  while(!q.empty()){
    int x = q.front(); // 큐에서 제일 앞에 값을 저장
    q.pop(); // 큐에서 제일 앞에 값을 삭제
    printf("%d ",x);
    for(int i = 0; i < a[x].size(); i++){
      int y = a[x][i];
      if(!c[y]){ // 방문하지 않았으면
        q.push(y);
        c[y] = true;
      }
    }
  }
}

int main(){
  //1과 2를 연결
  a[1].push_back(2);
  a[2].push_back(1);
  
  //1과 3을 연결
  a[1].push_back(3);
  a[3].push_back(1);

  //2와 3을 연결
  a[2].push_back(3);
  a[3].push_back(2);

  //2와 4를 연결
  a[2].push_back(4);
  a[4].push_back(2);
  
  //2와 5를 연결
  a[2].push_back(5);
  a[5].push_back(2);

  //3과 6을 연결
  a[3].push_back(6);
  a[6].push_back(3);

  //3과 7을 연결
  a[3].push_back(7);
  a[7].push_back(3);

  //4와 5를 연결
  a[4].push_back(5);
  a[5].push_back(4);

  //6과 7을 연결
  a[6].push_back(7);
  a[7].push_back(6);

  //BFS를 수행
  bfs(1);
  
}

 

결론

  • BFS는 너비를 우선으로 하여 탐색한다.
  • BFS를 다른 알고리즘에 적용해 응용하는 것이 핵심이다. BFS 그 자체로는 그냥 탐색 알고리즘 중 하나일 뿐
    별 의미가 없다.

 

'알고리즘' 카테고리의 다른 글

다이나믹 프로그래밍 ( Dynamic Programming )  (0) 2022.02.21
깊이 우선 탐색 ( DFS )  (0) 2022.02.20
스택(Stack), 큐(Queue)  (0) 2022.02.15
계수 정렬 ( Counting Sort )  (0) 2022.02.15
힙 정렬 ( Heap sort )  (0) 2022.02.11

스택

- 제일 나중에 들어온 원소가 제일 먼저 나가는 구조 ( LIFO - Last In First Out )

- 알고리즘보다는 자료구조에 가까운 개념이다.

  -> 알고리즘은 이러한 스택이나 큐를 이용해서 문제를 해결하는 방법이다.

 

#include<iostream>
#include<stack>

using namespace std;

int main(){
  stack<int> s;
  s.push(7);
  s.push(5);
  s.push(4);
  s.pop();
  s.push(6);
  s.pop();

  while(!s.empty()){
    cout << s.top() << " ";
    s.pop();
  }
}

- STL 라이브러리 문법을 이용해 사용한다.

- 직접 구현해 볼 수 있지만 너무 간단하므로 생략하기로 한다.

 


- 들어온 것이 먼저 나가는 구조 ( FIFO - First In First Out )

 

#include<iostream>
#include<queue>

using namespace std;

int main(){
  queue<int> q;
  q.push(7);
  q.push(5);
  q.push(4);
  q.pop();
  q.push(6);
  q.pop();

  while(!q.empty()){
    cout << q.front() << " ";
    q.pop();
  }
}

- STL 라이브러리 문법을 이용해 사용한다.

- 스택과 마찬가지로 직접 구현해 볼 수 있지만 너무 간단하므로 생략하기로 한다.

'알고리즘' 카테고리의 다른 글

깊이 우선 탐색 ( DFS )  (0) 2022.02.20
너비 우선 탐색 ( BFS )  (0) 2022.02.15
계수 정렬 ( Counting Sort )  (0) 2022.02.15
힙 정렬 ( Heap sort )  (0) 2022.02.11
C++ STL sort() 함수 다루기  (0) 2022.02.10

계수 정렬

  • '범위 조건'이 있는 경우에 한해서 굉장히 빠른 알고리즘 -> 속도 : O(N)
  • 단순하게 '크기를 기준'으로 세는 알고리즘

 

문제) 주어진 5 이하 자연수 데이터들을 오름차순 정렬하시오.
     1 3 2 4 3 2 5 3 1 2 3 4 4 3 5 1 2 3 5 2 3 1 4 3 5 1 2 1 1 1
  • 크기를 기준으로 갯수만 세주면 되기 때문에 다른 정렬들처럼 위치를 바꿀 필요가 없다.
  • 모든 데이터에 한 번씩만 접근하면 된다는 점에서 매우 효율적이다.

 

초기 상태

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

크기 = 1 크기 = 2 크기 = 3 크기 = 4 크기 = 5
0 0 0 0 0

 

1. 첫번째 상태

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

크기 = 1 크기 = 2 크기 = 3 크기 = 4 크기 = 5
1 0 0 0 0

 

2. 두번째 상태

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

크기 = 1 크기 = 2 크기 = 3 크기 = 4 크기 = 5
1 0 1 0 0

 

3. 세번째 상태

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

크기 = 1 크기 = 2 크기 = 3 크기 = 4 크기 = 5
1 1 1 0 0

 

 

4. 네번째 상태

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

크기 = 1 크기 = 2 크기 = 3 크기 = 4 크기 = 5
1 1 1 1 0

 

5. 다섯번째 상태

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

크기 = 1 크기 = 2 크기 = 3 크기 = 4 크기 = 5
1 1 2 1 0

 

.  .  . ( 위와 같이 계속 반복 )

 

 

최종 상태

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

크기 = 1 크기 = 2 크기 = 3 크기 = 4 크기 = 5
8 6 8 4 4

- 이제 크기 1부터 5까지 해당 숫자만큼 순서대로 출력하면 된다. 

- 이게 바로 '크기를 기준'으로 정렬하는 계수정렬의 정렬 방법이다.

 

 

계수 정렬 구현 코드

#include<stdio.h>

int main(){
  int temp;
  int count[5] = {0,}; // 범위가 5이하이기 때문에 5개까지 들어갈 공간을 만들어줌
  int array[30] = {
    1,3,2,4,3,2,5,3,1,2,
    3,4,4,3,5,1,2,3,5,2,
    3,1,4,3,5,1,2,1,1,1
  };

  for(int i = 0; i < 30; i++){
    count[array[i] - 1]++;
  }

  for(int i = 0; i < 5; i++){
    if(count[i] != 0){
      for(int j = 0; j < count[i]; j++){
        printf("%d", i + 1);
      }
    }
  }
}

- count[]를 문제에 주어진 수의 범위만큼 생성해줘야 한다.

  만약 주어진 숫자 중에 10000이 있다면 count[]도 10000까지 잡아줘야 한다.
  그렇기 때문에 보통의 상황에 자주 쓸 수 있는 알고리즘은 아니다.
  -> 정렬할 데이터의 범위에 영향을 매우 크게 받는 정렬 알고리즘이다.

'알고리즘' 카테고리의 다른 글

너비 우선 탐색 ( BFS )  (0) 2022.02.15
스택(Stack), 큐(Queue)  (0) 2022.02.15
힙 정렬 ( Heap sort )  (0) 2022.02.11
C++ STL sort() 함수 다루기  (0) 2022.02.10
병합 정렬 ( Merge sort )  (0) 2022.02.09

힙정렬

  • 시간복잡도가 O(nlogn)으로 퀵정렬, 병합정렬과 같은 시간복잡도를 가진 정렬 알고리즘
  • 병합정렬과는 다르게 추가적인 메모리 필요 X
  • 항상 O(nlogn)의 성능을 보여주기 때문에 최악의 경우 O(n^2)의 성능을 보이는
    퀵정렬보다 안정적인 성능을 보인다.
  • 이론적으로는 퀵 정렬 병합 정렬보다 우위에 있지만, 단순히 속도만 가지고 본다면 퀵 정렬이
    평균적으로 더 빠르기 때문에 힙 정렬이 많이 사용되지는 않는다.
  • 배열을 최대힙 or 최소힙으로 만들어 root에 있는 값을 정렬하는 식으로 구현한다.

힙(Heap)

  • 완전이진트리 형태의 자료구조
  • 힙은 최소힙과 최대힙 두가지로 나뉜다.
  • 힙은 일반적인 배열로 표현할 수 있다.

  최대 힙 : "부모 노드 > 자식 노드"를 모든 노드에서 만족하는 완전이진트리 
  최소 힙 : "부모 노드 < 자식 노드"를 모든 노드에서 만족하는 완전이진트리

 

완전이진트리

 - 노드를 삽입할 때 왼쪽부터 차례대로 추가하는 이진트리 ( 모든 노드의 차수가 2 이하로 구성된 트리 )

완전이진트리(각 노드의 왼쪽부터 채워진 이진트리) 그냥 이진트리

 

힙을 일반적인 배열로 표현

 a = {-1,3,4,5,1,7,2,8}; 이라는 배열이 있다고 가정하자.
 a[1]을 루트노드로 잡고 힙트리를 완성하면 밑에 그림과 같다.

 i = 현재 노드의 인덱스

 ☞ 부모노드 = i / 2 (i > 0)

 ☞ 왼쪽 자식 노드     = i * 2

 ☞ 오른쪽 자식 노드  = i * 2 + 1


배열을 최대힙(or 최소힙)으로 만들어주는 힙 생성 알고리즘 ( heapify )
  - 힙 구조(최대힙 or 최소힙 구조)로 만들어 주기 위해 하나의 노드에 대해서 수행하는 알고리즘
  - 특정 노드의 두 자식 중에 더 큰 자식 <-> 자기 자신 스위칭
     ( 이 과정을 모든 노드에 대해서 반복 )

5 때문에 최대 힙 구조 X heapify() 후 최대 힙 구조 O

  - 하나의 노드에 대해 한 번 heapify()를 수행하는데 걸리는 시간 : O(logN)
 
  - 전체 노드가 힙 구조를 다 만족하도록 만드는데 걸리는 시간 ( 제일 왼쪽 아래 노드부터 heapify() 시작 )
     ( == 트리에 있는 모든 노드에 대해 heapify()를 수행하는데 걸리는 시간 )
     => O(N * logN) ( 노드 개수 * 특정 노드에 대해 heapify() 1번 할 때 걸리는 시간 )

 

  - 실제로 전체를 힙 구조를 만족하도록 만드는데 걸리는 시간
        Ex)  이진탐색트리의 높이 : logN / 총 노드 갯수 : N
             - 실제로는 노드 갯수의 반만큼만 heapifiy()를 하면 되므로 ( 리프 노드들은 자식이 없기 때문에 할 필요 x )
                => N/2 * logN 이 된다.
                => N이 무한히 커진다면 N/2  >  logN라서 logN은 상수급으로 작아진다.
                => 그러므로 시간복잡도가 O(N * logN)이긴 하지만 logN이 상수급으로 작아지기 때문에 거의 O(N)에
                     가까운 속도를 낼 수 있다.


힙정렬의 원리

- 힙을 이용해 정렬을 하기 위해서는 이 힙을 최소힙이나 최대힙으로 만들어야 한다.
  힙이 최소힙 or 최대힙이 되었을 때 root는 항상 힙에서 최대값 or 최소값이 나오게 되어 있다.
  이 때 root를 힙의 마지막 노드와 교체한 뒤 힙의 마지막을 제외한 후 다시 최소힙이나 최대힙을
  만들어 준 후 root를 마지막 노드와 교체하는 방식으로 계속 반복한다. 


  힙 정렬 순서
  1) 정렬되지 않은 배열 생성

  2) 제일 처음에 이 배열을 최소힙 or 최대힙으로 구성 ( 모든 노드에 대해 힙 생성 알고리즘 heapify() 사용 )
  3) root <-> 마지막 노드 교체
  4) 마지막 노드를 제외한 상태로 다시 최소힙 or 최대힙 구성 ( root노드에 대해 다운힙 : O(logN) )
  5) 3 ~ 4번 반복

  => 최종적으로 힙정렬하는데 드는 시간
       => 처음 최대힙으로 만드는데 걸린 시간 + 스위칭한 후 root에 대해 다운힙하는데 걸리는 시간
            => O(N)  +  O(N * logN)
                 => O(N * logN)


힙 정렬 구현 코드

#include<stdio.h>
#include<iostream>

using namespace std;

int n = 7;
int heap[8] = {-100,1,9,5,4,7,2,8}; 

void heapify(int i){
  int parent = i;
  int child = i * 2;

  if(heap[child] < heap[child+1]) child++;

  if(heap[parent] < heap[child]){
    swap(heap[parent],heap[child]);
    if(child <= n / 2){
      heapify(child);
    }
  }
}

void heapSort(int i){
  int root = 1;
  int child = 2;
  swap(heap[root], heap[i]);

  //마지막 노드를 제외하고 최대힙으로 구성
  do{
    child = root * 2;
    if((heap[child] < heap[child + 1]) && (child < i - 1)){
      child++;
    } 

    if(heap[root] < heap[child] && child < i){
      swap(heap[root],heap[child]);
    }

    root = child;
  }while(child < i);
}

int main(){

  //처음에 최대 힙으로 만들기
  for(int i = n / 2; i >= 1; i--){
    heapify(i);
  }

  //힙 정렬 수행
  for(int i = n; i > 0; i--){
    heapSort(i);
    // printf("%d : ",i);
    // for(int j = 1; j <= n; j++){
    //   printf("%d ",heap[j]);
    // }
    // printf("\n");
  }


}

'알고리즘' 카테고리의 다른 글

스택(Stack), 큐(Queue)  (0) 2022.02.15
계수 정렬 ( Counting Sort )  (0) 2022.02.15
C++ STL sort() 함수 다루기  (0) 2022.02.10
병합 정렬 ( Merge sort )  (0) 2022.02.09
버블 정렬 ( Bubble sort )  (0) 2022.02.09

실제 알고리즘 대회에서 정렬 문제가 나올 때 빠르게 풀기 위해 sort()를 사용해 간단하게 정렬을 구현한다.

 

sort()

  • <algorithm> 헤더에 정의되어 있다.
  • default 설정  => 오름차순으로 정렬
  • 퀵 정렬을 기반으로 구현되어 있다. 
  • 퀵 정렬과 달리 모든 경우에 시간 복잡도 O(N * logN)을 보장한다.
  • sort(start, end, 정렬기준);

 

사용 예시

1) sort(arr, arr + n);                                     

    => 세번째 인자를 안 쓰면 default로 오름차순 정렬됨

 

2) sort(v.begin(), v.end());

    => 세번째 인자를 안 쓰면 default로 오름차순 정렬됨

 

3) sort(v.begin(), v.end(), greater<자료형>());     

    => 내림차순으로 정렬

 

4) sort(arr, arr + n, compare);                         

    => 함수를 지정해서 세번째 인자로 넣으면 정렬할 기준을 내 마음대로 지정할 수 있음

 

 

일반 배열 정렬 

#include<iostream>
#include<algorithm>

using namespace std;

int main(){
  int a[10] = {9,3,5,4,1,10,8,6,7,2};
  sort(a, a+10);
  for(int i = 0; i < 10; i++){
    printf("%d ",a[i]);
  }
}

                                                                    

정렬 기준을 사용자가 직접 함수를 정의해서 정렬

#include<iostream>
#include<algorithm>

using namespace std;

bool compare(int a, int b){
  return a > b;   // a가 b보다 클 때 우선적으로 출력하도록 함 (내림차순)
}

int main(){
  int a[10] = {9,3,5,4,1,10,8,6,7,2};
  sort(a, a+10, compare);
  for(int i = 0; i < 10; i++){
    printf("%d ",a[i]);
  }
}

 

실무에서 실제로 sort()를 사용하는 경우

- 실무에서는 알고리즘 문제에서처럼 숫자만 나열되어 있는 데이터는 거의 없고
  데이터들이 객체화 되어 있는 경우가 많기 때문에 객체를 비교해서 정렬하는 경우가 많음

#include<iostream>
#include<algorithm>

using namespace std;

class Student{
public:
  string name;
  int score;
  Student(string name, int score){
    this->name = name;
    this->score = score; 
  }

  /*
  정렬 기준 : 점수가 작은 순서
  다른 학생(student)과 비교할 때 내 점수(this->score)가 더 낮다면 우선순위가 높다라는 뜻 
  => 점수가 작은 순서부터 출력하겠다는 말임
  */
  bool operator < (Student& student){
    return this->score < student.score;
  }

};

int main(){
  Student students[] = {
    Student("김일병", 90),
    Student("김이병", 93),
    Student("김상병", 97),
    Student("김사병", 87),
    Student("김오병", 92)
  };
  
  sort(students,students + 5); // 클래스 내에서 연산자 오버로딩으로 정렬 기준을 정해줌
                               // => 따로 세번째 인자에 값을 넣어주지 않아도 됨
  for(int i = 0; i < 5; i++){
    cout << students[i].name << " ";
  }
}

 


실제 알고리즘 문제에서 여러 개의 변수가 존재하는 상황에 "특정한 변수" 를 기준으로 정렬해야 할 때
실무에서처럼 클래스를 정의해서 푸는 방식은 빨리 풀어야 할 때는 유리하지 않다.

=> 일반적으로 빠른 코드 구현이 필요할 경우 페어(Pair) STL 라이브러리를 사용하는 것이 더 효율적이다.

 

pair STL 사용 예시

#include<iostream>
#include<vector>
#include<algorithm>

using namespace std;

int main(){
	vector<pair<int,string>> v;
	v.push_back(pair<int, string>(90,"김일병"));
	v.push_back(pair<int, string>(85,"김이병"));
	v.push_back(pair<int, string>(82,"김상병"));
	v.push_back(pair<int, string>(98,"김사병"));
	v.push_back(pair<int, string>(79,"김오병"));

	sort(v.begin(), v.end());
	for(int i = 0; i < v.size(); i++) {
		cout << v[i].second << " ";
	}
}

vector STL : 마치 배열과 같이 동작하는데, 배열을 보다 사용하기 쉽도록 개편한 자료구조

Pair STL    : 한 쌓의 데이터를 처리할 수 있도록 해주는 자료구조

 

 

변수가 3개일 때 "두 개" 의 변수를 기준으로 정렬해야 하는 경우

 

예를 들어 학생 이름을 기준에 맞게 정렬하는 상황이라고 가정하도록 하겠다.

학생 정보 : 이름, 성적, 생년월일

정렬 기준 : 학생의 이름을 성적 순서대로 나열 ( 단, 성적이 동일한 경우 나이가 더 어린 학생을 먼저 나열 )

 

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

bool compare(pair<string, pair<int,int>> a, pair<string,pair<int,int>> b){
  if(a.second.first == b.second.first){
    return a.second.second > b.second.second;
  }else{
    return a.second.first > b.second.first;
  }
}

int main(){
  vector<pair<string, pair<int, int>>> v;

  v.push_back(pair<string,pair<int, int>>("김일병", make_pair(90, 19961001)));
  v.push_back(pair<string,pair<int, int>>("김이병", make_pair(97, 19940505)));
  v.push_back(pair<string,pair<int, int>>("김상병", make_pair(95, 19940101)));
  v.push_back(pair<string,pair<int, int>>("김사병", make_pair(90, 19910909)));
  v.push_back(pair<string,pair<int, int>>("김오병", make_pair(88, 19850303)));

  sort(v.begin(), v.end(), compare);

  for(int i = 0; i < v.size(); i++){
    cout << v[i].first << " ";
  }
}

'알고리즘' 카테고리의 다른 글

계수 정렬 ( Counting Sort )  (0) 2022.02.15
힙 정렬 ( Heap sort )  (0) 2022.02.11
병합 정렬 ( Merge sort )  (0) 2022.02.09
버블 정렬 ( Bubble sort )  (0) 2022.02.09
삽입 정렬(insertion sort)  (0) 2022.02.08

+ Recent posts