백준 1931번 회의실 배정

 

1931번: 회의실 배정

(1,4), (5,7), (8,11), (12,14) 를 이용할 수 있다.

www.acmicpc.net

 

📖 문제

 

그리디 알고리즘의 대표적인 예시 문제라고 해도 될 문제입니다. 그래서 풀이에 대해서 조금 정리해보려고 합니다.

 

 

 

🔎 접근 방법

  • 회의 일정에는 시작 시간과 종료 시간이 존재합니다.
  • 회의 일정은 겹치면 안되고, 최대한 많은 회의를 진행해야 합니다.

 

위 조건을 생각해봤을 때, 이전 회의 일정의 종료 시간과 겹치지 않아야 하고 이후 일정의 시작 시간과 겹치지 않아야 합니다.

즉, 회의 일정을 종료 시간 기준으로 오름차순 정렬하여 종료 시간이 빠른 일정부터 해결해나가면 정답에 접근할 수 있습니다.

 

1. 회의 일정을 저장할 수 있는 클래스 선언 후 Comparable 구현하기

배열을 사용하는 풀이도 많았지만 가장 직관적이고 자바라는 언어 특성을 사용할 수 있도록 Meeting 클래스를 만들어 객체의 비교 기준을 정의할 수 있는 Comparable를 구현해 줬습니다.

class Meeting implements Comparable<Meeting> {
    int start;
    int end;

    public Meeting(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    public int compareTo(Meeting obj) {
        // 종료 시간이 동일하다면 시작 시점이 빠른 순으로 정렬
        if (this.end == obj.end) {
            return this.start - obj.start;
        }
        // 종료 시점이 빠른 순으로 회의 일정을 정렬
        return this.end - obj.end;
    }
}

start와 end는 회의의 시작 시간과 종료 시간이고, Comparable을 구현하기 위한 compareTo() 메소드를 보면 정렬 조건을 확인할 수 있습니다. 

 

위에서는 정렬 조건을 종료 시간만을 언급했지만, 문제에서 "회의의 시작시간과 끝나는 시간이 같을 수도 있다."란 조건이 존재하기 때문에 회의 시작 시간도 신경 써줘야 합니다. 반례를 한번 생각해 볼까요

[입력]
4
3 5
1 3
8 8
5 8

위처럼 만약 입력이 들어왔다고 했을 때, 정답이 몇일까요? 정답은 4입니다. 하지만 회의의 종료 시간만을 고려해서 정렬하게 되면 3이라는 결과를 얻을 수도 있습니다. 어떻게 3이 나올 수 있었을까요. 회의의 종료시간을 기준으로 정렬을 해보겠습니다.

Meeting A(1, 3) -> Meeting B(3, 5) -> Meeting C(8, 8) -> Meeting D(5, 8)

회의의 종료 시간을 오름차순으로 정렬했습니다. 이제 처음 데이터부터 확인하면서 회의 일정을 확인해 보면 아래와 같습니다.

1시 - 3시 : Meeting A
3시 - 5시 : Meeting B
8시 - 8시 : Meeting C

Meeting D의 시작 시간인 5시가 이전 미팅 Meeting C의 종료 시간 8시보다 작기 때문에 이어서 회의를 진행할 수 없습니다.

이런 반례가 발생할 수 있기 때문에 해당 문제는 회의의 종료 시간도 오름차순으로 정렬해줘야 하고, 회의 종료 시간이 같을 때는 시작 시간으로 오름차순 정렬을 해줘야 합니다.

 

 

2. 입력받은 데이터로 Meeting 인스턴스 만들어서 PriorityQueue에 넣기

BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st;
PriorityQueue<Meeting> meetings = new PriorityQueue<>();
int n = Integer.parseInt(br.readLine());
for (int i = 0; i < n; i++) {
    st = new StringTokenizer(br.readLine());
    int start = Integer.parseInt(st.nextToken());
    int end = Integer.parseInt(st.nextToken());
    meetings.offer(new Meeting(start, end));
}

Meeting 인스턴스를 저장해서 바로 우리가 정의한 Comparable의 정렬 조건대로 정렬하기 위해서 우선순위 큐(Priority Queue)를 사용했습니다. 물론 ArrayList를 사용해서 Collections.sort()를 사용해 줘도 됩니다.

 

 

3. Priority Queue의 Meeting 객체를 하나씩 꺼내오면서 이전 Meeting의 종료 시간과 비교하여 정답을 확인

Meeting prevMeeting = meetings.poll();
int answer = 1; // 가장 처음 회의 일정은 추가되기 때문에 1부터 시작
while (!meetings.isEmpty()) {
    Meeting meeting = meetings.poll();
    // 현재 회의 시작 시간이 이전 회의 일정의 종료 시간과 동일하거나 크면 정답에 추가
    if (prevMeeting.end <= meeting.start) {
        answer++;
        // 현재 회의를 이전 회의로 지정
        prevMeeting = meeting;
    }
}
System.out.println(answer);

prevMeeting(이전 회의)에 가장 우선순위가 높은 회의 일정을 하나 넣어줍니다. N은 1 이상이기 때문에 비어있는 검사를 하지 않고 바로 poll()을 사용해서 넣어줬습니다. 방금 꺼내준 회의 일정은 바로 추가되기 때문에 answer는 1로 초기화합니다.

 

그리고 이제 Priority Queue가 비워질 때까지 반복문을 돌면서 회의 일정을 확인합니다. 만약 이전 회의의 종료 시간보다 현재 회의의 시작 시간이 크거나 같다면 정답 카운트를 증가시키고, prevMeeting를 다시 현재 회의 meeting으로 갱신합니다.

 

 

 

🧑🏻‍💻 전체 코드

import java.io.*;
import java.util.PriorityQueue;
import java.util.StringTokenizer;

public class Main {

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st;
        PriorityQueue<Meeting> meetings = new PriorityQueue<>();
        int n = Integer.parseInt(br.readLine());
        for (int i = 0; i < n; i++) {
            st = new StringTokenizer(br.readLine());
            int start = Integer.parseInt(st.nextToken());
            int end = Integer.parseInt(st.nextToken());
            meetings.offer(new Meeting(start, end));
        }
        Meeting prevMeeting = meetings.poll();
        int answer = 1; // 가장 처음 회의 일정은 추가되기 때문에 1부터 시작
        while (!meetings.isEmpty()) {
            Meeting meeting = meetings.poll();
            // 현재 회의 시작 시간이 이전 회의 일정의 종료 시간과 동일하거나 크면 정답에 추가
            if (prevMeeting.end <= meeting.start) {
                answer++;
                // 현재 회의를 이전 회의로 지정
                prevMeeting = meeting;
            }
        }
        System.out.println(answer);
    }
}

class Meeting implements Comparable<Meeting> {
    int start;
    int end;

    public Meeting(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    public int compareTo(Meeting obj) {
        // 종료 시간이 동일하다면 시작 시점이 빠른 순으로 정렬
        if (this.end == obj.end) {
            return this.start - obj.start;
        }
        // 종료 시점이 빠른 순으로 회의 일정을 정렬
        return this.end - obj.end;
    }
}

DFS 문제 따로 BFS 문제 따로 풀어본 적은 있는데 한 문제에서 동일한 그래프로 DFS와 BFS를 모두 사용해야 하는 문제였습니다.

 

🔗 문제 링크

https://www.acmicpc.net/problem/1260

 

1260번: DFS와 BFS

첫째 줄에 정점의 개수 N(1 ≤ N ≤ 1,000), 간선의 개수 M(1 ≤ M ≤ 10,000), 탐색을 시작할 정점의 번호 V가 주어진다. 다음 M개의 줄에는 간선이 연결하는 두 정점의 번호가 주어진다. 어떤 두 정점 사

www.acmicpc.net

 

 

 

🔍 문제 분석

  • 첫 째줄에 정점의 개수(N, 노드의 개수)와 간선의 개수(M) 그리고 탐색 시작 정점(V)이 공백으로 주어집니다.
  • 그다음 간선의 개수(M)만큼의 입력이 주어지고, 각 입력 줄에는 연결되어 있는 Node와 Node가 공백으로 주어집니다.
  • 정점의 번호는 1번부터 N번까지 이며, DFS 탐색 결과와 BFS 탐색 결과를 출력해 주면 되는 문제입니다.
  • 여기서 신경써줘야 할 점은 연결된 Node가 여러 개라면 크기가 작은 것부터 탐색을 시작합니다.

 

 

 

🖥️ 문제 풀이 코드

import java.io.*;
import java.util.*;

public class Main {
    static int n;
    static StringBuilder builder;
    static boolean[] visited;

    public static void main(String[] args) throws IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

        String[] inputData = reader.readLine().split(" ");
        n = Integer.parseInt(inputData[0]);
        int m = Integer.parseInt(inputData[1]);
        int v = Integer.parseInt(inputData[2]);

        // step 1. 그래프 만들기
        int[][] graph = new int[n + 1][n + 1];
        for (int i = 0; i < m; i++) {
            inputData = reader.readLine().split(" ");
            int startNum = Integer.parseInt(inputData[0]);
            int endNum = Integer.parseInt(inputData[1]);

            graph[startNum][endNum] = 1;
            graph[endNum][startNum] = 1;
        }
        builder = new StringBuilder();
        visited = new boolean[n + 1];
        dfs(v, graph);
        builder.append("\n");
        visited = new boolean[n + 1];
        bfs(v, graph);
        System.out.println(builder);
    }

    // step 2. BFS 탐색 (Queue)
    public static void bfs(int start, int[][] graph) {
        Queue<Integer> queue = new LinkedList<>();
        queue.offer(start);
        visited[start] = true;

        while (!queue.isEmpty()) {
            int vortex = queue.poll();
            builder.append(vortex).append(" ");
            int[] vortexWithNodes = graph[vortex];

            for (int i = 1; i <= n; i++) {
                if (vortexWithNodes[i] == 1 && !visited[i]) {
                    queue.offer(i);
                    visited[i] = true;
                }
            }
        }
    }

    // step 3. DFS 탐색 (재귀)
    public static void dfs(int start, int[][] graph) {
        visited[start] = true;
        builder.append(start).append(" ");
        int[] startWithNodes = graph[start];

        for (int i = 1; i <= n; i++) {
            if (startWithNodes[i] == 1 && !visited[i]) {
                dfs(i, graph);
            }
        }
    }
}

 

  • 처음에 Graph를 어떤 방식으로 해놓을지 고민했었습니다. 첫 번째 방법은 위에 구현한 방법과 같은 인접 행렬 방법을 사용하는 방법이 있고, 두 번째는 각 노드에 연결된 점들을 리스트에 모아두는 인접 리스트 방법이 존재합니다.
  • 인접 행렬 방법을 선택한 이유는 간단합니다. "연결된 노드가 여러 개라면 크기가 작은 노드부터 방문합니다."라는 조건에서 연결된 Node들이 정렬되어 있어야 된다는 것 때문입니다.
  • 만약 인접 리스트 방법을 사용했다면, 연결된 노드들을 정렬해 주는 Collections.sort()를 사용해야 됐습니다.

 

 

 

🧑🏻‍💻 문제 풀이 전략

1. BFS는 Queue를 이용해서 구현했습니다.

public static void bfs(int start, int[][] graph) {
    Queue<Integer> queue = new LinkedList<>();
    queue.offer(start);
    visited[start] = true;

    while (!queue.isEmpty()) {
        int vortex = queue.poll();
        builder.append(vortex).append(" ");
        int[] vortexWithNodes = graph[vortex];

        for (int i = 1; i <= n; i++) {
            if (vortexWithNodes[i] == 1 && !visited[i]) { // <-- 이미 탐색한 노드인지 확인
                queue.offer(i);
                visited[i] = true;
            }
        }
    }
}
  • 큐(Queue)를 통해서 탐색 노드에 연결된 노드들을 모두 탐색하고, 탐색이 가능한 데이터(값이 1이고, 방문기록이 false인 노드)라면 Queue에 모두 넣습니다.
  • 이때 Queue에 넣으면서 해당 노드의 방문처리를 해줘야 합니다.
  • Queue에 데이터가 없을 때까지 반복하여 BFS 탐색을 진행합니다.

 

2. DFS는 재귀를 이용해서 구현했습니다.

public static void dfs(int start, int[][] graph) {
    visited[start] = true;
    builder.append(start).append(" ");
    int[] startWithNodes = graph[start];

    for (int i = 1; i <= n; i++) {
        if (startWithNodes[i] == 1 && !visited[i]) { // <-- 이미 탐색한 노드인지 확인
            dfs(i, graph);
        }
    }
}
  • 재귀를 통해서 가장 깊은 곳까지 탐색을 하고, 더이상 탐색할 데이터가 없으면 빠져나오게 됩니다.

 

3. Graph와 Visited는 공유하는 데이터입니다.

  • 그래프 탐색을 하는데 있어서 Graph의 내부 값을 변경시키지는 않습니다.
  • 하지만 Visited는 값을 false에서 true로 변경시키며 탐색하기 때문에 static으로 선언해서 탐색 시작 전에 new boolean[]으로 초기화를 진행해줬습니다.
visited = new boolean[n + 1]; // <-- 탐색 전 초기화
dfs(v, graph);

visited = new boolean[n + 1]; // <-- 탐색 전 초기화
bfs(v, graph);

 

https://school.programmers.co.kr/learn/courses/30/lessons/160585

 

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 

이번 문제는 게임 과정에서 생길 수 있는 모든 반례를 찾는 문제로 판단된다.

 

문제를 간단하게 한번 분석해 보면 머쓱이가 혼자서 O와 X로 빙고 게임을 진행한다. 진행 중인 게임 상황이 입력으로 주어졌을 때, 그 게임이 정상적으로 나올 수 있는 상황이라면 1을 반환, 아니면 0을 반환하면 된다.

 

즉, 빙고 게임에서 나올 수 있는 모든 반례를 찾아서 해결해야 된다는 의미이다.

 

문제 분석

1. 후공(X)이 선공(O) 보다 먼저 나온 경우

이 경우는 간단하게 해결할 수 있다. 후공이 만약 선공보다 많이 나왔다면 X의 개수가 O의 개수보다 많을 것이다. 

if (xCount > oCount) {
    return 0;
}

 

 

2. 게임 순서가 선공(O), 후공(X) 한 번씩 번갈아서 진행되지 않은 경우

말이 조금 어려울 수 있는데, 선공 -> 후공 -> 선공 -> 후공 이렇게 되어야 하는데, 선공 -> 선공 -> 후공 -> 선공 이런 식으로 되었을 때를 처리해줘야 한다. 

 

후공(X)이 여러번 나왔을 경우는 1번 반례에서 해결이 되기 때문에 선공(O)이 여러 번 나왔을 경우만 신경 써주면 된다. 순서대로 게임이 진행되었다면 선공(O) 개수에서 후공(X) 개수를 뺐을 때 1 이하의 수가 나와야 한다. 

if (oCount - xCount > 1) {
    return 0;
}

 

 

3. 빙고를 다 맞춰 게임이 종료되었을 때의 조건

3.1 선공(O)이 승리한 경우

선공이 승리했을 경우를 보면 항상 O의 개수가 X의 개수보다 많아야 한다. 이 반대는 모두 불가능한 경우의 수이다.

if (O가 승리했을 때) {
    if (oCount <= xCount) {
        return 0;
    }
}

 

3.2 후공(X)이 승리한 경우

후공이 승리했을 경우를 보면 항상 O의 개수와 X의 개수가 동일하다. 개수가 다르다면 모두 불가능한 경우의 수이다.

if (X가 승리했을 때) {
    if (oCount != xCount) {
        return 0;
    }
}

 

3.3 둘 다 승리한 경우

이 경우를 가장 먼저 넣어줘야 한다. O도 승리하고, X도 승리했을 경우이다. 말이 안 되기 때문에 바로 0을 리턴해주자.

if (둘 다 승리했을 때) {
    return 0;
}

 

 

처음에 풀었던 풀이

class Solution {
    public int solution(String[] board) {
        int oCnt = 0;
        int xCnt = 0;

        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                if (board[i].charAt(j) == 'O') {
                    oCnt++;
                } else if (board[i].charAt(j) == 'X') {
                    xCnt++;
                }
            }
        }

        // 후공이 선공보다 먼저 나온경우
        if (xCnt > oCnt) {
            return 0;
        }

        // 순서대로 하나씩 증가하지 않은 경우
        if (oCnt - xCnt > 1) {
            return 0;
        }

        // 게임이 끝났는데 계속 진행된 경우
        String winner = getWinner(board);
        if (winner.equals("Error")) {
            return 0;
        }
        if (winner.equals("oWin")) {
            if (oCnt <= xCnt) {
                return 0;
            }
        }
        if (winner.equals("xWin")) {
            if (oCnt != xCnt) {
                return 0;
            }
        }

        return 1;
    }

    public String getWinner(String[] board) {
        boolean oWin = false;
        boolean xWin = false;
        for (int i = 0; i < 3; i++) {
            // 가로로 일치하는 경우
            if (board[i].equals("OOO")) {
                oWin = true;
            }
            // 세로로 일치하는 경우
            if (board[0].charAt(i) == 'O'
                && board[0].charAt(i) == board[1].charAt(i)
                && board[1].charAt(i) == board[2].charAt(i)) {
                oWin = true;
            }
        }

        if (board[0].charAt(0) == 'O' 
            && board[0].charAt(0) == board[1].charAt(1)
            && board[1].charAt(1) == board[2].charAt(2)) {
            oWin = true;
        }

        if (board[0].charAt(2) == 'O'
            && board[0].charAt(2) == board[1].charAt(1)
            && board[1].charAt(1) == board[2].charAt(0)) {
            oWin = true;
        }

        for (int i = 0; i < 3; i++) {
            // 가로로 일치하는 경우
            if (board[i].equals("XXX")) {
                xWin = true;
            }
            // 세로로 일치하는 경우
            if (board[0].charAt(i) == 'X'
                && board[0].charAt(i) == board[1].charAt(i)
                && board[1].charAt(i) == board[2].charAt(i)) {
                xWin = true;
            }
        }

        if (board[0].charAt(0) == 'X' 
            && board[0].charAt(0) == board[1].charAt(1)
            && board[1].charAt(1) == board[2].charAt(2)) {
            xWin = true;
        }

        if (board[0].charAt(2) == 'X'
            && board[0].charAt(2) == board[1].charAt(1)
            && board[1].charAt(1) == board[2].charAt(0)) {
            xWin = true;
        }

        if (oWin && xWin) {
            return "Error";
        } else if (oWin) {
            return "oWin";
        } else if (xWin) {
            return "xWin";
        }
        return "noWin";
    }
}

getWinner()라는 메소드를 만들어서 둘 다 승리했다면 "Error"를 반환하고, 선공이 승리했다면 "oWin"을 반환하고, 후공이 승리했다면 "xWin"을 반환하도록 했다. 

 

문제는 모두 통과되었지만 이렇게 작성했을 때 딱 눈에 보기에도 반복되는 코드가 너무 많고, 가독성이 떨어졌던 문제가 있었다. 

 

 

 

문제점 개선 후 리펙토링한 코드

class Solution {
    private final char O = 'O';
    private final char X = 'X';
    public int solution(String[] board) {
        int oCnt = 0;
        int xCnt = 0;
        
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                if (board[i].charAt(j) == O) {
                    oCnt++;
                } else if (board[i].charAt(j) == X) {
                    xCnt++;
                }
            }
        }
        
        // 후공이 선공보다 먼저 나온경우
        if (xCnt > oCnt) {
            return 0;
        }
        
        // 순서대로 하나씩 증가하지 않은 경우
        if (oCnt - xCnt > 1) {
            return 0;
        }
        
        // 게임이 끝났는데 계속 진행된 경우
        boolean oWin = getWinner(board, O);
        boolean xWin = getWinner(board, X);
        if (oWin && xWin) {
            return 0;
        }
        if (oWin && oCnt <= xCnt) {
            return 0;
        }
        if (xWin && oCnt != xCnt) {
            return 0;
        }
        
        return 1;
    }
    
    public boolean getWinner(String[] board, char value) {
        for (int i = 0; i < 3; i++) {
            // 가로로 일치하는 경우
            if (board[i].charAt(0) == value 
                && board[i].charAt(0) == board[i].charAt(1) 
                && board[i].charAt(1) == board[i].charAt(2)) {
                return true;
            }
            // 세로로 일치하는 경우
            if (board[0].charAt(i) == value
                && board[0].charAt(i) == board[1].charAt(i)
                && board[1].charAt(i) == board[2].charAt(i)) {
                return true;
            }
        }
        // 대각선 일치
        if (board[0].charAt(0) == value
            && board[0].charAt(0) == board[1].charAt(1)
            && board[1].charAt(1) == board[2].charAt(2)) {
            return true;
        }
        
        if (board[0].charAt(2) == value
            && board[0].charAt(2) == board[1].charAt(1)
            && board[1].charAt(1) == board[2].charAt(0)) {
            return true;
        }
        return false;
    }
}

1. 자주 사용했던 'O'와 'X'는 상수로서 처리해 줬다. 훨씬 가독성이 증가한 것을 느낄 수 있었다. 

2. boolean 타입을 반환하는 메서드로 변경하여 선공, 후공 승리를 나누었다. 이렇게 해서 코드의 반복을 줄일 수 있었다.

 

한눈에 보기에도 깔끔해진 코드로 작성할 수 있었다. 

계속 새로운 문제가 나오고 있지만 2023년 3월 16일 기준으로 레벨 1 문제를 모두 해결해 봤다. 

 

언어는 모두 Java를 사용해서 풀었다.

 

원래 레벨 1을 조금 풀다가 이정도면 2 레벨로 들어가도 되겠다는 어리섞은 생각을 하게 되었다. 쉽게 풀렸던 몇몇 레벨 2문제를 만나서 그동안 몰랐던 거 같다. 

 

그래서 적어도 레벨 1 문제를 빠르게 다 풀어보자는 생각으로 문제를 풀기 시작했다. 레벨 1문제를 풀며 느낀 점을 정리해보려고 한다.

 

 

🔎 무조건 List! 무조건 Map!

내가 처음에 레벨 2를 풀기 시작했을 때 했었던 생각이다. 배열 보다는 역시 리스트를 써야지 이런 생각을 했었고, 실제로도 그렇게 풀었었다. 물론 List, Map, Set 모두 훌륭한 자료구조이다. 하지만 레벨 1문제를 풀면서는 먼저 문제에 접근할 때 배열로 해결할 수 있지 않을까라는 생각으로 접근을 하는 연습을 많이 했다.

 

그 이유는 분명 List와 Map이 효율적인 문제에서는 속도도 빠르고, 오히려 배열보다 효율성도 좋을 때가 많다. 하지만 대부분의 레벨 1 문제는 배열을 사용해서 해결할 수 있는 문제가 대부분이었고, 속도도 배열이 대부분 빨랐다. 이건 무조건 배열을 쓰라는 소리가 아니다. 배열을 써서 해결할 수 있는 문제라면 굳이 List나 Map을 쓰지 않아도 된다는 말이다.

 

 

🔎 StringBuilder를 활용하자

문제를 풀다보면 String 타입의 결과를 도출해 내는 문제가 종종 있다. 풀었던 문제 중에서 예시를 들자면 어떤 String 타입의 배열이 주어졌을 때, "Kim"이라는 문자열이 존재하는 index를 "김서방은 {index}에 있다"라는 형태의 문자열로 반환하는 문제였다.

 

배열에서 해당 문자열의 위치를 찾는 건 반복문을 돌려서 찾거나 List 타입으로 변환해서 indexOf() 메소드를 사용해 주면 쉽게 얻을 수 있다. 이제 이렇게 얻은 index를 "김서방은 "과 "에 있다"라는 문자열 사이에 넣어야 한다. 선택은 여러 가지가 있다.

 

# 그냥 더해서 반환하기

return "김서방은 " + index + "에 있다";

String은 이렇게 직접 더하는 연산을 사용하면 속도가 많이 느려진다. 레벨 1짜리 문제여서 별 차이는 느끼지 못할 정도이지만 피하는 것이 좋다.

 

 

# 포매팅을 사용하기

return String.format("김서방은 %d에 있다", index);

정수형 변수인 index를 포매팅(%d)을 이용해서 반환해 줄 수 있다. 실제 속도 차이로 봤을 때는 위에서 String을 그대로 더한 결과보다 빨랐다.

 

 

# StringBuilder 사용하기

return new StringBuilder("김서방은 ").append(index).append("에 있다").toString();

오늘의 핵심이다. StringBuilder는 append()라는 메소드로 계속 이어서 문자를 이어 붙일 수 있다. 그리고 마지막에 toString()을 써주면 만들어진 문자열을 String 타입으로 반환해 준다. 속도도 위에서 사용했던 두 가지 방법보다 빨랐다.

 

 

🔎 char을 잘 활용하자

사실 이전까지 char 배열은 잘 사용하지 않았다. 어떻게 보면 char 자료형 자체를 많이 안 썼던 거 같다. 하지만 레벨 1 문제를 74문제나 푼 나의 생각은 바꼈다. char[]를 잘 사용하면...엄청난 결과를 얻을 것이니..

 

# char[]은 문자열 치환이 가능하다

char[] ch = new char[]{'안', '녕', '하', '세', '요'};

String str1 = new String(ch); // "안녕하세요"
String str2 = String.valueOf(ch); // "안녕하세요"

이 기능은 필자는 문제 풀 때 정말 많이 사용했던 기능이다. 쓰이는 곳이 정말 많고, 유용하다. char[]을 문자열 생성자에 넣어주면 그대로 문자열을 반환해 준다니... 흥미롭지 않은가...? 나만 흥미로울 수도 있다. 👀

 

 

# char는 숫자로도 쉽게 사용이 가능하다

char[] ch = new char[]{'0', '1', '2', '3', '4', '5'};

int zero = ch[0] - '0';  // 숫자 0
int one = ch[1] - '0';   // 숫자 1
int two = ch[2] - '0';   // 숫자 2
int three = ch[3] - '0'; // 숫자 3

아스키 코드를 사용한 방법인데, '0'의 아스키코드 번호는 48이고, '1'은 49 '2'는 50 이렇게 증가한다. 즉 문자인 '1'에서 숫자 1을 얻기 위해서는 '1' - '0'을 해주면 된다. 49 - 48 이기 때문에 가능한 것이다. 

 

그냥 Integer.parseInt()나 Integer.valueOf()를 쓰면 되는 거 아닌가라고 생각하는 사람이 있으면 큰일이다. 한번 넣어보면 결과를 알 수 있을 것이다.

int zero = Integer.valueOf('0');
System.out.println(zero);

위에 있는 코드의 출력 결과가 뭐가 나올까? 0이 아닌 '0'의 아스키코드 번호인 48이 나온다. 그럼 parseInt()는? 이건 "java: incompatible types: char cannot be converted to java.lang.String" 이런 오류와 함께 컴파일 오류가 발생한다. parseInt()의 매개변수로 들어갈 수 있는 건 String 타입이기 때문이다. 그러니 아스키코드 활용을 잘 알아두면 좋을 것이다.

 

 

# 대문자와 소문자를 판별

char lowerValue = 'a';
char upperValue = 'A';

if (Character.isLowerCase(lowerValue)) {
    System.out.println("소문자 입니다."); // 출력
}

if (Character.isUpperCase(upperValue)) {
    System.out.println("대문자 입니다."); // 출력
}

Character에는 대문자와 소문자를 판별해주는 isLowerCase()와 isUpperCase() 메소드가 존재하기 때문에 이거도 잘 활용하면 코드를 대폭 줄일 수 있다. 숫자인지 판별하는 isDigit()도 있으니 잘 사용해 보기를 바랍니다.

 

 

🔎 코드를 줄이자!!! 스트림!!!

이라고 생각하면 큰일이다. Stream으로 해결한 몇몇 코드를 보면 정말 한 눈에 반할 정도로 멋지고, 나도 쓰고 싶어질 때가 있다. 심지어...한 줄로 해결해 버리는 문제도 많이 있었다.

 

하지만 스트림으로 푸는 문제들은 코드를 돌려보면 알겠지만 속도가 많이 느리다. 예를 들어 단순 반복문과 배열을 사용했을 때는 0.03ms에서 0.09ms가 나오는 반면에 스트림으로 해결한 문제를 돌려보면 1ms을 넘어가는 경우도 다반사이다. 아름다움에 현혹되서는 안 된다.

 

그렇다고 스트림이 좋지 않다라는 말은 아니지만 쓸 때와 쓰지 않을 때를 잘 구분해야 된다는 말이다. 알고리즘 문제를 풀 때는 이왕이면 스트림은 피하는 것이 좋다고 생각된다. 나같은 초짜는 언제 스트림을 쓰고 안 써야 하는지 구분을 못하기 때문에... 🙂

 

생각나는 부분은 일단 여기 까지이다. 이제는 레벨 2를 풀러 떠나야겠다. 

 

시소 짝꿍

Level 2

 

문제 설명

어느 공원 놀이터에는 시소가 하나 설치되어 있습니다. 이 시소는 중심으로부터 2(m), 3(m), 4(m) 거리의 지점에 좌석이 하나씩 있습니다.이 시소를 두 명이 마주 보고 탄다고 할 때, 시소가 평형인 상태에서 각각에 의해 시소에 걸리는 토크의 크기가 서로 상쇄되어 완전한 균형을 이룰 수 있다면 그 두 사람을 시소 짝꿍이라고 합니다. 즉, 탑승한 사람의 무게와 시소 축과 좌석 간의 거리의 곱이 양쪽 다 같다면 시소 짝꿍이라고 할 수 있습니다.사람들의 몸무게 목록 weights이 주어질 때, 시소 짝꿍이 몇 쌍 존재하는지 구하여 return 하도록 solution 함수를 완성해주세요.


제한 사항

  • 2 ≤ weights의 길이 ≤ 100,000
  • 100 ≤ weights[i] ≤ 1,000
    • 몸무게 단위는 N(뉴턴)으로 주어집니다.
    • 몸무게는 모두 정수입니다.

입출력 예


🙅🏻‍♂️ 첫 번째 소스코드 (실패)

import java.util.*;

class Solution {
    public long solution(int[] weights) {
        long answer = 0;

        Map<Integer, int[]> toque = new HashMap<>();

        // weights에 해당하는 2, 3, 4 토큐값을 초기화
        for (int i = 0; i < weights.length; i++) {
            int target = weights[i];

            if (toque.get(target) == null) {
                int[] temp = new int[]{target * 2, target * 3, target * 4};
                toque.put(target, temp);
            }
        }

        for (int i = 0; i < weights.length - 1; i++) {
            for (int j = i + 1; j < weights.length; j++) {
                if (checkDuplicate(
                    toque.get(weights[i]), toque.get(weights[j]))) {
                    answer++;
                }
            }
        }

        return answer;
    }

    public boolean checkDuplicate(int[] x, int[] y) {
        for (int i = 0; i < x.length; i++) {
            for (int j = 0; j < y.length; j++) {
                if (x[i] == y[j]) {
                    return true;
                }
            }
        }
        return false;
    }
}

image

 

테스트 케이스 6번부터 15번까지 모두 시간 초과로 실패를 했다. 아마도 무작정 반복문을 사용하려고 했기 때문인 거 같다. 다른 방법을 한번 찾아봐야할 거 같다.


🙆🏻‍♂️ 최종 소스코드

반복문은 총 1번만 돌아야 해당 문제를 성공시킬 수 있다.

import java.util.*;

class Solution {
    public long solution(int[] weights) {
        long answer = 0;

        Map<Double, Integer> map = new HashMap<>();

        double[] divide = new double[]{
            1.0, 2.0/3, 1.0/2, 3.0/4
        };

        Arrays.sort(weights);
        for (int weight : weights) {
            for (double d : divide) {
                if (map.containsKey(weight * d)) {
                    answer += map.get(weight * d);
                }
            }
            map.put((double)weight, map.getOrDefault((double)weight, 0) + 1);
        }

        return answer;
    }
}

🕵🏻 문제 풀이전략

  1. divide 배열을 보면 1, 2/3, 1/2, 3/4가 들어가 있다. 이 부분부터 설명을 해야될 것 같다.
    • weights를 정렬을 하고나서 숫자 a, b를 뽑는다고 하면 a는 b보다 같거나 무조건 작을 것이다. 이 부분을 이용한다.
    • 2m, 3m, 4m의 거리가 있을 때, 큰 수인 b를 뽑았을 때 a가 될 수 있는 경우를 생각해본다.
      • a == bb * 1 == a
        • 이 조건은 a와 b가 같을 때의 조건이다.

      • a * 3 == b * 2
        • a가 3m에 있고, b가 2m에 있을 때의 조건이다.
        • 다르게 표현하면, b * (2/3) == a 가 된다.

      • a * 4 == b * 2b * (2/4) == ab * (1/2) == a
      • a * 4 == b * 3b * (3/4) == a
    • 즉, 위에서 말한 1, 2/3, 1/2, 3/4는 큰 수 b를 뽑았을 때, 짝궁이 될 수 있는 a를 찾기위한 비율 값이다.


  2. weights가 [100, 100, 180, 270, 360] 일 때를 생각해보자. (정렬 후의 상태)
    • b == 100일 때
      • a가 될 수 있는 값은 [100, 50, 75] 이다. 100 * (2/3)의 경우에는 정수가 아니기 때문에 적지 않겠습니다.
      • 이제 map에서 100, 50, 75에 해당하는 무게가 이전에 존재했었는지, 존재했다면 몇 명이나 있었는지 확인합니다.
      • 만약 이전에 해당하는 무게가 존재했었다면, 몇 명이나 존재했는지에 대한 값을 answer에 더해줍니다.
        • 만약 이전에 100이 2명, 75가 3명이었다면, 우리가 뽑은 b = 100인 사람은 총 5명과 짝꿍이 될 수 있기 때문입니다.

      • 그리고 마지막에는 map에 이미 100이라는 key를 가진 데이터가 있으면 그 key 값에 해당하는 value를 가져와서 1을 더한 후 다시 넣어줍니다. (본인을 1명 추가하는 작업)
      • map에 100이라는 key가 없었다면, 1이라는 값으로 초기화합니다. (자신이 100무게 중에는 최초의 사람)


  3. 2번 과정을 반복하면 답을 얻을 수 있습니다.

문제

현주는 N명의 인원이 참여하는 프로그래밍 스터디 그룹을 이끌고 있다.


현주는 스터디를 위해 대회를 세 개 개최하였고, 모든 구성원이 각 대회에 참여하였다. 참가자는 각 대회에서 0 이상 1,000 이하의 정수인 점수를 얻는다. 한 대회에서 둘 이상의 참가자가 동점이 나오는 경우도 있을 수 있다.


현주는 각 대회별 등수 및 최종 등수를 매기고 싶다. 등수는 가장 점수가 높은 사람부터 1등, 2등, ···, N등의 순서대로 붙는다. 만일 동점이 있을 경우 가능한 높은 (등수의 수가 작은) 등수를 부여한다. 즉, 점수가 내림차순으로 10,7,6,6,4의 순서일 경우, 6점을 받은 두 사람은 공동 3등이 되고, 그 다음 순서인 4점을 받은 사람은 5등이 된다. 이 규칙을 다르게 표현하면 다음과 같다: 각 사람마다 “나보다 점수가 큰 사람”의 수를 세어 1을 더한 것이 자신의 등수가 된다. 대회별 등수는 각 대회에서 얻은 점수를 기준으로 하며 최종 등수는 세 대회의 점수의 합을 기준으로 한다.


각 참가자의 대회별 등수 및 최종 등수를 출력하는 프로그램을 작성하시오.

 

 

제약조건

3 ≤ N ≤ 100,000

 

 

 

입력 형식

첫째 줄에 참가자의 수를 나타내는 정수 N이 주어진다.
이어 세 개의 줄에 각 대회의 결과를 나타내는 N개의 정수가 주어진다. 이중 i번째 정수는 그 대회에서 i번째 사람이 얻은 점수를 의미한다.

 

 

출력 형식

첫 세 개의 줄에는 각 참가자의 대회별 등수를 출력한다. 즉 이중 c번째 줄의 i번째 정수는 c번째 대회에서의 i번째 사람의 등수를 의미한다.
이어 새로운 줄에 같은 형식으로 각 참가자의 최종 등수를 출력한다.

 

 

나의 소스코드

import java.util.*;
import java.io.*;


public class Main
{
    public static void main(String args[]) throws IOException
    {
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

        int N = Integer.parseInt(reader.readLine());

        List<Pair> temp;

        List<Pair> result = new ArrayList<>();

        for (int i = 0; i < N; i++) {
            result.add(new Pair(i, 0));
        }

        for (int i = 0; i < 3; i++) {
            String[] arr = reader.readLine().split(" ");

            temp = new ArrayList<>();

            for (int j = 0; j < N; j++) {
                int score = Integer.parseInt(arr[j]);
                temp.add(new Pair(j, score));
                result.get(j).score += score;
            }

            List<Pair> copyList = new ArrayList<>(temp);
            Collections.sort(copyList);

            for (int j = 0; j < N; j++) {
                if (j == 0) {
                    copyList.get(j).rank = 1;
                    continue;
                }
                
                if (copyList.get(j).score == copyList.get(j - 1).score) {
                    copyList.get(j).rank = copyList.get(j - 1).rank;
                } else {
                    copyList.get(j).rank = j + 1;
                }
            }

            for (Pair p : temp) {
                System.out.print(p.rank + " ");
            }

            System.out.println();
        }

        temp = new ArrayList<>(result);
        Collections.sort(temp);

        for (int i = 0; i < N; i++) {
            if (i == 0) {
                temp.get(i).rank = 1;
                continue;
            }

            if (temp.get(i).score == temp.get(i - 1).score) {
                temp.get(i).rank = temp.get(i - 1).rank;
            } else {
                temp.get(i).rank = i + 1;
            }
        }

        for (Pair p : result) {
            System.out.print(p.rank + " ");
        }

    }
}

class Pair implements Comparable<Pair> {
    int index;
    int score;
    int rank;

    public Pair(int index, int score) {
        this.index = index;
        this.score = score;
        rank = 0;
    }

    @Override
    public int compareTo(Pair p) {
        return p.score - this.score;
    }
}

 

 

피드백

문제를 보면 그렇게 어렵게 느껴지는 알고리즘의 문제가 아니다. 처음 작성한 코드는 다음과 같다. 

import java.util.*;
import java.io.*;


public class Main
{
    public static void main(String args[]) throws IOException
    {
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

        int N = Integer.parseInt(reader.readLine());

        List<Integer> finalRank = new ArrayList<Integer>();
        
        for (int i = 0; i < 3; i++) {
            finalRank.add(0);
        }
        
        for (int i = 0; i < 3; i++) {
            List<Integer> tempList = new ArrayList<>();
            String[] inputData = reader.readLine().split(" ");

            for (int j = 0; j < N; j++) {
                int tempValue = Integer.parseInt(inputData[j]);
                tempList.add(tempValue);
                finalRank.set(j, finalRank.get(j) + tempValue);
            }

            printRank(tempList);
        }

        printRank(finalRank);
    }

    public static void printRank(List<Integer> data) {
        StringBuilder result = new StringBuilder();

        for (int i = 0; i < data.size(); i++) {
            int target = data.get(i);
            int rank = 1;
            for (int j = 0; j < data.size(); j++) {
                if (target < data.get(j)) {
                    rank++;
                }
            }
            result.append(rank).append(i != data.size() ? " " : "");
        }

        System.out.println(result);
    }
}

정말 단순하게 모든 요소에 대한 반복문을 돌면서 rank는 1로 초기화하고, 자신 보다 큰 점수가 있을 때 마다 1씩 증가시켜서 최종 rank를 자신의 등수에 넣는 형식의 코드입니다.

 

하지만 문제를 제출하고 보니 오답인 부분은 하나도 없었지만 8개정도가 시간 초과가 나왔다. 그 말은 즉... 위 로직에서 나올 수 있는 최대 시간 복잡도인 N * N이 나오면 안 된다는 뜻이다.

 

처음 작성했던 로직으로 풀고 싶어서 별 짓을 다해봤다. Map을 사용해서 점수에 대한 등수를 갱신하면서 같은 점수가 나오면 바로 get()을 통해서 가져올 수도 있게도 해봤다. 하지만 이 경우에도 10만개의 점수 데이터가 모두 다르다면 최대 100000 * 100000의 시간 복잡도가 나오기 때문에 별로 의미없는 코드였다.

 

그렇게 계속 고민을 하다가 소프티어 문제 해설에 적혀있는 내용을 봤다. 각 대회의 점수를 저장할 배열 temp[]를 선언하고, Pair<번호, 점수>의 객체를 사용하라는 내용이었다. 그래서 번호와 점수를 속성값으로 가지고 있는 Pair라는 클래스를 만들었고, 점수를 기준으로 내림차순할 수 있도록 만들었다. 내림차순으로 정렬한 후 첫 번째 Pair 객체는 무조건 rank가 1이다. 그 뒤 부터는 이전 Pair 객체의 점수와 비교해서 같으면 이전 Pair 객체의 등수를 넣고, 다르면 본인의 index에 해당하는 값을 넣어주면 된다.

 

이렇게 풀이를 하고 제출하니...정답이 나왔다...오늘도 잠 못 잘 뻔했다.

 

레벨 3문제는...정말 이전에도 그렇고 머리가 아프다.

 

 

 

 

+ Recent posts