이진탐색을 사용하기 위한 조건 : 정렬되어 있어야 한다.

찾으려는 데이터와 중간점(middle)위치에 있는 데이터를 반복적으로 비교해서 원하는 데이터를 찾는다.

시간 복잡도 : O(log(N))

 

1) 재귀를 이용한 구현

#include <iostream>
#include <vector>

using namespace std;

int n, target;
vector<int> v;

int binarySearch(int s, int e, int t) {
    if (s > e)
        return -1;

    int mid = (s + e) / 2;
    if (v[mid] == t) {
        return mid;
    }
    else if (v[mid] < t) {
        return binarySearch(mid+1, e, t);
    }
    else {
        return binarySearch(s, mid-1, t);
    }
}

int main(void) {
    cin >> n >> target;

    for (int i = 0; i < n; i++) {
        int a;
        cin >> a;
        v.push_back(a);
    }

    cout << binarySearch(0, n-1, target) + 1 << "\n";

    return 0 ;
}

2) while문을 이용한 구현

#include <iostream>
#include <vector>

using namespace std;

int n, target;
vector<int> v;

int binarySearch(int t) {
    int s = 0;
    int e = v.size() - 1;

    while (s <= e) {
        int mid = (s + e) / 2;
        if (v[mid] == t) {
            return mid;
        }
        else if (v[mid] < t) {
            s = mid+1;
        }
        else {
            e = mid - 1;
        }
    }

    return - 1;
}
int main(void) {
    cin >> n >> target;

    for (int i = 0; i < n; i++) {
        int a;
        cin >> a;
        v.push_back(a);
    }

    cout << binarySearch(target) + 1 << "\n";

    return 0 ;
}

나의 경우 2번이 편하다.

1번, 2번 아무거나 사용하여도 상관 없다!

 

파라메트릭 서치(Parametric Search)

  • 최적화 문제(문제의 상황을 만족하는 특정 변수의 최솟값, 최댓값을 구하는 문제)를 결정 문제(decision problem)로 바꾸어 해결하는 기법
  • 예를 들어 범위 내에서 조건을 만족하는 가장 큰 값을 찾으라는 최적화 문제라면 이분 탐색으로 결정 문제를 해결하면서 범위를 좁혀갈 수 있다.

Lower Bound, Upper Bound

  • 이진 탐색으로 돌음
  • countByRange 처럼 정렬된 배열에서 a~b까지의 숫자의 개수를 O(logN)으로 찾고자 할때 주로 사용한다.

Lower Bound

  • 키값보다 크거나 같은 첫번째 값
  • 찾으려고 하는 key 값이 없으면, key 값 초과인 값들 중, 가장 작은 값

Upper Bound

  • 키값보다 큰 첫번째 값
  • 최댓값보다 더 큰 값을 찾고자 하는 경우에는, 마지막 요소 다음 메모리를 가리키는 iterator(end()와 같은)를 반환한다. 따라서, 이 iterator에 접근하면 쓰레기값을 반환.

직접 구현할 경우는 기존 이진 탐색에서 값을 찾았을 경우 left를 이동할지 right를 이동할지를 생각해 로직을 수정하면된다.

또한 증명하는 방법은 이진탐색은  l~r의 크기가 1 또는 2일 경우를 결국은 지나므로 해당하는 경우를 모두생각해보면 편하다.

 

1) 직접 구현

#include <iostream>
#include <vector>

using namespace std;

int n, key;
vector<int> v;

// Key 보다 크거나 같은 첫번째 값 반환
int lowerBound() {
    int l = 0, r = n-1;

    while (l <= r) {
        int mid = (l + r) / 2;
        if (v[mid] < key) {
            l = mid + 1;
        }
        else {
            r = mid - 1;
        }
    }

    return l;
}

// Key보다 큰 첫번째 값
int upperBound() {
    int l = 0, r = n-1;

    while (l <= r) {
        int mid = (l + r) / 2;
        if ( v[mid] <= key) {
            l = mid + 1;
        }
        else {
            r = mid - 1;
        }
    }

    return l;
}

// 값이 [left, right]인 데이터의 개수를 반환하는 함수
int countByRange() {
    int left = lowerBound();
    int right = upperBound();

    return right - left;
}

int main(void) {
    cin >> n >> key;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        v.push_back(x);
    }

    int cnt = countByRange();
    if (cnt == 0) {
        cout << -1 << "\n";
    }
    else {
        cout << cnt << "\n";
    }

    return 0 ;
}

2) STL

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

using namespace std;

int n, key;
vector<int> v;

// 값이 [left, right]인 데이터의 개수를 반환하는 함수
int countByRange() {
    // Key 보다 크거나 같은 첫번째 값 반환
    vector<int>::iterator left = lower_bound(v.begin(), v.end(), key);
    // Key보다 큰 첫번째 값
    vector<int>::iterator right = upper_bound(v.begin(), v.end(), key);

    return right - left;
}

int main(void) {
    cin >> n >> key;
    for (int i = 0; i < n; i++) {
        int t;
        cin >> t;
        v.push_back(t);
    }

    int cnt = countByRange();
    if (cnt == 0) {
        cout << -1 << "\n"; 
    }
    else {
        cout << cnt << "\n";
    }

    return 0 ;
}
  DFS BFS
동작원리 스택
구현방법 *재귀함수 이용 큐 자료구조 이용
시간복잡도 O(N) O(N)

*재귀함수 : 자기 자신을 다시 호출하는 함수, 종료조건을 반드시 명시해야 한다.

 

DFS 동작과정

  1. 탐색 시작 노드를 스택에 삽입하고 방문 처리를 한다.
  2. 스택의 최상단 노드에 방문하지 않은 인접 노드가 있으면 그 인접 노드를 스택에 넣고 방문 처리를 한다. 방문하지 않은 인접 노드가 없으면 스택에서 최상단 노드를 꺼낸다. (방문처리 순서가 중요하다. 스택에 삽입되면서 준비상태가 되면 방문처리가 되어야 한다.)
  3. 2번 과정을 더 이상 수행할 수 없을 때까지 반복한다.

    - 재귀함수 자체가 스택형식으로 작동한다. (함수호출 - 스택에 삽입, 함수 종료 - 스택에서 제거)

    - 즉, DFS는 재귀함수를 이용하면 스택을 따로 선언하여 사용할 필요가 없다. 따라서 BFS보다 구현이 간편하다.

 

#include <iostream>
#include <vector>

using namespace std;

vector<vector<int> > graph = {
    vector<int> {},
    vector<int> {2, 3, 8},
    vector<int> {1, 7},
    vector<int> {1, 4, 5},
    vector<int> {3, 5},
    vector<int> {3, 4},
    vector<int> {7},
    vector<int> {2, 6, 8},
    vector<int> {1, 7}
};
bool visited[101];

// DFS 함수 정의
// 탐색 시작 노드 스택 삽입
void dfs(int now) {
    // 현재 노드를 방문 처리
    visited[now] = true;
    cout << now << " ";
    // 현재 노드의 인접 노드 중 방문하지 않은 노드 재귀적으로 방문
    for (int next : graph[now]) {
        if (!visited[next]) {
            dfs(next);
        }
    }
// 현재(최상단) 노드 스택에서 꺼내기
}
int main(void) {

    dfs(1);
    cout << "\n";

    return 0 ;
}

 

BFS 동작과정

  1. 탐색 시작 노드를 큐에 삽입하고 방문 처리를 한다.
  2. 큐에서 노드를 꺼내 해당 노드의 인접 노드 중에서 방문하지 않은 노드를 전부 큐에 삽입하고 방문처리를 한다. (방문처리 순서가 중요하다. 큐에 삽입되면서 준비상태가 되면 방문처리가 되어야 한다.)
  3. 2번 과정을 더 이상 수행할 수 없을 때까지 반복한다.

    - 일반적인 경우 BFS가 DFS보다 성능이 좋다.

 

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

using namespace std;

vector<vector<int> > graph = {
    vector<int> {},
    vector<int> {2, 3, 8},
    vector<int> {1, 7},
    vector<int> {1, 4, 5},
    vector<int> {3, 5},
    vector<int> {3, 4},
    vector<int> {7},
    vector<int> {2, 6, 8},
    vector<int> {1, 7}
};
bool visited[101];

// BFS 함수 정
void bfs(int start) {
    queue<int> q;

    // 탐색 시작 노드 큐에 삽입
    q.push(start);
    // 현재 노드를 방문 처리
    visited[start] = true;
    
    // 큐가 빌때까지 반복
    while(!q.empty()) {
        // 큐에서 원소 하나 뽑기
        int now = q.front();
        cout << now << " ";
        q.pop();

        // 해당 원소와 연결된, 아직 방문하지 않은 노 큐에 삽입 후 방문처리
        for (int next : graph[now]){
            if (!visited[next]) {
                q.push(next);
                visited[next] = true;
            }
        }
    }
}
int main(void) {

    bfs(1);
    cout << "\n";

    return 0 ;
}

 

결론

      트리의 깊이를 묻는 문제는 BFS를 사용하자. 둘 다 사용할 수 있어도 BFS를 쓰자.

 

지도(그래프)에서 동서남북으로 움직이는 문제는 자주 출제되는 문제유형인 것 같다.

2차원 배열에서 상하좌우로 1칸씩 움직일 수 있고 배열의 범위를 초과하는 것을 다뤄야 할 경우가 많다.

그래서 2차원 배열 동서남북 움직이기를 템플릿으로 만들어 두면 좋을 것 같다.

 

다음은 R, L, U, D를 입력 받아 동서남북으로 움직이는 전형적인 문제의 답안이다.

움직이는 좌표를 설정하고 공간을 벗어나는 경우를 continue로 무시하여 깔끔하게 코드를 작성하는 법이라 생각한다.

#include <iostream>
#include <vector>

using namespace std;

int r = 1, c = 1;
int n;
string plans;

// 동, 서, 남, 북 방향
int dr[4] = {0, 0, 1, -1};
int dc[4] = {1, -1, 0, 0};
char move_types[4] = {'R', 'L', 'D', 'U'};

int main(void) {
    cin >> n;
    cin.ignore();
    getline(cin, plans);

    // 이동 계획을 하나씩 확인
    for (int i = 0; i < plans.size(); i++) {
        char plan = plans[i];

        // 이동 후 좌표 구하기
        int tr = r, tc = c;
        for (int j = 0; j < 4; j++) {
            if (plan == move_types[j]) {
                tr += dr[j];
                tc += dc[j];
            }
        }

        // 공간을 벗어나는 경우 무시
        if (tr < 1 || tr > n || tc < 1 || tc > n) {
            continue;
        }

        r = tr;
        c = tc;
    }

    cout << r << " " << c << "\n";

    return 0;
}

 

C++에서는 순열과 조합을 각각 next_permutation, prev_permutation을 이용해 쉽게 구할 수 있다.

next_permutation

C++의 algorithm 헤더에는 n개의 원소의 순열을 구할 수 있는 next_permutation이라는 함수가 있습니다. 기본적 문법은 다음과 같습니다. 

 

1
2
3
4
5
6
7
// default
bool next_permutation (BidirectionalIterator first,
                         BidirectionalIterator last);
 
// custom
bool next_permutation (BidirectionalIterator first,
                         BidirectionalIterator last, Compare comp);
cs

 

next_permutation은 순열을 구할 컨테이너(쉽게 말해 배열)의 시작과 끝 iterator를 인자로 받습니다. 만약 해당 컨테이너에 다음 순열이 존재하면 그 컨테이너의 원소를 해당 순열 순서로 바꾸고 true를 반환하고, 다음 순열이 없다면 false를 반환합니다. 위 코드의 custom에서 볼 수 있듯이 비교 함수 comp를 인자로 넘기는 것도 가능합니다. 

 

 

 

next_permutation을 사용할 때는 몇 가지 주의할 점이 있습니다. 

  1. 오름차순으로 정렬된 값을 가진 컨테이너로만 사용가능합니다.
  2. default로 operator < 를 사용해 순열을 생성합니다. 즉 오름차순으로 순열을 생성합니다. 
  3. 중복이 있는 원소들은 중복을 제외하고 순열을 만들어줍니다. 

3번의 예시를 들자면 {0, 0, 1}과 같은 배열의 순열을 구한다면 중복을 제외한

{0, 0, 1}, {0, 1, 0}, {1, 0, 0}이 됩니다. 이를 이용해 조합(Combination)을 구할 수 있습니다. 


출처: https://mjmjmj98.tistory.com/38 [Live passionate😎]

 

<순열 - 중복 포함하지 않음> 

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

using namespace std;

vector<int> v{2, 1, 3};

int main(void) {

    // 1. next_permutation 사용을 위한 v 오름차순 정렬
    sort(v.begin(), v.end());

    // 2. 오름차순으로 정렬된 순열 출력
    do {
        for (auto it = v.begin(); it != v.end(); it++) {
            cout << *it << " ";
        }
        cout << endl;
    }
    while (next_permutation(v.begin(), v.end()));

    return 0 ;
}

<조합 - 중복 포함하지 않음>

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

using namespace std;

vector<int> v{2, 1, 3};
vector<int> t{1, 1, 0};

int main(void) {

    // 1. prev_permutation 사용을 위한 t 내림차순 정렬
    sort(t.begin(), t.end(), greater<int>());

    // 2. 내림차순으로 정렬된 데이터를 받아서 순열로 뽑고 그 부분만 v에서 뽑아내 조합으로 만들기
    do {
        for (int i = 0; i < v.size(); i++) {
            if (t[i]) {
                cout << v[i] << " ";
            }
        }
        cout << endl;
    }
    while (prev_permutation(t.begin(), t.end()));

    return 0 ;
}

do-while은 쓰면 안 좋다지만, 위의 경우는 예외로 쓰도록 하자.

참고로, next_permutation, prev_permutation 함수는 터미널에서는 기본 제공되지만, 백준과 같은 사이트에서 사용하려면

#include <algorithm>을 해줘야 한다.

 

시간복잡도

 - next_permutation 자체가 O(n)이고 nPr 자체는 O(n!) 이므로 위 템플릿처럼 순열 조합을 구현하면 O(n*n!)이 된다.

Prefix Sum : 여러 개의 누적합, 구간합을 묻는 쿼리에 대해 효율적인 알고리즘

  • 누적합 : [0, Right] 구간의 합
  • 구간합 : [Left, Right] 구간의 합

하나의 구간합을 구하는 것이 O(N)이고 구간합을 구하라는 M개의 쿼리가 들어온다면 시간복잡도는 O(NM)이다.

N = 1,000,000 이고 M = 1,000,000이라면 시간복잡도가 너무 안좋다.

이런 경우 사용하는 것이 Prefix Sum 이다.

누적합을 구해놓고 누적합 간에 -연산을 하면 구간합이기 때문이다.

 

- 시간복잡도 : O(N+M)

- 동작과정

  1) N개의 수에 대하여 접두사 합(Prefix Sum)을 계산하여 배열 P에 저장한다.

  2) 매 M개의 쿼리 정보 [L, R]을 확인할 때 구간 합은 P[R]-P[L-1]이다.

  ▪️ 이때, P[0] 에 0을 넣고 L >= 1 을 만들면 코드가 간결해진다.

 

#include <iostream>
#include <vector>

using namespace std;

int n = 5;
vector<int> data = {10, 20, 30, 40, 50};
vector<int> prefix_sum;

int main(void) {
    // 첫 배열 0 대입
    prefix_sum.push_back(0);

    int sum_value = 0;
    for (auto d : data) {
        sum_value += d;
        prefix_sum.push_back(sum_value);
    }

    int left = 3;
    int right = 4;
    cout << prefix_sum[right] - prefix_sum[left-1] << endl;


    return 0 ;
}

 

다만 위 코드는 90도 회전할 때마다 vector를 선언해 heap영역에 계속 쌓이게 된다.

여러번 돌리면 메모리 관점에서 좋지 않다.

그렇기 때문에 4번이 넘어가는 회전은 4로 나눈 나머지만 회전하는 등 사용에 주의가 필요하다.

투 포인터(Two Pointer) 알고리즘 : 리스트에 순차적으로 접근해야 할 때 2개의 점의 위치를 기록하면서 처리

 

투 포인터는 다음 2가지 케이스가 대표적인 문제 유형이다.

 

1. 특정한 합(M)을 가지는 부분 연속 수열 찾기

   - 시간복잡도 : O(N) { O(2*N)}

   - 동작과정

      1) 시작점(s)와 끝점(e)를 첫 번째 원소의 인덱스(0)로 지정한다

      2) s부터 e까지 현재 부분합이 특정한 합(M)과 같다면 카운트한다.

      3) s부터 e까지 현재 부분합이 특정한 합(M)보다 작다면 e를 1 증가시킨다.

      4) s부터 e까지 현재 부분합이 특정합 합(M)보다 크다면 s를 1 증가시킨다.

      5) 모든 경우를 확인할 떄까지 2)번부터 4)까지의 과정을 반복한다.

#include <iostream>
#include <vector>

using namespace std;

int n = 5, m = 5; // 데이터의 개수 n, 찾고자 하는 부분합 m

int arr[] = {1, 2, 3, 2, 5};

int main(void) {
    int cnt = 0;
    int interval_sum = 0;
    int e = 0;

    // start를 차례때로 증가시키며 반복
    for (int s = 0; s < n; s++) {
        // end를 가능한 만큼 이동시키기
        while (interval_sum < m && e < n) {
            interval_sum += arr[e++];
        }

        // 부분합이 m일 때 카운트 증가
        if (interval_sum == m) {
            cnt++;
        }

        interval_sum -= arr[s];
    }

    cout << cnt << endl;

    return 0 ;
}

2. 정렬되어 있는 두 리스트의 정렬된 합집합

    - 시간복잡도 : O(N+M) [N: a 크기, M: b 크기]

    - 동작과정

      1) 정렬된 리스트 a와 b를 입력 받는다.

      2) 리스트 a에서 처리되지 않은 원소의 인덱스를 i로 지정한다.

      3) 리스트 b에서 처리되지 않은 원소의 인덱스를 j로 지정한다.

      4) a[i]와 b[j]중 저 작은 원소를 결과(result) 리스트에 담고 해당 리스트의 인덱스(i 또는 j)를 1 증가시킨다.

      5) a와 b에 처리할 원소가 없을 때까지 2) ~ 4) 과정을 반복한다.

#include <iostream>
#include <vector>

using namespace std;

// 사전에 정렬된 리스트 a, b 선언
int n = 3, m =4;
vector<int> a = {1, 3, 5};
vector<int> b = {2, 4, 6, 8};

int main(void) {
    int i = 0;
    int j = 0;
    // 리스트 a와 b의 모든 원소 담을 수 있는 결과 리스트
    vector<int> result;

    // 더 이상 리스트 a와 b에 처리할 원소가 없을 때까지 반복
    while (i < n || j < m) {
        // 리스트 b의 모든 원소가 처리되었거나, 리스트 a의 원소가 더 작을 경우
        if (j >= m || (i < n && a[i] <= b[j])) {
            result.push_back(a[i++]);
        }
        // 처리되지 않은 리스트 b의 원소가 존재하고 리스트 a의 모든 원소가 처리되었거나, 리스트 b의 원소가 더 작을 경우
        else {
            result.push_back(b[j++]);
        }
    }

    for (int r : result) {
        cout << r << " ";
    }
    cout << endl;

    return 0 ;
}

 

2번 코드에서 while문 안의 if-else구문에 조건이 여러개 들어가는데 이 부분이 익숙하지 않다.

익숙해지도록 하자.

2차원 리스트를 반시계방향으 90도 돌리는 함수 템플릿이다.

자주 사용될 것 같아 template으로 등록한다.

java, python을 이용한 코드는 많지만 c++을 이용한 코드는 찾기 어려워 따로 정리한다.

시계방향으로 90도 돌리고 싶은 경우 해당함수를 3번 돌리자.

#include <iostream>
#include <vector>

using namespace std;

vector<vector<int> > rotate_a_matrix_by_90_degree(vector<vector<int> > &graph)
{
    int row_len = graph.size();
    int col_len = graph[0].size();

    vector<vector<int> > res(col_len, vector<int>(row_len, 0));    

    for (int i = 0; i < row_len; i++) {
        for (int j = 0; j < col_len; j++) {
            res[col_len-j-1][i] = graph[i][j];
        }
    }
    
    return res;
}
int main(void) {

    vector<int> t1 = {1,2,3,4};
    vector<int> t2 = {5,6,7,8};
    vector<int> t3 = {9,10,11,12};

    vector<vector<int> > a = {t1, t2, t3};


    // 반시계 방향 90도 돌리기
    vector<vector<int> > b = rotate_a_matrix_by_90_degree(a);

    for (int i = 0; i < b.size(); i++) {
        for (int j = 0; j < b[i]. size(); j++) {
            cout << b[i][j] << " ";
        }
        cout << endl;
    }

    // 시계 방향 90도 돌리기 =  반시계 방향 90도* 3 돌리기
    vector<vector<int> > c = rotate_a_matrix_by_90_degree(a);
    vector<vector<int> > d = rotate_a_matrix_by_90_degree(c);
    vector<vector<int> > e = rotate_a_matrix_by_90_degree(d);

    for (int i = 0; i < e.size(); i++) {
        for (int j = 0; j < e[i]. size(); j++) {
            cout << e[i][j] << " ";
        }
        cout << endl;
    }

    return 0 ;
}

1. 가장 기본적인 소수 판별 

   - 시간복잡도 : O(N)

#include <iostream>
#include <vector>

using namespace std;

int x;

bool isPrime(int x) {
    for (int i = 2; i < x; i++) {
        if (x % i == 0) {
            return false;
        }
    }
    return true;
}

int main(void) {
    cin >> x;

    cout << isPrime(x) << endl;

    return 0 ;
}

2. 성능을 높인 소수 판별

   - 시간복잡도 : O(sqrt(N))

#include <iostream>
#include <vector>
#include <cmath>

using namespace std;

int x;

bool isPrime(int x) {
    for (int i = 2; i <= sqrt(x); i++) {
        if (x % i == 0) {
            return false;
        }
    }
    return true;
}

int main(void) {
    cin >> x;

    cout << isPrime(x) << endl;

    return 0 ;
}

2. 에라토스테네스의 체(성능 업그레이드 버전)

   - 시간복잡도 : O(N*log(log(N)))

   - 다수의 소수를 구할 경우에 사용한다.

   - 동작 과정

      1) 2부터 N까지의 모든 자연수를 나열한다.

      2) 남은 수 중에서 아직 처리하지 않은 가장 작은 수 i를 찾는다.

      3) 남은 수 중에서 i의 배수를 모두 제거한다.(i를 제거하지는 않는다, N까지의 i의 배수 제거)

      4) 더 이상 반복할 수 없을 때까지 2), 3) 과정을 반복한다.

#include <iostream>
#include <vector>
#include <cmath>

using namespace std;

int n = 1000;
vector<bool> prime(n+1, true);

// 에라토스테네스의 체
void makePrime(int n) {
    for (int i = 2; i <= sqrt(n); i++) {
        if (prime[i]) {
            int j = 2;
            while (i * j <= n) {
                prime[i * j] = false;
                j++;
            }
        }
    }
}

int main(void) {

    makePrime(n);

    // 0, 1 은 소수가 아니다.
    prime[0] = false;
    prime[1] = false;

    for (int i = 0; i <= n; i++) {
        if (prime[i])
            cout << i << " ";
    }
    cout << endl;

    return 0;
}

 

총정리 : 다수의 소수를 판별할 땐 3번[O(N*log(log(N)))]을, 적은 수의 소수를 판별할 땐 2번[O(sqrt(N))]을 사용하자.

+ Recent posts