멈추지 않고 끈질기게
[C#] 리스트(List) 본문
※ 해당 포스팅은 개인의 공부 정리용 글입니다. 틀린 내용이 있다면 추후 수정될 수 있습니다.
※ 해당 포스팅은 .Net 5.0 버전을 기준으로 작성되었습니다.
이번 포스팅에서는 C#에서 제공하는 List 라는 자료구조에 대해 알아보겠습니다.
1. List의 선언 및 초기화
리스트는 System.Collection.Generic 네임스페이스에 정의되어 있으며, 해당 네임스페이스를 참조하여 사용 가능합니다.
리스트는 다음과 같이 선언 및 초기화할 수 있습니다.
//선언 후 초기화
List<int> list1;
list1 = new List<int>();
//선언과 동시에 초기화
List<string> list2 = new List<string>();
//선언과 동시에 초기화2
List<long> list3 = new List<long> { 1, 2, 3 };
//선언된 배열을 이용한 초기화
float[] arr = { 1.0f, 2.0f, 3.0f };
List<float> list4 = new List<float>(arr);
//이중 리스트
List<List<int>> list5 = new List<List<int>>();
선언 및 초기화 방식은 배열과 흡사하며, 배열처럼 초기 원소를 지정해 줄 수도 있습니다. 또한 이미 선언된 배열을 이용하여 초기화할 수도 있습니다(물론 해당 배열의 타입과 리스트의 제네릭이 동일해야 합니다). 리스트의 제네릭으로 리스트를 선언하여 이차원, 삼차원 배열처럼 이중, 삼중 리스트로 사용할 수도 있습니다.
차이점이라면, 리스트는 초기화할 때 배열처럼 크기를 미리 지정해 줄 필요가 없습니다. 따라서 저장해야 할 데이터 수를 정확히 알 수 없을 때에도 문제없이 사용할 수 있습니다.
2. List의 속성
리스트의 속성 값은 다음과 같습니다.
List<int> list = new List<int> { 1, 2, 3 };
//리스트의 원소 개수
Console.WriteLine($"리스트의 원소 갯수: {list.Count}");
//리스트의 현재 용량
Console.WriteLine($"리스트의 capacity: {list.Capacity}");
Count 는 리스트의 현재 원소 개수를 나타내는 값입니다. 배열의 Length 값과 비슷하지만, 리스트의 특성상 초기화 한 이후에도 원소의 추가, 제거에 따라 계속 변할 수 있습니다.
Capacity는 리스트의 현재 용량을 나타내는 값입니다. 해당 속성값의 영문 설명을 읽어보면 'total number of elements the internal data structure can hold without resizing', 즉 '리사이징 없이 가질 수 있는 최대 원소 갯수'라고 설명하고 있습니다. 배열처럼 초기화 시에 크기를 미리 지정해 줄 필요는 없지만, 실제로는 내부적으로 크기를 가지고 있으며 꽉 찬 상태에서 원소가 추가될 경우 리사이징을 통해 이를 해결하는 것입니다.
상기 코드에서는 리스트를 3개의 원소로 초기화하였고, Capacity 값이 4로 출력되고 있습니다. 참고로 리스트의 원소 갯수를 5개로 만들면 Capacity 값은 8로, 원소 갯수를 9개로 만들면 Capacity 값은 16으로 상승합니다. 원소 갯수가 Capacity를 넘어갈 때마다 리사이징이 일어나며, 기존 Capacity의 두배로 리사이징 한다는 것을 알 수 있습니다.
3. List 관련 함수
리스트 클래스에는 다양한 함수가 존재하며, 이 중 자주 사용되는 함수를 소개하겠습니다.
List<int> list = new List<int> { 1, 2 };
PrintElements(list);
//원소 추가
list.Add(4);
PrintElements(list);
list.Insert(2, 3);
PrintElements(list);
//원소 추가2
int[] arr = { 5, 6 };
list.AddRange(arr);
PrintElements(list);
list.InsertRange(0, list);
PrintElements(list);
//리스트 뒤집기
list.Reverse();
PrintElements(list, "list의 원소(리버스): ");
//리스트 정렬
list.Sort();
PrintElements(list, "list의 원소(오름차순): ");
list.Sort((a,b) => { return a < b ? 1 : -1; });
PrintElements(list, "list의 원소(내림차순): ");
//리스트 초기화
list.Clear();
PrintElements(list);
Console.WriteLine();
//원소 제거(값 참조)
list.AddRange(new int[] { 5, 6, 7, 7, 7, 8 });
PrintElements(list);
list.Remove(7);
PrintElements(list);
//원소 제거(인덱스 참조)
list.RemoveAt(1);
PrintElements(list);
//원소 제거(대리자 참조)
list.RemoveAll((a) => { return a == 7; });
PrintElements(list);
//해당 값 보유 여부
Console.WriteLine($"리스트에 8이 있는가? : {list.Contains(8)}");
Console.WriteLine($"리스트에 9가 있는가? : {list.Contains(9)}");
Console.WriteLine();
//원소의 인덱스 찾기
List<int> list2 = new List<int> { 5, 6, 5, 6, 5 };
PrintElements(list2);
Console.WriteLine($"5의 인덱스(앞에서부터): {list2.IndexOf(5)}" );
Console.WriteLine($"5의 인덱스(뒤에서부터): {list2.LastIndexOf(5)}");
Console.WriteLine($"7의 인덱스: {list2.IndexOf(7)}");
Console.WriteLine();
//조건에 맞는 원소 찾기
List<int> list3 = new List<int> { 1, 2, 3, 4, 5, 6 };
PrintElements(list3);
int f1 = list3.Find((a) => { return a % 3 == 0; });
int f2 = list3.FindLast((a) => { return a % 3 == 0; });
Console.WriteLine($"3으로 나눠지는 원소(앞에서부터): {f1}");
Console.WriteLine($"3으로 나눠지는 원소(뒤에서부터): {f2}");
//조건에 맞는 모든 원소 찾기
List<int> list4 = list3.FindAll((a) => { return a % 3 == 0; });
PrintElements(list4, "3으로 나누어 떨어지는 모든 원소: ");
//List -> Array 변환
int[] arr2 = list2.ToArray();
(PrintElemetns()는 원소 출력을 위해 개인적으로 작성한 함수입니다.)
Add() 함수로 리스트에 값을 추가할 수 있습니다. 리스트의 타입에 맞는 값만 추가할 수 있으며, 추가된 값은 맨 뒤쪽으로
추가됩니다. Insert() 함수를 통해 지정한 인덱스에 값을 추가할 수도 있습니다. 단, 인덱스 값이 리스트의 마지막 인덱스보다 클 경우 ArgumentOutOfRangeException이 발생하므로 주의가 필요합니다.
AddRange() 함수로 여러개의 값을 동시에 추가할 수도 있습니다. 배열이나 리스트를 매개변수로 넘기면 해당 매개변수의 모든 원소가 리스트 뒤쪽에 추가됩니다. InsertRange() 함수를 통해 지정한 인덱스에서부터 추가시킬 수도 있습니다.
참고로 AddRange(), InsertRange() 함수는 모두 IEnumerable<T> 인터페이스를 매개변수로 받는데, 사실 C#에서 배열과 리스트는 IEnumerable 인터페이스를 상속받는 자료구조이기 때문에 매개변수로 사용할 수 있는 것입니다. 해당 인터페이스에 대해서는 추후 다른 포스팅에서 자세히 다루도록 하겠습니다.
Reverse() 함수로 원소의 순서를 거꾸로 뒤집을 수 있습니다.
Sort() 함수를 통해 리스트 내부의 원소들을 정렬할 수 있습니다. 매개변수를 전달하지 않을 경우 해당 타입에 정의된 CompareTo() 함수를 기준으로 정렬하며(int의 경우 오름차순), 매개변수로 Comparison<T> 대리자를 전달하여 원하는 기준으로 정렬할 수도 있습니다. 상기 코드에서 처음에는 매개변수 없이 사용하여 오름차순으로, 그 다음에는 다음 원소가 더 클 경우 1을 반환하는 람다식을 전달하여 내림차순으로 정렬하였습니다. CompareTo() 함수와 Comparison<T> 대리자에 대해서는 추후 다른 포스팅에서 자세히 다루도록 하겠습니다.
Clear() 함수를 사용하여 리스트의 모든 원소를 제거할 수 있습니다. 단, Clear() 사용 후 확인해보면 Count 값은 반드시 0이 되지만, Capacity 값은 그대로인 점을 알 수 있습니다. Clear() 함수를 사용해도 해당 리스트가 메모리에서 차지하는 크기까지 초기화되는 것이 아니라는 점은 주의해야 합니다.
Remove() 함수로 특정 원소 값을 지울 수 있습니다. 해당 값이 리스트 내에 중복으로 존재할 경우, 가장 낮은 인덱스의 원소를 제거합니다. 해당 값이 리스트에 존재하지 않을 경우에는 아무 일도 일어나지 않습니다. RemoveAt() 함수를 통해 특정 인덱스에 존재하는 원소를 지울 수도 있습니다. 리스트의 인덱스 범위를 벗어나는 값을 전달할 경우, ArgumentOutOfRangeException이 발생합니다.
RemoveAll() 함수로 조건에 맞는 원소를 모두 지울 수도 있습니다. 매개변수로 전달한 대리자의 조건에 맞는 모든 원소를 제거합니다. 대리자에 대해서는 추후 다른 포스팅에서 자세히 다루도록 하겠습니다.
Contains() 함수로 매개변수 값이 현재 리스트에 존재하는지 여부를 확인할 수 있습니다. 존재한다면 true, 없다면 false 값을 반환합니다.
IndexOf() 함수로 해당 원소의 인덱스 값을 알아낼 수 있습니다. 해당 값이 리스트 내에 중복하여 존재할 경우, 중복된 원소 중 가장 낮은 인덱스 값을 반환합니다. LastIndexOf() 함수는 반대로 가장 높은 인덱스 값을 반환합니다. 해당 값이 리스트 내에 존재하지 않을 경우, 두 함수 모두 -1을 반환합니다.
Find() 함수로 리스트에서 원하는 조건에 부합하는 원소를 검색할 수도 있습니다. 매개변수로 전달한 대리자의 조건에 맞는 원소 중 가장 인덱스 값이 낮은 원소를 반환합니다. FindLast() 함수는 조건에 맞는 원소 중 가장 인덱스 값이 높은 원소를 반환합니다. 해당 조건에 맞는 원소가 없을 경우, 두 함수 모두 해당 타입의 기본값을 반환합니다. 상기 코드에서는 대리자로 3으로 나눈 나머지가 0일 때 true를 반환하는 람다식을 전달하여 3으로 나누어 떨어지는 원소를 검색하였습니다.
FindAll() 함수는 조건에 부합하는 모든 원소를 같은 타입의 리스트 형태로 반환합니다. 조건에 맞는 원소가 없을 경우, 빈 리스트를 반환합니다.
ToArray() 함수로 간단하게 같은 타입의 배열로 변환할 수도 있습니다.
4. List 구현 실습
다음은 List를 직접 구현해본 실습 코드입니다.
// MyList 구현부
public class MyList<T> : IEnumerable<T>
{
T[] _data;
int _count;
public int Count { get { return _count; } }
int _capacity;
public int Capacity { get { return _capacity; } }
// 기본 생성자
public MyList()
{
_data = new T[1];
_count = 0;
_capacity = 1;
}
// 초기 capacity 지정 생성자
public MyList(int capacity)
{
_data = new T[capacity];
_count = 0;
_capacity = capacity;
}
// 인덱서 설정
public T this[int idx]
{
get { return _data[idx]; }
set { _data[idx] = value; }
}
// 원소 추가 함수
// capacity가 부족하다면 Expand() 호출
public void Add(T data)
{
// capacity 부족
if (_count == _capacity)
Expand();
// 데이터 추가
_data[_count] = data;
_count++;
}
// capacity 두배로 증가
void Expand()
{
T[] temp = new T[_capacity * 2];
// 기존 데이터 복사
for(int i = 0; i < _count; i++)
temp[i] = _data[i];
_data = temp;
_capacity *= 2;
}
// 인덱스 참조 원소 제거 함수
public void RemoveAt(int idx)
{
for(int i = idx; i < _count - 1; i++)
_data[i] = _data[i + 1];
_count--;
}
// 데이터 초기화 함수
// capacity는 변화하지 않음
public void Clear()
{
_data = new T[_capacity];
_count = 0;
}
// IEnumerable<T> 구현부
public IEnumerator<T> GetEnumerator()
{
for(int i = 0; i < _count; i++)
{
yield return _data[i];
}
}
IEnumerator IEnumerable.GetEnumerator()
{
for (int i = 0; i < _count; i++)
{
yield return _data[i];
}
}
}
// MyList 테스트 실행부
MyList<int> list = new MyList<int>();
for(int i = 0; i < 10; i++)
list.Add(i);
// foreach 출력 확인
Console.WriteLine("list의 원소들");
foreach(int num in list)
Console.Write($"{num} ");
Console.WriteLine('\n');
// 인덱스[] 대응 확인
Console.WriteLine($"리스트의 4번째 원소: {list[3]}");
list[6] = 99;
Console.WriteLine($"리스트의 7번째 원소: {list[6]}");
// Count, Capacity 속성 확인
Console.WriteLine($"리스트의 Count: {list.Count}");
Console.WriteLine($"리스트의 Capacity: {list.Capacity}");
Console.WriteLine();
// RemoveAt() 동작 확인
list.RemoveAt(3);
list.RemoveAt(7);
Console.WriteLine("list의 원소들");
foreach (int num in list)
Console.Write($"{num} ");
Console.WriteLine('\n');
Console.WriteLine($"리스트의 Count: {list.Count}");
Console.WriteLine($"리스트의 Capacity: {list.Capacity}");
List의 Count와 Capacity 속성과 인덱스([ ]), 기본 생성자 및 초기 Capacity 지정 생성자, Add(), RemoveAt(), Clear() 함수를 구현하였고, foreach에 대응하기 위해 IEnumerable 인터페이스를 구현했습니다. 데이터 타입에 상관없는 자료구조를 구현하기 위해 제네릭 타입(T)으로 구현하였으며, Capacity가 꽉 찼을 경우 2배로 늘려주는 내부 함수 Expand()를 선언하였습니다.
실행 예시를 보면 0부터 9까지의 원소 10개를 추가하였고, Capacity 값이 16까지 증가했습니다. 기본 생성자에서 초기 Capacity 값을 1로 초기화하도록 했으므로, 원소를 추가하는 과정에서 Expand() 함수가 총 4번 호출되어 16까지 증가한 것을 확인할 수 있습니다. Expand() 함수를 살펴보면 기존의 _data 배열의 원소를 복사하는 내용을 포함하며, _data가 새롭게 생성한 배열을 가리키게 하여 기존의 배열을 가비지로 만들고 있습니다. 따라서 Expand()의 잦은 호출은 성능(속도)면에서나 메모리 관리 차원에서나 부정적이므로, 가급적이면 초기 Capacity를 지정하는 생성자 쪽을 이용하는 것이 좋습니다.
IEnumerator<T> 인터페이스를 상속받아 GetEnumerator(), IEnumerable.GetEnumerator() 함수를 구현했습니다. 해당 인터페이스를 상속해야 foreach 구문을 통해 모든 원소를 출력할 수 있으며, 구현 내용은 단순히 반복문을 돌며 _data의 각 원소를 반환하도록 했습니다. 단, yiled return으로 원소를 하나 반환한 후에 메인 스레드에 제어권을 넘겨주도록 구현해야 합니다.
5. List의 장단점
리스트의 장점은 동적으로 크기를 조절 가능하다는 점입니다. 처음 초기화할 때 배열처럼 크기를 정해줄 필요도 없고, 한번 초기화한 이후에도 원소를 자유롭게 추가할 수 있으며 필요 없어진 데이터는 아예 리스트에서 제거해버릴 수도 있습니다. 이차원 배열의 경우 초기화 한 시점에서 원소 배열의 크기까지 정해지지만, 이중 리스트는 각 원소 리스트가 서로 다른 크기를 가질 수 있습니다.
또한 인덱스를 통한 랜덤엑세스 등 기존 배열의 장점들도 가지고 있습니다.
다만 리스트에 많은 기능들이 존재하는 대신, 이를 무분별하게 사용할 경우 시간효율성을 크게 저하시킬 수 있습니다. 크기가 가변적이라고는 하나 실제로는 저장할 수 있는 Capacity가 존재하고, 원소 갯수가 Capacity를 넘어서는 순간 리사이징을 통해 크기를 늘리는 것이기 때문에 리사이징이 일어날 때마다 시간을 소모하게 됩니다. 또한 Remove(), Clear() 함수 등으로 원소를 제거해도 다시 리사이징 하진 않기 때문에, 많은 데이터를 저장했던 리스트를 초기화한 후 적은 데이터를 저장하는 용도로 재사용하는 것은 부적절합니다.
또한 리스트는 배열과 마찬가지로 인덱스를 통한 접근은 O(1)의 시간복잡도로 아주 빠르게 가능하지만, 특정 원소를 검색할때는 최대 O(n)의 시간복잡도를 소모합니다. IndexOf(), Find() 등 다양한 함수가 내부적으로 이러한 서칭을 시행하기 때문에, 크기가 큰 리스트에서 이러한 함수를 무분별하게 사용하면 심각한 시간 효율성 저하를 가져올 수 있습니다. 해당 자료구조에 대해 자주 검색을 해야 한다면, 추후 포스팅에서 다룰 딕셔너리(Dictionary)를 이용하는 것이 좋습니다.
추가로, 원소의 제거도 리스트의 크기가 클 수록 문제가 될 수 있습니다. 리스트는 인덱스를 포함하는 자료구조이기 때문에, 원소의 제거는 곧 재정렬을 필요로 합니다. 만약 RemoveAt(0)로 리스트의 맨 앞의 원소를 제거할 경우, n-1개의 원소들의 순서가 갱신되어야 합니다. 데이터를 저장한 후 자주 원소의 추가,제거를 반복해야 한다면 우선 Stack이나 Queue와 같은 자료구조로 구현 가능한지를 먼저 검토해보아야 합니다.
'C#' 카테고리의 다른 글
[C#] 대리자(Delegate) (0) | 2023.02.09 |
---|---|
[C#] IComparable 인터페이스, Comparison<T> 대리자 (0) | 2023.01.31 |
[C#] 딕셔너리(Dictionary) (0) | 2023.01.27 |
[C#] 큐(Queue)와 스택(Stack) (0) | 2023.01.26 |
[C#] 배열(Array) (0) | 2023.01.20 |