본 시리즈는 프로그래밍 자료구조의 개념을 정리하고 실습을 진행해보는 시리즈입니다.
스택(Stack)은 LIFO(Last In, First Out) 구조로 동작하는 선형 자료구조입니다.
스택을 생각할 때는 흔히 접시를 쌓아 올리는 모습을 떠올리면 쉽습니다.
스택에서는 주로 다음과 같은 연산으로 이루어집니다:
push
: 스택의 맨 위에 데이터를 추가하는 연산.pop
: 스택의 맨 위 데이터를 제거하는 연산.peek
(또는 top
): 스택의 맨 위에 있는 데이터를 제거하지 않고 확인하는 연산.isEmpty
: 스택이 비어 있는지 확인하는 연산.스택은 여러 가지 상황에서 유용하게 활용됩니다. 대표적인 활용 사례로는 다음이 있습니다:
Java에는 Stack
클래스가 존재하고 기본적인 스택 연산인 push()와 pop()을 지원합니다.
Stack
은 하위 버전 호환을 위한 Legacy 자료구조로 간주되며, 최신 Java에서는 Deque 인터페이스(ArrayDeque
, LinkedList
)를 사용하는 것이 더 권장됩니다.Deque
는 스택과 큐 양쪽에서 사용할 수 있는 자료구조로, 이후 포스팅에서 자세히 다루게 될 예정입니다.import java.util.Stack;
public class Main {
public static void main(String[] args) {
// Stack 생성
Stack<Integer> stack = new Stack<>();
// 데이터 추가 (Push)
stack.push(10);
stack.push(20);
stack.push(30);
// 데이터 제거 (Pop)
System.out.println(stack.pop()); // 30 출력
System.out.println(stack.pop()); // 20 출력
// 현재 스택 상태
System.out.println(stack); // [10] 출력
}
}
위 코드에서 push()
연산은 스택의 top(뒤쪽)에 데이터를 추가하고, pop()
연산은 top(뒤쪽)에서 데이터를 제거합니다.
참고로 Java의 Stack
클래스는 LIFO (Last In, First Out) 원칙에 따라 동작하며, 자료구조의 뒤쪽에서 push와 pop 연산이 이루어집니다.
Stack
은 내부적으로 List
인터페이스를 구현한 Vector
를 상속받아 구현되었기 때문에, 데이터가 뒤쪽(리스트의 끝부분)에 쌓이고, push()
와 pop()
모두 리스트의 끝에서 이루어집니다.Deque
인터페이스 권장
Java에서 스택을 구현할 때는 Stack
클래스 대신 Deque
인터페이스를 구현한 ArrayDeque
나 LinkedList
로 스택을 구현하는 것이 더 권장됩니다.
Deque
구현체에서도 push()
와 pop()
기능을 지원하기 때문에, Stack과 동일하게 사용하실 수 있습니다.Python에서는 별도의 Stack
자료형이 존재하지 않지만, 두 가지 방법으로 스택을 구현할 수 있습니다:
collections.deque
를 이용한 더 효율적인 구현두 방법 모두 다루기는 하되, deque
는 이후 포스팅에서 더 자세히 다룰 예정입니다.
Python의 리스트는 append()
와 pop()
메서드를 통해 스택처럼 사용할 수 있습니다.
# Python 리스트를 이용한 스택 구현
stack = []
# 데이터 추가 (Push)
stack.append(10)
stack.append(20)
stack.append(30)
# 데이터 제거 (Pop)
print(stack.pop()) # 30 출력
print(stack.pop()) # 20 출력
# 현재 스택 상태
print(stack) # [10] 출력
리스트는 스택처럼 작동하지만, 리스트의 pop()
연산은 마지막 요소에 대해서만 O(1)
시간 복잡도를 가지며, 중간에서 삭제하는 경우는 O(n)의 시간 복잡도가 발생할 수 있습니다.
append()
와 pop()
모두 O(1)
시간 복잡도를 가집니다.Python의 collections.deque
는 스택과 큐 연산을 모두 효율적으로 처리할 수 있는 양방향 자료구조입니다.
O(1)
시간 복잡도를 보장합니다.from collections import deque
# deque를 이용한 스택 구현
stack = deque()
# 데이터 추가 (Push)
stack.append(10)
stack.append(20)
stack.append(30)
# 데이터 제거 (Pop)
print(stack.pop()) # 30 출력
print(stack.pop()) # 20 출력
# 현재 스택 상태
print(stack) # deque([10]) 출력
deque
는 리스트보다 삽입 및 삭제가 훨씬 더 효율적인데, Deque
의 더 다양한 기능과 활용법은 다음 포스팅에서 큐(Queue)와 함께 자세히 다룰 예정입니다.
스택은 두 가지 주요 방식으로 구현될 수 있습니다: 배열 기반 스택과 연결 리스트 기반 스택.
구현 방식 | 장점 | 단점 | 메모리 사용 | Java | Python |
---|---|---|---|---|---|
배열 기반 스택 | - 메모리 연속성으로 인해 빠른 접근 속도 - 캐시 성능이 우수 | 크기 초과 시 배열 재할당으로 인한 비용 발생 가능 | 동적 크기 조절 가능 (재할당시 추가 메모리) | ArrayDeque | list |
연결 리스트 스택 | - 동적 크기 조절이 가능 - 삽입/삭제가 배열보다 효율적 | 각 노드에 추가적인 메모리 사용 (포인터) | 노드마다 추가적인 메모리 사용 (동적 메모리 할당) | LinkedList | deque |
스택은 다양한 문제에서 중요한 역할을 하는 자료구조입니다.
스택을 이용하여 괄호의 짝이 올바른지 확인하는 문제는 전형적인 스택 활용 예시입니다.
import java.util.Stack;
public class Main {
public static boolean isValidParentheses(String s) {
Stack<Character> stack = new Stack<>();
for (char c : s.toCharArray()) {
if (c == '(' || c == '{' || c == '[') {
stack.push(c); // 여는 괄호는 스택에 push
} else {
if (stack.isEmpty()) return false; // 스택이 비어있으면 짝이 안맞음
char open = stack.pop(); // 스택에서 여는 괄호를 pop
if ((c == ')' && open != '(')
|| (c == '}' && open != '{')
|| (c == ']' && open != '[')) {
return false; // 짝이 맞지 않으면 false
}
}
}
return stack.isEmpty(); // 모든 처리가 끝난 후 스택이 비어있어야 true
}
public static void main(String[] args) {
System.out.println(isValidParentheses("(){}[]")); // true
System.out.println(isValidParentheses("(]")); // false
}
}
def is_valid_parentheses(s):
stack = []
matching_brackets = {')': '(', '}': '{', ']': '['}
for char in s:
if char in matching_brackets.values():
stack.append(char) # 여는 괄호 push
elif char in matching_brackets.keys():
if not stack or stack.pop() != matching_brackets[char]: # 짝 확인
return False
return len(stack) == 0 # 스택이 비어 있어야 true
print(is_valid_parentheses("(){}[]")) # True
print(is_valid_parentheses("(]")) # False
스택은 재귀 함수의 호출을 반복문으로 변환할 때 사용할 수 있습니다.
재귀 함수를 스택을 사용하여 반복문으로 변환하는 좋은 예로 팩토리얼 계산이 있습니다.
n
에 대해 n! = n * (n-1) * (n-2) * ... * 1
로 정의됩니다. 우선, 재귀 함수를 사용하여 팩토리얼을 구현한 코드입니다:
public class StackTest {
// 재귀적으로 팩토리얼을 구하는 함수
public static int recursiveFactorial(int n) {
if (n == 0) {
return 1;
}
return n * recursiveFactorial(n - 1);
}
public static void main(String[] args) {
System.out.println(recursiveFactorial(5)); // 120 출력
}
}
# 재귀적으로 팩토리얼을 구하는 함수
def recursive_factorial(n):
if n == 0:
return 1
return n * recursive_factorial(n - 1)
# 테스트
print(recursive_factorial(5)) # 120 출력
이 재귀 함수는 n == 0
일 때 종료되며, n * recursiveFactorial(n - 1)
을 반복적으로 호출하여 팩토리얼 값을 계산합니다.
이제 이 재귀 호출을 스택을 사용하여 반복문으로 변환해 보겠습니다.
스택을 사용하여 호출 스택을 모방하면, 명시적으로 함수 호출 상태를 저장하고 처리할 수 있습니다.
import java.util.Stack;
public class StackTest {
// 스택을 이용한 반복문으로 팩토리얼을 구하는 함수
public static int stackFactorial(int n) {
Stack<Integer> stack = new Stack<>();
int result = 1;
// n부터 1까지의 값을 스택에 넣습니다.
while (n > 0) {
stack.push(n);
n--;
}
// 스택에서 값을 하나씩 꺼내어 팩토리얼을 계산합니다.
while (!stack.isEmpty()) {
result *= stack.pop();
}
return result;
}
public static void main(String[] args) {
System.out.println(stackFactorial(5)); // 120 출력
}
}
# 스택을 이용한 반복문으로 팩토리얼을 구하는 함수
def stack_factorial(n):
stack = []
result = 1
# n부터 1까지 스택에 값을 넣음
while n > 0:
stack.append(n)
n -= 1
# 스택에서 값을 꺼내어 팩토리얼 계산
while stack:
result *= stack.pop()
return result
# 테스트
print(stack_factorial(5)) # 120 출력
위 예시 코드들은 다음과 같이 동작합니다.
1. 스택에 n부터 1까지의 값을 넣음: n이 0보다 큰 동안 스택에 값을 계속 추가합니다.
2. 스택에서 값을 하나씩 꺼내어 곱셈: 스택에서 값을 하나씩 꺼내면서 곱셈을 수행하여 팩토리얼을 계산합니다.
스택(Stack)에서 이루어지는 연산들은 매우 효율적입니다.
스택에서 사용되는 대표적인 연산들의 시간 복잡도 및 공간 복잡도는 다음과 같습니다.
push()
: 스택의 맨 위에 새로운 데이터를 추가하는 연산입니다. pop()
: 스택의 맨 위에서 데이터를 제거하는 연산입니다. peek()
: 스택의 맨 위 데이터를 제거하지 않고 확인하는 연산입니다. isEmpty()
: 스택이 비어 있는지 여부를 확인하는 연산입니다. 스택에서의 탐색 연산은 스택의 모든 요소를 살펴보는 작업이므로, 최악의 경우 스택에 있는 모든 데이터를 확인해야 합니다.
n
은 스택에 들어있는 데이터의 개수입니다.따라서 스택은 다음과 같은 시간 복잡도를 가지고 있습니다:
연산 | 시간 복잡도 |
---|---|
push() | O(1) |
pop() | O(1) |
peek() | O(1) |
isEmpty() | O(1) |
전체 탐색 | O(n) |
스택은 선형적인 공간 복잡도를 가집니다.
공간 복잡도는 스택에 저장된 데이터의 개수에 따라 O(n)입니다.
스택 오버플로우는 스택 메모리 영역이 가득 차서 더 이상 데이터를 저장할 수 없는 상태를 말합니다.
예를 들어, 다음과 같은 상황이 오버플로우를 일으킬 수 있습니다:
public class StackOverflowExample {
public static void recursive() {
recursive(); // 재귀 호출이 무한히 반복됨
}
public static void main(String[] args) {
recursive(); // StackOverflowError 발생
}
}
위와 같은 코드에서 함수가 무한히 호출되면 StackOverflowError
가 발생합니다.
이번 포스팅에서는 스택(Stack) 자료구조의 개념, 구현 방법, 그리고 다양한 활용 사례를 살펴보았습니다.
스택 자료구조는 매우 기본적이지만, 다양한 문제 해결에서 필수적인 역할을 하기 때문에 그 개념을 잘 이해하고 있는 것이 중요합니다.
다음 포스팅에서는 큐(Queue) 자료구조를 다룰 예정입니다.
다다음 포스팅에선, 큐(Queue)와 스택(Stack)의 장점을 모두 갖춘 자료구조인 데크(Deque, Double-Ended Queue)를 살펴보며 큐와 스택을 더욱 효과적으로 다룰 수 있는 방법에 대해 논의할 예정입니다.