힙(Heap)은 완전 이진트리
의 일종으로서 일반적으로 root
에 최소값이 오는 최소힙
과 최대값이 오는 최대힙
으로 구분된다.
같은 완전이진트리
라 그런지 이진탐색트리
와도 비슷한데, 이쪽의 경우 좌우와 상관없이 부모노드가 자식노드보다 작다/크다
의 조건만을 갖는 트리이기 때문에 조금 다르다.
이를 일종의 반 정렬상태, 느슨한 정렬상태라고 부르기도 한다.
알고리즘 문제에서, 힙은 주로 우선순위 큐
자료구조를 사용하기 위해 사용된다.
자료구조 끝부분에서 작업이 많다면, 스택을 많이 사용한다.
마찬가지로 PriorityQueue
문제의 경우에도 뭔가 돋보이는 흐름이 존재한다.
그것은 바로
다양한 "시점"에서의 최대값, 혹은 최소값을 요구한다는점.
이다.
이 말이 무슨뜻이냐, 하면 사실 PriorityQueue
의 존재의의와 관련있다.
만약 모든 값이 비어있거나 다 넣은 후에, 최대값, 최소값을 판별하고 싶다면 PriorityQueue
를 사용할 필요가 있을까?
그냥 한바퀴 순회하면서 O(n)
으로 찾아버리면 되는데PriorityQueue
는 다 담는데만 Nlog(N)
의 시간이 필요하다.
PriorityQueue
를 사용하는 목적의 핵심은, 바로 삽입작업과 최대/최소값을 꺼내는 작업이, 각각 O(logN)
, O(1)
밖에 걸리지 않는다는 것이다.
그렇기 때문에 주로 다음과 같은 경우에 Heap
, PriorityQueue
가 사용된다.
1. 힙에 값을 삽입하는 시간에 따라, 여러 시점으로 나뉘어진다.
2. 각 시점마다 최대값 / 최소값을 요구한다.
3. 위와같은 삽입과, 최대/최소 확인 작업이 엄~청 많이 일어난다.
JAVA
PriorityQueue pq = new PriorityQueue();
힙 문제의 시작이자 끝.
일반 큐처럼 add
해주기만 하면 내부적으로 알아서 힙 구조를 만들어준다. 즉 add
로 넣고 peek / poll
로 확인하고 꺼내기만 하면 된다.
어마어마한 기능에 비해, 굉장히 편리한 자료구조라고 할 수 있을 것 같다.
PriorityQueue pq = new PriorityQueue(new Comparator(){
@Override
public int compare(Integer o1, Integer o2) {
return o2-o1;
}
});
한가지 유의할 점은 일반적으로 PiorityQueue
를 선언했을 때, 무조건 최소힙
으로 생성된다는 것.
이는 굳이 Integer
가 아니라, Comparable
인터페이스를 구현한 다른 요소를 넣어도 마찬가지다.
그렇기 때문에 최대힙을 만들어주고 싶다면 위처럼 Comparator
를 직접 구현해서 매개변수로 넣어주던가, 기본 자료형이 아니라면 Comparable
인터페이스로 구현한 compareTo()
함수를 알아서 손보면 된다!
힙 파트의 몸풀기 문제!
모든 음식들을 PriorityQueue
에 넣은 후 두 개씩 뽑아서 섞고, 다시 넣어주는걸 반복하면 된다!
import java.util.*;
class Solution {
public int solution(int[] scoville, int K) {
int answer = 0;
PriorityQueue<Integer> pq = new PriorityQueue<>();
for(int s : scoville) {
pq.add(s);
}
while(pq.peek()<K) {
if(pq.size()<2) {
return -1;
}
int f1 = pq.poll();
int f2 = pq.poll();
pq.add(f1 + f2*2);
answer++;
}
return answer;
}
}
쌀이 X톤 남아있다.
이 한 문장에 대한 해석이 중요한 문제.
쌀이 X톤 남아있다는 것은, X일 만큼 공급없이 공장을 가동할 수 있다는 뜻이다. 이를 한번 더 해석하면
최소 X일 안에는 쌀을 공급받아야 한다.
라는 조건에 이르게 된다.
아하 그렇구나, X일 안의 공급날 중 하나를 정해서 공급받으면 되겠구나!
그렇다면 언제를 선택해야 할까? 문제는 최소한의 횟수
로 공급받길 원하고 있다. 최소한의 횟수로 공급하려면, 달리말해 가장 공급량이 많은 날을 선택해야 한다는 뜻으로 해석할 수 있다.
조건의 해석이 끝났으니 이제 코드로 옮기기만 하면 된다.
Priority
는 쌀의 공급량. 일 수를 조금씩 늘려가면서 큐에 값을 삽입하고 하나씩 꺼내면서 일 수를 늘려나가면 된다.
전체 쌀의 공급량이 충분해지면, 즉 K보다 많아지면 프로그램을 종료한다.
import java.util.*;
class Solution {
public int solution(int stock, int[] dates, int[] supplies, int k) {
int answer = 0;
PriorityQueue<Integer> pq = new PriorityQueue<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
// TODO Auto-generated method stub
return o2-o1;
}
});
int i = 0;
while(stock<k) {
while(i<dates.length && dates[i]<=stock) {
pq.add(supplies[i]);
i++;
}
stock += pq.poll();
answer++;
}
return answer;
}
}
드디어 등장한 Level 3
문제.
흐름은 라면공장과 크게 다르지 않다. 앞서 말한 것처럼 문제를 여러 시점으로 나눠서, 각 시점에 대해 반복문을 작성하고, 값을 확인해주면 된다.
이 문제에선 먼저 넣을 수 있는 작업 중, 가장 작업량이 적은 작업을 넣고, 시간이 흐른 후 다시 넣을 수 있는 작업을 큐에 넣어준다.
한가지 중요한 점은, 당장에 PriorityQueue
가 비어있더라도, 넣을 작업이 남아있다면, 시간이 흘러야 한다는 것.
그리고 시작시간을 확인하기 위해 Job
을 별도의 클래스로 생성해둬야 한다는 것 정도이다.
import java.util.Arrays;
import java.util.Comparator;
import java.util.PriorityQueue;
class Solution {
public int solution(int[][] jobs) {
int current = 0;
int idx = 0;
int total = 0;
int len = jobs.length;
Arrays.sort(jobs, new Comparator<>() {
@Override
public int compare(int[] o1, int[] o2) {
// TODO Auto-generated method stub
return o1[0] - o2[0];
}
});
PriorityQueue<Job> pq = new PriorityQueue<>();
while(true) {
while(idx < len && jobs[idx][0] <= current) {
pq.add(new Job(jobs[idx][0], jobs[idx][1]));
idx++;
}
if(pq.isEmpty()) {
if(idx == len){
break;
}else {
current++;
}
}else {
Job j = pq.poll();
current += j.time;
total += current - j.start;
}
}
return total / len;
}
}
class Job implements Comparable<Job>{
int start;
int time;
Job(int start, int time){
this.start = start; this.time = time;
}
@Override
public int compareTo(Job o) {
// TODO Auto-generated method stub
return this.time - o.time;
}
}
여담으로 처음 풀 때, 아래 조건을 확인하지 못해서, 더 복잡하고 이상한 코드를 짜고 있었다.
앞으로 문제풀기전에 조건을 꼼꼼히 읽도록하자....💦
하드디스크가 작업을 수행하고 있지 않을 때에는 먼저 요청이 들어온 작업부터 처리합니다.
개인적으로 가장 까다로운 문제였다.
우선순위큐를 양방향으로 구현을 해야한다? 가장 먼저 떠오른 방법은 우선순위 큐를 각각 최대힙
, 최소힙
으로 두 개 구현하는 것이다.
그리고 나서 연산을 코딩하기 위해 시간복잡도를 확인했다.
operations
의 길이가 최대 100만
, 즉 연산시 모든 값을 훑는 작업은 O(N^2)
이 되어 시간초과가 난다.
예를들어 값을 50만개 넣고 50만개 빼는 연산을 한다고 가정하자. 넣는데는 아무 문제 없겠지만, 빼는 작업에서 O(n)
짜리 코드를 짜다가는 50만*50만
이라는 시간에 걸려서 시간초과가 날 것이다.
결론적으로, 값을 빼는데 O(n)
아래의 시간복잡도를 갖는 코드가 필요하다. 그렇게나 빠른, 자료구조를 생각해봐야 사실 몇 개 없다.
여기서는 HashMap
을 이용해보기로 했다. 이전 글에서도 작성했지만, 삽입, 탐색에 있어서 O(1)
이라는 시간복잡도를 갖는 사기적인 자료구조다.
아무튼 문제에선 최대힙, 최소힙에 값을 다 넣는 것과 더불어, 각 숫자의 개수에 대해서 HashMap
에 저장했다.
만약 최소힙이나 최대힙에서 값을 제거할 때, HashMap
에 값이 없다면, 그것은 나머지 한 쪽을 제거할 때 같이 제거되지 못한 이른바 유령 값
이다.
만약 유령 값이 발견되면, 무시하고 뽑아버리고 계속 진행하면 된다.
말은 쉽게 했지만, 막상 풀 땐 굉장히 많이 고민했던 문제다.
import java.util.Comparator;
import java.util.HashMap;
import java.util.PriorityQueue;
class Solution {
public int[] solution(String[] operations) {
HashMap<Integer, Integer> map = new HashMap<>();
PriorityQueue<Integer> minPq = new PriorityQueue<>();
PriorityQueue<Integer> maxPq = new PriorityQueue<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
// TODO Auto-generated method stub
return o2-o1;
}
});
int size = 0;
for(String oper : operations) {
String[] ops = oper.split(" ");
char command = ops[0].charAt(0);
int number = Integer.parseInt(ops[1]);
if(command == 'I') {
minPq.add(number);
maxPq.add(number);
if(map.containsKey(number)){
map.replace(number, map.get(number)+1);
}else {
map.put(number, 1);
}
size++;
}else {
if(size==0) {
continue;
}else if(number == 1) {
while(!map.containsKey(maxPq.peek())) {
maxPq.poll();
}
int tmp = maxPq.poll();
if(map.get(tmp)==1) {
map.remove(tmp);
}else {
map.replace(tmp, map.get(tmp)-1);
}
}else {
while(!map.containsKey(minPq.peek())) {
minPq.poll();
}
int tmp = minPq.poll();
if(map.get(tmp)==1) {
map.remove(tmp);
}else {
map.replace(tmp, map.get(tmp)-1);
}
}
size--;
if(size == 0) {
minPq = new PriorityQueue<>();
maxPq = new PriorityQueue<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
// TODO Auto-generated method stub
return o2-o1;
}
});
}
}
}
int[] answer = new int[2];
if(size ==0) {
answer[0] = 0; answer[1] = 0;
}else {
while(!map.containsKey(maxPq.peek())) {
maxPq.poll();
}
answer[0] = maxPq.poll();
while(!map.containsKey(minPq.peek())) {
minPq.poll();
}
answer[1] = minPq.poll();
}
return answer;
}
}
다른사람의 풀이를 확인하던 중, 한쪽 큐에서 값을 꺼낼 때마다, 반대쪽 큐에서 해당 값에 remove
연산을 사용해서 푸는 코드를 발견했다.
궁금해서 StackOverFlow
에 들어가서도 찾아봤지만, remove
연산의 시간복잡도는 O(n)
이 맞다. 즉 내가 한 계산대로라면 전체 시간복잡도가 O(n^2)
가 되어 시간초과가 나야하는데.. 어찌된 일인지, 잘 동작했던 것 같다.
이 부분에 대해서 이유는 아직 밝히지 못했고, 나중에 좀 더 찾아봐야겠다...