다익스트라

  • 가중 그래프에서 최단경로를 찾는 알고리즘
  • 모든 링크의 가중치는 양의 정수여야 한다.
  • 단일 시작점에서 시작하여 그래프의 모든 정점으로의 최단 경로를 계산하는 One To All 방식
  • 시간복잡도 O((V+E) log V)

 

 

동작 방식

 

 

다익스트라 알고리즘 - https://ko.wikipedia.org/wiki/%EB%8D%B0%EC%9D%B4%ED%81%AC%EC%8A%A4%ED%8A%B8%EB%9D%BC_%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98

 

  1. 출발 노드 설정, 최단 거리 테이블 초기화
    1. 모든 버텍스 간의 최단거리 값 무한대 설정. 시작 버텍스는 0
  2. 방문하지 않은 노드 중 최단거리가 가장 짧은 노드 선택
  3. 해당 노드를 거쳐 다른 노드로 가는 비용(최단거리 값 + 가중치)업데이트
    1. 기존 최단 거리 값보다 작을 시 테이블 업데이트
  4. 2~3의 과정 반복
  5. 각 정점에 대한 최단 거리 값 반환

 

다익스트라 (+ 우선순위 큐) 구현 코드

#define INF 1e9


// 거리(가중치) / 노드 번호
vector<vector<pair<int, int>>> graph; 

// 최단거리 테이블
vector<int> distances;



void Dijkstra(int start, int numVertices)
{


	// 우선순위 큐 (거리 , 노드 인덱스)
	priority_queue<pair<int, int>> pq;

	// 시작 정점의 거리 0 설정
	distances[start] = 0;	
	pq.push({ 0, start });

	while (!pq.empty())
	{
		// 현재 정점까지의 거리
		int currentDistance = pq.top().first;
		// 현재 정점
		int currentVertex = pq.top().second;
		pq.pop();

		// 이미 처리된 정점일 경우
		if (currentDistance > distances[currentVertex])
			continue;

		// 현재 정점의 모든 인접 정점 확인
		for (pair<int, int>& edge : graph[currentVertex])
		{
			int weight = edge.first;
			int neighbor = edge.second;

			// 비용이 더 적은 경로 발견된 경우
			if (distances[currentVertex] + weight < distances[neighbor])
			{
				distances[neighbor] = distances[currentVertex] + weight;
				pq.push({ distances[neighbor], neighbor });
			}
		}
	}
}
	graph.resize(6 + 1);
	distances.assign(6 + 1, INF);



	graph[0].emplace_back(7, 1);
	graph[0].emplace_back(9, 2);
	graph[0].emplace_back(14, 5);
	graph[1].emplace_back(10, 2);
	graph[1].emplace_back(15, 3);
	graph[2].emplace_back(2, 5);
	graph[2].emplace_back(11, 3);
	graph[3].emplace_back(6, 4);
	graph[5].emplace_back(9, 4);


	Dijkstra(0, 6);

	for (int i = 0; i <= 5; i++)
	{
		if (distances[i] == INF)
		{
			cout << i+1 << ": Unreachable" << endl;
		}
		else
		{
			cout << i+1 << ": " << distances[i] << endl;
		}
	}

지난 포스팅에서 그래프 구조의 탐색 알고리즘 중 하나인 DFS에 대해 알아보았습니다.

이번 포스팅에서는 깊이가 아닌, 너비를 우선으로 탐색하는 BFS에 대해 알아보며, 두개의 탐색 알고리즘이 어떤 차이점을 가지고 있는지 알아보겠습니다.

※ 깊이 우선 탐색 (DFS)

 

너비 우선 탐색 (Breadth First Search)

BFS - https://www.wscubetech.com/resources/dsa/dfs-vs-bfs

  • 시작 노드에서 가까운 노드 순서로 탐색하는 알고리즘
  • 현재 깊이와  동일한 모든 이웃노드를 방문 후 다음 깊이(레벨)로 탐색하는 방식
  • FIFO(선입선출)형태의 자료구조 큐를 사용하여 방문한 노드를 먼저 처리한다.
  • 각 레벨을 차례대로 방문하기 때문에 시작 노드에서 다른 노드까지의 최단경로를 찾을 수 있다.
    • 간선(Edge)가 비가중치인 경우에 최단경로가 보장되며, 가중치가 있을 경우 다른 알고리즘을 사용한다.
  • 큐에 모든 노드를 저장해야하므로 노드가 많을 경우 메모리 사용량이 증가한다.
  • 시간복잡도 : O(V + E) 
    • V : Vertex
    • E : Edge

 

BFS 탐색 과정

  • 선택 노드 0에서 시작 
    • 노드 0 큐에 삽입, Visited = true
  • 0계층의 이웃 노드 방문 (1 ~ 3)
    • 현재 노드에 (0)에 대해 방문하지 않은 인접노드(1,2,3)들을 찾아 큐에 추가, Visited = true
    • 방문체크, 방문하지 않은 노드만 큐에 삽입
  • 1계층의 모든 노드 방문 후 2계층 방문 (반복)
    • 방문체크 하지 않은(Visited == false)  노드 (4,5 / 6, 7) 큐에 삽입, Visited = true
  • 반복
  • Queue가 비어있는 경우 탐색 종료

 

BFS 구현 예시 코드

struct Vertex
{
	int data;

	Vertex() : data(0) {}
	Vertex(int data) : data(data) {}
};

vector<Vertex> vertices;
vector<vector<int>> adjacent;
vector<bool> isVisited;

void CreateGraph()
{
	vertices.resize(8);
	adjacent.resize(8);

	adjacent[0].push_back(1);
	adjacent[0].push_back(2);
	adjacent[0].push_back(3);
	adjacent[1].push_back(4);
	adjacent[1].push_back(5);
	adjacent[2].push_back(6);
	adjacent[3].push_back(7);
	adjacent[3].push_back(0);
	adjacent[4].push_back(1);
	adjacent[5].push_back(1);
	adjacent[6].push_back(2);
	adjacent[7].push_back(3);

	for (int i = 0; i < vertices.size(); i++)
	{
		vertices[i].data = i;
	}
}

void BFS(int start)
{
	queue<int> q;
	isVisited.resize(vertices.size(), false);

	// 방문한 Vertex True 후 큐에 삽입
	isVisited[start] = true;
	q.push(start);

	cout << "Start BFS Node : " << start << endl;

	while (!q.empty())
	{
		// 삽입된 인접 노드 꺼내기
		int current = q.front();
		q.pop();

		cout << "Current VertexData : " << vertices[current].data << endl;

		for (int near : adjacent[current])
		{
			if (!isVisited[near])
			{
				isVisited[near] = true;
				q.push(near);
			}
		}
	}

	cout << endl;
}

BFS(Start : 0) 출력 결과

 

DFS VS BFS

DFS VS BFS - https://namu.wiki/w/%EB%84%88%EB%B9%84%20%EC%9A%B0%EC%84%A0%20%ED%83%90%EC%83%89

 

항목 BFS (너비 우선 탐색) DFS (깊이 우선 탐색)
탐색 방식 가까운 노드부터 차례대로 탐색 (레벨별 탐색) 한 방향으로 깊게 탐색 후 되돌아가서 다른 방향 탐색
사용 자료 구조 큐 (Queue) 스택 (Stack) 또는 재귀 (Recursion)
최단 경로 가중치가 동일한 경우 최단 경로 보장 최단 경로 보장하지 않음
공간 복잡도 큐에 많은 노드를 저장해야 하므로 메모리 사용 많음 깊이에 따라 스택이나 재귀로 메모리 사용 적음
탐색 순서 레벨 순서대로 탐색 깊이 순서대로 탐색
적합한 경우 최단 경로를 찾거나, 레벨 기반 문제 해결 시 경로를 찾거나, 그래프의 연결 여부를 확인할 때

 

 

 

 


https://www.wscubetech.com/resources/dsa/dfs-vs-bfs

 

DFS vs BFS Algorithm (All Differences With Example)

Learn the key differences between DFS vs BFS algorithms with examples. Understand their applications, time complexity, and how they work in graph traversal.

www.wscubetech.com

 

깊이 우선 탐색(Depth First Search)

그래프 탐색 알고리즘 중 하나로, 시작 노드에서 자식의 노드들을 순서대로 탐색하며 깊이를 우선으로 탐색하는 알고리즘 입니다.

  • 한 방향으로 갈 수 있을 때까지 계속 가다가 더이상 갈 수 없게되면 가장 가까운 갈림길로 돌아와서 이곳으로부터 다른 방향으로 다시 탐색을 진행하는 과정
  • 넓게 탐색하기 전에 깊게 탐색
  • 모든 노드를 방문하고 싶을 때 사용한다.
  • 자기 자신을 호출하는 순환 알고리즘의 형태를 가지고 있다.
  • 어떤 노드를 방문했었는지 여부를 검사해야 한다.
  • O(V + E) V(노드의 수), E(간선의 수)

 

 

DFS 탐색 과정

https://gmlwjd9405.github.io/2018/08/14/algorithm-dfs.html#google_vignette

 

  • 시작 노드를 선택한다.
  • 현재 노드를 방문한다.
    • 방문한 노드를 방문했다고 표시한다.
  • 현재 노드의 인접 노드를 탐색한다.
    • 인접 노드가 없는 경우 탐색을 종료한다.
    • 깊이를 우선으로 탐색한다.
  • 모든 인접 노드 방문 후, 이전 노드로 되돌아간다.
    • 현재 노드의 인접 노드가 모두 방문 했거나, 방문할 노드가 없다면 이전 노드로 되돌아가며 탐색한다.
  • 탐색 종료

 

 

구현 예시 코드

struct Vertex
{
    // Data
};

vector<Vertex> vertices;
vector<vector<int>> adjacencyList;
vector<bool> visited; 

// DFS 함수 (재귀)
void DFS(int current) {
    visited[current] = true;
    cout << "Visited : " << current << endl;

    // 모든 인접 정점들을 순회
    for (int i = 0; i < adjacencyList[current].size(); i++)
    {
        int next = adjacencyList[current][i];
        if (!visited[next])
        {
            DFS(next);
        }
    }

}

// 끊겨있던 노드들 순회
void DFSAll()
{
    for (int i = 0; i < 6; i++)
    {
        if (visited[i] == false)
        {
            DFS(i);
        }
    }
}

void CreateGraph()
{
    vertices.resize(5);
    adjacencyList = vector<vector<int>>(5);
    visited = vector<bool>(5, false);

    adjacencyList[0].push_back(1);
    adjacencyList[0].push_back(3);
    adjacencyList[1].push_back(0);
    adjacencyList[1].push_back(2);
    adjacencyList[2].push_back(1);
    adjacencyList[2].push_back(3);
    adjacencyList[2].push_back(4);
    adjacencyList[3].push_back(2);
    adjacencyList[3].push_back(4);
    adjacencyList[4].push_back(2);
    adjacencyList[4].push_back(0);
}

출력 결과

 

해시(Hash)

해시는 데이터를 해시 함수(Hash Function)를 통해 고정된 크기의 값으로 변환하는 과정입니다.

  • 해시 값은 실제로는 2진수 값이지만, 개발자가 읽기 편하도록 일반적으로 16진수로 표현해서 보여준다.
  • 해시 함수(Hash Function) : 입력 데이터를 처리하여 고정된 길이의 해시 값을 생성하는 알고리즘
  • 해시 값(Hash Value) : 해시 함수에 의해 생성된 고정 길이의 숫자나 문자열. (Hash Code, Digest)
  • 데이터를 저장하거나 검색할 때 고유한 식별자로 사용할 수 있다.
  • 비가역성 : 해시값으로 원래 데이터를 복원할 수 없다.
  • Hash Table, Hash Map, Hash Set과 같은 자료구조가 있다.
  • DB에 저장할 비밀번호를 해시화 하여 사용(암호화), 데이터 무결성 검사 등에 사용된다.

 

해시 테이블(Hash Table)

해시 테이블은 키-값(Key-Value)의 한쌍을 저장하는 데이터 구조입니다.

해시 함수를 사용하여 Key를 해시 값으로 변환하고, 이 값을 기반으로 데이터를 저장하거나, 검색합니다.

https://en.wikipedia.org/wiki/Hash_table

  • 평균적으로 데이터 검색, 삽입, 삭제의 시간복잡도는 O(1)이다.
    • 최악의 경우 O(n)이 될 수 있으며, 해시 함수와 충돌 해결 기법으로 최소화 할 수 있다.
    • 충돌이 일어나지 않는다면, Map보다 Hash Map(C++ : Unordered Map)이 더 빠르다.
  • 버킷 (Bucket) : 동일한 해시 값으로 매핑된 데이터(Value)를 저장하는 공간
  • 충돌 (Collision) : 서로 다른 키가 같은 해시 값을 생성하는 경우.
    • 체이닝, 개방 주소법을 사용하여 해결할 수 있다.
  • 데이터를 배열의 특정 인덱스에 매핑하므로 대량의 데이터를 관리하는데 효율적이다.
  • 여유 공간을 추가로 할당하므로 메모리를 많이 사용하게 된다.

 

충돌 해결 방법

  • 체이닝 (Chaining)
    • 동일한 해시 값에 대해 LinkedList 또는 다른 자료구조를 사용해 데이터를 저장
    • 단순하게 구현할 수 있지만, 충돌이 많을 시 검색 성능 저하
  • 개방 주소법 (Open Addressing)
    • 충돌 발생 시 다른 빈 버킷을 탐색해 데이터를 저장
    • 선형 프로빙 (Linear Probing) : 순차적으로 다음 빈 공간을 탐색
    • 이차 프로빙 (Quadratic Probing) : 탐색 범위를 제곱 값으로 증가시킴
    • 이중 해싱 (Double Hashing) : 다른 해시 함수를 사용해 탐색

 

선형 프로빙

  • 같은 해시 h(k) 값이 나오면 빈 슬룻이 나올 때까지 해쉬테이블을 탐색한다.  "  h(k,i) = ( h'(k) + i ) mod m "
  • 삽입 : h(k)값에 빈 슬룻이 나오면 Key를 삽입
  • 탐색 : h(k)값의 위치에서 값을 찾을 때까지 하나씩 이동하면서 탐색
  • 삭제 : 실제로 값을 지우는 경우 DELETED 표기
  • 구현은 쉽지만, 값이 들어있는 slot의 수가 많으면 평균 검색 시간이 증가하며, 슬룻이 끝부분에 뭉치는 Primary Clustering 문제가 발생할 수 있다.

 

이차 프로빙

  • h( k ) = h( k ) + i² 
  • 충돌이 일어났을 때 탐색 범위를 제곱값으로 증가시켜 탐색한다.
  • Primary Clustering 문제를 줄일 수 있지만, Secondary Clustering 문제가 발생할 수있다.
  • Secondary Clustering : 처음 시작 값이 같으면 이전 key값과 동일한 위치만큼 이동하기 때문에 반복적으로 충돌이 일어나는 경우

 

이중 해싱

  • h₁(k) = k mod 13
    h₂(k) = i + ( k mod 13 )
    h(k,i) = (h₁(k) + i * h₂(k)) mod 13
  • 해시 함수를 두번 적용하여 저장위치를 찾는다.

 

힙(Heap)

완전 이진트리의 자료구조로 값의 최대 힙과 최소 힙의 유형을 가지고 있습니다.

삽입, 삭제의 시간복잡도는 O(log N)을 가지고 있습니다.

 

힙 정렬

주어진 데이터를 힙(Heap)을 사용하여 정렬하는 알고리즘입니다.

  • 최대 힙(Max Heap) : 내림차순 정렬
  • 최소 힙(Min Heap) : 오름차순 정렬

힙정렬 과정 - https://taaewoo.tistory.com/6

 

 

힙 정렬 과정

1. 리스트의 원소들을 모두 힙에 넣는다.

2. 힙의 루트 원소를 뽑아 정렬시킬 배열에 놓고 힙에서는 제거한다.

3. 2번의 과정을 재귀적으로 오름차순 또는 내림차순으로 정렬될때까지 반복한다.

 

특징 및 장단점

  • 제자리 정렬 (In-Place Sorting) : 추가적인 메모리 공간을 필요로 하지 않으며, 주어진 배열에서 바로 정렬을 수행한다.
  • 데이터를 비교하며 정렬을 수행한다.
  • 최선,평균,최악의 시간 복잡도는 O(log n)으로 일정한 시간복잡도를 보장한다.
  • 정렬하는 원소가 동일한 값일 경우, 순서를 보장하지 않는다.

 

힙정렬 구현

// Heap (완전 이진 트리 형태)
// 정렬할 배열 , 배열의 크기, 루트가 될 원소
void heapify(int arr[], int n, int i)
{
	int largest = i;
	int left = 2 * i + 1;
	int right = 2 * i + 2;

	// (right < n && arr[left] < arr[smallest]) -> 최소 힙
	if (left < n && arr[left] > arr[largest])
	{
		largest = left;
	}

	// (right < n && arr[right] < arr[smallest] -> 최소 힙
	if (right < n && arr[right] > arr[largest])
	{
		largest = right;
	}

	if (largest != i) {
		std::swap(arr[i], arr[largest]);
		heapify(arr, n, largest);
	}
}

// 힙정렬
void heapSort(int arr[], int n)
{
	for (int i = n/2 - 1; i >= 0; i--)
	{
		heapify(arr, n, i);
	}

	for (int i = n - 1; i > 0; i--)
	{
		std::swap(arr[0], arr[i]);
		heapify(arr, i, 0);
	}
}

 

HeapSort - 정렬 결과

 

 


 

병합 정렬 (Merge Sort)

  • 리스트의 원소들을 재귀적으로 반으로 나누고, 각 부분들을 정렬한 후 병합(Merge) 과정을 통해 다시 합치는 방식
    • 분할정복 알고리즘
  • 두 개의 정렬된 리스트를 합쳐서 하나의 정렬된 리스트로 만드는 과정

 

병합정렬 분할 / 정복 과정- https://anweh.tistory.com/85

 

 

특징 및 장단점

  • 분할정복 알고리즘
  • 동일한 값에대한 순서를 보장한다.
  • 리스트를 분할하고 병합하는 단계에서 병렬 처리에 유용하다.
  • 최선,최악,평균 시간복잡도 O(n lon n)을 유지한다.
  • 정렬시 추가적인 메모리 공간이 O(n)만큼 필요하다.

 

병합정렬 구현

// 병합
void merge(std::vector<int>& arr, int left, int right)
{
	if (left >= right) return;

	int mid = left + (right - left) / 2;

	merge(arr, left, mid);
	merge(arr, mid + 1, right);

	// 병합 단계
	int n1 = mid - left + 1;	// 왼쪽 크기
	int n2 = right - mid;		// 오른쪽 크기

	// 임시배열. 공간복잡도 O(n)이 추가적으로 필요한 이유
	std::vector<int> leftArr(n1), rightArr(n2);

	// 값 복사
	for (int i = 0; i < n1; i++)
	{
		leftArr[i] = arr[left + i];
	}

	for (int i = 0; i < n2; i++)
	{
		rightArr[i] = arr[mid + 1 + i];
	}

	// 두 배열 병합
	int i = 0, j = 0, k = left;
	while (i < n1 && j < n2)
	{
		if (leftArr[i] <= rightArr[j])
		{
			arr[k] = leftArr[i];
			i++;
		}
		else
		{
			arr[k] = rightArr[j];
			j++;
		}
		k++;
	}

	// 왼쪽 배열의 나머지 항목 복사
	while (i < n1) 
	{
		arr[k] = leftArr[i];
		i++;
		k++;
	}

	// 오른쪽 배열의 나머지 항목 복사
	while (j < n2)
	{
		arr[k] = rightArr[j];
		j++;
		k++;
	}
}

// 병합정렬 (분할)
void mergeSort(std::vector<int>& arr, int left, int right) {
	if (left < right)
	{
		int mid = left + (right - left) / 2;	// 중간값 계산

		mergeSort(arr, left, mid);				// 왼쪽 절반
		mergeSort(arr, mid + 1, right);			// 나머지 오른쪽 절반

		merge(arr, left, right);				// 마지막 병합
	}
}

MergeSort - 정렬 결과

 

레드블랙트리(Red Black Tree)는 자기 균형 이진 탐색 트리 구조로, 삽입과 삭제 연산 이후 레드/블랙의 색상을 사용하여 균형잡힌 정렬을 하는 이진 탐색 트리(BST)입니다.

 

RBT - https://ko.wikipedia.org/wiki/%EB%A0%88%EB%93%9C-%EB%B8%94%EB%9E%99_%ED%8A%B8%EB%A6%AC

 

속성

  • 모든 노드는 빨간색 또는 검은색을 가지고 있다.
  • 루트 노드는 항상 검은색 이다.
  • Leaf(NIL) 노드는 항상 검은색 이다.
  • 빨강 노드의 자식은 모드 검정색이다.
  • 임의의 노드에서 모든 리프노드에 이르는 경로에는 동일한 수의 검정 노드가 필요하다.

※NIL : 아무 데이터도 가지고 있지않는 더미 노드. 

특징

  • 이진 탐색 트리(BST)의 한 종류
  • BST의 최악의 경우 O(N)의 시간 복잡도를 개선해서 모든 경우의 시간복잡도 O(log N)을 유지
  • 항상 균형을 유지하여 특정 케이스에 치우친 성능 저하가 없다.
    • 삽입,삭제 시 회전 연산을 통해 균형을 유지한다.
  • Map, Set 자료구조는 RBT를 기반으로 구현되었다.

 

회전연산

삽입,삭제,탐색시 시간복잡도가 항상 O(log N)을 유지하기위해서 항상 균형잡힌 상태를 유지합니다.

해당 과정에서는 RBT의 속성을 유지하며 트리 구조를 조정하는데에는 왼쪽회전, 오른쪽 회전의 방식이 있습니다.

 

삽입

  • 새 노드는 처음에 빨간색으로 삽입된다.
  • 삽입 후, 트리는 RB-T 속성을 위반할 수 있으므로, 이를 수정하기 위해 재조정 및 색상 변경이 수행된다.
    • 삽입된 노드의 부모가 검정이면 트리 속성이 유지되므로 조정하지 않는다.
    • 삽입된 노드의 부모와 삼촌이 둘다 빨강일 경우
      • 부모 삼촌은 검정으로 변경
      • 조부모를 빨강으로 변경
      • 재귀적 처리를 통해 색상 조정
    • 부모가 빨강이고 삼촌이 검정일 경우
      • 회전연산을 통해 색상이 조정된다.
  • 노드들은 필요에 따라 색상이 변경될 수 있으며, 회전연산을 통해 균형을 유지한다.
  • BST와같이 재귀적으로 노드를 호출하며 조정된다.

 

삭제

  • 삭제 노드가 레드 노드일 경우
    • 삭제 후 조정 처리가 필요없다
  • 삭제 노드가 블랙 노드인 경우
    • 삭제 노드의 자리가 RBT속성을 위반하므로 추가적인 작업을 수행한다.
    • 형제가 레드일 경우
      • 부모-형제 색상 교환
      • 부모를 기준으로 회전연산
    • 형제가 블랙, 형제의 자식이 모두 블랙
      • 형제를 레드로 변경
      • 부모노드를 검정으로 변경 후 형제가 레드일 경우의 단계를 처러리
    • 형제가 블랙, 형제의 자식중 하나가 레드
      • 형제와 형제의 자식(레드)의 색상을 교환
      • 자식(레드)가 왼쪽일 경우 형제노드를 기준으로 오른쪽 회전
        • ---- 형제의 자식(레드)가 오른쪽일 경우 다음 단계부터 수행 ----
      • 부모와 형제 노드의 색상을 교환
      • 부모를 기준으로 왼쪽 회전
      • 형제노드의 오른쪽 자식을 블랙으로 변경

 

회전연산 - 왼쪽 회전

Left Rotation - https://aerimforest.tistory.com/239

 

	// Left Rotation
    void leftRotate(Node* node) {
        Node* rightNode = node->right; // 노드의 오른쪽 자식을 가져옴
        if (rightNode == nullptr) return; // 오른쪽 자식이 없으면 회전 불가

        node->right = rightNode->left; // 노드자식(오른쪽)의 왼쪽 서브트리를 노드의 오른쪽에 연결
        if (rightNode->left != nullptr) {
            rightNode->left->parent = node;
        }

        rightNode->parent = node->parent; // 노드의 부모를 노드자식(오른쪽)의 부모로 설정
        if (node->parent == nullptr) {
            root = rightNode; // 노드가 루트라면 노드자식(오른쪽)가 새 루트가 됨
        }
        else if (node == node->parent->left) {
            node->parent->left = rightNode;
        }
        else {
            node->parent->right = rightNode;
        }

        rightNode->left = node; // 노드를 노드자식(오른쪽)의 왼쪽 자식으로 설정
        node->parent = rightNode;
    }

 

 

회전연산 - 오른쪽 회전

Right Rotation - https://aerimforest.tistory.com/239



    //Right Rotation
    void rightRotate(Node* node) {
        Node* leftNode = node->left; // 노드의 왼쪽 자식을 가져옴
        if (leftNode == nullptr) return; // 왼쪽 자식이 없으면 회전 불가

        node->left = leftNode->right; // 노드 자식(왼쪽)의 오른쪽 서브트리를 노드의 왼쪽에 연결
        if (leftNode->right != nullptr) {
            leftNode->right->parent = node;
        }

        leftNode->parent = node->parent; // 노드의 부모를 노드 자식(왼쪽)의 부모로 설정
        if (node->parent == nullptr) {
            root = leftNode; // 노드가 루트라면 노드 자식(왼쪽)가 새 루트가 됨
        }
        else if (node == node->parent->left) {
            node->parent->left = leftNode;
        }
        else {
            node->parent->right = leftNode;
        }

        leftNode->right = node; // 노드를 노드 자식(왼쪽)의 오른쪽 자식으로 설정
        node->parent = leftNode;
    }

이진탐색트리 (Binary Search Tree)

BST - https://noguen.com/54

이진탐색트리는 이진탐색의 특징인 정렬된 데이터를 기반으로 빠른 탐색 을 장점으로 가지고 있는 트리구조입니다.

  • 각 노드는 2개의 자식을 가질 수 있다.
  • 왼쪽 서브트리에는 항상 해당 노드보다 작은 값을 가지고 있다.
  • 오른쪽 서브트리에는 항상 해당 노드보다 큰 값을 가지고 있다.
  • 중복된 값을 허용하지 않는다.
  • 조회 시 평균적으로 O(log N)의 시간 복잡도를 가질 수 있다.
    • 정렬이 균형잡혀있지 않은 경우(최악) O(N)의 시간복잡도를 가지고 있다. 
  • C++ : set, multiset, map, multimap 등이 내부적으로 이진 탐색중 하나인 R-BT로 구현되어있다.

 

탐색 수행 과정

  1. 데이터의 중앙 요소를 고른다.
  2. 중앙값과 목표값을 비교한다.
  3. 목표값 < 중앙값이면 중앙을 기준으로 왼쪽, 목표값 > 중앙값이면 중앙을 기준으로 오른쪽으로 이진 탐색을 수행한다.
  4. 찾는 값이 나올때까지 1~3단계를 반복한다.
  5. 찾는값이 나오지 않는다면 null을 반환한다.

 

삽입 수행 과정

  • 루트 노드의 값과 삽입 노드의 값을 비교한다.
  • 삽입값 < 루트값이면 왼쪽, 삽입값 > 루트값이면 오른쪽 서브트리로 이동한다.
  • 리프 노드에 도달한 후 삽입값과 리프 노드값을 비교하여 왼쪽 또는 오른쪽에 삽입한다.

 

 

삭제 수행 과정

  1. 삭제할 노드가 리프노드일 경우
    1. 해당 리프노드를 삭제한다.
  2. 삭제할 노드에 자식이 하나 있는 경우
    1. 해당 노드를 삭제한다.
    2. 해당 노드의 부모 노드와 자식 노드를 연결한다.
  3. 삭제할 노드에 자식이 둘 있는 경우
    1. 삭제할 노드를 찾는다.
    2. 삭제할 노드의 successor 노드를 찾는다
      1. succesor : 오른쪽 서브트리의 최소값.
    3. 삭제할 노드와 successor 노드의 값을 바꾼다.
    4. 바뀐 successor 노드를 삭제한다.

 

구현

BinarySearchTree.h

struct Node {
	int data;
	Node* left;
	Node* right;

	Node(int value) : data(value), left(nullptr), right(nullptr) {}
};
class BinarySearchTree
{
public:
	BinarySearchTree() : root(nullptr) {}

	void insert(int value);
	bool search(int value);
	void remove(int value);
	void inorderTraversal() const;
	int inorderTraversal(int value) const;

private:
	Node* root;

	Node* insertNode(Node* node, int value);
	bool searchNode(Node* node, int value);
	Node* removeNode(Node* node, int value);
	Node* findMin(Node* node);
	void inorder(Node* node) const;
	int inorder(Node* node, int value) const;

};

 

 

BinarySearchTree.cpp

void BinarySearchTree::insert(int value)
{
	root = insertNode(root, value);
}

bool BinarySearchTree::search(int value)
{
	return searchNode(root, value);
}

void BinarySearchTree::remove(int value)
{
	root = removeNode(root, value);
}

void BinarySearchTree::inorderTraversal() const
{
	inorder(root);
}

int BinarySearchTree::inorderTraversal(int value) const
{
	return inorder(root, value);
}

Node* BinarySearchTree::insertNode(Node* node, int value)
{
	if (node == nullptr) {
		return new Node(value);
	}

	if (value < node->data){
		node->left = insertNode(node->left, value);
	}
	else if (value > node->data) {
		node->right = insertNode(node->right, value);
	}


	return node;
}

bool BinarySearchTree::searchNode(Node* node, int value)
{
	if (node == nullptr){
		return false;
	}
	if (value == node->data){
		return true;
	}
	if (value < node->data){
		return searchNode(node->left, value);
	}
	else {
		return searchNode(node->right, value);
	}
}

Node* BinarySearchTree::removeNode(Node* node, int value)
{
	if (node == nullptr) {
		return nullptr;
	}


	if (value < node->data) {
		node->left = removeNode(node->left, value);
	}
	else if (value > node->data) {
		node->right = removeNode(node->right, value);
	}
	else {
		if (node->left == nullptr) {
			Node* temp = node->right;
			delete node;
			return temp;
		}
		else if (node->right == nullptr) {
			Node* temp = node->left;
			delete node;
			return temp;
		}

		Node* minNode = findMin(node->right);
		node->data = minNode->data;
		node->right = removeNode(node->right, minNode->data);
	}

	return node;
}

Node* BinarySearchTree::findMin(Node* node)
{
	while (node->left != nullptr) {
		node = node->left;
	}
	return node;
}

void BinarySearchTree::inorder(Node* node) const
{
	if (node == nullptr) {
		return;
	}
	inorder(node->left);
	std::cout << node->data << " ";
	inorder(node->right);
}

int BinarySearchTree::inorder(Node* node, int value) const
{
	if (node == nullptr) {
		std::cout << "Node Data : Can't Found." << std::endl;
		return NULL;
	}
	if (value < node->data) {
		inorder(node->left, value);
	}
	else if (value > node->data) {
		inorder(node->right, value);
	}

	if (value == node->data) {
		std::cout << "Node Data : " << node->data << std::endl;
	}
	return node->data;
}

트리 (Tree)

트리(Tree)는 계층적 관계를 나타내는 자료구조로 노드(Node)와 간선(Edge)로 구성되어잇으며 가장 상위 노드인 루트(Root)가있다. 각 노드는 다른 노드와 연결되어 부모.자식 관계를 형성하며 노드들은 자식 노드를 가질 수 있습니다.

  • 노드(Node) : 트리의 각 요소. 데이터를 표현하는데 사용한다.
  • 간선(Edge) : 두 노드를 연결, 관계를 나타낸다.
  • 루트(Root) : 트리의 최상위 노드. 부모가 없는 노드
  • 부모(Parent) : 다른 노드를 자식으로 가지는 노드
  • 자식(Child) : 부모 노드에 의해 연결되는 노드
  • 리프(Leaf) : 자식 노드가 없는 노드

 

특징 및 종류

  • 트리는 계층적으로 구성되어 있으며, 루트노드를 시작으로 여러 자식 노드들이 존재 할 수 있다.
  • 노드는 N개인 트리는 항상 N-1개의 간선(edge)을 가진다.
  • 두 노드간의 경로는 유일하다.
    • 두 개의 정점 사이에 반드시 1개의 경로만을 가진다.
  • 트리는 이진트리, 이진탐색트리(BST), 균형트리(AVL, RBT), 이진 힙(최대힙, 최소힙) 등이 있다.

 

이진트리(Binary Tree)

  • 각 노드가 최대 두개의 자식을 갖는 트리
  • 모든 트리가 이진 트리는 아니다
  • 기본적인 트리 구조로, 이진 검색트리, 힙(Heap) 트리 등에 사용된다.

 

이진탐색트리 (Binary Search Tree)

이진 탐색 트리 - https://dream-and-develop.tistory.com/146?pidx=0

  • 이진 트리의 형태, 각 노드는 키 값이 왼쪽 서브 트리의 모든 값보다 크고, 오른쪽 서브트리의 모든 값보다 작다.
  • 삽입, 삭제, 검색 등이 평균적으로 O(log n)의 시간 복잡도를 가지고 있다.
  • 트리가 불균형시 최악의 경우 O(n)의 시간복잡도를 가진다.
  • AVL Tree : 모든 노드에서 왼쪽 서브트리와 오른쪽 서브트리의 높이차이가 1인 이진 탐색 트리
    • 삽입,삭제시 회전(Rotate)하며 트리구조의 균형을 유지한다.
  • RB Tree : 각 노드에 색깔(Red,Black)을 부여하여 균형을 맞추는 이진 탐색 트리
    • AVL과 같이 노드간의 균형을 유지하지만, 더 적은 회전을 하며 정렬한다.

 

힙(Heap)

  • 각 부모 노드의 값이 자식 노드의 값보다 우선순위가 높은 구조
  • 최대 힙과 최소 힙이 있다.
  • 최대 힙 : 부모 노드의 값이 자식 노드의 값보다 크거나 같다.
  • 최소 힙 : 부모 노드의 값이 자식 노드의 값보다 작거나 같다.
  • 완전 이진 트리 형태를 가지며, 삽입,삭제 시 O(log n)의 시간복잡도를 가진다.

 

그래프 VS 트리

https://medium.com/@bnnj86/data-structure-tree-binary-tree-binary-searching-tree-d9daa3f69e67

 

그래프 (Graph)

그래프는 정점(Vertex)과 간선(Edge)로 이루어진 자료구조로, 정점간의 연결관계를 나타냅니다.

  • 정점(Vertex) : 데이터를 표현하는데 사용한다.
  • 간선(Edge) : 두 정점을 연결하며, 관계를 나타낸다.

 

특징 및 종류

  • 네트워크 모델
  • 2개 이상의 경로가 가능하다.
  • 부모-자식 관계 / 루트 노드 개념이 없다
  • 순환 / 비순환  그래프
    • 순환(Cycle) : 동일한 정점에서 시작하여 다시 그 정점으로 돌아오는 경로. 순환이 있는 그래프
    • 비순환(Acyclic) : 그래프 내에 순환이 없는 그래프
  • 방향 / 무방향 그래프
    • 방향 그래프 (Directed Graph) : 간선이 방향성을 가지고 있는 그래프
    • 무방향 그래프 (Undirected Graph) : 간선간의 방향이 없는 그래프로 서로 모두 이용 가능한 상태
  • 가중치 그래프
    • 가중치 (Weighted Graph) : 간선에 비용이나 가중치가 할당된 그래프
  • 연결 / 비연결 그래프
    • 연결 (Connected Graph) : 무방향 그래프에 있는 모든 정점에 대해서 경로가 존재하는 그래프
    • 비연결 (DisConnected Graph) : 무방향 그래프에서 특정 정점 사이에 경로가 존재하지 않는 경우
  • 완전 그래프
    • 완전(Complete Graph) : 그래프에 속해있는 모든 정점이 서로 연결되어 있는 그래프

 

 

행렬 / 리스트 표현 방식 - https://howtolivelikehuman.tistory.com/75

인접 리스트 

  • 각 정점마다 연결된 정점들을 리스트로 저장하는 방식
  • 메모리 사용량 O(V + E)이며, 인접 행렬보다 메모리 사용이 효율적이다.
  • 그래프 내의 적은 숫자의 간선을 가지는 그래프(희소 그래프)인 경우에 사용하기 적합하다.
  • 인접 노드를 빠르게 알 수 있지만, 연결 여부확인은 모든 노드를 탐색해야한다.

 

enum Vertex { A, B, C, D, E, F };

class Graph
{
private:
	int vertices;								             
	std::vector<std::vector<Vertex>> adjList;
public:
	void addEdge(Vertex u, Vertex v)
	{
		adjList[u].push_back(v);
		adjList[v].push_back(u);
	}
};

int main() {
	graph.addEdge(A, B);
	graph.addEdge(B, C);
	graph.addEdge(B, D);
	graph.addEdge(C, E);
	graph.addEdge(D, F);
}

 

 

인접 행렬

  • 정점간의 연결 여부를 2D 행렬(2차원 배열)로 표현하는 방식
  • 메모리 사용량은 O(V^2)이며, 인접 리스트보다 상대적으로 메모리를 더 많이 사용한다.
  • 노드간의 연결 정보를 바로 알 수 있으며, 구현이 쉽다 (탐색 속도 O(1))
  • 간선의 연결 여부는 빠르게 알 수 있지만, 어떤 노드에 인접한 노드를 찾기 위해서는 모든 노드를 순회해야한다.
  • 간선이 많이 존재하는 그래프(밀집 그래프) 형태에 사용하기 적합하다.
class arrGraph
{
private:
	int vertices;
	std::vector<std::vector<int>> adjMatrix;

public:
	void addEdge(int u, int v)
	{
		adjMatrix[u][v] = 1;
		adjMatrix[v][u] = 1;
	}
};

int main() {
	graph.addEdge(0, 1);
	graph.addEdge(1, 2);
	graph.addEdge(1, 3);
	graph.addEdge(2, 4);
	graph.addEdge(3, 4);
	graph.addEdge(3, 5);
  
	return 0;
}

개요

개발자로써 취업을 하는 과정에서 다양한 회사들이 코딩테스트를 보는 경우가 많습니다.

코딩테스트는 알고리즘과 자료구조에 많이 관련이 되있는데, 취업시의 코딩 테스트를 제외하고서도

시간 복잡도를 이해해야 하는 이유는 알고리즘을 빠르게 분석 및 상황에 맞춰 써야 할 알고리즘을 파악, 자신의 코드를 평가 할 수 있기 때문에 시간 복잡도를 이해를 해야합니다.

 

요약하자면, 효율적인 코딩을 하려면 자료구조와 알고리즘을 필수적으로 잘 다뤄야 한다고 볼 수 있습니다.

그러기 위해서 알고리즘의 최적화된 사용법을 알아야하는데, 그중 시간복잡도와 공간복잡도에 대해 알아보려고 합니다.

 

1. 시간 복잡도

입력 크기에 대해 어떠한 알고리즘이 실행되는데 걸리는 시간이며, 주요 로직의 반복횟수를 중점으로 측정된다.

 

시간복잡도는 특정한 크기의 입력에 대하여 알고리즘이 얼마나 오래 걸리는지를 의미하며, 알고리즘을 위해 필요한

연산의 횟수 또한 의미합니다.

 

※ 시간 복잡도의 빠르다, 느리다 라는 시간적인 의미를 두지 않는 이유는 각각의 하드웨어마다 차이가 있는점과

같은 하드웨어 더라도 사용중인 메모리의 상태에 따라 시간값이 달라지기 때문입니다.

 

 

2. 공간 복잡도

입력크기에 대해 어떠한 알고리즘이 실행되는데 필요한 메모리의 양을 의미합니다.

예시로는 변수를 선언 또는 요소들을 담을 공간(자료구조)들이 전부 메모리에 들어가는데, 이것을 공간 복잡도라고 할 수 있습니다.

 

 

3. 시간 복잡도와 공간 복잡도의 연관 관계

시간 복잡도는 알고리즘에서 입력 크기에 따라 알고리즘의 실행시 걸리는 연산의 횟수를 의미하며,

공간 복잡도는 입력 크기에 대해 알고리즘의 실행시 필요한 메모리 양을 의미합니다.

 

일반적으로 메모리 연산 횟수를 줄이기 위해(시간 복잡도)서 더 많은 메모리 공간(공간 복잡도)을 사용 하거나,

반대로 메모리 사용을 최소화 하기위해 메모리 연산이 증가하는 경향이 있습니다.

 

두 복잡도는 알고리즘의 다양한 요인에 의해 영향을 받을 수있으며, 한 측면이 향상되면 한 측면이 저하될 수 있습니다.

이것을 트레이드 오프 (Trade-off)관계라고 합니다.

 

결론적으로, 시간 복잡도와 공간 복잡도는 알고리즘의 성능에 대해 각각 연산횟수, 메모리 사용량을 나타낼 수 있으며

상황에 따라 시간 복잡도와 공간 복잡도의 우선도를 고려하거나, 시간 복잡도와 공간 복잡도를 동시에 고려 해야합니다.

 

 

4. Big O 표기법

빅오(Big-O) 표기법은 시공간 복잡도를 수학적으로 표시하는 표기법중 가장 대표적인 방법입니다.

빅오 표기법은 최악의 경우를 고려하므로, 프로그램이 실행되는 과정에서 소요되는 최악의 시간을 고려할 수 있습니다.

표기법의 방식은 가장 빠르게 증가하는 항만을 고려하는 방식이며, 인풋 데이터 증가율에 따른 알고리즘 성능을

논리적으로 예측하기 위해 사용합니다.

  • 가장 빠르게 증가하는 항만을 고려한다. 즉, 가장 높은 차수만 남기는 방식을 취한다.
  • 빅오는 점근선의 상한 범위를 찾는 것이다.
  • 작은 N이나 상수 인자는 고려하지 않는다.

 

 

  • 빅오  표기법에서 n이란 입력되는 데이터 (Data Input)을 나타냅니다.

시간 복잡도 성능 비교에 따라 

O(1) < O(㏒ N) < O(N) < O(N ㏒ N) < O(N²) < O(2ⁿ)

상수 함수 < 로그 함수 < 선형 함수 < 다향 함수 < 지수 함수 

순이며, 오른쪽으로 갈수록 효율이 떨어집니다.

 

Big-O 표기법의 종류와 예제

 

1. O(1) - 상수

 

입력 데이터의 크기에 상관없이 동일한 수의 연산이 필요하며 즉시 출력값을 얻어낼 수 있는것을 의미합니다.

대표적으로는 자료구조 Stack의 Push, Pop이 있습니다.

Stack, Queue

해당 이미지는 각각 Stack과 Queue의 내부 구조입니다.

Stack을 예시로 할 경우 pop함수를 사용하여 저장해둔 값을 꺼내오는데, 이때 pop의 특성은 Stack의 가장 마지막에 들어온 데이터를리턴하며, 삭제하는 구조로 가장 최상위 데이터 하나를 제외한 다른 탐색은 하지않고 즉시 출력값을 얻어낼 수 있습니다.

 

 

2. O(log N) - 로그

 

입력 데이터의 크기가 커질수록 처리 시간이 log만큼 짧아지는 경우에 log 시간을 표현합니다. 대표적인 예시로 BST(Binary Search Tree)가 있으며, 탐색 범위를 절반씩 좁혀가며 데이터를 탐색하기때문에 데이터 개수가 절반씩 줄어든 다는 점, 2배로 늘어나도 나뉘는 수가 1개 더 늘어날 뿐인 점에서 O(log N)이라 표현할 수 있습니다.

이진탐색트리 (BST)

해당 이미지는 이진탐색트리의 예시이며, 탐색하려는 값을 왼쪽에 있는 Branch 부터 비교, 탐색하는 형태로 이루어져 있는 자료구조입니다. BST는 하나의 branch로 치우쳐진 경우 O(n)을 될 수 있지만, 일반적으로는 트리의 높이 h에 따른 최대 노드의 수는 2^h - 1의 개수를 가질 수 있으며, 이로인해 O(log N)이라 표현할 수 있습니다.

 

3. O(N) - 선형

 

입력값이 증가함에 따라 시간또한 비례해서 증가합니다. 

대표적인 예시로 조건의 값이 참이 될때까지 증감연산을 하는 for문이 있습니다.

 

4. O(N log N) - 선형 로그

 

입력 데이터가 많아질수록 처리 시간이 로그 배만큼 늘어납니다.

대표적인 예시로 퀵 정렬(Quick Sort), 병합정렬(Merge Sort), 힙 정렬(Heap Sort)가 있습니다.

병합정렬의 예시

병합정렬 시간복잡도 예시

 

병합정렬 (Merge Sort)의 경우 List,Array 등의 저장된 값을 절반씩 나누어 번호순, 사전순과 같이 정해진 순서대로 정렬하는 알고리즘입니다.

정렬된 두 집합을 하나의 정렬된 집합으로 합치는데에 필요한 시간복잡도가 O( 두 집합의 원수의 개수),

그리고 분할 정복을 이용하여 병합정렬을 구현하면 총 시간복잡도가 O(N log N)입니다.

 

 

 

5. O(N²) - 다항

 

2차 복잡도 라고 부르기도하며, 입력값이 증가함에 따라 시간이 n의 제곱수의 비례하여 증가하는 것을 의미합니다.

대표적인 예시로는 루프 안에서의 루프를 실행하는 이중 for 문이 있습니다.

 

6. O(2ⁿ) - 지수

 

기하급수적 복잡도 라고 불리기도하며, Big-O 표기법중 가장 느린 시간 복잡도를 가지고 있습니다.

데이터의 양이 많아질수록 처리시간이 기하급수적으로 증가합니다.

대표적인 예시로는 피보나치 수열이 있습니다.

 


정리

 

시간 복잡도는 메모리가 실행되는데 걸리는 시간이 아닌, 메모리의 연산 횟수를 의미한다.

공간 복잡도는 메모리의 사용량을 의미한다.

시간 복잡도와 공간 복잡도는 서로 반비례 형태로 이루어져 있으며,알고리즘의 성능을 최적화 하기위해

두 복잡도를 고려해야한다.

복잡도의 표기법에는 대표적으로 최악의 상황을 고려하는 빅오 (Big-O)표기법이 있으며 

사용하는 데이터의 개수와 소모되는 시간에 따라 미분, 적분형식의 그래프 형태를 나타내기도한다.

 


 

 

출처 및 참고 자료

+ Recent posts