연결 리스트(Linked List)는 선형 배열과 비슷하지만 전혀 다른 자료 구조이다.
선형 배열은 번호가 붙혀진 칸에 원소들을 채워넣는 방식이라면, 연결 리스트는 각 원소들을 줄줄이 엮어서 관리하는 방식이다.
그렇다면 연결 리스트는 각 원소들을 어떻게 엮는다고 하는 것일까?
연결 리스트는 아래의 그림처럼 앞에 원소가 뒤에 원소를 가리키고 있는 형태이다.
각 원소들은 Node로 이루어져 있다. Node는 2가지 정보를 가지고 있다:
연결 리스트는 Link를 통해 연결되어 있기 때문에 원소의 삽입/삭제가 보다 쉽고 빠른 시간에 처리가 된다. 이런 이유로 OS 내부에서 많이 사용하고 있다.
다만, data 뿐만 아니라 link도 메모리에 저장되어 있어야 하므로 저장공간의 소요가 크다. 또한 'k번째 원소'를 찾아가는데 시간이 오래 걸린다.
아직 연결 리스트와 배열의 차이점이 명확하게 느껴지지 않는다면 정리된 표를 확인해보자.
배열(Array) | 연결 리스트(Linked List) |
---|---|
정적 자료구조 | 동적 자료구조 |
미리 크기를 정해 놓음 | 크기를 정할 필요가 없음 |
연속된 메모리 주소 할당 O | 연속된 메모리 주소 할당 X |
접근, 탐색 용이 | 추가, 삭제 용이 |
index 존재 | Node 존재 |
우선, Node의 link가 단방향으로 흐르는 경우부터 살펴보자.
위의 그림에서처럼 가장 처음 Node를 head라고 설정한다. head가 없다면 다음 원소를 찾을 수 없기 때문에 꼭 기억하고 있어야 한다.
맨 끝 Node를 tail이라고 설정한다. head만 있어도 tail까지 찾아갈 수 있지만 가장 마지막 자리에 값을 삽입하거나 삭제할 때 오랜 시간이 걸릴 수 있기 때문에 tail을 기억하도록 한다.
일반적인 언어에서 index는 0부터 시작하는데 현재 1부터 써있어서 어색할 수 있다. 이 0번째 인덱스는 뒤에 나올 내용에서 유용하게 쓰일 예정이다.
연결 리스트가 가질 수 있는 연산은 :
연결 리스트는 이 6가지 연산 중에서 삽입, 삭제, 합치기에 특히 특화된 자료구조이다.
그럼 연결 리스트를 직접 구현해보도록 하자.
연결 리스트를 구현하기 위해서는 먼저 노드가 필요하다.
노드는 데이터와 link를 가지고 있는 형태로 작성한다.
class Node:
def __init__(self, item):
self.data = item # data
self.next = None # 아직 link가 연결되어 있지 않은 단일 노드
연결 리스트는 기본적으로 head와 tail을 담고 있어야 한다.
또한, 연결 리스트의 길이를 반환할 때 항상 배열을 처음부터 세는 것보다 nodeCount를 담고 있다가 반환해주는 것이 더 빠르다.
# 처음엔 비어있는 연결 리스트 생성
class LinkedList:
def __init__(self):
self.nodeCount = 0
self.head = None # head와 tail에는 Node를 넣어줄 예정
self.tail.None
뒤에 설명하는 연산들은 모두 이 LinkedList
class에 포함되는 함수이다.
특정 위치의 원소를 찾기 위해서 head부터 tail까지 차례로 확인한다. 배열의 길이와 원소의 위치에 따라서 시간이 걸리므로 시간 복잡도는 이다.
def getAt(self, pos):
# pos가 Node의 범위를 넘어선다면 None을 반환
if pos <= 0 and pos > nodeCount:
return None
# 1번부터 pos번째까지 curr의 다음으로 넘어간다.
i = 1
curr = self.head
while i < pos:
curr = curr.next
i += 1
return curr
순회는 연결 리스트를 처음부터 끝까지 돌면서 data만 뽑아서 list에 담아 반환한다. 시간 복잡도는 이다.
def traverse(self):
result = []
curr = self.head
# curr이 None일 때는 tail을 넘어갔을 때
while curr != None:
result.append(curr.data)
curr = curr.next
return result
길이는 nodeCount만 반환하면 끝! 시간 복잡도는 이다.
def getLength(self):
return self.nodeCount
특정한 위치에 원소를 삽입하려고 한다.
앞의 3가지 연산은 간단하게 구현했지만 삽입부터는 link를 수정해야한다.
주의해야할 사항
getAt()
메소드를 사용하여 탐색을 하는 것보다 바로 tail을 지정하면 시간 복잡도를 줄일 수 있다.def insertAt(self, pos, newNode):
# pos 의 범위가 넘어가면 False
if pos < 1 or pos > self.nodeCount + 1:
return False
# 첫 번째 위치에 넣고 싶을 때
if pos == 1:
newNode.next = self.head
self.head = newNode
else:
# 마지막 위치에 넣고 싶을 땐 getAt 메소드 필요없이 tail을 불러오자.
if pos == self.nodeCount + 1:
prev = self.tail
else:
prev = self.getAt(pos - 1)
newNode.next = prev.next
prev.next = newNode
# 마지막 위치에 넣을 땐 tail도 조정해야 한다.
if pos == self.nodeCount + 1:
self.tail = newNode
# 마지막으로 count에 1을 더해준다.
self.nodeCount += 1
return True
특정한 위치의 원소를 삭제하면서 해당 값을 반환하는 pop
메소드를 구현하려고 한다.
원소 삽입과 마찬가지로 prev를 찾는 것으로 시작한다.
주의해야할 사항
def popAt(self, pos):
# pos가 범위를 넘어가면 error 발생
if pos < 1 or pos > self.nodeCount:
raise IndexError
# curr 찾기
curr = self.getAt(pos)
# 맨 앞의 원소를 빼고 싶을 때
if pos == 1:
self.head = self.head.next
# list가 1개만 있을 때
if self.nodeCount == 1:
self.tail = None
else:
prev = self.getAt(pos-1)
prev.next = curr.next
# 맨 뒤의 원소를 빼고 싶을 때
if pos == self.nodeCount:
self.tail = prev
# count 1 감소
self.nodeCount -= 1
return curr.data
두 리스트를 연결하는 것은 앞에 삽입과 삭제를 거쳤다면 쉽게 이해될 것이다. 뒤에 link
만 수정하면 되므로 시간 복잡도는 이다.
주의해야할 사항
def concat(self, L):
self.tail.next = L.head
# 뒤에 이어붙힐 tail이 있을 때만 업데이트
if L.tail:
self.tail = L.tail
self.nodeCount += L.nodeCount
class Node:
def __init__(self, item):
self.data = item # data
self.next = None
class LinkedList:
def __init__(self):
self.nodeCount = 0
self.head = None
self.tail.None
# 특정 원소 참조
def getAt(self, pos):
if pos <= 0 and pos > nodeCount:
return None
i = 1
curr = self.head
while i < pos:
curr = curr.next
i += 1
return curr
# 리스트 순회
def traverse(self):
result = []
curr = self.head
while curr != None:
result.append(curr.data)
curr = curr.next
return result
# 길이 얻어내기
def getLength(self):
return self.nodeCount
# 원소 삽입
def insertAt(self, pos, newNode):
if pos < 1 or pos > self.nodeCount + 1:
return False
if pos == 1:
newNode.next = self.head
self.head = newNode
else:
if pos == self.nodeCount + 1:
prev = self.tail
else:
prev = self.getAt(pos - 1)
newNode.next = prev.next
prev.next = newNode
if pos == self.nodeCount + 1:
self.tail = newNode
self.nodeCount += 1
return True
# 원소 삭제
def popAt(self, pos):
if pos < 1 or pos > self.nodeCount:
raise IndexError
curr = self.getAt(pos)
if pos == 1:
self.head = self.head.next
if self.nodeCount == 1:
self.tail = None
else:
prev = self.getAt(pos-1)
prev.next = curr.next
if pos == self.nodeCount:
self.tail = prev
self.nodeCount -= 1
return curr.data
# 리스트 합치기
def concat(self, L):
self.tail.next = L.head
if L.tail:
self.tail = L.tail
self.nodeCount += L.nodeCount
연결 리스트의 최대 장점은 삽입과 삭제가 유연하다는 것이다.
why? 배열은 삽입과 삭제가 일어나면 영향을 받는 모든 원소의 위치를 옮겨주어야하지만, 연결 리스트는 링크만 조절하면 된다.
원소 삽입 | 원소 삭제 | |
---|---|---|
맨 앞에 | ||
중간에 | ||
맨 끝에 |
지금까지 배운 구조로는 getAt()
메소드를 사용하기 때문에 조금 시간이 오래 걸린다. 이것을 보완하기 위해 새로운 메소드를 구현하고자 한다.
insertAfter(prev, newNode)
popAfter(prev)
특정 위치가 아니라 삽입을 하고 제거를 하기 위한 앞에 노드를 인자로 전달하여 해결하고자 한다. 이 메서드 모두 맨 앞에 삭제와 삽입을 하려고 하니 복잡한 코드가 될 것 같다. 이때 사용하는 것이 바로 우리가 지금까지 쓰지 않았던 0번째 인덱스이다.
맨 앞에 dummy node를 추가한 형태로 head는 dummy node를 가리킨다. 기존에 만들어놨던 LinkedList()
클래스에 살짝 수정이 필요하다.
class Node:
def __init__(self, item):
self.data = item
self.next = None
class LinkedList:
def __init__(self):
self.nodeCount = 0
self.head = Node(None) # head에 dummy node 지정
self.tail = None
self.head.next = self.tail
# 길이 얻어내기
def getLength(self):
return self.nodeCount
# 리스트 순회
def traverse(self):
result = []
curr = self.head
while curr.next: # head는 dummy이므로 next node의 유무로 반복문을 사용한다.
curr = curr.next # curr을 먼저 옮긴다.
result.append(curr.data)
return result
# 특정 원소 추출하기
def getAt(self, pos):
# pos가 0부터 가능하므로 기준이 바뀌었다.
if pos < 0 or pos > self.nodeCount:
return None
i = 0
curr = self.head
while i < pos:
curr = curr.next
i += 1
return curr
# 리스트 연결하기
def concat(self, L):
self.tail.next = L.head.next # head는 dummy 이므로 head 다음을 가리킨다.
if L.tail:
self.tail = L.tail
self.nodeCount += L.nodeCount
이전 노드를 인자로 받아서 삽입하는 insertAfter
메소드를 만든다. insertAt
메소드는 insertAfter
메소드를 활용하도록 수정할 수 있다.
기본적으로 insertAfter
메소드는 이전에 설명한 원리와 똑같다. insertAt
메소드는 pos에 따른 prev만 잘 찾아서 넘겨주면 된다.
def insertAfter(self, prev, newNode):
newNode.next = prev.next
if prev.next is None:
self.tail = newNode
prev.next = newNode
self.nodeCount += 1
return True
def insertAt(self, pos, newNode):
if pos < 1 or pos > self.nodeCount + 1:
return False
if pos != 1 and pos == self.nodeCount + 1:
prev = self.tail
else:
prev = self.getAt(pos - 1)
return self.insertAfter(prev, newNode)
이전 노드를 인자로 받아서 삽입하는 popAfter
메소드를 만든다. popAt
메소드는 popAfter
메소드를 활용하도록 수정할 수 있다.
기본적으로 popAfter
메소드는 이전에 설명한 원리와 똑같다. popAt
메소드는 pos에 따른 prev만 잘 찾아서 넘겨주면 된다.
def popAfter(self, prev):
curr = prev.next
if curr == None:
return None
if curr.next == None:
self.tail = prev
prev.next = curr.next
self.nodeCount -= 1
return curr.data
def popAt(self, pos):
if pos < 1 or pos > self.nodeCount:
raise IndexError
prev = self.getAt(pos-1)
return self.popAfter(prev)
class Node:
def __init__(self, item):
self.data = item
self.next = None
class LinkedList:
def __init__(self):
self.nodeCount = 0
self.head = Node(None)
self.tail = None
self.head.next = self.tail
def __repr__(self):
if self.nodeCount == 0:
return 'LinkedList: empty'
s = ''
curr = self.head
while curr.next:
curr = curr.next
s += repr(curr.data)
if curr.next is not None:
s += ' -> '
return s
def getLength(self):
return self.nodeCount
def traverse(self):
result = []
curr = self.head
while curr.next:
curr = curr.next
result.append(curr.data)
return result
def getAt(self, pos):
if pos < 0 or pos > self.nodeCount:
return None
i = 0
curr = self.head
while i < pos:
curr = curr.next
i += 1
return curr
def insertAfter(self, prev, newNode):
newNode.next = prev.next
if prev.next is None:
self.tail = newNode
prev.next = newNode
self.nodeCount += 1
return True
def insertAt(self, pos, newNode):
if pos < 1 or pos > self.nodeCount + 1:
return False
if pos != 1 and pos == self.nodeCount + 1:
prev = self.tail
else:
prev = self.getAt(pos - 1)
return self.insertAfter(prev, newNode)
def concat(self, L):
self.tail.next = L.head.next
if L.tail:
self.tail = L.tail
self.nodeCount += L.nodeCount
def popAfter(self, prev):
curr = prev.next
if curr == None:
return None
if curr.next == None:
self.tail = prev
prev.next = curr.next
self.nodeCount -= 1
return curr.data
def popAt(self, pos):
if pos < 1 or pos > self.nodeCount:
raise IndexError
prev = self.getAt(pos-1)
return self.popAfter(prev)
양방향 연결 리스트는 앞서 언급했던 것처럼 하나의 Node에 앞뒤로 2개의 Link가 있다. 아래의 그림에서 보는 것처럼 하나는 앞으로 가는 Next와 다른 하나는 뒤로 가는 Prev이다.
양방향 연결 리스트는 단방향 연결 리스트보다 메모리 사용량이 늘어나고 개발자가 조금더 신경써야 한다는 단점을 가지고 있지만 장점이 더욱 매력적이기 때문에 양방향 연결 리스트를 많이 사용한다.
양방향 연결 리스트는 데이터 원소들을 차례로 방문할 때 앞에서 뒤로만 가능했던 단방향과는 달리 뒤에서 앞으로도 갈 수 있다는 것이 큰 장점이다. 너무 당연해보이지만 OS 등에서는 리스트를 대상으로 앞/뒤로 왔다 갔다 하면서 작업해야하는 경우가 빈번히 요구되므로, 양방향 연결 리스트를 많이 사용한다.
단방향과 동일하게 먼저 노드를 구현한다. link가 앞뒤로 연결되어 있기 때문에 prev
, next
둘 다 만들어 준다.
class Node:
def __init__(self, item):
self.data = item
self.prev = None
self.next = None
양방향 연결 리스트의 연산을 구현하기 위한 클래스를 만든다.
head
와 tail
은 dummy node이기 때문에 None으로 지정해준다.
또한, head는 앞(prev)으로 가는 link가 없고 tail은 뒤(next)로 가는 link가 없기 때문에 각각 처리를 해준다.
class DoublyLinkedList:
def __init__(self):
self.nodeCount = 0
self.head = Node(None)
self.tail = Node(None)
self.head.prev = None
self.head.next = self.tail
self.tail.prev = self.head
self.tail.next = None
기본적인 rule은 단방향과 같다. 앞에서 뒤로 넘어가면서 탐색할 때 만약에 찾고자하는 위치가 후반부에 위치하고 있다면 리스트가 길어질수록 실행시간이 오래 걸릴 것이다. 이것을 양방향 연결 리스트는 방지할 수 있다.
앞 뒤를 나누어서 진행하기 때문에 시간 복잡도는 지만, 계수는 신경쓰지 않으므로 이다.
pos
가 nodeCount
의 절반인 수보다 크다면 후반부에 위치하는 것이기 때문에 tail
부터 시작하면서 찾는다.
def getAt(self, pos):
if pos < 0 or pos > self.nodeCount:
return None
# pos가 후반부에 위치할 때
if pos > self.nodeCount // 2:
i = 0
curr = self.tail
while i < self.nodeCount - pos + 1:
curr = curr.prev
i += 1
# pos가 전반부에 위치할 때
else:
i = 0
curr = self.head
while i < pos:
curr = curr.next
i += 1
return curr
head
와 tail
모두 dummy node이므로 next.next
가 존재할 때 다음의 값으로 넘어가서 담는다. 시간 복잡도는 이다.
def traverse(self):
result = []
curr = self.head
while curr.next.next:
curr = curr.next
result.append(curr.data)
return result
양방향 연결 리스트는 뒤에서 앞으로도 탐색할 수 있기 때문에 역방향으로도 data를 담아 반환할 수 있다. 시간 복잡도는 이다.
def reverse(self):
result = []
curr = self.tail
while curr.prev.prev:
curr = curr.prev
result.append(curr.data)
return result
양방향은 노드마다 link가 2개 있기 때문에 link를 둘다 수정해주면 된다.
newNode
의 prev
링크에 prev
노드를 지정한다.newNode
의 next
링크에 prev.next
노드를 지정한다.prev
의 next
링크에 newNode
노드를 지정한다.prev.next
의 prev
링크에 newNode
노드를 지정한다.nodeCount
를 1 증가시킨다.def insertAfter(self, prev, newNode):
next = prev.next
newNode.prev = prev
newNode.next = next
prev.next = newNode
next.prev = newNode
self.nodeCount += 1
return True
def insertAt(self, pos, newNode):
if pos < 1 or pos > self.nodeCount + 1:
return False
prev = self.getAt(pos - 1)
return self.insertAfter(prev, newNode)
삽입해야 하는 위치의 다음 노드가 주어졌을 때 원소를 삽입할 수도 있다. 또한 insertAt
메소드는 insertBefore
메소드를 사용할 수 있다.
def insertBefore(self, next, newNode):
prev = next.prev
newNode.next = next
newNode.prev = prev
next.prev = newNode
prev.next = newNode
self.nodeCount += 1
return True
def insertAt(self, pos, newNode):
if pos < 1 or pos > self.nodeCount + 1:
return False
next = self.getAt(pos + 1)
return self.insertBefore(next, newNode)
양방향은 노드마다 link가 2개 있기 때문에 마찬가지로 둘다 수정해야 한다.
prev
의 next
링크에 있는 데이터가 삭제해야하는 데이터(curr
)이다.prev
의 next
링크에 curr.next
노드를 지정한다.curr.next
의 prev
링크에 curr.prev
노드를 지정한다.nodeCount
를 1 감소시킨다.def popAfter(self, prev):
curr = prev.next
prev.next = curr.next
curr.next.prev = curr.prev
self.nodeCount -= 1
return curr.data
def popAt(self, pos):
if pos < 0 or pos > self.nodeCount:
raise IndexError
prev = self.getAt(pos-1)
return self.popAfter(prev)
삭제해야 하는 위치의 다음 노드가 주어졌을 때 원소를 삭제할 수도 있다.
def popBefore(self, next):
curr = next.prev
next.prev = curr.prev
curr.prev.next = curr.next
self.nodeCount -= 1
return curr.data
두 리스트를 합치는 method도 링크를 수정함으로써 빠르게 작성할 수 있다.
tail.prev.next
는 합쳐질 L 리스트의 head.next
를 가리켜야 한다(why? head
, tail
이 Dummy Node)head.next.prev
가 현재 리스트의 tail.prev
를 가리켜야 한다.tail
을 L 리스트의 tail
로 수정한다.nodeCount
를 합한다.def concat(self, L):
self.tail.prev.next = L.head.next
L.head.next.prev = self.tail.prev
if L.tail.prev:
self.tail = L.tail
self.nodeCount += L.nodeCount
class Node:
def __init__(self, item):
self.data = item
self.prev = None
self.next = None
class DoublyLinkedList:
def __init__(self):
self.nodeCount = 0
self.head = Node(None)
self.tail = Node(None)
self.head.prev = None
self.head.next = self.tail
self.tail.prev = self.head
self.tail.next = None
# 길이 얻어내기
def getLength(self):
return self.nodeCount
# 리스트 순회
def traverse(self):
result = []
curr = self.head
while curr.next.next:
curr = curr.next
result.append(curr.data)
return result
# 리스트 역방향 순회
def reverse(self):
result = []
curr = self.tail
while curr.prev.prev:
curr = curr.prev
result.append(curr.data)
return result
# 특정 원소 참조
def getAt(self, pos):
if pos < 0 or pos > self.nodeCount:
return None
if pos > self.nodeCount // 2:
i = 0
curr = self.tail
while i < self.nodeCount - pos + 1:
curr = curr.prev
i += 1
else:
i = 0
curr = self.head
while i < pos:
curr = curr.next
i += 1
return curr
# 원소 삽입 (이전 노드)
def insertAfter(self, prev, newNode):
next = prev.next
newNode.prev = prev
newNode.next = next
prev.next = newNode
next.prev = newNode
self.nodeCount += 1
return True
# 원소 삽입 (다음 노드)
def insertBefore(self, next, newNode):
prev = next.prev
newNode.next = next
newNode.prev = prev
next.prev = newNode
prev.next = newNode
self.nodeCount += 1
return True
# 원소 삽입 (위치)
def insertAt(self, pos, newNode):
if pos < 1 or pos > self.nodeCount + 1:
return False
prev = self.getAt(pos - 1)
return self.insertAfter(prev, newNode)
# 원소 삭제 (이전 노드)
def popAfter(self, prev):
curr = prev.next
prev.next = curr.next
curr.next.prev = curr.prev
self.nodeCount -= 1
return curr.data
# 원소 삭제 (다음 노드)
def popBefore(self, next):
curr = next.prev
next.prev = curr.prev
curr.prev.next = curr.next
self.nodeCount -= 1
return curr.data
# 원소 삭제 (위치)
def popAt(self, pos):
if pos < 0 or pos > self.nodeCount:
raise IndexError
prev = self.getAt(pos-1)
return self.popAfter(prev)
# 리스트 합치기
def concat(self, L):
self.tail.prev.next = L.head.next
L.head.next.prev = self.tail.prev
if L.tail.prev:
self.tail = L.tail
self.nodeCount += L.nodeCount
이렇듯 양방향 연결 리스트를 사용하면 생각보다 코드 구현이 조금더 깔끔해지는 것을 확인할 수 있다. 양방향 연결 리스트에서 가장 중요한 것은 head
와 tail
에 dummy node를 지정함으로써 깔끔한 코드를 구현할 수 있다는 것이다.
선형으로 쌓아야하는 데이터를 자주 삽입 및 삭제를 하는 문제가 있다면 양방향 연결 리스트를 먼저 떠올리도록 하자.