선형 자료구조는 자료를 순차적으로 나열한 형태이고, 많이 사용된다.
이와 반대로 비선형 자료구조는 하나의 자료 뒤에 다수의 자료가 올 수 있는 형태이다. 예를 들어 파일 탐색, 길 찾기 등은 일대일관계가 아니므로 비선형 자료구조를 활용해야 한다.
이번 포스트에서는 트리, 우선순위 큐, 그래프에 대해 살펴본다.
계층적 구조를 갖는 데이터를 표현하기 위한 자료구조
노드 : 데이터를 표현
간선 : 노드의 계층 구조를 표현하기 위해 사용
노드, 간선은 그래프에서도 활용되는 개념이다. 연결리스트는 앞뒤로만 연결할 수 있기 때문에 이렇게 트리처럼 여러 항목을 동시에 가리키지는 못한다!
트리는 양파같다. 까도까도 나오기 때문! 그래서 재귀함수와 잘 어울린다.
우선순위 큐, 탐색 용도의 레드블랙 트리로 활용되므로 아래처럼 기초적인 것은 잘 쓰이지 않는다.
#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 법칙]
힙 트리 구조
마지막 레벨을 제외한 모든 레벨에 노드가 꽉 차 있다. (완전 이진 트리)
마지막 레벨에 노드가 있을 때는, 항상 왼쪽부터 순서대로 채워야 한다.
힙 트리 구현
힙 트리에서 임의의 값을 추가하기
[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 를 왜 해주는 것일까?
위 코드에서 break 를 하게 되면 어떤 반복문을 빠져나오는 것일까?
거꾸로 하고 싶다면?
트리가 그래프의 일종이다.
그래프는 트리와 달리 딱히 계층 구조는 없고 수평적인 관계이다.
그래프란 현실 세계의 사물이나 추상적인 개념 간의 연결 관계를 표현한다.
가중치 그래프 (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;
}