미루고 미뤘던 자료구조, 이번 기회에 트라이에 대해서 공부하게 됐다.

내가 여러 사이트를 검색하면서 공부한 것을 정리했다.

트라이(Trie) 자료구조란?

 

쉽게 생각해서 문자열을 빠르게 탐색할 수 있는 자료구조다.

얼마나 빠르길래 이렇게 따로 명칭까지 있는 것일까?

 

결론부터 말하자면 M이 최대 문자열의 길이라고 했을 때, O(M)만에 검색할 수 있다.

 

트라이를 표현하는 특징은 여러 가지가 있다.

1) 집합에 포함된 문자열의 접두사들에 대응되는 노드들이 서로 연결된 트리구조

2) 루트에서부터 내려가면서 만나는 글자들을 모으면 찾고자 하는 문자열의 접두사를 얻을 수 있다.

3) 문자열 트리 구조

 

트라이 작동 원리

 

트라이는 주어진 문자열을 이루고 있는 문자를 앞에서부터 하나씩 노드를 생성해가면서 만들어진다.

이 과정에서는 반복적으로 재귀 호출이 일어나게 된다.

  1. 주어진 문자열에서 현재 문자를 가져온다.
  2. 현재 문자로 이루어진 노드를 확인한다.
    1. 만약 노드가 존재한다면, 그 노드로부터 다음 문자열을 탐색하러 간다.
    2. 만약 노드가 없다면, 노드를 새로 만들고, 해당 노드를 통해 다음 문자열을 탐색한다.
  3. 문자열의 마지막이 될 때까지 위의 과정을 반복한다.

삽입하는 과정이면,

해당 문자열이 "ABC"라고 해보자.

루트 노드로부터 ABC 문자열에서 문자 A를 검색한다.

문자 노드 A가 있으면 그 아래 자식 노드를 검색하러 가고, 없으면 새롭게 A라는 노드를 만든다.

문자 노드 B가 있으면 그 아래 자식 노드를 검색하러 가고, 없으면 새롭게 B라는 노드를 만든다.

이렇게 쭉 가다가 C가 끝나고 그다음을 확인해보니까 문자열의 끝인 널문자에 도달하게 됐다.

만약에 ABC라는 문자열을 트라이에 저장했다고 한다면 그 끝을 표현하는 Bool 변수를 true로 만들어주면 된다.

 

검색하는 과정이라면,

해당 문자열에 앞에서부터 문자를 하나씩 살펴보면서 해당 문자가 노드가 없으면 false고,

있다면 그 문자에 연결된 다음 자식 노드를 확인해보러 가면 된다.

 

트라이 자료구조 장/단점

 

장점은 문자열을 빠르게 찾을 수 있다.

여기서 빠르게 찾는다는 것은 탐색과 삽입이 모두 O(M)에 이루어진다.

왜? 트라이에서 검색을 할 경우에 최대 트리의 높이까지 탐색하게 되는데,

이때 시간 복잡도는 O(H)이지만, 트리의 높이가 결국 최대 문자열의 길이가 되기 때문에 결국 O(M)에 가능하다.

 

어떻게 삽입하든 간에 트라이가 구성된 트리는 항상 똑같다. 이는 자동으로 정렬된 효과를 볼 수 있다.

왜냐하면 A로 시작하는 문자열은 A라는 노드 아래로 쭉 이어진 서브 트리를 이루는 형태이기 때문이다.

 

단점은 공간 복잡도가 매우 크다. 빠르면 대가가 있다.(trade-off 관계)

숫자에 대한 트라이를 만든다면 0~9인 총 10개의 포인터 배열 선언 + 문자열의 끝을 의미하는 bool

알파벳에 대한 트라이를 만든다면 a~z인 총 26개의 포인터 배열 선언 + 문자열의 끝을 의미하는 bool

 

최종적으로 메모리는 [포인터 크기 x 포인터 배열 개수 x 트라이에 존재하는 총노드의 개수]

 

아니 map을 이용하면 트라이랑 비슷하지 않을까?

 

우리가 배운 map은 red-black tree 구조인 균형 이진 탐색 트리다.

어떤 자료를 검색할 때 O(logN)이 걸린다는 것을 안다.

 

그런데 생각해보자.

여러 개의 문자열 중에서 우리가 찾는 문자열을 검색하는 시간은 O(logN x M)이다.

왜? 여러 개의 문자열 N에서 우리가 찾고자 하는 문자열의 길이가 M이기 때문이다.

 

그런데 트라이는?

O(M)만에 끝내버린다.

 

어떻게 구현할까?

 

다른 알고리즘처럼 틀이 존재한다.

트라이는 자료구조이기 때문에 입맛에 맞게 변형하여 사용하면 된다.

다만 이것을 어떻게 활용할지는 우리에게 달렸다.

#include <iostream>
#include <string>

using namespace std;

//트라이를 구성하는 노드는 자손 노드를 가리키는 포인터 목록과 문자열의 끝인지를 나타내는 변수로 구성된다.
struct Trie
{
    Trie* ch[26]; //26가지 알파벳에 대한 트라이
    bool end;     //끝나는 지점을 표시해줌
    
    //문자열을 트라이에 넣어주는 함수
    void insert(const char* s)
    {
        //문자열 끝에 도달하는 경우
        if(*s == '\0')
        {
            this->end = true;
            return;
        }
        
        //현재 문자가 노드에 있으면 넘어가고, 아니라면 새롭게 하나 만들어주고 넘어간다.
        int now = *s - 'A';
        if(!ch[now]) ch[now] = new Trie();
        ch[now]->insert(s + 1);
    }
    
    bool find(const char* s)
    {
        //문자열 끝에 도달했을 때
        if(*s == '\0')
        {
            //문자열의 끝이라면 찾은 것이고, 아니라면 탐색에 실패
            if(end) return true;
            return false;
        }
        //다음으로 가는 노드가 있으면 다음으로 가고, 아니라면 탐색에 실패
        int now = *s - 'A';
        if(!ch[now]) return false;
        return ch[now]->find(s + 1);
    }
    
    Trie() : end(false)
    {
        for(int i = 0; i < 26; i++)
            ch[i] = nullptr;
    }
    ~Trie()
    {
        for(int i = 0; i < 26; i++)
        {
         if(ch[i])
             delete ch[i];
        }
    }
};

int main()
{
    ios_base::sync_with_stdio(0);
    cin.tie(0);
    
    Trie* root = new Trie();
    string s;
    cin >> s;
    root->insert(s.c_str());
    string temp = "AAA";
    if(root->find(temp.c_str()))
        cout << "find!\n";
    else
        cout << "No\n";
    
    delete root;
    return 0;
}

 

'Algorithm > Algorithm Concept' 카테고리의 다른 글

위상 정렬 (TopologySort)  (0) 2021.10.12
행렬의 다양한 연산들  (0) 2021.10.11
플로이드 워셜 (Floyd Warshall)  (0) 2021.09.03
투 포인터  (0) 2021.08.25
다익스트라 알고리즘  (0) 2021.08.24
위상 정렬이란?

 

사전 작업, 선행 작업 등 순서가 정해진 작업을 차례대로 수행해야 할 때, 그 순서를 나열한 것.

사이클이 존재하지 않는 방향 그래프의 모든 노드를 순서에 거스르지 않도록 나열한 것.

 

'스타크래프트' 게임을 생각하면 쉽게 이해할 수 있다.

'서플', '배럭'을 짓기 위해서는 선행 작업으로 '커맨트 센터'를 먼저 만들어야 한다.

이 순서를 어기게 되면, 영원히 '서플'과 '배럭'을 건설할 수 없게 된다.

 

이렇듯, 선행이 필수인 것부터 나열해서 정렬하는 것이 위상 정렬이다.

 

위상 정렬의 동작 과정

 

  1. 진입 차수가 0인 노드 먼저 큐에 넣는다.
  2. 큐가 빌 때까지 반복한다.
    1. 큐에서 원소를 꺼내고, 그 노드에서 출발하는 간선들을 모두 제거한다.
    2. 이때 새롭게 진입 차수가 0인 노드들은 큐에 넣어준다.

큐에서 빠져 나오는 순서가 바로 구하려고 하는 위상 정렬이다.

진입 차수(indegree)는 해당 노드로 들어오는 간선의 수를 의미한다.

 

위상 정렬의 시간 복잡도

 

V : 노드의 수

E : 간선의 수

시간 복잡도 : 인접 리스트로 구현하면 O(V + E)지만, 인접 행렬로 구현하면 O(V^2)

 

차례대로 모든 노드들을 확인하면서 해당 노드에서 출발하는 간선들을 차례대로 제거하므로

결과적으로는 모든 노드와 모든 간선을 확인하기 때문이다.

 

위상 정렬 구현

 

위상 정렬에 필요한 것은 Queue의 자료구조

입력 범위에 따라 인접 행렬, 인접 리스트로 그래프를 구성하고 해결한다.

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

using namespace std;

const int MAX = 32000 + 1;

int V, E;
int indegree[MAX];
vector<int> adj[MAX];

void topologySort()
{
    vector<int> ret;
    queue<int> q;
    
    //처음 시작할 때는 진입차수가 0인 노드를 큐에 넣어준다.
    for(int i = 1; i <= V; i++)
    {
        if(indegree[i] == 0)
            q.push(i);
    }
    
    while(!q.empty())
    {
        int now = q.front();
        q.pop();
        
        //큐에서 빠져 나온 순서가 정렬 순서
        ret.emplace_back(now);
        
        //해당 원소와 연결된 노드들의 진입차수에서 1빼기
        for(int i = 0; i < adj[now].size(); i++)
        {
            int next = adj[now][i];
            indegree[next]--;
            //새롭게 진입차수가 0이 되는 노드는 큐에 넣어주자.
            if(indegree[next] == 0)
                q.push(next);
        }
    }
    
    for(const auto& i : ret)
    {
        cout << i << ' ';
    }
    cout << '\n';
}

int main()
{
    ios_base::sync_with_stdio(0);
    cin.tie(0);
    
    cin >> V >> E;
    for(int i = 0; i < E; i++)
    {
        int a, b;
        cin >> a >> b;
        adj[a].push_back(b);
        indegree[b]++;
    }
    
    topologySort();
    
    return 0;
}
한 걸음 더 나아가서

 

위상 정렬 도중에 큐가 비게 된다는 것은 사이클이 존재한다는 것을 의미한다.

운영체제 시간에 배운 순환 대기 형태를 생각해보면 이해하기 쉽다.

 

이렇게 순환 대기 형태를 이루게 되면(사이클을 이루게 되면) 순환 대기 형태를 띠는 노드들은 진입 차수가 0이 될 수 없으므로,

위상 정렬 큐에 들어가지 못하고 중간에 끝나버리게 된다.

 

앞서 말했듯이 위상 정렬은 모든 노드들과 모든 간선을 확인해야 하는데,

모든 노드를 탐색하기도 전에 큐가 empty 형태가 되어 끝나버리게 되는 것이다.

 

즉, 이러한 사이클 구조를 발견하기 위해서는 모든 노드를 탐색하기 전에 정렬이 끝나버리는 것을 체크하면 해결할 수 있다.

void topologySort()
{
	vector<int> ret;
	priority_queue<int, vector<int>, greater<int> > q;

	for (int i = 1; i <= v; i++)
	{
		if (indegree[i] == 0) q.push(i);
	}

	for (int i = 1; i <= v; i++)
	{
		// 위상 정렬 도중에 큐가 비게 된다면 사이클이 존재한다는 의미
		if (q.empty())
		{
			cout << -1 << '\n';
			return;
		}

		int now = q.top(); q.pop();
		ret.push_back(now);

		for (int i = 0; i < graph[now].size(); i++)
		{
			int next = graph[now][i];
			indegree[next]--;
			if (indegree[next] == 0) q.push(next);
		}
	}

	for (int n : ret) cout << n << '\n';
}

 

위상 정렬의 답은 1개가 아니다?

 

때로는 위상 정렬의 답은 여러개가 될 수 있다.

진입 차수가 0이 되는 노드가 2개 이상이 큐에 들어가는 경우가 발생하면 여러 가지 답이 나올 수 있다.

'Algorithm > Algorithm Concept' 카테고리의 다른 글

트라이 (Trie) 자료구조  (0) 2021.10.18
행렬의 다양한 연산들  (0) 2021.10.11
플로이드 워셜 (Floyd Warshall)  (0) 2021.09.03
투 포인터  (0) 2021.08.25
다익스트라 알고리즘  (0) 2021.08.24

간단하게 행렬을 표현하는 방법에서 코딩 테스트에서 자주 나오는 행렬에 대한 몇 가지 구현 방법을 공부해보자.

 

행렬의 표현

 

행렬의 표현은 자료구조에서 보통 2차원 배열로 표현하게 된다.

만약에 큐브라면 3차원 배열로 구현하면 된다.

//2 x 2 행렬
int a[2][2] = 
{
    {1, 1},
    {1, 1}
};

//3 x 3 행렬
int b[3][3] =
{
    {1, 2, 3},
    {4, 5, 6},
    {7, 8, 9}
};

// 2 x 5 행렬
int c[2][5] = 
{
    {1, 2},
    {3, 4},
    {5, 6},
    {7, 8},
    {9, 10}
};
행렬의 곱셈

 

"A의 행렬 x B의 행렬" 를 물어보는 문제가 간간히 등장한다.

이럴 때 행렬의 곱셈을 구현하는 방법을 따로 연습하지 않았더라면 꽤나 시간을 잡아먹을 수 있다!

 

핵심은 다음과 같다.

C의 행렬[i][j] = A의 행렬의 i 행 x B의 행렬의 j열

 

구현은 다음과 같이 할 수 있다.

#include <iostream>

using namespace std;

int main()
{
    int a[2][3] = {{1,2,3},{4,5,6}};
    int b[3][2] = {{7,8},{9,10},{11,12}};
    int c[2][2] = {{0,0},{0,0}};
    
    // 행렬의 곱셈 구현 : 시간복잡도 O(N^3)
    for(int i = 0; i < 2; i++) // a의 행
    {
        for(int j = 0; j < 2; j++) // b의 열
        {
            for(int k = 0; k < 3; k++) // a의 열 == b의 행
            {
                c[i][j] += a[i][k] * b[k][j];
            }
        }
    }
    
    for(int i = 0; i < 2; i++)
    {
        for(int j = 0; j < 2; j++)
        {
            cout << c[i][j] << ' ';
        }
        cout << '\n';
    }
    
    return 0;
}
행렬의 회전

 

행렬의 회전하는 것도 자주 등장하는 문제 중 하나다.

시계방향인지, 반시계방향인지 생각하고 구현 방법을 배워놓으면 나중에 실수를 줄일 수 있다.

#include <iostream>
#include <vector>

using namespace std;

typedef vector<vector<int> > matrix;

// N x N 시계방향으로 90도 회전
matrix rotateRight(const matrix& a)
{
    int n = static_cast<int>(a.size());
    matrix ret(n, vector<int>(n, 0));
    
    for(int i = 0; i < n; i++)
    {
        for(int j = 0; j < n; j++)
        {
            ret[j][n - 1 - i] = a[i][j];
        }
    }
    return ret;
}

// N x M 시계방향으로 90도 회전
matrix rotateRightOther(const matrix& a)
{
    int r = static_cast<int>(a.size());
    int c = static_cast<int>(a[0].size());
    //주의! M x N 으로 만들자.
    matrix ret(c, vector<int>(r,0));
    
    for(int i = 0; i < r; i++)
    {
        for(int j = 0; j < c; j++)
        {
            ret[j][r - 1 - i] = a[i][j];
        }
    }
    return ret;
}

//N x N 반시계방향으로 90도 회전
matrix rotateLeft(const matrix& a)
{
    int n = static_cast<int>(a.size());
    matrix ret(n, vector<int>(n,0));
    
    for(int i = 0; i < n; i++)
    {
        for(int j = 0; j < n; j++)
        {
            ret[n - 1 - j][i] = a[i][j];
        }
    }
    return ret;
}

// N x M 반시계방향으로 90도 회전
matrix rotateLeftOther(const matrix& a)
{
    int r = static_cast<int>(a.size());
    int c = static_cast<int>(a[0].size());
    matrix ret(c, vector<int>(r,0));
    
    for(int i = 0; i < r; i++)
    {
        for(int j = 0; j < c; j++)
        {
            ret[c - 1 - j][i] = a[i][j];
        }
    }
    return ret;
}

void printMatrix(const matrix& a)
{
    int r = static_cast<int>(a.size());
    int c = static_cast<int>(a[0].size());
    for(int i = 0; i < r; i++)
    {
        for(int j = 0; j < c; j++)
        {
            cout << a[i][j] << ' ';
        }
        cout << '\n';
    }
    cout << '\n';
}

int main()
{
    matrix a(2, vector<int>(2, 0));
    for(int i = 0; i < 2; i++)
        for(int j = 0; j < 2; j++)
            a[i][j] = i * 2 + j;
    
    cout << "a의 회전하기 전의 모양" << endl;
    printMatrix(a);
    
    cout << "a를 시계방향으로 90도 회전" << endl;
    printMatrix(a = rotateRight(a));
    
    cout << "a를 시계방향으로 180도 회전" << endl;
    printMatrix(a = rotateRight(a));
    
    cout << "a를 시계방향으로 270도 회전" << endl;
    printMatrix(a = rotateRight(a));
    
    cout << "a를 시계방향으로 360도 회전" << endl;
    printMatrix(a = rotateRight(a));
    
    cout << "a를 반시계방향으로 90도 회전" << endl;
    printMatrix(a = rotateLeft(a));
    
    cout << "a를 반시계방향으로 180도 회전" << endl;
    printMatrix(a = rotateLeft(a));
    
    cout << "a를 반시계방향으로 270도 회전" << endl;
    printMatrix(a = rotateLeft(a));
    
    cout << "a를 반시계방향으로 360도 회전" << endl;
    printMatrix(a = rotateLeft(a));
    
    matrix b(2, vector<int>(3, 0));
    int num = 0;
    for(int i = 0; i < 2; i++)
        for(int j = 0; j < 3; j++)
            b[i][j] = num++;
    
    cout << "b의 회전하기 전의 모양" << endl;
    printMatrix(b);
    
    cout << "b를 시계방향으로 90도 회전" << endl;
    printMatrix(b = rotateRightOther(b));
    
    cout << "b를 시계방향으로 180도 회전" << endl;
    printMatrix(b = rotateRightOther(b));
    
    cout << "b를 시계방향으로 270도 회전" << endl;
    printMatrix(b = rotateRightOther(b));
    
    cout << "b를 시계방향으로 360도 회전" << endl;
    printMatrix(b = rotateRightOther(b));
    
    cout << "b를 반시계방향으로 90도 회전" << endl;
    printMatrix(b = rotateLeftOther(b));
    
    cout << "b를 반시계방향으로 180도 회전" << endl;
    printMatrix(b = rotateLeftOther(b));
    
    cout << "b를 반시계방향으로 270도 회전" << endl;
    printMatrix(b = rotateLeftOther(b));
    
    cout << "b를 반시계방향으로 360도 회전" << endl;
    printMatrix(b = rotateLeftOther(b));
    
    return 0;
}

'Algorithm > Algorithm Concept' 카테고리의 다른 글

트라이 (Trie) 자료구조  (0) 2021.10.18
위상 정렬 (TopologySort)  (0) 2021.10.12
플로이드 워셜 (Floyd Warshall)  (0) 2021.09.03
투 포인터  (0) 2021.08.25
다익스트라 알고리즘  (0) 2021.08.24

이번에는 최단 경로 알고리즘 중 하나인 플로이드 워셜에 대해서 공부해보자.

모든 지점에서 다른 모든 지점까지의 최단경로를 모두 구해주는 알고리즘에 대해서 배워보자.

플로이드 워셜

모든 지점에서 다른 모든 지점까지의 최단 경로를 모두 구해주는 알고리즘이다.

 

쉽게 설명하면, A 노드에서 B 노드로 가는 최단 경로가

A -> B (한 번에 가는 것)

A -> K -> B (어떤 노드를 거쳐서 가는 것)

가 있을 텐데 이중 우리는 더 짧은 경로를 원하므로 min(A -> B, A -> K -> B)를 갱신하면 된다.

 

단계마다 거쳐 가는 노드를 기준으로 더 짧은 경로를 선택하며 반복하는 것이다.

이때 단계마다 비용이 O(N^2)이다.

거쳐 가는 노드를 기준으로 시작 노드에서 도착 노드까지 모든 경로를 검사하는데 드는 비용이 N^2 이므로

거쳐 가는 노드가 N개이면? 시간 복잡도는 O(N x N^2) = O(N^3)

 

플로이드 워셜 알고리즘은 다이나믹 프로그래밍의 기법이기도 하다.

점화식을 살펴보면 D[a][b] = min(D[a][b], D[a][k] + D[k][b])

구현 방법

플로이드 워셜은 사전 작업이 필요하다.

  • 2차원 배열을 무한 값으로 초기화
  • 자기 자신에서 자기 자신으로 가는 비용은 0으로 갱신
  • 이후 간선의 정보를 입력받고 플로이드 워셜을 수행
//플로이드 워셜
#include <iostream>
#include <algorithm>
using namespace std;

const int INF = 1e9;

int n, m;
int graph[501][501];
int main() {
    ios_base::sync_with_stdio(0); cin.tie(0); cout.tie(0);
    
    cin >> n >> m;
    //최단 거리 테이블을 모두 무한으로 초기화
    fill(graph[0], graph[0] + 501 * 501, INF);
    
    //자기 자신으로 가는 비용은 0으로
    for(int i = 1; i <= n; i++) {
        for(int j = 1; j <= n; j++) {
            if(i == j) graph[i][j] = 0;
        }
    }
    
    //각 간선에 대한 정보를 입력 받아 그 값으로 초기화
    for(int i = 0; i < m; i++) {
        int a, b, c;
        cin >> a >> b >> c;
        graph[a][b] = c;
    }
    
    //점화식에 따라 플로이드 워셜 알고리즘 수행
    //D[a][b] = min(D[a][b], D[a][k] + D[k][b])
    for(int k = 1; k <= n; k++) {
        for(int i = 1; i <= n; i++) {
            for(int j = 1; j <= n; j++) {
                graph[i][j] = min(graph[i][j], graph[i][k] + graph[k][j]);
            }
        }
    }
    
    //그래프 출력
    for(int i = 1; i <= n; i++) {
        for(int j = 1; j <= n; j++) {
            if(graph[i][j] == INF)
                cout << "INF\n";
            else
                cout << graph[i][j] << ' ';
        }
        cout << '\n';
    }
    return 0;
}

연습문제

플로이드

역사

회장뽑기

운동

플로이드2

경로찾기

'Algorithm > Algorithm Concept' 카테고리의 다른 글

위상 정렬 (TopologySort)  (0) 2021.10.12
행렬의 다양한 연산들  (0) 2021.10.11
투 포인터  (0) 2021.08.25
다익스트라 알고리즘  (0) 2021.08.24
최단 경로 알고리즘(Shortest Path)  (0) 2021.08.23

오늘은 투 포인터 알고리즘에 대해서 공부해보자.

이 투 포인터를 모르면 코테에서 낭패를 볼 수 있다?라고 말할 정도로 생각보다 중요한 알고리즘 테크닉이다.

남들도 다 아는 알고리즘을 나만 몰라서 틀린다.. 이거 매우 억울한 일이다. 얼른 공부해서 습득하자!

투 포인터 (Two pointer)

이름과 똑같이 두 개의 포인터를 가지고 문제를 해결하는 알고리즘이다.

문제를 풀다보면 배열의 길이가 2중 포문으로 해결을 할 수 없을 정도로 큰 배열이 주어지게 됐을 때, 구간의 합이 M인 곳이 몇 개 인지? 물어보는 문제에서는 완전 탐색으로는 시간 초과로 해결할 수 없는 문제가 있다.

대표적으로 BOJ2003 수들의 합2 문제와 BOJ2470 두 용액 문제다.

 

이런 문제들의 특징은 입력 크기가 너무 커서 완전 탐색으로는 해결할 수 없는 문제들이다.

이럴 때 사용하는 것이 바로 투 포인터 알고리즘이다.

 

투 포인터 알고리즘이 강력한 이유

두 개의 포인터만 하나의 배열에서 움직이므로 O(N) + O(N) = O(2N) => 시간 복잡도 O(N)

 

투 포인터 알고리즘의 유형은 2가지가 있는데 다음과 같다.

  1. 포인터 2개가 같은 방향으로 진행하기
  2. 포인터 하나는 왼쪽에서 오른쪽으로, 다른 하나는 오른쪽에서 왼쪽으로 진행하기

성능은 알겠는데 어떻게 구현하고 사용할까?

 

BOJ2003 수들의 합 2 

위의 문제를 예시로 살펴보자.

이 문제는 투 포인터 알고리즘 유형의 1번에 속하는 문제다.

포인터 2개가 같은 방향으로 진행하기

두 포인터가 배열의 첫 번째부터 시작해서 한 step씩 확인하면서 구간의 합이 M이 되는지 체크해서 문제를 해결해나간다.

들어가기 앞서서 사전 작업이 있다.

  • left 포인터와 right 포인터는 모두 0으로 시작한다.
  • 현재 구간의 합은 left와 right모두 0을 가리키고 있기 때문에 배열의 첫 번째 값이 된다.
  • 반드시 left는 right보다 같거나 작아야 한다. 이 순서가 꼬이게 되면 알 수 없는 상황이 발생할 수 있다.

위의 사전 작업을 숙지하고 아래를 보자.

  1. 현재 구간의 합 sum < M
    1. 아직 구간의 합이 M보다 작기 때문에 늘리려면 어떻게 할까? right 포인터를 한 칸 오른쪽으로 늘리면 된다.
  2. 현재 구간의 합 sum > M
    1. 구간의 합이 M보다 커졌다. 이럴 때는 줄여야 한다. left 포인터를 한 칸 오른쪽으로 늘리면 된다.
  3. 현재 구간의 합 sum == M
    1. 우리가 원하는 구간의 합 M을 찾았기 때문에 정답을 +1 하자.
    2. 그런 다음 두 포인터를 모두 한 칸씩 늘리면 된다. 왜?
      1. 현재 구간의 합이 답이 되기 때문에 left를 한 칸 늘리거나, right를 한 칸 늘려도 절대 답이 될 수 없기 때문이다.

위의 이론을 바탕으로 구현을 하게 되면 아래의 구현처럼 된다.

//2003 수들의 합2
#include <iostream>
using namespace std;

int main() {
    ios_base::sync_with_stdio(0); cin.tie(0); cout.tie(0);
    
    int n, m;
    int arr[10001];
    
    cin >> n >> m;
    for(int i = 0; i < n; i++) cin >> arr[i];
    
    int left = 0;
    int right = 0;
    int sum = arr[0]; //처음에 left, right가 모두 0을 가르키고 있기 때문에 합은 첫번째 배열의 값이다.
    int ans = 0;
    while(right < n) {
        //합이 m보다 작으면 right 포인터를 오른쪽으로 이동
        if(sum < m) {
            right++;
            if(right < n)
                sum += arr[right];
        }
        //합이 m보다 크다면 left 포인터를 오른쪽으로 이동
        else if(sum > m) {
            sum -= arr[left];
            left++;
        }
        //합이 같다면 두 포인터 모두 오른쪽으로 이동하고, 정답을 +1
        else {
            sum -= arr[left];
            left++;
            right++;
            if(right < n)
                sum += arr[right];
            
            ans++;
        }
    }
    cout << ans << endl;
    return 0;
}

처음 봐도 이해가 잘 되지 않는다면, 공책을 펼쳐놓고 직접 디버깅해보자!

어떤 알고리즘이든 한 번에 습득하기 어렵다. 반복 숙달하자.

 

BOJ2470 두 용액

이번에는 다른 스타일의 문제를 살펴보자.

이 문제의 유형은 위에서 말했듯이 2번 스타일이다.

포인터 하나는 왼쪽에서 오른쪽, 다른 하나는 오른쪽에서 왼쪽으로 진행

 

그렇다면 사전 작업은 어떻게 할까?

  • left는 0, right는 n - 1
  • ans는 초기 두 용액의 합으로 나올 수 있는 절댓값인 20억으로 세팅하기
  • 합이 0에 가까운 두 용액을 저장할 변수

사전 작업을 숙지하고 어떻게 풀지 생각해보자.

두 용액의 합을 0에 가깝게 만들기 위해서는 최대한 왼쪽에 있는 애들은 오른쪽 애들보다 작아야 할 것이다.

그렇기 때문에 처음에 정렬을 수행한다. 이렇게 되면 왼쪽에는 작은 애들이 몰리고, 오른쪽은 큰 애들이 몰린다.

이렇게 세팅을 한 후 두 용액을 합쳤을 때 기존에 저장된 ans보다 더 작고 0에 가깝다면 갱신을 시키면서 문제를 해결해나간다.

정리하면 다음과 같다.

  • 우선 입력된 배열을 정렬한다.
  • 두 용액의 합의 절댓값이 기존에 저장된 ans보다 더 작다면? 갱신하자.
    • ans = abs(arr[left] + arr[right]
    • ret[0] = arr[left] 두 용액 중 왼쪽 용액 저장
    • ret[1] = arr[right] 두 용액 중 오른쪽 용액 저장
  • arr[left] + arr[right] < 0
    • 두 용액의 합이 0보다 작다는 말은 무슨 뜻일까?
    • 0에 가깝게 만들기 위해서는 left를 오른쪽으로 옮겨주면 된다.
  • arr[left] + arr[right] > 0
    • 반대로 0보다 크다면?
    • 0에 가깝게 만들기 위해서는 right를 왼쪽으로 옮겨주면 된다.

위의 이론을 바탕으로 구현을 하게 되면 아래의 구현처럼 된다.

//2470 두 용액
#include <iostream>
#include <algorithm>
#include <cmath>
using namespace std;

int n;
int arr[100001];

int main() {
    ios_base::sync_with_stdio(0); cin.tie(0); cout.tie(0);
    
    cin >> n;
    for(int i = 0; i < n; i++) cin >> arr[i];
    sort(arr, arr + n); //정렬 필수, 서로 합이 0에 가깝게 만들기 위해서
    
    int left = 0;
    int right = n - 1;
    int ans = 2e9; // 두 용액의 합을 최대 20억으로 잡아두고 시작
    int ret[2];
    while(left < right) {
        //두 용액의 합이 0에 가까운지 확인하기
        int value = arr[left] + arr[right];
        
        if(ans > abs(value)) {
            ret[0] = arr[left];
            ret[1] = arr[right];
            ans = abs(value);
            
            // 합이 0이면 탈출하자.
            if(value == 0) break;
        }
        
        if(value > 0) right--;
        else left++;
    }
    cout << ret[0] << ' ' << ret[1] << endl;
    return 0;
}

정리해보면 투 포인터 알고리즘은 다음과 같다.

유형에 익숙해지는 것도 좋지만,

투 포인터를 어떤 조건일 때는 증가시키고 감소시키는지를 잘 결정하면 어떤 유형이 나오더라도 해결할 수 있을 것이다.

연습문제

부분합

수 고르기

회전 초밥

소수의 연속합

세 용액

[카카오 인턴] 보석 쇼핑

'Algorithm > Algorithm Concept' 카테고리의 다른 글

행렬의 다양한 연산들  (0) 2021.10.11
플로이드 워셜 (Floyd Warshall)  (0) 2021.09.03
다익스트라 알고리즘  (0) 2021.08.24
최단 경로 알고리즘(Shortest Path)  (0) 2021.08.23
수학 관련  (0) 2021.08.18

이번에는 최단 경로 알고리즘 하면 떠오르는 알고리즘 중 하나인 다익스트라 알고리즘에 대해서 정리해보자.

다익스트라 알고리즘 (Dijkstra Algorithm)

다익스트라 알고리즘의 정의부터 보자.

하나의 정점에서 출발해서 다른 노드들로 가는 각각의 최단 경로를 모두 구해주는 알고리즘이다.

용어로는 '단일 출발 최단 경로' 라고도 한다.

또, '단일 도착 최단 경로' 도 뒤집으면 똑같은 말이기도 하다.

모든 정점에서 출발해서 어떤 하나의 정점까지의 최단 경로를 찾는 문제로 뒤집어서 생각하면 다익스트라와 똑같기 때문이다.

 

다만, 음의 간선에서는 사용할 수 없다. 이 때는 벨만-포드 알고리즘을 사용해야 한다. (아직 안 배웠다..)

 

사실 다익스트라 알고리즘 단계별 그림은 다른 블로그들의 설명도 너무 좋아서 핵심만 요약하는게 좋을 것 같다고 생각한다.

다익스트라 step

가장 먼저 출발 노드를 설정하기.

최단 거리 테이블을 무한대로 초기화 하기 (여기서 말하는 무한대는 아직 가보지 않은 곳을 무한대라고 표기한다.)

#1 방문하지 않는 노드 중에서 최단 거리가 가장 짧은 노드를 선택한다.

#2 해당 노드를 거쳐서 다른 노드로 가는 비용을 계산한 후 최단 거리 테이블을 갱신한다.

#1과 #2를 반복하면서 최단 거리를 갱신해 나간다.

 

중요한 건 한 단계마다 하나의 노드에 대한 최단 거리를 찾게 된다는 점이다.

다익스트라 구현

구현 방법으로는 2차원 배열을 이용한 방법과 인접 리스트와 우선순위 큐를 이용한 방법이 있다.

2차원 배열로 구현을 하면 시간 복잡도가 O(V^2)이고, 인접 리스트와 우선순위 큐로 구현을 하면 O(ElogV)로 더 빠르게 가능하다.

 

우선순위 큐의 등장은 힙의 자료구조를 사용하는 개념 때문에 도입됐다.

#1에서 방문하지 않는 노드 중에서 최단 거리가 가장 짧은 노드를 선택할 때 모든 노드를 탐색하면 O(V)이지만, 최소 힙을 통해 찾게 된다면 O(logV)만에 찾을 수 있기 때문이다.

 

보통 코딩 테스트에서는 개선된 다익스트라 알고리즘으로 구현하는 것이 높은 성공률을 보여주기 때문에, 우선순위 큐를 이용한 구현 방법을 살펴보자.

 

이것도 중요할 수 있다. 우선순위 큐에 들어가거나 나오는 요소는 어떤 의미를 가질까?

요소는 {비용, 노드} 로 되어있는데, 이를 뜻하는 바는 노드까지 가는 최단 거리가 비용이라는 의미다.

 

또!

우선순위 큐를 기반으로 구현하게 되면, '서로 다른 두 정점 사이에 여러 개의 간선이 존재할 수 도 있다' 라는 것도 해결할 수 있게 된다.

 

코드의 주석까지 자세하게 달아놨다. 미래의 내가 까먹더라도 어떤 의미인지 빠르게 찾기 위해서..

반복 숙달하자!

#include <iostream>
#include <queue>
#include <algorithm>
#include <vector>
using namespace std;

int n, m, start;
vector<pair<int, int> > adj[100001];
int dist[100001];

void dijkstra(int start) {
    priority_queue<pair<int, int> > pq; // default : max-heap
    pq.push({0, start}); // {비용, 노드}
    dist[start] = 0;
    while(!pq.empty()) {
        int cost = -pq.top().first; // 앞에 -를 붙이면 min-heap 으로 가능하다. (테크닉)
        int now = pq.top().second;
        pq.pop();
        
        // 중요: 앞서 말했듯이 우선순위 큐에서 요소를 뽑았을 때 그 의미는 다음과 같다.
        // now 노드까지 가는 최단 거리가 cost야.
        // 그런데 이미 더 적은 최단 거리로 갱신이 됬다면? 이 노드는 이미 처리가 됬다는 뜻이다.
        if(dist[now] < cost) continue;
        
        // 현재 노드와 연결된 다른 인접한 노드들을 확인한다.
        // 혹여나 현재 노드를 거쳐서 다른 노드의 거리를 갱신할 수 있으니까 확인해보는 것
        for(int i = 0; i < adj[now].size(); i++) {
            int next = adj[now][i].first;
            int nextCost = cost + adj[now][i].second;
            // 현재 노드를 거쳐서 다른 노드로 이동하는 거리가 더 짧은 경우 갱신해준다.
            // 현재 노드까지의 최단거리 + 다른 노드의 거리 < 테이블[다른 노드] 최단 거리
            if(nextCost < dist[next]) {
                dist[next] = nextCost;
                pq.push({-nextCost, next}); // 마찬가지로 넣을 때 비용은 앞에 -붙여서 넣으면 min-heap구동
            }
        }
    }
}

int main() {
    cin >> n >> m >> start;
    
    // 모든 간선 정보를 입력받기
    for(int i = 0; i < m; i++) {
        int a, b, c;
        cin >> a >> b >> c;
        // a 노드에서 b 노드로 가는 비용이 c 라는 의미
        adj[a].push_back({b,c});
    }
    
    // 문제에 따라 다르겠지만 보통은 나올 수 없는 값으로 무한대를 초기화 시켜준다.
    fill(dist, dist + 100001, 987654321);
    
    // 다익스트라 알고리즘 수행
    dijkstra(start);
    
    // 거리 테이블 출력
    for(int i = 1; i <= n; i++) {
        if(dist[i] == 987654321)
            cout << "INF\n";
        else
            cout << dist[i] << '\n';
    }
    
    return 0;
}

연습 문제들

확실히 어려운 개념이다 보니 랭크도 다르다..

최단경로

파티

녹색 옷 입은 애가 젤다지?

거의 최단경로

도로포장

해킹

도로검문

특정한 최단경로

KCM Travel

지름길

최소비용 구하기

택배 배송

간선 이어가기 2

백도어

'Algorithm > Algorithm Concept' 카테고리의 다른 글

플로이드 워셜 (Floyd Warshall)  (0) 2021.09.03
투 포인터  (0) 2021.08.25
최단 경로 알고리즘(Shortest Path)  (0) 2021.08.23
수학 관련  (0) 2021.08.18
백트래킹 (Backtracking)  (0) 2021.07.28

오늘은 최단 경로 알고리즘에 대해서 간단하게 개요를 정리하자.

최단 경로란?

 

그래프 상에서 가장 짧은 경로를 찾는 경로, 가중치 그래프에서는 간선의 가중치의 합이 최소가 되는 경로를 찾는 문제다.

그런데, 최단 경로라고 해도 문제마다 다르다. 어떤 경우들이 있을까?

 

단일 출발 최단 경로 : 어떤 하나의 정점에서 출발하여 나머지 모든 정점까지의 최단 경로

단일 도착 최단 경로 : 모든 정점에서 출발하여 어떤 하나의 정점까지의 최단 경로 (뒤집어서 생각하면 단일 출발 최단 경로다)

단일 쌍 최단 경로 : 모든 정점에서 모든 정점까지의 최단 경로

최단 경로 알고리즘

 

최단 경로 문제가 나오게 되면 이를 해결할 알고리즘들이 있다.

 

BFS(완전 탐색)

가중치가 없거나 가중치가 모두 동일한 경우(미로 탐색 문제)에서는 최단 경로를 구할 수 있게 된다.

(1, 1) -> (N, M)

# 특이하게도, 0의 가중치와 1의 가중치가 동시에 존재하는 0-1 BFS도 있다.

이럴 때는 0의 가중치를 큐의 앞부분에 우선적으로 배치하여야 한다.

 

다익스트라 알고리즘

음이 아닌 가중치의 그래프에서만 사용할 수 있는 알고리즘.

어떤 하나의 정점에서 나머지 모든 정점까지의 최단 경로를 구할 때 사용한다.

 

벨만 포드 알고리즘

음인 가중치도 사용할 수 있는 알고리즘

나머지는 다익스트라 알고리즘과 비슷하다.

 

플로이드 워셜 알고리즘

모든 정점 사이의 최단 경로를 모두 구하는 알고리즘

3중 포문으로 구현이 쉽다.

 

여러 알고리즘을 알았으니, 제일 좋은 공부법은 알고리즘 틀을 배우고 빠르게 여러 문제들을 풀면서 복습하는 느낌으로 공부하자.

'Algorithm > Algorithm Concept' 카테고리의 다른 글

투 포인터  (0) 2021.08.25
다익스트라 알고리즘  (0) 2021.08.24
수학 관련  (0) 2021.08.18
백트래킹 (Backtracking)  (0) 2021.07.28
파라메트릭서치 (Parametric Search)  (0) 2021.05.09

최근 코테에서 자주 나오는 부분이 모듈러 연산, 최대 공약수, 소수다.

자주 풀어보지 않기 때문에 막상 나오면 헤매는 경우가 잦기 때문에 오늘 정리하려고 한다.

아래의 정리는 백준의 강의를 듣고 감명받은 점들과 나의 생각을 정리해서 적어놓았다.

모듈러 연산(나머지 연산, modular)

말 자체가 처음에는 와닿지 않는 용어지만 % 기호를 보면 한 번에 이해하기 쉽다.

10 % 7 => 3
6 % 7 => 6
0 % 7 => 0
7 % 7 => 0

어떤 수를 7로 모듈러 연산한다고 하면 나올 수 있는 경우의 수는 0 ~ 6 다.

아무리 큰 수가 와도 저 안에 머무른다는 소리다.

 

응용

어떤 물고기가 (1,1) 에서 5 x 5 격자 안에서 움직인다고 할 때 5의 속력으로 동쪽으로 이동한다고 하면?

(1,1) -> (1,2) -> (1,3) -> (1,4) -> (1,5) -> (1,1)

한 칸 씩 이동한다고 구현하면 위와 같이 5번을 다 계산하지만 모듈러 연산을 쓰면 다음과 같이 편하게 압축(최소화)할 수 있다.

y축(열)만 보면 (1 + 5) % 5 = 1로 쉽게 압축할 수 있다.

 

음수는 예외적으로 뒤에다가 나머지 연산하는 수를 붙이지만 덧셈과 곱셈은 다음과 같이 풀어서 사용이 가능하다.

단, 나누기는 제외

(a + b) % c = (a % c + b % c) % c
(a * b) % c = (a % c * b % c) % c
(a - b) % c = (a % c - b % c + c) % c

이렇게 풀어서 사용하는 경우는 오버플로우를 방지하기 위해서다.

 

최대공약수 GCD( Greatest Common Divisor)

정의는 두 수 a와 b의 공통된 약수 중에서 가장 큰 정수다.

최대공약수를 빠르게 구하는 알고리즘은 유클리드 호제법이다.

반복문보다는 재귀로 구현하는 게 훨씬 기억하기 쉽고 실수를 최대한으로 줄일 수 있다.

시간 복잡도는 매 턴마다 b로 나누기 때문에 O(lgB) = O(lgN)

//재귀 구현
int gcd(int a, int b) {
	if(b == 0) return a;
    return gcd(b, a%b);
}

//반복문 구현
int gcd(int a, int b) {
	while(b != 0) {
    	int r = a % b;
        a = b;
        b = r;
    }
}

응용

최대공약수를 구할 수 있으면 최소공배수도 쉽게 구할 수 있다.

중간중간에 나눠주는 이유는 오버플로우 방지

//g: 최대공약수
int lcm(int a, int b, int g) {
	return g * (a / g) * (b / g);
}

소수

정의는 약수가 1과 자기 자신밖에 없는 수를 소수라고 한다.

 

어떤 수가 소수인지 아닌지 판별하는 방법

판별하는 방법은 그 수를 n이라고 하면 2부터 sqrt(n)까지 나누어 떨어지면 그 수는 소수가 아니라는 방법을 이용해서 구현한다.

보통은 루트는 정수로 떨어지지 않기 때문에 정수로 떨어트리기 위해서 양변을 제곱하는 형태로 취하는 것이 좋다고 한다.

2 <= i <= sqrt(n)
4 <= i * i <= n

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

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

대량의 소수를 빠르게 구하는 방법 => 에라토스테네스의 체

이 방법은 다음과 같은 방법으로 소수를 판별한다.

2는 소수이고, 2의 배수는 모두 지운다.

3은 소수이고, 3의 배수는 모두 지운다.

5는 소수이고, 5의 배수는 모두 지운다.

....

지워진 수는 소수가 아니고 남아있는 수가 소수라는 뜻이다.

시간 복잡도 O(NlglgN)

int prime[100];  // 소수 저장
int pn = 0;      // 소수의 개수
bool check[101]; // 지웠다면 true, 아니라면 false
int n = 100;

for(int i = 2; i <= n; i++) {
	//2는 냅두고,
	if(check[i] == false) {
    	//소수를 저장하고 싶으면
    	prime[pn++] = i;
    	//2의 배수는 모두 지운다.
    	for(int j = i + i; j <= n; j += i) {
        	check[j] = true;
        }
    }
}

자주 자주 나오는 문제의 유형은 아니지만 꼭 기억해두자.

 

연습문제

소수 찾기

소수 구하기

나머지

최대공약수와 최소공배수

최소공배수

GCD 합

골드바흐의 추측

'Algorithm > Algorithm Concept' 카테고리의 다른 글

다익스트라 알고리즘  (0) 2021.08.24
최단 경로 알고리즘(Shortest Path)  (0) 2021.08.23
백트래킹 (Backtracking)  (0) 2021.07.28
파라메트릭서치 (Parametric Search)  (0) 2021.05.09
[cmath] math 관련 함수들  (0) 2021.05.07
백트래킹(Backtracking)

 

알고리즘을 어느 정도 공부하게 되면 백트래킹이라는 용어를 많이 들어봤을 것이다.

가끔 백트래킹이랑 완전 탐색을 같은 용어로 사용하게 되는데 오늘 확실히 정리해서 이해하도록 하자.

 

많은 블로그들을 검색하면서 공부했지만 이게 가장 나에게는 이해가 되는 정의였다.

 

해를 찾는 도중 다음의 경우가 해가 아니라면 되돌아가서 다시 해를 구하는 기법

 

무슨 말이냐면 분명 이 길로 가면 막히는 곳이 나오는데?라고 생각되면 다른 곳으로 방향을 트는 것이다.

이미 그 길은 내가 끝까지 가보지 않더라도 막힌 길을 알기 때문이다.

 

DFS 기법으로 하게 되면 막힌 곳을 알더라도 끝까지 가서 확인한다.  -무식한 방법-

백트래킹 기법으로 하게 되면 "아 이 길은 막힌 곳이니까 다른 곳으로 가자." -현명한 방법-

 

백트래킹에 사용되는 용어들

 

유망하다(promising) : 해가 될 가능성이 존재하는 루트

가지치기(pruning) : 배제하다. 유망하지 않다.

 

막힌 곳인 걸 알면 그 길은 배제하고 나머지 가능한 길만 생각한다는 것.

현재 위치에서 다음 위치에 대해서 유망한 지 검사하기.

 

테크닉, 상상해보자.

현재 돌에서 a, b, c, d 돌로 넘어가야 한다.

내가 현재 돌에서 a로 일단 이동해본다.

a로 왔는데 규칙에 어긋나지 않다면?(유망하다면) 다음 단계로 진행하고,

아니라면 현재 돌에서 a를 배제한 나머지 돌에서 다시 이동을 시도해본다.

 

또 다른 방법은

일단 두고 시작한다.

바둑 판을 상상해보면 일단 바둑알을 바둑 판에 두고 경기를 끝내기(하나의 경우의 수)

다시 돌아와서 바둑 판에 뒀던 바둑알을 무르고 다시 다른 곳에 두는 것이다.

돌아올 때는 반드시 내가 방문했던 곳을 지워야 한다. (복원하기)

어찌 보면 유망하기보다는 완전 탐색에 가깝다고 생각된다.. 써보고 보니까

 

+(추가)

아래와 같이 이런 류의 기법들은 흔히 완전 탐색이지만,

중간에 가지치기를 하는 것이 있다면 백트래킹이라고 생각하면 된다.

백트래킹은 그냥 완탐 처럼 보이지만 중간에 자를 수 있거나 배제할 수 있다면 이것 또한 백트래킹이기 때문이다.

// 흔히 보는 완탐
vis[i] = true;
dfs(cnt + 1);
vis[i] = false;

// 하지만? 이렇다면 백트래킹
if(promising() == false) continue;
정리

 

한마디로 모든 경우의 수를 보는 완전 탐색과 비슷하지만, 조건을 만족할 때(유망할 때)만 확인한다는 점에서 다르다.

 

대표적인 문제는  N-Queen

 

'Algorithm > Algorithm Concept' 카테고리의 다른 글

최단 경로 알고리즘(Shortest Path)  (0) 2021.08.23
수학 관련  (0) 2021.08.18
파라메트릭서치 (Parametric Search)  (0) 2021.05.09
[cmath] math 관련 함수들  (0) 2021.05.07
[GCD, LCM] 유클리드 호제법  (0) 2021.05.07

파라메트릭 서치

생각보다 받아들이는데 어려운 개념이다.

이 알고리즘은 최적화 문제결정 문제로 바꾸는 기법이다.

조건을 만족하면서도 최대한 많이 가져갈 수 있는 방법을 찾는 느낌?? 혹은

조건을 만족하면서도 최대한 적게 가져갈 수 있는 방법을 찾는 느낌이라고 생각하면 좋다.

 

예시를 들어보면 아래의 문제를 인용해서 생각해보자.

BOJ2110 공유기 설치

대표적인 파라메트릭 서치 문제다.

공유기 C개를 설치해야 하는데 두 공유기 사이의 거리를 최대한으로 하려면 어느 정도쯤에 설치해야 할까?라는 문제다.

 

(아래의 예시는 위의 문제 스포가 될 수 있어서 임의로 그냥 생각한 풀이입니다.)

예를 들어보자.

공유기 3개를 설치해야 하는데

만약에 내가 두 공유기 사이의 거리를 2로 설치한다고 가정했을 때,

2씩 공유기를 설치해보니 공유기 3개가 모두 설치되었다. 이는 조건을 만족하니까 우리가 찾는 정답이므로 2로 갱신한다.

 

그렇다면, 여기서 끝일까? 아니다.

한 발자국 더 나아가 보면서 생각해 봐야 한다.

두 공유기 사이의 거리를 3으로 확장해보면 어떨까?

3씩 공유기를 설치해보니 공유기 3개가 모두 설치되었다. 분명 2일 때도 공유기 3개가 모두 설치되었는데? 조금 더 멀리 설치해도 공유기 3개가 모두 설치된 것이다. 마찬가지로 조건을 만족하니까 기존에 저장했던 2를 3으로 갱신한다.

 

그렇다면, 진짜로 끝일까? 아니다.

한 발자국 더 나아가 보자.

두 공유기 사이의 거리를 4로 확장해보면?

4씩 공유기를 설치해보니 공유기 2개가 설치되고 1개는 설치되지 못했다.

이렇게 된다면 주어진 조건 공유기 3개를 설치하지 못했으므로 정답이 아니다.

따라서 여기서 탐색을 멈춘다. 즉, 정답에 저장된 3이 답이 된다.

 

이렇듯, 범위를 좁혀가면서 주어진 조건을 만족하면 한 걸음 더 나아가 보고 아니라면 돌아오는 느낌으로 탐색을 하면 된다.

 

이런 기법은 많은 문제를 풀면 느낌이 오게 된다.

아래의 문제들은 백준 사이트에서 파라메트릭 서치 대표 문제들이다.

시간 나면 다시 풀어보자.

랜선 자르기

기타 레슨

공유기 설치

나무 자르기

 

기본적인 구현은 다음과 같이 생각하면 좋다.

1. 초깃값 세팅 start, end

2. mid가 결정된 후 그 mid가 조건에 맞는지 검사하기

3. 조건에 따라 범위 수정 후에 다시 2번으로 돌아가기

int start = 0; // 문제마다 start와 end 값은 변동될 수 있다.
int end = 1e8; // 범위가 커진다면 long long 자료형으로 오버플로우를 방지할 수 있다.
int ans = 0;
while(start <= end)
{
    int mid = (start + end) / 2;
    if(calc(mid)) // 조건을 만족한다면
    {
    	ans = mid;
        s = mid + 1; // 한 걸음 더 가보자.
    }
    else
    	e = mid - 1; // 조건을 만족하지 못하니까, 탐색 범위를 줄여보자.
}

또, 파라메트릭 서치 같은 문제들은 범위가 어마어마하게 크다. 보통 O(N)으로 해결될 수 없는 문제들 같은 경우 이러한 방법도 생각해보는 것도 좋다.

'Algorithm > Algorithm Concept' 카테고리의 다른 글

수학 관련  (0) 2021.08.18
백트래킹 (Backtracking)  (0) 2021.07.28
[cmath] math 관련 함수들  (0) 2021.05.07
[GCD, LCM] 유클리드 호제법  (0) 2021.05.07
stable sort과 unstable sort  (0) 2021.04.26

+ Recent posts