라인 스위핑 알고리즘이란, 1차원 좌표(수직선)에 그려진 여러 선분을 합치는 알고리즘이다.

 

위의 그림과 같이, 수직선에 5개의 선분을 긋고자 한다고 해보자.

 

위의 그림에선, 여러 선분들이 서로 겹치지 않게 그려있지만 수직선은 1차원 좌표이기 때문에 실제로 수직선에 선분을 긋게 되면 선분들은 서로 겹치게 된다.

 

이런 식으로, 실제로 좌표에 선분을 긋게되면 범위가 겹치는 선분은 모두 하나로 합쳐지게 된다.

이런 식으로 여러 구간이 주어졌을 때, 겹치는 구간을 모두 합치고자 할 때 사용하는 알고리즘이 라인 스위핑 알고리즘이다.

 

위와 같은 선분에 대해서만 사용할 수 있는 것이 아니라, 시간처럼 시작과 끝이 주어질 수 있는 대상에 대해서는 모두 사용이 가능하다.

 

라인 스위핑

 

위처럼, 여러 구간을 하나로 합치려면 일반적으로는 이중 반복문으로 구현하게 된다.

현재 선분과 다른 모든 선분을 비교하여, 구간이 겹치는 선분이 있다면 하나로 합치는 것이다.

이런 방식은, O(N^2)의 시간복잡도를 지니게 된다.

 

너무 시간복잡도가 크기때문에, 이를 줄이기 위해 라인 스위핑을 사용하는 것이다.

라인 스위핑은 먼저 선분을 시작지점을 기준으로 정렬해야 한다. 

 

입력이 아래와 같이 주어졌다고 해보자.

 

 

자료구조에 선분의 정보를 저장한 뒤, 시작점을 기준으로 정렬하게 되면

노란색 -> 초록색 -> 보라색 -> 하늘색 -> 갈색 순서로 선분을 처리하게 된다. (노란색과 초록색은 바뀔 수도 있다.)

 

먼저, 합쳐진 선분에 대한 정보를 MergeLine 이라는 곳에 저장한다고 해보자.

가장 처음 비교할 선분은 노란색 선분이다. 노란색 선분에 대해서는 비교할 정보가 없기 때문에 MergeLine을 최초에는 노란색 선분으로 저장해주어야 한다.

 

 

 

이후, MergeLine과 초록색 선분을 비교해보자.

 

MergeLine의 시작점 : 0

MergeLine의 끝점 : 2

 

초록색 선분의 시작점 : 0

초록색 선분의 끝점 : 7

 

이 떄, 시작점과 끝점을 모두 비교할 필요는 없다.

 

초록색 선분의 시작지점이 MergeLine의 끝점보다 작거나 같다면, 두 선분은 합칠 수 있는 것이라고 생각할 수 있다.

만약, 초록색 선분의 시작지점이 MergeLine의 끝점보다 크다면 두 선분은 합칠 수 없다고 생각할 수 있다.

 

현재, 두 선분의 관계를 보면, MergeLine의 끝점(2)보다 초록색 선분의 시작점(0)이 더 작기때문에, 두 선분은 합칠 수 있다.

 

위와 같이, MergeLine의 정보가 갱신되었다.

 

다음은 MergeLine과 보라색선분을 비교해보자.

위와 동일하게 계산해보면 보라색선분도 MergeLine과 합칠 수 있다.

두 선분을 합쳐도 MergeLine에 변화는 없다.

 

다음으로, MergeLine과 하늘색 선분을 비교해보자.

이번엔, 합칠 수 없다.

하늘색선분의 시작지점(8)이 MergeLine의 끝지점(7)보다 크기 때문이다.

그러므로 새로운 MergeLine을 하나 더 만들어야 한다.

 

 

이렇게, 하늘색 선분의 길이와 동일한 MergeLine를 하나 더 생성하였다.

이제는 새로 만들어진 이 MergeLine을 기준으로 비교를 진행할 것이다.

 

다음으로 비교할 선분은 갈색 선분이다.

갈색 선분은 MergeLine과 합칠 수 있으므로, 두 선분을 합쳐주면 모든 선분을 합치는 작업이 끝나게 된다.

 

코드 구현

 

먼저, 위 그림과 같이 5개의 선분을 입력받는다고 가정해보자.

//입력

int NumOfLines = 5;

std::vector<std::pair<int, int>> Lines;
Lines.resize(NumOfLines);

Lines[0] = { 0, 2 };
Lines[1] = { 8, 15 };
Lines[2] = { 3, 6 };
Lines[3] = { 0, 7 };
Lines[4] = { 13, 15 };

 

시작점과 끝점을 효율적으로 관리하기 위해, std::pair를 사용하였다.

선분은 정렬되지 않은 상태로 입력받았다.

 

먼저, 위에서 말했듯이 정렬을 해주어야 한다.

std::sort(Lines.begin(), Lines.end());

 

std::pair의 경우 정렬기준은 first가 우선이고, first가 같다면 second를 기준으로 정렬하게 된다.

그러므로 시작점(first)를 기준으로 정렬하는 현재 상황에선 정렬 함수를 직접 정의할 필요는 없다.

 

std::pair<int, int> MergeLine = Lines[0];
std::pair<int, int> CurLine = { 0, 0 };

비교를 진행하기 위해, 이렇게 2개의 변수를 선언하였다.

 

MergeLine은 합쳐진 선분을 의미한다. 이 선분의 초기값은 가장 앞에있는 선분으로 잡아주었다.

CurLine은 현재 비교하고자 하는 선분이다. 초기값은 크게 의미는 없다. 아무값으로나 초기화해주어도 상관없다.

 

std::vector<std::pair<int, int>> MergeLines;
MergeLines.reserve(5);

MergeLines는 합쳐진 선분들을 저장할 vector이다.

MergeLines는 선분의 개수를 초과할 수 없기 때문에, 전체 선분의 개수와 동일하게 5로 reserve해주었다.

(입력받는 선분의 개수가 너무 많을 땐, 메모리적으로 낭비가 심할 수 있기 때문에 std::list를 사용하는 것도 좋다.)

 

for (int i = 1; i < NumOfLines; i++)
{
    CurLine = Lines[i];

    if (CurLine.first <= MergeLine.second)
    {
        MergeLine.second = std::max(CurLine.second, MergeLine.second);
    }
    else
    {
        MergeLines.push_back(MergeLine);
        MergeLine = CurLine;
    }
}

MergeLines.push_back(MergeLine);

위는 선분을 비교하는 함수이다.

현재 선분을 CurLine에 저장해준 뒤, MergeLine과 비교하도록 했다.

위에서 말했듯, CurLine의 시작지점과 MergeLine의 끝지점만 확인하였다.

 

두 선분을 비교할 때, 시작지점은 무조건 MergeLine이 더 앞에있거나 같을 수 밖에 없다.

(정렬된 상태로 merge를 진행했기 때문에)

 

그러므로, 두 선분을 합칠 때는 끝지점에 대해서만 갱신해주면 된다.

만약, MergeLine 이 (1, 2)이고, CurLine이 (1, 5)인 상황이 있을 수도 있다.

이 경우 CurLine과 MergeLine의 끝지점중 더 큰쪽으로 MergeLine을 갱신해야 하기 때문에

std::max를 사용하여 더 큰값을 저장해주었다.

 

만약, 두 선분이 겹치지 않는다면, 기존의 MergeLine은 벡터에 저장해주고 현재 선분으로 MergeLine을 갱신해주었다.

 

반복문이 다 끝나고 나서, 현재 MergeLine을 꼭 벡터에 다시 넣어주어야 한다.

마지막 MergeLine은 벡터에 저장되지 않았으니 말이다.

 

내부의 값을 보면, 위 사진과 같이 선분이 잘 합쳐져있는 것을 볼 수 있다.

 

 

코드 전문

더보기
#include <iostream>
#include <vector>
#include <algorithm>

int main()
{
    int NumOfLines = 5;

    std::vector<std::pair<int, int>> Lines;
    Lines.resize(NumOfLines);

    Lines[0] = { 0, 2 };
    Lines[1] = { 8, 15 };
    Lines[2] = { 3, 6 };
    Lines[3] = { 0, 7 };
    Lines[4] = { 13, 15 };
    
    std::sort(Lines.begin(), Lines.end());

    std::pair<int, int> MergeLine = Lines[0];
    std::pair<int, int> CurLine = { 0, 0 };

    std::vector<std::pair<int, int>> MergeLines;
    MergeLines.reserve(5);

    for (int i = 1; i < NumOfLines; i++)
    {
        CurLine = Lines[i];

        if (CurLine.first <= MergeLine.second)
        {
            MergeLine.first = std::min(CurLine.first, MergeLine.first);
            MergeLine.second = std::max(CurLine.second, MergeLine.second);
        }
        else
        {
            MergeLines.push_back(MergeLine);
            MergeLine = CurLine;
        }
    }

    MergeLines.push_back(MergeLine);

    return 0;
}

 

 

그림을 보면, C->A로 갈 땐, A를 밟은 적이 없기 때문에 이동할 수 있지만, A->A로 갈 땐 이미 A를 밟은 적이 있기 때문에 A로 이동할 수 없다.

 

한 번 밟았던 알파벳을 다시 밟지 않고 상하좌우 4방향으로 이동할 때, 최대 몇 칸까지 이동할 수 있는지를 구하는 문제이다.

 

 

문제 풀이

 

이 문제는 간단한 DFS문제이다. 

기존에 배열의 좌표를 기준으로 하던 방문체크를 알파벳으로 바꿔주면 끝이다.

 

시작점인 (1, 1) 을 시작으로 상하좌우 4방향에 대해 DFS를 실행하고, 더이상 전진할 수 없을 때, 최대 이동 거리를 계속 갱신해주면 된다.

 

풀이 코드

int Height = 0;
int Width = 0;

std::vector<std::vector<char>> Board;
std::vector<bool> isVisit;

int MaxDist = 0;

std::vector<std::pair<int, int>> Dir = { {1, 0}, {0, 1}, {-1, 0}, {0, -1} };

 

먼저, 배열의 가로, 세로를 저장할 변수를 선언하였다.

이후, 알파벳이 저장될 배열 Board와 방문체크를 위한 isVisit도 선언해주었다.

 

MaxDist는 최대 이동 거리를 저장할 변수이다.

DFS에서 더이상 이동할 수 없을 때 이동한 거리와 MaxDist중 더 큰 값으로 MaxDist를 계속 갱신할 것이다.

 

Dir은 DFS에서 상하좌우를 탐색하기 위한 벡터이다.

 

이후, 아래와 같이 입력을 받고 초기화 해주었다.

std::cin >> Height >> Width;

Board.resize(Height, std::vector<char>(Width));
isVisit.resize(26);

for (int i = 0; i < Height; i++)
{
    for (int j = 0; j < Width; j++)
    {
        std::cin >> Board[i][j];
    }
}

 

 

DFS함수는 아래와 같다.

void DFS(int _X, int _Y, int _Depth)
{
    char CurChar = Board[_Y][_X];
    isVisit[CurChar - 'A'] = true;

    bool CanGo = false;

    for (int i = 0; i < 4; i++)
    {
        int NextX = _X + Dir[i].first;
        int NextY = _Y + Dir[i].second;

        if (NextX < 0 || NextY < 0 || NextX >= Width || NextY >= Height)
        {
            continue;
        }

        char NextChar = Board[NextY][NextX];
        if (isVisit[NextChar - 'A'] == true)
        {
            continue;
        }

        CanGo = true;

        DFS(NextX, NextY, _Depth + 1);
    }

    isVisit[CurChar - 'A'] = false;

    if (CanGo == false)
    {
        MaxDist = std::max(MaxDist, _Depth);
    }
}

 

일반적인 DFS와 동일하다. 다만, 방문체크를 알파벳을 기준으로 하고 있다.

CanGo라는 불리언 변수를 만들어두고, DFS내부에서 상하좌우 4방향중 단 한쪽이라도 이동했다면 CanGo를 true로 만들어주었다. 만약 CanGo가 False라면, 더이상 이동할 수 없다는 의미이므로 MaxDist를 갱신해주었다.

 

std::cout << MaxDist;

return 0;

 

마지막에 MaxDist를 출력해주면 끝이다.

 

 

 

코드 전문

더보기
#include <iostream>
#include <vector>
#include <algorithm>

void Init()
{
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);
    std::cout.tie(nullptr);
}

int Height = 0;
int Width = 0;

std::vector<std::vector<char>> Board;
std::vector<bool> isVisit;

int MaxDist = 0;

std::vector<std::pair<int, int>> Dir = { {1, 0}, {0, 1}, {-1, 0}, {0, -1} };

void DFS(int _X, int _Y, int _Depth)
{
    char CurChar = Board[_Y][_X];
    isVisit[CurChar - 'A'] = true;

    bool CanGo = false;

    for (int i = 0; i < 4; i++)
    {
        int NextX = _X + Dir[i].first;
        int NextY = _Y + Dir[i].second;

        if (NextX < 0 || NextY < 0 || NextX >= Width || NextY >= Height)
        {
            continue;
        }

        char NextChar = Board[NextY][NextX];
        if (isVisit[NextChar - 'A'] == true)
        {
            continue;
        }

        CanGo = true;

        DFS(NextX, NextY, _Depth + 1);
    }

    isVisit[CurChar - 'A'] = false;

    if (CanGo == false)
    {
        MaxDist = std::max(MaxDist, _Depth);
    }
}

int main()
{
    Init();

    std::cin >> Height >> Width;

    Board.resize(Height, std::vector<char>(Width));
    isVisit.resize(26);

    for (int i = 0; i < Height; i++)
    {
        for (int j = 0; j < Width; j++)
        {
            std::cin >> Board[i][j];
        }
    }

    DFS(0, 0, 1);

    std::cout << MaxDist;

    return 0;
}

'코딩테스트 문제 풀이 (C++)' 카테고리의 다른 글

백준 3758 - KCPC (C++)  (0) 2024.04.22
백준 2170 - 선 긋기 (C++)  (0) 2024.04.21
백준 1107 - 리모컨 (C++)  (0) 2024.04.19
백준 1647 - 도시 분할 계획 (C++)  (0) 2024.04.17
백준 9663 - N_Queen (C++)  (0) 2024.04.16

 

 

리모컨에는 0~9까지의 숫자 버튼이 있고, +와 -의 버튼이 있다.

 

숫자를 직접 눌러서, 원하는 채널로 옮길 수도 있고 +와 -버튼을 이용하여 원하는 채널로 이동할 수도 있다.

 

하지만, 리모컨에는 모든 숫자가 작동하는 것이 아니라 고장난 숫자가 있을 수도 있다.

그렇기 때문에, 항상 숫자를 직접 눌러서 채널을 옮길 수는 없다.

(원하는 채널이 134채널인데, 1번 3번 4번 숫자패드가 고장났다면 숫자를 눌러서 134번으로 이동할 수는 없다.)

 

이 때, 멀쩡한 숫자 버튼와 +, -버튼을 이용해서 원하는 채널에 접근하기 위해 버튼을 누르는 횟수의 최소값을 구하자.

 

 

문제 풀이

 

이 문제는 다소 무식한 방법으로 풀었다.

좀 유식해보이게 말하고 싶다면, 브루트 포스라고 하기도 한다.

 

먼저, 원하는 채널에 접근하기 위해 떠올리기 가장 쉬운 아이디어는 숫자버튼으로 원하는 채널에 최대한 근접하게 이동한 다음, +와 -를 눌러서 해당 채널까지 이동하는 것이다.

 

그렇다면, 숫자버튼으로 누를 수 있는 채널중에 원하는 채널과 가장 가까운 채널을 찾아야 하는데 어떻게 찾을까?

 

위에서 말한 아이디어는 아래와 같다.

  위의 그림에서, 하늘색 네모칸은 숫자 버튼을 눌러서 이동할 수 있는 채널들이다.

그 중에서, 가장 가까운 채널으로 숫자 버튼을 눌러 이동한 후에, +,- 버튼으로 한칸씩 움직이며 원하는 채널에 도달하는 것이다.

 

이를 반대로 생각해보자.

 

원하는 채널에서, +, - 버튼을 이용해 1씩 채널을 옮기며 탐색하다보면, 숫자버튼을 눌러서 이동할 수 있는 채널을 찾을 수 있지 않을까?

 

누를 수 있는 숫자가 (1, 4, 6)이고 이동하고자 채널이 150이라고 해보자.

 

150에서 1을 빼면, 149가 된다. 이 149는 숫자를 눌러서 이동할 수 있을까? 9 버튼이 망가졌으므로 이동할 수 없다.

다시 149에서 1을 빼면, 148이 된다. 148또한 숫자를 눌러서 이동할 수 없다.

반복하다 보면, 146이라는 채널은 숫자 버튼을 눌러서 이동할 수 있음을 확인할 수 있다.

 

하지만, 빼기만 검사해서는 안된다.

 

누를 수 있는 숫자가, (2, 3, 4) 이고, 현재 채널이 200이라면 이 때, 숫자 번호로 누를 수 있는 가장 가까운 번호는 222이다. 즉, 위의 방식으로 빼기만 검사했다간 이 채널을 탐색할 수 없게 된다.

 

그러므로, 이동하고자 하는 채널에서 1씩 빼며 가까운 채널을 탐색하는 경우와

1씩 더해가며 채널을 탐색하는 경우를 둘 다 구해야 한다.

 

두 경우에서 버튼을 몇 번 누르는지 비교한 후 더 적게 누르는 쪽을 답으로 출력하면 된다.

하지만, 사실 이렇게만 출력하면 문제를 맞추지 못한다.

 

왜냐하면, 한 가지 경우의 수가 더 있기 때문이다.

 

누를 수 있는 버튼이, (2) 이고 이동하고자 하는 채널이 101 이라고 해보자

이 때, 숫자 버튼으로 이동할 수 있는 채널중 가장 가까운 채널은 22이다.

22를 눌렀다가 +버튼을 89번 눌러서 101일번 채널에 도착할 수 있다.

이 경우엔 총 91번의 버튼을 눌러야 한다.

 

하지만, 이렇게 하지 않고 처음에 +버튼 하나만 누른다면?

100채널에서 101채널로 단 1회만에 이동할 수 있다.

 

즉, 숫자 버튼을 사용하지 않고 +버튼 (혹은 -버튼) 만을 사용해서 원하는 채널에 도달하는 경우도 고려해야 한다는 것이다.

 

문제의 답은 아래 3가지의 경우 중 버튼을 누르는 횟수가 가장 적은 것을 출력하면 된다.

 

1. 이동하고자 하는 채널에서 1씩 빼며, 숫자 버튼을 눌러 이동할 수 있는 가장 가까운 채널을 탐색하는 경우

2. 이동하고자 하는 채널에서 1씩 더하며, 숫자 버튼을 눌러 이동할 수 있는 가장 가까운 채널을 탐색하는 경우

3. +버튼 (혹은 -버튼)만을 눌러서 이동하고자 하는 채널에 도달하는 경우

 

 

풀이 코드

 

int TargetChannel = 0;
std::cin >> TargetChannel;

int NumOfBrokenBt = 0;
std::cin >> NumOfBrokenBt;

std::vector<bool> PushableBts(10, true);

if (NumOfBrokenBt > 0)
{
    for (int i = 0; i < NumOfBrokenBt; i++)
    {
        int Bt = 0;
        std::cin >> Bt;

        PushableBts[Bt] = false;
    }
}

 

먼저, 위와 같이 입력을 받아주었다.

이동하고자 하는 채널을 입력받았고, 부서진 버튼의 개수를 입력받았다.

 

이후, bool값을 저장하는 벡터를 숫자버튼 개수만큼 resize하였고, 초기값을 모두 true로 저장하였다.

이 벡터에서 1번 인덱스의 값이 true라면, 1번 숫자 버튼은 누를 수 있다는 의미이다. 반대로, 2번 인덱스의 값이 false라면2번 숫자 버튼은 망가졌다는 의미이다.

 

이후, 부서진 버튼의 개수가 1개 이상이라면, 입력에 따라 배열의 값을 false로 바꿔주면서 망가진 숫자 버튼을 표현해주었다.

 

다음은 숫자버튼으로 가장 가까운 채널로 이동한 뒤, +,-로 원하는 채널에 도달할 때 버튼을 몇 번 눌러야 하는가를 구하는 함수이다.

int GetCount(const std::vector<bool>& _PushableBts, int _TargetChannel, int _AddValue)
{
	int CurChannel = _TargetChannel;
	int Count = 0;

	while (true)
	{
		if (CurChannel > 1000000 || CurChannel < 0)
		{
			Count = INT_MAX;
			break;
		}

		if (CurChannel == 100)
		{
			break;
		}

		bool CanNot = false;
		std::string ChannelStr = std::to_string(CurChannel);
		for (int i = 0; i < ChannelStr.size(); i++)
		{
			int CurNum = ChannelStr[i] - '0';

			if (_PushableBts[CurNum] == false)
			{
				CanNot = true;
				break;
			}
		}

		if (CanNot == false)
		{
			Count += ChannelStr.size();
			break;
		}

		Count++;
		CurChannel += _AddValue;
	}

	return Count;
}

 

인자중에 첫 번째 인자는 숫자 버튼 배열이다. 두 번째 인자는 이동하고자 하는 채널의 번호이며, 3번은 더하며 탐색할것인가 빼며 탐색할 것인가를 설정하는 것이다.

 

함수 내부에서 선언된 Count는 버튼을 몇 번 눌렀는가 이다.

 

반복문 내부를 보면, 가장 위에 조건문이 하나 있다.

if (CurChannel > 1000000 || CurChannel < 0)
{
    Count = INT_MAX;
    break;
}

이 조건은, 가장 가까운 채널을 탐색하지 못하였을 경우에 대한 예외처리이다.

채널은 음수가 될 수 없고, 1000000을 넘을 수도 없다.

(물론 실제로는 100만을 넘을 수는 있는데, 그 범위를 고려할 이유가 없다.)

 

아무튼 가장 가까운 채널을 탐색하지 못했다면, INT_MAX를 반환해주어 답이 되지 못하도록 설정해주었다.

(최소값을 기준으로 답을 출력하기 때문에, INT_MAX가 출력될 일은 없기 때문이다.)

 

다음 부분을 보자.

bool CanNot = false;
std::string ChannelStr = std::to_string(CurChannel);

for (int i = 0; i < ChannelStr.size(); i++)
{
    int CurNum = ChannelStr[i] - '0';

    if (_PushableBts[CurNum] == false)
    {
        CanNot = true;
        break;
    }
}

 

ChannelStr은 각 자리수의 숫자를 좀 더 편하게 탐색하기 위해 int형의 채널을 string으로 변환해준 것이다.

모든 자리수의 숫자를 검사하면서, 숫자 패드로 누를 수 없는 번호가 섞여있는지 검사하고 있다.

 

만약, 모든 번호를 누를 수 있다면, 반복문이 끝나고 나서 CanNot은 false일 것이고, 단 하나라도 누를 수 없다면 CanNot은 true가 될 것이다.

 

if (CanNot == false)
{
    Count += ChannelStr.size();
    break;
}

 

CanNot이 false라면 (해당 채널을 숫자 번호를 눌러 이동할 수 있다면)

해달 채널의 길이를 Count에 더해준 뒤 문자열을 종료해주고 있다.

해당 채널이 1313이라면, 총 4번의 버튼을 눌러야 하기 때문에 문자열의 길이를 더해준 것이다.

 

Count++;
CurChannel += _AddValue;

만약, CanNot이 true라서 반복문이 끝나지 않았다면, Count를 1회 더해주고, 채널을 1씩 더해주거나 빼주면서 다음 채널을 탐색하게 될 것이다.

 

(_AddValue는 함수의 파라미터에 등록된 변수인데, 인수로 1을 전달하면 1씩 더하며 탐색할 것이고 -1을 전달하면 1씩 빼며 탐색할 것이다.)

 

위의 함수를 아래와 같이, 두 번 실행해준다.

int SubCount = 0;
int AddCount = 0;

SubCount = GetCount(PushableBts, TargetChannel, -1);
AddCount = GetCount(PushableBts, TargetChannel, 1);

 

SubCount는 1씩 빼며 채널을 탐색한 것이고, AddCount는 1씩 더하며 채널을 탐색한 것이다.

 

int OnlyPMCount = abs(TargetChannel - 100);

OnlyPMCount는 +, - 만을 이용하여 채널을 이동했을 때 버튼을 누른 횟수이다.

그냥, 원하는 채널에서 현재 채널을 뺴주면 된다.

단, 음수가 나올 수도 있기 때문에 절대값을 취해주었다.

 

마지막으로 아래와 같이 최소값을 구한 뒤 출력해주면 끝이다.

int Answer = 0;

Answer = std::min(SubCount, AddCount);
Answer = std::min(Answer, OnlyPMCount);

std::cout << Answer;

return 0;

 

 

코드 전문

 

더보기
#include <iostream>
#include <vector>
#include <set>
#include <string>
#include <algorithm>
#include <climits>

void Init()
{
	std::ios::sync_with_stdio(false);
	std::cin.tie(nullptr);
	std::cout.tie(nullptr);
}

int GetCount(const std::vector<bool>& _PushableBts, int _TargetChannel, int _AddValue)
{
	int CurChannel = _TargetChannel;
	int Count = 0;

	while (true)
	{
		if (CurChannel > 1000000 || CurChannel < 0)
		{
			Count = INT_MAX;
			break;
		}

		bool CanNot = false;
		std::string ChannelStr = std::to_string(CurChannel);
		for (int i = 0; i < ChannelStr.size(); i++)
		{
			int CurNum = ChannelStr[i] - '0';

			if (_PushableBts[CurNum] == false)
			{
				CanNot = true;
				break;
			}
		}

		if (CanNot == false)
		{
			Count += ChannelStr.size();
			break;
		}

		Count++;
		CurChannel += _AddValue;
	}

	return Count;
}

int main()
{
	Init();

	int TargetChannel = 0;
	std::cin >> TargetChannel;

	int NumOfBrokenBt = 0;
	std::cin >> NumOfBrokenBt;

	std::vector<bool> PushableBts(10, true);

	if (NumOfBrokenBt > 0)
	{
		for (int i = 0; i < NumOfBrokenBt; i++)
		{
			int Bt = 0;
			std::cin >> Bt;

			PushableBts[Bt] = false;
		}
	}

	int SubCount = 0;
	int AddCount = 0;

	SubCount = GetCount(PushableBts, TargetChannel, -1);
	AddCount = GetCount(PushableBts, TargetChannel, 1);

	int OnlyPMCount = abs(TargetChannel - 100);
	
	int Answer = 0;
	Answer = std::min(SubCount, AddCount);
	Answer = std::min(Answer, OnlyPMCount);

	std::cout << Answer;

	return 0;
}

프로그래밍을 하다 보면, 데이터를 다른 객체에 복사해야 하는 상황이 자주 발생한다.

이 때, 복사를 어떻게 하느냐에 따라 프로그램의 안정성도 달라지고 성능도 달라진다.

 

복사는 얕은 복사와 깊은 복사로 나누어진다.

각각 알아보도록 하자.

 

얕은 복사

 

먼저, 얕은 복사에 대해 알아보자. 뭐를 복사하길래 얕다는 것일까?

얕은 복사는 가지고 있는 데이터를 그대로 복사하게 된다.

인스턴스 A에 int Hp = 100, int Mp = 50 으로 저장되어 있다고 한다면

인스턴스 A의 데이터를 인스턴스 B에 복사하게 되면 오른쪽과 같이 인스턴스 B의 값이 인스턴스 A와 동일해진다.

 

class ShallowCopy
{
public:
    int Hp = 0;
    int Mp = 0;
};

int main()
{
    ShallowCopy A = {100, 50};
    ShallowCopy B = {0, 0};
    
    B = A;
    
    std::cout << B.Hp << " " << B.Mp;
    
    return 0;
}

 

위으 코드를 보면, 인스턴스 A와 B를 생성해준 뒤, B = A 로 대입연산을 해주고 있다.

이 때, 복사에 대해 연산자를 따로 정의하지 않았다면, B에는 A의 값이 그대로 복사될 것이다.

 

우리가 의도한대로 데이터가 잘 복사되었다.

하지만, 데이터가 이렇게 잘 복사되어서는 안되는 상황이 있다.

 

복사인데, 잘 복사되면 안된다니 무슨 소리일까?

아래 그림을 보자.

 

만약, 클래스에서 Level을 포인터 변수로 선언하고, 동적 할당을 하도록 설계하였다면?
인스턴스 A에서는 자체적으로 할당한 메머리의 주소값을 Level에 저장하고 있을 것이다. (16을 주소값이라고 가정하자.)

 

그런데, B = A 연산을 진행하게 되면, 인스턴스 B에서도 16이라는 동일한 주소값을 가리키게 된다.

 

이 때, B에서 만약 Level의 값을 바꿔버린다면?

A에서도 참조하는 값이 바뀌기 때문에 A에도 영향을 미치게 된다.

 

심지어 B에서 해당 메모리를 해제해버린다면?

A에선 아무것도 모르고 메모리를 참조하려다가 잘못된 메모리를 참조하여 크래시가 발생할 수도 있다.

 

이처럼 하나의 포인터를 여러 객체가 소유하고 있다가, 한 객체가 메모리를 해제해버려서 나머지 객체들이 잘못된 메모리 공간을 참조하게 되는 경우를 댕글링 포인터라고 한다.

 

얕은 복사는 이런 문제가 생긴다. 애초부터 함께 공유하도록 설계한 메모리 영역이라면 문제가 없겠지만, 각 인스턴스가 다르게 사용해야 하는 경우라면 여러가지 문제가 발생할 수 밖에 없다.

 

#include <iostream>

class ShallowCopy
{
public:
    int Hp = 0;
    int Mp = 0;

    int* Level = nullptr;

    ShallowCopy()
    {
        Level = new int();
    }
};

int main()
{
    ShallowCopy A;
    A.Hp = 100;
    A.Mp = 50;
    *A.Level = 5;

    ShallowCopy B;

    B = A;

    std::cout << A.Hp << " " << A.Mp << " " << *A.Level << " " << A.Level << std::endl;
    std::cout << B.Hp << " " << B.Mp << " " << *B.Level << " " << B.Level << std::endl;

    return 0;
}

 

위와 같이 얕은 복사를 하는 코드를 작성하고 실행하게 되면, 아래와 같이 값이 동일하게 나온다.

 

가리키는 주소값까지 완전히 동일한 것을 볼 수 있다. 얕은 복사는 이처럼 변수에 저장되어 있는 값을 그대로 복사하기 때문에, 포인터형을 복사할 때 문제가 발생할 수 있다.

 

이를 해결하기 위한 방법이 깊은 복사이다.

 

깊은 복사

 

깊은 복사는 위에서 말했던, 댕글링 포인터의 문제를 해결하기 위해 만들어졌다.

결론부터 말하자면, 깊은 복사는 별도의 메모리 영역을 동적으로 할당한 뒤에 저장되어 있는 값을 복사하는 형식이다.

 

아래 그림을 보자.

 

왼쪽처럼, A의 Level과 B의 Level이 같은 주소를 참조하고 있다면, 얕은 복사가 된 상태이다.

반면, 깊은 복사의 경우 오른쪽처럼 별도의 메모리 영역을 참조하게 된다.

 

깊은 복사는 주소값만을 가져오는 것이 아니라, 별도로 메모리 공간을 동적으로 할당한 뒤에 내부의 값을 복사하는 것이다.

 

그렇다면, 깊은 복사는 어떻게 해야할까?

 

복사 생성자, 복사 대입 연산자를 정의해야 한다.

복사 생성자란 아래와 같이 객체를 생성할 때 값을 복사하여 초기화 하는 것을 말한다.

class DeepCopy
{
public:
    int Hp = 0;
    int Mp = 0;
    
    int* Level = nullptr;
    
    DeepCopy()
    {
        Level = new int();
    }
}

int main()
{
    DeepCopy A;
    
    A.Hp = 100;
    A.Mp = 50;
    *A.Level = 5;
    
    DeepCopy B(A);
    
    return 0;
}

 

위의 코드를 보면, B를 생성할 때 인자에 A를 대입하였다.

이렇게, 객체를 직접 넣었을 때 호출되는 생성자를 복사 생성자라고 한다.

 

만약 위의 코드처럼 복사 생성자를 직접 정의하지 않은 상태로 복사 생성자를 호출하게 되면 디폴트 복사 생성자가 호출된다. 디폴트 복사 생성자는 얕은 복사를 실행하기 때문에, 깊은 복사를 하고 싶다면 반드시 복사 생성자를 직접 정의해주어야 한다.

 

복사 생성자를 정의해보자.

DeepCopy(const DeepCopy& _Other)
{
    if(Level == nullptr)
    {
        Level = new int();
    }

    Hp = _Other.Hp;
    Mp = _Other.Mp;

    *Level = *_Other.Level;
}

 

복사 생성자는 인자로 const (본인의 자료형)& 형태로 받아야 한다.

위에서는 DeepCopy 클래스이기 때문에 const DeepCopy& 타입으로 인자를 설정하였다.

생성자 내부에선 Level을 동적할당하였고, 값들을 복사해주고 있다.

 

이렇게 생성자를 정의한 뒤에 아래 코드를 실행하게 되면?

int main()
{
    DeepCopy A;
    A.Hp = 100;
    A.Mp = 50;
    *A.Level = 5;

    DeepCopy B(A);

    std::cout << A.Hp << " " << A.Mp << " " << *A.Level << " " << A.Level << std::endl;
    std::cout << B.Hp << " " << B.Mp << " " << *B.Level << " " << B.Level << std::endl;
}

 

아래와 같이, 내부의 값은 동일하지만 Level은 서로 다른 주소를 가리키게 된다.

서로 다른 주소를 가리키기 때문에, 어느 한 쪽에서 메모리를 해제하더라도 다른 쪽에는 전혀 영향이 가지 않는다.

이처럼, 안정적으로 복사를 설계하는 것을 깊은 복사라고 한다.

 

깊은 복사는 생성자 뿐만이 아니라, 복사 대입 연산자에서도 사용할 수 있다.

복사 대입 연산이란, B = A; 처럼 등호를 이용하여 복사를 하는 경우이다.

 

이 때에도 복사 대입 연산자가 별도로 정의되어 있지 않다면, 디폴트 복사 대입 연산자를 호출하여 얕은 복사를 진행하게 된다. 그러므로 깊은 복사를 사용하고 싶다면 반드시 정의해주어야 한다.

 

복사 대입 연산자는 아래와 같이 연산자 오버로딩을 이용하여 정의할 수 있다.

DeepCopy& operator= (const DeepCopy& _Other)
{
    Hp = _Other.Hp;
    Mp = _Other.Mp;

    *Level = *_Other.Level;

    return *this;
}
더보기

복사 대입 연산자 오버로딩에서 왜 void가 아니라, 본인의 참조형을 반환하는지 궁금한 사람도 있을텐데

A = B = C; 와 같은 체이닝을 가능하게 하기 위해서라고 한다.

이 상태로, 아래의 코드를 실행한다면?

int main()
{
    DeepCopy A;
    A.Hp = 100;
    A.Mp = 50;
    *A.Level = 5;

    DeepCopy B;
    
    B = A;

    std::cout << A.Hp << " " << A.Mp << " " << *A.Level << " " << A.Level << std::endl;
    std::cout << B.Hp << " " << B.Mp << " " << *B.Level << " " << B.Level << std::endl;
}

 

복사 생성자와 동일한 결과를 볼 수 있다.

 

아래는 코드 전문이다.

더보기
#include <iostream>

class ShallowCopy
{
public:
    int Hp = 0;
    int Mp = 0;

    int* Level = nullptr;

    ShallowCopy()
    {
        Level = new int();
    }
};

class DeepCopy
{
public:
    int Hp = 0;
    int Mp = 0;

    int* Level = nullptr;

    DeepCopy(const DeepCopy& _Other)
    {
        if(Level == nullptr)
        {
            Level = new int();
        }

        Hp = _Other.Hp;
        Mp = _Other.Mp;

        *Level = *_Other.Level;
    }

    DeepCopy()
    {
        Level = new int();
    }

    DeepCopy& operator= (const DeepCopy& _Other)
    {
        Hp = _Other.Hp;
        Mp = _Other.Mp;

        *Level = *_Other.Level;
        
        return *this;
    }
};

int main()
{
    {
        ShallowCopy A;
        A.Hp = 100;
        A.Mp = 50;
        *A.Level = 5;
  
        ShallowCopy B;
  
        B = A;
  
        std::cout << A.Hp << " " << A.Mp << " " << *A.Level << " " << A.Level << std::endl;
        std::cout << B.Hp << " " << B.Mp << " " << *B.Level << " " << B.Level << std::endl;
    }

    {
        DeepCopy A;
        A.Hp = 100;
        A.Mp = 50;
        *A.Level = 5;

        DeepCopy B;

        B = A;

        std::cout << A.Hp << " " << A.Mp << " " << *A.Level << " " << A.Level << std::endl;
        std::cout << B.Hp << " " << B.Mp << " " << *B.Level << " " << B.Level << std::endl;
    }

    return 0;
}

'C++ > C++' 카테고리의 다른 글

C++ - Move Semantic (std::move, std::forward)  (1) 2024.06.09
C++ - L-Value vs R-Value  (1) 2024.06.09
C++ - 가변 인자 템플릿  (0) 2024.04.18
C++ - 코루틴 (coroutine)  (0) 2024.04.14
C++ - string_view (읽기 전용 문자열 컨테이너)  (0) 2024.04.11

일반적인 상황에서 함수는 인자의 자료형과 개수가 고정되어 있다.

Function(int _A)
{
}

 

위와 같이 함수가 선언, 정의되어 있다면 해당 함수는  int형의 인수를 받으며, 다른 자료형을 인수에 대입하게되면 컴파일 오류가 나거나, 자동으로 int형으로 타입캐스팅이 되어버린다.

 

물론, 인수로 1개를 초과하는 데이터를 전달할 수도 없다.

 

하지만, C++에서는 코드의 재활용성을 높이기 위해  템플릿이라는 문법을 제공해주고 있고, 이를 활용하면 하나의 함수에서 다양한 타입의 파라미터를 사용할 수 있게 된다.

 

template <typename T>
Function(T _A)
{
}

 

위와 같이 템플릿을 사용한다면, 하나의 함수로 다양한 자료형에 대해 대응할 수 있게되며 코드의 양을 비약적으로 줄일 수 있다.

 

하지만, 템플릿을 사용한다고 하더라도  대입할 수 있는 인자의 개수는 고정되어 있다.

Function<int>(3, 5, 7);

 

위와 같이, 선언한 것과 파라미터의 개수가 달라지게 되면 당연히 컴파일 오류가 발생하게 된다.

 

하지만, 이를 가능하게 만들어주는 문법이 있다. 그 것이 바로 가변인자 템플릿이다.

 

 

가변 인자 템플릿

 

가변 인자 템플릿이란, 파라미터의 개수를 고정하지 않고 가변적으로 사용할 수 있도록 도와주는 문법이다.

생각해보면, printf 같은 함수도 인자의 개수가 고정되어 있지 않고, 원하는 만큼 인수를 전달할 수 있었다.

(실제로는 printf는 C스타일의 가변인자를 사용하였고, C++스타일인 가변 인자 템플릿과는 다르다.)

 

그렇다면, 어떻게 사용하는지 먼저 확인해보자.

template<typename... Var>
void Function(Var... Arg)
{
}

 

이런 방식으로 사용하게 된다.

 

언뜻 보면, 일반적인 템플릿과 똑같아보인다.

하지만, 잘 보면 문법이 다소 다르다.

 

Var을 보면 typename... 으로 ...이 붙은 채로 작성되어있다.

이 typename...은 여러 개의 인자를 가변적으로 받겠다는 의미이며, 이는 파라미터 팩이라고 칭한다.

 

#include <string>

template<typename... Var>
void Function(Var... Arg)
{
}

int main()
{
    Function<int, long long, float, double>(1, 2, 1.1f, 1.2l);
    Function<char, std::string>('A', "ABC");

    return 0;
}

 

위처럼, 인자를 임의로 대입하여도 컴파일 오류가 발생하지 않고, 정상적으로 실행이 된다.

 

이제, 인자로 받은 함수들을 모두 콘솔에 출력하는 기능을 만들어보자.

 

가변 인자를 다루는 방법은 크게 두 가지 방법이 있다.

 

1. 재귀함수

2. 폴드 표현식

 

재귀함수는 C++ 11부터 사용이 가능한 문법이다.

반면, 폴드표현식은 C++ 17부터 사용이 가능한 문법이다.

언어 버전을 확인하고, 문법을 취사하여 사용하도록 하자.

 

가변 인자 템플릿 - 재귀함수

 

먼저, 재귀함수를 활용하는 방법이다.

재귀함수를 사용하여 가변인자 템플릿을 활용하기 위해선 고정 인자가 하나 필요하다.

 

고정인자가 뭔지는 아래 코드를 보면 바로 알 수 있다.

template<typename T, typename... Types>
void Print(T _FixArg, Types... _VarArgs)
{
}

 

 

보면, 앞에 T _FixArg라는 파라미터를 하나 고정으로 두고, 두 번째 파라미터부터 가변 인자를 받고 있다.

이 때, _FixArg가 고정인자이다.

 

고정 인자는 1개가 될 수도 있고, 2개가 될 수도 있고, 더 많을 수도 있다.

그런데, 고정 인자가 왜 필요하다는걸까?

 

일단, 아래 코드를 보자.

 

template<typename T>
void Print_1(T _FixArg)
{
    std::cout << _FixArg << " ";
}

template<typename T1, typename... Types>
void Print_1(T1 _FixArg , Types... _VarArgs)
{
    std::cout << _FixArg << " ";
    Print_1(_VarArgs...);
}

 

먼저 아래에 있는 가변 인자를 사용하는 함수를 보면, 내부에서 고정 인자를 출력한 뒤, 나머지 가변 인자에 대해 Print_1을 재귀적으로 호출하고 있다.

 

그 위의 함수를 보면, 가변 인자를 사용하는 아래 함수와 동일하게 인자를 출력해주고 있지만, 가변인자를 사용하지 않고 있다. 함수의 이름은 동일하게 선언하여 오버로딩이 되어있는 상태이다.

 

이제, 재귀함수가 호출되는 과정을 살펴보자.

 

이 함수를 메인함수에서 아래와 같이 실행했다고 가정해보자.

int main()
{
    Print_1(1, 1.1f, 1.2l, 'A');
    return 0;
}

 

아래는 재귀함수가 호출되는 과정이다.

// Print_1(1, 1.1f, 1.2l, 'A'); << 최초 인수
//_FixArg == 1, VarArgs... -> (1.1f, 1.2l, 'A')

template<typename T1, typename... Types>
void Print_1(T1 _FixArg , Types... _VarArgs)
{
    std::cout << _FixArg << " "; // _FixArg인 1이 출력됨
    Print_1(_VarArgs...);
}

//Print_1(1.1f, 1.2l, 'A') << 두 번째로 호출되었을 때 인수
//_FixArg == 1.1f, VarArgs... -> (1.2l, 'A')

template<typename T1, typename... Types>
void Print_1(T1 _FixArg , Types... _VarArgs)
{
    std::cout << _FixArg << " "; // _FixArg인 1.1f가 출력됨
    Print_1(_VarArgs...);
}

//Print_1(1.2l, 'A') << 세 번째로 호출되었을 때 인수
//_FixArg == 1.2l, VarArgs... -> ('A')

template<typename T1, typename... Types>
void Print_1(T1 _FixArg , Types... _VarArgs)
{
    std::cout << _FixArg << " "; // _FixArg인 1.2l이 출력됨
    Print_1(_VarArgs...);
}

//Print_1('A') << 네 번째로 호출되었을 때 인수
//_FixArg == 'A', VarArgs... -> 없음

//이 때, 어떤 함수가 호출될까?
//가변인자를 사용하지 않는 Print_1 함수가 호출된다.
//두 함수가 오버로딩되어 있다면, 가변 인자를 사용하지 않는 함수가 우선적으로 호출된다.

template<typename T>
void Print_1(T _FixArg)
{
    std::cout << _FixArg << " "; //'A'를 출력함
}

//이후, 재귀함수를 더 이상 호출하지 않기 떄문에 출력은 끝이 난다.

 

아래는 동일한 내용을 그림으로 표현한 것이다.

 

기본적으로, 재귀함수를 돌면서 값을 출력해주고 있기 때문에, 고정인자가 없다면 재귀 함수가 끝나지 않는다.

(인자의 개수가 줄어들지 않기 때문에)

 

또한, 고정인자를 사용하였더라도, 가변인자가 없는 함수를 오버로딩하지 않았다면, 재귀함수의 종료 시점이 존재하지 않아 마지막 문자를 계속 출력하다가 스택 오버플로우가 발생할 것이다.

 

가변인자가 없는 함수에선 재귀호출을 하고 있지 않기 때문에, 가변인자가 없는 함수가 호출되는 시점이 재귀함수의 종료 시점이라고 파악할 수 있는 것이다.

 

실행하면 콘솔 창에선 아래와 같이 인자들이 순서대로 출력되는 것을 볼 수 있다.

 

이 번엔, 인자들을 모두 더하는 함수를 만들어보자.

 

간단하게 아래와 같이 만들어 볼 수 있다.

template<typename T>
int Sum(T _FixArg)
{
    return _FixArg;
}

template<typename T1, typename... Types>
int Sum(T1 _FixArg, Types... _VarArgs)
{
    int Result = _FixArg + Sum(_VarArgs...);
    return Result;
}

int main()
{
    std::cout << Sum(1, 2, 3, 4);

    return 0;
}

 

실행결과는 아래와 같다.

정상적으로 모든 인자를 더해준 값이 출력되었다.

 

그런데, 여기서 주의할 점이 하나 있다. 너무나 당연한 말이지만, 여러 자료형에 대해서 동일한 작업을 반복하는 만큼 연산자가 어떻게 오버로딩 되어있는가를 확실히 파악해야 한다.

 

예를 들어, A라는 클래스가 있고, B라는 클래스가 있는 상태에서

Sum(A, B, 1, 4, 2.5f, 'A') 이런식으로 호출한다면, A와 B의 합이 우리가 원하는 결과와 같은지 확실하게 확인해야 한다.

또, 2.5f와 'A'의 합은 우리 의도와 맞는지, 정확히 생각하고 실행해야 한다.

 

그렇기 때문에, 가변인자 템플릿을 사용하는 함수라면 함수의 내부 동작이 어떻게 이루어지는지 파악하고서 올바른 자료형을 판단해서 사용하여야 한다.

 

(사실, 특정 자료형에 대해서만 의도대로 작동하는 함수라면, 그건 가변인자 템플릿으로 만들지 않는게 맞다.)

 

가변 인자 템플릿 - 폴드 표현식

 

폴드 표현식은 고정 인자를 사용하지 않고, 재귀 함수를 사용하지 않고 가변 인자에 대응하는 방법이다.

C++ 17부터 사용할 수 있게 되었다.

 

먼저 폴드 표현식의 코드부터 보자.

아래는 모든 가변 인자를 더한 값을 반환하는 함수의 코드이다.

 

template<typename ...Types>
int Sum(Types... _VarArgs)
{
    return (_VarArgs + ...);
}

int main()
{
    std::cout << Sum(1, 2, 3, 4);
    return 0;
}

 

아주 단순하고 간단하고 짧아졌다!

보면, 함수를 오버로딩 하지도 않았고 재귀함수를 사용하지도 않았고 고정인자를 사용하지도 않았다.

내부의 코드도 단 1줄이 되었고, 가독성도 상당히 좋아졌다.

 

내부의 (_VarArgs + ...); 이 의미가 모든 인자를 더하라는 뜻이 된다.

 

그런데, 뺄셈의 경우 아래와 같은 문제가 발생할 수 있다.

template<typename ...Types>
int Sub_1(Types... _VarArgs)
{
    return (_VarArgs - ...);
}

template<typename ...Types>
int Sub_2(Types... _VarArgs)
{
    return (... - _VarArgs);
}

int main()
{
    std::cout << Sub_1(1, 2, 3, 4);
    std::cout << std::endl;
    std::cout << Sub_2(1, 2, 3, 4);

    return 0;
}

 

위의 코드를 실행해보면?

서로 다른 값이 출력된다.

...의 위치를 앞에 두느냐 뒤에 두느냐에 따라 값이 달라지는 것이다.

 

왜 그러는 것일까?

 

폴드 계산식은 말 그대로, 접었다는 뜻이다.

(_VarArgs - ...)을 전개해보면 아래와 같다.

즉, 가변 인자를 모두 전개한 뒤, 마지막 인자들부터 차례대로 빼주고 있다는 것이다.

 

위의 코드의 인수인 1, 2, 3, 4를 기준으로 본다면

1 - (2 - (3 - 4)) 를 호출하고 있는 것이다.

 

반면, (... - _VarArgs) 는 어떨까?

이렇게, 앞에서부터 갚을 빼며 전개한다.

 

즉, ...을 이항연산에서 앞에 두느냐 뒤에 두느냐에 따라 계산 순서가 달라지는 것이다.

 

덧셈, 곱셈같은 경우는 교환법칙이 성립하기 때문에 순서가 중요하지 않지만, 뻴셈, 나눗셈 등의 경우엔 교환법칙이 성립하지 않기 때문에 주의를 하여 사용해야 한다.

 

폴드 표현식은 여러 가지 사용법이 있다.

아래와 같이도 사용 가능하다.

template<typename ...Types>
int Sub_3(Types... _VarArgs)
{
    return (1 - ... - _VarArgs);
}

template<typename ...Types>
int Sub_4(Types... _VarArgs)
{
    return (_VarArgs - ... - 1);
}

 

위의 함수는 맨 앞에 1이라는 인자가 있다고 가정하고 계산을 진행하는 경우이며,

아래 함수는 맨 뒤에 1이라는 인자가 있다고 가정하고 계산을 진행하는 것이다.

 

출력을 하고 싶을 땐, 아래와 같이 사용할 수도 있다.

template<typename ...Types>
void PrintAll(Types... _VarArgs)
{
    (std::cout << ... << _VarArgs);
}

 

 

여기까지 가변 인자 템플릿에 대해 알아보았다.

'C++ > C++' 카테고리의 다른 글

C++ - L-Value vs R-Value  (1) 2024.06.09
C++ - 얕은 복사 vs 깊은 복사  (1) 2024.04.18
C++ - 코루틴 (coroutine)  (0) 2024.04.14
C++ - string_view (읽기 전용 문자열 컨테이너)  (0) 2024.04.11
C++ - SSO (Small String Optimization)  (0) 2024.04.11

 

 

집들이 이렇게 연결되어 있다고 가

정해보자.

저 모든 집들을 일단 두개의 마을로 분리해야 한다.

이런 식으로, 여러 집들을 두 개의 마을로 분할하는 것이다.

(물론, 두 개의 마을로 나누는 방법은 이 외에도 많다.)

 

이렇게, 두 마을을 분할했다면, 마을 사이의 도로를 제거하고, 각 마을의 도로를 최소화하여야 한다.

 

먼저, 마을 사이의 도로를 모두 제거하였다.

그런데, 보면 왼쪽 마을은 연결되어 있는 도로가 불필요하게 많다.

 

위의 그림과 같이, 도로를 하나 제거하여도, 모든 집이 연결되는 경우가 두 가지나 있기 때문이다.

두 경우 중에서, 도로 유지비의 합이 최소인 경우를 골라서, 그 때의 도로 유지비의 합을 반환하면 된다.

 

문제 풀이

 

이 문제는 최소 신장 트리(최소 스패닝 트리)를 이용하여 푸는 문제이다. 본인은 크루스칼 알고리즘을 활용하여 문제를 해결하였다.

 

크루스칼 알고리즘을 모른다면, 알고리즘을 공부하고 오도록 하자.

 

전체적으로 일반적인 크루스칼 알고리즘 문제와 똑같다. 하지만, 이 문제는 마을을 분리해야 한다는 조건이 하나 추가되었다. 그렇다면, 마을을 분리한 뒤에 도로를 제거하면 될까?

 

아니다. 도로를 제거하여 마을을 분리해도 된다.

무슨 말이냐면, 어쨋든 이 문제는 모든 집을 연결하면서, 간선의 가중치의 합의 최소를 구하는 문제이다.

 

그렇다면, 모든 집에 대해 일단 최소 신장 트리를 구성한 다음, 그중 가장 유지비가 비싼 간선 하나만 제거하여도, 두 개의 마을이 분리가 되는 것이다.

 

아래 그림을 보자.

 

이렇게, 집들이 도로로 연결되어 있다고 가정해보자.

이 집들에 대해 최소 신장 트리를 구성했을 때 아래와 같다. (임의로 가정한 최소 신장 트리이다.)

최소 신장 트리인 만큼 인접한 집과 집 사이엔, 하나의 간선만 연결되어 있다.

이 때, 하나의 간선을 제거하게 된다면, 해당 집과 집은 연결이 끊어지게 되고, 아래와 같아진다.

 

 이렇게, 간선 하나만 제거하여도 두개의 마을로 분리되는 것이다.

그렇다면, 간선을 하나 제거할 때 가장 유지비가 비싼 간선을 제거한다면?

 

여러 집들을 두 마을로 분리하면서, 가중치의 합이 최소인 형태가 되는 것이다.

 

즉, 크루스칼 알고리즘을 이용해서 최소 신장 트리를 구성한다음, 가장 비싼 가중치의 간선만 제거하면 답을 구할 수 있다.

 

풀이 코드

int GetRootParent(int _Index)
{
    if (Parents[_Index] == _Index)
    {
        return _Index;
    }
    Parents[_Index] = GetRootParent(Parents[_Index]);
    return Parents[_Index];
}

void Setparent(int _Left, int _Right)
{
    if (_Left < _Right)
    {
        Parents[GetRootParent(_Right)] = _Left;
    }
    else
    {   
        Parents[GetRootParent(_Left)] = _Right;
    }
}

bool isCycle(int _Left, int _Right)
{
    int LeftRootParent = GetRootParent(_Left);
    int RightRootParent = GetRootParent(_Right);

    return (LeftRootParent == RightRootParent);
}

 

먼저, 크루스칼 알고리즘을 사용하기 위해 유니온 파인드 함수들을 정의하였다.

std::cin >> NumOfHouse >> NumOfRoad;

Parents.resize(NumOfHouse + 1);
for (int i = 1; i <= NumOfHouse; i++)
{
    Parents[i] = i;
}

 

이후, 입력을 받았고, 부모 배열을 초기화 해주었다.

 


struct Link
{
    int Start = 0;
    int End = 0;
    int Cost = 0;
};

bool compare(const Link& _Left, const Link& _Right)
{
    return _Left.Cost < _Right.Cost;
}

std::vector<Link> Links(NumOfRoad);
for (int i = 0; i < NumOfRoad; i++)
{
    std::cin >> Links[i].Start >> Links[i].End >> Links[i].Cost;
}

std::sort(Links.begin(), Links.end(), compare);

 

간선의 정보를 저장할 구조체를 선언하였고, 정렬하기 위한 compare함수를 정의하였다.

이후 간선의 정보를 입력받았으며, 해당 간선을 가중치 기준으로 정렬해주었다.

 

int SumCost = 0;
int LastWeight = 0;

for (int i = 0; i < NumOfRoad; i++)
{
    if (isCycle(Links[i].Start, Links[i].End) == false)
    {
        SumCost += Links[i].Cost;
        Setparent(Links[i].Start, Links[i].End);

        LastWeight = Links[i].Cost;
    }


SumCost -= LastWeight;
std::cout << SumCost;

 

이후, 일반적인 크루스칼 알고리즘과 동일하게 가중치의 합을 구해주었다.

 

그런데, 위에서 말했듯이 이렇게 연결한 다음, 가장 가중치가 비싼 간선을 제거해주어야 한다.

그러므로, 가장 마지막에 연결된 간선의 가중치를 LastWeight에 기록하였고, 마지막에 LastWeight를 가중치의 합에서 빼주었다.

 

(간선은 가중치 순으로 정렬되어 있기 때문에, 가장 마지막에 연결된 간선이 가중치가 가장 크다.)

 

SumCost를 출력해주면 문제는 끝이다.

 

코드 전문

더보기
#include <iostream>
#include <vector>
#include <algorithm>

void Init()
{
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);
    std::cout.tie(nullptr);
}

struct Link
{
    int Start = 0;
    int End = 0;
    int Cost = 0;
};

std::vector<int> Parents;

int NumOfHouse = 0;
int NumOfRoad = 0;

bool compare(const Link& _Left, const Link& _Right)
{
    return _Left.Cost < _Right.Cost;
}

int GetRootParent(int _Index)
{
    if (Parents[_Index] == _Index)
    {
        return _Index;
    }
    
    Parents[_Index] = GetRootParent(Parents[_Index]);
    return Parents[_Index];
}

void Setparent(int _Left, int _Right)
{
    if (_Left < _Right)
    {
        Parents[GetRootParent(_Right)] = _Left;
    }
    else
    {
        Parents[GetRootParent(_Left)] = _Right;
    }
}

bool isCycle(int _Left, int _Right)
{
    int LeftRootParent = GetRootParent(_Left);
    int RightRootParent = GetRootParent(_Right);

    return (LeftRootParent == RightRootParent);
}

int main()
{
    Init();

    std::cin >> NumOfHouse >> NumOfRoad;

    Parents.resize(NumOfHouse + 1);
    for (int i = 1; i <= NumOfHouse; i++)
    {
        Parents[i] = i;
    }

    std::vector<Link> Links(NumOfRoad);
    for (int i = 0; i < NumOfRoad; i++)
    {
        std::cin >> Links[i].Start >> Links[i].End >> Links[i].Cost;
    }

    std::sort(Links.begin(), Links.end(), compare);

    int SumCost = 0;
    int LastWeight = 0;

    for (int i = 0; i < NumOfRoad; i++)
    {
        if (isCycle(Links[i].Start, Links[i].End) == false)
        {
            SumCost += Links[i].Cost;
            Setparent(Links[i].Start, Links[i].End);

            LastWeight = Links[i].Cost;
        }
    }

    SumCost -= LastWeight;
    std::cout << SumCost;

    return 0;
}

 

 

지난번에, Attribute와 GameplayTag에 대해서는 어느정도 알아보았다.  Attribute는 캐릭터의 특정 속성의 구체적인 수치를 의미하는 것이고, GameplayTag는 플레이어의 현재 상태를 의미하는 것이다.

 

Attribute와 GameplayTag를 활용하여 컨텐츠를 만들려면 원하는 시기에 Attribute와 GameplayTag의 값을 변경해주는 기능이 필요할 것이다.

 

그 과정에서 중요한 역할을 하는 클래스가 GameplayEffect라고 한다. 이번 게시글에선 GameplayEffect에 대해 알아보고자 한다.

 

 

GameplayEffect

 

 먼저, 공식문서의 설명을 보자.

 

그렇다고 한다. GameplayEffect는 GAS에서 Attribute를 변경하는 방식이라고 한다. 하지만, 아예 처음 접하는 사람의 입장에선 다소 설명이 부족하다고 느껴지는 부분이 있다.

 

그래서, 예제 프로젝트 깃허브의 설명도 참조해보았다.

 

위의 글은 예제 프로젝트를 제공해준 깃허브의 GameplayEffect에 대한 설명이다.

아래는 번역이다.

더보기

GameplayEffect (GE)는 본인 혹은 다른 개체의 Attribute와 GameplayTags를 변경하기 위한 통로이다. GE는 기절, 이동속도 증가와 같이 오래 유지되는 디버프/버프 상태를 적용하기도 하고, Damage, Healing과 같이 Atrribute를 즉시 변경해주기도 한다. UGameplayEffect 클래스는 하나의 GameplayEffect를 정의하는 데이터 전용 클래스를 의미한다. 추가적인 로직을 GameplayEffect에 추가해서는 안된다. 일반적으로 디자이너들이 UgameplayEffect를 상속받은 블루프린트 클래스를 많이 만들게 될 것이다.

 

GameplayEffects 는 Modifier와 Execution을 통해 Attribute를 변경한다. (GameplayEffectExecutionCalculation).

GameplayEffects는 Instanct, Duration, Infinite의 3가지 타입을 가지고 있다.

 

추가적으로 GameplayEffects는 GameplayCues를 더하거나 실행할 수 있다.  Instant 타입의 GameplayEffect는 GameplayCue, GameplayTags 를 실행하는 반면, Duration이나 Infinite타입의 GameplayEffect는 GameplayCue, GameplayTags를 더하거나 제거할 수 있다.

 

아래는 GameplayEffect의 세가지 타입에 대한 부가적인 설명이다.

 

Instant타입은 GameplayCueEvent를 실행하고, Duration과 Infinite는 GameplayCueEvent를 제거하거나 더해준다고 한다.

더보기

GameplayCue는 나중에 좀 더 자세히 다뤄보아야 겠지만, 간단하게 알고 넘어가자면 특정 상황에서 발생하는 사운드나 이펙트와 관련된 것이라고 한다.

 

Instant는 Attribute의 BaseValue를 즉시 영구적으로 변경할 때 사용된다고 한다.

단, GameplayTags는 한 프레임에서 조차 적용되지 않는다고 한다.

 

Duration은 Attribute의 CurrentValue를 일시적으로 변경하거나, GameplayEffect가 만료되거나, 수동적인 제거 등의 이유로 GameplayTags가 제거될 때 사용된다고 한다. GameplayEffect의 지속시간은 UGameplayEffect클래스에서 지정할 수 있다고 한다.

 

Infinite는 Attribute의 CurrentValue를 일시적으로 변경하거나, GameplayEffect가 제거되어 GameplayTag를 제거하기 위해 사용한다고 한다. 이 경우엔 UGameplayEffect가 만료되는 일이 없기 때문에, ASC나 ablility에 의해 수동적으로 제거해야만 한다고 한다.

 

정리하자면, Instant는 Attribute를 한 번 바꾸고 더 이상 후속처리를 할 필요가 없을 때 사용한다는 것 같다.

예를 들어, 플레이어가 레벨업을 해서 최대 체력이 증가했다면, 이 수치는 감소될 일이 없기 때문에 Instant로 값을 한 번 바꾼 뒤 GameplayEffect를 소멸시키는 것 같다.

 

Duration은 특정 기간동안 적용되는 상태에 대해 사용하는 것 같다. 예를 들어, Buff스킬을 사용하면 일반적으로 지속시간이 존재한다. 3분이라고 가정한다면, 버프 스킬을 사용하고 3분동안은 CurrentValue가 변경되어 있다가 지속시간이 끝나는 3분 뒤에 CurrentValue를 다시 BaseValue로 변경해주어야 한다. 그렇기 때문에, GameplayEffect는 3분동안 살아있다가 마지막에 CurrentValue를 BaseValue로 변경해준 뒤에 소멸하는 듯 하다.

 

Infinite는 반대로, 특정 상태가 언제 소멸되는지 예측할 수 없을 때 사용하는 것 같다. 예를 들어, 플레이어가 안개로 가득한 맵에 입장했다고 가정해보자. 해당 맵 안에서는 이동속도가 10%가 감소하고 플레이어의 시야 범위가 좁아진다고 했을 때, 플레이어가 이 맵을 탈출하게 되면 다시 이동속도와 시야 범위를 기존의 값으로 돌려놓아야 한다. 하지만, 플레이어가 정확히 몇 분, 몇 초가 걸려서 이 맵을 탈출할 지 정확히 예측할 수 없기 때문에, GameplayEffect는 계속 살아있다가 플레이어가 맵을 탈출하는 시기에 수동적으로 제거해주어야 하는 듯 하다.

 

블루프린트 클래스를 한 번 훑어보자.

 

 

예제 프로젝트를 보면, 이렇게 3개의 GameplayEffect 블루프린트 클래스가 있다.

세 클래스 모두 Infinite이며, 플레이어의 체력, 마나, 스태미너의 자연치유에 관한 GameplayEffect이며 플레이어가 죽을 때 까지 계속 자연치유를 하기 때문에 Infinite로 되어있는 듯 하다.

 

내부를 보자.

 

먼저, Duration Policy를 보면 Infinite로 되어있다.

여기서 Instant, Duration, Infinite를 설정할 수 있다.

 

밑에 있는 Period를 확장해보면 이렇게 3개의 옵션이 나온다.

Period는 해당 GameplayEffect를 몇 초 주기로 적용할 것인가에 관한 옵션이다.

실제로, Period 수치를 변경하며 치유 주기를 확인해보니 3을 입력하면 3초 주기로 Hp가 회복되는 것을 보았다.

 

밑의 두 옵션에 대해서 정확히 이해하지는 못했지만, 에디터의 코멘트를 번역해보면 이와 같다.

 

Execute Periodic Effect on Application : True일 경우, 매 주기마다 어플리케이션에서 Effect가 실행된다.

false일 경우, 첫 번째 주기가 끝날 때 까지 실행되지 않는다.

 

아마, 이 효과가 적용된 프레임에 바로 효과를 적용할 것인지, 아니면 한 번의 주기를 건너 뛴 다음 주기부터 효과를 적용할 건지에 대한 옵션인 것 같다.

 

Periodic Inhition Policy : 주기적인 GameplayEffect가 더이상 억제되지 않을 때, 어떻게 반응할 것인가?

1) Never Reset : 마지 억제가 발생하지 않았던 것처럼 주기적인 타이밍은 계속된다.

2) Reset Period : 주기를 리셋한다. 다음 실행은 억제가 사라진 이후로부터 한 번의 주기가 끝난 이후에 발생된다.

3) Execute And Reset :즉시 실행하고, 주기를 초기화한다.

 

이 것은 외부에서 설정한 어떤 원인으로 인해, GameplayEffect의 효과가 일시적으로 억제되다가 해당 원인이 사라지면서 다시 GameplayEffect의 효과가 적용될 때 어떻게 반응할건지를 고르는 것 같다.

 

예를 들어, HP 리젠 주기가 3초라고 해보자.

2.6초가 지난 후, HP리젠을 억제하는 디버프가 들어왔고, 다시 이 디버프가 사라졌다고 해보자.

 

만약, Never Reset을 선택했다면, 디버프가 사라지고 0.4초 뒤에 HP가 리젠된다.Reset Period를 선택했다면, 디버프가 사라지고 3초 뒤에 HP가 리젠된다.Execute And Reset을 선택했다면, 디버프가 사라지는 순간 HP가 리젠되고, 그 이후로 3초의 주기마다 HP가 동일하게 리젠된다.

 

GameplayEffect가 적용되는 주기를 디테일하게 조절하는 옵션인 듯 하다.

 

밑에는 GameplayEffect 카테고리가 있다.

 이 카테고리에는 Componenty와 Modifiers, Executions가 있다.

 

Components를 확장해보면 아래와 같은 옵션들이 나온다.

첫 번째에 있는 컴포넌트에는 여러가지 목록이 존재한다. 목록을 설정함에 따라 GameplayEffect의 적용 조건을 다양하 세팅할 수 있는 듯 하다.

 

현재 설정되어 있는 것은 GameplayTag를 기반으로 이 GameplayEffect의 효과가 적용되어야 하는가, 적용되지 말아야 하는가, 삭제되어야 하는가 등을 제어하는 컴포넌트인 듯 하다. 

 이 컴포넌트의 아래 옵션들을 보면, 여러가지가 있는데, 이 중에서 현재 프로젝트의 HealthRegen에는 Ongoing Tag Requirements 만 설정되어 있다.

 

아마, State.Dead 태그가 없어야지만, 이 GameplayEffect가 적용된다는 의미인 듯 하다.

 

다음은 Modifer카테고리를 확장해보자.

이렇게 옵션이 나온다.

Attribute는 이 GameplayEffect가 어떠한 Attribute의 값을 변경할 것인지를 선택하는 것 같다.

Modifer Op는 Add, Devide, Multiply, Override, Invalid가 있는데, 더할지 곱할지 나눌지 덮어씌울지 등을 선택하는 것 같다. Invalid는 뭘까.. 설정을 바꿔서 실행해봤더니 크래시가 발생했다. 해당 Attribute가 변경되지 못하도록 막는 옵션인 걸로 추측된다.

 

아래에 있는 Modifier Magnitude 도 확장해보았다.

 

Coefficient는 계수이다. Attribute에 적용되는 수치에 Coefficient를 곱해서 최종적으로 적용되는 듯하다.

Attribute to Capture는 어떤 Attribute를 기반으로 이 Attribute의 값을 변경할 것인가를 선택하는 것 같다. 여기선 HealthRegenRate가 선택되어 있다.

 

그 외에도 여러가지 옵션들이 있는데, 이건 좀 더 만져보면서 테스트해보아야 할 듯 하다..

 

여기까지 GameplayEffect에 대해 알아보았다.

개념들을 파헤치다 보니 어떤 방식으로 GAS에서 상호작용이 발생하는지 대충은 감이 잡히는 것 같다.

 

체스판에 퀸을 N개 두었을 때, 서로 공격할 수 없도록 놓는 경우의 수라고 한다.

체스에서 퀸의 공격 범위는 위 그림과 같다.

가로, 세로, 대각선의 방향으로는 보드를 벗어자니 않는 선에서 자유롭게 이동할 수 있고, 추가적으로 주변 8방향에 대해서도 움직일 수 있다.

 

이 퀸을 체스판 위에 두었을 때, 서로 공격할 수 없도록 엇갈리게 퀸을 배치하는 경우의 수를 구하면 된다.

 

 

문제 풀이

 

위에서 퀸의 범위를 보면, 일단 하나의 행에 퀸을 2개 이상 배치할 수 없다는 것을 알 수 있다.

왜냐하면, 퀸이 동일한 행에 2개 이상 존재하게 되면 서로가 공격할 수 있게 되고, 이는 문제의 조건과 맞지 않는다.

 

그렇기 때문에, 기본적으로 하나의 행에는 하나의 퀸만 존재할 수 있다.

그런데 문제를 보면 조건이 하나 더 있는데, N * N 크기의 체스판에 N개의 퀸을 놓으라는 것이다.

 

하나의 행에 퀸을 2개 이상 놓을 수 없는데, N개의 퀸을 놓아야 한다면 하나의 행에 퀸은 반드시 1개가 존재해야 한다는 것이다.

 

위의 그림과 같이 퀸을 배치하면, 서로 공격할 수 없는 상태로 배치할 수 있다.

하지만, 퀸은 총 5개만 배치되어 있다. 문제의 조건대로라면 7개의 퀸이 배치되어야 하는데, 2개의 퀸이 배치되지 않은 것이다. 이렇게 배치되어서는 안되고, 반드시 각 행마다 1개의 퀸이 존재하도록 배치해야 한다.

 

각 행마다 1개씩 배치하면 된다는 것을 알았으니, 다음 과정은 간단하다.

1행부터 퀸을 보드에 놓고, 2행에서 놓을 수 있는 위치를 체크하며 퀸을 놓고, 다음은 3행에서 놓을 수 있는 위치를 체크하며 퀸을 놓고... 행의 수만큼 반복하면 된다. 그렇게 반복해서, 마지막 행까지 도달했다면 경우의 수를 카운트 해주면 된다.

 

아래 예시를 보자.

먼저, 처음에 (0, 0) 의 위치에 퀸을 놓았다.

이 경우엔 빨간색 범위와 같이 공격 범위가 생기고, 다음 행에서 퀸을 놓을 수 있는 위치는 파란색으로 표시된 두 칸이다.

파란색으로 표시된 곳중 더 앞의 칸에 퀸을 놓아보자.

 

이렇게 되어버린다. 다음 행에 퀸을 놓을 수 있는 위치가 없다.

이럴 땐, 그냥 앞으로 다시 돌아가면 된다. 

앞으로 돌아가서, 기존에 놓았던 자리가 아닌 다른 파란색 자리에 퀸을 놓게 되면 오른쪽처럼 다음 행에 퀸을 놓을 수 있는 자리가 하나 생긴다. 해당 위치에 퀸을 놓아보면 아래와 같다.

 

이렇게, 다음 행에 퀸을 놓을 수가 없게 된다.

이동할 수 있는 위치가 생길때까지 다시 뒤로 쭉 돌아가자.

뒤로 쭉 돌아가다 보니 완전히 처음으로 돌아와 버렸다.

이 땐, 1행에서 놓지 않았던 위치에 퀸을 놓으면서 다시 테스트하면 된다.

이런식으로 1행 2열에서 테스트를 진행하면 된다.

테스트가 끝나면, 1행 3열에서도 해보고 1행 4열에서도 해보면 끝이다.

 

쭉~ 테스트를 하다 보면 아래와 같은 경우가 생긴다.

각 행마다 퀸을 1개씩 놓으며 마지막 행까지 도달한 것이다.

이렇게 마지막까지 도달했다면, 경우의 수를 1개 카운트 해주면 된다.

 

최종적으로는 아래와 같은 두 가지 경우의 수를 탐색할 수 있다.

 

즉, 4*4칸의 보드에 대해서는 2가 답이 된다.

 

 

 

풀이 코드

int Width = 0;
int Answer = 0;

std::vector<std::pair<int, int>> QueenPos;

 

 

그리고 보드의 가로, 세로의 길이를 저장할 Width 변수와 경우의 수를 기록할 Answer 변수도 선언하였다.

마지막으로 각 행마다 퀸이 위치하는 좌표를 저장해둘 QueenPos를 선언하였다.

 

체스판의 상태를 기록할 배열을 직접적으로 선언하지 않았는데, 퀸의 좌표만 가지고도 문제를 해결할 수 있기 때문에 체스판 배열은 따로 선언하지 않았다.

 

아래는 퀸을 놓을 수 있는 곳인지 검사하는 함수이다.

bool isAble(int _X, int _Y)
{
    for (int i = 0; i < _Y; i++)
    {
        if (QueenPos[i].second == _X)
        {
            return false;
        }

        if (abs(QueenPos[i].first - _Y) == abs(QueenPos[i].second - _X))
        {
            return false;
        }
    }

    return true;
}

 

우리는 먼저 1행부터 차례대로 퀸을 놓기로 했다.

그렇다면, K행에 퀸을 놓을 수 있는지 검사할 때, K보다 큰 행에 있는 퀸은 검사할 필요가 없다.

왜냐하면 뒤의 행에는 아직 퀸을 하나도 안놓았기 때문이다.

그렇기 때문에, 현재 퀸을 놓으려는 행보다 이전의 행에 대해서만 반복문을 돌아주도록 하였다.

 

먼저, 나와 같은 열에 퀸이 있다면, false를 반환하도록 하였다.

퀸은 세로 공격 범위가 체스판을 벗어나지 않는 선에서 자유이기 때문에, 같은 행에 2개의 퀸이 놓여지는 건 모순이된다.

그러므로 X값이 같다면 false를 반환해주었다.

 

다음은 대각선 체크이다. 기본적으로, 대각선은 (1, 1) 혹은 (1, -1) 단위로 한 칸을 이동하게 된다.

현재 좌표가 M,N 이라면, 대각선의 좌표는 (M + a, N + a) 혹은 (M + a, N - a) 의 형태가 된다.

 

(M + a, N + a)  - (M, N) -> (a, a)

(M + a, N - a)  - (M, N) -> (a, -a)

 

즉, 퀸의 좌표에서 나의 좌표를 뺀 좌표의 x와 y의 절대값이 같다면, 그 퀸은 현재 좌표를 기준으로 대각선상에 존재한다는 것을 알 수 있다.

 

그러므로 abs(QueenPos[i].first - _Y) == abs(QueenPos[i].second - _X) 인 경우에도 false를 return해주었다.

 

다음은 퀸을 놓는 함수이다.

void SetQueen(int _X, int _Y)
{
    if (_Y == Width - 1)
    {
        Answer++;
        return;
    }

    QueenPos[_Y] = { _Y, _X };

    for (int i = 0; i < Width; i++)
    {
        if (isAble(i, _Y + 1) == true)
        {
            SetQueen(i, _Y + 1);
        }
    }

    return;
}

 

먼저, 맨 위의 조건문을 보자.

현재 위치가 마지막 행이라면 Answer을 증가시켜주고 return해주고 있다.

위에서 말한 것 처럼 마지막 행까지 도달했다면 해당 경우를 카운트해주는 것이다.

 

다음은, 현재 좌표를 퀸의 좌표 배열에 저장하였다.

이후, 다음 행에 대해 반복문을 돌며, 퀸을 놓을 수 있다면 퀸을 놓도록 하였다.

 

이 함수는 재귀적으로 호출되면서 DFS와 동일한 형태로 탐색하게 된다.

 

마지막으로, 아래와 같이 main함수에서 실행해주면 된다.

int main()
{
    std::cin >> Width;
    QueenPos.resize(Width);

    for (int i = 0; i < Width; i++)
    {
        SetQueen(i, 0);
    }

    std::cout << Answer;

    return 0;
}

 

 

코드 전문

 

더보기
#include <iostream>
#include <vector>
#include <chrono>

void Init()
{
    std::ios::sync_with_stdio(false);
    std::cin.tie(nullptr);
    std::cout.tie(nullptr);
}

int Width = 0;
int Answer = 0;

std::vector<std::pair<int, int>> QueenPos;

bool isAble(int _X, int _Y)
{
    for (int i = 0; i < _Y; i++)
    {
        if (QueenPos[i].second == _X)
        {
            return false;
        }

        if (abs(QueenPos[i].first - _Y) == abs(QueenPos[i].second - _X))
        {
            return false;
        }
    }

    return true;
}

void SetQueen(int _X, int _Y)
{
    if (_Y == Width - 1)
    {
        Answer++;
        return;
    }

    QueenPos[_Y] = { _Y, _X };

    for (int i = 0; i < Width; i++)
    {
        if (isAble(i, _Y + 1) == true)
        {
            SetQueen(i, _Y + 1);
        }
    }

    return;
}

int main()
{
    Init();

    std::cin >> Width;
    QueenPos.resize(Width);

    for (int i = 0; i < Width; i++)
    {
        SetQueen(i, 0);
    }

    std::cout << Answer;

    return 0;
}

 

문제 그대로 주어진 숫자를 소인수분해하여 소인수를 오름차순으로 출력하면 된다.

 

 

문제 풀이

 

기본적인 아이디어는 모든 소인수는 소수라는 개념을 토대로 하였다.

 

먼저, 최대숫자가 500만이기 때문에, 500만 이하의 소수를 구하는 것이 먼저일 것이다.

소수를 구하는 알고리즘으로 에라토스 테네스의 체를 사용하였다.

모른다면 여기를 클릭하여 나오는 게시글을 참조하자.

 

하지만, 500만 이하의 소수를 모두 구하는 것은 사실 불필요하다.

왜냐하면,  숫자 N을 A * B로 표현한다면 둘 중 하나는 반드시 sqrt(N)보다 작기 때문이다.

더보기

(R은 N의 제곱근, A, B는 곱했을 때 N이 되는 N의 약수)

 

$$ N = R * R $$

$$ N = A * B $$

$$ R * R = A * B $$

 

만약, A가 R보다 크다면 아래의 모순이 발생

$$ R * R < A * B $$

 

즉, B가 R보다 작은 값이어야지만 R * R = A * B 의 관계에 모순이 생기지 않는다.

 

B가 R보다 크더라도 마찬가지의 관계가 성립되므로

A와 B중 하나는 반드시 R보다 작은 값이어야 한다.

 

sqrt(N)이하의 소수를 모두 구했다면, 두 가지 경우를 생각해볼 수 있다.

 

1. N의 소인수가 모두 sqrt(N)보다 작은 경우

2. N의 소인수에 sqrt(N)보다 큰 소인수가 포함되어있는 경우

 

1번 경우를 생각해보자.

N의 소인수가 모두 sqrt(N)보다 작다면 N을 나누었을 때 나머지가 0이되는 수를 sqrt(N)이하의 소수에서 찾아 계속 N을 나누어준다면 N은 최종적으로 모든 소인수를 제거하고 1이 될 수 있다.

소인수가 하나씩 제거될 때 마다 해당 소인수를 출력해준다면, 답을 구할 수 있다.

 

2번 경우를 생각해보자.

N의 소인수중에서 sqrt(N)보다 큰 소인수가 포함되어 있다면, sqrt(N)이하의 소수들로 N을 아무리 나누어도 N은 1이 되지 않는다. sqrt(N) 보다 큰 소인수가 나누어지지 않기 때문이다.

 

N을 sqrt(N)이하의 소수들중 나머지가 0이되는 수로 계속 나누었을 때, N이 M이 되었다고 해보자.

이 M은 sqrt(N)보다 큰 수이다. 

 

여기서 다시 두 가지 경우를 따져보자.

 

1. M이 소수인 경우

2. M이 합성수인 경우

 

M이 소수라면, 그냥 마지막에 함께 출력해주면 된다.

M은 N의 소인수이기 때문이다.

 

M이 합성수라면, M이 소수가 될 때 까지 계속 나누어주어야 한다.

그런데 M이 합성수일 수 있을까?

 위에서 숫자 N을 두 수의 곱으로 표현한다면 둘 중 하나는 반드시 sqrt(N)보다 작아야 한다고 했다.

 

즉, M이 합성수라면 M의 소인수에도 반드시 sqrt(M)보다 작은 부분이 포함되어 있어야 한다.

하지만, sqrt(M) < sqrt(N) 이므로 N을 나누는 과정에서 M의 소인수는 모두 제거될 수 밖에 없다.

(N이 더이상 나누어지지 않을 때까지 나누었기 때문에)

 

고로 M은 반드시 소수이다.

 

결과적으로 마지막에 남은 M이라는 숫자가 1이 아니라면, M을 추가적으로 출력해주면 끝이다.

 

이해가 안된다면 코드를 보며 다시 이해해보자.

 

풀이코드

 

int NumSize = 0;
std::cin >> NumSize;

std::vector<int> Nums(NumSize);
for (int i = 0; i < NumSize; i++)
{
    std::cin >> Nums[i];
}

 

먼저, 소인수를 구해야 하는 숫자들을 입력받고 저장해주자.

int MaxSize = std::sqrt(5000000);

std::vector<int> AllNumbers(MaxSize + 1, 0);
AllNumbers[1] = -1;

for (int i = 2; i <= MaxSize; i++)
{
    if (AllNumbers[i] == -1)
    {
        continue;
    }

    for (int j = i * 2; j <= MaxSize; j += i)
    {
        AllNumbers[j] = -1;
    }
}

 

에라토스 테네스의 체 알고리즘을 이용하여, sqrt(5000000)이하의 소수를 모두 구해주었다.

std::vector<int> PrimeNumbers;
PrimeNumbers.reserve(MaxSize);

for (int i = 1; i <= MaxSize; i++)
{
    if (AllNumbers[i] == 0)
    {
        PrimeNumbers.push_back(i);
    }
}

 

소수만 저장할 벡터를 따로 선언하여, 소수만 옮겨주었다.

 

std::vector<int> Answers;
Answers.reserve(NumSize);

for (int i = 0; i < NumSize; i++)
{
    int TargetNumber = Nums[i];

    for (int j = 0; j < PrimeNumbers.size(); j++)
    {
        if (TargetNumber % PrimeNumbers[j] == 0)
        {
            TargetNumber /= PrimeNumbers[j];
            Answers.push_back(PrimeNumbers[j]);
            j--;
        }

        if (TargetNumber <= 1)
        {
            break;
        }
    }

    if (TargetNumber > 1)
    {
        Answers.push_back(TargetNumber);
    }

    for (int j = 0; j < Answers.size(); j++)
    {
        std::cout << Answers[j] << " ";
    }

    std::cout << "\n";

    Answers.clear();
}

 

먼저, 출력할 소인수들을 저장해줄 Answer벡터를 선언하였다.

이후, 소인수를 구할 숫자들의 수만큼 반복문을 돌며 각 숫자들의 소인수를 구해주었다.

 

소인수를 구하는 방법은 간단하다.

소수만 모아둔 벡터의 원소들로 숫자를 계속 나누어주었다.

 

하지만, 주의해야 하는 점은 숫자가 같은 소수로 여러번 나누어 질 수 있다는 것이다.

64의 경우, 2 * 2 * 2 * 2 * 2 * 2 이다.

즉, 같은 소수가 소인수에 여러 번 포함될 수 있다는 것이다.

 

그렇기 때문에 중간의 코드를 보면 j-- 를 해주고 있다.

다음 인덱스로 넘어가지 않고 동일한 숫자로 다시 나누어보는 것이다.

 

그렇게, 숫자를 나누어보면서 나누어지는 소수들을 Answer벡터에 넣어주었다.

마지막에 남은 TargetNumber가 1보다 큰 숫자라면 해당 숫자도 Answer벡터에 삽입해주었다.

 

마지막으로 Answer의 원소를 모두 출력해준 뒤, Answer를 재사용하기 위해 clear해주었다.

 

 

 

첫 번째 게시글에서 Attribute가 무엇인지 간단하게 알아보았었는데 이번 게시글에선 조금 더 자세히 알아보도록 하겠다.

 

Attribute는 FGameplayAttributeData라는 구조체 안에 정의되어 있다.

이 내부에는 BaseValue 와 CurrentValue가 있다.

 

 

BaseValue는 아무런 영향도 받지 않는 기본 수치이고, CurrentValue는 버프, 디버프 등의 여러 영향을 고려하여 현재 플레이어에게 적용되고 있는 수치인 것 같다.

 

예제 프로젝트에 적힌 설명을 보자.

먼저, 아래는 Attribute에 대한 설명이다.

더보기

 

Attribute는 FGameplayAttributeData라는 구조체에 의해 정의된 실수값이다. 이것은 캐릭터의 HP 부터 캐릭터의  Level, 캐릭터가 보유한 포션의 개수까지 모든 것을 대표할 수 있다. 만약 이것이 액터에 속해있는 게임플레이와 관련된 수치라면, Attribute를 사용하는 것을 고려해야 한다. Attribute는 GameplayEffects에 의해서만 수정되어야 한다. 그래야 ASC에서 변화를 예측할 수 있기 때문이다.

 

Attribute는 AttributeSet 내부에서 정의되어 존재한다. AttributeSet은 리플리케이트되기로 한 Attribute들을 리플리케이트 하는 것에 대한 책임을 가지고 있다. 어떻게 Attribute를 정의하는지는 AttributeSets섹션을 확인해라.

 

TIP : 만약, 데이터의 Attribute목록에서 특정 Attribute가 보여지지 않게 하고 싶다면, Meta = (HideInDetailsView) 지정자를 사용하면 된다.

위의 설명을 보면 Atrribute는 FGameplayAttributeData 라는 구조체 내부에 정의된 실수값이라고 표현할 수 있다.

FGameplayAttributeData내부를 보면 아래와 같은 2개의 float형 변수가 선언되어 있다.

 

 

위와 같이 BaseColor과 CurrentValue 두개의 float 변수가 선언되어 있다.

아무래도 BaseValue는 기본 수치인 것 같고, CurrentValue는 여러가지 영향에 의해 최종적으로 계산되어 Actor에게 직접적으로 적용되고 있는 수치가 아닐까 싶다. 예제 프로젝트 깃허브의 설명은 아래와 같다.

더보기

 

Attribute는 BaseValue와 CurrentValue 두 값으로 구성되어 있다. BaseValue는 Attribute의 영구적인 값이고, CurrentValue는 GameplayEffects에 의해 일시적으로 수정된 수치가 BaseValue에 더해진 것이다.

예시를 위해, 캐릭터가 600units/second 라는 BaseValue를 가진 Attribute로 이동하고 있다고 가정해보자. 아직 GameplayEffects에 의해 수정된 이동속도가 없기 때문에 CurrentValue 또한 600u/s 일 것이다. 만약, 너의 캐릭터가 50u/s만큼의 이동속도가 증가하는 버프를 얻었다면, BaseValue는 여전히 600u/s 이지만, CurrentValue는 600u/s + 50u/s의  합으로 설정되어 있을 것이다. 이동속도 증가 버프가 사라진다면, CurrentValue는 다시 BaseValue인 600u/s로 돌아오게 된다.

 

GAS 초심자들은 가끔 BaseValue가 Attribute의 최대값이라고 착각하는 경우가 있다. 이것은 잘못된 접근이다. abilities나 UI에 의해 참조되는 Attribute의 최대값은 별도의 Attribute로 생성해주어야 한다. 최대값과 최소값을 하드코딩 하기 위해서는 최대, 최소에 대한 값을 가진 FAttributeMetaData 으로 DataTable을 정의하는 방법이 있다. 그러나 해당 구조체에 적혀있는 에픽게임즈의 코멘트를 보면 WIP(아직 개발중)이라고 한다. 더 많은 정보를 얻고 싶다면 Attribute.h 를 참고하자. 혼동을 피하기 위해서, Attribute의 최대 수치는 별도의 Attribute로 만들어주고 현재 수치에 대한 Attribute를 최대, 최소의 정보를 가진 Attribute를 이용해서 clamp하는 것을 추천한다. Attribute의 값을 Clamp하는 것은 Current Value를 바꾸고 싶다면 PreAttributeChange() 항목을 참조하면 되고, GameplayEffect로부터 BaseValue를 바꾸고 싶다면 PoseGameplayEffectExecute()항목을 참조하면 된다. 

 

BaseValue에 대한 영구적인 변경은 인스턴스 GameplayEffect로부터 오는 반면, CurrentValue의 변경은 영구적인 GameplayEffect와 지속시간으로부터 온다. 주기적인 GameplayEffect는 인스턴스 GameplayEffect와 동일하게 취급되며 BaseValue를 바꾸게 된다.

 

(이 마지막 문장은 이해가 잘 안된다. 이 부분은 GameplayEffect에 대한 지식이 있어야 제대로 이해할 수 있을 듯 하다.)

 

일단, 설명은 위와 같다. BaseValue는 말 그대로 어떠한 영향도 없는 기본 수치이며 CurrentValue는 어떠한 영향에 의해 증가(감소)된 상태의 수치인 것이다. 

 

BaseValue는 Attribute의 최대값이 아니라고 설명하는 부분이 있다. CurrentHP라는 이름으로 현재 HP에 대한 Attribute를 정의하였다면, 이 Attribute는 정말 현재 HP에 대해서만 관여해야 한다.

 

만약 MaxHP가 100이고, CurrentHP가 50이라고 해보자. 이 때, HP를 100채워주는 물약을 먹었다면, CurrentHP는 150이 되고, MaxHP를 초과하기 때문에 100으로 clamping되어야 한다. 이런 clamping을 할 때, Attribute내부의 BaseValue를 이용해서 clamp 하는 것이 아니라는 것이다. BaseValue는 누군가에게 피해를 입지도 않았고, 어떠한 버프나 디버프도 없는 상태의 값일 뿐이기 때문이다. CurrentValue 는 BaseValue보다 클 수도 있고 작을 수도 있다. BaseValue를 CurrentValue의 범위 기준으로 삼는 것은 매우 잘못되었다는 것이다.

 

 그렇기 때문에 아예 별도로 MaxHP에 대한 Attribute를 정의한 뒤, 이 값에 의해 HP Attribute를 Clamp 하는 것이 좋은 방법이라고 한다. 보통 게임 내부의 HP창을 보면 최대 체력 / 현재 체력 이런식으로 표시가 되는데, 이 것을 BaseValue / CurrentValue로 하는 것이 아니라, MaxHP의 CurrentValue / CurrentHp의 CurrentValue 이렇게 사용하라는 것 같다.

 

이렇게 정의된 Attribute가 변경될 때마다, 특정 함수를 실행하면서 대응하고 싶다면 UAbilitySystemComponent::GetGameplayAttributeValueChangeDelegate(FGameplayAttribute Attribute) 함수를 사용하여 델리게이트에 원하는 함수를 바인딩하면 된다고 한다.

 

아래는 예제 프로젝트의 PlayerState 파생클래스에서 콜백함수를 바인딩하고 있는 코드이다.

HealthChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetHealthAttribute()).AddUObject(this, &AGDPlayerState::HealthChanged);
MaxHealthChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetMaxHealthAttribute()).AddUObject(this, &AGDPlayerState::MaxHealthChanged);
HealthRegenRateChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetHealthRegenRateAttribute()).AddUObject(this, &AGDPlayerState::HealthRegenRateChanged);
ManaChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetManaAttribute()).AddUObject(this, &AGDPlayerState::ManaChanged);
MaxManaChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetMaxManaAttribute()).AddUObject(this, &AGDPlayerState::MaxManaChanged);
ManaRegenRateChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetManaRegenRateAttribute()).AddUObject(this, &AGDPlayerState::ManaRegenRateChanged);
StaminaChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetStaminaAttribute()).AddUObject(this, &AGDPlayerState::StaminaChanged);
MaxStaminaChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetMaxStaminaAttribute()).AddUObject(this, &AGDPlayerState::MaxStaminaChanged);
StaminaRegenRateChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetStaminaRegenRateAttribute()).AddUObject(this, &AGDPlayerState::StaminaRegenRateChanged);
XPChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetXPAttribute()).AddUObject(this, &AGDPlayerState::XPChanged);
GoldChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetGoldAttribute()).AddUObject(this, &AGDPlayerState::GoldChanged);
CharacterLevelChangedDelegateHandle = AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetCharacterLevelAttribute()).AddUObject(this, &AGDPlayerState::CharacterLevelChanged);

 

 

이러한, Attribute들은 FGameplayAttributeData라는 구조체에 1대1로 대응한다.

4개의 Attribute를 만들고 싶다면, 4개의 FGameplayAttributeData 구조체를 생성해야 하는 것이다.

Attribute가 많아질수록 관리가 힘들어지기 때문에, Attribute를 한 번에 관리하기 위한 AttributeSet 클래스도 존재한다.

 

예제 프로젝트에선 아래와 같이 AttributeSet을 상속받은 클래스를 만든 뒤 내부에 Attribute들을 선언해주었다.

 

참고로 Attribute는 블루프린트로 선언할 수 없고 무조건 C++로 선언해야 한다고 한다.

또한 AttributeSet 내부엔 아래와 같은 함수들도 선언되어 있다.

 

정의는 아래와 같이 되어있는데, Attribute를 제대로 리플리케이팅하기 위해 있는 함수라고 한다.

GAMEPLAYATTRIBUTE_REPNOTIFY는 기본적으로 Attribute를 제대로 Replicate 하기 위한 역할을 한다고 한다.

첫번째 게시글에서도 작성하였지만, Attribute의 변화에 대응하기 위해 바인딩한 콜백함수들은 해당 매크로 내부에서 broadcast해주고 있다.

 

AttributeSet은 여러 방식으로 정의하여, 하나의 ASC가 여러개의 AttributeSet을 관리하도록 할 수도 있다고 한다.

 

플레이어 스탯관련 AttributeSet을 만들어, 그 안에는 Hp, Mp 등을 정의하고

커뮤니티 관련 AttributeSet을 만들어, 친밀도, 친구의 수 등을 정의하였다면 ASC에 두개의 AttributeSet을 연결하여 하나의 ASC가 여러개의 AttributeSet을 관리하도록 할 수 있다고 한다.

 

그런데, 동일한 AttributeSet을 여러개 연결할 수는 없다고 한다. 예를 들어 스탯관련 AttributeSet을  FPlayerStatAttributeSet 라는 클래스로 만들었다면, ASC에는 FPlayerStatAttributeSet 인스턴스는 1개만 연결할 수 있다는 것이다. 실제로는 여러개의 인스턴스를 연결하더라도 연결 자체가 안되는 것은 아닌데, Attribute를 변경하거나 탐색할 때 어느 인스턴스로부터 정보를 참조해야 하는지 알 수가 없어 의도와는 다른 결과를 초래할 수 있다고 한다.

 

Attribute들의 값을 초기화하는 방법은 여러가지가 있다고 한다.

위에서 AttributeSet 내부에서 Attribute를 선언할 때, ATTRIBUTE_ACCESSORS 매크로를 사용하였다면

InitHealth, InitMana 등의 함수를 호출할 수 있게 된다. 이를 이용해서 C++에서 초기화 해도 된다.

 

하지만, 예제 프로젝트의 경우 블루프린트를 이용하여 초기화해주고 있다.

GameplayEffect를 상속받은 블루프린트 클래스이다.

내부의 Detail창을 보면 아래와 같은 Modifier들이 있다.

확장해보면 아래와 같다.

이렇게 초기화 세팅을 해주고 있다.

그런데 보면 모든 Attribute를 여기서 초기화해주고 있는 것이 아니라, Health, Mana, Stamina등은 초기화해주고 있지 않다.

예를 들어, Health(현재 체력)의 경우, 최초에는 최대체력(MaxHealth)로 초기화가 되어야 할 것이다.

그런데, MaxHealth와 Health를 둘 다 숫자를 입력하여 초기화하게 되면, MaxHealth수치를 바꿀 때마다 Health의 초기화 수치도 수정해야 한다.

 

예제 코드에선 아래와 같이 Max로 설정한 수치로 해당 Attribute를 초기화해주고 있다.

위의 코드는 Pawn이 Contoller에 의해 possess될 때 실행되고 있다.

 

첫 번째 게시글에서 알아봤던 것과 겹치는 부분도 많지만, Attribute를 조금 더 자세하게 알아보았고 사용법에 대해서도 조금 알아보았다. 복습하면서 더 이해되는 부분도 있었던 것 같다. 이렇게 야금야금 파헤치다 보면 언젠가 고수가 될 수 있을 거라 믿어 의심치 않는다!

 

+ Recent posts