그래프를 왜 배울까요? 자료구조의 분류부터 차근차근 살펴보겠습니다.
자료구조는 크게 선형 자료구조와 비선형 자료구조로 나뉩니다. 그래프는 비선형 자료구조로 데이터들 간의 관계를 효율적으로 표현하는데에 있습니다.
비선형 자료구조에는 트리와 그래프가 있습니다. 트리는 부모-자식 노드간의 계층적 관계를 나타내는 반면, 그래프는 모든 노드가 서로 자유롭게 연결될 수 있습니다. 예를 들어, SNS에서 친구 관계나 지하철 노선도처럼 "누구나 누구와든 연결될 수 있는" 관계를 표현할 때 그래프가 필요합니다.
즉, 그래프를 배우는 이유는:
정점(Vertex)들간의 관계를 행렬로 표현한 것입니다.
먼저 인접 행렬을 사용한 그래프의 기본 구조를 살펴보겠습니다:
public class AdjacencyMatrixGraph<T> {
private T[] vertices; // 정점 데이터 저장
private int[][] matrix; // 인접 행렬
private int size; // 현재 정점 개수
private int maxSize; // 최대 정점 개수
}
인접 행렬 그래프는 정점들을 배열에 저장하고, 간선 연결 정보를 2차원 행렬에 저장합니다. 이제 각 메서드의 구현과 동작 방식을 자세히 살펴보겠습니다.
@SuppressWarnings("unchecked")
public AdjacencyMatrixGraph(int maxSize) {
this.maxSize = maxSize;
this.vertices = (T[]) new Object[maxSize];
matrix = new int[maxSize][maxSize];
}
생성자는 그래프의 초기화를 담당하며, 다음과 같은 작업을 수행합니다.
maxSize 매개변수를 통해 그래프가 담을 수 있는 최대 정점 수를 지정합니다.vertices: 정점 객체들을 저장할 배열matrix: 간선 연결 정보를 저장할 2차원 배열new T[maxSize]와 같은 코드는 불가능). 이러한 제약을 우회하기 위해 Object 배열을 생성한 후 타입 캐스팅을 사용합니다.@SuppressWarnings("unchecked") 어노테이션: 제네릭 배열 생성 시 발생하는 타입 안전성 경고를 억제합니다. 이 경고는 런타임에 실제 타입 정보가 소거되기 때문에 발생하지만, 코드 내에서 타입 안전성을 유지하도록 설계되었다면 이 경고를 무시해도 안전합니다.Java에서 제네릭 배열을 직접 생성할 수 없는 이유는 타입 소거(type erasure) 때문입니다. 컴파일 시 제네릭 타입 정보는 소거되어 런타임에는 존재하지 않습니다. 이를 해결하기 위한 일반적인 방법은:
이 방식은 컴파일러 경고를 발생시키지만, 클래스 구현이 타입 안전성을 보장한다면 실제 문제는 발생하지 않습니다.
@Override
public void addVertex(T vertex) {
vertices[size++] = vertex;
}
addVertex 메서드는 그래프에 새로운 정점을 추가합니다:
vertices 배열의 현재 size 위치에 저장합니다.size 변수는 현재 그래프에 포함된 정점의 수를 나타내며, 후위 증가 연산자(size++)를 사용하여 정점 추가 후 자동으로 증가합니다.실제 사용 예시
AdjacencyMatrixGraph<String> graph = new AdjacencyMatrixGraph<>(5);
graph.addVertex("A"); // vertices[0] = "A", size = 1
graph.addVertex("B"); // vertices[1] = "B", size = 2
graph.addVertex("C"); // vertices[2] = "C", size = 3
위 코드 실행 후 vertices 배열은 ["A", "B", "C", null, null]과 같은 상태가 되며, size는 3이 됩니다.
주의사항
현재 구현에는 다음과 같은 제한사항이 있습니다:
실제 프로덕션 코드에서는 이러한 예외 상황을 처리하는 코드를 추가하는 것이 좋습니다.
@Override
public void addEdge(int from, int to) {
if (from < 0 || from >= size || to < 0 || to >= size) {
throw new IllegalArgumentException("Invalid vertex index");
}
matrix[from][to] = 1;
matrix[to][from] = 1;
}
addEdge 메서드는 두 정점 사이에 간선을 추가합니다:
matrix[from][to] = 1: from에서 to로 가는 간선 추가matrix[to][from] = 1: to에서 from으로 가는 간선 추가 (무방향 그래프의 경우)matrix[from][to]와 matrix[to][from] 모두 1로 설정)matrix[from][to]만 1로 설정, matrix[to][from]은 설정하지 않음)AdjacencyMatrixGraph<String> graph = new AdjacencyMatrixGraph<>(5);
graph.addVertex("A");
graph.addVertex("B");
graph.addVertex("C");
graph.addEdge(0, 1); // "A"와 "B" 연결
graph.addEdge(0, 2); // "A"와 "C" 연결
위 코드 실행 후 인접 행렬 matrix는 다음과 같은 상태가 됩니다:
[ [0, 1, 1],
[1, 0, 0],
[1, 0, 0] ]
이는 "A"가 "B"와 "C"에 연결되어 있음을 나타냅니다.
public void depthFirstSearch() {
resetVisited(); // 방문 초기화
for (int i = 0; i < size; i++) {
if (!visited[i])
depthFirstSearch(i);
}
System.out.println();
}
private void depthFirstSearch(int v) {
visited[v] = true;
System.out.print(vertices[v] + " ");
for (int w = 0; w < maxSize; w++) {
if (matrix[v][w] == 1 && !visited[w]) {
depthFirstSearch(w);
}
}
}
A B D G E F Cpublic void iterativeDFSBadVersion() {
resetVisited();
int v = 0;
Stack<Integer> s = new ArrayStack<>(maxSize);
visited[v] = true; // 스택에서 pop하기 전에 방문 표시
s.push(v);
while (!s.isEmpty()) {
v = s.peek();
s.pop();
System.out.print(vertices[v] + " ");
for (int w = 0; w < maxSize; w++) {
if (matrix[v][w] == 1 && !visited[w]) {
visited[w] = true; // 스택에 넣을 때 방문 표시
s.push(w);
}
}
}
System.out.println();
}
A C E G F D B (재귀 DFS와 다른 순서)public void iterativeDFSV2() {
resetVisited();
int v = 0;
Stack<Integer> s = new ArrayStack<>(maxSize);
s.push(v);
while (!s.isEmpty()) {
v = s.peek();
s.pop();
if (visited[v]) {
continue;
}
visited[v] = true; // 꺼낼 때 방문 표시
System.out.print(vertices[v] + " ");
for (int w = size - 1; w >= 0; w--) {
if (matrix[v][w] == 1 && !visited[w]) {
s.push(w);
}
}
}
System.out.println();
}
A B D G E F C (재귀 DFS와 유사한 순서)Iterator를 사용하는 이유:
재귀 DFS의 핵심은 "현재 위치에서 다음 선택을 바로 처리"하는 것입니다. Iterator는 각 정점에서 "어디까지 탐색했는지" 정확히 기억하므로, 재귀와 동일한 방식으로 동작할 수 있습니다.
public void iterativeDFSWithIterator() {
resetVisited();
Stack<Iterator<Integer>> s = new ArrayStack<>(maxSize);
int start = 0;
visited[start] = true;
System.out.print(vertices[start] + " ");
s.push(getAdjacencyIterator(start));
while (!s.isEmpty()) {
Iterator<Integer> current = s.peek();
if (current.hasNext()) {
int w = current.next(); // 현재 위치에서 다음 선택
if (!visited[w]) {
visited[w] = true;
System.out.print(vertices[w] + " ");
s.push(getAdjacencyIterator(w)); // 새 위치로 이동
}
} else {
s.pop(); // 현재 위치에서 더 갈 곳이 없으면 이전 위치로 백트래킹
}
}
}
A B D G E F C (재귀 DFS와 완전히 동일)큐를 사용하여 너비 우선 탐색을 수행합니다. 시작 정점에서 가까운 정점들부터 차례로 방문합니다.
public void breadthFirstSearch() {
resetVisited(); // 방문 초기화
// 학습용이므로 간단히 LinkedList로 구현
Queue<Integer> queue = new LinkedList<>();
int start = 0;
visited[start] = true; // 삽입하기 전에 방문 표시
queue.offer(start);
System.out.print(vertices[start] + " ");
while (!queue.isEmpty()) {
int v = queue.poll();
for (int w = 0; w < size; w++) {
if (!visited[w] && matrix[v][w] == 1) {
visited[w] = true;
queue.offer(w);
System.out.print(vertices[w] + " ");
}
}
}
System.out.println();
}
A B C D E G F (레벨별 탐색 순서)정점(Vertex)들간의 관계를 연결 리스트로 표현한 것입니다. 인접 행렬과 달리 각 정점마다 실제로 연결된 정점들만 저장하여 메모리를 효율적으로 사용합니다.
먼저 인접 리스트를 사용한 그래프의 기본 구조를 살펴보겠습니다:
public class AdjacencyListGraph<T> implements Graph<T> {
private T[] vertices; // 정점 데이터 저장
private Node[] list; // 각 정점의 인접 리스트 헤드 배열
private int size; // 현재 정점 개수
private int maxSize; // 최대 정점 개수
private boolean[] visited; // DFS/BFS용 방문 체크 배열
}
인접 리스트 그래프는 정점들을 배열에 저장하고, 각 정점의 연결 정보를 개별 연결 리스트에 저장합니다. 이제 각 메서드의 구현과 동작 방식을 자세히 살펴보겠습니다.
연결 리스트를 구현하기 위한 노드 클래스입니다:
static class Node {
int vertex = -1; // 연결된 정점의 인덱스
Node next = null; // 다음 노드 참조
public Node(int vertex, Node next) {
this.vertex = vertex;
this.next = next;
}
}
설계 특징:
@SuppressWarnings("unchecked")
public AdjacencyListGraph(int maxSize) {
this.maxSize = maxSize;
this.vertices = (T[]) new Object[maxSize];
list = new Node[maxSize];
}
생성자는 그래프의 초기화를 담당하며, 다음과 같은 작업을 수행합니다:
maxSize 매개변수를 통해 그래프가 담을 수 있는 최대 정점 수를 지정합니다vertices: 정점 객체들을 저장할 배열list: 각 정점의 인접 리스트 헤드를 저장할 배열인접 행렬과의 차이점:
O(V^2) 크기의 2차원 배열 할당O(V) 크기의 1차원 배열만 할당 (연결 리스트는 필요시 동적 생성)@Override
public void addVertex(T vertex) {
vertices[size++] = vertex;
}
addVertex 메서드는 인접 행렬과 동일한 방식으로 구현됩니다:
vertices 배열의 현재 size 위치에 저장주목할 점: 이 시점에서는 list[size-1]이 여전히 null입니다. 간선이 추가될 때까지 연결 리스트가 생성되지 않습니다.
@Override
public void addEdge(int from, int to) {
if (from < 0 || from >= size || to < 0 || to >= size) {
throw new IllegalArgumentException(
"Invalid vertex index");
}
Node temp = new Node(to, list[from]);
list[from] = temp;
}
addEdge 메서드는 Head Insertion 방식으로 간선을 추가합니다:
new Node(to, list[from])vertex = to: 연결될 정점 번호 설정next = list[from]: 기존 리스트를 다음 노드로 연결list[from] = temp로 새 노드를 리스트의 맨 앞에 배치O(1) - 리스트 맨 앞 삽입은 상수 시간Head Insertion은 연결 리스트의 맨 앞에 새 노드를 삽입하는 방식입니다:
// 초기 상태: list[0] → null
addEdge(0, 1); // list[0] → [1|next:null]
addEdge(0, 2); // list[0] → [2|next:→[1|next:null]]
addEdge(0, 3); // list[0] → [3|next:→[2|next:→[1|next:null]]]
public void depthFirstSearch() {
resetVisited();
depthFirstSearch(0);
System.out.println();
}
private void depthFirstSearch(int v) {
visited[v] = true;
System.out.print(vertices[v] + " ");
Node current = list[v];
while (current != null) {
int w = current.vertex;
if (!visited[w]) {
depthFirstSearch(w);
}
current = current.next;
}
}
// 인접 행렬 방식
for (int w = 0; w < maxSize; w++) {
if (matrix[v][w] == 1 && !visited[w]) {
depthFirstSearch(w);
}
}
// 인접 리스트 방식
Node current = list[v];
while (current != null) {
int w = current.vertex;
if (!visited[w]) {
depthFirstSearch(w);
}
current = current.next;
}
시간 복잡도: O(V + E)
public void iterativeDFS() {
resetVisited();
int start = 0;
Stack<Integer> s = new ArrayStack<>(maxSize);
s.push(start);
while (!s.isEmpty()) {
int v = s.peek();
s.pop();
if (visited[v]) {
continue; // 이미 방문한 정점은 건너뛰기
}
visited[v] = true;
System.out.print(vertices[v] + " ");
Node current = list[v];
while (current != null) {
int w = current.vertex;
if (!visited[w]) {
s.push(w);
}
current = current.next;
}
}
System.out.println();
}
public void breadthFirstSearch() {
resetVisited();
Queue<Integer> q = new LinkedList<>();
int start = 0;
visited[start] = true;
q.offer(start);
System.out.print(vertices[start] + " ");
while (!q.isEmpty()) {
int v = q.poll(); // Vertex 수만큼 확인
Node current = list[v];
while (current != null) {
int w = current.vertex; // 간선 확인
if (!visited[w]) {
visited[w] = true;
System.out.print(vertices[w] + " ");
q.offer(w);
}
current = current.next;
}
}
System.out.println();
}
인접 행렬 BFS:
for (int w = 0; w < size; w++) {
if (!visited[w] && matrix[v][w] == 1) {
// ...
}
}
각 정점마다 모든 정점(V개) 확인
시간 복잡도: O(V²)
인접 리스트 BFS:
Node current = list[v];
while (current != null) {
// 실제 연결된 정점들만 확인
current = current.next;
}
각 정점마다 실제 연결된 정점들만 확인
시간 복잡도: O(V + E)