샷건치며 배우는 자료구조 with C++, 8화
노드를 기반으로 하는 자료구조, 링크드 리스트에 대해 알아봅시다.
읽어주세요
자료구조를 배우기 위해 위대한 항로(?)를 넘어 여기까지 오신 분들 환영합니다. 본 게시글은 자료구조를 공부하면서 복습 겸 정리하기 위해 작성하였습니다. 개인적으로 강좌 형식 및 장난을 섞어가며 작성하는 걸 좋아하기 때문에 진지한 게시글을 원하신다면 뒤로 가기를 눌러주세요.
C++ 언어를 기반으로 하고 있습니다. 다른 프로그래밍 언어를 사용 중이신 경우 개념(이론)을 배우는 데 큰 문제는 없지만, 실제 코드로 구현할 땐 생각보다 차이가 있을 수 있습니다.
업데이트
- C 언어 구현 내용 제거
- 게시글의 내용이 너무 복잡해져 제거하였습니다.
게시글 개선을 위해 임시 숨김 처리
링크드 리스트
링크드 리스트Linked List는 데이터를 저장하는 선형 자료구조로, 배열과는 다른 방식으로 요소를 관리합니다. 배열은 연속적인 메모리 공간에 데이터를 저장하는 반면, 링크드 리스트는 노드라는 개별 단위로 데이터를 저장하고, 각 노드가 다음 노드와 연결된(Linked) 형태를 띄고 있습니다.
링크드 리스트는 크게 세 가지로 구분됩니다. 각 노드가 다음 노드만을 가리키는 단일 링크드 리스트, 각 노드가 이전 노드와 다음 노드를 가리키는 이중 링크드 리스트, 마지막 노드가 첫 번째 노드와 연결된 구조를 갖는 원형 링크드 리스트입니다.
노드
노드Node는 링크드 리스트에서 사용되는 기본 구성 단위입니다.
각 노드는 데이터를 저장하고, 다른 노드와의 연결 정보를 갖습니다. C/C++ 언어에서 노드는 보통 구조체 또는 클래스로 구현합니다.
- 데이터: 노드가 실제로 저장하는 값입니다. 정수, 실수, 문자, 구조체 등 다양한 데이터 타입을 사용할 수 있습니다.
- 포인터: 이전 노드 또는 다음 노드 등을 가리키는 포인터 변수입니다. 이를 통해 노드가 서로 연결됩니다.
각 노드는 다음 또는 이전 노드를 가리키는 포인터를 갖기 때문에 서로 연결된 형태를 보입니다. 마치 기차 칸들이 연결되어 있는 것처럼 말이죠. 각 칸(노드)은 다음 칸(노드)으로 가는 길(포인터)을 알고 있을 수 있습니다.
위 사진은 단일 링크드 리스트의 노드 모습입니다. 1번 다음 2번, 2번 다음 3번 순으로 노드가 연결되어 있는 걸 확인할 수 있습니다.
특징
-
유연
크기가 고정된 배열과는 달리, 동적 메모리 할당을 이용하기 때문에 실행 중에 원하는 만큼 데이터를 추가 및 제거할 수 있습니다.
-
비연속적
기본적으로 동적 메모리 할당을 이용하기 때문에 메모리 공간이 연속적으로 할당되지 않습니다.
-
빠른 추가 및 제거 속도
중간 삽입을 제외하고, 이중 링크드 리스트 기준으로 데이터를 삽입 및 제거하는 속도가 \(O(1)\)으로 빠릅니다.
-
느린 탐색 속도
인덱스가 없기 때문에 원하는 데이터를 찾으려면 순차적으로 접근해야 합니다. \(O(n)\)의 시간복잡도가 발생합니다.
단일 링크드 리스트
단일 링크드 리스트Singly Linked List는 링크드 리스트의 가장 기본적인 형태로, 각 노드가 하나의 데이터와 다음 노드를 가리키는 포인터를 갖는 선형 자료구조입니다. 노드는 단방향(하나의 방향)으로 연결되어 있어, 한 노드에서 다음 노드로 갈 수 있지만 그 반대는 불가합니다.
각 노드는 다음 노드만을 가리키기 때문에, 리스트를 순회할 때 항상 헤드Head(1)에서 시작해야 합니다.
- 단일 링크드 리스트의 첫 번째 노드를 가리키는 포인터
기본 구성
단일 링크드 리스트의 기본 구성입니다.
노드는 데이터를 갖는 변수와 다음 노드를 가리키는 포인터 변수를 갖습니다.
단일 링크드 리스트는 첫 번째 노드를 가리키는 포인터 변수 헤드(head
)와 요소의 수를 기록하는 size
변수를 갖습니다. head
의 값이 NULL
이거나 size
변수의 값이 0
이라면 리스트가 비어있음을 나타냅니다.
기본 연산
노드 삽입
append
연산은 리스트의 맨 끝에 새 노드를 추가합니다.
리스트의 끝까지 가기 위해 모든 노드를 순회하기 때문에 시간 복잡도는 \(O(n)\)입니다.
-
새 노드 생성
새 노드를 생성합니다.
nullptr
인 것을 사전에 검사하여 오류가 발생할 수 있는 상황을 차단합니다. -
리스트가 비어 있다면
m_Head
가nullptr
라면 리스트가 비어 있다는 의미입니다. 비어 있는 상태에서 새 노드를 추가하는 건 첫 노드인 것과 마찬가지입니다.m_Head
에newNode
를 할당한 후 크기를1
증가시킵니다. -
리스트가 비어 있지 않다면
m_Head
에서 시작하여 다음 노드가nullptr
가 아닐 때까지 반복하여 순회합니다.nullptr
를 만나면 다음 노드가 없다는 것을 의미하며, 마지막 노드에 도달했음을 나타냅니다.마지막 노드의 다음에(
current->next
)newNode
를 할당하여 새 노드를 추가(연결)합니다.Tip
m_Head == nullptr
연산 대신m_Size == 0
연산으로 대체해도 됩니다. 두 조건식 모두 리스트가 비어 있음을 나타냅니다. -
크기 증가
새 노드가 추가되었으니 크기를
1
증가시킵니다.
prepend
연산은 리스트의 처음에 새 노드를 추가합니다.
리스트의 길이, 순회와는 상관없기 때문에 시간 복잡도는 \(O(1)\)입니다.
-
새 노드 생성
새 노드를 생성합니다.
nullptr
인 것을 사전에 검사하여 오류가 발생할 수 있는 상황을 차단합니다. -
새 노드의 다음 노드를 기존의 헤드를 가리키도록
기존의 헤드는 새 노드의 다음이 되기 때문에, 새 노드의 다음(
newNode->next
)을 기존의 헤드를 가리키도록 합니다. -
기존의 헤드를 새 노드로
새 노드가 헤드가 되기 때문에, 현재 헤드를 새 노드로 변경합니다.
-
크기 증가
새 노드가 추가되었으니 크기를
1
증가시킵니다.
forward_list_prepend.cpp | |
---|---|
push_front
메서드를 사용해 리스트의 맨 앞에 새 노드를 추가할 수 있습니다.
insertAt
연산은 리스트의 특정 위치에 새 노드를 추가하며, 사용자로부터 인덱스를 넘겨받습니다.
단일 링크드 리스트는 단방향이기 때문에 삽입하려는 위치의 직전(이전) 노드를 찾아야 합니다. 즉 탐색 과정을 거치기 때문에 인덱스가 0
이 아니라면 시간 복잡도는 \(O(n)\)입니다.
-
인덱스 범위 검사
인덱스가 음수이거나 리스트의 수를 초과하면 삽입 연산을 수행하지 않도록 합니다.
index >= size
로 수행하지 않는 이유는 직전 노드(index -1
)를 찾아야하기 때문에 그렇습니다. -
인덱스가 0이라면
인덱스가
0
이라면 리스트의 처음에 새 노드를 추가하겠다는 뜻입니다.prepend
연산을 수행하여 노드를 추가합니다. -
삽입 위치의 이전 노드 찾기
이전 노드에서 새 노드를 가리키고, 새 노드에서 이전 노드의 다음을 가리키기 위해 삽입 위치의 이전 노드를 찾습니다.
-
새 노드 생성
새 노드를 생성합니다.
nullptr
인 것을 사전에 검사하여 오류가 발생할 수 있는 상황을 차단합니다. -
새 노드의 다음을 삽입 위치의 이전 노드의 다음을 가리키기
이전 노드와 이전 노드 다음 그 사이에 새 노드가 삽입되기 때문에 새 노드의 다음을 이전 노드의 다음을 가리키도록 합니다.
-
삽입 위치의 이전 노드의 다음을 새 노드를 가리키기
이전 노드와 이전 노드 다음 그 사이에 새 노드가 삽입되기 때문에 이전 노드의 다음을 새 노드를 가리키도록 합니다.
-
크기 증가
새 노드가 추가되었으니 크기를
1
증가시킵니다.
// ...
int main(int argc, char* argv[]) {
// 단일 링크드 리시트
std::forward_list<int> sll;
// 앞에 데이터 추가
sll.push_front(40);
sll.push_front(20);
sll.push_front(10);
sll.push_front(0);
// 중간 데이터 추가
auto it = std::next(sll.begin(), 2);
sll.insert_after(it, 30);
// 출력
for (auto n : sll) {
std::cout << n << " ";
}
std::cout << "\n";
return 0;
}
std::next(sll.begin(), index)
로 index
번째에 있는 이터레이터를 반환 받습니다.
insert_after(it, value)
형식으로 사용해 index
번째에 있는 노드 다음에 새 노드를 추가합니다. 'after'라는 단어를 보시면 알 수 있듯이 해당 위치 뒤에 삽입함을 의미합니다.
노드 삭제
deleteBegin
연산은 리스트의 첫 노드를 제거합니다.
리스트의 길이, 순회와는 상관없기 때문에 시간 복잡도는 \(O(1)\)입니다.
-
리스트가 비어 있다면
리스트가 비어 있다면 제거할 노드가 없음을 나타냅니다. 아무 처리도 하지 않고
return
하도록 합니다. -
제거할 헤드 노드 임시 보관
제거 대상인 헤드 노드를
temp
에 임시로 보관합니다. 제거하기 전 헤드 노드를 변경해야 하기 때문입니다. -
헤드 노드를 헤드 노드의 다음을 가리킨다
m_Head
에m_Head->next
를 할당합니다. 즉 제거 대상인 헤드 노드의 다음 노드를 헤드로 만듭니다. -
헤드 노드 제거
노드는 동적 할당되었으니
delete
로 동적 할당을 해제합니다. -
크기 감소
노드가 제거되었으니 크기를
1
감소시킵니다.
pop_front()
메서드를 호출하여 맨 앞의 노드를 제거할 수 있습니다.
deleteEnd
연산은 리스트의 마지막 노드를 제거합니다.
리스트의 끝까지 가기 위해 모든 노드를 순회하기 때문에 시간 복잡도는 \(O(n)\)입니다.
-
리스트가 비어 있는 지 확인
리스트가 비어 있는 지 확인합니다. 비어 있는 상태라면 제거할 노드가 없으니 생략하도록 합니다.
-
리스트에 노드가 하나 뿐인 지 확인
리스트에 노드가 하나 뿐인 지 확인합니다.
리스트에 노드가 하나 뿐이라면 해당 노드가 헤드 노드이자 마지막 노드입니다. 굳이 이전 노드를 찾는 순회 과정을 거칠 필요가 없기 때문에 바로 제거하도록 합니다.
-
마지막 노드의 이전 노드까지 이동
현재 노드의 다다음 노드를 확인하여
nullptr
이 아니라면 다음 노드로 이동하도록 합니다. 이 과정을 반복하여 마지막 노드의 이전 노드까지 이동할 수 있습니다. -
제거
삭제할 마지막 노드를(
current->next
)를temp
에 임시로 보관합니다.이전 노드(
current->next
)의 다음 노드는 제거될 예정이기 때문에 다음을 가리킬 노드가 없습니다.nullptr
을 할당합니다.delete
를 호출하여 마지막 노드를 제거합니다. 그리고m_Size
변수의 값을1
감소시킵니다.
append
연산과 마찬가지로 맨 끝까지 순회하는 건 비효율적이기 때문에 별도로 맨 끝 노드를 제거하는 메서드가 존재하지 않습니다. 직접 구현해야 합니다.
deleteAt
연산은 리스트의 특정 위치에 있는 노드를 제거하며, 사용자로부터 인덱스를 넘겨 받습니다.
단일 링크드 리스트는 단방향이기 때문에 제거 대상의 노드 위치를 순회하여 찾아야합니다. 시간 복잡도는 \(O(n)\)입니다.
-
인덱스 범위 검사
인덱스가 음수이거나 리스트의 수를 초과하면 제거 연산을 수행하지 않도록 합니다.
insertAt
과는 다르게 새 노드 추가가 아닌 기존의 노드를 제거하는 것이기 때문에 리스트의 크기와 같거나 큰 인덱스는 존재하지 않습니다. 그래서index >= m_Size
를 조건식으로 합니다. -
인덱스가 0이라면
인덱스가
0
이라면 첫 노드 제거를 의미합니다.DeleteBegin
을 호출하여 리스트의 첫 노드를 제거하도록 합니다. -
제거 대상 노드의 이전 노드를 찾는다
제거 대상의 이전 노드를 찾습니다.
-
제거
제거 대상의 노드를
temp
에 임시로 보관합니다. 그리고 이전 노드의 다음 노드를 제거 대상 노드의 다음을 가리키도록 합니다.delete
를 호출하여 노드를 제거한 후, 크기를1
감소시킵니다.
erase_after()
메서드를 사용해 지우고 싶은 노드의 앞 노드를 제거할 수 있습니다.
만약 이 메서드로 가장 첫 노드를 제거하고 싶다면 그건 불가능합니다. pop_front()
메서드를 사용해야 합니다.
clear
연산은 모든 노드를 순회하며 제거합니다. 모든 노드를 순회하기 때문에 시간 복잡도는 \(O(n)\)입니다.
forward_list_clear.cpp | |
---|---|
전체 코드
sll.cpp | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 |
|
10 20 30
SinglyLinkedList Size: 0
이중 링크드 리스트
이중 링크드 리스트Doubly Linked List는 각 노드가 데이터와 함께 이전 노드와 다음 노드를 갖는 자료구조입니다.
단일 링크드 리스트는 단방향 탐색만 가능했다면, 이중 링크드 리스트는 헤드와 테일이라는 두 개의 특별한 포인터를 갖아 양방향 탐색이 가능합니다. 헤드Head는 리스트의 가장 첫 번째 노드를 가리키고, 테일Tail은 리스트의 가장 마지막 노드를 가리킵니다.
현실 세계의 지하철 열차를 생각하시면 됩니다. 지하철 열차의 칸을 떠올려보세요. 헤드와 테일을 제외한 각 칸은 앞 칸과 뒷 칸이랑 연결되어 있습니다.
기본 구성
이중 링크드 리스트의 기본 구성입니다.
노드는 데이터를 갖는 변수와 이전 노드를 가리키는 포인터, 다음 노드를 가리키는 포인터 변수를 갖습니다.
아중 링크드 리스트는 리스트의 첫 노드를 가리키는 head
와 마지막 노드를 가리키는 tail
그리고 현재 리스트의 요소 수를 기록하는 size
변수를 갖습니다.
단일 링크드 리스트의 기본 구성에서 tail
과 prev
가 추가된 것 뿐입니다.
기본 연산
노드 삽입
append
연산은 리스트의 끝에 새 노드를 추가합니다.
이중 링크드 리스트는 단일 링크드 리스트와 다르게 테일 포인터를 사용하기 때문에 순회 연산이 사용되지 않습니다. 그래서 시간 복잡도는 \(O(1)\)입니다.
초반 head
와 tail
이 NULL
일 때 append
연산 수행 시 head
와 tail
이 유일하게 같은 노드를 가리키는 순간입니다.
-
새 노드 생성
새 노드를 생성합니다.
-
리스트가 비어 있는 경우
리스트가 비어 있는 경우 새 노드가 첫 노드이자 마지막 노드입니다. 그렇기 때문에
head
와tail
에 새 노드를 할당합니다. -
리스트에 노드가 있는 경우
리스트에 노드가 있는 경우, 새 노드의 이전 노드를 기존의 테일 노드를 가리키도록 합니다. 기존의 테일 노드는 더 이상 테일 노드가 안 되기 때문이죠.
기존의 테일 노드의 다음 노드는 새 노드를 가리키고, 테일 노드를 새 노드를 가리키도록 합니다. 그리고 크기를
1
증가시킵니다.
dll_append2.cpp | |
---|---|
C++은 std::list
라는 이중 링크드 리스트 컨테이너를 제공하고 있습니다.
push_back()
메서드를 통해 리스트 끝에 새 노드를 추가할 수 있습니다.
prepend
연산은 리스트의 처음에 새 노드를 추가합니다.
리스트의 길이, 순회 연산과는 상관없기 때문에 시간 복잡도는 \(O(1)\)입니다.
-
새 노드 생성
새 노드를 생성합니다.
-
리스트가 비어 있는 경우
리스트가 비어 있는 경우 새 노드가 첫 노드이자 마지막 노드입니다. 그렇기 때문에
head
와tail
에 새 노드를 할당합니다. -
리스트에 노드가 있는 경우
리스트에 노드가 있는 경우, 새 노드의 다음 노드를 기존의 헤드 노드를 가리키도록 합니다. 기존의 헤드 노드는 더 이상 헤드 노드가 안 되기 때문이죠.
기존의 헤드 노드의 이전 노드는 새 노드를 가리키고, 헤드 노드를 새 노드를 가리키도록 합니다. 그리고 크기를
1
증가시킵니다.
list_prepend.cpp | |
---|---|
push_front()
메서드를 통해 리스트의 처음에 새 노드를 추가할 수 있습니다.
insertAt
연산은 리스트의 특정 위치에 새 노드를 추가하며, 사용자로부터 인덱스를 넘겨 받습니다.
이중 링크드 리스트는 양방향 탐색이 가능합니다. 단일 링크드 리스트 방식으로 수행할 경우 조금 비효율적입니다. 그래서 헤드 또는 테일에서 시작하여 끝까지 순회하는 것보다, index
의 값을 절반으로 나누어 순회할 방향을 정하는 것이 그나마 성능 향상에 도움이 됩니다. 시간 복잡도는 \(O(n)\)입니다.
-
인덱스 범위 검사
인덱스가 음수이거나 리스트의 수를 초과하면 삽입 연산을 수행하지 않도록 합니다.
index >= size
로 수행하지 않는 이유는 직전 노드(index -1
)를 찾아야하기 때문에 그렇습니다. -
인덱스가 0이라면
인덱스가
0
이라면 리스트의 처음에 새 노드를 추가하겠다는 뜻입니다.prepend
연산을 수행하여 노드를 추가합니다. -
인덱스가 마지막이라면
인덱스가 리스트의 크기와 같다면 마지막에 새 노드를 추가하겠다는 뜻입니다.
append
연산을 수행하여 노드를 추가합니다. -
새 노드 생성
새 노드를 생성합니다.
-
반으로 나누어 진행 방향 결정, 삽입될 위치의 노드 취득
인덱스가 리스트 크기의 절반보다 크면 테일에서, 작으면 헤드에서 시작하도록 합니다. 헤드나 테일에서 시작하여 순회하는 것보다 조금이나마 나은 성능을 보입니다.
6번부터는 중간 삽입 처리를 수행합니다. 왜냐하면 위에서 리스트의 처음과 끝 처리를 하기 때문에, 6번까지 왔다는 건 중간에 새 노드가 삽입된다는 걸 의미합니다.
-
새 노드의 next를 현재 노드로 설정
새 노드의
next
를 현재 노드로 설정합니다.새 노드가 삽입되기 전, 현재 노드와 현재 노드의
prev
그 사이에 추가되는 것이기 때문에 새 노드의next
를 현재 노드를 가리키도록 합니다. -
새 노드의 prev를 현재 노드의 prev로 설정
새 노드의
prev
를 현재 노드의prev
로 설정합니다.새 노드가 삽입되기 전, 현재 노드와 현재 노드의
prev
그 사이에 추가되는 것이기 때문에 새 노드의prev
를 현재 노드의prev
를 가리키도록 합니다. -
현재 노드의 prev의 next를 새 노드로 설정
현재 노드의
prev
의next
를 새 노드로 설정합니다.새 노드가 삽입되기 전, 현재 노드와 현재 노드의
prev
그 사이에 추가되는 것이기 때문에 이전 노드의next
를 새 노드를 가리키도록 합니다. -
현재 노드의 prev를 새 노드로 설정
현재 노드의
prev
를 새 노드로 설정합니다.새 노드가 삽입되기 전, 현재 노드와 현재 노드의
prev
그 사이에 추가되는 것이기 때문에 현재 노드의prev
를 새 노드를 가리키도록 합니다. -
크기 증가
새 노드가 추가되었으니 크기를
1
증가시킵니다.
list_insert_at.cpp | |
---|---|
std::list
컨테이너에선 insert()
메서드를 통해 중간에 새 노드를 추가할 수 있습니다. 첫 번째 매개변수로 추가될 위치를 받고, 두 번째 매개변수로 추가될 데이터를 넘겨 받습니다.
노드 제거
deleteBegin
연산은 리스트의 첫 노드를 제거합니다.
리스트의 길이, 순회와는 상관없기 때문에 시간 복잡도는 \(O(1)\)입니다.
-
리스트가 비어 있다면 생략
리스트가 비어 있다면 제거할 노드가 존재하지 않습니다.
-
제거할 헤드 노드 임시 보관
제거 대상인 헤드 노드를 임시 보관합니다.
-
리스트의 노드가 하나뿐인 경우
리스트의 노드가 하나 뿐인 경우
m_Head
와m_Tail
을nullptr
로 변경합니다. -
그외 경우
리스트의 노드가 두 개 이상인 경우
m_Head
를m_Head
의next
노드를 가리키도록 합니다. 그리고prev
는nullptr
을 할당합니다.m_Head
의prev
는 없으니까요. -
삭제
delete
를 사용하여 노드를 제거합니다. -
크기 감소
노드를 제거하였으니 크기를
1
감소시킵니다.
list_delete_begin.cpp | |
---|---|
std::list
컨테이너는 pop_front()
메서드를 호출하여 리스트의 맨 앞 노드를 제거할 수 있습니다.
deleteEnd
연산은 리스트의 마지막 노드를 제거합니다.
이중 링크드 리스트는 테일 포인터를 갖기 때문에 순회와 상관없이 바로 접근이 가능합니다. 시간 복잡도는 \(O(1)\)입니다.
-
리스트가 비어 있다면 생략
리스트가 비어 있다면 제거할 노드가 존재하지 않습니다.
-
제거할 테일 노드 임시 보관
제거 대상인 테일 노드를 임시 보관합니다.
-
리스트의 노드가 하나뿐인 경우
리스트의 노드가 하나 뿐인 경우
m_Head
와m_Tail
을nullptr
로 변경합니다. -
그외 경우
리스트의 노드가 두 개 이상인 경우
m_Tail
을m_Tail
의prev
노드를 가리키도록 합니다. 그리고next
는nullptr
을 할당합니다.m_Tail
의prev
는 없으니까요. -
삭제
delete
를 사용하여 노드를 제거합니다. -
크기 감소
노드를 제거하였으니 크기를
1
감소시킵니다.
list_delete_end.cpp | |
---|---|
std::list
컨테이너는 pop_back()
메서드를 호출하여 리스트의 맨 뒤 노드를 제거할 수 있습니다.
deleteAt
연산은 리스트의 특정 위치에 있는 노드를 제거하며, 사용자로부터 인덱스를 넘겨 받습니다.
이중 링크드 리스트는 양방향 탐색이 가능합니다. 단일 링크드 리스트 방식으로 수행할 경우 조금 비효율적입니다. 그래서 헤드 또는 테일에서 시작하여 끝까지 순회하는 것보다, index
의 값을 절반으로 나누어 순회할 방향을 정하는 것이 그나마 성능 향상에 도움이 됩니다. 시간 복잡도는 \(O(n)\)입니다.
-
인덱스 범위 검사
인덱스가 음수이거나 리스트의 길이를 초과하는 경우 생략합니다.
-
인덱스가 0이라면
인덱스의 값이
0
이라면 첫 번째 노드를 제거함을 의미합니다.DeleteBegin()
함수를 호출합니다. -
인덱스가 마지막이라면
인덱스의 값이 리스트 크기의
1
을 뺀 값과 같다면 마지막 노드를 제거함을 의미합니다.DeleteEnd()
함수를 호출합니다. -
반으로 나누어 진행 방향 결정, 제거할 노드 취득
리스트의 크기를 반으로 나누어 인덱스 값보다 작다면 헤드에서, 크다면 테일에서 시작하도록 진행 방향을 설정합니다.
제거할 노드를 취득합니다.
-
연결 끊기
사이에 끼어있는 중간 노드를 제거하는 것이기 때문에 제거 대상의 노드를 가리키지 않도록 합니다.
-
해제
delete
를 사용하여 제거 대상의 노드를 제거합니다. -
크기 감소
노드가 제거되었으니 크기를
1
감소시킵니다.
std::list
컨테이너는 erase()
메서드를 사용하여 특정 위치에 있는 노드를 제거할 수 있습니다.
clear
연산은 모든 노드를 순회하여 제거합니다.
모든 노드를 순회하기 때문에 시간 복잡도는 \(O(n)\)입니다.
전체 코드
dll.cpp | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 |
|
10 20 30 40
원형 링크드 리스트
원형 링크드 리스트Circular Linked List는 일반적인 링크드 리스트의 변형으로, 마지막 노드가 첫 번째 노드와 연결되거나 첫 번째 노드가 마지막 노드와 연결되어 순환 구조를 갖는 자료구조입니다. 끝에서 시작으로, 시작에서 끝으로 가기 때문에 '끝'이라는 개념이 없다고 보시면 됩니다.
원형 링크드 리스트는 크게 단일과 이중으로 구분할 수 있습니다.
단일 원형 링크드 리스트는 마지막 노드의 next
가 head
를 가리키고, 이중 원형 링크드 리스트는 tail->next
가 head
를 가리키고 head->prev
가 tail
을 가리킵니다.
기본 구성
tail
노드를 사용하지 않습니다. 왜냐하면 head->prev
가 곧 tail
이고, tail->next
가 곧 head
이기 때문에 그렇습니다.
기본 연산
노드 삽입
append
연산은 리스트의 맨 끝에 새 노드를 추가합니다.
단일 링크드 리스트 기반은 리스트의 끝까지 가기 위해 순회 과정을 거치기 때문에 시간 복잡도는 \(O(n)\)입니다.
prepend
연산은 리스트의 처음에 새 노드를 추가합니다.
리스트의 길이, 순회와는 상관없기 때문에 시간 복잡도는 \(O(1)\)입니다.
insertAt
연산은 리스트의 특정 위치에 새 노드를 추가합니다. 사용자로부터 인덱스를 넘겨 받습니다.
단일 링크드 리스트를 기반으로 하기 때문에 삽입하려는 위치의 직전 노드를 찾아야 합니다. 순회 과정을 거치기 때문에 인덱스가 0
이 아니라면 시간 복잡도는 \(O(n)\)입니다.
append
연산은 리스트의 맨 끝에 새 노드를 추가합니다.
이중 링크드 리스트 기반은 리스트의 끝을 prev
로 바로 접근할 수 있습니다. 시간 복잡도는 \(O(1)\)입니다.
prepend
연산은 리스트의 처음에 새 노드를 추가합니다.
리스트의 길이, 순회와는 상관없기 때문에 시간 복잡도는 \(O(1)\)입니다.
```cpp title="dcll_prepend.cpp" linenums="1" class DoublyCircularLinkedList { // ...
//! @brief 리스트의 처음에 새 노드를 추가하는 메서드
//! @param value 값
void Prepend(int value) noexcept {
// (1) 새 노드 생성
auto* newNode = new Node(value);
if (!newNode) { return; }
// (2) 리스트가 비어 있다면
if (m_Head == nullptr) {
m_Head = newNode;
// 자기 자신을 가리킨다
newNode->prev = newNode;
newNode->next = newNode;
m_Size +=- 1;
return;
}
// (3) 리스트에 노드가 있다면
auto* last = m_Head->prev; // 마지막 노드
newNode->next = m_Head; // 새 노드의 다음 노드가 헤드 노드를 가리킨다
newNode->prev = last; // 새 노드의 이전 노드가 마지막 노드를 가리킨다
m_Head->prev = newNode; // 헤드 노드의 이전 노드가 새 노드를 가리킨다
last->next = newNode; // 마지막 노드의 다음 노드가 새 노드를 가리킨다
m_Head = newNode; // 헤드 갱신
// (4) 크기 증가
m_Size += 1;
}
}; ```
insertAt
연산은 리스트의 특정 위치에 새 노드를 추가합니다. 사용자로부터 인덱스를 넘겨 받습니다.
이중 링크드 리스트는 양방향 탐색이 가능합니다. 단일 링크드 리스트 방식으로 수행할 경우 조금 비효율적입니다. 그래서 헤드 또는 테일에서 시작하여 끝까지 순회하는 것보다, index
의 값을 절반으로 나누어 순회할 방향을 정하는 것이 그나마 성능 향상에 도움이 됩니다. 시간 복잡도는 \(O(n)\)입니다.
노드 제거
deleteBegin
연산은 리스트의 첫 노드를 제거합니다.
테일 노드를 찾는 순회 과정이 있기 때문에 시간 복잡도는 \(O(n)\)입니다.
deleteEnd
연산은 리스트의 마지막 노드를 제거합니다.
리스트의 마지막 노드를 찾기 위해 순회 연산을 거치기 때문에 시간 복잡도는 \(O(n)\)입니다.
deleteAt
연산은 리스트의 특정 위치에 있는 노드를 제거하며, 사용자로부터 인덱스를 넘겨 받습니다.
단일 원형 링크드 리스트는 단방향이기 때문에 제거 대상의 노드 위치를 순회하여 찾아야합니다. 시간 복잡도는 \(O(n)\)입니다.
clear
연산은 모든 노드를 제거합니다.
모든 노드를 순회하며 제거하기 때문에 시간 복잡도는 \(O(n)\)입니다.
deleteBegin
연산은 리스트의 첫 노드를 제거합니다.
리스트의 길이, 순회와는 상관없기 때문에 시간 복잡도는 \(O(1)\)입니다.
deleteEnd
연산은 리스트의 마지막 노드를 제거합니다.
이중 링크드 리스트는 양방향 접근이 가능하기 때문에 바로 마지막 노드의 접근이 가능합니다. 시간 복잡도는 \(O(1)\)입니다.
deleteAt
연산은 리스트의 특정 위치에 있는 노드를 제거하며, 사용자로부터 인덱스를 넘겨 받습니다.
이중 링크드 리스트는 양방향 탐색이 가능합니다. 단일 링크드 리스트 방식으로 수행할 경우 조금 비효율적입니다. 그래서 헤드 또는 테일에서 시작하여 끝까지 순회하는 것보다, index
의 값을 절반으로 나누어 순회할 방향을 정하는 것이 그나마 성능 향상에 도움이 됩니다. 시간 복잡도는 \(O(n)\) 입니다.
clear
연산은 모든 노드를 제거합니다.
모든 노드를 순회하며 제거하기 때문에 시간 복잡도는 \(O(n)\)입니다.
dcll_clear.cpp | |
---|---|
전체 코드
scll.cpp | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 |
|
dcll.cpp | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
|
정리
단일 링크드 리스트
단일 링크드 리스트는 노드가 한 방향으로만 연결되고, 첫 노드(head
)에서 순차적으로 접근이 가능합니다.
헤드 부분의 노드 삽입과 삭제는 매우 빠르나, 중간과 마지막 노드의 삽입과 삭제는 느립니다.
구조가 단순하여 구현이 쉽지만, 역방향 순회가 불가능합니다.
이중 링크드 리스트
이중 링크드 리스트는 노드가 양방향으로 연결되고, 헤드(head
)와 테일(tail
)이라는 두 개의 포인터 변수를 가집니다.
양쪽에서 노드의 삽입과 삭제가 가능하기 때문에 속도가 빠릅니다.
단일 링크드 리스트에 비해 구현이 다소 복잡하지만, 역방향 순회가 가능하다는 장점이 있습니다.
원형 링크드 리스트
원형 링크드 리스트는 단일과 이중으로 구현이 가능하고, 마지막 노드의 포인터가 헤드 포인터와 연결됩니다. 그래서 끝이 없고 완전한 순환이 가능합니다.
순환 구조를 갖기 때문에 잘못된 구현 시 무한 루프에 빠질 수 있다는 단점이 있지만, 계속되는 순환이 필요할 때 굉장히 유용하게 사용됩니다. (예: 뮤직 플레이어 등)