코틀린 코딩테스트 강좌, 위상 정렬

개요

위상 정렬(Topological Sort)은 방향 그래프에서 정점을 선형적으로 나열하는 알고리즘입니다. 이 알고리즘은 주로 작업의 의존성을 표현하는 문제에 맞게 사용됩니다. 예를 들어, 과목 이수도, 태스크 스케줄링 등 다양한 문제를 해결하는 데 유용합니다.

문제 설명

다음은 위상 정렬을 활용한 문제입니다.

문제: 과목 이수 문제

대학교에서 과목 A를 이수하기 위해 반드시 이수해야 하는 과목 B와 C가 있습니다. 여러 과목과 이수 조건이 주어질 때, 모든 과목을 이수하기 위한 수업 순서를 구하는 프로그램을 코틀린으로 작성하세요. 과목의 수업 순서는 여러 가지가 될 수 있습니다. 가능한 모든 수업 순서를 반환하는 것이 목표입니다.

입력 형식

  • 첫 번째 줄에 정수 N (1 ≤ N ≤ 20): 과목의 개수
  • 두 번째 줄에 정수 M (0 ≤ M ≤ 100): 이수 조건 쌍의 개수
  • 다음 M줄에는 ‘A B’ 형태로 각 줄마다 이수 조건을 의미하는 정수 두 개가 주어집니다. ‘A’ 과목을 끝내기 위해서는 ‘B’ 과목을 먼저 이수해야 합니다.

출력 형식

과목의 이수 순서를 공백으로 구분하여 한 줄에 출력합니다. 가능한 이수 순서가 여러 개라면, 사전 순으로 가장 빠른 과목 순서만을 출력합니다.

문제 풀이 과정

1. 문제 이해하기

문제를 해결하기 위해 먼저 위상 정렬의 개념을 이해해야 합니다. 그래프에서 정점은 과목을, 간선은 이수 조건을 나타냅니다. 이수 조건이 있어야만 이수할 수 있는 과목을 나타내는 방향 그래프를 구성할 수 있습니다.

2. 그래프 구성

문제로 주어진 과목과 이수 조건을 바탕으로 그래프를 구성합니다. 각 과목은 노드가 되고, 이수 조건은 방향 간선으로 표현됩니다.

val graph = mutableMapOf>()
val inDegree = IntArray(N + 1) // 각 과목의 진입 차수를 저장

3. 진입 차수 계산

각 과목의 진입 차수를 계산합니다. 진입 차수는 해당 과목에 도달하기 위해 먼저 이수해야 하는 과목의 개수를 의미합니다.

for (i in 0 until M) {
    val a = readLine()!!.split(' ').map { it.toInt() }
    graph.getOrPut(a[1]) { mutableListOf() }.add(a[0])
    inDegree[a[0]]++
}

4. 위상 정렬 알고리즘 구현하기

위상 정렬은 BFS 방식으로 구현할 수 있습니다. 진입 차수가 0인 노드부터 시작하여 해당 노드를 차례대로 방문하면서 진입 차수를 줄여 나갑니다. 이 과정에서 노드가 추가로 진입 차수가 0이 되는 경우 큐에 추가합니다.

fun topologicalSort(N: Int): List {
    val result = mutableListOf()
    val queue: Queue = LinkedList()

    // 진입 차수가 0인 노드 큐에 추가
    for (i in 1..N) {
        if (inDegree[i] == 0) {
            queue.add(i)
        }
    }

    while (queue.isNotEmpty()) {
        // 사전 순 정렬을 위해 큐에서 정점 꺼내기
        val current = queue.poll()
        result.add(current)

        // 현재 정점에서 나가는 간선 처리
        for (next in graph[current] ?: listOf()) {
            inDegree[next]--
            if (inDegree[next] == 0) {
                queue.add(next)
            }
        }
    }

    return result
}

5. 최종 코드

위상 정렬 기능을 포함한 최종 코드는 아래와 같습니다.

fun main() {
    val (N, M) = readLine()!!.split(' ').map { it.toInt() }

    val graph = mutableMapOf>()
    val inDegree = IntArray(N + 1)

    // 그래프 및 진입 차수 초기화
    for (i in 0 until M) {
        val (a, b) = readLine()!!.split(' ').map { it.toInt() }
        graph.getOrPut(b) { mutableListOf() }.add(a)
        inDegree[a]++
    }

    // 위상 정렬 수행
    val result = topologicalSort(N)
    
    // 결과 출력
    println(result.joinToString(" "))
}

결론

위상 정렬은 다양한 그래프 문제를 해결하는 데 있어 매우 유용한 알고리즘입니다. 과목 이수 문제와 같이 의존 관계를 고려해야 하는 상황에서 더욱 빛을 발합니다. 이번 강좌에서는 위상 정렬의 기본 개념과 함께 문제 풀이 과정을 단계별로 살펴보았습니다. 앞으로도 다양한 알고리즘 문제를 통해 코틀린 실력을 한 단계 끌어올리시길 바랍니다.

코틀린 코딩테스트 강좌, 유니온 파인드

안녕하세요! 이번 글에서는 코틀린을 사용하여 유니온 파인드(Union-Find) 알고리즘에 대해 살펴보겠습니다. 유니온 파인드는 집합 데이터 구조를 효율적으로 관리하는 방법으로, 주로 연결된 컴포넌트를 찾고 결합하는 데 사용됩니다. 이 알고리즘은 그래프 이론과 관련된 문제에서 종종 사용되는 중요한 개념입니다.

문제 설명

다음은 유니온 파인드를 이용하여 해결할 수 있는 문제입니다. 연결된 컴포넌트의 개수 세기 문제를 통해 유니온 파인드의 중요성을 이해해 보겠습니다.

문제: 연결된 컴포넌트의 개수

당신은 N개의 노드와 M개의 간선이 있는 그래프를 가지고 있습니다. 각 노드는 1부터 N까지 번호가 매겨져 있습니다. 다음과 같이 M개의 간선이 주어질 때, 연결된 컴포넌트의 개수를 구하는 프로그램을 작성하세요.

입력 형식

N M
u1 v1
u2 v2
...
uM vM
    

여기서 N은 노드의 개수, M은 간선의 개수이며, u_iv_i는 i번째 간선의 양 끝 점을 나타냅니다.

출력 형식

연결된 컴포넌트의 개수를 한 줄에 출력합니다.

예제

입력:
5 3
1 2
2 3
4 5

출력:
2
    

예제에서 주어진 그래프를 살펴보면, 노드 1, 2, 3이 하나의 연결된 컴포넌트를 이루고, 노드 4, 5가 또 하나의 연결된 컴포넌트를 이루므로 총 2개의 연결된 컴포넌트가 존재합니다.

유니온 파인드 알고리즘 소개

유니온 파인드(Union-Find) 또는 분리 집합(Disjoint Set Union) 자료 구조는 다음과 같은 두 가지 주요 연산을 지원합니다:

  • Find: 특정 원소가 속한 집합의 대표 원소를 찾습니다.
  • Union: 두 개의 집합을 하나로 합칩니다.

유니온 파인드는 주로 비연결 그래프의 연결성을 관리하기 위해 사용되며, 효율성을 위해 경로 압축(Path Compression)과 합치기 기준(Union by Rank) 기법을 사용합니다. 이러한 기법들은 합친 후에 트리 형태로 구조를 유지함으로써 ‘Find’ 연산을 빠르게 수행할 수 있도록 돕습니다.

코틀린으로 구현하기

이제 유니온 파인드를 코틀린으로 구현해 보겠습니다. 아래는 위 문제를 해결하기 위한 코드입니다.

class UnionFind(val size: Int) {
    private val parent = IntArray(size + 1) { it } // 각 노드의 부모를 자기 자신으로 초기화
    private val rank = IntArray(size + 1) { 1 } // 각 집합의 랭크 초기화

    // Find 연산: 경로 압축 기법
    fun find(node: Int): Int {
        if (parent[node] != node) {
            parent[node] = find(parent[node]) // 부모를 재귀적으로 찾으면서 경로를 압축
        }
        return parent[node]
    }

    // Union 연산: 랭크를 기준으로 집합을 합침
    fun union(node1: Int, node2: Int) {
        val root1 = find(node1)
        val root2 = find(node2)

        if (root1 != root2) {
            if (rank[root1] > rank[root2]) {
                parent[root2] = root1
            } else if (rank[root1] < rank[root2]) {
                parent[root1] = root2
            } else {
                parent[root2] = root1
                rank[root1]++
            }
        }
    }

    // 연결된 컴포넌트의 개수 세기
    fun countComponents(): Int {
        val rootSet = mutableSetOf()
        for (i in 1..size) {
            rootSet.add(find(i)) // 각 노드의 루트를 찾아 집합에 추가
        }
        return rootSet.size // 유일한 루트의 개수가 연결된 컴포넌트의 개수
    }
}

fun main() {
    val reader = System.`in`.bufferedReader()
    val (n, m) = reader.readLine().split(" ").map { it.toInt() }
    val unionFind = UnionFind(n)

    for (i in 0 until m) {
        val (u, v) = reader.readLine().split(" ").map { it.toInt() }
        unionFind.union(u, v) // 간선 정보로 두 노드를 합침
    }

    println(unionFind.countComponents()) // 연결된 컴포넌트 개수 출력
}
    

코드 설명

위 코드는 유니온 파인드 자료 구조를 구현한 것입니다. 각 노드의 부모를 저장하기 위해 parent 배열을 사용하고, 각 집합의 랭크(높이)를 저장하기 위해 rank 배열을 사용합니다.

1. Find 메서드는 경로 압축을 통해 해당 노드의 루트를 재귀적으로 찾아갑니다. 이 과정에서 모든 노드가 직접 루트를 가리키게 하여 향후 탐색을 빠르게 합니다.

2. Union 메서드는 두 개의 노드의 루트를 찾아, 랭크를 비교하여 더 낮은 랭크를 가진 집합이 높은 랭크를 가진 집합에 연결되도록 합니다. 만약 랭크가 동일한 경우, 하나의 집합을 다른 집합의 루트에 연결하고 랭크를 증가시킵니다.

3. countComponents 메서드는 모든 노드에 대해 루트를 찾아 유일한 루트의 개수를 계산함으로써 연결된 컴포넌트의 개수를 반환합니다.

시간 복잡도 분석

유니온 파인드의 각 연산은 매우 빠르며, 거의 상수 시간에 수행할 수 있습니다. 이 알고리즘의 평균 시간 복잡도는 다음과 같습니다:

  • Find: O(α(N))
  • Union: O(α(N))

α(N)는 아커만 함수의 역함수이며, 이는 매우 느리게 성장하는 함수입니다. 따라서 유니온 파인드는 대규모 데이터에서도 효율적으로 작동합니다.

마무리

이번 글에서는 유니온 파인드 알고리즘의 기초 개념과 활용을 통해, 연결된 그래프의 문제를 해결하는 방법에 대해 배웠습니다. 코틀린을 이용한 구현을 통해 이론뿐만 아니라 실습도 병행할 수 있었는데, 이는 코딩 테스트 준비에 큰 도움이 될 것입니다.

유니온 파인드는 다양한 분야에서 사용될 수 있는 중요한 알고리즘이므로, 반드시 숙지하시기를 권장합니다. 추후 다른 알고리즘과 자료 구조에 대한 강좌도 이어질 예정이니 많은 관심 부탁드립니다!

이 글이 도움이 되었길 바라며, 궁금한 점이 있다면 댓글로 남겨주세요!

코틀린 코딩테스트 강좌, 원하는 정수 찾기

문제 설명

주어진 정수 배열에서 특정 정수를 찾는 프로그램을 작성하십시오. 함수는 다음과 같은 시그니처를 가집니다:

fun findNumber(arr: IntArray, target: Int): Boolean

입력으로 주어지는 arr는 정수 배열이며, target은 우리가 찾고자 하는 정수입니다.
만약 target이 배열에 존재하면 true를 반환하고, 그렇지 않으면 false를 반환해야 합니다.

예제

입력

arr = [1, 2, 3, 4, 5]
target = 3

출력

true

입력

arr = [1, 2, 3, 4, 5]
target = 6

출력

false

풀이 접근법

이 문제를 해결하기 위해서는 배열을 탐색하여 타겟 숫자가 존재하는지를 확인할 필요가 있습니다. 배열 탐색 알고리즘에는 여러 가지가 있지만, 여기서는 가장 기본적인 선형 검색을 사용하겠습니다.
배열의 각 요소를 순차적으로 검사하여 목표 숫자가 발견되면 true를 반환합니다.

만약 배열을 끝까지 검사하게 되면 false를 반환합니다. 이 방식은 시간 복잡도가 O(n)으로, 배열의 길이에 비례해서 실행 시간이 증가합니다.
그러나 배열이 정렬되어 있는 경우 이진 탐색 알고리즘을 사용할 수 있어, 시간 복잡도를 O(log n)으로 줄일 수 있습니다. 여기서는 단순화를 위해 선형 검색을 구현하겠습니다.

코드 구현

이제 코틀린을 사용하여 이 문제를 해결하는 코드를 작성해보겠습니다.

fun findNumber(arr: IntArray, target: Int): Boolean {
        for (number in arr) {
            if (number == target) {
                return true
            }
        }
        return false
    }

코드 설명

위의 코드는 배열 arr의 모든 요소에 대해 반복문을 실행합니다. 각 요소가 target과 같은지 비교하고, 같다면 true를 반환합니다.
반복문이 끝날 때까지 target이 발견되지 않으면 false를 반환합니다.

복잡도 분석

이 알고리즘의 시간 복잡도는 O(n)입니다. 배열의 크기 n에 비례해서 탐색하는 시간이 증가하기 때문입니다.
공간 복잡도는 O(1)로 추가적인 메모리를 사용하지 않습니다. 이는 최적의 공간 복잡도를 가지고 있음을 의미합니다.

테스트 케이스

추가 테스트 케이스

val testArray = intArrayOf(10, 20, 30, 40, 50)
println(findNumber(testArray, 30)) // true
println(findNumber(testArray, 60)) // false
println(findNumber(intArrayOf(), 1)) // false
println(findNumber(intArrayOf(5), 5)) // true
println(findNumber(intArrayOf(5), 10)) // false

결론

이 예제를 통해 코틀린을 사용한 간단한 탐색 알고리즘을 작성하는 방법을 배웠습니다. 선형 검색은 가장 기본적인 통합 규칙으로,
실제 프로젝트에서는 데이터양과 복잡성에 따라 적절한 알고리즘을 선택하는 것이 중요합니다.
이를 통해 다양한 종류의 문제를 풀 수 있는 능력을 기를 수 있습니다.

코틀린 코딩테스트 강좌, 오큰수 구하기

프로그래밍 및 알고리즘 테스트에서 빈번하게 등장하는 문제 중 하나는 ‘오큰수’ 문제입니다. 이 문제는 효율적인 알고리즘과 데이터 구조에 대한 이해를 요구하며, 다양한 실제 프로그래밍 상황에서도 매우 유용하게 적용될 수 있는 개념입니다.

문제 설명

문제: 주어진 정수 배열에서 각 숫자에 대해 그 숫자보다 큰 수 중에서 자신보다 오른쪽에 나오는 수를 찾고, 그 수를 출력하세요. 만약 존재하지 않는다면 -1을 출력합니다.

입력 형식

  • 첫째 줄에 자연수 N (1 ≤ N ≤ 1,000,000)이 주어집니다.
  • 둘째 줄에 N개의 정수로 이루어진 배열이 주어집니다. 값은 1 이상 1,000,000 이하입니다.

출력 형식

N개의 정수로 이루어진 배열을 출력하여 각 숫자에 대한 오큰수를 나타냅니다.

예시

입력

    4
    3 5 2 7
    

출력

    5 7 -1 -1
    

문제를 푸는 방법

이 문제를 해결하기 위해서 스택(Stack) 자료구조를 활용할 것입니다. 스택은 LIFO(Last In, First Out) 구조로, 가장 나중에 들어온 데이터가 가장 먼저 나가는 특성을 가지고 있습니다. 이 구조를 사용하여 각 요소에 대해 오큰수를 효율적으로 찾을 수 있습니다.

알고리즘 설명

  1. 스택을 생성합니다. 이 스택은 인덱스를 저장할 것입니다.
  2. 주어진 배열을 순회합니다.
  3. 현재 숫자가 스택의 최상단에 있는 숫자보다 클 경우, 스택에서 인덱스를 꺼내고, 해당 인덱스에 현재 숫자를 오큰수로 저장합니다.
  4. 현재 인덱스를 스택에 추가합니다.
  5. 모든 숫자에 대해 이 과정을 반복한 후, 스택에 남아있는 인덱스는 오큰수가 존재하지 않는 것이므로 -1을 저장합니다.

코틀린 코드 구현


fun findNextGreater(nums: IntArray): IntArray {
    val result = IntArray(nums.size) { -1 }  // 결과 배열 초기화
    val stack = mutableListOf()  // 스택 생성

    for (i in nums.indices) {
        // 스택이 비어있지 않고 현재 수가 스택의 최상단의 수보다 클 경우
        while (stack.isNotEmpty() && nums[stack.last()] < nums[i]) {
            result[stack.removeAt(stack.size - 1)] = nums[i]  // 오큰수 저장
        }
        stack.add(i)  // 현재 인덱스 추가
    }
    
    return result
}

fun main() {
    val inputArray = intArrayOf(3, 5, 2, 7)
    val result = findNextGreater(inputArray)

    print(result.joinToString(" "))  // 결과 출력
}
    

코드 설명

위의 코드는 오큰수를 찾는 알고리즘을 코틀린으로 구현한 것입니다. 주요 부분을 살펴보겠습니다.

1. 결과 배열 초기화

결과 배열은 오큰수를 저장하기 위한 배열입니다. 기본값으로 -1로 초기화합니다. 이는 오큰수가 존재하지 않을 경우를 처리하기 위함입니다.

2. 스택을 활용한 탐색

스택의 최상단 인덱스가 현재 인덱스 i가 가리키는 값보다 작은 경우, 그 인덱스를 꺼내고 해당 인덱스의 결과 배열에 현재 숫자를 할당합니다. 이 과정을 통해 오큰수를 찾아갑니다.

3. 최종 결과 출력

모든 숫자에 대해 탐색 후, 최종 결과 배열을 출력합니다. 결과는 주어진 배열의 각 수에 대한 오큰수가 저장된 상태로 출력됩니다.

성능 분석

이 방법의 시간복잡도는 O(N)입니다. 각 요소를 스택에 한 번씩 넣고 빼기 때문에 효율적입니다. 또한, 공간 복잡도는 O(N)으로 스택과 결과 배열을 사용하므로 사용된 메모리 양이 입력 크기에 비례합니다.

팁: 이 문제를 푸는 연습을 통해 스택의 작동 원리를 더욱 깊이 이해하고, 다양한 알고리즘 문제에 응용할 수 있습니다. 예를 들어, ‘오큰수’ 문제와 비슷한 다른 문제들도 연습해 보세요!

마무리

오늘은 코틀린을 이용하여 오큰수를 구하는 문제를 해결하는 방법을 알아보았습니다. 데이터 구조와 알고리즘의 중요성을 느끼셨기를 바라며, 앞으로도 알고리즘 문제 해결 능력을 키우기 위한 꾸준한 연습을 이어가시길 바랍니다.

코틀린 코딩테스트 강좌, 외판원의 순회 경로 짜기

안녕하세요, 이번 강좌에서는 코틀린을 이용하여 외판원의 순회 경로 문제를 해결하는 방법에 대해 알아보겠습니다.

1. 문제 정의

외판원 문제란, 외판원이 여러 도시들을 한 번씩만 방문하고 출발한 도시로 돌아오는 경로 중에서 가장 최소 비용의 경로를 찾는 문제입니다. 입력으로는 각 도시 간의 이동 비용이 주어지며, 목표는 최소 비용으로 모든 도시를 방문하는 경로를 출력하는 것입니다.

2. 문제 접근

외판원 문제는 NP-hard 문제로 알려져 있습니다. 일반적인 해법으로는 완전 탐색, 동적 프로그래밍, 이분 탐색 및 그리디 알고리즘 등을 사용할 수 있습니다. 하지만, 특수한 경우로 소규모의 데이터를 다룰 때는 완전 탐색을 통해 해결할 수 있습니다.

이 문제를 해결하기 위한 접근 방식은 다음과 같습니다:

  1. 모든 도시 간의 거리 또는 비용을 표 형태로 나타냅니다.
  2. 모든 가능한 경로를 생성합니다.
  3. 각 경로의 총 값을 계산하여 최소값을 찾습니다.

3. 문제 해결을 위한 코틀린 코드

이제 코틀린을 사용하여 이 문제를 해결하기 위한 코드를 작성해보겠습니다. 아래 코드는 외판원 문제를 해결하기 위한 기본적인 접근 방식을 보여줍니다:


fun main() {
    val n = 4 // 도시의 수
    val cost = arrayOf(
        intArrayOf(0, 10, 15, 20),
        intArrayOf(10, 0, 35, 25),
        intArrayOf(15, 35, 0, 30),
        intArrayOf(20, 25, 30, 0)
    )

    val visited = BooleanArray(n)
    visited[0] = true // 시작점을 첫 번째 도시로 설정
    val result = tsp(0, 1, cost, visited, 0, n)
    println("최소 비용은: $result")
}

fun tsp(current: Int, count: Int, cost: Array, visited: BooleanArray, totalCost: Int, n: Int): Int {
    if (count == n) {
        return totalCost + cost[current][0] // 모든 도시를 방문한 경우, 시작 도시로 돌아옴
    }

    var minCost = Int.MAX_VALUE
    
    for (i in 0 until n) {
        if (!visited[i]) { // 방문하지 않은 도시라면
            visited[i] = true // 도시 방문 기록
            val newCost = tsp(i, count + 1, cost, visited, totalCost + cost[current][i], n)
            minCost = Math.min(minCost, newCost) // 최소 비용 계산
            visited[i] = false // 백트래킹
        }
    }
    
    return minCost
}

4. 코드 설명

위 코드는 외판원의 순회 경로 문제를 해결하기 위한 간단한 재귀적 접근 방식을 사용합니다. 각 함수의 구조는 다음과 같습니다:

  • main 함수: 도시의 개수와 비용 배열을 초기화합니다. 그리고 tsp 함수를 호출하여 최소 경로를 계산합니다.
  • tsp 함수: 현재 도시, 방문 도시 수, 비용 배열, 방문 기록, 현재까지의 총 비용, 도시 개수를 매개변수로 입력받습니다. 모든 도시를 방문했을 경우, 시작 도시로 돌아가는 비용을 포함하여 최소 비용을 반환합니다.

5. 동작 과정

코드를 간단히 살펴보면:

  1. 주어진 도시들을 배열(cost) 형태로 구성합니다.
  2. 첫 번째 도시에서 시작하여 모든 가능 경로를 재귀적으로 탐색합니다.
  3. 각 경로의 총 비용을 계산하고, 최소 비용을 업데이트합니다.
  4. 모든 도시를 방문할 때마다 현재 경로의 비용을 결과로 반환합니다.

6. 결론

이번 시간에는 코틀린을 사용하여 외판원의 순회 경로 문제를 해결하는 방법을 배웠습니다. 이 문제는 컴퓨터 과학 및 알고리즘 교육에서 매우 중요한 사례이며, 다양한 최적화 문제를 이해하는 데 큰 도움이 될 것입니다. 더 나아가 대규모 문제에 대해서는 동적 프로그래밍 등의 중요한 기술을 배워야 합니다.

다음 강좌에서는 좀 더 고급 알고리즘 및 다양한 문제 해결 테크닉에 대해 다루어 보도록 하겠습니다. 감사합니다!