멀티 스레드 환경에선 동기화를 달성하기 위해, lock을 보통 사용하곤 한다.

하지만, lock을 사용하는 경우 오버헤드가 발생하기도 하고 데드락의 문제도 발생할 수 있다.

 

성능상 이점을 보기 위해 사용하는 것이 멀티 스레딩인데, 오히려 성능을 저하시킨다면 상당히 잘못된 설계라고 할 수 있을 것이다. 이 때문에, lock을 사용하지 않고 프로그램을 설계하고자 하는 것을 lock_free라고 한다.

 

lock_free를 달성하기 위해 사용되는 알고리즘 중 하나가 CAS이다.

 

CAS

어떠한 변수의 값을 읽고 쓰는 연산을 할 때, 스레드는 자신의 스택 영역에 해당 변수를 저장하게 된다.

그리고, 스택 영역의 데이터를 기반으로 연산을 진행항 뒤 메인 메모리에 쓰기 작업을 함으로써 작업을 마치게 된다.

 

하지만, 멀티 스레딩 환경에선 문제가 발생할 수 있다. 스레드의 스택 영역에 저장된 값을 기반으로 연산을 하는 동안 다른 스레드가 메인 메모리의 값을 바꿔버렸다면 결과값이 기대값과 달라질 수 있기 때문이다.

 

CAS는 이를 탐지한다. 스레드의 스택영역에 저장되어 있는 변수의 값이 메인 메모리에 저장되어 있는 값과 동일한지를 판단한 후, 동일하다면 그 때 쓰기 연산을 실행하는 것이다.

 

ABA 문제

CAS는 lock_Free를 달성하기 위해 자주 사용되는 방식이지만, ABA 문제를 일으킬 수 있어서 주의가 필요한 알고리즘이라고 한다.

 

ABA 문제는 그림을 보며 이해해보자.

 

 

이런 스택이 있다고 해보자. A,B,C,D 총 4개의 데이터를 가지고 있는 스택이며 top은 A이고 next는 B이다.

 

이 때, 2개의 스레드가 있다고 해보자.

1번 스레드는 pop() 연산을 1회 진행할 것이며, 2번 스레드는 2번의 pop()과 1번의 push()작업을 진행할 것이다.

2번 스레드가 1번 스레드보다 훨씬 빠른 상황이라고 가정할 것이다.

 

스레드 1이 pop을 하기 전에, 스레드 2가 모든 작업을 마친다고 한다면, 아래와 같은 순서로 스레드 2는 작업을 할 것이다.

 

A, B를 pop하였고, 다시 A를 push하여 스택의 원소는 3개이며 top은 그대로 A인 상황이다.

 

이 때, 1번 스레드에서 작업을 하려고 한다면?

 

스레드의 스택 영역에 저장되어 있는 top과 메인 메모리의 top이 동일하기 때문에, CAS결과 문제가 없다고 판단하여 pop연산을 진행할 것이다. 하지만, 실제로는 스택 내부의 값이 달라져있다.

 

스레드 1에선 top()인 A를 pop()하면서 next()인 B를 top()으로 설정할 것이다.

하지만, 메인 메모리에는 B가 존재하지 않는다. 해당 작업이 끝난 뒤, 메인 메모리에서 스택의 top()에 접근하면? 치명적인 오류가 발생할 것이다.

 

이 것이 ABA 문제이다. 단순히 값이 같냐 다르냐 이것만으로 판단하기엔 위와 같은 문제가 발생할 여지가 있다는 것이다.

 

ABA 문제를 해결하기 위해, Doble CAS나 Hazard Pointer와 같은 기법을 사용한다고 한다.

해당 기법은 다른 게시물에서 별도로 정리해보아야 할 듯 하다.

 

1행 1열에서 시작해서, n행 n열까지 도달하기 위해선, 몇개의 검은 방을 하얀 방으로 바꾸어야 하는 가를 구하는 문제이다.

 

가는 길이 검은방으로 막혀있다면, 검은 방을 하얀 방으로 바꿔 지나갈 수 있는 길로 만들 수 있다. 이렇게, 방을 바꿔가며 목적지까지 이동할 때, 방의 색을 바꾸는 최소의 수를 구하면 된다.

 

문제 풀이

 

본인은 BFS를 활용하여 해결하였다. 행렬의 최대 크기가 50인만큼, 완전탐색을 하여도 부담이 없기 때문이다.

 

또한, 본인이 푼 방식은 50개의 칸만 탐색하는 완전탐색이 아니라, 이미 거쳤던 곳도 다시 방문하여 탐색하는 방식인데 행렬의 크기가 커질수록 연산 부담이 커지겠지만, 문제에서 주어진 최대 행렬 크기가 50 x 50 이기 때문에 사용할 수 있었다.

 

먼저 2개의 이차원 배열을 준비하였다.

 

하나는 맵 배열이고, 하나는 방을 바꾼 횟수를 저장한 배열이다.

맵 배열을 Map, 방을 바꾼 횟수를 저장한 배열을 ChangeCount라고 하겠다.

 

ChangeCount에서 (i, j)가 의미하는 것은 (1, 1)에서 (i, j)로 갈 때, 검은 방을 하얀 방으로 바꾸는 최소 횟수이다.

 

탐색 방식은 아래와 같다.

 

먼저, 첫 번째 칸 (1, 1)을 큐에 삽입한다.

이후, 큐의 size가 0이 될 때까지 반복문을 돌린다.

 

반복문 내부에선, 큐의 front를 기준으로 4방향을 탐색한다.

 

다음에 나아갈 방향이 검은 방이라면, 현재 칸의 ChangeCount + 1과 다음 칸의 ChangeCount를 비교한다.

만약, 다음 칸의 ChangeCount 값이 현재 칸의 ChangeCount + 1 보다 같거나 작다면, 다음 칸으로 나아가지 않는다.

 (ChangeCount는 최소값을 저장하는 배열이기 때문에, 최소 값을 갱신할 수 없는 상황이라면 나아가지 않는다.)

 

다음에 나아갈 방향이 하얀 방이라면, 현재 칸의 ChangeCount와 다음 칸의 ChangeCount를 비교한다.

만약, 다음 칸의 ChangeCount 값이 현재 칸의 ChangeCount보다 같거나 작다면, 다음 칸으로 나아가지 않는다.

 

다음에 나아갈 방향이 검은 방일 때와 하얀 방일 때 모두 처리하는 과정은 똑같다. 다만, 검은 방인 경우 해당 방의 색을 바꿔주어야 하기 때문에, ChangeCount에 1을 더해서 비교해주고 있다.

 

이 과정을 모두 마치고 나면, ChangeCount의 (n, n)에는 1,1에서 시작해서 n,n에 도착할 때 까지 방의 색을 바꾼 최소 횟수가 저장되어 있을 것이다.

 

풀이 코드

 

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

std::vector<std::vector<int>> Map(Size, std::vector<int>(Size));
for (int i = 0; i < Size; i++)
{
    std::string Input;
    std::cin >> Input;

    for (int j = 0; j < Size; j++)
    {
        Map[i][j] = Input[j] - '0';
    }
}

 

먼저 입력을 받아주었다.

int로 받으면 한 줄이 하나의 원소에 저장되어 버려서, string으로 받은 뒤 하나씩 분배해주었다.

 

struct Node
{
    int Y = 0;
    int X = 0;
};

 

위치 정보를 저장할 구조체를 선언하였다. std::pair을 사용해도 되지만, 직접 정의한 구조체를 이용해 조금 더 가독성을 높혀보았다.

 

std::vector<std::vector<int>> ChangeCount(Size, std::vector<int>(Size, 10000000));

ChangeCount[0][0] = 1 - Map[0][0];

std::queue<Node> BFS;
BFS.push({ 0, 0});

std::vector<int> DirX = { 0, 1, 0, -1 };
std::vector<int> DirY = { -1, 0, 1, 0 };

 

ChangeCount는 위에서 말한 것과 같이 해당 칸으로 이동하는동안 방을 바꾸는 최소 횟수를 저장한 배열이다.

최소 값을 갱신하는 배열이기 때문에 초기값을 충분히 큰 값으로 잡아주었다.

 

다음은 BFS를 하기 위한 queue를 선언하였고, 4방향을 탐색하기 위한 Dir 배열도 선언해주었다.

while (BFS.size() > 0)
{
    Node CurNode = BFS.front();
    BFS.pop();

    for (int i = 0; i < 4; i++)
    {
        int NextX = CurNode.X + DirX[i];
        int NextY = CurNode.Y + DirY[i];

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

        if (Map[NextY][NextX] == 0)
        {
            if (ChangeCount[CurNode.Y][CurNode.X] + 1 >= ChangeCount[NextY][NextX])
            {
                continue;
            }
            else
            {
                ChangeCount[NextY][NextX] = ChangeCount[CurNode.Y][CurNode.X] + 1;
                BFS.push({ NextY, NextX });
            }
        }
        else
        {
            if (ChangeCount[CurNode.Y][CurNode.X] >= ChangeCount[NextY][NextX])
            {
                continue;
            }
            else
            {
                ChangeCount[NextY][NextX] = ChangeCount[CurNode.Y][CurNode.X];
                BFS.push({ NextY, NextX });
            }
        }
    }
}

 

큐의 최상위 원소를 뽑아서, 4방향을 탐색해주었다.

 

위에서 설명한 것과 같이, 다음에 나아갈 곳이 검은 방이라면 ChangeCount + 1 을 기준으로 대소비교를 하고있으며 하얀 방이라면 ChangeCount를 기준으로 대소비교를 하고 있다.

 

최소값을 갱신할 수 있다면, 해당 방향으로 나아가 계속 최소값을 갱신해주었다.

std::cout << ChangeCount[Size - 1][Size - 1];

return 0;

 

BFS가 끝난 뒤 목적지의 값을 출력해주면 끝이다.

 

코드 전문

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

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

struct Node
{
    int Y = 0;
    int X = 0;
};

int main()
{
    Init();

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

    std::vector<std::vector<int>> Map(Size, std::vector<int>(Size));
    for (int i = 0; i < Size; i++)
    {
        std::string Input;
        std::cin >> Input;

        for (int j = 0; j < Size; j++)
        {
            Map[i][j] = Input[j] - '0';
        }
    }

    std::vector<std::vector<int>> ChangeCount(Size, std::vector<int>(Size, 10000000));

    ChangeCount[0][0] = 1 - Map[0][0];

    std::queue<Node> BFS;
    BFS.push({ 0, 0});

    std::vector<int> DirX = { 0, 1, 0, -1 };
    std::vector<int> DirY = { -1, 0, 1, 0 };

    while (BFS.size() > 0)
    {
        Node CurNode = BFS.front();
        BFS.pop();

        for (int i = 0; i < 4; i++)
        {
            int NextX = CurNode.X + DirX[i];
            int NextY = CurNode.Y + DirY[i];

            if (NextX < 0 || NextY < 0 || NextX >= Size || NextY >= Size)
            {
                continue;
            }
            
            if (Map[NextY][NextX] == 0)
            {
                if (ChangeCount[CurNode.Y][CurNode.X] + 1 >= ChangeCount[NextY][NextX])
                {
                	continue;
                }
                else
                {
                	ChangeCount[NextY][NextX] = ChangeCount[CurNode.Y][CurNode.X] + 1;
                	BFS.push({ NextY, NextX });
                }
            }
            else
            {
                if (ChangeCount[CurNode.Y][CurNode.X] >= ChangeCount[NextY][NextX])
                {
                    continue;
                }
                else
                {
                    ChangeCount[NextY][NextX] = ChangeCount[CurNode.Y][CurNode.X];
                    BFS.push({ NextY, NextX });
                }
            }
        }
    }

    std::cout << ChangeCount[Size - 1][Size - 1];

    return 0;
}

 

기존에는 CubeMap은 무조건 dds파일로, 일반 텍스쳐는 다른 확장자로 로드하는 이상한 구조였다.

이번엔, 확장자 별로 로드방식을 다르게 하되, 큐브맵 텍스쳐와 일반적인 디퓨즈 텍스쳐를 구분하여 로드하도록 수정할 것이다.

void ResourceManager::LoadTexture(const std::string& _TextureName, ETextureType _Type)
{
	std::string Format = GetFormat(_TextureName);

	if (Format == "dds")
	{
		LoadDDSTexture(_TextureName, _Type);
	}
	else
	{
		LoadGeneralTexture(_TextureName, _Type);
	}
}

 

먼저 LoadTexture 함수를 위와 같이 수정하였다.

파일 이름에서 확장자를 탐색한 뒤, 확장자를 기준으로 dds라면 LoadDDSTexture함수를 호출하였고 아니라면 LoadGeneralTexture함수를 호출하였다.

 

이렇게 확장자를 구분한 이유는 stb 라이브러리는 dds파일을 지원하지 않는 것도 있고, dds파일의 경우 마이크로 소프트에서 다이렉트X에 맞게 만든 확장자이다 보니 DirectX 함수를 사용하는 것이 여러모로 좋을 것 같기 때문이기도 하다.

 

std::string ResourceManager::GetFormat(const std::string& _FileName)
{
    int Count = 0;

    for (int i = _FileName.size() - 1; i >= 0; i--)
    {
        if (_FileName[i] == '.')
        {
            break;
        }

        Count++;
    }

    std::string Format = _FileName.substr(_FileName.size() - Count, Count);

    return Format;
}

 

GetFormat함수 내부는 위와 같다.

void ResourceManager::LoadDDSTexture(const std::string& _TextureName, ETextureType _Type)
{
    Microsoft::WRL::ComPtr<ID3D11Texture2D> Texture;
    Microsoft::WRL::ComPtr<ID3D11ShaderResourceView> SRV;

    std::wstring Path = L"../Texture/";
    std::wstring TextureName;
    TextureName.assign(_TextureName.begin(), _TextureName.end());

    Path += TextureName;

    UINT Flag = 0;

    if (_Type == ETextureType::CubeMap)
    {
        Flag = D3D11_RESOURCE_MISC_TEXTURECUBE;
    }

    HRESULT Result = DirectX::CreateDDSTextureFromFileEx(
        EngineBase::GetInstance().GetDevice().Get(), Path.c_str(), 0, D3D11_USAGE_DEFAULT,
        D3D11_BIND_SHADER_RESOURCE, 0,
        Flag,
        DirectX::DDS_LOADER_FLAGS(false), (ID3D11Resource**)Texture.GetAddressOf(),
        SRV.GetAddressOf(), nullptr);

    if (Result != S_OK)
    {
        std::cout << "LoadCubeMapTexture failed " << std::endl;
        return;
    }

    TextureData NewTextureData;
    NewTextureData.Texture = Texture;
    NewTextureData.ShaderResourceView = SRV;

    LoadedTextures.insert({ _TextureName, NewTextureData });
}

 

위 코드는 DDS 파일 로드 함수이다.

Cube텍스쳐의 경우, Flag를 설정하여 SRV를 Cube텍스쳐로 생성하도록 하였다.

void ResourceManager::LoadGeneralTexture(const std::string& _TextureName, ETextureType _Type)
{
    std::string Path = "../Texture/";
    Path += _TextureName;

    int Width = 0;
    int Height = 0;
    int Channels = 0;

    unsigned char* LoadedImage = stbi_load(Path.c_str(), &Width, &Height, &Channels, 0);
    if (LoadedImage == nullptr)
    {
        std::cout << "Image Load Failed" << std::endl;
        return;
    }

    std::vector<uint8_t> Image;

    Image.resize(Width * Height * Channels);
    memcpy(Image.data(), LoadedImage, Image.size() * sizeof(uint8_t));

    Microsoft::WRL::ComPtr<ID3D11Texture2D> Texture;
    Microsoft::WRL::ComPtr<ID3D11ShaderResourceView> SRV;

    D3D11_TEXTURE2D_DESC TexDesc = {};
    TexDesc.Width = Width;
    TexDesc.Height = Height;
    TexDesc.MipLevels = TexDesc.ArraySize = 1;
    TexDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
    TexDesc.SampleDesc.Count = 1;
    TexDesc.Usage = D3D11_USAGE_IMMUTABLE;
    TexDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE;

    if(_Type == ETextureType::CubeMap)
    {
        TexDesc.MiscFlags = D3D11_RESOURCE_MISC_TEXTURECUBE;
    }

    D3D11_SUBRESOURCE_DATA InitData;
    InitData.pSysMem = Image.data();
    InitData.SysMemPitch = TexDesc.Width * sizeof(uint8_t) * Channels;

    HRESULT Result = EngineBase::GetInstance().GetDevice()->CreateTexture2D(&TexDesc, &InitData, Texture.GetAddressOf());
    if (Result != S_OK)
    {
        std::cout << "CreateTexture2D failed " << std::endl;
        return;
    }

    Result = EngineBase::GetInstance().GetDevice()->CreateShaderResourceView(Texture.Get(), nullptr, SRV.GetAddressOf());
    if (Result != S_OK)
    {
        std::cout << "CreateTexture2D failed " << std::endl;
        return;
    }

    TextureData NewTextureData;
    NewTextureData.Texture = Texture;
    NewTextureData.ShaderResourceView = SRV;

    stbi_image_free(LoadedImage);

    LoadedTextures.insert({ _TextureName, NewTextureData });

    return;
}

 

위 코드는 DDS를 제외한 나머지 텍스쳐를 로드하는 코드이다.

stb라이브러리를 사용해서 로드하고 있으며, DDS파일과 동일하게 ETextureType에 따라 Flag를 설정하여 Cube텍스쳐는 그에 맞게 로드하도록 하였다.

 

 

기존과 동일하게 잘 렌더링이 된다!

 

(나중에 알게된 사실이지만 큐브맵 텍스쳐는 DDS만 된다고 한다. png나 jpg등으로 사용하려면 직접 조립해야한다고...)


 

일정을 규칙에 따라 차례대로 달력에 기록한다.

일정이 겹치면, 해당 일정은 아랫줄에 추가로 기록한다.

 

모든 일정을 기록한 뒤, 직사각형의 코팅지를 일정 위에 붙인다고 했을 때, 최소로 필요한 코팅지의 면적을 구하면 된다.

 

문제 풀이

 

본인은 365일을 기록하는 벡터를 만든 뒤, 해당 벡터에 일정을 모두 기록하여 문제를 해결하였다.

7
2 4
4 5
5 6
5 7
7 9
11 12
12 12

 

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

이렇게, 모든 날의 일정을 기록할 벡터를 선언하였다.

이후, 일정을 차례대로 입력할 것이다.

 

문제 예시를 보면, 겹치는 일정이 하나가 생길 때마다 높이가 1씩 증가한다.

그러므로, 한 날짜에 일정이 부여될 때마다 ++을 해줄 것이다.

 

(만약, 5일에 5개의 일정이 있다면, 5번 인덱스의 값은 5가 된다.)

 

이제 차례대로 입력을 처리해보자.

첫 번째 입력은 2 4 이다.

이렇게 2~4에 1을 더해주었다.

다음 입력은 4~5이다.

이번엔, 4와 5에 1을 더해서 위 표와 같은 값으로 갱신되었다.

다음 입력은 5~6이다.

위 표와 같이 갱신되었다.

다음 입력은 5~7이다.

이렇게 갱신되었다.

동일한 방식으로 나머지 모든 입력을 처리해주면 아래와 같아질 것이다.

 

이렇게, 일정을 모두 기록하였다면 코팅지의 최소 크기를 구하면 된다.

먼저, 일정이 없는 날에는 코팅지를 붙일 필요가 없다.

그러므로 일정이 있는 날에 대해서만 코팅지의 크기를 계산해줄 것이다.

 

 

먼저, 이 두개의 섹션에 대해서만 코팅지를 붙이면 된다.

각 섹션의 길이가 코팅지의 가로 길이일 것이다.

 

코팅지의 세로 길이는 그 안에 있는 모든 일정을 담아야 하기 때문에, 섹션에 포함된 값들 중 가장 큰 값이 코팅지의 세로 길이가 될 것이다.

 

그러므로, 왼쪽 섹션의 코팅지의 세로 길이는 3이 될 것이며 오른쪽 섹션의 코팅지의 세로 길이는 2가 될 것이다.

 

왼쪽 섹션의 코팅지의 넓이 : 가로 길이 * 세로 길이 = 8 * 3 = 24

오른쪽 섹션의 코팅지의 넓이 : 가로 길이 * 세로 길이 = 2 * 2 = 4

 

정답 = 24 + 4 = 28 이 된다.

 

풀이 코드

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

std::vector<std::pair<int, int>> Schedules(InputSize);

for (int i = 0; i < InputSize; i++)
{
    std::cin >> Schedules[i].first >> Schedules[i].second;
}

 

먼저, 입력되는 일정 정보를 vector에 저장해주었다.

 

std::vector<int> Days(367);

for (int i = 0; i < InputSize; i++)
{
    for (int j = Schedules[i].first; j <= Schedules[i].second; j++)
    {
        Days[j]++;
    }
}

 

이후, 일정을 기록할 Days 배열을 선언한 뒤, 모든 일정을 기록해주었다.

일정이 하나 겹칠 때마다 1씩 값을 증가시켜주었다.

 

여기서 Days의 길이가 365가 아니라, 367인데 이유는 아래에서 확인해보자.

int Start = 0;
int MaxY = 0;
int Sum = 0;

for (int i = 1; i <= 366; i++)
{
    if (Days[i - 1] != 0 && Days[i] == 0)
    {
        Sum += MaxY * (i - Start);
        MaxY = -1;
    }
    else if (Days[i - 1] == 0 && Days[i] != 0)
    {
        Start = i;
    }

    if (Days[i] != 0)
    {
        MaxY = std::max(MaxY, Days[i]);
    }
}

 

먼저, 시작 인덱스와 섹션의 최대 값을 저장할 Start와 MaxY를 선언해주었고, 정답을 저장할 Sum도 선언해주었다.

 

여기서, 각 섹션의 시작을 값이 0인 인덱스에서 0이 아닌 인덱스로 변화하는 시점으로 잡아주었다. 또한, 섹션의 끝은 값이 0이 아닌 인덱스에서 값이 0으로 변하는 시점으로 잡아주었다.

 

이 때, 시작부터 끝까지 모든 섹션을 동일하게 처리하기 위해, 배열을 367칸으로 선언하여 0번 인덱스와 366번 인덱스를 활용하였다.

 

만약 0~364로 선언된 배열이었을 때, 마지막 일정이 365일에 끝난다면 해당 일정이 속한 섹션을 반복문 내에서 처리할 수 없기 때문에 추가적으로 코드를 작성해야 한다.

 

그렇게 각 섹션의 길이와 최대값을 구한 뒤, 섹션을 탈출할 때 두 값을 곱해 코팅지의 넓이를 구하고 Sum에 더해주었다.

 

std::cout << Sum;

return 0;

 

마지막으로 답을 출력해주면 된다.

 

코드 전문

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

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

int main()
{
    Init();

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

    std::vector<std::pair<int, int>> Schedules(InputSize);

    for (int i = 0; i < InputSize; i++)
    {
        std::cin >> Schedules[i].first >> Schedules[i].second;
    }

    std::vector<int> Days(367);

    for (int i = 0; i < InputSize; i++)
    {
        for (int j = Schedules[i].first; j <= Schedules[i].second; j++)
        {
            Days[j]++;
        }
    }

    int Start = 0;
    int MaxY = 0;
    int Sum = 0;

    for (int i = 1; i <= 366; i++)
    {
        if (Days[i - 1] != 0 && Days[i] == 0)
        {
            Sum += MaxY * (i - Start);
            MaxY = -1;
        }
        else if (Days[i - 1] == 0 && Days[i] != 0)
        {
            Start = i;
        }

        if (Days[i] != 0)
        {
            MaxY = std::max(MaxY, Days[i]);
        }
    }

    std::cout << Sum;

    return 0;
}

 
멀티 스레드 상황에서 한 가지 상황을 가정해보자.

int a = 0;
int b = 0;

void Func()
{
    a = 1;
    b = 2;
}

 
위의 코드에서 Func 함수에 2개의 스레드가 접근하였다고 해보자.
이 때, 스레드에서 관찰될 수 있는 (a, b)의 값은 어떤 경우가 있을까?
 
먼저, a와 b에 아무것도 입력되지 않은 상황인 (0, 0)이 있을 것이다.
a만 입력되고 b는 입력되지 않은 (1, 0) 도 가능할 것이다.
a와 b가 모두 입력된 (1, 2)또한 가능할 것이다.
 
그렇다면, (0, 2)는 가능할까?
 
상식적으로 생각했을 때, 코드는 위에서 아래로 진행되기 때문에 a = 1; 이라는 코드가 실행되지 않았다면, b = 2; 또한 실행될 수 없다.
 
그러므로, a가 0인 상황에서 b에만 2가 입력되는 상황은 발생하지 않을 것만 같다.
하지만, 실제로는 발생할 수 있다.
 
왜냐하면, 컴파일러 최적화 때문이다.
 
Func() 함수를 실행하기 전에, b를 이용한 어떤 연산을 했다고 해보자.
그렇다면, Func에 들어오는 순간 CPU의 캐시에는 b와 관련된 데이터가 있을 것이다.
이 때 Func에서 a = 1; 를 먼저 실행한다면, 캐시를 비워야 하는 상황이 발생할 수도 있다. 그리고 나서, b = 2;을 실행했을 때, b를 다시 캐시에 가져오기 위한 추가적인 작업도 말생할 수 있다.
 
하지만, b =  2;를 먼저 실행한다면? 이미 캐시에 b에 관한 데이터가 있기 때문에, 캐시를 비울 필요 없이 그대로 연산을 할 수 있다. 즉, b = 2;를 먼저 실행하고 a = 1;을 실행하는 것이 결과적으론 훨씬 효율적인 것이다.
 
Func()의 코드는 a = 1;을 먼저 실행하든 b = 2;를 먼저 실행하든 결과에는 차이가 없기 때문에 컴파일러는 최대한 효율적인 방식으로 코드의 순서를 재배치한다.
 
싱글 스레드 환경에선, 결과만 같다면 코드를 아무리 재배치하더라도 별 다른 문제는 발생하지 않을 것이다.
 
하지만, 멀티 스레드 환경에선 다르다.
 
위에서 말했듯이, (a,b)의 값을 (0, 2)로 관측하는 상황도 발생하는 것이다. 그런데 이게 왜 문제라는걸까?
 

int Value = 0;
bool A = false;

void Func1()
{
    Value = 100;
    A = true;
}

void Func2()
{
    if(A == true)
    {
        std::cout << Value;
    }
}

 
위의 코드를 보자. Func1()과 Func2()를 각각 다른 스레드를 이용하여 병렬적으로 실행하였다고 해보자.
 
일반적인 상황이라면, Value = 100;이 된 이후에, A = true가 되기 때문에, Func2()에서는 A가 false라서 아무것도 출력하지 않거나, A가 true인 상황에선 100을 출력할 수도 있을 것이다.
 
하지만, 위에서 말했던 컴파일러 재배치의 문제 때문에,  Func1()에서 A = true; 가 먼저 실행될 수도 있고, 이 경우에 Func2()에선 0을 출력하게 될 것이다.
 
우리가 A == true일 때, Value를 출력하고자 함은 일반적으로 100이라는 값을 출력하기 위함일 것이다.
그런데, 실제로는 0이 출력될 여지가 있는 것이다. 상황에 따라 치명적인 문제가 될 수도 있는 것이다.
 
이처럼, 컴파일러의 명령어 재배치를 통제하기 위한 수단이, std::memory_order 이다.
아무데서나 쓸 수 있는 건 아니고, atomic객체의 함수에 인자로 넣어서 사용할 수 있다.
 
종류는 5가지가 있다.
 
memory_order_relaxed : 명령어의 재배치를 무제한으로 허용함.
memory_order_acquire : 해당 명렁어 위로 명령어가 재배치 되지 않도록 막음.
memory_order_release : 해당 명령어 아래로 명령어가 재배치 되지 않도록 막음.
memory_order_acq_rel  :  memory_order_acquirememory_order_release 를 모두 수행.
memory_order_seq_cst : 어떠한 재배치도 발생하지 않고, 일반적으로 생각하는 논리와 동일하게 명령어가 작동.
 

std::atomic<int> Value = 0;
std::atomic<int> Value2 = 0;

std::atomic<bool> A = false;

void Func1()
{
    Value.store(100);
    A.store(true);
}

 
위에서 보았던 코드를 atomic으로 변경한 코드이다.
위에서도 말했듯이, A에 true를 저장하는 이유는 Func2에서 Value = 100이라는 값을 확인할 수 있도록 하기 위함이었다.
그렇다면, 이 코드에서 Value.store(100); 은 A.store(true); 의 아래로 재배치 되어서는 안된다.

std::atomic<int> Value = 0;
std::atomic<bool> A = false;

void Func1()
{
    Value.store(100);
    A.store(true, std::memory_order_release);
}

 
이 때, A의 두번째 인자에 std::memory_order_release 넣어줌으로서, 명령어가 아래로 재배치되는 것을 막아줄 수 있다.

void Func2()
{
    if(A.load() == true)
    {
        std::cout << Value.load();
    }
    
    Value2.store(100);
}

 
이번엔, Func2()를 보자. 이해를 돕기 위해 아래에 Value2.store(0); 이라는 코드를 추가하였다.
이 코드는 A가 true가 되는 순간 value의 값을 출력한 뒤, Value2의 값을 100으로 변경해주는 작업을 하고 있다.
 
그렇다면, 우리가 의도한 것은 100이 먼저 출력되고, 그 다음에 value2의 값이 100으로 변경되는 것일 것이다.
하지만, 명령어가 재배치 되면 Value2에 100을 먼저 삽입한 뒤, Value의 값을 출력할 수도 있다.
그러므로, Value2.store(100)을 if 문 위로 재배치 되지 않도록 막아주어야 한다.
 
이 때, std::memory_order_acquire를 사용하여 해당 메모리 위로 명령어가 재배치 되지 않도록 막아줄 수 있다.
 
상황의 특성상, std::memory_order_release는 주로 쓰기(store)과 함께 사용되고 std::memory_acquire는 주로 읽기(load)와 함께 사용된다.
 
반면, 읽기와 쓰기를 함께 하는 작업도 존재한다.
바로, fetch_add()함수이다.(fetch_sub() 등 다른 연산도 존재한다.)
 
fetch_add()는 atomic객체에 저장된 값에 원하는 값을 더해주는 함수이다.
더하기를 하기 위해선, 먼저 저장되어 있는 값을 읽고, 그 값에 숫자를 더한 뒤 결과값을 써주어야 한다.
즉, 읽기와 쓰기 연산이 모두 사용되는 것이다.
 
이 경우엔 memory_order_acq_rel 를 사용하여, 아래에 있는 명령어가 위로 배치되지 않도록 하며 동시에 위에 있는 명령어가 아래로 배치되지 않도록 해준다.
 
하지만, 이 경우에도 메모리 재배치가 아예 발생하지 않는 것은 아니다.

void Func3()
{
    int a = 0;
    int b = 0;
    
    Value.fetch_add(3, std::memory_order_acq_rel);
    
    int c = 0;
    int d = 0;
}

 
이 코드에선, a와 b를 선언하는 명령어는 fetch_add의 밑으로 내려가지 않는다.
c와 d의 선언또한 fetch_add 의 위로 올라가지 않는다.
 
하지만, int a = 0; 과 int b = 0;의 순서는 바뀔 수 있고, 아래에서도 int c = 0; 과 int d = 0;의 순서는 얼마든지 바뀔 수 있다.
 
이러한 재배치마저 모두 막아버리고 싶다면, std::memory_order_seq_cst를 사용하면 된다.
참고로 atomic객체의 멤버함수에 인자로 memory_order을 넣지 않는다면 std::memory_order_seq_cst가 디폴트로 설정된다.
 
std::memory_order_seq_cst의 경우엔 생각보다 상당히 비싼 연산이라, 오버헤드가 많이 발생한다고 한다.
하지만, 현대의 amd나 intel의 일반적인 CPU는 순차적 일관성이 거의 보장되어있기 때문에 std::memory_order_seq_cst를 사용한다고 해서 성능에 큰 문제는 없다고 한다.
 
하지만, 임베디드에서 사용되는 ARM CPU의 경우에는 치명적일 수 있기 때문에 주의해서 사용해야 한다고 한다. 

운영체제는 스케줄링을 통해, 여러 프로세스가 효율적으로 실행될 수 있도록 하고 있다.

효율적이지 못한 스케줄링으로 인해 발생할 수 있는 두 가지 문제 (콘보이 효과, 기아 현상)에 대해 알아보자.

 

만약, 스케줄링이 먼저 들어온 프로세스를 먼저 처리하는 방식이라고 해보자.

 

숫자는 작업 처리에 소요되는 시간이다.

이 때, 3개의 작업이 대기하는 시간을 구해보자.

 

첫 번째 프로세스는 0초를 대기한 뒤, 1초동안 작업을 할 것이다.

두 번째 프로세스는 1초간 대기한 뒤, 15초간 작업을 할 것이다.

세 번째 프로세스는 16초간 대기한 뒤, 3초간 작업을 할 것이다.

 

평균 대기시간은 17 / 3초 (5.66666초)이며, 대기시간 + 작업시간의 평균은 12초이다.

 

이 번엔, 세 작업의 순서만 바꿔보자.

 

첫 번째 프로세스는 0초를 대기한 뒤, 15초간 작업을 할 것이다.

두 번째 프로세스는 15초를 대기한 뒤, 3초간 작업을 할 것이다.

세 번째 프로세스는 18초를 대기한 뒤, 1초간 작업할 것이다.

 

평균 대기시간은 11초이며, 대기시간 + 작업시간의 평균은 52 / 3 초 (17.33333초) 이다.

 

평균적으로 작업이 처리되는 시간이 기존보다 훨씬 길어진 것을 알 수 있다.

이 것이 콘보이 효과이다.

 

작업시간이 긴 프로세스를 우선적으로 처리하느라, 작업시간이 짧은 프로세스가 오랜 기간을 대기하며 평균 대기시간이 높아져 비효율적으로 작업하게 되는 것을 콘보이 효과라고 한다.

 

이를 해결하기 위해선, 짧은 프로세스를 먼저 실행해야 할 것이다.

 

 

이렇게 순서를 바꾸니, 평균 대기시간은 4 / 3 초 (1.333초)이며, 대기시간 +작업시간의 평균은 8초가 되었다.

기존보다 훨씬 효율적으로 작업이 실행되는 것을 알 수 있다.

 

하지만, 이처럼 짧은 프로세스를 우선적으로 실행하기 위해선 계속적인 정렬이 필요하다.

 

 

계속적인 정렬 없이 실행하게 되면, 결국 실행시간이 짧은 프로세스가 뒤에 위치하는 순간이 존재하기 떄문에 실행시간을 기준으로 계속적인 정렬이 필요하다.

 

(실제로는 우선순위 큐를 사용하기 때문에, 정렬이라는 말은 다소 맞지 않을 수도 있다.)

 

그렇다면, 이런 상황을 보자.

2개의 프로세스를 계속 번갈아가며 반복해서 실행하고자 한다.

A프로세스는 1초면 작업을 완료하고, B프로세스는 2초면 작업을 완료한다.

 

A와 B를 동시에 실행시킨다면, A가 실행시간이 더 짧기때문에 우선적으로 실행될 것이다.

A가 끝나고, B가 실행될 차례이다. 하지만, 작업 큐에 A가 다시 삽입되는 순간 A의 실행시간이 더 짧기 때문에 A가 앞으로 오도록 정렬될 것이며 A를 다시 실행하게 될 것이다.

 

A의 작업이 마친 뒤, 다시 B를 실행하고 싶어도 똑같이 A를 실행하게 될 것이다.

 

즉, 작업 시간을 기준으로 계속적인 정렬을 하다보니 작업시간이 긴 프로세스는 영원히 실행되지 않는 상황이 벌어지는 것이다.

 

이를 기아현상 (Starvation)이라고 한다.

 

정리해보자.

콘보이 효과란, 실행시간이 긴 프로세스를 우선적으로 처리하게 되면 다른 프로세스들이 대기하는 기간이 길어져 전체적은 효율이 떨어지는 상황을 의미한다.

기아 현상이란, 실행시간이 짧은 프로세스를 우선적으로 처리하려다 실행시간이 긴 프로세스를 영원히 처리하지 못하는 상황을 의미한다.

기아 현상은 실행 시간을 기준으로 한 스케줄링에서만 문제가 되는 것이 아니라, 어떤 기준이든 우선 순위가 부여된 상황이라면 발생할 수 있는 현상이다. 

 

이를 해결하기 위한 여러가지 알고리즘 중 라운드 로빈이라는 알고리즘이 있다.

 

라운드 로빈 알고리즘이란, 어떤 기준이든 우선순위를 두지 않고 정해진 실행 시간에 맞게 모든 프로세스를 균등하게 실행하는 알고리즘이라고 한다.

 

예를 들어, 작업 시간을 30ms로 설정하였다면 현재 실행중인 프로세스의 작업이 30ms를 초과하는 순간 작업을 강제로 멈추고 대기 큐의 끝으로 밀어넣는다고 한다.

프로세스와 스레드에 관해 공부를 하다보면, 필연적으로 접하는 단어가 동시성과 병렬성이다.

두 단어에 대해 알아보자.

 

스레드가 1개든 100개든, 코어가 하나라면 한 번에 하나의 작업밖에 처리하지 못한다.

 

만약, 스레드 1에선 음악을 실행하려 하고, 스레드 2에선 게임을 실행하려 하고, 스레드 3에선 동영상을 실행하려 한다고 했을 때, 일반적으로 생각할 수 있는 상황은 음악 실행을 먼저 하고 음악이 끝난 뒤에 게임을 실행하며, 게임이 끝난 뒤에 동영상을 실행하는 것이다.

 

하지만 현실에서 컴퓨터는 다르게 작동한다. 싱글코어 CPU를 사용한다고 하더라도, 우리는 게임을 하면서 검색을 할 수 있고, 동시에 음악도 들을 수 있다. 어떻게 그럴 수 있을까?

 

CPU는 여러 개의 작업을 동시에 실행하기 위해, 매우 고속으로 번갈아가며 작업을 실행한다.

0.001초간 음악을 실행하고, 0.001초간 게임을 실행하고, 0.001초간 동영상을 실행하고.. 이 과정을 초고속으로 번갈아서 하다 보면 우리 눈에는 음악과 게임과 동영상이 동시에 실행되고 있는 것처럼 보이는 것이다.

 

즉, 실제로는 세가지 작업이 동시에 처리되고 있는 것이 아니라는 것이다.

이 것을 동시성이라고 한다. 

 

실제로는 동시에 처리되고 있지 않으나, 동시에 처리되고 있는 것처럼 보이는 상황을 동시성이라고 한다.

 

이번엔 위 그림의 상황을 모자.

코어당 1개의 작업을 처리할 수 있을 때, 코어가 3개라면?

동시에 3개의 작업을 처리할 수 있다.

 

즉, 음악 실행, 게임 실행, 동영상 실행을 물리적으로 동시에 처리할 수 있다는 것이다.

이 것이 병렬성이다.

 

실제로 동시에 처리되고 있는 상황을 병렬성이라고 한다.

표로 정리하면 아래와 같다.

동시성 병렬성
동시에 실행되고 있지 않지만, 그렇게 보이는 것 물리적으로 동시에 실행되고 있는 것
싱글 코어에서 여러 작업을 실행하는 방식 멀티 코어에서 여러 작업을 실행하는 방식

 

물론, 멀티코어 CPU라고 해서 동시성을 사용하지 않는 것은 아니다.

 

코어가 4개일 때, 8개의 작업이 주어진다면 하나의 코어는 2개 이상의 작업을 해야하는 경우가 발생한다.

이 때, 하나의 코어는 여러 작업을 처리하기 위해 동시성을 사용하게 된다.

 

 

 

강산이가 입력한 키보드 입력 정보를 토대로 비밀번호를 알아맞추는 문제이다.

 

ABC를 쓰고, 마지막 C를 지우고 D를 입력하면 ABD가 된다.

(ABC-D) -> ABD

 

AB를 쓰고, 왼쪽 화살표를 1회 입력한 뒤 C를 입력하면 ACB가 된다.

(AB<C) -> ACB

 

이처럼, 강산이가 입력한 화살표와 백스페이스, 알파벳 정보를 토대로 최종적으로 입력되는 비밀번호를 출력하면 된다.

 

문제 풀이

이렇게 문자열의 중간에 문자를 삽입하고 삭제하는 등의 문제의 대부분은 스택을 활용하는 문제라고 생각하면 된다.

 

문자열의 경우 중간에 문자를 삽입하거나 삭제하는 연산의 시간복잡도는 O(N)이다. 

이 문제에선 문자열의 최대 길이가 1000000으로 주어졌기 때문에, 하나 삭제하거나 삽입할 때마다 최대 1000000번의 연산을 실행하는 것이다. 그러므로 문자열 자체를 이용해서는 이 문제를 해결할 수 없다.

 

Stack을 활용해서 문제를 푸는 방법을 보자.

 

 

이렇게, 현재 텍스트 커서를 기준으로 양쪽에 2개의 스택을 두고 문제를 해결할 것이다.

 

입력은 <<BP<A>>Cd- 가 들어온다고 가정할 것이다.

 

먼저,<<를 입력받으면 텍스트 커서는 왼쪽으로 한 칸씩 움직여야 할 것이다. 하지만, 현재 아무런 문자도 입력되지 않은 상황이라 텍스트 커서를 아무리 왼쪽으로 옮겨도 텍스트 커서는 움직이지 않는다.

 

그러므로, <<는 아무런 변화를 주지 않는다.

 

다음, B를 입력받았다고 해보자. 우리가 키보드에 A를 쳐보면, 텍스트 커서는 A의 바로 뒤에 붙어있는 것을 볼 수 있다.

그러므로, 입력반은 문자도 이와 동일하게 텍스트 커서의 앞에 입력해주자.

 

LeftStack에 문자를 삽입하는 것을 입력하는 것이라고 가정하겠다. LeftStack에 B를 삽입해주자.

스택은 이렇게 변화하게 된다.

다음 입력 P에 대해서도 동일하게 실행해보자.

스택은 위 그림과 같아진다.

 

다음 입력은 < 이다. 처음 <를 입력받았을 때엔 문자가 아무것도 입력되어 있지 않았기 때문에 텍스트 커서가 움직이지 않았지만, 이번엔 BP가 입력되어 있기 때문에, 텍스트 커서를 왼쪽으로 한 칸 움직이면 텍스트 커서는 B와 P사이에 위치하게 된다. 그러므로 스택은 아래와 같이 변하게 된다.

 

LeftStack은 텍스트 커서를 기준으로 왼쪽에 있는 문자들이며, RightStack은 텍스트 커서 기준으로 오른쪽에 있는 문자들인 셈이다. 그러므로, LeftStack의 가장 위의 원소를 빼서 RightStack으로 넣어주는 것을 텍스트 커서를 왼쪽으로 한 칸 움직인 것이라고 할 수 있는 것이다.

 

다음 입력 A에 대해서도 위에서 보았던 과정을 그대로 거쳐주자.

입력받은 문자는 텍스트 커서를 기준으로 위치하게 되므로, A는 위처럼 LeftStack에 삽입된다

 

다음 입력은 >이다. 텍스트 커서를 우측으로 한 칸 이동하는 것이다.

이는 <와 반대로 하면 된다. RightStack의 top을 LeftStack으로 넣어주자.

위의 그림과 같은 상태에서 다시 >를 입력받게 된다면, 텍스트 커서는 이미 문자의 마지막에 위치하기 때문에 아무런 변화가 없다. 그대로, 다음 입력을 받아주면 된다.

 

다음 입력은 C이다. C를 입력받으면, LeftStack에는 C가 추가될 것이다. 다음 d에 대해서도 똑같이 LeftStack에 d가 추가될 것이다.

스택은 위 그림과 같아질 것이다.

 

마지막으로, -입력이 들어온다면 텍스트 커서 바로 앞의 문자를 지워주면 된다.

그러므로, LeftStack의 top을 제거해주면 된다.

최종적으로 스택은 위와 같아진다.

 

그대로, LeftStack과 RightStack에 있는 문자열을 순서대로 string에 옮겨서 출력해주면 된다.

 

여기서 유의할 점은, 우리가 출력해야 하는 답과 LeftStack에서 꺼내는 순서가 반대라는 것이다.

LeftStack에서 차례대로 원소를 꺼내면, CPAB가 된다. 하지만, 우리가 원하는 답은 BAPC이다. 이를 고려하여 순서를 바꿔주어야 한다.

 

반면, RightStack은 위 경우에서는 남아있는 문자가 없기 때문에 확인할 수 없지만 RightStack은 꺼내는 순서가 문자열의 순서와 일치하기 때문에 RightStack의 원소는 뒤집지 않고 그대로 저장하여주면 된다.

 

풀이 코드

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

std::vector<std::string> Answers(NumCase);

 

테스트 케이스의 수를 입력받고, 답을 저장할 배열을 선언해주자.

 

std::string Input;
std::cin >> Input;

std::stack<char> LeftStack;
std::stack<char> RightStack;

 

이후, 각 케이스에 대해 문자열을 입력받고 두개의 스택을 선언하여 주었다.

 

for (int i = 0; i < Input.size(); i++)
{
    if (Input[i] == '<')
    {
        if (LeftStack.size() > 0)
        {
            RightStack.push(LeftStack.top());
            LeftStack.pop();
        }
    }
    else if (Input[i] == '>')
    {
        if (RightStack.size() > 0)
        {
            LeftStack.push(RightStack.top());
            RightStack.pop();
        }
    }
    else if (Input[i] == '-')
    {
        if (LeftStack.size() > 0)
        {
            LeftStack.pop();
        }
    }
    else
    {
        LeftStack.push(Input[i]);
    }
}

 

다음은 위에서 말했던 과정을 그대로 코드로 옮긴 것이다.

 

<를 입력받으면 LeftStack의 top을 RightStack으로 옮겨준다. 다만, LeftStack의 size가 0이라면 아무런 일도 발생하지 않는다.

 

>도 반대로 작동할 뿐 똑같이 구현하였다.

 

-를 입력받으면, LeftStack의 top을 지워준다.

 

알파벳을 입력받으면 그대로 LeftStack에 삽입하여 준다.

 

int LeftSize = LeftStack.size();
int RightSize = RightStack.size();

std::string Answer(LeftSize + RightSize, '\0');
for (int i = 0; i < LeftSize; i++)
{
    Answer[LeftSize - 1 - i] = LeftStack.top();
    LeftStack.pop();
}

for (int i = LeftSize; i < Answer.size(); i++)
{
    Answer[i] = RightStack.top();
    RightStack.pop();
}

Answers[Case] = Answer;

 

모든 과정이 끝나고, Stack에 있는 문자들를 답으로 옮겨주어야 한다.

위에서 말했듯이, LeftStack은 순서가 반대이기 때문에 Answer의 뒤쪽부터 데이터를 삽입해주고 있다.

 

그리고, LeftStack의 문자를 모두 저장한 뒤, RightStack의 문자를 그 뒤에 추가로 저장해주고 있다.

이 때, RightStack의 순서는 반대가 아니기 때문에 그대로 저장해주면 된다.

 

이렇게 구한 답을 마지막에 출력해주면 문제는 끝이다.

 

코드 전문

더보기
#include <iostream>
#include <vector>
#include <stack>
#include <iterator>

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

int main()
{
    Init();

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

    std::vector<std::string> Answers(NumCase);

    for(int Case = 0; Case < NumCase; Case++)
    {
        std::string Input;
        std::cin >> Input;

        std::stack<char> LeftStack;
        std::stack<char> RightStack;

        for (int i = 0; i < Input.size(); i++)
        {
            if (Input[i] == '<')
            {
                if (LeftStack.size() > 0)
                {
                    RightStack.push(LeftStack.top());
                    LeftStack.pop();
                }
            }
            else if (Input[i] == '>')
            {
                if (RightStack.size() > 0)
                {
                    LeftStack.push(RightStack.top());
                    RightStack.pop();
                }
            }
            else if (Input[i] == '-')
            {
                if (LeftStack.size() > 0)
                {
                    LeftStack.pop();
                }
            }
            else
            {
                LeftStack.push(Input[i]);
            }
        }

        int LeftSize = LeftStack.size();
        int RightSize = RightStack.size();

        std::string Answer(LeftSize + RightSize, '\0');
        for (int i = 0; i < LeftSize; i++)
        {
            Answer[LeftSize - 1 - i] = LeftStack.top();
            LeftStack.pop();
        }

        for (int i = LeftSize; i < Answer.size(); i++)
        {
            Answer[i] = RightStack.top();
            RightStack.pop();
        }

        Answers[Case] = Answer;
    }

    std::copy(Answers.begin(), Answers.end(), std::ostream_iterator<std::string>(std::cout, "\n"));

    return 0;
}

환경매핑은 큐브매핑을 활용해서 물체에 주변 사물이 반사되어 비치는 것을 구현하는 기술이다.

 

먼저, 반사를 테스트 하기 위한 구체 렌더러 하나를 추가해주었다.

 

우측에 검정색 동그라미가 하나 보인다. 아직 텍스쳐를 입히지 않아서 새까맣게 보인다.

일단 환경매핑 쉐이더를 작성해보자.

버텍스 쉐이더는 기존과 완전히 동일하다.

 

#include "LightHeader.hlsli"

cbuffer WorldLightBuffer : register(b0)
{
    float3 EyeWorld;
    float Padding;
    
    Light Lights[LIGHT_NUM];
};

struct PixelShaderInput
{
    float4 pos : SV_POSITION;
    float2 TexCoord : TEXCOORD;
    
    float3 WorldPos : POSITION1;
    float3 WorldNormal : NORMAL;
};

TextureCube CubeMapTexture : register(t0);
SamplerState Sampler : register(s0);

float4 main(PixelShaderInput _Input) : SV_TARGET
{
    float3 EyeDir = _Input.WorldPos - EyeWorld;
    EyeDir = normalize(EyeDir);
    
    _Input.WorldNormal = normalize(_Input.WorldNormal);
    
    float3 RefEyeDir = reflect(EyeDir, _Input.WorldNormal);
    
    float4 Color = CubeMapTexture.Sample(Sampler, RefEyeDir);
    return Color;
}

 

이렇게 픽셀쉐이더를 작성해주었다.

이존의 배경을 만들기 위한 큐브매핑과 다르게 Light 정보를 상수버퍼로 연결해주었다.

일단은 빛을 적용하지 않고, EyeWorld만 가져와서 사용할 것이다.

 

물체가 반사되어 눈으로 들어오는 과정을 역산하여, 눈으로부터 나가는 광선이 반사되었을 때 큐브맵의 어디에 충돌하는가를 계산하여 색을 정하는 것이다.

 

공식은 매우매우 간단하다. EyeDir(눈이 물체를 바라보는 방향 벡터)를 WorldNormal에 대해 반사시킨 벡터로 큐브맵을 샘플링하면 된다.

 

텍스쳐도 적용하고, 해당 쉐이더를 연결해주면 아래와 같은 결과가 나온다.

 

 

구체에 배경이 잘 반사되어 비치는 것을 확인할 수 있다.

 

여러개의 노드와 간선의 정보가 주어진다.

 

이 때, 특정 노드에서 다른 노드로 이동 거리가 수색범위 m보다 더 크다면, 해당 노드의 아이템을 주울 수 없다.

여러 개의 노드 중, 어떤 노드에서 출발하여 수색을 해야 습득한 아이템의 총 개수가 가장 큰 지를 구하면 된다.

 

출력하는 것은 출발한 노드가 아니라, 획득한 아이템의 총 합이다.

 

문제 풀이

전형적인 최단거리 문제이다. 여러 알고리즘을 다 사용해도 되겠지만, 본인은 플로이드 워셜 알고리즘을 사용하였다.

입력 개수의 최대값이 지금처럼 크지 않을 땐, 플로이드 워셜을 사용하는 것이 좋다. 매우 구현이 간단하기 때문이다.

 

먼저, 주어진 간선의 정보를 이용하여 모든 노드와 노드사이의 최단 거리를 구한다.

 

이후, 최단 거리를 기반으로 특정 노드에서 시작했을 때, 아이템을 얼마나 습득할 수 있는지를 구한다.

이 과정을 모든 노드에 대해 검사하여, 아이템 습득량의 최대값을 구하면 된다.

 

풀이 코드

int NumField = 0;
int SeekRange = 0;
int NumRoad = 0;

std::cin >> NumField >> SeekRange >> NumRoad;

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

std::vector<std::vector<std::pair<int, int>>> Links(NumField);
for (int i = 0; i < NumRoad; i++)
{
    int Start = 0;
    int End = 0;
    int Weight = 0;

    std::cin >> Start >> End >> Weight;

    Start--;
    End--;

    Links[Start].push_back({ End, Weight });
    Links[End].push_back({ Start, Weight });
}

 

입력을 받는 코드이다.

먼저, 노드의 수와 수색 범위, 간선의 수를 입력받은 뒤, 각 노드에 아이템이 몇 개 있는지를 입력받는다.

이후, 간선의 정보를 입력받아서 Links라는 벡터에 저장하도록 하였다.

Start와 End에 --를 해주는 이유는 입력이 1부터 시작하기 때문이다.

std::vector<std::vector<int>> Cost(NumField, std::vector<int>(NumField, INT_MAX / 2 - 1));

for (int i = 0; i < NumField; i++)
{
    for (int j = 0; j < Links[i].size(); j++)
    {
        Cost[i][Links[i][j].first] = Links[i][j].second;
    }
}

이후, 노드간의 최단거리를 저장할 Cost벡터를 선언하였고, 입력받은 간선의 정보를 기반으로 Cost의 값을 초기화해주었다. 직접적으로 연결되어 있는 간선에 대해서만 Cost를 갱신한 것이다.

for (int i = 0; i < NumField; i++)
{
    for (int j = 0; j < NumField; j++)
    {
        for (int k = 0; k < NumField; k++)
        {
            Cost[j][k] = std::min(Cost[j][k], Cost[j][i] + Cost[i][k]);
        }
    }
}

 

이후, 플로이드 워셜 알고리즘을 사용하여 다른 노드를 거쳐서 도착하는 경우에 대해서도 Cost를 모두 갱신해주었다.

for (int k = 0; k < NumField; k++)
{
    Cost[k][k] = 0;
}

 

플로이드 워셜 알고리즘을 사용하고 나면 시작점과 도착점이 같은 경우에도 Cost가 갱신되어 있다.

하지만, 실제로는 시작점과 도착점이 같은 경우의 Cost는 존재하지 않기 때문에 이 값들을 0으로 바꿔주었다.

int MaxItemSum = 0;

for (int i = 0; i < NumField; i++)
{
    int CurItemSum = 0;

    for (int j = 0; j < NumField; j++)
    {
        if (Cost[i][j] <= SeekRange)
        {
            CurItemSum += Items[j];
        }
    }

    if (MaxItemSum < CurItemSum)
    {
        MaxItemSum = CurItemSum;
    }
}

 

위의 코드는 답을 구하는 코드이다.

i번 노드에서 시작했다고 했을 때, 수색 가능한 모든 노드에 존재하는 아이템의 개수를 CurItemSum에 누적하였다.

이후, CurItemSum이 MaxItemSum보다 더 크다면, MaxItemSum을 갱신하여 최대값을 저장해주었다.

std::cout << MaxItemSum;

return 0;

 

마지막으로 출력해주면 끝이다.

 

코드 전문

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

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

int main()
{
    Init();

    int NumField = 0;
    int SeekRange = 0;
    int NumRoad = 0;

    std::cin >> NumField >> SeekRange >> NumRoad;
    
    std::vector<int> Items(NumField);
    for (int i = 0; i < NumField; i++)
    {
        std::cin >> Items[i];
    }

    std::vector<std::vector<std::pair<int, int>>> Links(NumField);
    for (int i = 0; i < NumRoad; i++)
    {
        int Start = 0;
        int End = 0;
        int Weight = 0;

        std::cin >> Start >> End >> Weight;
        
        Start--;
        End--;
        
        Links[Start].push_back({ End, Weight });
        Links[End].push_back({ Start, Weight });
    }

    std::vector<std::vector<int>> Cost(NumField, std::vector<int>(NumField, INT_MAX / 2 - 1));
    
    for (int i = 0; i < NumField; i++)
    {
        for (int j = 0; j < Links[i].size(); j++)
        {
            Cost[i][Links[i][j].first] = Links[i][j].second;
        }
    }

    for (int i = 0; i < NumField; i++)
    {
        for (int j = 0; j < NumField; j++)
        {
            for (int k = 0; k < NumField; k++)
            {
                Cost[j][k] = std::min(Cost[j][k], Cost[j][i] + Cost[i][k]);
            }
        }
    }

    for (int k = 0; k < NumField; k++)
    {
        Cost[k][k] = 0;
    }

    int MaxItemSum = 0;

    for (int i = 0; i < NumField; i++)
    {
        int CurItemSum = 0;

        for (int j = 0; j < NumField; j++)
        {
            if (Cost[i][j] <= SeekRange)
            {
                CurItemSum += Items[j];
            }
        }

        if (MaxItemSum < CurItemSum)
        {
            MaxItemSum = CurItemSum;
        }
    }

    std::cout << MaxItemSum;

    return 0;
}

+ Recent posts