[C++] 비선형 자료구조

seunghyun·2023년 3월 1일
0

선형 자료구조는 자료를 순차적으로 나열한 형태이고, 많이 사용된다.

  • 배열
  • 연결 리스트
  • 스택/큐

이와 반대로 비선형 자료구조는 하나의 자료 뒤에 다수의 자료가 올 수 있는 형태이다. 예를 들어 파일 탐색, 길 찾기 등은 일대일관계가 아니므로 비선형 자료구조를 활용해야 한다.

  • 트리
  • 그래프

이번 포스트에서는 트리, 우선순위 큐, 그래프에 대해 살펴본다.


트리

  • 계층적 구조를 갖는 데이터를 표현하기 위한 자료구조

  • 노드 : 데이터를 표현

  • 간선 : 노드의 계층 구조를 표현하기 위해 사용

노드, 간선은 그래프에서도 활용되는 개념이다. 연결리스트는 앞뒤로만 연결할 수 있기 때문에 이렇게 트리처럼 여러 항목을 동시에 가리키지는 못한다!

트리는 양파같다. 까도까도 나오기 때문! 그래서 재귀함수와 잘 어울린다.

우선순위 큐, 탐색 용도의 레드블랙 트리로 활용되므로 아래처럼 기초적인 것은 잘 쓰이지 않는다.

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

class Node
{
public:
	Node(const char* data) : data() {}
public:
	const char* data;
    vector<Node*> children;
};

Node* CreateTree()
{
	Node* root = new Node("우리 동아리");
    {
    	Node node = new Node("컴퓨터공학부");
        root->childrem.push_back(node);
        {
        	Node* leaf = new Node("모바일");
            node->children.push_back(leaf);
        }
        {
        	Node* leaf = new Node("백엔드");
            node->children.push_back(leaf);
        }
        {
        	Node* leaf = new Node("프론트엔드");
            node->children.push_back(leaf);
        }
    }
    {
    	Node node = new Node("디자인학부");
        root->childrem.push_back(node);
        {
        	Node* leaf = new Node("2D 일러스트");
            node->children.push_back(leaf);
        }
        {
        	Node* leaf = new Node("3D 아트");
            node->children.push_back(leaf);
        }
        {
        	Node* leaf = new Node("영상");
            node->children.push_back(leaf);
        }
    }
    {
    	Node node = new Node("기타학부");
        root->childrem.push_back(node);
        {
        	Node* leaf = new Node("기획");
            node->children.push_back(leaf);
        }
        {
        	Node* leaf = new Node("회계");
            node->children.push_back(leaf);
        }
    }
    
    return root;
}

// 깊이 (depth) : 루트에서 어떤 노드에 도달하기 위해 거쳐야 하는 간선의 개수
void PrintTree(Node* root, int depth = 0)
{
	for (int i=0; i<depth; i++)
    	cout << "-";

	cout << root->data << endl;
    int size = root->children.size();
    for (int i=0; i<size; i++) // 간접적인 기저사항 !!!
    {
    	Node* node = root->children[i];
        PrintTree(node, depth + 1); // 재귀 함수 !!!
    }
}

// 높이
int GetHeight(Node* root)
{
	int ret = 1;
    int size = root->children.size();
    for (int i=0; i<size; i++)
    {
    	Node* node = root->children[i];
        int h = GetHeight(node) + 1;
        
        if (ret < h)
        	ret = h;
    }
    
    return ret;
}

int main()
{
	Node* root = CreateTree();
    
    PrintTree(root);

	int h = GetHeight(root);
}

우선순위 큐

시간복잡도는 아래 코드 참고 ⭐️

왜 필요한가?

  • 가장 좋은 케이스 하나 (최솟값 혹은 최댓값) 를 추출해야할 때 유용하다.

이진 트리

  • 각 노드가 최대 두 개의 자식 노드를 가지는 트리

이진 검색 트리

  • 왼쪽으로 가면 현재값보다 작고,
  • 오른쪽으로 가면 현재값보다 크다.
  • 검색이 빠를 수 있다는 이점이 있다.

이진 검색 트리의 문제 ✔️

  • 그냥 막~ 추가하면, 한쪽으로 기울어져서 균형이 깨지고

    • 연결리스트와 다를 것이 없어져서
    • 탐색이 O(N)이 될 수 있다.
  • 트리 재배치를 통해 균형을 유지하는 것이 과제이다. (AVL, Red-Black)

힙 트리 ⭐️

  • [1 법칙]

    • 부모 노드가 가진 값은 항상 자식 노드가 가진 값보다 크다.
    • 왼쪽, 오른쪽 값 중 어떤 값이 더 커야 하는지는 정해져있지 않다.
  • [2 법칙]

    • 노드 개수를 알면, 트리 구조는 무조건 확정할 수 있다.

힙 트리 구조

  • 마지막 레벨을 제외한 모든 레벨에 노드가 꽉 차 있다. (완전 이진 트리)

  • 마지막 레벨에 노드가 있을 때는, 항상 왼쪽부터 순서대로 채워야 한다.

힙 트리 구현

  • 배열(vector) 을 이용해서 힙 구조를 바로 표현할 수 있다.
    1) i 번 노드의 왼쪽 자식은 [(2*i) + 1]번
    2) i 번 노드의 오른쪽 자식은 [(2*i) + 2]번
    3) i 번 노드의 부모는 [(i-1) / 2]번

    이미지 출처

힙 트리에서 임의의 값을 추가하기

  • [2 법칙] 으로 일단 마지막에 추가

  • [1 법칙] 으로 도장깨기 (쿠데타 or 실패)

힙 트리에서 최대값 꺼내기

  • 힙 트리 특성상 최대값은 무조건 루트 노드에 있는 값이다

  • 최댓값 (루트노드) 을 먼저 제거한다

  • [2 법칙] 으로, 제일 마지막에 위치한 데이터를 루트로 옮긴다

  • 역 도장 깨기를 시작한다

우선순위 큐를 구현한 아래 코드를 보며 자세히 살펴보자!

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

template<typename T, typename Predicate = less<T>>
class PriorityQueue
{
public:
	// O(logN) 트리의 높이에 의존적이기 때문이다
    /* 삽입, 재배치 */
	void push(const T& data)
    {
    	// 우선 힙 구조부터 맞춰준다
        _heap.push_back(data);
        
        // 도장깨기 시작
        int now = static_cast<int>(_heap.size()) - 1; // 마지막 인덱스
        
        // 루트 노드까지
        while (now > 0)
        {
        	// 부모 노드와 비교해서 더 작으면 패배
        	int next = (now - 1) / 2; // next 는 now 의 부모 노드
            // if (_heap[now] < _heap[next])
            // break;
            if (_predicate(_heap[now], _heap[next]))
            	break;
                
            // 데이터 교체
            ::swap(_heap[now], _heap[next]);
            // 현재 노드의 인덱스를 부모 노드의 인덱스로 업데이트
            now = next;
        }
    }
    
    // O(logN) 트리의 높이에 의존적이기 때문이다
    /* 최댓값 추출, 그 다음 최댓값을 [0]에 놓기 위해 재배치 */
    void pop()
    {
    	_heap[0] = _heap.back(); // 마지막 데이터를 맨 앞에 넣어주고
        _heap.pop_back(); // 마지막을 제거
        
        int now = 0; // 꼭대기부터 내려온다
        
        while(true)
        {
        	int left = 2 * now + 1;
            int right = 2 * now + 2;
            
            // 경우 1 : 리프에 도달한 경우.. 도망쳐!
            if (left >= (int)_heap.size())
            	break;
                
            int next = now;
            
            // 경우 2 : 왼쪽 비교
            if (_heap[next] < _heap[left])
            	next = left;
                
            // 경우 3 : 둘 중 숫자를 오른쪽과 비교
            // if (right < _heap.size() && _heap[next] < _heap[right])
            if (right < _heap.size() && _predicate(_heap[next], _heap[right]))
            	next = right;
                
            // 경우 4 : 왼쪽, 오른쪽 둘 다 현재 값보다 작으면 종료
            if (next == now)
				break;
            
            ::swap(_heap[now], _heap[next]);
            now = next;
        }
    }
    
    // O(1)
    T& top()
    {
    	return _heap[0];
    }

	// O(1)
	bool empty()
    {
    	return _heap.empty();
    }
    
private:
	vector<T> _heap;
    Predicate _pred; // 판별해주는 것 자체를 하나의 객체로 만들어준다
};

int main()
{
	vector<int> v;
    // less 이면 큰 값이 먼저 튀어나오고, greater 로 해주면 작은 값이 먼저 튀어나와요 (방향성)
    PriorityQueue<int, vector<int>, less<int>> pq; 
    
    pq.push(10);
    pq.push(40);
    pq.push(30);
    pq.push(50);
    pq.push(20);
    
    int value = pq.top();
    pq.pop();
}

now = next 를 왜 해주는 것일까?

  • 힙은 각 노드의 값이 자식 노드보다 작은 최소 힙(Min Heap) 또는 각 노드의 값이 자식 노드보다 큰 최대 힙(Max Heap)으로 구성된다. 따라서, 새로운 요소를 추가한 후에도 힙의 특성을 유지하기 위해 부모 노드와의 비교 및 교환 작업이 필요하다.

위 코드에서 break 를 하게 되면 어떤 반복문을 빠져나오는 것일까?

  • break 문은 현재 실행 중인 가장 가까운 반복문을 빠져나오게 된다.
    위 코드에서는 while 반복문을 빠져나온다.

거꾸로 하고 싶다면?

  • predicate 또는 - 로 음수 처리

그래프

트리가 그래프의 일종이다.
그래프는 트리와 달리 딱히 계층 구조는 없고 수평적인 관계이다.

그래프란 현실 세계의 사물이나 추상적인 개념 간의 연결 관계를 표현한다.

  • 정점: 데이터를 표현 (사물, 개념 등)
  • 간선: 정점들을 연결하는데 사용

가중치 그래프 (weighted graph)

  • 간선에 수치를 줘서 비용이나 가치를 부여할 수 있다.
  • 단방향, 양방향 모두 가능하다.

게임에서는 그래프를 언제 이용할까?

  • 맵에서 길찾기로 제일 많이 쓰인다

아래처럼 만들면 직관적이지만 탐색 속도가 느리고 복잡해서 잘 쓰이지 않는다.

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

void CreateGraph_1()
{
	struct Vertex
    {
    	vector<Vector> edges; // 다른 Vertex 객체들의 주소를 저장하겠다
	};
    
    // vector<int> v2(10, -1); // 10개의 원소를 모두 -1로 초기화하겠다
    // vector<int> v3{1,2,3,4,5,6,7}; // 또다른 초기화 방법
    
    vector<Vertex> v(6); // 아래 resize 와 아주 똑같은 의미이다
    // v.resize(6); // size
    // v.reserve(6); // capacity 아직 데이터는 없지만 이사비용 최소화를 위해 영역 확보를 하겠다!
    
    v[0].edges.push_back(&v[1]); // v 벡터의 두 번째 Vertex 객체의 주소를 추가. 이렇게 하면 그래프에서 v[0]과 v[1]의 간선(edge)이 연결됨
    v[0].edges.push_back(&v[3]);
    
    v[1].edges.push_back(&v[0]);
    v[1].edges.push_back(&v[2]);
    v[1].edges.push_back(&v[3]);
    // ...
    
    // Q) 0번 -> 3번이 연결되어 있나요?
	bool connected = false;
    
    int size = v[0].edges.size();
    for(int i = 0; i < size; i++)
    {
    	Vertex* vertex = v[0].edges[i];
        if (vertex == &v[3])
        {
			connected = true;
		}
    }
}

int main()
{

}

대신 아래와 같이 그래프를 만든다.

인접 리스트, 인접 행렬

  • 인접 리스트는 각 정점마다 연결된 간선의 정보를 저장하는 자료구조이다.

  • 인접 행렬은 2차원 배열을 사용하여 정점 간의 연결 정보를 나타내는 자료구조이다. 정점의 개수를 V라고 할 때, VxV 크기의 배열을 사용하여 각 위치의 값이 1이면 연결되어 있음을 나타내고, 0이면 연결되어 있지 않음을 나타낸다.

  • 인접 행렬을 사용하면 정점 간의 연결 상태를 배열로 표현하기 때문에 간선의 추가 및 검색이 상수 시간O(1) 에 이루어진다. 또한, 그래프의 크기가 작거나 밀집 그래프(간선의 수가 정점의 수에 비해 많은 경우)인 경우에 유리하다.

  • 하지만 인접 행렬은 정점의 개수에 비례하는 공간을 사용하기 때문에 그래프의 크기가 큰 경우 메모리를 많이 차지하게 된다. 또한, 간선이 많이 없는 희소 그래프의 경우 불필요한 메모리 낭비가 발생할 수 있다. 따라서, 그래프의 크기와 구조에 따라 적절한 자료구조를 선택하는 것이 중요하다.

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

// 인접 리스트: 실제로 연결 애들'만' 넣어준다. 
void CreateGraph_2()
{
	// 정점은 여기서 관리하고
	struct Vertex
    {
    	int data;
    };
    
    vector<Vertex> v(6);
    
    // 연결 여부는 여기서 관리한다
    vector<vector<int>> adjacent; // 이중 벡터
    adjacent.resize(6);
    
    //[v][v][][v][][v]
    // adjacent[0].pusg_back(1);
	// adjacent[0].pusg_back(3);    
    adjacent[0] = {1, 3};
    adjacent[1] = {0, 2, 3};
    adjacent[3] = {4};
    adjacent[5] = {4};
    
    // Q) 0번 -> 3번이 연결되어 있나요?
    bool connected = false;
    
    int size = adjacent[0].size();
    for (int i=0; i<size; i++)
	{
		int vertex = adjacent[0][i];
        if (vertex == 3)
        {
        	connected = true;
        }
	}
    
}

// 인접 행렬
void CreateGraph_3()
{
	struct Vertex
    {
    	int data;
    };
   	
    vector<Vertex> v(6);
    
    // 연결된 목록을 행렬로 관리
    // [X][O][X][O][X][X] // vector<bool>(6, false) 이것이당
    // [O][X][O][O][X][X]
    // [X][X][X][X][X][X]
    // [X][X][X][X][O][X]
    // [X][X][X][X][X][X]
    // [X][X][X][X][O][X]
    
    // 행렬을 이용한 그래프 표현
    // 메모리 소모 심하지만, 빠른 접근
    vector<vector<bool>> adjacent(6, vector<bool>(6, false));
    adjacent[0][1] = true;
    adjacent[0][3] = true;
    adjacent[1][0] = true;
    adjacent[1][2] = true;
    adjacent[1][3] = true;
    adjacent[3][4] = true;
    adjacent[5][4] = true;
    
    // Q) 0번->3번이 연결되어 있나요?
    bool connected = adjacent[0][3];
    
    // 또는 이렇게
    vector<vector<int>> adjacent2 = 
    {
    	{-1, 15, -1, 35, -1, -1},
        {15, -1, +5, 10, -1, -1},
        {-1, +5, -1, -1, -1, -1},
        {35, 10, -1, -1, +5, -1},
        {-1, -1, -1, +5, -1, +5},
        {-1, -1, -1, -1, -+5, -1}
    };
}


int main()
{
	vector<int> v;
    
}
  • 여기서 재귀 함수를 쓰지 않는 이유는 그래프는 무한루프가 생길 수도 있기 때문이다
    그래서 이제 그래프 탐색 방법론이 중요한데, 가장 기본 중의 기본이 DFS, BFS 이다.
profile
game client programmer

0개의 댓글