Linked List

링크드 리스트는 연속된 메모리를 포기하는 대신 삽입/삭제에 따른 복사 비용을 줄이는 것에 초점을 둔 컨테이너다.

장단점이 Vector와 거의 정반대에 가깝다.

 

각 원소는 데이터와 다음 원소의 주소를 가지고 있으며, 이 한 쌍을 노드라고 부른다.

노드와 노드를 연결하여 사용하는 방식이다.

// node structure
template<typename T>
stuct Node
{
	data T;
	Node* next;
}

// example
Node a, b;
a.next = &b;

링크드 리스트는 연속된 메모리를 사용하지 않기 때문에 아래와 같은 특징이 있다.

  • 중간 삽입/삭제 로직만은 복사 비용이 없음
    특정 노드를 찾는데까지 O(n)이 걸리기 때문에 특정 노드를 찾으면 포인터를 이용해 상수 시간에 끝낼 수 있다.

  • 크기 변경이 굉장히 자유로움

그에 반해 단점은 아래와 같다.

  • 캐시 효율이 안 좋음
    노드의 메모리들이 여기저기에 흩어져 있어서 지역성이 굉장히 나쁘다.
    그래서 CPU의 캐시 라인을 거의 활용하지 못한다.

  • 인덱스 기반 접근 불가능
    순차 탐색으로 항상 O(n)의 복잡도를 가진다.

  • 추가 메모리(포인터) 존재
    정말 필요한 데이터 외에도 포인터를 필수적으로 가져야한다.

  • 외부 메모리 단편화 발생 가능
    노드들이 개별적으로 할당되므로, 실제 메모리는 충분함에도 연속적이지 못하여 할당을 받을 수 없는 경우가 생길 수도 있다.

시간 복잡도

단일 링크드 리스트를 기준으로 한다.

  • 탐색, 접근 : O(n)
    첫 노드부터 하나씩 따라가야하므로 for문과 다를게 없는 O(n)

  • 삽입 / 삭제(특정 노드를 알 고 있을 때) : O(1)
    포인터의 연결만 바꾸면 되는 상수 시간 복잡도를 가진다.
    특정 노드를 모를때는 어쩔 수 없이 순회가 필요한데, 이 경우에는 O(n)이 되기도 한다.

언제 사용해야 하는가

  • 삽입/삭제의 노드를 알고 있을 때, 삽입/삭제가 활발할 경우
  • 원소의 순서가 자주 바뀔때
  • 순차 접근 위주

언제 피해야 하는가

  • 인덱스 접근이 필요한 경우
  • 캐시 효율이 중요한 경우
  • 빈번히 순회하는 경우
  • 데이터 크기가 큰 경우
  • 성능이 중요한 경우
    그래서 게임에서는 잘 안쓰이는 것 같다..

정리

랜덤 접근이 불가능하고, 캐시 효율이 나쁘기 때문에 성능부분에서 기대하기는 힘들다.

그래서 사실 사용한 적이 한 번도 없다... 진짜 쓸 상황이 안 생겼다.....!

 

그러면 왜 아직도 이 리스트가 레거시되고 있지 않는 이유가 궁금했는데,

바로 다른 시스템과 결합된 형태로 많이 사용된다고 한다.

 

  • intrusive list
  • free list
  • LRU cache

각 부분은 나중에 알아보겠다.

 

 

구현

노드

template<typename T>
struct Node
{
    T data;
    Node* next;
};

데이터와 연결된 노드를 저장할 포인터 2가지로 이루어진 기본 노드 구성

 

멤버 변수

private:
    Node<T>* m_head;
    size_t m_size;

리스트의 시작 부분 포인터와 크기가 정의된다.

 

탐색

Node<T>* find(const T& value) const
{
	// Head부터 순차 탐색한다.
    Node<T>* cur = m_head;
    while (cur)
    {
        if (cur->data == value)
            return cur;
        cur = cur->next;
    }
    return nullptr;
}

 

삽입

먼저 가장 앞에 삽입하는 경우다.

void push_front(const T& value)
{
    // 삽입될 노드 생성
    Node<T>* node = new Node<T>(value);
    // 노드의 다음을 head로 연결
    node->next = m_head;
    // 시작점을 삽입 된 노드로 변경
    m_head = node;
    // 크기 갱신
    ++m_size;
}

 

그 후 중간에 삽입하는 경우다.

template<typename T>
bool push(size_t index, const T& value)
{
    // 찾으려는 인덱스가 존재하는 데이터 개수보다 많은 경우 종료
    if (index > m_size)
        return false;

    // 맨 앞에 넣을 경우 push_front호출
    if (index == 0)
    {
        push_front(value);
        return true;
    }

    // Head부터 index - 1 까지 순차 탐색
    Node<T>* cur = m_head;
    for (size_t i = 0; i < index - 1; ++i)
    {
        cur = cur->next;
    }

    // 삽입하려는 노드 생성	
    Node<T>* node = new Node<T>(value);
    // 삽입하려는 노드 앞 뒤로 연결
    node->next = cur->next;
    cur->next = node;
    
    ++m_size;
    return true;
}

중간에 순차 탐색할 때 index - 1을 하는 이유는 뒤에 있는 노드는 알 수 없기 때문이다.

마지막에 삽입하는 경우는 생략한다.

 

삭제

일반적인 삭제 로직에서는 삭제할 노드가 첫 번째 노드일 때와 중간 노드일 때의 처리가 다르다.

해당 방식을 동일하게 처리하고자, head 앞에 빈 공간인 dummy노드를 이용했다.

bool remove(const T& value)
{
    // stack으로 head의 앞에 빈 공간을 만든다.
    Node<T> dummy;
    dummy.next = m_head;
    Node<T>* cur = &dummy;
    
    // value값을 가진 노드의 이전 노드를 찾는 과정
    while (cur->next)
    {
        if (cur->next->data == value)
        {
            // 지울 노드를 저장 후 삭제 처리
            Node<T>* delTarget = cur->next;
            cur->next = delTarget->next;
            delete removeVal;
            
            // 혹시 Head를 지웠을 경우의 대비
            m_head = dummy->next;
            
            // 사이즈 갱신 후 종료
            --m_size;
            return true;
        }
        
        cur = cur->next;
    }

    return false;
}

 

제일 깔끔한 방법으로는 double pointer를 사용하는 방법도 있다. (근데 좀 헷갈림;)

bool remove(const T& value)
{
    // head를 가리키는 포인터의 주소
    Node<T>** pp = &head;   

    while (*pp)
    {
        if ((*pp)->data == value)
        {
        	// delTarget = 삭제해야하는 노드
            Node<T>* delTarget = *pp;
            
            // 포인터 자체를 교체!
            *pp = delTarget->next;  
            delete delTarget;
            --m_size;
            return true;
        }
        pp = &((*pp)->next);
    }
    return false;
}

간단하게 설명하면 pp는 처음에는 노드 그자체를 가리킨다.

하지만 pp = &((*pp)->next); 실행 후 부터는 노드의 next를 가리키는 포인터가 된다.

즉, 사실상 이전 노드라고 생각하는게 편할 수도 있다.

'코딩 테스트 > 자료구조' 카테고리의 다른 글

Queue  (0) 2026.01.13
Stack  (0) 2026.01.12
Vector  (1) 2026.01.07
String & Vector  (0) 2025.05.18
힙 [Heap]  (0) 2022.07.29

+ Recent posts