분할 정복 알고리즘의 기본 개념

분할 정복의 핵심 아이디어는 분할 정복 이름 그대로 '분할(Divide)', '정복(Conquer)', '결합(Combine)' 의 세 단계로 이루어져 있다.

  • 분할 : 큰 문제를 여러 개의 작은 문제로 나눈다.
  • 정복 : 나눈 작은 문제들을 각각 해결한다.
  • 결합 : 해결한 결과를 합쳐서 전체 문제의 답을 만든다.

쉽게 설명하면 큰 케이크를 한 번에 먹으려 하면 힘들지만, 조각조각 나눠서 먹으면 쉬운것과 비슷하다.

 

 

분할 정복의 대표 알고리즘으로는 병합 정렬과 퀵 정렬이 있다.

 

 

1. 병합 정렬(Merge Sort)

병합 정렬은 배열을 반으로 나누고, 각각을 정렬한 뒤, 두 정렬된 배열을 합쳐서 전체 배열을 정렬하는 방법이다.

 

멀쩡히 있는 배열을 왜 쪼갤까?

 

배열을 작게 나누면, 작은 문제들은 쉽게 해결되고, 마지막에 이들을 합치면 큰 문제도 쉽게 풀린다는 개념이다.

fun mergeSort(arr: IntArray): IntArray {
    if (arr.size <= 1) return arr  // 배열 크기가 1 이하이면 이미 정렬된 상태

    val mid = arr.size / 2
    val left = mergeSort(arr.copyOfRange(0, mid))
    val right = mergeSort(arr.copyOfRange(mid, arr.size))

    return merge(left, right)
}

fun merge(left: IntArray, right: IntArray): IntArray {
    var i = 0
    var j = 0
    val merged = mutableListOf<Int>()

    while (i < left.size && j < right.size) {
        if (left[i] <= right[j]) {
            merged.add(left[i])
            i++
        } else {
            merged.add(right[j])
            j++
        }
    }
    while (i < left.size) {
        merged.add(left[i])
        i++
    }
    while (j < right.size) {
        merged.add(right[j])
        j++
    }
    return merged.toIntArray()
}

fun mainMergeSort() {
    val arr = intArrayOf(38, 27, 43, 3, 9, 82, 10)
    println("원본 배열: ${arr.joinToString(", ")}")
    val sortedArr = mergeSort(arr)
    println("정렬된 배열: ${sortedArr.joinToString(", ")}")
}

 

위 코드의 실행 결과를 살펴보자

원본 배열: 38, 27, 43, 3, 9, 82, 10
정렬된 배열: 3, 9, 10, 27, 38, 43, 82

 

원본 배열이 정렬되는 것을 볼 수 있다.

 

 

 

2. 퀵 정렬(Quick Sort)

퀵 정렬은 배열에서 하나의 값을 피벗(pivot)으로 선택한 후, 피벗보다 작은 값과 큰 값으로 배열을 분할하고, 이를 재귀적으로 정렬하는 방법이다.

 

코드로 알아보는게 이해가 더 쉬울것 같다.

fun quickSort(arr: IntArray, low: Int = 0, high: Int = arr.size - 1) {
    if (low < high) {
        val pi = partition(arr, low, high)
        quickSort(arr, low, pi - 1)
        quickSort(arr, pi + 1, high)
    }
}

fun partition(arr: IntArray, low: Int, high: Int): Int {
    val pivot = arr[high]  // 1. 피벗으로 배열의 마지막 원소를 선택
    var i = low - 1        // 2. i는 피벗보다 작거나 같은 요소들이 위치할 인덱스의 마지막 위치를 추적

    // 3. low부터 high - 1까지 반복하여 피벗과 비교
    for (j in low until high) {
        if (arr[j] <= pivot) {  // 만약 현재 값이 피벗보다 작거나 같으면,
            i++                // i를 한 칸 증가시키고,
            // 4. arr[i]와 arr[j]를 교환하여, 피벗보다 작은 값들을 배열 앞쪽으로 모읍니다.
            val temp = arr[i]
            arr[i] = arr[j]
            arr[j] = temp
        }
    }
    // 5. i + 1 위치와 피벗(arr[high])을 교환하여, 피벗이 정렬된 위치에 오도록 합니다.
    val temp = arr[i + 1]
    arr[i + 1] = arr[high]
    arr[high] = temp

    return i + 1  // 피벗의 최종 위치를 반환합니다.
}

fun mainQuickSort() {
    val arr = intArrayOf(10, 7, 8, 9, 1, 5)
    println("원본 배열: ${arr.joinToString(", ")}")
    quickSort(arr)
    println("정렬된 배열: ${arr.joinToString(", ")}")
}

 

코드를 살펴보면 분할, 정복, 결합을 모두 볼 수 있다.

데이터를 빠르게 검색하거나 저장하는 일은 개발에서 필수적이다. (항상 이걸 어떻게 하면 잘할까 고민한다..) 이러한 작업을 가장 효율적으로 수행하는 자료구조 중 하나가 바로 해시 테이블(Hash Table) 이다.

 

해시 테이블은 키-값(Key-Value) 쌍으로 데이터를 저장하며, 거의 상수 시간(평균 O(1))에 데이터에 접근할 수 있는 강력한 도구이다.

 

 

해시 테이블이란?

해시 테이블은 위에서 언급한것과 같이 키(key)와 값(value)의 쌍으로 데이터를 저장하는 구조이다. 여기서 중요한 개념은 해시 함수(hash function)로, 입력된 키를 고정된 크기의 인덱스로 변환한다. 이 인덱스는 데이터를 저장할 배열의 위치를 결정한다.

 

예를 들어 "apple"이라는 키를 입력하면, 해시 함수는 "apple"을 정수 42와 같이 특정 인덱스로 변환한다. 그러면 해시 테이블은 "apple"에 대응하는 값을 저장하게 된다.

 

 

해시 함수(Hash Function) 

해시 함수는 키를 받아서 배열의 인덱스로 변환하는 역할을 한다. 좋은 해시 함수는 다음 조건을 만족한다.

  • 결정론적 : 동일한 키는 항상 같은 해시 값을 반환해야 한다.
  • 균등 분포 : 가능한 모든 키에 대해 인덱스가 균등하게 분포되어 충돌을 최소화한다.
  • 빠른 계산 : 해시 함수는 빠르게 계산되어야 하며, 전체 성능에 큰 영향을 주지 않아야 한다.

 

해시 함수가 서로 다른 키에 대해 같은 인덱스를 반환하는 경우 이를 충돌(collision)이라고 한다. 충돌은 해시 테이블에서 피할 수 없는 문제이지만, 다양한 해결 기법을 통해 이를 효과적으로 관리할 수 있다.

 

충돌 해결 전략

  • 체이닝(Chaining) : 각 인덱스에 연결 리스트(또는 다른 자료구조)를 사용하여, 동일한 해시 값을 가진 여러 요소를 저장한다.
  • 오픈 어드레싱(Open Addressing) : 충돌이 발생하면, 해시 테이블 내에서 다른 빈 공간을 찾아 데이터를 저장한다. 대표적인 방법으로는 선형 탐사 (Linear Probing), 이차 탐사(Quadratic Probing) 등이 있다.

 

 

해시 테이블의 장단점 및 사용 시 고려사항

 

장점  : 

  • 빠른 검색 및 삽입 : 평균적으로 O(1)의 시간 복잡도로 데이터 접근이 가능하다.
  • 유연성 : 키와 값의 쌍으로 데이터를 관리하므로, 다양한 데이터 타입에 쉽게 적용할 수 있다.
  • 효율적인 메모리 사용 : 적절한 해시 함수와 충돌 해결 기법을 사용하면, 메모리 낭비 없이 효율적으로 데이터를 저장할 수 있다.

단점 및 고려사항 : 

  • 충돌 발생 가능성 : 해시 함수의 품질에 따라 충돌이 많이 발생하면 성능이 저하될 수 있다.
  • 동적 크기 조절 : 해시 테이블은 데이터의 양이 증가하면 크기를 동적으로 조절해야 하며, 이 과정에서 비용이 발생할 수 있다.
  • 해시 함수 선택 : 좋은 해시 함수를 선택하는 것은 해시 테이블의 성능과 직접적으로 연결되므로 매우 중요하다.

 

이런 해시테이블은 어디에 사용할 수 있을까?

  1. 데이터베이스 인덱싱 : 대규모 데이터베이스에서 해시 테이블은 빠른 검색과 데이터 접근을 위한 인덱스로 활용된다.
  2. 캐시(Cache) : 웹 애플리케이션에서 사용자 데이터를 캐싱하거나, 자주 사용하는 데이터를 임시 저장해주더 빠른 응답을 제공하는 데 해시 테이블을 사용한다.
  3. 집합 구현 : 집합은 중복 없는 데이터를 저장하는 자료구조이다. 해시 테이블을 기반으로 구현된 HashSet은 데이터의 중복 여부를 빠르게 판단할 수 있다.

 

Kotlin에서는 표준 라이브러리에서 HashMap을 제공하여, 해시 테이블을 쉽게 사용할 수 있다.

fun main() {
    // HashMap 생성: 키는 String, 값은 Int
    val hashMap = HashMap<String, Int>()
    
    // 데이터 삽입
    hashMap["apple"] = 5
    hashMap["banana"] = 3
    hashMap["cherry"] = 7
    
    // 데이터 조회
    println("사과의 수: ${hashMap["apple"]}") // 출력: 사과의 수: 5
    
    // 데이터 존재 여부 확인
    if (hashMap.containsKey("banana")) {
        println("바나나가 존재합니다.")
    }
    
    // 모든 키와 값 순회
    for ((key, value) in hashMap) {
        println("$key : $value")
    }
}

 

HashMap의 내부 동작 원리에 대해 살짝 알아보면

  • 해시 함수 적용 : 입력된 키에 대해 내부 해시 함수를 적용하여 인덱스를 계산하고
  • 충돌 처리 : 동일한 인덱스가 발생한 경우, 체이닝(LinkedList 등)을 사용하여 여러 값을 저장한다
  • 동적 크기 조절 : 데이터가 많아지면 자동으로 크기를 확장하여 성능 저하를 방지한다.

HashMap 내부적으로 처리를 해주기 때문에 그냥 가져다 쓰기만 하면 된다...!

 

 

해시 테이블 응용 문제

 

1. 중복된 문자 제거

문자열에서 중복된 문자를 제거하고, 결과를 유지하는 문제는 해시 테이블을 활용하여 해결할 수 있다.

fun removeDuplicates(s: String): String {
    val seen = HashSet<Char>()
    val sb = StringBuilder()
    
    for (ch in s) {
        if (seen.add(ch)) {  // add()는 중복된 문자가 없으면 true를 반환합니다.
            sb.append(ch)
        }
    }
    return sb.toString()
}

fun main() {
    val input = "banana"
    println("중복 제거 결과: ${removeDuplicates(input)}") // 출력 예: "ban"
}

 

 

2. 캐시 구현 예제

간단한 캐시를 해시 테이블로 구현하여, 자주 사용하는 데이터를 빠르게 검색하는 방법을 보여준다.

class SimpleCache<K, V> {
    private val cache = HashMap<K, V>()
    
    fun put(key: K, value: V) {
        cache[key] = value
    }
    
    fun get(key: K): V? {
        return cache[key]
    }
    
    fun contains(key: K): Boolean {
        return cache.containsKey(key)
    }
}

fun mainCache() {
    val cache = SimpleCache<String, Int>()
    cache.put("apple", 5)
    cache.put("banana", 3)
    println("apple: ${cache.get("apple")}")  // 출력: apple: 5
}

투 포인터란 무엇일까?

투 포인터 기법은 배열이나 리스트 등에서 두 개의 인덱스(포인터)를 사용하여 문제를 해결하는 알고리즘이다. 주로 정렬된 배열에서 조건에 맞는 쌍을 찾거나, 두 포인터를 이용해 특정 구간을 탐색할 때 유용하다.

 

 

슬라이딩 윈도우란 또 무엇일까?

슬라이딩 윈도우는 연속된 구간(윈도우)을 다루는 기법으로, 한 번에 한 구간씩 보면서 원하는 조건(예: 합, 평균, 최대/최소값 등)을 만족하는 최적의 결과를 찾는 방법이다.

이때 "윈도우"는 배열이나 문자열에서 연속된 부분을 의미하며, 이 윈도우를 한칸씩 옮겨가며 문제를 해결한다.

 

 

이해가 잘 안된다 실생활 비유? or 더 쉬운 비유로 한번 이해를 해보자.

 

투 포인터 비유 : 책의 양쪽 끝에서 찾기

생각해보자  한 권의 책에서 특정 단어를 찾으려고 할 때, 책의 앞쪽과 뒤쪽에서 동시에 찾아본다면 더 빨리 찾을 수 있지 않을까? 투 포인터는 배열의 양쪽 끝에 포인터를 두고, 조건에 맞게 서로 이동시키면서 문제를 해결하는 기법이다.

 

 

슬라이딩 윈도우 비유 : 창문을 옮겨서 보는 풍경

슬라이딩 윈도우는 마치 큰 창문을 옮겨가며 밖의 풍경을 보는 것과 같다. 창문 너머의 한 부분만 보면서 그 부분의 특징(예: 평균, 합 등)을 차악하고, 창문을 조금씩 옮겨 전체 풍경을 보는 것처럼 배열의 연속된 구간을 살펴본다.

 

살짝 느낌이 온다.

 

 

투 포인터의 기본 원리

동작 방식 : 정렬된 배열에서 왼쪽 포인터는 가장 작은 값을, 오른쪽 포인터는 가장 큰 값을 가리킨다. 두 값을 합하여 조건(target)과 비교한다.

  • 합이 target보다 작으면, 더 큰 값을 찾기 위해 왼쪽 포인터를 오른쪽으로 이동한다. 
  • 합이 target보다 크면, 더 작은 값을 찾기 위해 오른쪽 포인터를 왼쪽으로 이동한다.

이렇게 하면 조건을 만족하는 두 수를 찾거나, 조건에 맞는 구간을 빠르게 탐색할 수 있다.

 

흠.. 그렇다면 두 구간의 합을 비교하거나, 두 구간의 조건을 동시에 고려 할 때, 두 수의 합이나 곱, 차 등을 구할때 사용하면 좋을 것 같다는 생각이 든다.

 

 

두 수의 합(target)을 구하는 예제를 Kotlin으로 작성해보자

class Solution {
    fun solution(nums: IntArray, target: Int): IntArray {
        var left = 0
        var right = nums.size - 1
        
        while (left < right) {
            val sum = nums[left] + nums[right]
            when {
                sum == target -> return intArrayOf(left, right)
                sum < target -> left++   // 합이 작으면 왼쪽 포인터 이동
                else -> right--          // 합이 크면 오른쪽 포인터 이동
            }
        }
        return intArrayOf() // target을 만족하는 쌍이 없으면 빈 배열 반환
    }
}

// 테스트
fun main() {
    val sol = Solution()
    val result = sol.solution(intArrayOf(1, 2, 3, 4, 6), 7)
    println("두 수의 인덱스: ${result.joinToString(", ")}")
}

 

코드에서 left, right 변수를 따로 설정해 투 포인터 기법을 통해 원하는 두 수의 인덱스를 찾아주었다.

 

 

 

 

슬라이딩 윈도우의 기본 원리

동작 방식 : 배열이나 문자열에서 고정된 크기의 구간(윈도우)를 설정하고, 이 윈도우를 좌우로 옮기면서 원하는 조건(예: 합,평균)을 계산한다.

 

어떻게 작동을 하는걸까?

 

슬라이딩 윈도우는 매번 윈도우를 이동하면서, 윈도우 내의 값을 효율적으로 갱신한다. 새로운 값이 추가되고, 이전 값이 제외되면서 구간의 합이나 평균 등이 빠르게 업데이트된다.

 

글로만 보니까 무슨 말인지 이해가 잘 안된다. 코드로 보면 좀 나으니 예제를 봐보자

 

 

정수 배열에서 연속된 부분 배열의 합이 target 이상이 되는 최고 길이를 찾는 예제이다.

class Solution {
    fun solution(nums: IntArray, target: Int): Int {
        var sum = 0
        var left = 0
        var minLength = Int.MAX_VALUE
        
        for (right in nums.indices) { // indices 파라미터 인덱스 값에 접근한다.
            sum += nums[right]
            
            // 현재 윈도우의 합이 target 이상이면, 최소 길이를 갱신하고 윈도우를 좁힙니다.
            while (sum >= target) {
                minLength = minOf(minLength, right - left + 1)
                sum -= nums[left] // 윈도우를 좁히는 부분
                left++
            }
        }
        
        return if (minLength == Int.MAX_VALUE) 0 else minLength
    }
}

// 테스트
fun main() {
    val sol = Solution()
    val result = sol.solution(intArrayOf(2, 3, 1, 2, 4, 3), 7)
    println("최소 길이: $result")
}

 

실제로 출력되는 값

최소 길이: 2

 

처음에는 왜? 어떻게 동작하는거지? 했지만... 결국 이해했는데 이해한 내용을 적어둔다.

 

  1. 주어진 배열 : [2, 3, 1, 2, 4, 3]
  2. 목표 합(target) : 7

여기서 연속된 부분의 합이 7 이상이 되는 최소 길이를 찾는 과정인 것이다.

  1. 처음 right 포인터가 이동하면서 누적합(sum)이 증가한다.
  2. 누적합이 7 이상이 된느 첫 번째 구간은 [2, 3, 1, 2]로, 합은 8이고 길이는 4이다.
  3. 윈도우를 좁혀서 최소 길이를 갱신하면, 더 짧은 구간 [4, 3]이 조건을 만족한다. 이 구간의 길이는 2이다.

따라서 최소 길이는 2가 되어, 최종적인 값이 출력되는 것이다.

 

 

 

 

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

 

프로그래머스

SW개발자를 위한 평가, 교육, 채용까지 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프

programmers.co.kr

 

프로그래머스 기초레벨의 5명씩 문제를 한번 풀어봤다.

 

class Solution {
    fun solution(names: Array<String>): Array<String> {
        val result = mutableListOf<String>()
        for (i in names.indices step 5) {
            result.add(names[i])
        }
        return result.toTypedArray()
    }
}

 

코드 설명

  • names.indices step 5:  배열의 인덱스 범위를 5씩 건너뛰면서 반복합니다.
  • result.add(names[i]):   각 그룹의 첫 번째 이름(인덱스 i의 값)을 결과 리스트에 추가합니다.
  • result.toTypedArray(): 결과 리스트를 배열로 변환하여 반환합니다.
더보기

toTypedArray() 함수는 Kotlin의 컬렉션(예: List)을 배열(Array)로 변환해 주는 함수이다. 이 함수는 컬렉션에 있는 요소들을 기반으로 동일한 타입의 배열을 만들어 반환한다.

자세한 설명

  • 컬렉션과 배열의 차이:
    Kotlin에서는 List, Set, Map과 같은 컬렉션과, 고정 크기를 가지는 배열이 있다.
    컬렉션은 요소 추가나 삭제가 가능한 반면, 배열은 생성 후 크기가 고정된다
  • toTypedArray() 사용 이유:
    때때로 함수나 API가 배열 타입을 요구할 때, 컬렉션(List 등)으로 작업한 후 최종적으로 배열로 변환해야 할 필요가 있다. 이때 toTypedArray()를 사용하면 쉽게 변환할 수 있다.
  • 동작 방식:
    내부적으로 toTypedArray()는 컬렉션에 포함된 각 요소를 순회하며 동일한 타입의 배열을 만들어, 그 배열에 요소들을 채워 반환한다.

1. 그리디 알고리즘의 정의

그리디 알고리즘은 문제를 해결할 때 현재 상황에서 가장 최선의 선택을 하여 최종 해답을 도출하는 방법이다. 이 때, 각 단계마다 선택하는 것이 최적해를 보장하는 경우에만 효과적이다.

 

 

2. 국부 최적 선택(Local Optimal Choice) 이란??

국부 최적선택이란 각 단계에서 당장 가장 이득이 큰 선택을 하는 것을 의미한다. 예를 들어, 쇼핑할 때 할인율이 가장 높은 상품을 먼저 고르는 것과 비슷하다.

 

 

3. 그리디 알고리즘의 장단점

장점 : 

  • 구현이 단순하고 빠르며, 계산 비용이 적다.
  • 문제의 크기가 커져도 각 단계의 선택만 고려하면 되므로 효율적이다.

단점 : 

  • 매 순간 최선의 선택이 전체 최적해를 보장하지 않을 수 있다.
  • 문제에 따라 그리디 알고리즘이 최적해를 찾지 못하는 경우가 있다.

 

4. 대표 문제 : 동전 거스름돈 문제

 - 문제 설명 : 정당한 화폐 단위 (예: 500원, 100원, 50원, 10원)가 주어지고, 거슬러 줘야 하는 금액이 주어졌을 때, 가장 적은 수의 동전으로 거스름돈을 지급하는 방법을 구하는 문제다.

 - 힌트 : 그리디로 접근하면 거슬러 줄 금액이 있을 때, 가장 큰 단위의 동전을 최대한 많이 사용한다. 그 후, 남은 금액에 대해 같은 방식으로 반복한다.

 - 조건 : 이 접근법은 화폐 단위가 "정당한 화폐 단위"일 때 최적해를 보장한다.

 

 

Kotlin을 이용한 동전 거스름돈 문제 구현

fun coinChange(amount: Int, coins: Array<Int>): Int {
    var remaining = amount
    var count = 0

    // 동전 배열은 큰 단위부터 정렬되어 있다고 가정합니다.
    for (coin in coins) {
        if (remaining >= coin) {
            val numCoins = remaining / coin  // 해당 동전으로 몇 개를 사용할 수 있는지 계산
            count += numCoins                 // 총 동전 개수에 더합니다.
            remaining %= coin                 // 남은 금액을 계산합니다.
            println("동전 $coin원: 사용 개수 = $numCoins, 남은 금액 = $remaining")
        }
    }
    return count
}

fun main() {
    // 예제: 거스름돈 1260원, 화폐 단위는 500, 100, 50, 10원
    val amount = 1260
    val coins = arrayOf(500, 100, 50, 10)
    println("총 동전 개수: ${coinChange(amount, coins)}")
}

 

  • 초기 변수 설정:
    • remaining 변수는 아직 거슬러 줘야 하는 금액을 저장한다.
    • count 변수는 사용한 동전의 총 개수를 저장한다.
  • 동전 단위 순회:
    • 배열 coins는 큰 단위부터 정렬되어 있으므로, 500원부터 차례로 처리한다.
    • 만약 remaining 금액이 현재 동전보다 크거나 같다면,
      remaining / coin으로 해당 동전이 몇 개 필요한지 계산한다.
  • 동전 개수 갱신 및 남은 금액 계산:
    • 계산한 동전 개수를 count에 더하고,
    • remaining %= coin으로 남은 금액을 갱신한다.
    • 각 단계에서 현재 동전 단위, 사용 개수, 남은 금액을 출력하여 진행 과정을 확인할 수 있다.
  • 최종 결과 반환:
    • 모든 동전 단위를 처리한 후, count를 반환한다.

 

실행결과

동전 500원: 사용 개수 = 2, 남은 금액 = 260
동전 100원: 사용 개수 = 2, 남은 금액 = 60
동전 50원: 사용 개수 = 1, 남은 금액 = 10
동전 10원: 사용 개수 = 1, 남은 금액 = 0
총 동전 개수: 6

 

그래프(Graph)란 무엇일까?

그래프는 정점(Vertex)와 간선(Edge)으로 구성된 자료구조이다.

 

  • 정점(Vertex) : 그래프의 구성요소로, 사람이나 도시처럼 개별 요소를 나타낸다.
  • 간선(Edge) : 정점들을 연결하는 선이다. 두 정점 사이의 관계(예:도로, 친구 관계)를 나타낸다.
  • 예시 : 학교에서 여러 학생(정점)들이 친구 관계(관선)를 맺고 있는 것을 그래프로 생각할 수 있다.

 

그래프의 표현 방법

그래프를 컴퓨터에서 표현하는 방법에는 여러 가지가 있다. 가장 일반적인 두 가지 방법은 인접 리스트인접 행렬이다.

  • 인접 리스트(Adjacency List) : 각 정점에 연결된 정접들의 리스트를 저장한다.

       예시

0: [1, 2]
1: [0, 3]
2: [0, 3]
3: [1, 2]

 

  • 인접 행렬(Adjacency Matrix) : 정점 간의 연결 여부를 2차원 배열 형태로 저장한다.

        예시

matrix[0][1] = 1

 

 

현실 세계에서의 그래프 예시로는 

  • 학교 네트워크 : 학생들을 정점으로 하고, 친구 관계를 간선으로 표현
  • 지도와 도로 : 도시를 정점, 도로를 간선으로 표현하여 경로 탐색 문제 해결
  • 인터넷 : 웹 페이지를 정점으로 하고, 하이퍼링크를 간선으로 표현하여 검색 알고리즘 등에 응용

등이 있다.

 

 

 

깊이 우선 탐색(DFS, Depth First Search)

1. DFS 의 기본 원리

DFS는 시작 정점에서 출발하여 한 방향으로 끝까지 탐색한 후, 더 이상 진행할 수 없으면 마지막 분기점으로 돌아가 다른 경로를 탐색하는 방식이다. 이러한 과정은 재귀 호출을 통해 자연스럽게 구현된다.

 

2. DFS의 동작 방식과 재귀 호출

동작 방식 :

  1. 시작 정점을 방문하고, 방문 목록에 추가한다.
  2. 현재 정점에서 인접한 정점 중 아직 방문하지 않은 정점을 선택한다.
  3. 선택한 정점으로 이동하여 같은 과정을 반복한다.
  4. 더 이상 방문할 정점이 없으면, 이전 정점으로 되돌아가 (Backtracking) 다른 경로를 탐색한다.

재귀 호출 : DFS는 재귀적으로 자신을 호출하면서, 깊은 경로를 먼저 탐색한다.

 

 

3. DFS의 장단점

 

장점 :

  • 구현이 간단하고 직관적이다.
  • 경로 탐색, 사이클 검출 등 다양한 문제에 응용할 수 있다.

단점 : 

  • 재귀 호출로 인해 스택 오버플로우가 발생할 수 있다.
  • 최악의 경우 모든 정점을 방문하므로, 시간 복잡도가 높을 수 있다.

 

4. DFS 구현 예제(Kotlin)

// DFS를 구현하기 위해, 방문한 정점을 기록할 집합과 재귀 함수를 사용합니다.
fun dfs(node: Int, visited: MutableSet<Int>, graph: Map<Int, List<Int>>) {
    // 현재 정점을 방문 목록에 추가하고, 출력합니다.
    visited.add(node)
    println("DFS 방문: $node")

    // 현재 정점과 연결된 모든 정점을 순회합니다.
    for (neighbor in graph[node] ?: emptyList()) {
        // 아직 방문하지 않은 정점이면 재귀 호출합니다.
        if (!visited.contains(neighbor)) {
            dfs(neighbor, visited, graph)
        }
    }
}

fun mainDFS() {
    // 간단한 무방향 그래프를 인접 리스트로 표현합니다.
    val graph: Map<Int, List<Int>> = mapOf(
        0 to listOf(1, 2), // 0번 정점은 1번,2번 정점에 간선을 갖는다.
        1 to listOf(0, 3),
        2 to listOf(0, 3),
        3 to listOf(1, 2)
    )
    
    val visited = mutableSetOf<Int>()
    println("=== DFS 탐색 결과 ===")
    dfs(0, visited, graph)
}

 

위에 코드에서 인접 리스트로 표현된 그래프를 실제로 살펴보면

인접리스트 예시

위 그림에서 방향성을 나타내는 화살표를 빼고 안쪽에 있는 간선 3개를 빼면 위의 코드의 그래프이다.

 

해당 코드를 실행해보면 시작 정점 0에서 시작하여 0 > 1 > 3> 2 순으로 방문을 진행하고, 각 단계에서 재귀 호출이 이루어지고, 되돌아가며 다른 경로를 탐색하는 모습은 미로에서 한 길로 쭉 들어갔따가 다시 돌아가는 것과 비슷하다.

 

 

 

 

너비 우선 탐색(BFS, Breadth First Search)

1. BFS의 기본 원리

BFS는 시작 정점에서부터 인접한 모든 정점을 먼저 방문하고, 그 다음 단계의 정점을 방문하는 방식이다. 즉, "가까운 것부터 넓게" 탐색하는 방법이다.

 

 

2. BFS의 동작 방식과 큐의 역할

동작 방식 :

  1. 시작 정점을 방문하고, 큐에 추가한다.
  2. 큐에서 정점을 꺼내 그 정점과 인접한 모든 정점을 방문한다.
  3. 방문한 정점을 큐에 추가하면서, 순차적으로 탐색한다.

큐의 역할 :  BFS에서는 먼저 들어온 정점을 먼저 처리하는 FIFO(선입선출) 자료구조인 큐를 사용한다.

 

 

3. BFS의 장단점

장점 : 

  • 최단 경로를 찾는 데 효과적이다.
  • 큐를 사용하므로 DFS보다 스택 오버플로우 위험이 적다.

단점 : 

  • 모든 인접 정점을 저장해야 하므로 메모리 사용량이 많아질 수 있다.
  • 그래프의 모든 정점을 한 번씩 방문하므로, 큰 그래프에서는 시간이 오래 걸릴 수 있다.

 

4. BFS 구현 예제(Kotlin)

import java.util.LinkedList
import java.util.Queue

fun bfs(start: Int, graph: Map<Int, List<Int>>) {
    val visited = mutableSetOf<Int>()
    val queue: Queue<Int> = LinkedList()

    // 시작 정점을 방문 처리하고 큐에 추가합니다.
    visited.add(start)
    queue.offer(start)

    while (queue.isNotEmpty()) {
        val node = queue.poll()
        println("BFS 방문: $node")
        
        // 현재 정점과 연결된 모든 정점을 순회하며 방문하지 않은 정점을 큐에 추가합니다.
        for (neighbor in graph[node] ?: emptyList()) {
            if (!visited.contains(neighbor)) {
                visited.add(neighbor)
                queue.offer(neighbor)
            }
        }
    }
}

fun mainBFS() {
    // 간단한 무방향 그래프를 인접 리스트로 표현합니다.
    val graph: Map<Int, List<Int>> = mapOf(
        0 to listOf(1, 2),
        1 to listOf(0, 3),
        2 to listOf(0, 3),
        3 to listOf(1, 2)
    )

    println("=== BFS 탐색 결과 ===")
    bfs(0, graph)
}

 

코드의 실행에 대해서 생각해보면 시작 정점 0에서 시작하여, 먼저 0에 인접한 1과 2를 방문하고, 그 후 1과 2의 인접 정점(3 등)을 방문하는 순서로 진행된다.

 

 

 

DFS와 BFS의 비교

1. 탐색 방식의 차이

  • DFS : 한 방향으로 깊게 들어간 후, 더 이상 진행할 수 없으면 되돌아가는 방식 -> "깊이 우선"으로 탐색한다.
  • BFS : 시작 정점에서 가까운 정점을 모두 방문한 후, 그 다음 레벨로 넘어가는 방식 -> "너비 우선"으로 탐색한다.

 

2. 시간 복잡도와 메모리 사용 비교

  • 시간 복잡도 : 두 알고리즘 모두 그래프의 정점 수(V)와 간선 수 (E)에 따라 O(V+E)의 시간 복잡도를 가진다.
  • 메모리 사용 : DFS는 재귀 호출(또는 스택)을 사용하므로 깊이에 따라 메모리 사용량이 결정된다.                                                                         / BFS는 큐에 모든 인접 정점을 저장하므로, 그래프가 넓을 경우 메모리 사용량이 증가할 수 있다.

 

언제 각각 사용해야 할까?

  • DFS 사용 경우 : 경로 깊이가 중요한 경우 / 모든 경로를 탐색해야 하는 문제(예: 순열, 조합, 백트래킹 문제)
  • BFS 사용 경우 : 최단 경로 문제 / 너비 우선 탐색을 통해 빠르게 탐색할 수 있는 경우

 

위의 DFS와 BFS는 문제를 더 풀어보면서 Kotlin 을 연습해봐야겠다.

 

동적 계획법(Dynamic Programming, DP) - DP

DP란 무엇일까? 동적 계획법은 큰 문제를 여러 개의 작은 문제로 나누어 해결한 후, 그 결과를 저장하여 전체 문제를 효율적으로 해결하는 방법이다. 예를 들어, 큰 퍼즐을 맞출 때, 각 조각을 따로 기억해둔다면 다시 같은 조각을 찾지 않아도 된다.

 

 

DP의 핵심 개념

  • 부분 문제(Overlapping Subproblems) : 큰 문제를 해결하는 과정에서 동일한 작은 문제가 여러 번 반복되는 경우가 있다.
  • 최적 부분 구조(Optimal Substructure) : 문제의 최적해가 그 하위 문제들의 최적해로 구성될 수 있을 때, DP를 적용할 수 있다.

 

 

대표 문제 : 계단 오르기

 - 문제 설명 : 계단이 총 n개 있고, 한 번에 1계단 또는 2계단씩 오를 수 있을 때, 계단을 모두 오르는 방법의 수를 구하시오.

 - 예시 : n = 3일때, 가능한 경우는

  1.      1,1,1
  2.      1,2
  3.      2,1

   총 3가지 방법

 

 

 - 문제 해결을 위한 아이디어

  • 계단 오르기 문제는 작은 문제(예 : n-1 계단을 오르는 방법, n-2 계단을 오르는 방법)로 나누어 생각할 수 있다.
  • 점화식 : n 번째 계단에 도달하는 방법의 수는 dp[n] = dp[n-1] + dp[n-2] 이다. 왜냐하면 마지막에 한 계단을 오르는 경우와 두 계단을 오르는 경우로 나눌 수 있기 때문이다.
  • 기본 조건 : dp[0] = 1 (아무 계단도 오르지 않은 경우를 1가지 방법으로 간주) / dp[1] = 1 (첫 번째 계단까지 오르는 방법은 1가지)

 

1. 반복적(Bottom-Up) 방식으로 DP 구현하기

fun climbStairs(n: Int): Int {
    // n이 0 또는 1인 경우 바로 반환
    if (n <= 1) return 1

    // dp 배열 생성: dp[i]는 i번째 계단까지 도달하는 방법의 수
    val dp = IntArray(n + 1)
    dp[0] = 1
    dp[1] = 1

    // 2번째 계단부터 n번째 계단까지 방법의 수를 구함
    for (i in 2..n) {
        dp[i] = dp[i - 1] + dp[i - 2]
    }
    return dp[n]
}

fun main() {
    val n = 5  // 예를 들어 계단이 5개인 경우
    println("계단 $n개를 오르는 방법의 수: ${climbStairs(n)}")
    // 출력 예시: 계단 5개를 오르는 방법의 수: 8
}

 

 

2. 재귀와 메모이제이션을 활용한 방식

fun climbStairsRecursive(n: Int, memo: MutableMap<Int, Int> = mutableMapOf()): Int {
    // n이 0 또는 1이면 바로 반환
    if (n <= 1) return 1
    // 이미 계산된 값이면 바로 반환
    if (memo.containsKey(n)) return memo[n]!!

    // 재귀 호출과 메모이제이션
    memo[n] = climbStairsRecursive(n - 1, memo) + climbStairsRecursive(n - 2, memo)
    return memo[n]!!
}

fun main() {
    val n = 5
    println("재귀적 접근: 계단 $n개를 오르는 방법의 수: ${climbStairsRecursive(n)}")
    // 출력 예시: 재귀적 접근: 계단 5개를 오르는 방법의 수: 8
}

// 같은 계산을 반복하지 않도록, 한 번 계산한 결과를 memo에 저장한다. 이렇게 하면, 재귀 호출의 중복을 피할 수 있다.

검색 알고리즘이란 무엇일까?

검색(Searching)은 배열이나 리스트 같은 데이터 집합에서 특정 값(또는 조건에 맞는 요소)을 찾아내는 과정이다.

예를 들어, 반에서 학생들 이름이 적힌 명단이 있을 때 "철수"라는 이름을 찾는 것과 비슷하다.

 

왜 검색이 필요할까?

  • 데이터 찾기 : 많은 데이터 중에서 필요한 정보를 빠르게 찾기 위해 사용된다.
  • 알고리즘 문제 : 코딩 테스트에서 특정 값을 찾는 문제가 출제된다.
  • 효율성 : 올바른 검색 알고리즘을 사용하면 데이터의 양이 많더라도 빠른 시간 안에 원하는 값을 찾을 수 있다.

 

 

1. 선형 검색(Linear Search)

배열의 첫 번째 요소부터 순서대로 하나씩 검사하면서 찾고자 하는 값과 일치하는지 확인한다.

 

특징 

  • 간단하고 구현하기 쉽다
  • 데이터가 정렬되어 있지 않아도 사용할 수 있다.
  • 최악의 경우 모든 요소를 확인해야 하므로, 시간 복잡도는 O(n)이다.
// 선형 검색 함수: 배열에서 target 값을 찾으면 해당 인덱스를 반환하고, 없으면 null 반환
fun linearSearch(arr: Array<Int>, target: Int): Int? {
    for (i in arr.indices) {
        if (arr[i] == target) {
            return i  // 찾은 경우 해당 인덱스 반환
        }
    }
    return null  // 값이 없으면 null 반환
}

fun main() {
    val numbers = arrayOf(5, 3, 8, 4, 2, 10)
    val target = 4
    val index = linearSearch(numbers, target)
    
    if (index != null) {
        println("선형 검색: $target 값은 인덱스 $index 에 있습니다.")
    } else {
        println("선형 검색: $target 값을 찾을 수 없습니다.")
    }
}

// arr.indices 는 배열의 인덱스 범위를 반환한다.
// 각 요소를 순차적으로 검사하여, target 값과 일치하면 해당 인덱스를 반환한다.

 

 

 

2. 이진 검색(Binary Search)

정렬된 배열을 반으로 나누어 원하는 값이 어느 쪽에 있는지 판단하여 검색 범위를 좁혀가는 방식이다.

 

특징

  • 배열이 정렬되어 있어야 사용할 수 있다.
  • 매 반복마다 검색 범위가 절반으로 줄어들기 때문에, 시간 복잡도는 O(log n)으로 매우 효율적이다.
// 이진 검색 함수: 정렬된 배열에서 target 값을 찾으면 해당 인덱스를 반환, 없으면 null 반환
fun binarySearch(arr: Array<Int>, target: Int): Int? {
    var low = 0
    var high = arr.size - 1

    while (low <= high) {
        val mid = (low + high) / 2  // 중간 인덱스 계산
        when {
            arr[mid] == target -> return mid  // 중간 값이 target이면 반환
            arr[mid] < target -> low = mid + 1  // target이 더 크면 오른쪽 범위로 좁힘
            else -> high = mid - 1              // target이 더 작으면 왼쪽 범위로 좁힘
        }
    }
    return null  // target 값을 찾지 못하면 null 반환
}

fun main() {
    val sortedNumbers = arrayOf(2, 3, 4, 5, 8, 10)  // 이진 검색을 위해 정렬된 배열 필요
    val target = 5
    val index = binarySearch(sortedNumbers, target)
    
    if (index != null) {
        println("이진 검색: $target 값은 인덱스 $index 에 있습니다.")
    } else {
        println("이진 검색: $target 값을 찾을 수 없습니다.")
    }
}


// 배열이 정렬되어 있다는 전제하에, low와 high 인덱스를 사용해 중간 mid을 계산한다.

 

정렬이란 무엇일까?

정렬(sorting)은 데이터 (예: 숫자, 문자열 등)를 일정한 순서대로 나열하는 과정이다. 예를 들어, 시험 점수가 섞여 있을 때 낮은 점수부터 높은 점수 순서대로 정렬하면, 누구의 점수가 가장 낮고 누구의 점수가 가장 높은지 쉽게 알 수 있다.

 

왜 정렬이 필요할까?

  • 검색 : 정렬된 데이터를 빠르게 검색할 수 있다.
  • 분석 : 데이터를 정리해서 보고서나 그래프로 만들 때, 정렬된 상태가 더 유용하다.
  • 문제 해결 : 코딩 테스트나 알고리즘 문제에서 정렬은 자주 사용되는 기본 기술이다.

 

버블 정렬(Bubble Sort) 이해하기

 

버블 정렬의 원리 

  • 버블 정렬은 인접한 두 개의 원소를 비교하여 순서가 맞지 않으면 서로 교환한다.
  • 한 번의 반복(패스)마다 가장 큰 값이 맨 뒤로 이동하게 된다.
  • 이 과정을 전체 배열이 정리될 때까지 반복한다.

 

버블 정렬 알고리즘의 단계별 설명

  1. 배열의 첫 번째와 두 번째 요소를 비교한다. 만약 첫 번째가 두 번째보다 크다면 서로 교환한다.
  2. 두 번째와 세 번째 요소를 비교하고, 필요하면 교환한다.
  3. 배열 끝까지 이 과정을 반복하면, 한 번의 반복 후 가장 큰 값이 배열의 끝으로 이동한다.
  4. 배열의 크기만큼 (또는 더 적은 횟수로) 반복하여 전체 배열을 정렬한다.

버블 정렬의 단점 

  • 비교와 교환이 반복되기 때문에, 데이터가 많을 경우 시간이 많이 걸린다. 시간복잡도 O(n^2)

버블 정렬 예제 코드

// 버블 정렬 함수: 배열을 오름차순으로 정렬
fun bubbleSort(arr: Array<Int>): Array<Int> {
    // 배열의 길이를 저장합니다.
    val n = arr.size
    // 전체 배열을 n-1번 반복합니다.
    for (i in 0 until n - 1) {
        // 각 반복마다 인접한 두 원소를 비교합니다.
        for (j in 0 until n - i - 1) {
            // 만약 현재 원소가 다음 원소보다 크다면, 두 원소의 위치를 교환합니다.
            if (arr[j] > arr[j + 1]) {
                // 교환 로직
                val temp = arr[j]
                arr[j] = arr[j + 1]
                arr[j + 1] = temp
                // 교환 후 로그 출력 (각 단계별 상태 확인)
                println("교환 후 배열 상태: ${arr.joinToString(", ")}")
            }
        }
    }
    return arr
}

fun main() {
    val numbers = arrayOf(64, 34, 25, 12, 22, 11, 90)
    println("원래 배열: ${numbers.joinToString(", ")}")
    val sortedNumbers = bubbleSort(numbers)
    println("정렬된 배열: ${sortedNumbers.joinToString(", ")}")
}

 

실행 결과

원래 배열: 64, 34, 25, 12, 22, 11, 90
교환 후 배열 상태: 34, 64, 25, 12, 22, 11, 90
교환 후 배열 상태: 34, 25, 64, 12, 22, 11, 90
교환 후 배열 상태: 34, 25, 12, 64, 22, 11, 90
교환 후 배열 상태: 34, 25, 12, 22, 64, 11, 90
교환 후 배열 상태: 34, 25, 12, 22, 11, 64, 90
교환 후 배열 상태: 25, 34, 12, 22, 11, 64, 90
교환 후 배열 상태: 25, 12, 34, 22, 11, 64, 90
교환 후 배열 상태: 25, 12, 22, 34, 11, 64, 90
교환 후 배열 상태: 25, 12, 22, 11, 34, 64, 90
교환 후 배열 상태: 12, 25, 22, 11, 34, 64, 90
교환 후 배열 상태: 12, 22, 25, 11, 34, 64, 90
교환 후 배열 상태: 12, 22, 11, 25, 34, 64, 90
교환 후 배열 상태: 12, 11, 22, 25, 34, 64, 90
교환 후 배열 상태: 11, 12, 22, 25, 34, 64, 90
정렬된 배열: 11, 12, 22, 25, 34, 64, 90

재귀 함수란 무엇일까?

재귀 함수란 자기 자신을 호출하는 함수이다. 즉, 문제를 해결하기 위해 동일한 문제의 더 작은 버전을 반복해서 호출하는 방식이다.

 

재귀 함수의 구성 요소

  • 기본 조건(Base Case) : 재귀 호출을 멈추는 조건이다. 기본 조건이 없으면 함후가 무한히 자기 자신을 호출하게되는 무한루프에 빠지게 된다.
  • 재귀 호출(Recursive Case) : 문제를 더 작은 문제로 나누어 자기 자신을 호출하는 부분이다.

 

 

재귀 함수 예제

1. 팩토리얼 계산(n!)

fun factorial(n: Int): Int {
    // 기본 조건: n이 0이면 1 반환
    if (n == 0) {
        return 1
    }
    // 재귀 호출: n * (n-1)! 계산
    return n * factorial(n - 1)
}

fun main() {
    val number = 5
    println("$number! = ${factorial(number)}") // 출력: 5! = 120
}

 

 

2. 피보나치 수열 계산

피보나치 수열은 앞의 두 수의 합으로 다음 수를 만드는 수열이다.

재귀적으로 f(n) = f(n-1) + f(n-2)로 정의되며, f(0)=0, f(1)=1 이다.

fun fibonacci(n: Int): Int {
    // 기본 조건: n이 0 또는 1이면 n 반환
    if (n == 0 || n == 1) {
        return n
    }
    // 재귀 호출: 피보나치 수열의 합 계산
    return fibonacci(n - 1) + fibonacci(n - 2)
}

fun main() {
    val n = 10
    println("Fibonacci($n) = ${fibonacci(n)}") // 예시 출력: Fibonacci(10) = 55
}

 

 

 

3. 문자열 재귀적으로 뒤집기

 - 문제 설명 : 문자열을 재귀 함수를 사용하여 뒤집는 코드를 작성해보자

 - 힌트 : 문자열의 첫 번째 문자와 나머지 부분을 분리하여, 나머지 부분을 재귀적으로 뒤집고 첫 번째 문자를 맨 뒤에 붙이는 방식으로 접근해보자

더보기
fun reverseRecursively(str: String): String {
    // 기본 조건: 문자열이 비어있으면 그대로 반환
    if (str.isEmpty()) {
        return str
    }
    // 재귀 호출: 문자열의 첫 번째 문자를 마지막으로 보내고, 나머지 문자열을 뒤집음
    return reverseRecursively(str.substring(1)) + str[0]
}

fun main() {
    val text = "Hello"
    println("원래 문자열: $text")
    println("뒤집은 문자열: ${reverseRecursively(text)}")
    // 출력: 뒤집은 문자열: olleH
}

+ Recent posts