[iOS 3주차] Algorithm: 이상한 문자 만들기 - String vs Substring / 문자열 쪼개기 / 정규식

DoyleHWorks·2024년 11월 7일
2
post-thumbnail

고민의 흐름대로 작성함 (GitHub)

Algorithm: 이상한 문자 만들기

https://school.programmers.co.kr/learn/courses/30/lessons/12930
func solution(_ s:String) -> String {
    return ''
}

문제 접근 1

문자열을 쪼개는 메서드는 여러가지가 있다. 그 중에서 components() 메서드를 사용하기로 했다.
입력값에서 각 단어들을 공백 기준으로 나눠서 각각 처리한 후, 다시 하나의 String으로 합쳐 반환하려고 하였다.

  let words = str.components(separatedBy: .whitespacesAndNewlines)
  var processedWords: [String] = []

  for word in words {
      var processedWord = ""
      for (index, char) in word.enumerated() {
          if index % 2 == 0 {
              processedWord.append(char.uppercased())
          } else {
              processedWord.append(char.lowercased())
          }
      }
      processedWords.append(processedWord)
  }

↓정제: map() / enumerated() / joined() / 삼항연산자, 메서드 체이닝 활용

  let words = str.components(separatedBy: .whitespacesAndNewlines)
  let processedWords = words.map {
      $0.enumerated()
          .map({ (index, char) in index % 2 == 0 ? char.uppercased() : char.lowercased() })
          .joined()
  }

그런데 막상 다시 String으로 돌려보내려니 막막하였다.
기존의 공백을 유지할 수 없었기 때문이다.

입력값기대되는 반환값
"try hello world""TrY HeLlO WoRlD"
"whats up  dog""WhAtS Up  DoG"

단어의 구분 기준이 공백이 " " 하나로만 고정되어있다면 문제 없지만, 문제에서 제공하는 입력값의 문자열은 하나 '이상'의 공백문자로 단어가 구분되어 있다고 했다. components(separatedBy: .whitespacesAndNewlines) 에서 " "가 아닌 .whitespacesAndNewlines를 넣은 것은 그런 연유에서이다.

이를 해결하기 위해 입력값의 문자열에서 각 단어를 range(of:options:)를 사용해 범위를 찾고 그 범위를 대체하려고 했으나, 이론상 가능하겠지만 그다지 효율적이지 않다는 생각이 들었다. 기존의 위치 정보를 보존하고 특정 부분만 떼어내어 처리 후 돌려주고 싶은데..

그럴바에야 제일 처음 단어를 추출할 때 해당 단어의 range를 함께 찾아주는 게 좋다고 판단했다.

문제 접근 2

1) range() 메서드는 Range<String.Index>? 타입의 값을 반환한다.
정규식 \\S+를 사용해 공백이 아닌 문자열을 찾고, 그 문자열을 Range<String.Index> 값과 함께 꺼내면 될 것 같다.

2) 어라? 근데 range() 메서드를 쓰는 시점에서, 그 자리에서 해당 문자열을 Substring으로 꺼내서 바꾸고 다시 넣어줄 순 없을까? 하지만 range()는 조건에 맞는 첫 번째 결과만 검색한다. NSRegularExpression을 쓰면 다 찾을 수 있다고 하는데 공부가 너무 길어질 것 같다.. 일단 보류! 앞에서 한 단어씩 추출해야겠다.

하나씩 단어를 추출해 처리하는 식을 쓰다보니 작은 애니메이션이 상상되었다.
마치 기다란 줄이 함수를 통과하면서 머리부터 나오는 그림이다.
굳이 문자로 표현해보자면 이 표처럼..

반복횟수0123
input"aaaa bbbb cccc""bbbb cccc""cccc"""
output"""AaAa ""AaAa BbBb ""AaAa BbBb CcCc"

기존의 문자열을 머리부터 넣어서, 요구하는 조건에 맞춰 단어는 변환해주고, 그대로 새로운 문자열을 이어서 뽑아내는 것이다.

3) 하지만 이 방식대로 함수를 짜다보니 복잡해보였다.
일단 Range<String.Index> 값을 다루는 것 자체가 너무 어색하다.
배운 내용을 억지로 쓰려고 하고 있는 건 아닌가? 문자열 인덱스는 다루기 복잡하니, 정수형 인덱스로 다루고 싶은데..

문자열을 "단어 부분"과 "공백 부분"으로 나눠서 배열로 치환하자.
그 다음 단어 부분만 바꿔서 붙이면 될 것이다.
단어 부분과 공백 부분으로 나누려면 어떻게 해야하지?
그런데 검색해보니 공부를 보류하기로 한 NSRegularExpression가 또 나왔다...;;

문제 해결 및 인사이트

이래저래 생각해봐도 문자열에 접근할 때 단어 단위로 접근하려고 하니 복잡해진 것 같다.
String.Element, 즉 Character 단위로 접근해서 문자를 변환해가는 알고리즘을 만드는 게 낫겠다고 판단했다.

반복횟수0123456...
input"aaaa bbbb cccc""aaa bbbb cccc""aa bbbb cccc""a bbbb cccc"" bbbb cccc""bbbb cccc""bbb cccc"...
output"""A""Aa""AaA""AaAa""AaAa ""AaAa B"...
func solution(_ str: String) -> String {
    var mutableString = ""
    var shouldCapitalize = true
    
    for char in str.lowercased() {
        if char.isWhitespace {
            mutableString.append(char)
            shouldCapitalize = true
        } else {
            shouldCapitalize ? mutableString.append(char.uppercased()) : mutableString.append(char)
            shouldCapitalize.toggle()
        }
    }
    return mutableString
}

막상 그렇게 접근하니 간단히 정리되었다.
나름 생각도 많이 하고 복잡했는데.. 그래서 글도 이렇게 써가면서 했는데.. 좀 허무하다.
하지만 처음인데 헤매는 게 당연하다고 생각하기로 했다.

여러가지 고민을 하면서 각 메서드의 한계점이나 다른 방법들의 가능성을 검토할 수 있었다.
NSRegularExpression을 사용하면 정규식을 통해 많은 단어를 동시에 처리할 수 있고, 정규식 엔진이 알아서 최적화를 해준다고 한다.
문자열이 특정 패턴의 단어 단위로 정말 길어진다면 초기화 비용을 감수해도 좋을지 모른다.

아무튼 이에 대한 공부/삽질은 여기까지 해야겠다!! 머리 아프다. xP


What I've Learned

Substring

Substring은 Swift에서 String의 일부를 나타내는 타입이다. String에서 특정 부분을 추출할 때 생성되며, 원본 String의 메모리를 공유한다. 이는 성능 최적화를 위해 설계된 것으로, 메모리 복사를 최소화하여 문자열 작업을 효율적으로 수행할 수 있다.

  • 메모리 공유: Substring은 원본 String의 일부를 참조하며, 새로운 메모리를 할당하지 않는다.
  • 임시적 사용: Substring은 보통 짧은 기간 동안 사용된다. 원본 String이 해제되면 Substring도 유효하지 않게 된다.
  • 변환 필요: 장기간 저장하거나 독립적으로 사용하려면 String으로 변환해야 한다.

StringSubstring의 차이점

특징StringSubstring
목적독립적인 문자열 관리원본 문자열의 일부를 효율적으로 참조
메모리 관리별도의 메모리 공간을 할당원본 String의 메모리를 참조
사용 수명독립적이며 장기간 사용 가능원본 String이 해제되면 유효하지 않음
변환 필요성불필요장기간 사용 시 String으로 변환 필요
성능더 많은 메모리 소비메모리 복사를 피하므로 더 효율적
변경 가능 여부String은 불변이지만 복사 후 수정 가능자체 수정은 불가능하며, 수정하려면 변환 필요

사용 예시

// 1. Substring 사용 예시
let fullString = "Hello, Swift!"
if let commaIndex = fullString.firstIndex(of: ",") {
    let substring = fullString[..<commaIndex]  // "Hello"
    print(substring)  // 출력: "Hello"
}
// 2. Substring을 String으로 변환
let stringVersion = String(substring)
print(stringVersion)  // "Hello"

어떤 경우에 StringSubstring을 사용해야 할까?

  1. Substring 사용이 적합한 경우:

    • 문자열의 특정 부분을 일시적으로 사용할 때.
    • 예를 들어, 검색, 필터링, 비교 등의 작업에서 성능 최적화가 필요할 때.
  2. String 사용이 적합한 경우:

    • 문자열을 장기적으로 저장하거나 다른 데이터 구조에 넣을 때.
    • Substring을 독립적으로 다루어야 하는 경우(메모리 공유를 피하고 싶을 때).

문자열 쪼개는 메서드

1. split(separator:)

let sentence = "Swift is fun"
let words = sentence.split(separator: " ")
print(words) // ["Swift", "is", "fun"]
  • 구분자를 기준으로 문자열을 나누고, Substring 배열로 반환된다.
  • 빈 문자열은 포함되지 않는다.

2. components(separatedBy:)

let sentence = "Swift,is,fun"
let words = sentence.components(separatedBy: ",")
print(words) // ["Swift", "is", "fun"]
  • 구분자를 기준으로 문자열을 나누고, String 배열을 반환한다.
  • 빈 문자열도 포함될 수 있다.

3. prefix(while:) / suffix(while:)

let sentence = "Swift is fun"
let prefix = sentence.prefix(while: { $0 != " " })
print(prefix) // "Swift"
  • 조건을 만족하는 앞부분 또는 뒷부분을 추출한다.
  • 조건을 만족하는 첫 번째 문자에서 멈춘다.
  • Substring을 반환한다.

4. enumerateSubstrings(in:options:_:)

string.enumerateSubstrings(in: string.startIndex..<string.endIndex, options: .byWords) { (substring, _, _, _) in
    print(substring)
}
let sentence = "Swift is fun."
sentence.enumerateSubstrings(in: sentence.startIndex..<sentence.endIndex, options: .byWords) { (substring, _, _, _) in
    print(substring ?? "") 
}
// 출력: "Swift", "is", "fun."
  • 지정한 옵션에 따라 문자열의 하위 문자열을 순회한다.
    • .byWords: 단어 단위로 분리.
    • .bySentences: 문장 단위로 분리.
    • .byLines: 줄 단위로 분리.
    • .byComposedCharacterSequences: 개별 문자(유니코드 시퀀스) 단위.
  • 반환값이 없고 클로저를 통해 하위 문자열(Substring?)을 하나씩 전달한다.

5. replacingOccurrences(of:with:)

 let sentence = "Hello World"
 let modified = sentence.replacingOccurrences(of: " ", with: ",")
 print(modified) // "Hello,World"
  • 문자열의 특정 부분을 다른 문자열로 바꾸고, 이를 통해 쪼개는 효과를 줄 수 있다.
  • 새로운 문자열 String을 반환한다.

6. 정규식과 함께 range(of:options:) 사용

if let range = string.range(of: "\\s+", options: .regularExpression) {
    // range를 이용하여 문자열을 나눔
}
  • 문자열 내에서 특정 텍스트나 정규식의 첫 번째 일치 범위를 반환한다.
let sentence = "Swift  is   fun"
if let range = sentence.range(of: "\\s+", options: .regularExpression) {
    print("First match:", sentence[range]) // "  " (첫 번째 공백 패턴)
}
let words = sentence.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }
print(words) // ["Swift", "is", "fun"]
  • 정규식을 이용해 문자열의 특정 패턴을 기준으로 나눌 수 있다.

range(of:options:)

range(of:options:)문자열 내에서 특정 텍스트나 정규식 패턴을 검색하여 첫 번째 일치 범위를 반환하는 유용한 메서드이다. 다양한 검색 옵션을 통해 유연한 문자열 매칭이 가능하다.

메서드 정의

func range(
    of searchString: String,
    options mask: String.CompareOptions = [],
    range searchRange: Range<String.Index>? = nil,
    locale: Locale? = nil
) -> Range<String.Index>?

매개변수

  1. of searchString

    • 검색할 문자열 또는 정규식 패턴.
    • 예: "Swift", "\\d+" (정규식)
  2. options mask (기본값: [])

    • 검색 방식을 설정하는 옵션(여러 개 설정 가능).
  3. range searchRange (기본값: nil)

    • 검색할 범위를 지정한다.
    • 지정하지 않으면 전체 문자열을 검색.
  4. locale (기본값: nil)

    • 문자열 비교 시 특정 지역 설정을 적용.
    • nil일 경우, 사용자의 기본 로케일을 사용.

반환값

  • Range<String.Index>?:
    • 일치하는 범위(Range<String.Index>)를 반환.
    • 일치하는 문자열이 없으면 nil 반환.

주요 옵션 (String.CompareOptions)

다양한 검색 방식을 제어할 수 있다.

옵션설명
.caseInsensitive대소문자를 구분하지 않고 검색.
.literal문자열을 리터럴로 처리 (특수 문자도 그대로).
.backwards뒤에서부터 검색.
.anchored문자열의 시작 또는 에서 고정된 위치만 검색.
.numeric숫자를 숫자로 취급하여 비교.
.diacriticInsensitive발음 구별 기호(é vs e)를 무시하고 비교.
.widthInsensitive폭이 다른 문자(全 vs 全: 일반 vs 좁은 폭)를 동일하게 취급.
.regularExpression정규식 패턴으로 검색.

예시

// 1. 단순 텍스트 검색
let text = "Swift is amazing!"
if let range = text.range(of: "Swift") {
    print("Matched:", text[range]) // "Swift"
}
// 2. 대소문자 구분 없이 검색
let text = "Swift is amazing!"
if let range = text.range(of: "swift", options: .caseInsensitive) {
    print("Matched:", text[range]) // "Swift"
}
// 3. 문자열 끝에서부터 검색
let text = "Hello, Hello, Hello!"
if let range = text.range(of: "Hello", options: .backwards) {
    print("Last Match:", text[range]) // "Hello" (마지막 "Hello")
}
// 4. 정규식 사용
let text = "I have 123 apples and 45 bananas."
if let range = text.range(of: "\\d+", options: .regularExpression) {
    print("Matched:", text[range]) // "123" (첫 번째 숫자)
}
// 5. 특정 범위에서 검색
let text = "Swift programming is fun!"
let rangeToSearch = text.startIndex..<text.index(text.endIndex, offsetBy: -4)

if let range = text.range(of: "is", range: rangeToSearch) {
    print("Matched in range:", text[range]) // "is"
}
// 6. 발음 구별 기호 무시 검색
let text = "Café"
if let range = text.range(of: "Cafe", options: .diacriticInsensitive) {
    print("Matched:", text[range]) // "Café"
}

활용 팁

  1. 일치 여부만 확인:
    반환값이 nil인지 아닌지로 일치 여부를 확인할 수 있다.

    if text.range(of: "target") != nil {
        print("Found!")
    }
  2. 일치한 문자열 추출:
    반환된 Range를 이용해 부분 문자열을 추출할 수 있다.

    let match = text[range] // 일치한 부분만 추출
  3. 정규식을 활용한 복잡한 패턴 검색:

    • 정규식을 사용하여 숫자, 이메일, 특정 패턴 등을 쉽게 검색할 수 있다.
    if let range = text.range(of: "\\b\\w+@\\w+\\.com\\b", options: .regularExpression) {
        print("Matched Email:", text[range])
    }

정리

range(of:options:)문자열 검색정규식 매칭에서 매우 강력한 메서드이다.
주요 특징은 다음과 같다:

  1. 문자열 또는 정규식을 검색하여 첫 번째 매칭 범위를 반환.
  2. 다양한 검색 옵션을 통해 대소문자 구분, 정규식, 방향 등 검색 방식을 세밀하게 조정 가능.
  3. 반환된 Range를 활용하여 문자열의 특정 부분을 쉽게 추출할 수 있음.

정규식(Regular Expression)

정규식(Regular Expressions)은 문자열에서 특정 패턴을 검색, 매칭, 변환하는 데 사용되는 강력한 도구이다. Swift를 포함한 대부분의 프로그래밍 언어에서 사용되며, 복잡한 문자열 작업을 간단하게 처리할 수 있다.

1. 정규식의 기본 개념

  • 정규식이란?
    문자열에서 특정 패턴을 정의하고, 이를 이용해 해당 패턴과 일치하는 부분을 찾거나 변환하는 데 사용된다.

  • 주요 용도:

    1. 문자열 검색: 특정 패턴의 위치를 찾기.
    2. 문자열 추출: 필요한 데이터 추출.
    3. 문자열 검증: 이메일, 전화번호 등의 형식 검사.
    4. 문자열 치환: 특정 패턴을 다른 문자열로 변환.

2. 정규식의 기본 문법

표현식의미예시
.임의의 한 문자 (줄 바꿈 제외)a.c → "abc", "adc" 등
^문자열 시작^Hello → "Hello"로 시작
$문자열 끝world$ → "world"로 끝
*0회 이상 반복a* → "", "a", "aaa"
+1회 이상 반복a+ → "a", "aaa"
?0회 또는 1회a? → "", "a"
{n}정확히 n번 반복a{3} → "aaa"
{n,}최소 n번 반복a{2,} → "aa", "aaa"
{n,m}최소 n번, 최대 m번 반복a{2,4} → "aa", "aaa", "aaaa"
[abc]a, b, c 중 하나[abc] → "a", "b", "c"
[^abc]a, b, c가 아닌 문자[^abc] → "d", "e"
[a-z]소문자 알파벳 범위[a-z] → "a", "b", ... "z"
\\d숫자 (0-9)\\d+ → "123"
\\D숫자가 아닌 문자\\D+ → "abc"
\\w단어 문자 (알파벳, 숫자, _)\\w+ → "abc123"
\\W단어 문자가 아닌 문자\\W+ → "@"
\\s공백 문자 (스페이스, 탭, 줄바꿈)\\s+ → 공백
\\S공백이 아닌 문자\\S+ → "Hello"
OR 연산자cat|dog → "cat" 또는 "dog"
( )그룹(ab)+ → "abab"
(?: )비캡처 그룹 (그룹화는 하지만 캡처하지 않음)(?:ab)+ → "abab"

3. 정규식의 활용 사례

1. 문자열 검색

특정 패턴이 문자열에 포함되어 있는지 확인.

let text = "The price is 100 dollars."
if let range = text.range(of: "\\d+", options: .regularExpression) {
    print("Matched:", text[range]) // "100"
}

2. 문자열 검증

입력된 문자열이 특정 형식(예: 이메일, 전화번호)을 따르는지 확인.

let email = "test@example.com"
let emailPattern = "[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}"
let isValid = email.range(of: emailPattern, options: [.regularExpression, .caseInsensitive]) != nil
print(isValid) // true

3. 문자열 치환

특정 패턴을 다른 문자열로 변환.

let sentence = "Swift 5 is amazing."
let result = sentence.replacingOccurrences(of: "\\d", with: "X", options: .regularExpression)
print(result) // "Swift X is amazing."

4. 문자열 분리

특정 패턴 기준으로 문자열 나누기.

let sentence = "apple, banana; orange grape"
let components = sentence.components(separatedBy: "[,;\\s]+".regularExpression)
print(components) // ["apple", "banana", "orange", "grape"]

4. Swift에서 정규식 관련 주요 메서드

1. range(of:options:)

  • 문자열에서 정규식 패턴과 일치하는 첫 번째 범위를 반환.

2. replacingOccurrences(of:with:options:)

  • 정규식 패턴을 기반으로 문자열을 변환.

3. components(separatedBy:)

  • 정규식 패턴을 기준으로 문자열을 분리.

4. NSRegularExpression 클래스

복잡한 정규식 매칭과 변환 작업을 위해 사용.

  • 메서드:
    • matches(in:options:range:): 모든 매칭 결과 반환.
    • stringByReplacingMatches(in:options:range:withTemplate:): 정규식을 기반으로 문자열 변환.
import Foundation

let text = "User123 has logged in with ID456."
let regex = try NSRegularExpression(pattern: "\\d+")
let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))

for match in matches {
    if let range = Range(match.range, in: text) {
        print("Matched:", text[range]) // "123", "456"
    }
}

5. 정규식의 장점과 한계

장점:

  • 복잡한 문자열 패턴을 간단히 처리 가능.
  • 문자열 검색, 검증, 치환에 매우 유용.
  • 여러 프로그래밍 언어에서 일관성 있게 사용 가능.

한계:

  • 가독성 저하: 정규식이 복잡해질수록 읽기 어려움.
  • 디버깅 어려움: 실수를 찾고 수정하기 어려울 수 있음.
  • 성능 문제: 매우 복잡한 정규식은 큰 데이터에서 느릴 수 있음.

6. 정리

정규식의 주요 기능

  • 패턴 검색: 특정 문자열이나 구조를 찾을 수 있음.
  • 문자열 분리: 특정 패턴 기준으로 문자열을 나눌 수 있음.
  • 문자열 치환: 특정 패턴을 기반으로 변환 작업 수행.
  • 입력 검증: 사용자의 입력이 특정 형식을 만족하는지 확인 가능.

Swift에서의 활용

  • range(of:options:), replacingOccurrences(of:with:options:) 등의 메서드와 함께 사용.
  • 복잡한 정규식 작업NSRegularExpression을 사용하여 처리 가능.

profile
Reciprocity lies in knowing enough

2개의 댓글

comment-user-thumbnail
2024년 11월 8일

문제 하나에서 정말 깊고 다양하게 파고 들어가시네요!!
궁금한 거 여기 벨로그에 검색하면 되겠어요

1개의 답글