컬렉션 조작은 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은 두 번째 리스트의 길이이다.

시간 복잡도 분석

리스트 병합에서 시간 복잡도는 다음과 같다:

T_{\text{merge}}(n, m) = O(n + m)

리스트 슬라이싱과 필터링에서의 복잡도는 리스트의 크기 n에 비례하여 다음과 같이 표현된다:

T_{\text{sublist}}(n) = O(n)
T_{\text{filter}}(n) = O(n)

이 복잡도는 리스트의 크기와 변환 과정에서 얼마나 많은 계산이 필요한지를 나타낸다.

리스트의 재정렬 및 복잡도 분석

리스트를 정렬하는 작업은 Dart의 sort() 메소드를 사용하며, 기본적으로 퀵소트(QuickSort) 알고리즘이 적용된다. 이는 최선의 경우 O(n \log n)의 시간 복잡도를 갖지만, 최악의 경우 O(n^2)이 될 수 있다. 하지만 Dart는 최악의 경우에도 O(n \log n) 성능을 보장하는 알고리즘을 사용한다.

시간 복잡도

리스트를 오름차순 또는 내림차순으로 정렬하는 경우 시간 복잡도는 다음과 같다:

T_{\text{sort}}(n) = 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에 포함된 모든 요소를 반환하며, 중복된 요소는 제거된다.

A \cup B = \{x \mid x \in A \text{ 또는 } x \in B\}
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() 메소드를 사용한다.

A \cap B = \{x \mid x \in A \text{ 그리고 } x \in B\}
Set<int> intersectionSet = set1.intersection(set2);  // 결과: {3}

차집합

차집합은 첫 번째 Set에서 두 번째 Set에 포함되지 않은 요소만 반환하는 연산이다. Dart에서는 difference() 메소드를 사용한다.

A - B = \{x \mid x \in A \text{ 이지만 } x \notin B\}
Set<int> differenceSet = set1.difference(set2);  // 결과: {1, 2}

시간 복잡도 분석

Set의 주요 연산들의 시간 복잡도는 다음과 같다:

여기서 nm은 두 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의 키와 값만을 따로 추출하여 리스트 형태로 가져오고자 할 때는 keysvalues 속성을 사용할 수 있다.

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 구조를 기준으로 다음과 같이 분석된다.

여기서 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의 키와 값을 각각 리스트 형태로 추출하고 싶을 때는 keysvalues 속성을 사용할 수 있다. 이 속성들은 Iterable 타입을 반환하며, toList() 메소드를 사용하여 리스트로 변환할 수 있다.

List<String> keyList = scores.keys.toList();   // 결과: ['John', 'Alice', 'Bob']
List<int> valueList = scores.values.toList();  // 결과: [92, 95, 85]

Map의 구조 시각화

Map의 기본 구조와 데이터를 시각적으로 나타낼 수 있다. Dart에서의 Map은 키-값 쌍으로 구성되며, 이를 시각적으로 표현하면 다음과 같다:

graph TD; A["Map"] --> B["John: 92"]; A --> C["Alice: 95"]; A --> D["Bob: 85"];

이 다이어그램은 Map의 기본 구조를 시각적으로 보여주며, 각 키-값 쌍이 어떻게 연결되어 있는지를 나타낸다.

Map의 병합과 필터링의 복잡도 분석

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}