알고리즘/Mo's 알고리즘

BOJ 백준 23238 Best Student

kyjdummy 2025. 5. 18. 12:38

 

문제 : https://www.acmicpc.net/problem/23238

 

[ 문제 요약 ]

  • 배열과 쿼리가 주어지며 배열 안에 최우수 한 사람의 고유 번호가 주어집니다.
  • 쿼리로 구간이 주어지면 그 안에서 최우수 한 사람의 고유 번호가 가장 많이 등장한 사람의 고유 번호를 출력합니다. 횟수가 같다면 고유 번호가 더 큰 사람의 번호를 출력합니다.

 

[ 테스트 케이스 설명 ]

5 3		// 일자(1<=100,000), 쿼리 수(1<=100,000)
2 1 2 1 1	// 1일부터N일 까지의 최우수 학생 ID 번호를 나타내는 N개의 양의 정수 (1<=10^9)
1 2		// s일 부터 e일 까지
1 4
1 5
//출력 : S,E 동안 가장 자주 최우수 학생으로 선발된 학생의 ID 출력, 여러명이면 그 중ID번호가 큰것 출력
2
2
1

 

 

[ 알고리즘 분류 ]

  • 오프라인 쿼리
  • 제곱근 분할법
  • mo’s 알고리즘

 

[ 문제 해설 ]

 

이 문제에서 주어지는 학생들의 ID가 최대 10억이므로 카운팅 배열로 처리하기 위해서는 값 압축을 해야 합니다. 압축할 때 편의상 map을 사용하는데 그러면 시간 초과를 받게 되어 이분 탐색으로 압축해야 합니다. 이분 탐색이 훨씬 빠릅니다.

 

또한 이 문제 시간제한이 1.2초로, 자바로 풀 때 추가로 주는 시간이 없어서, 주어지는 값을 입력받을 때 BufferedReader로 사용하면 시간 초과라 빠른 입력으로 입력을 받아야 겨우 시간 안에 통과할 수 있습니다. 아래는 빠른 입력을 할 수 있는 Reader 클래스와, 또는 int형 만 빠르게 입력을 받을 수 있는 read 함수 두 개를 올려드립니다.

 

// 빠른 입력이 가능한 Reader 클래스

class Reader {
    final int SIZE = 1 << 13;
    byte[] buffer = new byte[SIZE];
    int index, size;

    String nextString() throws Exception {
        StringBuilder sb = new StringBuilder();
        byte c;
        while ((c = read()) < 32) { if (size < 0) return "endLine"; }
        do sb.appendCodePoint(c);
        while ((c = read()) >= 32); // SPACE 분리라면 >로, 줄당 분리라면 >=로
        return sb.toString();
    }

    char nextChar() throws Exception {
        byte c;
        while ((c = read()) < 32); // SPACE 분리라면 <=로, SPACE 무시라면 <로
        return (char)c;
    }
    
    int nextInt() throws Exception {
        int n = 0;
        byte c;
        boolean isMinus = false;
        while ((c = read()) <= 32) { if (size < 0) return -1; }
        if (c == 45) { c = read(); isMinus = true; }
        do n = (n << 3) + (n << 1) + (c & 15);
        while (isNumber(c = read()));
        return isMinus ? ~n + 1 : n;
    }

    long nextLong() throws Exception {
        long n = 0;
        byte c;
        boolean isMinus = false;
        while ((c = read()) <= 32);
        if (c == 45) { c = read(); isMinus = true; }
        do n = (n << 3) + (n << 1) + (c & 15);
        while (isNumber(c = read()));
        return isMinus ? ~n + 1 : n;
    }

    double nextDouble() throws Exception {
        double n = 0, div = 1;
        byte c;
        boolean isMinus = false;
        while ((c = read()) <= 32) { if (size < 0) return -12345; }
        if (c == 45) { c = read(); isMinus = true; }
        else if (c == 46) { c = read(); }
        do n = (n * 10) + (c & 15);
        while (isNumber(c = read()));
        if (c == 46) { while (isNumber(c = read())){ n += (c - 48) / (div *= 10); }}
        return isMinus ? -n : n;
    }

    boolean isNumber(byte c) {
        return 47 < c && c < 58;
    }

    boolean isAlphabet(byte c){
        return (64 < c && c < 91) || (96 < c && c < 123);
    }

    byte read() throws Exception {
        if (index == size) {
            size = System.in.read(buffer, index = 0, SIZE);
            if (size < 0) buffer[0] = -1;
        }
        return buffer[index++];
    }
}

// int 범위 내에서 빠른 입력을 할 수 있는 함수
    static int read() throws Exception {
        int c, n = System.in.read() & 15;
        boolean m = n == 13;
        if (m)n = System.in.read() & 15;
        while ((c = System.in.read()) >= 48) {
        n = (n << 3) + (n << 1) + (c & 15);}
        return m ? ~n + 1 : n;
    }

 

 

학생의 ID를 압축한 후, 그 압축된 ID 값을 기준으로 제곱근 분할을 진행합니다.

 

각 학생들의 ID마다 몇 번 등장했는지 확인할 수 있는 카운팅 배열 cnt를 선언,

 

각 학생들의 ID를 제곱근 분할하여, 그 구간 안에 등장한 가장 큰 학생의 등장 횟수를 저장할 배열 fac 선언, 그리고 fac의 값이 감소할 때, 감소할지 말지를 추적할 배열 facFreq를 선언합니다.

 

fac 배열은 제일 큰 값만 저장하면 되므로 값의 증가에 추적이 필요 없으나 감소는 필요합니다.

 

facFreq 배열은 제곱근 구간 안에서 등장 횟수의 등장 횟수를 추적하는 배열입니다. 이 배열이 있어야, 같은 등장 횟수가 여러 개일 경우, 값을 제대로 추적할 수 있습니다.

 

최종적으로 값을 찾을 때는 제곱근 배열 fac에서 최대값을 찾아서 그 구간 안에 cnt 값이 가장 큰 노드 번호를 출력합니다. 이때 노드 번호는 압축한 노드 번호이므로 복원 후 저장해야 합니다.

 

이 문제는 제곱근 분할을 어디에 적용하고, 값의 변동을 어떻게 추적할지를 이해해야 합니다.

 

[ 정답 코드 ]

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

class Main{
	
	static int N, Q;
	static int idx;// 좌표 압축 후 가장 큰 학생의 번호
	static int sqrt;// 학생의 번호를 구간으로 나눌 제곱근 값
	static int ans[];// 최종 결과를 담을 배열
	static int arr[];// 처음 입력되는 학생의 번호
	static int brr[];// 좌표 압축에 사용할 배열
	static int cnt[];// idx : 학생의 번호, value : 그 학생의 등장 횟수
	static int fac[];// idx : 학생번호의 구간, value : 해당 구간의 등장한 최대 횟수
	static int facFreq[][];// 각 학생의 구간마다 등장 횟수의 등장횟수를 담는 배열, value : 등장 횟수
	static List<Query> query;
	
	public static void main(String[] args)throws Exception{
		Reader in = new Reader();
		N = in.nextInt();
		Q = in.nextInt();
		sqrt = (int)Math.sqrt(N);
		ans = new int[Q + 1];
		arr = new int[N + 1];
		brr = new int[N + 1];
		cnt = new int[100_001];
		fac = new int[N / sqrt + 1];
		facFreq = new int[N / sqrt + 1][N + 1];
		query = new ArrayList<>();
		// 좌표 압축을 위해 brr배열에 같은 값을 담음
		for(int i=1; i<=N; i++)
			arr[i] = brr[i] = in.nextInt();
		
		Arrays.sort(brr);// 좌표 압축을 위한 정렬
		
		// 좌표 압축 및 압축 값의 가장 큰 값을 idx에 저장
		for(int i=1; i<=N; i++)
		{
			arr[i] = binarySearch(arr[i]);
			if(idx < arr[i])
				idx = arr[i];
		}
		// 쿼리를 입력 받음
		for(int i=1; i<=Q; i++)
		{
			int s = in.nextInt();
			int e = in.nextInt();
			query.add(new Query(s, e, i, s / sqrt));
		}
		// mo's 정렬 
		Collections.sort(query);
		
		int s = 1;
		int e = 0;
		
		for(Query q : query)
		{
			while(e < q.e) plus(arr[++e]);
			while(q.s < s) plus(arr[--s]);
			while(q.e < e) minus(arr[e--]);
			while(s < q.s) minus(arr[s++]);
			// ans에 값을 담을 때 값 압축 전 값으로 저장
			ans[q.idx] = brr[ find() ]; 
		}
		
		StringBuilder sb = new StringBuilder();
		
		for(int i=1; i<=Q; i++)
			sb.append(ans[i]).append('\n');
		
		System.out.print(sb);
	}
	static void minus(int nodeNum) {
		// 해당 노드 번호의 구간을 구함
		int facIdx = nodeNum / sqrt;
		// 해당 구간의 등장 횟수(cnt[nodeNum])이 0이 되었고,
		// 그 등장 횟수(cnt[nodeNum])이 해당 구간(fac[facIdx])의 최댓 값이였을 경우
		// 그 최댓 값(fac[facIDx])을 -1 감소 시킴
		if(--facFreq[facIdx][cnt[nodeNum]] == 0 && fac[facIdx] == cnt[nodeNum])
			--fac[facIdx];
		// 해당 학생 등장 횟수 하나 감소
		--cnt[nodeNum];
	}
	static void plus(int nodeNum) {
		int facIdx = nodeNum / sqrt;
		// 해당 학생 등장 횟수 하나 추가
		++cnt[nodeNum];
		// 해당 학생이 있는 구간의 최댓 값 갱신
		fac[facIdx] = Math.max(fac[facIdx], cnt[nodeNum]);
		// 해당 학생이 있는 구간의 해당 등장횟수(cnt[nodeNum]) 값 증가
		++facFreq[facIdx][cnt[nodeNum]];
	}
	static int find() {
		int max = 0;
		// 구간에서 가장 큰 값을 모두 순회하여 찾아냄
		for(int i=0; i<fac.length; i++)
			max = Math.max(fac[i], max);
		
		// 구간에 대해 역으로 순회하며, max와 같은 첫번 째 값만 연산대상
		for(int i=fac.length - 1; i>=0; i--)
		{
			if(fac[i] != max)
				continue;
			// 해당 구간의 s와 e를 구해서 그중 max와 같은 최대 등장 횟수를
			// 갖는 학생 아이디를 반환함
			int s = i * sqrt;
			int e = Math.min(s + sqrt, idx);
			while(e >= s)
			{
				if(cnt[e] == max)
					return e;
				--e;
			}
		}
		return 0;
	}
	static int binarySearch(int target) {
		int s = 1;
		int e = N;
		int res = 0;
		
		while(s <= e)
		{
			int mid = (s + e) >> 1;

			if(brr[mid] < target)
				s = mid + 1;
			else
			{
				res = mid;
				e = mid - 1;
			}
		}
		
		return res;
	}
	static class Query implements Comparable<Query>{
		int s, e, idx, fac;
		Query(int s, int e, int idx, int fac){
			this.s=s;
			this.e=e;
			this.idx=idx;
			this.fac=fac;
		}
		@Override
		public int compareTo(Query o) {
			if(fac != o.fac)
				return fac - o.fac;
			
			if((fac&1) == 0)
				return e - o.e;
			
			return o.e - e;
		}
	}
}

class Reader {
    final int SIZE = 1 << 13;
    byte[] buffer = new byte[SIZE];
    int index, size;

    int nextInt() throws Exception {
        int n = 0;
        byte c;
        boolean isMinus = false;
        while ((c = read()) <= 32) { if (size < 0) return -1; }
        if (c == 45) { c = read(); isMinus = true; }
        do n = (n << 3) + (n << 1) + (c & 15);
        while (isNumber(c = read()));
        return isMinus ? ~n + 1 : n;
    }

    boolean isNumber(byte c) {
        return 47 < c && c < 58;
    }

    byte read() throws Exception {
        if (index == size) {
            size = System.in.read(buffer, index = 0, SIZE);
            if (size < 0) buffer[0] = -1;
        }
        return buffer[index++];
    }
}