순차 자료구조는 삽입 연산이나 삭제 연산 후 연속적인 물리 주소를 유지하기 위해 원소들을 이동시키는 추가 작업 및 시간이 소요되는 문제점이 있다. 또한, 배열을 이용하는 순차 자료구조는 배열이 가진 메모리 사용의 비효율성 문제까지 동반하기 때문에, 이를 개선한 자료 표현 방법인 연결 자료구조가 필요하다.
연결 자료구조는 자료의 논리적 순서와 물리적 순서가 불일치하고, 각 원소에 저장되어 있는 다음 원소의 주소에 의해 순서가 연결되어 있는 방식을 이룬다. 즉, 물리적인 순서를 맞추기 위한 오버헤드가 발생하지 않는다. 덧붙여 크기 변경이 유연하며, 메모리 사용에 효율적이다. 리스트의 연결 자료구조 표현을 '연결 리스트'라고 부를 수 있다. 연결 리스트엔 단순 연결 리스트, 원형 연결 리스트, 이중 연결 리스트, 이중 원형 연결 리스트의 방식이 존재하며, 먼저 단순 연결 리스트에 대해 알아보도록 하겠다.
연결 리스트의 원소 표현 단위 구조는 '노드'로 정의된다. 노드 데이터와 다음 노드의 포인터가 저장되어 있으며, 이러한 노드가 사슬처럼 이어진 구조가 연결 리스트이다. 다음과 같은 구조라 이해하면 쉽다.
[데이터 | 다음 노드 포인터] → [데이터 | 다음 노드 포인터] → [데이터 | NULL]
이때 노드의 데이터가 위치한 곳을 데이터 필드, 다음 노드의 주소가 저장된 곳을 링크 필드라 부른다. NULL은 아무것도 가리키지 않는 값이다.
연결 리스트는 순차 리스트에 비해 삽입과 삭제가 자유롭다. 그렇다면 노드를 어떻게 삽입할 수 있을까? 주소, 다시 말해 포인터를 고쳐서 노드를 끼워 넣으면 된다. 단순히 새로운 노드를 만들고, 앞 노드의 포인터를 만든 새 노드를 가리키게 바꾼 다음 새 노드가 뒷 노드를 가리키게 만들면 되는 구조다. 포인터 순서를 바꿔주는 것이 전부인 것이다. 이를 코드로 보면 다음과 같다. [A] → [B] → [C] 라는 리스트에서 B와 C 사이에 새로운 노드를 끼운다 가정하자.
Node *newNode = (Node*)malloc(sizeof(Node)); // 새 노드 생성
newNode->data = 'X'; // 데이터 할당
newNode->next = B->next; // 새 노드가 B의 다음을 가리킴
B->next = newNode; // B가 새 노드를 가리킴
next는 다음 노드의 위치를 저장하는 포인터로서 기능한다. 여기서 C나 A는 전혀 건들 필요 없고, B와 새로이 만든 노드만 조작해도 삽입이 가능하다. 위의 코드를 뜯어보면, 새 노드가 B->next, 즉 C를 기억하게 만들고 B가 새 노드를 기억하게 만든 것이 다다.
A->next = B->next; // A가 B를 건너뛰고 C를 가리킴
free(B); // B를 메모리에서 제거
위는 같은 [A] → [B] → [C] 구조에서 B를 삭제하기 위한 코드로 구현한 것이다. 연결 리스트에서 노드의 삭제를 위해선 삭제할 노드의 앞 노드를 찾고, 그 앞 노드에 삭제할 노드의 링크 필드값을 저장한다. 그 후 앞 뒤를 연결한다. B를 메모리에서 해제하는 것이다. 지금까진 중간 지점에서의 삽입과 삭제를 알아봤다면, 맨 앞이나 맨 뒤의 특수 경우에서의 삽입과 삭제에 대해서도 알아보자.
리스트에는 head라는 리스트의 시작을 가리키는 포인터가 존재한다. 연결 리스트는 배열처럼 인덱스로의 접근이 불가하므로 head를 통해 리스트의 시작 지점을 건드릴 수 있다. head는 리스트의 출력 대상이 아니며, 입구의 참조점이다. 가시적인 데이터가 아닌 리스트를 건들기 위한 시작 지점이라 이해할 수 있겠다. 따라서 [A] → [B] → [C]의 맨 앞에 X를 삽입한다고 하면,
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->data = 100;
newNode->next = head; // head를 가리킴
head = newNode; // head가 newNode를 가리키도록 변경
처럼 된다. 중간 삽입과 형식은 일치하되 head인 것만 다르다. 만약 A를 삭제하는 경우엔, A는 맨 앞에 위치한 노드로 임시로 A를 저장하게 한 뒤 삭제해야 한다. 따라서 이런 구조가 된다.
Node *temp = head;
head = head->next; // head를 다음 노드로 갱신
free(temp); // 원래의 첫 노드 제거
이는 첫 노드를 제거하고 시작점을 옮기기 위함이다. 맨 뒤에서의 삽입과 삭제는 head와 비슷하게 NULL이라는 값을 통해 진행할 수 있다. 삽입에선 새 노드가 NULL을 가리키도록 하고, 기존의 마지막 노드가 새로운 노드를 가리킨다. 삭제에선 마지막에서 두 번째 노드가 연결을 끊는 방식으로 삭제를 진행한다. 코드로 보면 이러하다.
// 마지막 노드 삽입
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->data = value;
newNode->next = NULL;
// 마지막 노드 삭제
temp = head;
while (temp->next->next != NULL)
temp = temp->next;
free(temp->next);
temp->next = NULL;
마지막 노드의 삭제가 이렇게 번거로운 이유는 연결 리스트가 배열이 아니기 때문에 인덱스로 접근할 수 없어 직접 몇 번째 노드인지 지정하지 못하기 때문이다. 따라서 무조건 처음(head)부터 시작해서 반복문을 통해 temp 포인터로 노드를 찾아주어야만 한다. 여기서 'temp'란 연결 리스트 안을 이동하며 작업하는 '작업 포인터'라고 이해하면 된다. 위 코드를 분석해본다면, if문들은 예외처리가 되고, temp->next->next != NULL 조건이 참인 동안 반복하여 마지막 앞 노드에 도달하게 만드는 방식이다. 이후로는 temp를 통하여 마지막 노드를 삭제할 수 있다. 노드의 next를 NULL로 바꾸어 끝을 재설정하는 것까지가 삭제 코드가 된다.
실제 연결 리스트에선 노드 구조체를 만들어야 한다. 이는 연결 리스트에서 가장 핵심이 되는 부분이다!
typedef struct Node {
int data;
struct Node* next;
} Node;
Node는 구조체 이름이고, struct는 구조체를 선언하겠다는 의미이며 typedef는 구조체를 짧게 줄여 부를 수 있도록, 즉 별명을 붙여줄 수 있도록 하는 키워드이다. 이로인해 우리는 struct Node가 아닌 Node라 간결히 정의한 구조체를 부를 수 있다. 다음의 int data;는 이 구조체 안에 정수형 데이터를 저장하겠다는 뜻으로, 한 노드가 담고 있는 실제 값을 의미한다. 데이터필드에 정수형 데이터를 받을 준비를 하고 있다는 선언인 것이다. struct Node* next;는 다음 노드를 카리키는 포인터인 next를 이용하여 이 노드가 어떤 대상과 이어질지를 나타내는 의미이다. 마지막 줄의 } Node;는 구조체를 Node라 칭하겠다는 뜻이다.
이렇게 노드 구조체를 정의하는 이유라 함은, 순차 리스트의 배열과는 달리 연결 리스트의 노드는 사용자가 직접 만들어야하는 개념이기에 그러하다. C언어는 연결 리스트를 제공하지 않는다. 그래서 우리는 연결 리스트를 사용하기 위해 맨 처음 노드라는 추상적 개념을 구조체로 정의하여 C언어에서의 프로그래밍을 진행하게 된다.
'Study > C' 카테고리의 다른 글
C 언어 자료구조 스터디 [7] <재귀함수의 예시 및 알고리즘 가상코드> (0) | 2025.04.24 |
---|---|
C 언어 자료구조 스터디 [6] <구조체와 재귀호출> (0) | 2025.04.08 |
C 언어 자료구조 스터디 [5] <순차 선형 리스트의 삽입과 삭제 및 희소행렬> (0) | 2025.04.07 |
C 언어 자료구조 스터디 [4] <동적 할당과 포인터> (0) | 2025.04.03 |
C 언어 자료구조 스터디 [3] <다차원 배열> (0) | 2025.04.01 |