[iOS 4주차] Algorithm: 배열 속 세 정수의 합이 0인 경우의 수 - endIndex vs count / 반열림 범위 / 재귀함수 / 배열에 요소 추가하기

DoyleHWorks·2024년 11월 11일
3

[두서없음 주의] 고민의 흐름대로 작성했어요
[스압 주의] 내용이 정말 길어요: 화면 우측의 목차를 이용해주세요!

Algorithm: 삼총사

배열 속 세 정수의 합이 0인 모든 경우의 수 (Github)

https://school.programmers.co.kr/learn/courses/30/lessons/147355
import Foundation

func solution(_ number:[Int]) -> Int {
    return 0
}

문제 파악

처음에 짠 코드는 이렇다:

import Foundation

func solution(_ numbers: [Int]) -> Int {
    guard (3...13).contains(numbers.count), numbers.allSatisfy({ (-1000...1000).contains($0) }) else {
        print("3 ≤ 입력값의 길이 ≤ 13, -1,000 ≤ 입력값의 각 원소 ≤ 1,000 이어야 합니다.")
        return -1
    }
    
    var possibleCaseCount = 0
    
    for indexA in numbers.startIndex...numbers.endIndex {
        if indexA + 3 <= numbers.count {
            let numberA = numbers[indexA]
            for indexB in (indexA + 1)...numbers.endIndex {
                if indexB + 2 <= numbers.count {
                    let numberB = numbers[indexB]
                    for indexC in (indexB + 1)...numbers.endIndex {
                        if indexC + 1 <= numbers.count {
                            let numberC = numbers[indexC]
                            if numberA + numberB + numberC == 0 {
                                possibleCaseCount += 1
                            }
                        }
                    }
                }
            }
        }
    }
    
    return possibleCaseCount
}

이대로 답안을 제출하고 끝내도 되지만..
보다시피 상당히 복잡해보이고, 가독성이 매우 나쁘다.
이 방향성을 유지한다면, 개선할 수 있는 점은 크게 세 가지로 보인다:

  1. 더해야 할 각 숫자는 let으로 일일히 선언할 필요 없이 마지막에 numbers[indexA] + numbers[indexB] + numbers[indexC] 이렇게 더해준다.
  2. 반복되는 for문과 if문 구조를 간략화한다.
  3. 삼중 루프이고 구조가 반복되기 때문에 재귀함수로 정리할 수 있을 것 같다.

문제 접근 1: 조건문 간략화

1번은 문장 안에 썼듯이 간단하게 해결 가능하다.

2번은..

for indexA in numbers.startIndex...number.endIndex {
	if indexA + 3 <= numbers.count {
    	// ...
    }
}

이런 식으로 반복되니까 for문과 if문을 함께 묶어줄 수 있겠다.
indexA + 3 <= numbers.count 를 치환하면 indexA <= numbers.count - 3이지 않은가?
그러니 밑의 코드에서 ???에 들어갈 수만 찾으면 될 것 같다.

for indexA in numbers.startIndex...(number.endIndex - ???)

그런데 endIndexcount는 정확히 어떻게 다른걸까?

당연히 Index는 0부터 시작하니까, endIndex + 1 = count 인 줄 알았다.
하지만 찾아보니 endIndex마지막 요소의 인덱스 + 1이었다.

Swift의 컬렉션 설계 철학과 범위 연산의 일관성 때문이라고 한다.
이를 바탕으로, 1번 내용도 포함하여 for문과 if문을 간략하게 정리해보았다.

    var possibleCaseCount = 0
    
    for i in 0...numbers.endIndex - 3 {
        for j in i+1...numbers.endIndex - 2 {
            for k in j+1...numbers.endIndex - 1 {
                if numbers[i] + numbers[j] + numbers[k] == 0 {
                    possibleCaseCount += 1
                }
            }
        }
    }
    
    return possibleCaseCount

3번 내용을 적용하지 않은 지금도 썩 나쁘지 않지만, 삼중 루프가 그대로 nested되어 재사용성이 낮다. 도전하는 마음으로 재귀함수를 구현해보기로 했다.

문제 접근 2: 재귀함수화

재귀함수(recursive function)를 짜는 것은 처음이라 덜컥 겁부터 났지만, "while문을 사용할 때 조심하는 것이랑 뭐가 다르겠어" 하구 용기를 가졌다 >:D

    var possibleCaseCount = 0

    func recursive(_ index: Int, _ current: [Int] ) {
        // 종료 조건: 3개의 숫자를 선택한 경우
        if current.count == 3 {
            if current.reduce(0, +) == 0 {
                possibleCaseCount += 1
            }
            return
        }
        
        // 재귀 단계; 다음 숫자를 선택
        for i in index..<numbers.count {
            recursive(i + 1, current + [numbers[i]])
        }
    }
    
    recursive(0, [])
    
    return possibleCaseCount

컴파일러의 타입 추론

위 재귀함수에서 Swift 컴파일러는 아래와 같은 과정을 거친다:

  1. 함수 선언 확인:
    • current 매개변수가 [Int] 타입으로 선언됨을 확인.
  2. 함수 호출 시 타입 검사:
    • 재귀 호출 시 전달된 인수 current + [numbers[i]]의 결과가 [Int]임을 확인.
  3. 타입 일치 확인:
    • 호출되는 함수의 매개변수 타입 [Int]와 전달된 값의 타입이 일치하므로, 정상적으로 컴파일되고 실행된다.

경우의 수를 탐색하는 원리

각 단계에서 current + [numbers[i]]를 통해 current 배열에 숫자가 하나씩 추가되고, current 배열의 숫자가 3개가 되면 합을 계산한 후 새로운 조합을 위해 return한다. 각 깊이(첫번째 숫자, 두번째 숫자, 세번째 숫자)에서 return을 해도 아직 바깥 단계의 for문 안에 있기 때문에 단계적으로 반복된다. 만약 배열의 숫자가 3개가 아닌데 다음으로 추가할 숫자가 없다면, 그보다 바깥에 있는 for 루프에 의해 다음 경우로 넘어가게 된다. 탐색하는 순서를 예시를 통해 시각화하면 아래와 같다.

[-2, 3, 0, 2, -5] 를 입력값으로 줬을 때의 모든 경우의 수 (정렬: 탐색 순서)

  • current = [-2] (index 0)
    • current = [-2, 3] (index 0 → 1)
      • current = [-2, 3, 0] → 합: 1 (index 0 → 1 → 2)
      • current = [-2, 3, 2] → 합: 3 (index 0 → 1 → 3)
      • current = [-2, 3, -5] → 합: -4 (index 0 → 1 → 4)
    • current = [-2, 0] (index 0 → 2)
      • current = [-2, 0, 2] → 합: 0 (유효 케이스) (index 0 → 2 → 3)
      • current = [-2, 0, -5] → 합: -7 (index 0 → 2 → 4)
    • current = [-2, 2] (index 0 → 3)
      • current = [-2, 2, -5] → 합: -5 (index 0 → 3 → 4)
    • current = [-2, -5] (index 0 → 4)
  • current = [3] (index 1)
    • current = [3, 0] (index 1 → 2)
      • current = [3, 0, 2] → 합: 5 (index 1 → 2 → 3)
      • current = [3, 0, -5] → 합: -2 (index 1 → 2 → 4)
    • current = [3, 2] (index 1 → 3)
      • current = [3, 2, -5] → 합: 0 (유효 케이스) (index 1 → 3 → 4)
    • current = [3, -5] (index 1 → 4)
  • current = [0] (index 2)
    • current = [0, 2] (index 2 → 3)
      • current = [0, 2, -5] → 합: -3 (index 2 → 3 → 4)
    • current = [0, -5] (index 2 → 4)
  • current = [2] (index 3)
    • current = [2, -5] (index 3 → 4)
  • current = [-5] (index 4)

조잘조잘:

  • for 루프 구조 안에 for 루프 구조가 있다보니 헷갈리는데, 위 경우의 수로부터 예를 들어 current = [-2, -5] (index 0 → 4) 으로부터 current = [3] (index 1) 에 넘어가는 경우를 보자.
    • (index 0 → 1~4) 루프 구조 안에는 (index 0 → 4) 루프 실행이 있다. 그런데 (index 0 → 4) 루프 실행 안에 있는 세번째 숫자를 채택하는 루프 구조는 for i in 5..<5가 되고, 이는 빈 범위이기에 실행되지 않고 종료된다. 또한, 이 함수는 안에 있는 루프 구조가 끝나면 생명 주기가 끝나는 함수이다. 따라서 (index 0 → 4) 루프 실행은 끝난다.
    • (index 0 → 1~4) 에서 마지막 실행이었던 (index 0 → 4) 루프 실행이 종료되어버렸으니, 마찬가지로 (index 0) 안의 루프 구조(index 0 → 1~4)도 종료된다. 그러면 (index 0) 루프 실행이 끝이 난다.
    • (index 0) 루프 실행마저도 (index 0~4) 루프 구조 안에 있는 하나의 루프 실행이다. (index 0) 루프 실행이 종료되면 다음 루프 실행은? (index 1) 루프 실행이다. 이런 식으로 반복된다..

문제 접근 3: 재귀함수 최적화

재귀함수를 열심히 이해해서 만든 건 좋다. 근데 한 가지 마음에 걸리는 게 있다.
단순한for문 반복에서는 각 범위에서 제한을 주니 필요없는 조합들이 발생하지 않는다.
그런데 재귀함수를 사용했더니 생각하지 않아도 될 경우의 수들을 훑게 된다: (index 0 → 4), (index 1 → 4), (index 2 → 4), (index 3), (index 4)

그뿐만이 아니고 current라는 배열은 재귀함수 스택에서 매번 만들어지니 메모리 낭비가 이루어질 것 같다.

    var possibleCaseCount = 0
    var combination = [Int](repeating: 0, count: 3) // 중복된 배열 생성 방지

    func recursive(_ depth: Int, _ start: Int) {
        if depth == 3 { // 종료 조건
            if combination.reduce(0, +) == 0 {
                possibleCaseCount += 1
            }
            return
        }
        
        for index in start..<numbers.count { // 재귀 단계
            combination[depth] = numbers[index]
            recursive(depth + 1, index + 1)
        }
    }
    
    recursive(0, 0)
    
    return possibleCaseCount

문제 접근 3-1

combination이란 배열을 미리 선언하고,depth 매개변수로 재귀 깊이를 추적하게 하였다. depth가 3이 되는 것은 combination[0], combination[1], combination[2] 모두 값이 변경되었음을 의미하기에, 이 시점에서 합을 계산한 후 return하게 된다. 따라서 depth가 3보다 작은 경우에만 루프가 실행되며, 3에 도달할 시 더 이상 combination[depth]에 접근하지 않는다. return한 후에는 재귀 호출의 이전 단계로 돌아간다.

예시로 나타내면 이렇다:

numbers = [1, -2, -1, 0]
combination = [0, 0, 0]

1. recursive(0, 0) 호출 (index 0)
   combination[0] = 1
   
   2. recursive(1, 1) 호출 (index 01)
      combination[1] = -2
      
      3. recursive(2, 2) 호출 (index 012)
         combination[2] = -1
         
         4. recursive(3, 3) 호출 (index 012 계산)
            depth == 3이므로 return
         
      3. recursive(2, 3) 호출 // 루프의 다음 반복 (index 0 → 1 → )
         combination[2] = 0
         
         4. recursive(3, 4) 호출 (index 013 계산)
            depth == 3이므로 return
            
      // 3. recursive(2, 4) 호출 (index 0 → 1 → ?)
         // 빈 범위라서 루프 종료
            
   2. recursive(1, 2) 호출 // 루프의 다음 반복 (index 0 → )
      combination[1] = -1
      
      3. recursive(2, 3) 호출 // 루프의 다음 반복 (index 0 → 2 → )
         combination[2] = 0
         
         4. recursive(3, 4) 호출 (index 023 계산)
            depth == 3이므로 return
            
      // 3. recursive(2, 4) 호출 (index 0 → 2 → ?)
         // 빈 범위라서 루프 종료
      
   // 2. recursive(1, 3) 호출 (index 0 → 3 → ?)
      // 빈 범위라서 루프 종료
            
1. recursive(0, 1) 호출 (index 1)
   combination[0] = -2
   
   2. recursive(1, 2) 호출 (index 12)
      combination[1] = -1
      
      3. recursive(2, 3) 호출 (index 123)
         combination[2] = 0
         
         4. recursive(3, 4) 호출 (index 123 계산)
            depth == 3이므로 return
            
      // 3. recursive(2, 4) 호출 (index 1 → 2 → ?)
         // 빈 범위라서 루프 종료
      
   // 2. recursive(1, 3) 호출 (index 1 → 3 → ?)
      // 빈 범위라서 루프 종료

1. recursive(0, 2) 호출 (index 2)
   combination[0] = -1
  
   2. recursive(1, 3) 호출 (index 23)
      combination[1] = 0
      
      3. recursive(2, 4) 호출 (index 23?)
         // 빈 범위라서 루프 종료
         
   // 2. recursive(1, 4) 호출 (index 2 → 3 → ?)
      // 빈 범위라서 루프 종료

1. recursive(0, 3) (index 3)
   combination[0] = 0
   
   2. recursive(1, 4) 호출 (index 3?)
   // 빈 범위라서 루프 종료
   
// 모든 루프가 끝남

유효하지 않은 범위는 위에서 표시한 것처럼 호출이 되어도 바로 루프가 종료되어 뛰어넘게 된다.
로직 자체를 얘기할 때는 이 단계들을 일일히 설명하는 것이 비효율적이지만, 이렇게 직접 표시해보면 깊이를 단계단계 바꿔가며 루프구조를 통해 탐색하는 것을 알 수 있다! 마치 파일 탐색기로 폴더를 탐색하는 것 같다.

문제 접근 3-2

		// 종료 조건

        if start >= numbers.count { // 빈 범위 방지 조건
        return
        }
        
        // 재귀 단계

가운데에 빈 범위 방지 조건을 넣음으로써 필요없는 범위에 대해 함수 호출을 막고 싶었다. 하지만 자세히 들여다보니 무의미하다고 판단했다. 왜냐하면 결국엔 for문의 범위 연산에서 해당 조건은 걸러지게 되고, if문과 for문의 연산 비용은 별다를게 없기 때문이다. 그나마 가독성이나 논리 명확성은 높일 수 있을 것 같다.

대신에 단순히 start를 비교하는 것보다, depth에 따라 함께 비교하면 필요없는 경우의 수를 더 넘길 수 있을 것 같다.
이마저도 입력값이 작다면 큰 의미가 없는 최적화지만, 없는 것보단 좋을 것 같다.

		// 종료 조건

        if depth == 1 && start >= numbers.count - 1 {
        	// depth 1에서 남은 요소가 충분하지 않으면 조합 생성 불가
        	return
        }
        
        // 재귀 단계

결론 및 인사이트

import Foundation

func solution(_ numbers: [Int]) -> Int {
    guard (3...13).contains(numbers.count), numbers.allSatisfy({ (-1000...1000).contains($0) }) else {
        print("3 ≤ 입력값의 길이 ≤ 13, -1,000 ≤ 입력값의 각 원소 ≤ 1,000 이어야 합니다.")
        return -1
    }
    
    var possibleCaseCount = 0
    var combination = [Int](repeating: 0, count: 3) // 중복된 배열 생성 방지

    func recursive(_ depth: Int, _ start: Int) {
        if depth == 3 { // 종료 조건
            if combination.reduce(0, +) == 0 {
                possibleCaseCount += 1
            }
            return
        }
        
        // depth 1에서 남은 요소가 충분하지 않으면 건너뜀
        if depth == 1 && start >= numbers.count - 1 {
        	return 
        }
        
        for index in start..<numbers.count { // 재귀 단계
            combination[depth] = numbers[index]
            recursive(depth + 1, index + 1)
        }
    }
    
    recursive(0, 0)
    
    return possibleCaseCount
}
  • 배열에서 endIndexcount와 그 값이 같지만 쓰임새가 전혀 다르며, 여기엔 Swift의 철학도 내재되어있다.
  • 루프가 중첩될 때, 반복문 vs 재귀함수는 각각의 장단점이 있다.
    • 반복문은 스택 오버헤드 없이 순회를 수행하기에 더 효율적이다 (큰 데이터일수록 성능 차이가 커진다).
    • 재귀함수는 유연성과 확장성이 좋아 조건 추가나 수정이 더 쉽다 (유지보수성과 코드 재사용성 중시).
실제로 성능에 차이가 많이 난다
  • nested된 반복문이 복잡하고 보기 나쁘다고 생각했는데 꼭 그렇지만도 않은 것 같다. 반복문이 더 직관적으로 보인다.
  • 최적화할 때는 '최적화' 자체에도 비용이 들어갈 수 있는 점을 생각해야 한다.
    • 최적화는 단순히 빠르게 하는 경우뿐만 아니라 '최선'을 고르는 선택이 될 때도 있다.

What I've Learned

🤔 왜 endIndex는 +1이 되어있는가?

endIndex마지막 요소의 인덱스 + 1 로 정의되어있다. 따라서 count와 같은 값을 갖는다.

1. 반열림 범위의 일관성

Swift에서는 반열림 범위 (..<)를 자주 사용한다. 이 범위는 시작점은 포함하되, 끝점은 포함하지 않는 방식이다. 이를 통해 코드를 더 읽기 쉽게 만들고, 에러를 방지할 수 있다.

let array = [10, 20, 30, 40]
for i in array.startIndex..<array.endIndex {
    print(array[i])
}

여기서 startIndex..<endIndex0..<4로 해석되어 모든 유효한 인덱스를 포함한다.

2. 안전성

endIndex가 배열의 유효한 마지막 인덱스를 초과하는 값을 가지도록 설계된 이유는 범위 기반 접근에서 초과 오류를 방지하기 위해서이다.

let array = [10, 20, 30, 40]
let slice = array[1..<array.endIndex]  // [20, 30, 40]

이렇게 하면 마지막 요소까지 포함하는 범위를 안전하게 지정할 수 있다. 만약 endIndex가 마지막 유효 인덱스였더라면, 범위를 설정할 때 1..<array.count와 같은 별도 처리가 필요했을 것이다.

3. C 스타일 루프와의 차이

전통적인 C 스타일 루프에서는 범위를 i = 0; i < count; i++로 지정하곤 했다. Swift는 이를 보다 명시적이고 안전하게 표현하기 위해 endIndex를 포함하여 범위의 상한을 유효 범위 바깥 값으로 설정한 것이다.

요약:

  • endIndex는 유효한 마지막 인덱스의 다음 위치를 가리킨다.
  • 이는 반복문 및 범위 연산에서 반열림 범위(..<)를 일관되게 사용하고, 안전한 배열 접근을 보장하기 위한 설계이다.

🤔 startIndex와 count가 같은 값이라면, 왜 구분했는가?

countendIndex가 같은 값을 가지면서도 따로 제공되는 이유는, 각각의 역할이 다르고, 특정 상황에서 더 명확하고 안전하게 코드를 작성할 수 있도록 하기 위함이다.

1. countendIndex가 구분되지 않았을 때의 문제

만약 count만 제공된다면, 다음과 같은 문제 상황이 발생할 수 있다.

문제 상황: 범위 지정 시 혼란

let array = [10, 20, 30, 40]

// 마지막 요소까지 포함하려는 범위:
let slice = array[0..<array.count]  // [10, 20, 30, 40]

// 반복문
for i in 0..<array.count {
    print(array[i])  // 출력: 10, 20, 30, 40
}

위 코드는 잘 작동하지만, 이때 count의 의미는 "마지막 인덱스 + 1"과 유사하게 해석된다. 범위를 설정하는 코드와 요소 개수를 확인하는 코드가 개념적으로 다른 역할을 수행하는데, 둘이 같은 이름이라면 혼란이 생길 수 있다.

또 다른 상황: 유효 범위 초과 방지

만약 count가 단순히 유효한 마지막 인덱스를 가리켰다면, 다음과 같은 범위 연산이 안전하지 않을 수 있다:

for i in 0...array.count - 1 {  // 만약 실수로 `...`을 사용하면?
    print(array[i])  // 마지막 인덱스 초과로 런타임 에러 발생
}

이런 경우를 막기 위해 Swift는 endIndex라는 별도의 속성을 제공하여 범위의 끝을 명확히 정의한다.

2. 다른 컬렉션에서도 활용

Array 외에도 Swift의 컬렉션 타입은 매우 다양하다. 특히 Slice, String, Dictionary 등의 경우, 인덱스요소 개수가 동일한 의미를 갖지 않을 수 있다. 이러한 상황에서 endIndexcount의 분리가 더욱 중요해진다.

예시: String에서 Character 접근

let text = "Swift"
print(text.count)     // 5 (문자 개수)
print(text.endIndex)  // Index(5) (유효한 범위의 끝)

for index in text.startIndex..<text.endIndex {
    print(text[index])  // S, w, i, f, t
}
  • 여기서 count는 문자열의 문자 개수(5)를 나타낸다.
  • endIndex는 마지막 문자 다음 위치를 나타낸다.
  • 인덱스 범위를 startIndex..<endIndex로 설정하여 모든 문자를 안전하게 반복할 수 있다.

예시: Slice

Slice는 원래 배열의 일부를 잘라낸 서브컬렉션이다. 이 경우 인덱스가 원래 배열의 인덱스 범위를 유지하기 때문에, countendIndex가 다르게 작동한다.

let array = [10, 20, 30, 40, 50]
let slice = array[1..<4]  // [20, 30, 40]

print(slice.count)     // 3 (요소 개수)
print(slice.endIndex)  // 4 (원래 배열의 인덱스 기준)
  • slice.count는 슬라이스된 요소의 개수 (3개)이다.
  • slice.endIndex는 원래 배열의 인덱스 기준으로 마지막 요소 이후의 위치이다.

3. 안전성과 명확성

Swift는 안전성을 중시한다. 따라서:

  • count요소의 개수라는 개념을 명확히 전달합니다.
  • endIndex범위 끝을 설정할 때 사용하는 안전한 값으로 설계되었다.

이런 분리 덕분에, 컬렉션의 크기와 유효 인덱스 범위를 각각 명확히 구분할 수 있다.

요약:

  1. count는 요소의 개수를 나타내며, 크기를 파악할 때 사용된다.
  2. endIndex는 마지막 유효 인덱스 다음 위치를 나타내며, 반복 및 범위 연산에서 안전성을 제공한다.
  3. 이 분리는 다양한 컬렉션 타입에서 특히 유용하며, 코드의 안전성과 명확성을 높인다.

🤔 startIndex == 0 ?

startIndex가 항상 0인 것은 아니다.

Swift에서 startIndex는 컬렉션(예: 배열, 문자열, 슬라이스 등)의 첫 번째 유효 인덱스를 나타내며, 0일 수도 있고 아닐 수도 있다. 특정 컬렉션 타입과 상황에 따라 다르게 동작합니다.

1. Array

배열의 경우, startIndex는 항상 0이다.

let array = [10, 20, 30]
print(array.startIndex)  // 출력: 0

2. String

문자열의 경우, startIndex는 항상 0이 아니다. Swift의 문자열은 유니코드 스칼라를 기반으로 동작하며, 인덱스는 단순한 정수가 아니라 문자열 내 특정 위치를 나타내는 구조이다.

let text = "Swift"
print(text.startIndex)  // 문자열의 첫 번째 위치 (0처럼 보이지만 실제로는 Character의 위치)

문자열에서는 startIndex가 유니코드 스칼라에 따라 위치를 정의하므로, 0처럼 보이지만 내부적으로는 다르게 동작할 수 있다.

3. Slice

슬라이스(Slice)는 기존 컬렉션의 부분 집합을 나타낸다. startIndex는 원래 컬렉션의 인덱스를 유지하기 때문에, 0이 아닐 수 있다.

let array = [10, 20, 30, 40, 50]
let slice = array[1..<4]  // [20, 30, 40]

print(slice.startIndex)  // 출력: 1 (원래 배열의 인덱스 유지)
print(slice[1])          // 20

슬라이스는 원래 컬렉션의 인덱스를 유지하므로, startIndex는 슬라이스의 첫 번째 요소가 원래 배열에서 가리키던 위치를 나타낸다.

4. 딕셔너리(Dictionary) 및 집합(Set)

딕셔너리와 집합의 경우, 인덱스는 요소의 추가 순서에 따라 달라질 수 있으며 0이 아니다.

let dict = ["a": 1, "b": 2, "c": 3]
print(dict.startIndex)  // 0처럼 보이지 않음. Dictionary.Index 타입 값

딕셔너리의 startIndex는 내부 구현에 따라 첫 번째 요소의 인덱스를 가리키며, 일반적인 정수 기반 인덱스와는 다르다.

정리

  • 배열(Array): startIndex는 항상 0.
  • 문자열(String): startIndex는 첫 번째 문자의 위치로, 0처럼 보이지만 내부적으로는 다름.
  • 슬라이스(Slice): startIndex원래 컬렉션의 인덱스를 유지.
  • 딕셔너리(Dictionary), 집합(Set): startIndex정수형 0이 아니며, 내부 구조에 따라 달라짐.

Swift의 startIndex는 데이터 타입과 상황에 따라 달라질 수 있도록 설계되었습니다. 이를 통해 더 유연하고 안전한 범위 연산을 제공한다.


🤔 왜 Swift에서는 반열림 범위를 자주 사용하는가?

Swift에서 반열림 범위(..<)를 자주 사용하는 이유는 안전성, 일관성, 그리고 코드 가독성을 높이기 위해서이다. 이 방식은 범위 기반 반복과 인덱스 접근을 보다 명확하고 오류 없이 수행할 수 있도록 돕는다.

1. 범위 연산의 일관성

반열림 범위는 Swift에서 배열, 문자열, 슬라이스 등 다양한 컬렉션 타입과 함께 일관되게 동작한다. 이 일관성은 개발자가 코드를 예측 가능하게 작성하는 데 큰 도움을 준다.

배열과 문자열에서 일관된 범위 사용

let array = [10, 20, 30, 40]
let slice = array[1..<3]  // [20, 30]

let text = "Swift"
let substring = text[text.startIndex..<text.index(text.startIndex, offsetBy: 3)]
print(substring)  // Swi
  • 배열문자열 모두 반열림 범위를 동일한 방식으로 처리하여 사용성을 높인다.
  • 이로 인해 다른 데이터 타입에 대해 동일한 패턴을 사용할 수 있다.

2. 반복문의 직관성

반열림 범위는 마지막 인덱스를 포함하지 않으므로 반복문의 직관성을 높여준다. 일반적인 C 스타일 루프와 비슷한 동작을 하기 때문에 직관적이다:

// C 스타일 루프
for (int i = 0; i < n; i++) {
    // ...
}

// Swift의 for-in 루프
for i in 0..<n {
    // ...
}
  • 둘 다 i < n 조건을 만족하므로, 루프 범위를 초과하지 않는 안전한 반복이 가능하다.

3. 범위의 경계값 변경이 쉬움

반열림 범위는 종료 조건 변경이 쉬워서, 유연하게 코드 작성이 가능하다.

예시: 시작 또는 끝 범위를 동적으로 조정

let array = [10, 20, 30, 40, 50]

// 첫 3개의 요소만 출력
for i in 0..<3 {
    print(array[i])  // 10, 20, 30
}

// 마지막 3개의 요소만 출력
for i in 2..<array.count {
    print(array[i])  // 30, 40, 50
}

반열림 범위를 사용하면 종료 지점만 쉽게 조정하여 원하는 서브 범위를 추출할 수 있다.

4. Swift 설계 철학: 안전성과 간결성

Swift는 안전성간결성을 핵심 설계 목표로 한다. 반열림 범위는:

  • 범위 초과 문제를 방지하여 안전한 코드를 작성할 수 있도록 돕고,
  • 불필요한 -1 또는 +1 계산 없이 간결하게 범위를 설정할 수 있도록 한다.

요약

  • 반열림 범위(..<)는 안전성과 일관성을 제공한다.
  • 끝점을 포함하지 않으므로 범위 초과 오류를 방지하고, 범위 조정이 쉽다.
  • 배열, 문자열, 슬라이스 등 다양한 컬렉션 타입에서 일관된 방식으로 작동한다.
  • Swift의 간결하고 안전한 반복을 가능하게 하는 중요한 설계 요소이다.

🤔 재귀함수를 만드는 법

재귀함수자기 자신을 호출하는 함수이다.

1. 재귀함수의 기본 구조

재귀함수는 종료 조건(Base Case)재귀 단계(Recursive Case)로 구성된다.

func recursiveFunction(_ parameter: Int) {
    // 종료 조건(Base Case)
    if parameter <= 0 {
        return
    }
    
    print("재귀 호출: \(parameter)")
    
    // 재귀 단계(Recursive Case)
    recursiveFunction(parameter - 1)
}

동작 설명:

  1. 함수가 자기 자신을 호출한다.
  2. 종료 조건을 만나면 더 이상 재귀 호출을 하지 않고 반환된다.
  3. 매 호출마다 parameter 값을 감소시켜 종료 조건에 가까워지도록 만든다.

2. 재귀함수의 예시

1) 팩토리얼 계산

팩토리얼 n!n * (n-1) * (n-2) * ... * 1로 정의된다. 팩토리얼은 재귀적으로 계산할 수 있다.

func factorial(_ n: Int) -> Int {
    if n == 0 {
        return 1  // 종료 조건: 0! = 1
    }
    return n * factorial(n - 1)  // 재귀 단계
}

// 사용 예시
print(factorial(5))  // 출력: 120 (5 * 4 * 3 * 2 * 1)

2) 피보나치 수열

피보나치 수열의 각 항은 앞의 두 항의 합으로 정의된다.
수열: 0, 1, 1, 2, 3, 5, 8, 13, ...

func fibonacci(_ n: Int) -> Int {
    if n == 0 {
        return 0  // 종료 조건
    } else if n == 1 {
        return 1  // 종료 조건
    }
    return fibonacci(n - 1) + fibonacci(n - 2)  // 재귀 단계
}

// 사용 예시
print(fibonacci(7))  // 출력: 13

3) 디렉토리 탐색

재귀함수는 계층적 데이터 구조(예: 디렉토리, 트리) 탐색에 유용하다.

func exploreDirectory(_ directory: String) {
    print("Exploring \(directory)")
    
    // 가정: 하위 디렉토리 목록 가져오기 (가상 함수)
    let subdirectories = getSubdirectories(of: directory) 
    
    for subdirectory in subdirectories {
        exploreDirectory(subdirectory)  // 하위 디렉토리 탐색
    }
}

// 가상 데이터
func getSubdirectories(of directory: String) -> [String] {
    return ["subdir1", "subdir2"]  // 가상의 하위 디렉토리 목록
}

// 사용 예시
exploreDirectory("/root")

3. 재귀함수 작성 시 주의할 점

  1. 종료 조건을 반드시 정의해야 한다.

    • 종료 조건이 없으면 무한 재귀 호출로 인해 프로그램이 멈추거나 스택 오버플로우(Stack Overflow)가 발생할 수 있다.
  2. 재귀 호출의 깊이를 고려해야 한다.

    • 재귀 호출이 너무 깊어지면 성능 문제가 발생할 수 있다.
    • 예를 들어, 피보나치 수열 재귀는 입력값이 커질수록 중복 계산이 많아져 비효율적이다.
  3. 대체 알고리즘(반복문) 고려

    • 일부 경우에는 재귀 대신 반복문을 사용하는 것이 더 효율적일 수 있다.
    • 피보나치나 팩토리얼 같은 경우 반복문을 사용하면 중복 호출을 방지할 수 있다.

4. 재귀 vs 반복

  • 재귀문제의 구조가 계층적이거나 분할정복(Divide and Conquer) 방식에 적합한 경우에 유용하다.
  • 반복문은 단순한 루프를 사용할 때 성능과 이해 측면에서 더 나은 선택일 수 있다.

5. 재귀와 관련된 알고리듬

  • 분할정복 (Merge Sort, Quick Sort)
  • DFS (깊이 우선 탐색)
  • 백트래킹 (N-Queens, 미로 탐색)
  • 하노이의 탑

재귀함수는 이러한 문제들을 해결할 때 직관적이고 간결한 코드를 작성하는 데 매우 유용하다.


🤔 배열에 요소를 추가하는 여러 방법

1. append 메서드

배열의 끝에 단일 요소를 추가한다.

var array = [1, 2, 3]
array.append(4)
print(array)  // 출력: [1, 2, 3, 4]

2. append(contentsOf:) 메서드

다른 배열이나 시퀀스의 모든 요소를 추가한다.

var array = [1, 2]
array.append(contentsOf: [3, 4, 5])
print(array)  // 출력: [1, 2, 3, 4, 5]

3. insert 메서드

배열의 특정 위치에 요소를 삽입한다.

var array = [1, 3, 4]
array.insert(2, at: 1)  // 인덱스 1 위치에 2 삽입
print(array)  // 출력: [1, 2, 3, 4]

4. insert(contentsOf:at:) 메서드

배열의 특정 위치에 다른 배열이나 시퀀스의 모든 요소를 삽입한다.

var array = [1, 4, 5]
array.insert(contentsOf: [2, 3], at: 1)  // 인덱스 1 위치에 [2, 3] 삽입
print(array)  // 출력: [1, 2, 3, 4, 5]

5. replaceSubrange 메서드

배열의 특정 범위를 대체하거나, 범위에 새 요소를 삽입한다.

  • 범위 덮어쓰기: 범위 크기와 대체할 요소 개수가 동일한 경우
    var array = [1, 2, 3, 4, 5]
    array.replaceSubrange(1...3, with: [10, 20])
    print(array)  // 출력: [1, 10, 20, 5]
  • 범위 확장: 대체할 요소가 범위보다 많을 경우
    var array = [1, 2, 5, 6]
    array.replaceSubrange(2..<2, with: [3, 4])  // 인덱스 2 위치에 [3, 4] 삽입
    print(array)  // 출력: [1, 2, 3, 4, 5, 6]
  • 범위 축소: 대체할 요소가 범위보다 적을 경우
    var array = [1, 2, 3, 4, 5]
    array.replaceSubrange(1...4, with: [10])
    print(array)  // 출력: [1, 10]
  • 요소 삽입: 범위의 시작과 끝이 같은 경우
    var array = [1, 2, 3, 4, 5]
    array.replaceSubrange(2..<2, with: [10, 20])
    print(array)  // 출력: [1, 2, 10, 20, 3, 4, 5]

6. += 연산자

배열의 끝에 하나 이상의 요소를 추가할 때 사용한다.
단일 요소 추가 시에도 가능하지만, 그럴 땐 명확성을 위해 append를 쓰자.

var array = [1, 2, 3]
array += [4, 5]
print(array)  // 출력: [1, 2, 3, 4, 5]

7. + 연산자

두 배열을 합쳐서 새로운 배열을 반환한다. 기존 배열은 변경되지 않는다.

let array1 = [1, 2]
let array2 = [3, 4]

let combinedArray = array1 + array2
print(combinedArray)  // 출력: [1, 2, 3, 4]

8. reserveCapacityappend를 조합

배열의 용량을 미리 확보하여 요소를 추가할 때 성능을 최적화한다.

var array: [Int] = []
array.reserveCapacity(10)  // 용량 미리 확보
array.append(1)
array.append(2)
print(array)  // 출력: [1, 2]

배열에 요소를 추가하는 방법 요약

메서드/연산자설명예시결과
append배열의 끝에 단일 요소를 추가array.append(4)[1, 2, 3, 4]
append(contentsOf:)배열이나 시퀀스의 모든 요소를 추가array.append(contentsOf: [3, 4, 5])[1, 2, 3, 4, 5]
insert배열의 특정 위치에 단일 요소 삽입array.insert(2, at: 1)[1, 2, 3, 4]
insert(contentsOf:at:)배열의 특정 위치에 다른 배열이나 시퀀스의 모든 요소 삽입array.insert(contentsOf: [2, 3], at: 1)[1, 2, 3, 4, 5]
replaceSubrange특정 범위를 대체하거나 삽입- array.replaceSubrange(1...3, with: [10, 20])
- array.replaceSubrange(2..<2, with: [3, 4])
[1, 10, 20, 5]
[1, 2, 3, 4, 5, 6]
+= 연산자배열의 끝에 하나 이상의 요소 추가array += [4, 5][1, 2, 3, 4, 5]
+ 연산자두 배열을 합쳐서 새로운 배열 반환let combinedArray = array1 + array2[1, 2, 3, 4]
reserveCapacity + append용량을 미리 확보한 후 요소 추가로 성능 최적화array.reserveCapacity(10)
array.append(1)
[1, 2]

권장 사항

  • 단일 요소 추가: append 사용.
  • 여러 요소 추가: append(contentsOf:) 또는 += 사용.
  • 특정 위치에 요소 삽입: insert (단일 요소), insert(contentsOf:at:) (여러 요소).
  • 범위 대체 및 삽입: replaceSubrange 사용.
  • 성능 최적화: 요소가 많을 경우 reserveCapacity로 용량 확보 후 추가.
profile
Reciprocity lies in knowing enough

2개의 댓글

comment-user-thumbnail
2024년 11월 11일

스압주의는 이럴 때 쓰는 거군요

1개의 답글