스택, 큐 그리고 우선순위 큐 맛보기

스택, 큐 그리고 우선순위 큐 맛보기

작성자
태인태인
카테고리
📗 스터디
작성일
2023년 12월 22일
태그
Java
Data Structure

스택 (Stack)

스택은 마지막에 들어간 자료가 가장 먼저 처리되는 후입선출(LIFO) 자료구조이다. 이름 그대로 아래서 부터 위로 하나씩 쌓는 모습을 생각해보면 이해가 빠르다.
스택 자료구조의 용어는 다음과 같다.
  • top : 삽입과 삭제가 일어나는 위치(최상단)
  • push : top 위치에 새로운 데이터를 삽입
  • pop : top 위치에 있는 데이터를 삭제하고 확인
  • peek : top 위치에 있는 데이터를 확인
 

큐 (Queue)

큐는 먼저 들어간 자료가 가장 먼저 처리되는 선입선출(FIFO) 자료구조이다. 역시 Queue라는 이름에서 알 수 있듯이 ‘기다리는 대기 줄’을 생각하면 된다.
큐의 용어도 알아보자.
  • rear : 가장 끝 데이터
  • front : 가장 앞 데이터
  • add : rear 부분에 새로운 데이터를 삽입
  • poll : front 부분에 있는 데이터를 삭제하고 확인 (스택의 pop과 유사)
  • peek : front에 있는 데이터를 확인

우선 아주 기초적인 문제를 통해 큐와 스택 자료구조를 사용하는 법을 살펴보자.

백준 #10828 : 스택

문제

정수를 저장하는 스택을 구현한 다음, 입력으로 주어지는 명령을 처리하는 프로그램을 작성하시오.
명령은 총 다섯 가지이다.
  • push X: 정수 X를 스택에 넣는 연산이다.
  • pop: 스택에서 가장 위에 있는 정수를 빼고, 그 수를 출력한다. 만약 스택에 들어있는 정수가 없는 경우에는 -1을 출력한다.
  • size: 스택에 들어있는 정수의 개수를 출력한다.
  • empty: 스택이 비어있으면 1, 아니면 0을 출력한다.
  • top: 스택의 가장 위에 있는 정수를 출력한다. 만약 스택에 들어있는 정수가 없는 경우에는 -1을 출력한다.

입력

첫째 줄에 주어지는 명령의 수 N (1 ≤ N ≤ 10,000)이 주어진다. 둘째 줄부터 N개의 줄에는 명령이 하나씩 주어진다. 주어지는 정수는 1보다 크거나 같고, 100,000보다 작거나 같다. 문제에 나와있지 않은 명령이 주어지는 경우는 없다.

출력

출력해야하는 명령이 주어질 때마다, 한 줄에 하나씩 출력한다.

이 문제는 스택의 기본 연산을 모두 구현하면 되는 개념 문제이다.
Java에서는 아래와 같이 Stack 구조를 사용할 수 있다.
Stack<자료형> stack = new Stack<>();
그럼 문제의 입력 조건에 맞게 코드를 작성해보자.
import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.IOException; import java.util.Stack; public class Main { public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StringBuilder sb = new StringBuilder(); int N = Integer.parseInt(br.readLine()); String input; Stack<Integer> stack = new Stack<>(); for(int i=0; i<N; i++) { input = br.readLine(); if(input.contains("push ")) { int X = Integer.parseInt(input.replace("push ", "")); stack.push(X); // PUSH: 값 삽입 } else if(input.equals("pop")) { if(stack.isEmpty()) sb.append("-1").append("\n"); else sb.append(stack.pop()).append("\n"); // POP: 값 삭제 후 반환 } else if(input.equals("size")) { sb.append(stack.size()).append("\n"); } else if(input.equals("empty")) { sb.append(stack.isEmpty() ? "1" : "0").append("\n"); } else if(input.equals("top")) { if(stack.isEmpty()) sb.append("-1").append("\n"); else sb.append(stack.peek()).append("\n"); // PEEK: 값 반환 } } System.out.println(sb); } }
 

이제 큐의 사용법을 알아보자.

백준 #10845 : 큐

문제

정수를 저장하는 큐를 구현한 다음, 입력으로 주어지는 명령을 처리하는 프로그램을 작성하시오.
명령은 총 여섯 가지이다.
  • push X: 정수 X를 큐에 넣는 연산이다.
  • pop: 큐에서 가장 앞에 있는 정수를 빼고, 그 수를 출력한다. 만약 큐에 들어있는 정수가 없는 경우에는 -1을 출력한다.
  • size: 큐에 들어있는 정수의 개수를 출력한다.
  • empty: 큐가 비어있으면 1, 아니면 0을 출력한다.
  • front: 큐의 가장 앞에 있는 정수를 출력한다. 만약 큐에 들어있는 정수가 없는 경우에는 -1을 출력한다.
  • back: 큐의 가장 뒤에 있는 정수를 출력한다. 만약 큐에 들어있는 정수가 없는 경우에는 -1을 출력한다.

입력

첫째 줄에 주어지는 명령의 수 N (1 ≤ N ≤ 10,000)이 주어진다. 둘째 줄부터 N개의 줄에는 명령이 하나씩 주어진다. 주어지는 정수는 1보다 크거나 같고, 100,000보다 작거나 같다. 문제에 나와있지 않은 명령이 주어지는 경우는 없다.

출력

출력해야하는 명령이 주어질 때마다, 한 줄에 하나씩 출력한다.

Java에서 Queue는 아래와 같이 선언하여 이용할 수 있다.
Queue<자료형> queue = new LinkedList<>();
문제의 입력 조건을 고려해 코드를 작성하면 다음과 같다.
import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.IOException; import java.util.LinkedList; import java.util.Queue; public class Main { public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StringBuilder sb = new StringBuilder(); int N = Integer.parseInt(br.readLine()); String input; Queue<Integer> queue = new LinkedList<>(); int last = 0; for(int i=0; i<N; i++) { input = br.readLine(); if(input.contains("push ")) { int X = Integer.parseInt(input.replace("push ", "")); queue.add(X); //ADD: 값 추가 last = X; } else if(input.equals("pop")) { if(queue.isEmpty()) sb.append("-1").append("\n"); else sb.append(queue.poll()).append("\n"); //POLL: 값 삭제 후 반환 } else if(input.equals("size")) { sb.append(queue.size()).append("\n"); } else if(input.equals("empty")) { sb.append(queue.isEmpty() ? "1" : "0").append("\n"); } else if(input.equals("front")) { if(queue.isEmpty()) sb.append("-1").append("\n"); else sb.append(queue.peek()).append("\n"); //PEEK: 값 반환 } else if(input.equals("back")) { if(queue.isEmpty()) sb.append("-1").append("\n"); else sb.append(last).append("\n"); } } System.out.println(sb); } }
대부분 Stack 구조와 비슷하지만, 이 문제에서 조금 생각해봐야 하는 것은 back 명령어에 대한 처리이다.
back이 입력되면 현재 Queue에서 가장 뒤에 있는 값을 반환해야 하는데, 가장 뒤의 값을 반환하는 명령어는 존재하지 않는다. ArrayList로 변환해서 마지막 요소를 반환해볼 수 있지만 데이터의 크기가 커지면 비효율적이다.
Queue에 데이터가 출입하는 순서를 생각해보면 쉽게 해결할 수 있다. Queue에 데이터가 추가(add)될 때마다 last라는 변수에 추가된 값을 저장해두고, ‘back’이 입력된 시점에 이 last 값을 출력하면 해당 시점에서의 가장 뒤에 있는 값을 반환할 수 있다.

 
큐와 스택의 이론과 사용법을 알아봤으니, 한 단계 응용된 문제를 풀어보자.

백준 #2164 : 카드2

문제

N장의 카드가 있다. 각각의 카드는 차례로 1부터 N까지의 번호가 붙어 있으며, 1번 카드가 제일 위에, N번 카드가 제일 아래인 상태로 순서대로 카드가 놓여 있다.
이제 다음과 같은 동작을 카드가 한 장 남을 때까지 반복하게 된다. 우선, 제일 위에 있는 카드를 바닥에 버린다. 그 다음, 제일 위에 있는 카드를 제일 아래에 있는 카드 밑으로 옮긴다.
예를 들어 N=4인 경우를 생각해 보자. 카드는 제일 위에서부터 1234 의 순서로 놓여있다. 1을 버리면 234가 남는다. 여기서 2를 제일 아래로 옮기면 342가 된다. 3을 버리면 42가 되고, 4를 밑으로 옮기면 24가 된다. 마지막으로 2를 버리고 나면, 남는 카드는 4가 된다.
N이 주어졌을 때, 제일 마지막에 남게 되는 카드를 구하는 프로그램을 작성하시오.

입력

첫째 줄에 정수 N(1 ≤ N ≤ 500,000)이 주어진다.

출력

첫째 줄에 남게 되는 카드의 번호를 출력한다.

예제 입력 1

6

예제 출력 1

4

풀이 방향 설정

문제를 첫 줄까지만 읽어봐도 카드가 쌓여있는 모습을 떠오르기 때문에 Stack 구조를 사용하겠다는 생각이 든다(아님 말고)
하지만 문제를 계속 읽어보자.
“우선, 제일 위에 있는 카드를 바닥에 버린다. 그 다음, 제일 위에 있는 카드를 제일 아래에 있는 카드 밑으로 옮긴다.”라는 부분을 읽어보면 가장 위에서만 값을 빼고 추가할 수 있는 Stack 구조보다는 한쪽에서는 추가, 다른 한 쪽에서는 삭제가 가능한 Queue구조가 적합하다는 것을 알 수 있다.
이 문장의 로직을 Queue로 그대로 구현하면 쉽게 코드를 작성할 수 있다.
 
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.LinkedList; import java.util.Queue; import java.util.Stack; public class Main { public static void main(String[] args) throws IOException { int N; BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); N = Integer.parseInt(br.readLine()); Queue<Integer> queue = new LinkedList<>(); for(int i=1; i<=N; i++) { queue.add(i); } while(queue.size() > 1) { queue.remove(); int top = queue.poll(); queue.add(top); } System.out.println(queue.peek()); } }
1부터 N까지의 수를 Queue에 차례로 추가한 후, Queue의 size가 1이 될 때까지 아래의 동작을 반복하면 된다.
  • queue.remove() : 위에 있는 카드 버리기
  • queue.add(queue.poll) : 위에 있는 카드를 버리고, 해당 카드를 맨 아래에 추가
 

또 다른 문제를 살펴보자.

백준 #9012 : 괄호

문제

괄호 문자열(Parenthesis String, PS)은 두 개의 괄호 기호인 ‘(’ 와 ‘)’ 만으로 구성되어 있는 문자열이다. 그 중에서 괄호의 모양이 바르게 구성된 문자열을 올바른 괄호 문자열(Valid PS, VPS)이라고 부른다. 한 쌍의 괄호 기호로 된 “( )” 문자열은 기본 VPS 이라고 부른다. 만일 x 가 VPS 라면 이것을 하나의 괄호에 넣은 새로운 문자열 “(x)”도 VPS 가 된다. 그리고 두 VPS x 와 y를 접합(concatenation)시킨 새로운 문자열 xy도 VPS 가 된다. 예를 들어 “(())()”와 “((()))” 는 VPS 이지만 “(()(”, “(())()))” , 그리고 “(()” 는 모두 VPS 가 아닌 문자열이다.
여러분은 입력으로 주어진 괄호 문자열이 VPS 인지 아닌지를 판단해서 그 결과를 YES 와 NO 로 나타내어야 한다.

입력

입력 데이터는 표준 입력을 사용한다. 입력은 T개의 테스트 데이터로 주어진다. 입력의 첫 번째 줄에는 입력 데이터의 수를 나타내는 정수 T가 주어진다. 각 테스트 데이터의 첫째 줄에는 괄호 문자열이 한 줄에 주어진다. 하나의 괄호 문자열의 길이는 2 이상 50 이하이다.

출력

출력은 표준 출력을 사용한다. 만일 입력 괄호 문자열이 올바른 괄호 문자열(VPS)이면 “YES”, 아니면 “NO”를 한 줄에 하나씩 차례대로 출력해야 한다.

예제 입력 1

6 (())()) (((()())() (()())((())) ((()()(()))(((())))() ()()()()(()()())() (()((())()(

예제 출력 1

NO NO YES NO YES NO

풀이 방향 설정

문제를 요약하자면 괄호가 짝에 맞게 열리고 닫혀있는 문자열인지 여부를 판별하는 코드를 작성하라는 문제이다.
올바른 괄호 문자열 여부를 판단하기 위한 방법은 다음과 같다.
  • 주어진 문자열에서 한 글자씩 저장
  • 만약 추가하려는 문자열이 바로 이전에 저장된 문자열과 짝이 맞는 경우 → 이전에 저장된 문자열 제거
  • 위 과정을 반복한 후에 저장된 문자열이 하나도 없을 경우 “올바른 문자열” → “YES” 출력
그리고 이를 구현하기 위한 자료구조로는 Stack이 적합해보인다.
한 글자씩 push 하다가 스택의 top과 짝이 맞는 문자열의 경우에는 push 하기 전에 기존 스택에서 top을 제거(pop)하면 된다. 최종적으로 스택의 현재 size가 0인 경우에는 “YES”, 아니면 “NO”를 출력하면 된다.
 
코드는 다음과 같다.
import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.IOException; import java.util.Stack; public class Main { public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StringBuilder sb = new StringBuilder(); int N = Integer.parseInt(br.readLine()); String input; for(int i=0; i<N; i++) { input = br.readLine(); sb.append(solve(input)).append('\n'); } System.out.println(sb); } public static String solve(String s) { Stack<Character> stack = new Stack<>(); for(int i = 0; i < s.length(); i++) { char c = s.charAt(i); if(c == '(') { stack.push(c); } else { if(stack.empty() || stack.peek() != '(') { return "NO"; } else stack.pop(); } } if(stack.empty()) { return "YES"; } else { return "NO"; } } }

이번엔 우선순위 큐에 대해 간단하게 알아버고 관련된 문제를 하나 풀어보자.

우선순위 큐 (Priority Queue)

큐는 먼저 들어오는 데이터가 먼저 나가는 선입선출 (FIFO) 형식의 자료구조지만 우선순위 큐는 들어오는 순서에 상관 없이 우선순위가 높은 데이터가 먼저 나가는 자료구조이다.
우선순위 큐는 힙(Heap)을 이용하여 구현하는 것이 가장 효율적이다.
힙을 비롯한 구체적인 내용은 본 글에서는 다루지 않겠다.
 
Java에서는 다음과 같이 우선순위 큐를 사용할 수 있다.
PriorityQueue<자료형> queue = new PriorityQueue<>
기본적으로 제공되는 Priority Queue는 제일 작은 수가 맨 앞부터 배열된다.
// 기본형: 우선순위가 낮은 숫자가 먼저 나옴 (작은 숫자) PriorityQueue<Integer> pQ = new PriorityQueue<>();
큰 수가 맨 앞부터 배열되게 하려면 아래와 같이 선언하면 된다.
// 우선순위가 높은 숫자가 먼저 나옴 (큰 숫자) PriorityQueue<Integer> pQ = new PriorityQueue<>(Collections.reverseOrder());
 
그런데, 위의 기준이 아닌 새로운 정렬 기준을 직접 정의해야 하는 상황이 있을 수 있다.
이 경우는 아래 문제를 통해 살펴보자.
 

백준 #11286 : 절댓값 힙

문제

절댓값 힙은 다음과 같은 연산을 지원하는 자료구조이다.
  1. 배열에 정수 x (x ≠ 0)를 넣는다.
  1. 배열에서 절댓값이 가장 작은 값을 출력하고, 그 값을 배열에서 제거한다. 절댓값이 가장 작은 값이 여러개일 때는, 가장 작은 수를 출력하고, 그 값을 배열에서 제거한다.
프로그램은 처음에 비어있는 배열에서 시작하게 된다.

입력

첫째 줄에 연산의 개수 N(1≤N≤100,000)이 주어진다. 다음 N개의 줄에는 연산에 대한 정보를 나타내는 정수 x가 주어진다. 만약 x가 0이 아니라면 배열에 x라는 값을 넣는(추가하는) 연산이고, x가 0이라면 배열에서 절댓값이 가장 작은 값을 출력하고 그 값을 배열에서 제거하는 경우이다. 입력되는 정수는 -231보다 크고, 231보다 작다.

출력

입력에서 0이 주어진 회수만큼 답을 출력한다. 만약 배열이 비어 있는 경우인데 절댓값이 가장 작은 값을 출력하라고 한 경우에는 0을 출력하면 된다.

예제 입력 1

18 1 -1 0 0 0 1 1 -1 -1 2 -2 0 0 0 0 0 0 0

예제 출력 1

-1 1 0 -1 -1 1 1 -2 2 0

풀이 방향 설정

문제를 이해해보면 정수를 입력받아 배열에 저장하고, 만약 입력값이 ‘0’인 경우에는 배열에서 가장 절댓값이 작은(만약 최소 절댓값이 여러개라면 그 중 가장 작은 수, 즉 음수)를 출력하는 문제이다.
우선순위 큐를 이용해 절댓값이 작은 순으로 저장해두고, ‘0’이 입력될 때마다 큐의 가장 앞에 있는 수를 poll 하여 출력하면 문제를 풀 수 있다.
 
그런데 문제는 절댓값이 “작은 데이터 우선, 같은 경우 음수 우선” 이라는 정렬 기준을 직접 정의해주어야 한다는 것이다.
이는 우선순위 큐 선언 시 매개변수에 람다식*을 이용해 정렬 기준을 커스텀해주면 된다.
이때 Comparator 개념을 사용하는데, Comparator의 return 값은 다음과 같은 의미를 갖는다.
compare return
설명
양수
첫번째 매개변수가 더 큰 값으로 판단
0
같은 값으로 판단
음수
첫번째 매개변수가 더 작은 값으로 판단
* 람다식 : 람다 함수는 프로그래밍 언어에서 사용되는 개념으로 익명 함수(Anonymous functions)를 지칭. (화살표 함수)
 
따라서 위의 문제에서는 아래와 같이 매개변수를 작성해주면 우리가 원하는 대로 정렬할 수 있을 것이다.
PriorityQueue<Integer> MyQueue = new PriorityQueue<>((o1, o2) -> { //절댓값 작은 데이터 우선 int o1_abs = Math.abs(o1); int o2_abs = Math.abs(o2); //절댓값 같은 경우 음수 우선 if(o1_abs == o2_abs) { return o1 > o2 ? 1 : -1; //첫번째가 더 크면 양수 리턴, 두번째가 더 크면 음수 리턴 } else { return o1_abs - o2_abs; //첫번째 절댓값이 크면 양수 리턴, 두번째 절댓값이 크면 음수 리턴 } });
 
이제 문제는 간단해졌다. 0이 입력되면 PriorityQueue에서 front값을 출력해주기만 하면 된다.
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.PriorityQueue; public class Main { public static void main(String[] args) throws IOException { int N; BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); N = Integer.parseInt(br.readLine()); PriorityQueue<Integer> MyQueue = new PriorityQueue<>((o1, o2) -> int o1_abs = Math.abs(o1); int o2_abs = Math.abs(o2); if(o1_abs == o2_abs) { return o1 > o2 ? 1 : -1; } else { return o1_abs - o2_abs; } }); for(int i=0; i<N; i++) { int input = Integer.parseInt(br.readLine()); if(input==0) { if(MyQueue.isEmpty()) { System.out.println("0"); } else { System.out.println(MyQueue.poll()); } } else { MyQueue.add(input); } } } }

댓글

guest