1. 링크드 리스트

1-1 싱글 링크드 리스트의 특징

내용
  • 메모리의 동적할당을 기반으로 구현된 시퀀스형 자료구조입니다. (메모리를 차지하는 크기의 한계가 없다)


  • (C언어에서는 Head에 값을 넣지 않지만 파이썬에서는 그냥 값 갖는다)
  • 개별적으로 흩어져 있는 원소의 주소를 연결(Link)하여 하나의 자료구조를 이루고 있다.
  • 임의 접근이 아닌 순차적으로 탐색하며 접근해야 한다. => O(n)

  • 이제 노드와 노드간의 연결로 만들어진 링크드 리스트의 객체를 만들 수 있게 해주는 클래스를 작성해 보겠습니다.

    # 노드 클래스
    class Node:
      def __init__(self, data):
        self.data = data
        self.next = None
    
    # 링크드 리스트 클래스
    class Linked_List:
      def __init__(self):
        self.head = None
    

1-2 싱글 링크드 리스트 삽입 연산

내용
  • 3가지 메소드가 있다
    • 맨 앞에 추가 : prepend
    • 맨 뒤에 추가 : append
    • 특정 노드 뒤에 추가 : insert_after
  • 2가지 경우를 고려한다
    • 리스트가 비어있는 경우
    • 리스트에 데이터가 있는 경우
    상황 해결 방법
    리스트가 비어있는 상황 append 또는 prepend에 케이스 추가
    맨 앞에 추가하는 상황 prepend 이용
    맨 뒤에 추가하는 상황 append 이용
    특정 노드 뒤에 추가하는 상황 insert_after 이용
코드
  # 노드 클래스
  class Node:
    def __init__(self, data):
      self.data = data
      self.next = None

  # 링크드 리스트 클래스
  class Linked_List:
    def __init__(self):
      self.head = None
      self.tail = None


    """삽입 관련 메소드"""
    # 맨 앞에 추가
    def prepend(self, data):
      new_node = Node(data)
      # 빈 리스트인 상황에 대한 케이스 추가
      if self.head is None:
        self.head = new_node
        self.tail = new_node
      # 비어있지 않은 상황
      else:
        temp = self.head
        self.head = new_node
        new_node.next = temp

    
    # 맨 뒤에 추가
    def append(self, data):
      new_node = Node(data)
      # 빈 리스트인 상황에 대한 케이스 추가
      if self.head is None:
        self.head = new_node
        self.tail = new_node
      # 비어있지 않은 상황
      else:
        self.tail.next = new_node
        self.tail = new_node
    
    # 특정 노드(prev) 뒤에 추가
    def insert_after(self, prev, data):
      new_node = Node(data)
      # 특정 노드(prev)가 tail 노드인 경우
      if self.tail is prev:
        prev.next = new_node
        self.tail = new_node
      else:
        new_node.next = prev.next
        prev.next = new_node

1-3 싱글 링크드 리스트 접근 연산

내용
  • 특정 인덱스에 있는 값을 얻고자 할 때 사용되는 연산이다.
코드
  # 노드 클래스
  class Node:
    def __init__(self, data):
      self.data = data
      self.next = None

  # 링크드 리스트 클래스
  class Linked_List:
    def __init__(self):
      self.head = None
      self.tail = None

    # 접근 메소드
    def get_node(self, index):
      # head에서 시작
      iter = self.head
      # 인덱스만큼 한 칸씩 이동
      for _ in range(index):
        iter = iter.next
      # 원하는 인덱스에 도착하면 노드 리턴
      return iter

1-4 싱글 링크드 리스트의 탐색 연산

내용
  • 특정 값이 있는지 확인하고 싶을 때 사용하는 연산으로 있으면 해당 노드를 리턴한다.
코드
  # 노드 클래스
  class Node:
    def __init__(self, data):
      self.data = data
      self.next = None

  # 링크드 리스트 클래스
  class Linked_List:
    def __init__(self):
      self.head = None
      self.tail = None

    # 탐색 메소드
    def get_node(self, data):
      # head에서 시작
      iter = self.head
      # 노드가 있기만 하면 일단 그 노드의 값 탐색
      while iter is not None:
        if iter.data == data:
          return iter

        iter = iter.next
      return None

1-5 싱글 링크드 리스트의 삭제 연산

내용
  • 3가지 메소드가 있다
    • 맨 앞 노드 삭제 : pop_left
    • 맨 뒤 노드 삭제 : pop
    • 특정 노드 뒤의 노드 삭제 : delete_after
    상황 해결 방법
    맨 앞 노드 삭제하는 상황 pop_left 이용
    맨 뒤 노드 삭제하는 상황 pop 이용
    특정 노드 뒤의 노드를 삭제하는 상황 delete_after 이용
코드
  # 노드 클래스
  class Node:
    def __init__(self, data):
      self.data = data
      self.next = None

  # 링크드 리스트 클래스
  class Linked_List:
    def __init__(self):
      self.head = None
      self.tail = None

    """삭제 메소드"""
    # 맨 앞 노드 삭제
    def pop_left(self):
      self.head = self.head.next
    
    # 맨 뒤 노드 삭제
    def pop(self):
      self.tail = None

    """문제는 새로운 tail을 어떻게 지정해 줄 것인가
    링크드 리스트는 next속성만 있어서 앞의 노드를 가리킬 방법이 없다
    이 문제를 해결할 방법은 2가지가 있다 (제가 생각나는 것 기준)
    1. 처음 노드부터 순차적으로 이동해 끝에 도달하면 그 노드를 tail로 지정한다
    2. 링크드 리스트 객체에 length속성을 만들어 링크드 리스트에 노드를 추가할 때 마다 lenght를 1씩 키운다.
     -> 링크드 리스트 길이에 관한 속성이 생긴다 -> 접근해서 tail로 지정"""

      # 여기서는 다른 코드를 건드리지 않아도 되는 1번 방법 했다
      iter = self.head
      while iter.next is not None:
        iter = iter.next
      self.tail = iter
    
    # 특정 노드 뒤의 노드 삭제
    def delete_after(self, prev):
      if prev.next = self.tail:
        prev.next = None
        self.tail = prev
      else:
        prev.next = prev.next.next

    """내가 원하는 노드를 바로 지정해서 삭제할 수 없는 이유는 그렇게 하면 삭제된 노드의 앞 노드와 뒷 노드를 연결할 수 없기 때문이다.
    그래서 prev(노드)를 지정하게 되면 뒤의 노드를 삭제해도 그 다음 노드와 연결 시켜줄 수 있다."""

    '''다음에 배우게 될 더블리 링크드 리스트는 앞의 노드를 나타내는 prev속성을 가지기 때문에 내가 원하는 노드를 지정해서 삭제할 수 있다.'''
      

2. 더블리 링크드 리스트

2-1 더블리 링크드 리스트의 특징

내용
  • 양쪽 방향으로 순회할 수 있도록 구현된 시퀀스형 자료구조입니다.
  • class Node:
      def __init__(self, data):
        self.data = data
        self.next = None
        self.prev = None
    
    
    class Doubly_Linked_List:
      def __init__(self):
        self.head = None
        self.tail = None
    
  • 장점

    연산 장점
    삽입 만약 리스트 길이를 알고 있고 인덱스를 통해 삽입을 한다면 큰 인덱스 값의 경우 끝에서 부터 역으로 순회할 수 있어 시간복잡도 낮출 수 있다
    접근 마찬가지로 리스트 길이를 알고 있고 인덱스를 통해 접근할 때 인덱스 값이 큰 경우 시간복잡도 낮출 수 있다
    탐색 정렬 되어있지 않으면 어차피 선형탐색 해야 하므로 이점이 없어보인다
    삭제 삽입의 경우과 마찬가지로 인덱스를 통해 삭제 한다면 큰 인덱스 값의 경우 끝에서 부터 순회할 수 있어 시간복잡도를 낮출 수 있다
  • 단점
    prev라는 새로운 속성을 추가해야 하므로 메모리가 더 사용될 것이다.

2-2 더블리 링크드 리스트 삽입 연산

코드
class Node:
  def __init__(self, data):
    self.data = data
    self.next = None
    self.prev = None


class Doubly_Linked_List:
  def __init__(self):
    self.head = None
    self.tail = None
  
  """삽입 관련 메소드"""

  # 맨 앞에 추가
  def prepend(self, data):
    if self.head is None:
      self.head = new_node
      self.tail = new_node
    else:
      self.head.prev = new_node
      new_node.next = self.head
      self.head = new_node
  
  # 특정 노드 뒤에 추가
  def insert_after(self, prev_node, data):
    new_node = Node(data)
    if prev_node is self.tail:
      prev_node.next = new_node
      new_node.prev = prev_node
      new_node.next = None
      self.tail = new_node
    else:
      new_node.next = prev_node.next
      prev_node.next.prev = new_node
      new_node.prev = prev_node
      prev_node.next = new_node
  

  
  # 맨 뒤에 추가
  def append(self, data):
    new_node = Node(data)
    if self.head is None:
      self.head = new_node
      self.tail = new_node
    else:
      self.tail.next = new_node
      new_node.prev = self.tail
      new_node.next = None
      self.tail = new_node

  """인덱스를 통해 삽입하려는 경우 인자로 받은 인덱스를 메소드 get_node(index)를 사용해 인덱스에 접근해서 new_node를 사이에 넣어준다.
  더블리 링크드 리스트가 아니라 그냥 링크드 리스트면 시간복잡도가 O(n) 이겠지만, 더블리 링크드 리스트는 인덱스가 리스트의 중간을 넘어가면
  끝에서 부터 역순회하도록 함으로써 최대 시간 복잡도를 O(n)/2 로 낮출 수 있다. 근데 O(n/2)도 n이 큰 경우 O(n)으로 생각하기 때문에
  큰 차이는 없을 수도 있다."""

2-3 더블리 링크드 리스트 접근 연산

내용
  • 인자로 받은 인덱스와 리스트의 길이를 비교해 length/2 < index 이면 끝에서 부터 역순회 하는게 더 빠르다.
  • 리스트의 길이를 알려면 삽입 관련 메소드에 모두 데이터가 추가될 때마다 length 속성에 +1 하도록 코드를 작성해야 한다.
  • 접근 메소드에서도 length/2와 index를 비교하는 if문을 추가한다.
  • 코드는 생략하겠습니다.

2-4 더블리 링크드 리스트 탐색 연산

내용
  • 정렬되어 있지 않다면 어차피 어디 있는지 알 수 없기 때문에 링크드 리스트와 같다.

2-5 더블리 링크드 리스트 삭제 연산

내용
  • 삽입과 비슷하게 인덱스를 통해 삭제하려고 한다면 접근에 관한 메소드를 사용해야 하고, 접근 메소드를 위에서 말한 것처럼 작성하면 시간 복잡도를 낮출 수 있다.
  • 링크드 리스트에서와 조금 다른 점이 있다. 바로 인자로 내가 삭제하고자 하는 노드를 직접 지정할 수 있다는 것이다.
코드
def delete(self, node_to_delete):
        """더블리 링크드 리스트 삭제 연산 메소드"""
    
        # 링크드 리스트에서 마지막 남은 데이터를 삭제할 때
        if node_to_delete is self.head and node_to_delete is self.tail:
            self.tail = None
            self.head = None
    
        # 링크드 리스트 가장 앞 데이터 삭제할 때
        elif node_to_delete is self.head:
            self.head = self.head.next
            self.head.prev = None
    
        # 링크드 리스트 가장 뒤 데이터 삭제할 떄
        elif node_to_delete is self.tail:
            self.tail = self.tail.prev
            self.tail.next = None
    
        # 두 노드 사이에 있는 데이터 삭제할 때
        else:
            node_to_delete.prev.next = node_to_delete.next
            node_to_delete.next.prev = node_to_delete.prev
    

3. 시간 복잡도

  • 접근과 탐색의 경우 싱글과 더블 모두 복잡도는 O(n) 이다.

    연산 시간 복잡도
    접근 O(n)
    탐색 O(n)
  • 임의의 위치에 대한 삽입과 삭제의 경우 조금 생각할게 생긴다.
    삽입의 경우 어쨋든 삽입하려는 노드 앞의 노드에 접근해야한다. (prev_node)
    삭제의 경우 싱글은 삽입과 이유가 같다. 앞의 노드에 접근해야 한다. (prev_node)
    삭제의 경우 더블은 바로 해당 노드를 지울 수 있지만 앞 뒤의 노드를 연결(link)해주기 위해서는 결국 접근해야 한다.
    그래서 결론적으로 모두 시간 복잡도는 최악의 경우에 대해 O(n)이다.

    연산 시간 복잡도
    삽입 O(n)
    삭제 O(n)
  • 맨 앞/뒤 삽입/삭제의 경우,

    • 삽입의 경우 맨 앞: prepend, 맨 뒤: append는 head와 tail에 바로 접근 (insert_after와 다르게)
    • 삭제의 경우 맨 앞은 싱글, 더블 둘다 바로 삭제하고자 하는 노드(head)에 한 번에 접근 가능,
    • 맨 뒤 삭제의 경우 싱글은 삭제는 한 번에 접근 가능하지만,
      tail 앞의 노드를 새롭게 self.tail로 지정하기 위해 맨 끝 앞의 노드에 접근해야 한다. -> O(n)
    • 맨 뒤 삭제의 경우 더블은 tail에 바로 접근하고 tail앞의 노드 또한 self.tail.prev로 한 번에 접근 가능하다. -> O(1)
    연산 싱글 링크드 리스트 더블리 링크드 리스트
    맨 앞 삽입 O(1) O(1)
    맨 앞 삭제 O(1) O(1)
    맨 뒤 삽입 O(1) O(1)
    맨 뒤 삭제 O(n) O(1)

4. 결론

  • 큐(queue)는 First-In-First-Out 이기 때문에 맨 앞으로만 노드가 삽입되고 삭제되어 싱글 링크드 리스트로 구현해도 된다.

  • 스택(stack)은 Last-In-First-Out 이기 때문에 맨 뒤부터 삭제되어 더블리 링크드 리스트로 구현하는게 좋다.

  • 파이썬 리스트는 접근이나 탐색의 시간 복잡도가 낮은 동적 배열로 구현된다.

Tags:

Categories:

Updated: