컬렉션 조작은 Dart에서 리스트(List), 셋(Set), 맵(Map)과 같은 컬렉션을 다루는 다양한 기법들을 의미한다. 이 챕터에서는 Dart에서 제공하는 컬렉션을 보다 효과적으로 다루기 위한 다양한 방법을 설명한다.
리스트(List) 조작
리스트는 순서가 있는 컬렉션으로, Dart에서 가장 많이 사용되는 자료형 중 하나다. 리스트에서 데이터를 추가하거나 제거하는 작업은 매우 흔하며, Dart에서는 이를 지원하는 다양한 메소드가 제공된다.
요소 추가
리스트에 요소를 추가하는 방법으로는 add()
와 addAll()
메소드가 있다. add()
는 단일 요소를 리스트의 끝에 추가하고, addAll()
은 여러 요소를 한 번에 추가할 수 있다.
List<int> numbers = [1, 2, 3];
numbers.add(4); // 결과: [1, 2, 3, 4]
numbers.addAll([5, 6]); // 결과: [1, 2, 3, 4, 5, 6]
요소 제거
리스트에서 특정 요소를 제거할 때는 remove()
와 removeAt()
메소드를 사용할 수 있다. remove()
는 리스트에서 특정 값을 제거하고, removeAt()
은 인덱스를 기반으로 요소를 제거한다.
List<int> numbers = [1, 2, 3, 4];
numbers.remove(3); // 결과: [1, 2, 4]
numbers.removeAt(0); // 결과: [2, 4]
리스트 병합 및 슬라이싱
리스트 병합
두 개 이상의 리스트를 병합하는 작업은 addAll()
메소드를 사용하거나, Dart에서 제공하는 ...
(스프레드 연산자)를 활용할 수 있다.
List<int> list1 = [1, 2, 3];
List<int> list2 = [4, 5, 6];
List<int> merged = [...list1, ...list2]; // 결과: [1, 2, 3, 4, 5, 6]
리스트 슬라이싱
리스트에서 특정 구간을 추출할 때는 sublist()
메소드를 사용할 수 있다. sublist(startIndex, endIndex)
형태로 사용되며, startIndex
부터 endIndex-1
까지의 값을 포함하는 서브리스트를 반환한다.
List<int> numbers = [1, 2, 3, 4, 5, 6];
List<int> slice = numbers.sublist(1, 4); // 결과: [2, 3, 4]
리스트 필터링
리스트에서 조건에 맞는 요소만 추출할 때는 where()
메소드를 사용한다. 이 메소드는 함수형 프로그래밍의 개념을 따르며, 불리언 값을 반환하는 조건을 기반으로 리스트를 필터링한다.
List<int> numbers = [1, 2, 3, 4, 5, 6];
List<int> evenNumbers = numbers.where((num) => num % 2 == 0).toList(); // 결과: [2, 4, 6]
리스트 정렬
리스트의 요소를 오름차순이나 내림차순으로 정렬하는 방법은 sort()
메소드를 사용한다. 기본적으로 sort()
메소드는 오름차순 정렬을 수행하지만, 커스텀 비교 함수를 제공하여 내림차순으로도 정렬할 수 있다.
List<int> numbers = [5, 1, 4, 2, 3];
numbers.sort(); // 결과: [1, 2, 3, 4, 5]
numbers.sort((a, b) => b.compareTo(a)); // 내림차순 정렬 결과: [5, 4, 3, 2, 1]
리스트의 변환
리스트의 각 요소를 특정 방식으로 변환하는 작업은 map()
메소드를 사용하여 가능하다. 이 메소드는 리스트의 모든 요소에 변환 로직을 적용한 새로운 리스트를 반환한다.
List<int> numbers = [1, 2, 3, 4, 5];
List<int> squaredNumbers = numbers.map((num) => num * num).toList(); // 결과: [1, 4, 9, 16, 25]
리스트의 모든 요소에 함수 적용
리스트의 모든 요소에 대해 함수나 연산을 적용하는 방법으로는 forEach()
를 사용할 수 있다. 이는 주로 리스트 내의 각 요소를 순회하며 부수적인 작업을 할 때 사용된다.
List<int> numbers = [1, 2, 3];
numbers.forEach((num) => print(num)); // 1, 2, 3 출력
리스트의 탐색
리스트에서 특정 조건에 맞는 요소를 찾는 방법으로는 firstWhere()
, lastWhere()
, indexOf()
등이 있다.
첫 번째 일치하는 요소 찾기
firstWhere()
메소드는 리스트에서 주어진 조건을 만족하는 첫 번째 요소를 반환한다. 만약 조건을 만족하는 요소가 없을 경우, 기본값을 설정하거나 예외를 발생시킬 수 있다.
List<int> numbers = [1, 2, 3, 4, 5];
int firstEven = numbers.firstWhere((num) => num % 2 == 0); // 결과: 2
마지막 일치하는 요소 찾기
lastWhere()
메소드는 firstWhere()
와 동일하지만, 리스트에서 마지막으로 조건을 만족하는 요소를 반환한다.
int lastEven = numbers.lastWhere((num) => num % 2 == 0); // 결과: 4
특정 요소의 인덱스 찾기
indexOf()
메소드는 리스트에서 특정 값의 첫 번째 인덱스를 반환한다. 만약 값이 리스트에 없으면 -1
을 반환한다.
List<int> numbers = [1, 2, 3, 4, 5];
int index = numbers.indexOf(3); // 결과: 2
리스트의 중복 제거
리스트의 중복 요소를 제거하는 방법으로는 toSet()
메소드를 사용할 수 있다. 이 메소드는 리스트를 Set
으로 변환하여 중복을 제거한 후, 다시 리스트로 변환한다.
List<int> numbers = [1, 2, 2, 3, 3, 4];
List<int> uniqueNumbers = numbers.toSet().toList(); // 결과: [1, 2, 3, 4]
리스트의 길이와 빈 리스트 확인
리스트의 길이를 확인할 때는 length
속성을 사용한다. 또한, 리스트가 비어 있는지 확인할 때는 isEmpty
또는 isNotEmpty
속성을 사용할 수 있다.
List<int> numbers = [1, 2, 3];
int length = numbers.length; // 결과: 3
bool isEmpty = numbers.isEmpty; // 결과: false
리스트 초기화
리스트를 고정된 길이로 초기화하거나 특정 값으로 초기화할 때는 List.filled()
메소드를 사용한다. 이는 리스트를 특정 크기와 값으로 초기화하는 데 유용하다.
List<int> zeros = List.filled(5, 0); // 결과: [0, 0, 0, 0, 0]
리스트의 부분 업데이트
리스트의 특정 구간을 다른 값으로 업데이트하는 방법으로는 setRange()
메소드를 사용할 수 있다. 이 메소드는 리스트의 일부를 지정한 값으로 덮어쓴다.
List<int> numbers = [1, 2, 3, 4, 5];
numbers.setRange(1, 3, [10, 20]); // 결과: [1, 10, 20, 4, 5]
리스트의 빈 자리 삽입
리스트에 특정 위치에 요소를 삽입할 때는 insert()
와 insertAll()
메소드를 사용할 수 있다. insert()
는 단일 요소를 삽입하고, insertAll()
은 여러 요소를 삽입한다.
List<int> numbers = [1, 2, 4, 5];
numbers.insert(2, 3); // 결과: [1, 2, 3, 4, 5]
List<int> numbers2 = [1, 2, 5];
numbers2.insertAll(2, [3, 4]); // 결과: [1, 2, 3, 4, 5]
리스트 병합 및 변환 과정의 복잡도 분석
리스트를 병합하거나 변환할 때의 복잡도를 수학적으로 분석할 수 있다. 리스트에 요소를 하나씩 추가하는 것은 시간 복잡도가 O(1)이지만, 리스트를 전체적으로 병합하는 경우에는 복잡도가 O(n + m)이 될 수 있다. 여기서 n은 첫 번째 리스트의 길이, m은 두 번째 리스트의 길이이다.
시간 복잡도 분석
리스트 병합에서 시간 복잡도는 다음과 같다:
리스트 슬라이싱과 필터링에서의 복잡도는 리스트의 크기 n에 비례하여 다음과 같이 표현된다:
이 복잡도는 리스트의 크기와 변환 과정에서 얼마나 많은 계산이 필요한지를 나타낸다.
리스트의 재정렬 및 복잡도 분석
리스트를 정렬하는 작업은 Dart의 sort()
메소드를 사용하며, 기본적으로 퀵소트(QuickSort) 알고리즘이 적용된다. 이는 최선의 경우 O(n \log n)의 시간 복잡도를 갖지만, 최악의 경우 O(n^2)이 될 수 있다. 하지만 Dart는 최악의 경우에도 O(n \log n) 성능을 보장하는 알고리즘을 사용한다.
시간 복잡도
리스트를 오름차순 또는 내림차순으로 정렬하는 경우 시간 복잡도는 다음과 같다:
이 복잡도는 리스트에 포함된 요소 수에 따라 결정되며, 매우 큰 리스트를 처리할 때 성능 최적화의 중요한 부분이 된다.
Set 조작
Set은 중복을 허용하지 않는 컬렉션이며, Dart에서 리스트와 유사한 방식으로 사용할 수 있지만, 순서가 보장되지 않는다. Set의 주요 특징은 중복 요소를 허용하지 않는다는 점이다.
요소 추가 및 제거
Set에 요소를 추가하는 것은 리스트와 동일하게 add()
메소드를 사용한다. 하지만, 중복된 값은 허용되지 않는다.
Set<int> numbers = {1, 2, 3};
numbers.add(3); // 결과: {1, 2, 3}
remove()
메소드는 리스트와 동일하게 특정 요소를 제거한다.
numbers.remove(2); // 결과: {1, 3}
Set 연산
Set을 활용하면 교집합, 합집합, 차집합 등의 수학적 집합 연산을 간편하게 수행할 수 있다. 이러한 연산은 수학적 표현으로도 설명할 수 있다.
합집합
두 Set의 합집합은 Dart에서 union()
메소드를 사용하여 구현할 수 있다. 이 연산은 두 Set에 포함된 모든 요소를 반환하며, 중복된 요소는 제거된다.
Set<int> set1 = {1, 2, 3};
Set<int> set2 = {3, 4, 5};
Set<int> unionSet = set1.union(set2); // 결과: {1, 2, 3, 4, 5}
교집합
교집합은 두 Set에서 공통된 요소만 반환하는 연산으로, intersection()
메소드를 사용한다.
Set<int> intersectionSet = set1.intersection(set2); // 결과: {3}
차집합
차집합은 첫 번째 Set에서 두 번째 Set에 포함되지 않은 요소만 반환하는 연산이다. Dart에서는 difference()
메소드를 사용한다.
Set<int> differenceSet = set1.difference(set2); // 결과: {1, 2}
시간 복잡도 분석
Set의 주요 연산들의 시간 복잡도는 다음과 같다:
- 합집합: O(n + m)
- 교집합: O(\min(n, m))
- 차집합: O(n)
여기서 n과 m은 두 Set의 크기를 나타낸다.
Map 조작
Map은 키와 값의 쌍으로 이루어진 컬렉션이다. Dart에서는 Map을 사용하여 키를 기반으로 값을 빠르게 조회하고, 추가 및 삭제 작업을 수행할 수 있다.
요소 추가 및 업데이트
Map에 새로운 요소를 추가하거나 기존 요소를 업데이트할 때는 []
연산자를 사용한다.
Map<String, int> scores = {'John': 90, 'Jane': 85};
scores['Alice'] = 95; // 새로운 요소 추가
scores['John'] = 92; // 기존 요소 업데이트
요소 제거
Map에서 요소를 제거할 때는 remove()
메소드를 사용한다. 키를 기반으로 값을 삭제할 수 있다.
scores.remove('Jane'); // 결과: {'John': 92, 'Alice': 95}
요소 탐색
Map에서 특정 키에 대한 값을 조회할 때는 []
연산자를 사용할 수 있다. 존재하지 않는 키를 조회하면 null
을 반환한다.
int? johnScore = scores['John']; // 결과: 92
키와 값 목록
Map의 키와 값만을 따로 추출하여 리스트 형태로 가져오고자 할 때는 keys
와 values
속성을 사용할 수 있다.
Iterable<String> names = scores.keys; // 결과: ('John', 'Alice')
Iterable<int> scoresList = scores.values; // 결과: (92, 95)
Map의 고급 조작
Map은 키-값 쌍을 기반으로 데이터를 저장하고 조작하는 컬렉션으로, 다양한 방법으로 탐색, 수정, 업데이트가 가능하다. 아래에서는 Dart의 Map에서 제공하는 고급 기능들을 살펴본다.
키 존재 여부 확인
Map에서 특정 키가 존재하는지 확인하려면 containsKey()
메소드를 사용할 수 있다. 이 메소드는 해당 키가 존재하면 true
를 반환하고, 그렇지 않으면 false
를 반환한다.
Map<String, int> scores = {'John': 92, 'Alice': 95};
bool hasJohn = scores.containsKey('John'); // 결과: true
bool hasBob = scores.containsKey('Bob'); // 결과: false
값 존재 여부 확인
특정 값이 Map에 존재하는지 확인할 때는 containsValue()
메소드를 사용한다.
bool hasScore95 = scores.containsValue(95); // 결과: true
Map 병합
Dart에서 두 개 이상의 Map을 병합할 수 있다. 이 작업은 addAll()
메소드를 사용하여 수행할 수 있으며, 만약 동일한 키가 존재할 경우 마지막으로 추가된 Map의 값이 우선된다.
Map<String, int> map1 = {'John': 90};
Map<String, int> map2 = {'Alice': 95, 'John': 92};
map1.addAll(map2); // 결과: {'John': 92, 'Alice': 95}
Map의 기본값 설정
Dart에서는 존재하지 않는 키를 조회할 때 기본값을 설정할 수 있다. 이 작업은 putIfAbsent()
메소드를 통해 가능하다. 키가 존재하지 않으면 기본값이 추가되며, 이미 존재하는 경우는 무시된다.
scores.putIfAbsent('Bob', () => 85); // 결과: {'John': 92, 'Alice': 95, 'Bob': 85}
Map의 순회
Map의 모든 키와 값을 순회할 때는 forEach()
메소드를 사용한다. 이 메소드는 각 키-값 쌍에 대해 제공된 함수를 호출한다.
scores.forEach((key, value) {
print('$key:$value');
});
// 출력 결과:
// John: 92
// Alice: 95
// Bob: 85
Map 변환 및 필터링
Map 변환
Map의 모든 값을 특정 방식으로 변환하려면 map()
메소드를 사용할 수 있다. 이 메소드는 새로운 Map을 반환하며, 기존의 키-값 쌍에 변환 로직을 적용한다.
Map<String, int> updatedScores = scores.map((key, value) => MapEntry(key, value + 5));
// 결과: {'John': 97, 'Alice': 100, 'Bob': 90}
Map 필터링
Map에서 조건에 맞는 요소만 추출하려면 where()
메소드를 사용할 수 있다. 이 메소드는 주어진 조건을 만족하는 키-값 쌍을 필터링하여 새로운 Map을 생성한다.
Map<String, int> highScores = scores.where((key, value) => value > 90);
// 결과: {'John': 92, 'Alice': 95}
Map에서의 복잡도 분석
Map에서의 주요 연산들의 시간 복잡도는 HashMap 구조를 기준으로 다음과 같이 분석된다.
- 요소 추가: O(1)
- 요소 제거: O(1)
- 키 또는 값 존재 여부 확인: O(1)
- Map 순회: O(n)
여기서 n은 Map에 포함된 키-값 쌍의 개수이다. 이러한 복잡도는 일반적인 HashMap 구조의 시간 복잡도와 유사하다.
Map의 메모리 효율성
Dart의 Map은 메모리 효율성 면에서도 적절한 구조를 제공한다. 키-값 쌍을 효율적으로 저장하기 위해 해시 테이블이 사용되며, 충돌 처리를 위한 메커니즘도 내장되어 있다.
Map의 키와 값 변환
Map의 키 또는 값을 변환하는 작업은 map()
메소드와 달리, 특정적으로 키만 또는 값만 변경하는 방법으로도 가능하다. Dart에서는 각각의 키와 값을 변환한 후 새로운 Map을 반환하는 방식으로 이러한 작업을 처리할 수 있다.
값 변환
Map의 값을 특정 방식으로 변환하려면 mapValues()
패턴을 적용할 수 있다. Dart는 직접적인 mapValues()
메소드를 제공하지 않지만, map()
메소드를 활용하여 동일한 기능을 수행할 수 있다.
Map<String, int> updatedScores = scores.map((key, value) => MapEntry(key, value * 2));
// 결과: {'John': 184, 'Alice': 190, 'Bob': 170}
키 변환
키를 변환하는 작업은 조금 더 복잡할 수 있는데, 주로 키에 대한 변경이 이루어질 때 새로운 Map을 반환하여야 한다. Dart는 map()
메소드를 사용하여 키를 변환할 수 있으며, 기존의 키-값 쌍에 변환 로직을 적용할 수 있다.
Map<String, int> updatedKeys = scores.map((key, value) => MapEntry('$key_score', value));
// 결과: {'John_score': 92, 'Alice_score': 95, 'Bob_score': 85}
Map의 깊은 복사와 얕은 복사
Dart에서 Map을 복사할 때는 얕은 복사(shallow copy)와 깊은 복사(deep copy)를 구분해야 한다. 얕은 복사는 단순히 참조를 복사하는 반면, 깊은 복사는 각 요소의 값을 모두 새롭게 복사하여 완전히 독립적인 Map을 생성한다.
얕은 복사
얕은 복사는 기본적으로 addAll()
메소드를 사용하여 이루어진다. 이 경우 복사된 Map은 원본 Map의 참조를 그대로 사용하게 된다.
Map<String, int> shallowCopy = {};
shallowCopy.addAll(scores);
// shallowCopy는 scores와 동일한 참조를 가짐
깊은 복사
깊은 복사는 모든 키-값 쌍을 새롭게 할당하여 새로운 Map을 생성하는 방법이다. 이를 위해서는 모든 값을 명시적으로 복사해야 한다.
Map<String, int> deepCopy = scores.map((key, value) => MapEntry(key, value));
// deepCopy는 scores와 독립적인 복사본
Map의 기본 값 설정
Dart에서는 Map에서 특정 키에 값이 없을 때 기본값을 설정하는 방법으로 putIfAbsent()
메소드를 사용할 수 있다. 이 메소드는 해당 키가 이미 존재하면 아무 작업도 하지 않고, 키가 존재하지 않으면 기본값을 삽입한다.
Map<String, int> scores = {'John': 92, 'Alice': 95};
scores.putIfAbsent('Bob', () => 85); // Bob이 없을 경우 85 추가
이 방법은 데이터가 부족할 때 기본값을 설정하는 유용한 방식이다.
Map의 키 리스트와 값 리스트
Map의 키와 값을 각각 리스트 형태로 추출하고 싶을 때는 keys
와 values
속성을 사용할 수 있다. 이 속성들은 Iterable
타입을 반환하며, toList()
메소드를 사용하여 리스트로 변환할 수 있다.
List<String> keyList = scores.keys.toList(); // 결과: ['John', 'Alice', 'Bob']
List<int> valueList = scores.values.toList(); // 결과: [92, 95, 85]
Map의 구조 시각화
Map의 기본 구조와 데이터를 시각적으로 나타낼 수 있다. Dart에서의 Map은 키-값 쌍으로 구성되며, 이를 시각적으로 표현하면 다음과 같다:
이 다이어그램은 Map의 기본 구조를 시각적으로 보여주며, 각 키-값 쌍이 어떻게 연결되어 있는지를 나타낸다.
Map의 병합과 필터링의 복잡도 분석
Map에서 병합 및 필터링 작업은 다음과 같은 시간 복잡도를 갖는다:
- 병합: 두 Map을 병합하는 경우 시간 복잡도는 O(n + m), 여기서 n과 m은 각각 병합할 Map의 크기이다.
- 필터링: Map에서 조건을 만족하는 요소를 필터링할 때의 시간 복잡도는 O(n), 여기서 n은 Map의 크기이다.
이러한 복잡도는 Map의 크기에 따라 성능이 좌우되며, 매우 큰 Map을 처리할 때 성능 최적화가 중요한 요소가 된다.
Map의 고급 기능과 활용 사례
Dart의 Map은 다양한 데이터 구조와 복잡한 데이터 저장소를 처리하는 데 매우 유용하다. 특히 JSON과 같은 데이터를 다룰 때 Dart의 Map은 매우 직관적이고 강력한 도구로 활용된다.
JSON과 Map
JSON 데이터를 Dart에서 다룰 때 Map을 사용하여 쉽게 파싱하고 조작할 수 있다. Dart는 dart:convert
라이브러리를 통해 JSON 데이터를 Map으로 변환할 수 있다.
import 'dart:convert';
String jsonData = '{"John": 92, "Alice": 95, "Bob": 85}';
Map<String, int> scores = jsonDecode(jsonData);
print(scores); // 결과: {John: 92, Alice: 95, Bob: 85}