#include <thread>
#include <atomic>
#include <mutex>

class SpinLock
{
public:
	void lock()
	{
		// CAS(Comapre-And-Swap)
		bool expected = false;
		bool desired = true;

		while (false == bLocked.compare_exchange_strong(expected, desired))
		{
			expected = false;
		}
	}

	void unlock()
	{
		bLocked.store(false);
	}

private:
	atomic<bool> bLocked = false;
};

int32 sum = 0;
SpinLock spinlock;
mutex m;

void Add()
{
	for (int i = 0; i < 100000; i++)
	{
		lock_guard<SpinLock> guard(spinlock);
		sum++;
	}
}

void Sub()
{
	for (int i = 0; i < 100000; i++)
	{
		lock_guard<SpinLock> guard(spinlock);
		sum--;
	}
}

int main()
{
	thread t1(Add);
	thread t2(Sub);

	t1.join();
	t2.join();

	cout << sum << endl;
}

 

스핀락을 활용하기 위해서는 lock() 실행 시, 자원을 획득하는 스레드를 하나만 넘겨주고 다른 스레드들은 접근할 수 없도록 while()에 대기시킬 수 있어야 합니다. 하지만 아래와 같은 코드는 atomic하게 동작하지 않기 때문에, 이를 처리할 방법이 필요합니다.

while(bLocked) 
{
}

// 데이터영역 boolean
bLocked = true;

 

간단한 코드이지만, 스레드는 언제나 병렬 작업을 실시하기 때문에 bLcoked이 false인 경우 동시에 while문을 빠져나가면서 [bLocked = true] 코드로 접근할 수 있습니다. 이를 방지하기 위해 atomic 변수 지원 함수 중 CAS(Compare-And-Swap)을 지원해주는 compare_exchange_strong() 함수를 사용합니다.

 

함수 내부는 다음과 같이 작동한다고 볼 수 있습니다.

bool expected = false;
bool desired = true;

if (bLocked == expected)
{
    bLocked = desired;
    return true;
}
else
{
    expected = bLocked;   // CAS 실패 시 expected 값은 실제 메모리 값으로 갱신됨
    return false;
}

 

 

메모리 값(bLocked)이 expected와 같은 지 원자적으로 비교하고, 같으면 desired로 교체하면서 true를 반환, 다르면 교체 실패로 expected를 현재 메모리값으로 자동 업데이트 하는 구조입니다. 해당 연산이 원자적으로 이뤄지기 때문에, while문을 빠져나와 true로 값이 입력되는 상황이 완전히 동시에 처리되도록 할 수 있게 됩니다.

 

하지만 아시다시피, while문을 결국 실행한다는 것은 불필요한 스레드에게 작업을 할당하는 것과 다름 없습니다(CPU 작업이 계속 진행됨). 이런 경우 sleep을 활용하면 효과적일 수 있습니다.

class SpinLock
{
public:
	void lock()
	{
		// CAS(Comapre-And-Swap)
		bool expected = false;
		bool desired = true;

		while (false == bLocked.compare_exchange_strong(expected, desired))
		{
			expected = false;
			this_thread::sleep_for(std::chrono::milliseconds(100));
			// this_thread::sleep_for(100ms);
			// this_thread::yield(); == this_thread::sleep_for(0ms);
		}
	}

	void unlock()
	{
		bLocked.store(false);
	}

private:
	atomic<bool> bLocked = false;
};

'서버' 카테고리의 다른 글

[Server] 데드락(DeadLock)  (0) 2025.03.17
[Server] Lock(mutex)  (0) 2025.03.17
[Server] atomic  (0) 2025.03.17
[Server] 스레드  (0) 2025.03.17

[신장 트리(Spanning Tree)]

 

크루스칼 알고리즘에 대해 알아보기 전에 신장 트리를 먼저 알아보도록 하겠습니다. 신장 트리란, 그래프의 모든 정점을 잇지만 사이클이 없는 부분 그래프를 뜻합니다. 

 

위와 같은 그래프가 존재할 경우, 간선을 초록색, 보라색으로 나누면 신장 트리가 만들어집니다. 그래프 내에 신장 트리는 여러 개 존재할 수 있습니다.

 

 

[최소 신장 트리(Minimum Spanning Tree)]

 

간선에는 가중치(Weight)가 존재합니다. 최소 신장 트리(MST)는 신장 트리들 중, 간선들의 가중치 합이 최소가 되는 신장 트리를 뜻합니다.

 

보라색 간선으로 이루어지는 신장 트리와 초록색 간선으로 이루어지는 신장 트리들의 가중치 합은 각각 10(2+2+3+3), 11(1+1+4+5)입니다. 그렇다면 최소 신장 트리는 가중치의 합이 10인 보라색 간선 신장 트리가 됩니다.

 

 

[크루스칼 알고리즘(Kruskal Algorithm)]

 

그렇다면 그래프에서 최소 신장 트리는 어떻게 찾을까요? 여기서 크루스칼 알고리즘을 사용할 수 있습니다. 크루스칼 알고리즘은 간선들을 이용해서 모든 노드를 연결하되, 전체 비용이 최소가 되도록 선택하는 방법을 제공해줍니다. 알고리즘 절차는 다음과 같습니다.

1. 모든 간선을 가중치(비용) 기준으로 오름차순 정렬
2. 정렬된 간선을 하나씩 확인
	2-1. 두 정점이 아직 연결되지 않았다면 → 선택
	2-2. 이미 연결되어 있다면(사이클 발생) → 무시
3. 모든 정점이 연결되면 종료

 

여기서 두 정점이 연결되어 있는지 확인하는 방법으로 Union-Find(서로소 집합) 자료구조를 활용합니다.

Find(x) : x가 속한 그룹(대표 노드) 검색
Union(x, y) : 두 그룹 합치기

 

 

위에서 살펴봤던 그래프를 기준으로 크루스칼이 어떻게 진행되는지 확인해보겠습니다.

// 간선 목록(오름차순 정렬)
1: (1–3), (3–5)
2: (1–5), (4–5)
3: (2–3), (2–4)
4: (1–2)
5: (3–4)

// 초기 집합 : {1}, {2}, {3}, {4}, {5}
parent[1] = 1
parent[2] = 2
parent[3] = 3
parent[4] = 4
parent[5] = 5

 

간선을 하나씩 살펴보면서 Union-Find로 사이클을 체크합니다.

 

> 1단계

: [1-3] 간선은 가중치가 1입니다. find(1)과 find(3)을 실행하면 서로 다른 집합으로 판단되기 때문에, Union(1, 3)을 통해 3의 대표를 1로 설정합니다(노드를 오름차순으로 정렬하기).

// 집합 : {1, 3} {2} {4} {5}
parent[1] = 1
parent[2] = 2
parent[3] = 1 // << 변경
parent[4] = 4
parent[5] = 5

 

> 2단계

: [3-5] 간선은 가중치가 1입니다. find(3)은 parent[3] = 1로, 1이 나옵니다. find(5)는 5가 나오기 때문에 서로 다른 집합으로 판단하여, Union(3, 5)를 통해 5의 대표를 1로 설정해줍니다.

// 집합 : {1, 3, 5} {2} {4}
parent[1] = 1
parent[2] = 2
parent[3] = 1
parent[4] = 4
parent[5] = 1 // << 변경

 

> 3단계

: [1-5] 간선은 가중치가 2입니다. find(1)은 1이고, find(5)는 1이 나타나기 때문에, 이미 같은 집합 내에 있어 추가 시 사이클이 발생하게 됩니다. 해당 노드 간의 Union은 실시해주지 않습니다.

 

> 4단계

: [4-5] 간선은 가중치가 2입니다. find시 4와 5는 각각 4, 1이 나오기 때문에 다른 집합으로 판단되어 Union(4, 5)를 실시합니다. 이때 집합이 변경됩니다.

// 집합 : {1, 3, 4, 5} {2}
parent[1] = 1
parent[2] = 2
parent[3] = 1
parent[4] = 1 // << 변경
parent[5] = 1

 

> 5단계

: [2-3] 간선은 가중치가 3입니다. find시 2와 3은 각각 2, 1이 나오기 때문에 다른 집합으로 판단되어 Union(2, 3)을 실시합니다.

// 집합 : {1, 2, 3, 4, 5}
parent[1] = 1
parent[2] = 1
parent[3] = 1
parent[4] = 1
parent[5] = 1

 

여기까지 진행할 경우, 모든 노드가 하나의 집합으로 이루어지게 되며 가중치를 오름차순으로 하여 알고리즘을 수행했기 때문에 비용이 7로 작업이 마무리됩니다.

 

 

[코드로 알아보기]

 

크루스칼 알고리즘에 대한 문제도 다익스트라 문제와 마찬가지로 백준에 존재합니다.

https://www.acmicpc.net/problem/1197

 

아래는 문제에 대한 풀이 소스 코드입니다.

// 백준 [골드4] [1197] [최소 스패닝 트리]
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>

using namespace std;

struct Edge
{
	int From = 0;
	int To = 0;
	int Weight = 0;
};

vector<Edge> Graph;
vector<int> Parent;

bool Compare(const Edge& _Left, const Edge& _Right)
{
	if (_Left.Weight != _Right.Weight) // 가중치가 같지 않은 경우, 가중치 오름차순 정렬
	{
		return _Left.Weight < _Right.Weight;
	}

	return _Left.To < _Right.To; // 가중치가 같은 경우에는 노드 번호 오름차순 정렬
}

int Find_Parent(int _Value) // Parent 반환하기
{
	if (Parent[_Value] != _Value)
	{
		Parent[_Value] = Find_Parent(Parent[_Value]);
	}

	return Parent[_Value];
}

void Union_Parent(int _From, int _To) // 합치기
{
	_From = Find_Parent(_From);
	_To = Find_Parent(_To);

	if (_From > _To)
	{
		Parent[_From] = _To;
	}
	else
	{
		Parent[_To] = _From;
	}
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int N = 0, M = 0; // N: 정점 수(1~N), M: 간선 수
    cin >> N >> M;

    Graph.resize(M);
    for (int i = 0; i < M; i++)
    {
        int u, v, w;
        cin >> u >> v >> w;
        Graph[i] = { u, v, w };
    }

    // 가중치 오름차순 정렬
    sort(Graph.begin(), Graph.end(), Compare);

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

    long long Result = 0;
    int Picked = 0;
    for (const auto& Edge : Graph)
    {
    	int From = Edge.From;
        int To = Edge.To;
        int Weight = Edge.Weight;
        int From_Parent = Parent[From];
        int To_Parent = Parent[To];
    
		if (Find_Parent(From_Parent) != Find_Parent(To_Parent))
		{
			Union_Parent(From_Parent, To_Parent);
			Result += Weight;
            ++Picked;
            if (Picked == N - 1) // MST 완성되면 조기 종료
            {
                break;
            }
		}
    }

    cout << Result;
    
    return 0;
}

[다익스트라 알고리즘이란]

 

그래프 탐색 알고리즘 중, 최소 비용을 구하는 알고리즘으로 다익스트라, 벨만포드, 플로이드 워셜 등이 있습니다. 해당 포스팅에서는 다익스트라에 대해 알아보도록 하겠습니다.

 

다익스트라 알고리즘은 하나의 시작 노드에서 다른 노드들까지 도달하는데 필요한 최소 비용을 구하는 알고리즘읍니다. 알고리즘을 수행하기 위해서는 노드 정보와, 노드간 연결 간선, 그리고 간선마다의 가중치가 필요합니다.

 

결론적으로 시작 노드로 부터 도착 노드까지 가는데 필요한 최소 가중치를 구하는 알고리즘인 것입니다. 아래의 그래프를 통해 간단하게 예시를 알아보도록 하겠습니다.

 

그래프에는 3개의 노드(1, 2, 3번 노드)와 3개의 간선(1-2, 1-3, 2-3)이 존재합니다. 이제 1번 노드를 시작노드로 하여, 다른 노드까지의 최소 비용을 눈대중으로 살펴봅시다.

 

> 1번 노드에서 2번 노드로

: 1번에서 2번으로 가는 방법은 바로 가거나, 3번 노드를 거쳐서 가는 방법이 있습니다. 하지만 3번 노드를 거쳐서 가는 경우에는 1-3의 가중치 4, 2-3의 가중치 2로 총 6의 비용이 발생합니다. 이럴 때는 그냥 바로 가면 됩니다(비용 1 발생).

 

> 1번 노드에서 3번 노드로

: 3번으로 가는 방법도 바로 가거나, 2번 노드를 거쳐가면 됩니다. 여기서는 바로 갈 때 4의 비용이 필요하지만, 2번을 거쳐가는 경우에는 1-2의 가중치 1, 2-3의 가중치 2로 총 3의 비용만 있으면 되기 때문에, 4의 비용보다 적게 갈 수 있습니다.

 

 

[다익스트라 알고리즘 알아보기]

 

위에서 눈대중으로 살펴본 방식을 활용하여 알고리즘을 수행하면 됩니다.

1. Dist 배열을 Weight[시작점 노드]로 초기화
2. 시작점 방문 처리
3. Dist 배열에서 최소 비용 노드를 찾은 뒤 방문 처리(이미 방문한 노드 제외)
4. 최소 비용 노드를 거쳐갈지 말지 고려하여 Dist 갱신(이미 방문한 노드 제외)
5. 모든 노드를 방문할 때까지 3~4번 반복

 

이제 아래의 그래프에 대해 단계별로 다익스트라 알고리즘을 수행해봅시다.

weight =
[ [ 0, 5, 3, ∞, 4 ],
  [ 5, 0, 1, 3, ∞ ],
  [ 3, 1, 0, ∞, ∞ ],
  [ ∞, 3, ∞, 0, 2 ],
  [ 4, ∞, ∞, 2, 0 ] ]

visit = [0, 0, 0, 0, 0]
dist  = [0, 5, 3, ∞, 4]   // 1행 그대로

 

> 1단계 - 1번 노드 방문

: 변화가 없고, 방문 체크만 진행합니다.

visit = [1, 0, 0, 0, 0]
dist  = [0, 5, 3, ∞, 4]

 

> 2단계 - 3번 노드 방문

: 3번 노드에 인접한 노드는 1번, 2번 노드입니다. 여기서 2번 노드로 가는 경우에는 기존의 5의 비용으로 가는 것보다, 현재 방문중인 노드를 거쳐서 가는게 비용이 더 저렴합니다. 이를 기록해줍니다.

visit = [1, 0, 1, 0, 0]
dist  = [0, 4, 3, ∞, 4]
// 2: min(5, 3+1)=4 → 갱신

 

> 3단계 - 2번 노드 방문

: 3번 노드에 인접한 노드는 1번, 3번, 4번 노드입니다. 이중 4번 노드는 1번 노드와 연결되어 있지 않아 INF로 표시되어 있지만, 3번 노드 후 2번 노드를 거쳐가면 4+3의 비용으로 4번 노드에 도달할 수 있습니다. 이를 기록해줍니다.

visit = [1, 1, 1, 0, 0]
dist  = [0, 4, 3, 7, 4]
// 4: min(∞, 4+3)=7 → 갱신

 

> 4단계 - 5번 노드 방문

: 5번 노드는 1번, 4번 노드와 인접해있습니다. 여기서 4번 노드로 가는 비용이 기존에 7로 기록되어 있지만, 5번 노드를 거쳐서 가는 경우 6으로 더 저렴하기 때문에 이를 갱신해줍니다.

visit = [1, 1, 1, 0, 1]
dist  = [0, 4, 3, 6, 4]
// 4: min(7, 4+2)=6 → 갱신

 

> 5단계 - 4번 노드 방문

: 변화가 없고, 방문 체크만 진행합니다. 여기서 모든 노드를 체크했기 때문에 다익스트라 알고리즘은 종료됩니다.

visit = [1, 1, 1, 1, 1]
dist  = [0, 4, 3, 6, 4]

 

 

[백준 문제로 알아보기(코드)]

 

백준 문제 중 다익스트라를 위한 문제가 있습니다. [1753번 최단경로] 입니다.

https://www.acmicpc.net/problem/1753

 

노드 갯수와 간선 정보, 시작노드를 알려준 다음 나머지 노드로 갈 때 필요한 최소 비용을 구하는 문제입니다. 여기에 다익스트라를 적용하여 문제를 풀 수 있습니다. 아래는 코드입니다.

// 백준 [골드 4] [1753] [최단 경로]

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

#define INF INT32_MAX

using namespace std;

// 방향그래프가 주어지면 주어진 시작점에서 다른 모든 정점으로의 최단 경로를 구하는 프로그램을 작성

void Dijkstra(vector<vector<pair<int, int>>>& _Graph, vector<int>& _Dist, int _Start)
{
    // 우선순위 큐는 현재까지 발견된 가장 짧은 거리(가중치)가 가장 작은 노드부터 확인하기 위해서 사용
    priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> PQ;

    _Dist[_Start] = 0; // 시작 노드의 최소 비용은 0(자기 자신은 비용이 필요없음)
    PQ.push({ 0, _Start }); // 시작 노드부터 비용을 함께 넣고 탐색 시작

    while (!PQ.empty())
    {
        int CurWeight = PQ.top().first; // 현재 방문중인 노드까지 오는데 필요한 비용
        int CurNode = PQ.top().second; // 현재 방문중인 노드
        PQ.pop();

        for (const auto& Edge : _Graph[CurNode]) // 현재 방문중인 노드의 인접 노드들을 탐색
        {
            int ToNode = Edge.first; // 인접 노드(To 노드)
            int ToWeight = Edge.second; // 인접 노드로 가는대 필요한 비용
            // CurWeight + Weight == 현재 노드까지 오는대의 비용 + To 노드까지 가는대의 비용
            if (CurWeight + ToWeight < _Dist[ToNode]) 
            {
                _Dist[ToNode] = CurWeight + ToWeight; // 원래 값보다 최소면 갱신
                PQ.push({ _Dist[ToNode], ToNode }); // 방문을 위해 push
            }
        }
    }
}

int main()
{
	ios::sync_with_stdio(false);
	cin.tie(nullptr);

	int V = 0, E = 0; // V : 정점 갯수 // E : 간선 갯수 (1 ≤ V ≤ 20,000, 1 ≤ E ≤ 300,000)
	cin >> V >> E;

	int K = 0; // K : 시작 정점
	cin >> K;

    // 인접 리스트: Graph[u] = { {v, w}, ... }
    vector<vector<pair<int, int>>> Graph(V + 1);
	vector<int> Dist(V + 1, INF);
    for (int i = 0; i < E; ++i)
    {
        int U, V, W; // U -> Vto with cost W
        cin >> U >> V >> W;
        Graph[U].push_back({ V, W });
        // 여러 간선 중 최소만 유지하고 싶다면 아래처럼 전처리하거나, 그대로 둬도 정답엔 지장 없음
        // (정답성엔 문제 없지만 메모리·시간 최적화 원하면 map/정렬로 최소 간선만 남기기)
    }

	Dijkstra(Graph, Dist, K);
    for (int i = 1; i <= V; ++i)
    {
        if (Dist[i] == INF)
        {
            cout << "INF\n";
        }
        else 
        {
            cout << Dist[i] << '\n';
        }
    }

	return 0;
}

 

[Directed Acyclic Graph(DAG)]

 

특정 노드에서 시작하여, 그래프를 순회하고 다시 시작 노드로 돌아올 수 있는 경우 방향 사이클이 존재하는 그래프(Directed Cycle)라고 할 수 있습니다. 아래는 예시 그래프입니다.

 

하지만 특정 노드에서 시작해도 다시 시작 노드로 돌아올 수 없는 경우가 존재하는데, 이런 경우에는 방향 사이클이 없는 그래프(Directed Acyclic Graph; DAG)라고 합니다..

 

DAG의 경우 작업들의 우선순위를 표현할 때 사용하면 좋은 그래프 형태입니다. 위의 그래프에서 3번을 실행하기 위해서는 2번을 거치거나 6번을 거쳐와야하고, 2번을 실행하기 위해선 1번을 거쳐오거나 5번을 거쳐와야합니다.

 

 

[위상 정렬이란]

 

DAG 형태의 그래프에서 활용 가능한 알고리즘으로, 작업을 차례대로 수행해야할 때, 그 순서를 결정해줄 수 있습니다. 위상 정렬을 알아보기 전에 먼저 진입 차수의 개념을 알아보도록 하겠습니다.

 

진입 차수란 노드로 들어오는 간선의 개수를 말합니다.

 

노드 3 : 노드 2, 노드 6에서 뻗어나온 간선이 닿아있는데, 이럴 경우 노드 3의 진입 차수는 2

노드 1 : 이전에서 뻗어나온 간선이 없기 때문에 진입 차수가 0

노드 4 : 노드 3에서 뻗어나온 간선이 닿아있기 때문에 진입 차수가 1

 

위상 정렬은 진입 차수가 0인 노드를 출력하고, 이와 연결된 간선을 제거하는 방식으로 진행됩니다.

1. 진입 차수가 0인 노드를 큐에 삽입
2. 큐에서 원소를 꺼내 출력하고, 이와 연결된 모든 간선을 제거
3. 큐가 빌때 까지 1~2번을 반복

 

위의 DAG 그래프에 대한 정보를 정리해보겠습니다.

노드 진입 차수 나가는 간선
1 2
2 2 (1, 5) 3
3 2 (2, 6) 4
4 1 (3) -
5 0 2, 6
6 5 (5) 3

 

정리된 값을 활용하여 정렬 과정을 설명하면 다음과 같습니다.

 

1. 초기 큐에  진입 차수가 0인 노드 1, 5를 삽입

2. 노드 1을 꺼내면서 이와 연결된 노드 2의 진입 차수 감소 (2 -> 1)

3. 노드 5를 꺼내면서 이와 연결된 노드 2의 진입 차수 감소 (1 -> 0), 노드 6의 진입 차수 감소 (1 -> 0)

4. 진입차수가 0이 된 노드 2와 노드 6을 큐에 삽입

5. 노드 2를 꺼내면서 이와 연결된 노드 3의 진입 차수 감소 (2 -> 1)

6. 노드 6을 꺼내면서 이와 연결된 노드 3의 진입 차수 감소 (1 -> 0)

7. 진입 차수가 0인 노드 3을 큐에 삽입

8. 노드 3을 꺼내면서 이와 연결된 노드 4의 진입 차수 감소 (1 -> 0)

9. 진입 차수가 0인 노드 4를 큐에 삽입

10. 4를 꺼냄

11. 종료

 

단계를 정리한 표는 다음과 같습니다.

단계 큐 상태 꺼낸 노드 추가된 노드 결과 순서
초기 [1, 5] - - -
1 [5] 1 2 1
2 [2, 6] 5 6 1, 5
3 [6] 2 - 1, 5, 2
4 [3] 6 3 1, 5, 2, 6
5 [4] 3 4 1, 5, 2, 6, 3
6 [] 4 - 1, 5, 2, 6, 3, 4

 

여기서 주의할 점이, 1과 5를 처음에 넣는 순서에 따라서 충분히 출력 결과가 달라질 수 있다는 점입니다. 위에서는 1부터 꺼냈기 때문에 [1, 5, 2, 6, 3, 4]로 나오지만, 5부터 꺼냈을 경우 [5, 1, 2, 6, 3, 4]가 될 수도 있다는 것입니다.

 

아래는 위상 정렬 알고리즘을 구현한 예시 소스 코드입니다.

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

using namespace std;

void Topology(vector<vector<int>>& _Graph, vector<int> _InDegree, size_t _N, size_t _M)
{
	queue<int> Que;

	for (size_t i = 1; i <= _N; i++)
	{
		if (0 == _InDegree[i])
		{
			Que.push(i); // 진입차수 0인 Node
		}
	}

	while (!PQue.empty())
	{
		int From = Que.front();
		Que.pop();
		for (size_t i = 0; i < _Graph[From].size(); i++)
		{
			int To = _Graph[From][i];
			--_InDegree[To];
			if (0 == _InDegree[To])
			{
				Que.push(To);
			}
		}
	}
}

int main() // 위상정렬
{
	size_t N = 0, M = 0;
	cin >> N >> M;
	vector<vector<int>> Graph(N + 1);
	vector<int> InDegree(N + 1);
	for (size_t i = 0; i < M; i++)
	{
		int To, From;
		cin >> To >> From;
		Graph[To].push_back(From);
		++InDegree[From];
	}

	Topology(Graph, InDegree, N, M);

	return 0;
}

[다익스트라와 플로이드 워셜]

 

다익스트라의 경우, 한 노드에서 다른 특정 노드까지의 최단 경로를 구하는 알고리즘입니다. 이에 비해 플로이드-워셜은 모든 노드간의 최단 거리를 구하는 방식의 알고리즘입니다.

 

다익스트라를 활용해서도 모든 노드 간의 최단거리를 구할 수 있겠지만, 노드 수가 수백개 이하일 경우와 더불어 모든 노드 간의 최단거리를 구하는 방법에 대한 플로이드-워셜을 사용하는 것이 더 간단한 해결책이 될 수 있습니다. 아래에는 두 알고리즘 간의 차이를 간단하게 정리해본 것입니다.

  다익스트라 플로이드-워셜
유형 한 노드에서 다른 노드들까지의 최단 거리 모든 노드 간의 최단 거리
복잡도 O(ElogV) O(V^3)
성격 탐욕(Greedy) 다이나믹 프로그래밍(DP)
결과 1차원 리스트(배열) 2차원 배열
기타 음수 간선(가중치) 활용 불가능 음수 간선(가중치) 활용 가능

 

 

[플로이드-워셜 구현 방식 알아보기]

 

다음과 같이 노드와 간선 정보가 존재한다고 가정해보겠습니다.

 

플로이드-워셜 알고리즘을 활용하기 위해서는 2차원 테이블 정보가 필요합니다. 테이블의 정보는 다음과 같이 준비해줍니다.

 

1. 각 i,j 행렬(테이블)이 존재할 때, 특정 i행과 j열은 출발지 i, 목적지 j를 뜻함(2,3일 경우, 2번 노드부터 3번 노드를 뜻함)

2. 출발지-목적지 정보는 가중치(간선) 값으로 채워줌(위에서 2 to 3의 경우 7의 가중치를 넣어줌)

3. 출발지-목적지가 자기 자신인 경우에는 0으로 채움

4. 출발지-목적지가 연결되지 않은 경우에는 무한대 값(INF)으로 채움

 

위와 같이 테이블을 준비해 주었다면, 각 노드를 거쳐가는 경우를 고려하면서 테이블을 갱신해줍니다. 점화식은 다음과 같습니다.

Dist[i][j] = min(Dist[i][j], Dist[i][k] + Dist[k][j]) // k는 경유 노드

 

 

> 1번 노드를 거쳐가는 경우

 

위의 그래프에서 1번 노드를 경유하는 경우의 수들은 파란색으로 색칠된 영역들입니다. 

 

  • i = 1:
    Dist[1][2] = min(Dist[1][2], Dist[1][1] + Dist[1][2])
    Dist[1][3] = min(Dist[1][3], Dist[1][1] + Dist[1][3])
    Dist[1][4] = min(Dist[1][4], Dist[1][1] + Dist[1][4])
  • i = 2:
    Dist[2][1] = min(Dist[2][1], Dist[2][1] + Dist[1][1])
    Dist[2][3] = min(Dist[2][3], Dist[2][1] + Dist[1][3])
    Dist[2][4] = min(Dist[2][4], Dist[2][1] + Dist[1][4])
  • i = 3:
    Dist[3][1] = min(Dist[3][1], Dist[3][1] + Dist[1][1])
    Dist[3][2] = min(Dist[3][2], Dist[3][1] + Dist[1][2])
    Dist[3][4] = min(Dist[3][4], Dist[3][1] + Dist[1][4])
  • i = 4:
    Dist[4][1] = min(Dist[4][1], Dist[4][1] + Dist[1][1])
    Dist[4][2] = min(Dist[4][2], Dist[4][1] + Dist[1][2])
    Dist[4][3] = min(Dist[4][3], Dist[4][1] + Dist[1][3])

여기서 2 to 4의 경우, 기존의 INF 값에서 변화가 발생합니다.

Dist[2][4] = min(INF, Dist[2][1] + Dist[1][4]) = min(INF, 3 + 6) = 9

 

2 to 4의 경우, 기존에는 간선으로 연결되어 있지 않아 INF로 표시되지만 점화식을 거치면서 2 to 1, 1 to 4로 연결되고 이에 대한 가중치 합이 9로 기존의 INF보다 낮기 때문에 갱신되는 것입니다.

 

3 to 2의 경우에도 마찬가지로, 점화식 결과 기존의 INF보다 [Dist[3][1] + Dist[1][2] = 5 + 4 = 9]의 값이 더 작기 때문에 값이 갱신됩니다.

 

 

> 2번 노드를 거쳐가는 경우

 

  • i = 1:
    Dist[1][2] = min(Dist[1][2], Dist[1][2] + Dist[2][2])
    Dist[1][3] = min(Dist[1][3], Dist[1][2] + Dist[2][3])
    Dist[1][4] = min(Dist[1][4], Dist[1][2] + Dist[2][4])
  • i = 2:
    Dist[2][1] = min(Dist[2][1], Dist[2][2] + Dist[2][1])
    Dist[2][3] = min(Dist[2][3], Dist[2][2] + Dist[2][3])
    Dist[2][4] = min(Dist[2][4], Dist[2][2] + Dist[2][4])
  • i = 3:
    Dist[3][1] = min(Dist[3][1], Dist[3][2] + Dist[2][1])
    Dist[3][3] = min(Dist[3][3], Dist[3][2] + Dist[2][3])
    Dist[3][4] = min(Dist[3][4], Dist[3][2] + Dist[2][4])
  • i = 4:
    Dist[4][1] = min(Dist[4][1], Dist[4][2] + Dist[2][1])
    Dist[4][3] = min(Dist[4][3], Dist[4][2] + Dist[2][3])
    Dist[4][4] = min(Dist[4][4], Dist[4][2] + Dist[2][4])

 

 

> 3번 노드를 거쳐가는 경우

 

  • i = 1:
    Dist[1][2] = min(Dist[1][2], Dist[1][3] + Dist[3][2])
    Dist[1][3] = min(Dist[1][3], Dist[1][3] + Dist[3][3])
    Dist[1][4] = min(Dist[1][4], Dist[1][3] + Dist[3][4])
  • i = 2:
    Dist[2][1] = min(Dist[2][1], Dist[2][3] + Dist[3][1])
    Dist[2][3] = min(Dist[2][3], Dist[2][3] + Dist[3][3])
    Dist[2][4] = min(Dist[2][4], Dist[2][3] + Dist[3][4])
  • i = 3:
    Dist[3][1] = min(Dist[3][1], Dist[3][3] + Dist[3][1])
    Dist[3][2] = min(Dist[3][2], Dist[3][3] + Dist[3][2])
    Dist[3][4] = min(Dist[3][4], Dist[3][3] + Dist[3][4])
  • i = 4:
    Dist[4][1] = min(Dist[4][1], Dist[4][3] + Dist[3][1])
    Dist[4][2] = min(Dist[4][2], Dist[4][3] + Dist[3][2])
    Dist[4][4] = min(Dist[4][4], Dist[4][3] + Dist[3][4])

 

 

 

> 4번 노드를 거쳐가는 경우

 

  • i = 1:
    Dist[1][2] = min(Dist[1][2], Dist[1][4] + Dist[4][2])
    Dist[1][3] = min(Dist[1][3], Dist[1][4] + Dist[4][3])
    Dist[1][4] = min(Dist[1][4], Dist[1][4] + Dist[4][4])
  • i = 2:
    Dist[2][1] = min(Dist[2][1], Dist[2][4] + Dist[4][1])
    Dist[2][3] = min(Dist[2][3], Dist[2][4] + Dist[4][3])
    Dist[2][4] = min(Dist[2][4], Dist[2][4] + Dist[4][4])
  • i = 3:
    Dist[3][1] = min(Dist[3][1], Dist[3][4] + Dist[4][1])
    Dist[3][2] = min(Dist[3][2], Dist[3][4] + Dist[4][2])
    Dist[3][4] = min(Dist[3][4], Dist[3][4] + Dist[4][4])
  • i = 4:
    Dist[4][1] = min(Dist[4][1], Dist[4][4] + Dist[4][1])
    Dist[4][2] = min(Dist[4][2], Dist[4][4] + Dist[4][2])
    Dist[4][3] = min(Dist[4][3], Dist[4][4] + Dist[4][3])

 

위와 같이 모든 노드에 대한 갱신이 완료되면 플로이드-워셜은 종료됩니다. 아래는 플로이드-워셜 구현 코드입니다.

int main()
{
    const long long INF = (long long)4e15; // 충분히 큰 값 (가중치 합 대비 여유 있게)

    int N, M;                 // N: 노드 수, M: 간선 수
    cin >> N >> M;

    // 1) 초기화: i==j면 0, 나머지는 INF
    vector<vector<long long>> Dist(N + 1, vector<long long>(N, INF));
    for (int i = 0; i < N; ++i)
    {
        Dist[i][i] = 0;
    }

    // 2) 간선 입력: 출발-도착의 가중치 채우기 (다중 간선이면 최솟값 유지)
    for (int i = 0; i < M; ++i)
    {
        int U, V;
        long long W;
        cin >> U >> V >> W;
        if (W < Dist[U][V])
        {
            Dist[U][V] = W;
        }
    }

    // 3) 플로이드-워셜: 경유지 k를 하나씩 허용하며 최단거리 갱신
    for (int k = 0; k < N; ++k)
    {
        for (int i = 0; i < N; ++i)
        {
            for (int j = 0; j < N; ++j)
            {
                long long via = Dist[i][k] + Dist[k][j];
                if (via < Dist[i][j])
                {
                    Dist[i][j] = via;
                }
            }
        }
    }

    return 0;
}

 

 

 

[new와 placement new]

 

메모리를 할당하면서 객체를 생성하는 new 연산과 달리, placement new는 이미 확보된 메모리 공간에 객체를 생성할 때 사용하는 특수한 new 연산자입니다. 

 

> new

MyClass* obj = new MyClass();

 

new는 위와 같이 실시하며, 동적 메모리에서 공간을 할당한 뒤, 그 공간에서 생성자를 호출합니다.

 

> placement new

char buffer[sizeof(MyClass)];
MyClass obj = new(buffer) MyClass();

 

placement new는 미리 확보된 메모리 공간(위에서는 buffer)가 존재하고, 그 공간을 생성자 호출용으로만 활용합니다. 이 과정에서 새 메모리 할당은 발생하지 않습니다.

 

 

[placement new 문법]

 

연산자 정의는 다음과 같이 이루어져 있습니다.

void* operator new(size_t size, void* ptr) noexcept;
////////////////////////////////////////////////////
T* obj = new(ptr) T(constructor_args...);

 

확보된 ptr 주소를 인자로 받아, 그 위치에 객체를 생성하는 것입니다. 아래는 실제 사용 예시에 대한 코드입니다.

#include <iostream>
#include <new> // placement new를 사용하려면 필요

class MyClass
{
public:
    int Value;

    MyClass(int _Value) : Value(_Value)
    {
        std::cout << "Constructor: " << Value << std::endl;
    }

    ~MyClass()
    {
        std::cout << "Destructor: " << Value << std::endl;
    }
};

int main()
{
    // 메모리만 확보
    alignas(MyClass) char buffer[sizeof(MyClass)];

    // 확보된 메모리에 객체 생성
    MyClass* obj = new(buffer) MyClass(123);

    std::cout << "Value = " << obj->Value << std::endl;

    // 명시적으로 소멸자 호출 (delete X)
    obj->~MyClass();

    return 0;
}

///////////////////////////////////////////
<출력>
Constructor: 123
Value = 123
Destructor: 123

 

 

[사용 이유]

 

placement new의 활용 목적 대부분은 메모리풀에서 활용하기 위함에 있습니다. 미리 큰 버퍼를 만들어두고, placement new를 통해 필요한 위치에 객체를 생성하는 것입니다.

void* pool = malloc(sizeof(MyClass) * 100);
MyClass* obj = new(pool) MyClass();

/////////////////////////////////////////////////
<메모리풀 예시>
class MemoryPool
{
    char* Pool;
    size_t Offset;

public:
    MemoryPool(size_t Size)
    {
        Pool = new char[Size];
        Offset = 0;
    }

    ~MemoryPool() { delete[] Pool; }

    template<typename T, typename... Args>
    T* Allocate(Args&&... args)
    {
        void* Ptr = Pool + Offset;
        Offset += sizeof(T);
        return new(Ptr) T(std::forward<Args>(args)...);
    }
};

 

추가로, 객체의 생성과 소멸 시점을 직접 관리하고 싶은 경우에도 활용합니다. shared_ptr 등에는 내부 구현에 placement new를 활용하고 있습니다.

 

 

[주의 사항]

 

placement new는 실제로 new와 같이 메모리를 할당하는 것이 아니라, 미리 할당해둔 메모리 영역을 활용하는 것이기 때문에 delete 연산자를 호출하기보단 명시적으로 소멸자를 호출하는 것이 좋습니다. 또한 이미 생성된 영역에 대해 다시 생성을 실시하면 같은 영역에 객체가 덮어씌워질 수 있습니다.

[명령어 파이프라인이란]

 

명령어 파이프라인(Instruction Pipeline)은 CPU 내부에서 명령어를 효율적으로 처리하기 위해 작업 공정을 나누고 동시에 처리하는 방식입니다. 명령어를 하나씩 단계별로 처리하는 것이 아니라, 여러 명령어가 서로 다른 단계를 동시에 처리하도록 만든 구조인 것입니다.

 

 

[구조]

 

CPU는 명령어를 실행할 때마다 다음의 과정을 거칩니다.

1. IF : Instruction Fetch, 명령어를 메모리에서 가져옴
2. ID : Instruction Decode, 명령어를 해석함
3. OF : Operand Fetch, 필요한 데이터(피연산자)를 읽음
4. EX : MEM // Execute, 실제 연산 수행
5. WB : Write Back, 결과를 레지스터나 메모리에 저장

 

비파이프라인 구조에서는 위의 5가지 단계가 모두 끝나야 다음 명령어 처리를 수행할 수 있습니다. 즉, 하나의 [IF -> ID -> OF -> EX -> WB]가 완료되어야 다음의 [IF -> ID -> OF -> EX -> WB]를 수행한다는 것입니다.

 

하지만 파이프라인 구조에서는 명령어 처리를 다음과 같이 실시합니다.

 

한 사람이 모든 작업을 순차적으로 처리하는 것이 아니라, 여러 명이 각 단계만 전담해서 동시에 일을 처리하는 구조라고 보시면 될 것 같습니다.

 

 

[장점]

비파이프라인(순차 처리) - 각 명령어가 5단계라면, 1개 명령어 처리에 5클록
- 5개의 명령어 처리 시, 5x5클록(25클록)
파이프라인(병렬 처리) - 첫 명령어 처리는 5단계가 걸리지만, 이후부터는 매 클록마다 새로운 명령이 투입되어 처리됨
- 각 명령어가 5단계라면, 5 + (5 - 1) = 9클록으로 명령어 처리 가능
- 비파이프라인 구조보다 이론적으로 5배 빠르지만, 해저드(지연, 충돌)로 인해 5배 빠른것은 아님

 

 

[단점 및 해결책]

 

물론 장점만 존재하는 것은 아닙니다. 보통 해저드(Hazard) 문제가 발생하는데, 해저드는 여러 명령이 동시에 움직이다보니 발생하는 충돌과 의존성 문제를 말합니다. 

 

> 구조적 해저드(Structural Hazard)

: 두 단계가 동시에 같은 하드웨어 자원을 쓰려할 때 발생하는 문제를 말합니다.

명령어 인출 단계(IF)와 오퍼랜드 읽기(OF)가 동시에 메모리를 사용하려 할 때

 

구조적 해저드는 명령용 메모리와 데이터용 메모리를 분리하거나(하버드 구조), 자원 복제 또는 접근 시간 조정을 필요로 합니다.

 

> 데이터 해저드(Data Hazard)

: 이전 명령의 결과가 아직 나오지 않았는데, 다음 명령이 그 값을 필요로 하는 경우 발생하는 문제입니다.

ADD R1, R2, R3   ; R1 = R2 + R3
SUB R4, R1, R5   ; R4 = R1 - R5 (R1이 아직 안 나옴)

 

데이터 해저드는 결과가 나오는 즉시 다음 명령으로 전달하는 전방 전달(Forwarding)이나 NOP을 삽입하여 잠깐 멈춰서 기다리는 버블(Stall)을 통해 해결할 수 있습니다.

 

> 제어 해저드(Control Hazard)

: 분기(branch) 명령이 있을 때 다음 명령이 무엇일지 확정되지 않은 경우 발생하는 문제입니다.

BEQ R1, R2, LABEL

 

위의 문제는 분기 조건이 참일지 거짓일지 모르기 때문에, 이미 인출된 다음 명령이 무의미해질 수 있게 됩니다. 이에 대한 해결적으로는 분기 예측(Branch Prediction), 브랜치 지연 슬롯(Delay Slot), 스페큘레이티브 실행(Speculative Execution) 등이 있습니다.

'컴퓨터과학 > 시스템' 카테고리의 다른 글

[CS] CPU(Central Processing Unit)  (0) 2025.09.30
[CS] 페이지 폴트(Page Fault)  (0) 2025.05.19
[CS] 세그멘테이션(Segmentation)  (0) 2025.05.19
[CS] 페이징(Paging)  (0) 2025.05.19
[CS] 가상 메모리(Virtual Memory)  (0) 2025.05.18

[홀 펀칭의 필요성]

 

일반적으로 외부로부터 내부로 오는 요청에 대한 처리를 할 경우, 포트 포워딩을 실시할 수도 있겠지만, 현실적으로 모든 사용자가 이런 설정을 하지는 않습니다.

 

때문에 P2P(온라인 게임, 화상 채팅 등) 같은 프로그램들은 자동으로 NAT을 뚫어주는 방식을 활용하는데, 여기에 대표적인 기술로 홀 펀칭(Hole Punching)이 있습니다.

 

 

[홀 펀칭(Hole Punching) 이란]

 

홀 펀칭(Hole Punching)은 NAT/공유기 뒤에 있는 두 클라이언트가 서로 직접 연결할 수 있게 NAT에 구멍을 만들어주는 기술입니다. UDP 기반 통신에서 많이 활용하는데(TCP도 가능은 한데 UDP보다 어려움) P2P 기반으로 통신을 진행하는 경우 많이 활용됩니다.

 

포트 포워딩의 경우에는 사용자가 직접 공유기 설정을 통해 포트를 개방하는 것이지만, 홀 펀칭의 경우엔 서버(프로그램)이 자동으로 NAT에 구멍을 만들어서 외부와 연결할 수 있도록 해주는 것입니다.

 

 

[홀 펀칭 동작 원리(UDP 통신)]

 

A(192.168.0.2)와 B(192.168.0.3)이 사설망 뒤에 존재한다고 가정해보겠습니다. 공인 IP는 있지만 NAT 때문에 외부에서 직접 접근이 불가능한 상황입니다. 단순 접속 시도는 공유기가 "어떤 PC에 접근해야하는지"를 모르기 때문에 접속 실패가 발생합니다. 이를 위해 홀 펀칭을 통해 통신을 시도하는 경우를 알아보도록 하겠습니다.

 

1. 중앙 서버(시그널링 서버)가 준비 : A와 B의 공인 IP와 포트 넘버를 알아냄

  1-1. A의 공인 IP와 포트 넘버 = 220.x.x.x:50001

  1-2. B의 공인 IP와 포트 넘버 = 121.x.x.x:60002

2. 서버가 A와 B에게 서로의 주소(공인 IP와 포트 넘버)를 알려줌

3. A와 B가 동시에 접속 시도

  3-1. A는 B의 공인IP:포트넘버로 UDP 패킷 전송

  3-2. B는 A의 공인IP:포트넘버로 UDP 패킷 전송

4. NAT은 "내부에서 특정 외부 주소로 나간 연결"이 있으면 그 주소에서 오는 응답을 허용, 그래서 두 쪽이 동시에 패킷을 보낼 경우, NAT이 각각 구멍을 열고 서로 패킷을 수신할 수 있게 됨

5. 이제 A와 B는 중앙 서버를 거치지 않고 통신 가능해짐

 

그래도 이 또한 NAT 종류에 따라 다를 수 있습니다. 방화벽 정책이 다르거나 대칭형 NAT의 경우엔 홀 펀칭이 이뤄지지 않을 수 있기 때문입니다. 이 경우에는 또 대안으로 STUN/TURN 서버를 활용할 수 있습니다.

 

- STUN : 공인 IP/포트를 알려주는 서버(홀펀칭 지원)

- TURN : 홀펀칭 실패 시 중계 서버로 역할 수행

[포트 포워딩의 개념]

 

기본적으로 NAT은 내부에서 외부로 요청하는 경우에만 자동으로 응답을 돌려주고, 반대의 경우에는 응답을 돌려주지 않습니다. 때문에 외부에서 내부로 연결 요청이 들어올 때는 공유기가 포트를 열어둔 PC를 알지 못하는 상태가 되는데, 이 문제를 해결하기 위하여 포트 포워딩을 활용합니다.

 

포트 포워딩이란 특정 공인 IP의 특정 포트에서 내부 특정 IP와 특정 포트로 연결 가능하도록 해주는 기능입니다. 즉, 외부에서 들어오는 연결을 공유기가 특정 내부 PC(or 서버)로 넘겨주도록 설정하는 것입니다.

 

 

[필요 이유]

 

집에서 게임 서버(192.168.0.10:7777)를 구축했고, 여기에 친구가 접속하고자 합니다. 소지중인 공유기의 IP는 220.x.x.x로 가정하겠습니다. 이 경우 기본 NAT 상태라면 외부에서 220.x.x.x:7777로 접속을 시도해도 공유기는 "어느 내부 PC에 연결하는 것인지?"를 모릅니다(연결 실패).

 

이에 대한 해결책으로 공유기 설정에서 포트 포워딩을 실시합니다. 7777 포트에 대한 접속은 192.168.0.10:7777로 접속하도록 설정하는 것입니다. 이렇게 해주면 외부에서 220.x.x.x:7777로 연결을 시도할 경우, 공유기가 연결을 192.168.0.10:7777로 유도해줍니다(트래픽을 해당 위치로 전달).

[네트워크 통신]

 

내 PC(192.168.0.2:12345)가 www.example.com(93.184.216.34:80)에   접속한다고 가정해보겠습니다. 통신 방식은 다음과 같습니다. 

 

1. 내 PC에서 요청

: 사용자가 브라우저에 www.example.com을 입력하면 DNS를 통해 IP로 변환되며 TCP 소켓 연결이 생성됩니다. 요청은 [내 PC IP:12345 -> 서버IP:80]로 실시됩니다.

 

2. LAN 내부 통신

: 내 PC(사설IP : 192.168.0.2)가 공유기(192.168.0.1)에게 패킷을 전송합니다. 이때 이더넷 프레임 안에서는 내 PC의 MAC 주소와 공유기의 MAC 주소가 출발지, 목적지로 포함되어 있습니다.

 

3. NAT 변환

: 공유기(라우터)는 내부 사설 IP(192.168.0.2:12345)를 자신의 공인 IP(220.x.x.x:50001)로 변환시켜 인터넷으로 전송합니다. 이렇게 변환되어야 다른 네트워크 대역이 현재 존재하는 네트워크 대역을 알 수 있습니다.

 

4. 인터넷 통신

: 공인 IP(220.x.x.x)를 출발지로 하여 여러 라우터를 거쳐, 목적지 서버(93.184.216.34)까지 이동합니다. 경로 상에는 IP 주소 기반 라우팅만 일어나고, MAC 주소는 각 구가간마다 변경됩니다.

 

5. 서버 응답

: 서버는 요청을 받은 뒤, 응답 패킷을 목적지(220.x.x.x:50001)로 돌려보냅니다.

 

6. 공유기의 NAT 역변환

: 공유기는 응답을 받아서 다시 내부 클라이언트(192.168.0.2:12345)로 매핑합니다. 이 덕분에 여러 PC(192.168.0.3, 192.168.0.4, ...)가 동시에 인터넷을 사용해도 통신간 혼동이 발생하지 않습니다.

 

7. 내 PC에서 응답 수신

: 공유기가 응답을 내 PC MAC 주소로 전달합니다. 브라우저가 서버 응답(HTML, 이미지 등)을 받아 웹사이트를 표시해줍니다.

+ Recent posts