[인공지능 데브코스] 1주차 day1 - 자료구조(1)
11월 30일 월
인공지능을 공부하기 전에 프로그래밍의 기본인 자료구조와 알고리즘의 기초부터 공부를 시작했다. 대부분 익숙한 내용들이지만 오랬동안 다루지 않았던 Python으로 여러 자료구조들을 공부해 보면서 Python에 대해 익숙해질 수 있었고 예전에 배웠던 것들을 확실하게 복습할 수 있었다.
자료구조
- 파이썬에서 이미 제공하는 데이터 타입: 문자열, 리스트, 딕셔너리, 튜플, 셋
- 자료구조를 알아야 하는 이유: 파이썬에서 제공하는 기본 데이터 타입으로 해결하기 힘든 문제를 해결하기 위해서
=>풀어야 하는 문제에 따라 내가 사용하려는 자료구조가 어떤 성질을 가져야 하는지를 생각해야 한다.
해결하고자 하는 문제에 따라 최적의 해법은 서로 다르다
=>이 선택을 어떻게 해야 하느냐를 알기 위해 자료구조를 이해해야 한다.
선형배열(Linear Arrays)
배열
원소들을 순서대로 늘어 놓은것
리스트(list)
파이썬에서 제공하는 배열 데이터 타입
(보통의 언어들과는 다르게 각 원소를 다른 데이터 타입으로 사용가능하다)
리스트의 연산
- 원소 덧붙이기
list.append(val) #O(1)
- 끝에서 꺼내기
list.pop() #O(1)
- 원소 삽입하기
list.insert(index, val) #O(n)
- 원소 삭제하기
del(list[index]) #항목으로 삭제
list.pop(index) #index로 삭제
#O(n)
- 원소 탐색하기
list.index(val) #O(n)
리스트의 정렬
sorted
: 내장함수, 정렬된 새로운 리스트를 얻어냄nlist = sorted(list, reverse = True)
sort
: 리스트의 메서드, 해당리스트를 정렬list.sort(reverse = True)
key
: lambda를 이용하여 원하는 기준으로 정렬
sorted(list, key = lambda x:len(x)) #길이를 기준으로 정렬
리스트의 탐색
- 선형탐색: 앞에서 부터 뒤로 발견될 때까지 순차적으로 탐색
O(n)
- 이진탐색: 정렬되어있는 배열에서만 사용가능한 탐색
O(log n)
이진탐색의 구현
주어진 배열을 반으로 나누고 찾는 값이 왼쪽, 오른쪽중 어디에 있는지 확인 후 배열의 범위를 update해준다
def solution(L, x): #L은 정렬된 배열, x는 찾고자하는 값
left = 0; right = len(L) #left, right를 각각 배열의 처음과 마지막으로 설정
while left<right: #left가 right보다 작을 때까지 반복(범위안에 원소가 존재할 때까지)
m = (left + right) // 2 #중간위치를 m으로 설정
if L[m] < x: #중간위치보다 x가 크면 범위를 [m+1, right)로 update
left = m+1
elif L[m]: #중간위치보다 x가 작으면 범위를 [left, m)로 update
right = m
return left if (left<len(L) and L[left] == x ) else -1
#범위안에 x가 있다면 left는 right와 같아져서 x를 가리키게 되고 아니라면 범위를 벗어나거나 다른 값을 가리키게 된다.
#범위안에 x가 여러개라면 left는 그 중 가장 작은 값을 가리키고 범위안에 x가 없다면 x보다 큰 가장 작은 값을 가리킨다.
이진탐색의 재귀적 구현
def solution(L, x, l, u): #L: 정렬된 배열, x: 찾는 값, l: update된 범위의 가장 처음위치, u: update된 범위의 가장 끝위치
if l > u: #l 이 u보다 크거나 범위가 안에 원소가 없으므로 x를 찾지 못하고 재귀함수를 종료
return -1
mid = (l + u) // 2 #mid는 l과 u의 중간의 위치
if x == L[mid]: #x가 mid가 가리키는 값과 같다면 찾은 것이므로 mid를 return
return mid
elif x < L[mid]: #x가 중간위치보다 작다면 범위를 [l, mid)로 update
return solution(L,x,l,mid)
else: #아니라면 범위를 [mid+1, u)로 update
return solution(L,x,mid+1,u)
연결리스트(Linked List)
추상적 자료구조
내부구현은 숨겨두고 밖에서 직관적으로 사용할 수 있는 요소를 제공하는 자료구조
내부구현을 잘 알지 못하더라도 사용하는 데에 지장이 없다.
두가지 요소를 포함한다.
- Data: 정수, 문자열 등 자료가 담고있는 정보
- A set of operations: 삽입, 삭제, 순회, 정렬, 탐색 등 data를 다룰 수 있는 연산
연결리스트
값을 가지고 있는 노드들을 일렬로 연결시켜 만든 추상적 자료구조
연결리스트의와 배열의 차이
-
장점
배열보다 중간에서의 원소의 삽입과 삭제가 빠르다 -
단점
배열보다 탐색의 속도가 느리다.
배열보다 공간을 더 많이 사용한다.
=> 원소의 삽입과 삭제가 많이 일어난다면 연결리스트 탐색이 많이 일어난다면 배열을 사용하는 것이 유리하다.
Node
연결 리스트를 이루고 있는 하나의 단위를 노드라고 하며 노드 한개당 2가지 정보를 포함한다.
data
: 노드 자체가 가지고 있는 값next
: 어떤 노드와 연결되어 있는지에 대한 정보
class Node:
def __init__(self, item):
self.data = item
self.next = None
연결 리스트의 구현
추상적 자료구조이므로 data, a set of operations를 포함하고 있다.
Data
head
: 맨 앞에 있는 노드tail
: 맨 뒤에 있는 노드nodeCount
: 연결 리스트의 길이
class LinkedList:
def __init__(self):
self.nodeCount = 0
self.head = None
self.tail = None
A set of operations
traverse()
모든 node의 값을 head부터 순차적으로 리스트에 담아서 반환O(n)
def traverse(self):
result = [] #빈 list 생성
curr = self.head #curr을 head로 초기화
while curr is not None: #curr이 None이 아닐 때까지 즉, 맨 끝에 도달할 때까지 반복
result.append(curr.data) #result에 curr의 값을 추가해주고
curr = curr.next #curr을 다음 노드로 변경
return result
getAt(pos)
: pos위치에 있는 노드의 값을 반환O(n)
def getAt(self, pos): #O(n)
if pos < 1 or pos > self.nodeCount: #pos가 범위 밖에 있으면 None을 반환
return None
i = 1
curr = self.head #curr을 맨 첫번째 노드로 설정
while i < pos: #curr을 pos번 next해줌
curr = curr.next
i += 1
return curr #pos번째 node를 반환
insertAt(pos, newNode)
: pos위치에 newNode를 삽입O(n)
def insertAt(self, pos, newNode):
if pos < 1 or pos > self.nodeCount + 1: #pos가 범위 밖이라면 False를 반환
return False
if pos == 1: #pos가 1이라면 맨 처음에 삽입하는 것이므로 head가 바뀌게 되어 따로 처리
newNode.next = self.head #newNode의 next를 지금 맨 처음 노드로 바꿔주고
self.head = newNode #현재 head를 newNode로 바꿔줌
else:
if pos == self.nodeCount + 1: #link를 바꿔주어야 하기 때문에 삽입할 위치 전의 노드를 탐색
prev = self.tail #속도향상을 위해 삽입할 위치가 맨 끝이라면 getAt을 사용하지 않고 tail을 사용
else:
prev = self.getAt(pos - 1)
newNode.next = prev.next #찾은 노드의 다음 노드를 새 노드의 next로 설정하고
prev.next = newNode #찾은 노드의 다음을 새 노드로 설정
if pos == self.nodeCount + 1: #맨 끝에 삽입할 경우에는 tail이 바뀌므로 따로 처리
self.tail = newNode
self.nodeCount += 1 #전체 노드의 개수를 1만큼 증가
return True
popAt(pos)
: pos위치에 있는 node를 삭제O(n)
def popAt(self, pos):
if pos < 1 and pos > self.nodeCount: #범위밖이라면 error
raise IndexError
if pos == 1: #삭제할 위치가 1이라면 head가 바뀌므로 따로 처리
curr = self.head
data = curr.data
self.head = curr.next
else:
prev = self.getAt(pos - 1)
curr = prev.next
data = curr.data
prev.next = curr.next
if pos == self.nodeCount: #삭제할 위치가 마지막이라면 tail이 바뀌므로 따로처리
self.tail = prev
if pos == self.nodeCount and pos == 1: #노드가 1개만 있는 경우에는 모든 노드가 사라지고 tail과 head도 None이 되므로 따로 처리
self.tail = None
self.head = None
self.nodeCount -= 1 #nodeCount를 1만큼 감소
return data
dummy node의 필요성
위에서 언급한 popAt(pos)
, insertAt(pos, newNode)
는 지정 위치까지 직접 이동해서 찾은 후 연산을 하기 때문에 선형 시간복잡도O(n)
를 가지며 비효율적이다.
연결리스트에서는 효율적인 삽입과 삭제를 위해 지정한 노드의 다음에 삽입하는 insertAfter(prev, newNode)
와 지정한 노드 다음 노드를 삭제하는 popAfter(prev)
를 사용하는데 이와 같은 함수로는 맨 앞에 노드 삽입이나 맨 앞 노드의 삭제를 할 수 없으므로 데이터를 가지고 있지 않은 dummy node를 맨앞에 추가하여 head로 사용하여 맨 앞 노드도 삭제, 삽입을 할 수 있도록 한다.
insertAfter(prev, newNode)
: 지정한 노드 다음 위치에 새로운 노드를 삽입O(1)
def insertAfter(self, prev, newNode): #prev노드 다음위치에 newNode를 삽입하는 함수
newNode.next = prev.next #새로운 노드의 다음을 prev의 다음이었던 노드로 설정
if prev.next is None: #prev가 마지막 노드라면 tail이 새로운 노드가 되므로 따로 처리
self.tail = newNode
prev.next = newNode #prev의 next를 newNode로 설정
self.nodeCount += 1
return True
#insertAfter를 이용해서 insertAt을 다시 구현
def insertAt(self, pos, newNode): #insertAfter를 구현했으므로 pos를 이용해 삽입할 위치 전의 노드인 prev만 구하면 쉽게 구현할 수 있다.
if pos < 1 or pos > self.nodeCount + 1:
return False
if pos != 1 and pos == self.nodeCount + 1: #맨끝이라면 prev는 tail (굳이 필요는 없지만 시간을 단축하기 위해 추가)
prev = self.tail
else: #아니라면 getAt을 이용해 구함
prev = self.getAt(pos - 1)
return self.insertAfter(prev, newNode) #위에서 구한 prev로 insertAfter를 이용해 노드 삽입
popAfter(prev)
: 지정한 노드 다음위치의 노드를 삭제O(1)
def popAfter(self, prev): #prev다음위치에 있는 노드를 삭제하는 함수
cur = prev.next #prev다음 위치에 있는 노드를 cur로 설정
if cur is None: #prev가 맨 끝 노드였다면 삭제할 노드가 없으므로 따로 처리
return None
elif cur.next is None: #prev가 맨 끝에서 두번째 노드였다면
prev.next = None #맨 끝 노드를 삭제하는 것이므로 tail이 바뀌게 되어 따로 처리
self.tail = prev
else: #그 이외의 경우는 prev.next를 다음다음 노드와 연결시키는 것으로 처리
prev.next = cur.next
self.nodeCount -= 1
return cur.data
#popAfter를 이용해서 popAt을 다시 구현
def popAt(self, pos): #insert와 동일
if pos < 1 or pos > self.nodeCount:
raise IndexError
else:
prev = self.getAt(pos-1)
return self.popAfter(prev)
위에서 구현한 insertAt(pos, newNode)
와 popAt(pos)
를 보면 처음에 구현했을때 보다 예외처리를 해야할 부분이 줄어서 코드가 간결해 진 것을 확인 할 수 있다.
양방향 연결 리스트(Doubled Linked List)
양쪽방향으로 진행가능한 Node를 이용하여 만든 연결 리스트로 앞, 뒤 방향으로 이동할 수 있다.
Node
class Node:
def __init__(self, item):
self.data = item
self.prev = None
self.next = None
Data
연결 리스트와 동일
A set of operations
getAt(pos)
: 단방향 연결 리스트와 다른점은 양방향으로 움직일 수 있기 때문에 pos가 head에 가까운지 확인후 더 가까운 쪽에서 탐색을 시작할 수 있다.
중간에 있는 값만 호출할 때는 소모시간이 같지만 평균 소모시간을 단축 시킬 수 있다.O(n)
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
양방향 연결 리스트에서의 dummy node
양방향 연결 리스트에서는 전에 구현했던 insertAfter(pos, newNode)
, popAfter(pos)
뿐만 아니라 선택한 노드의 전 노드에서 작업을 수행하는 insertBefore(pos, newNode)
, popBefore(pos)
또한 구현할 수 있다. 그러나 이를 위해서는 단방향 연결 리스트때와 같은 이유로 tail쪽에 값이 없는 dummy node가 필요하다. 아래에서는 head와 tail에 dummy node를 추가하고 단방향 연결 리스트에서 구현했던 연산들을 구현해 보았다.
- 양방향 연결 리스트에서만 구현할 수 있는
insertBefore(next, newNode)
와 다시 구현해본insertAt(pos, newNode)
ㄱㄱㄱㄱㄱㄲㄱㄲㄱㄲㄲㄲㄲㄲ
def insertBefore(self, next, newNode): #link 바꾼 노드의 next와 prev를 모두 바꿔줘야 하므로 4개의 값을 바꿔야 한다.
pre = next.prev
pre.next = newNode
newNode.next = next
newNode.prev = pre
next.prev = 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)
popBefore(next)
와 다시 구현해본popAt(pos)
def popBefore(self, next):
cur = next.prev
cur.prev.next = next
next.prev = cur.prev
self.nodeCount -= 1
return cur.data
def popAt(self, pos):
if pos < 1 or pos > self.nodeCount:
raise IndexError
else:
next = self.getAt(pos+1)
return self.popBefore(next)
위의 코드를 보면 양방향 연결 리스트를 이용하면 앞에 있는 노드를 삭제, 삽입 할 수 있을 뿐만 아니라 뒤에 있는 노드 또한 삭제, 삽입 할 수 있다는 것을 알 수 있다.
정리해 보면 양방향 연결리스트는
1. getAt(pos)함수의 소모시간을 단축시킬수 있다
2. 노드의 삭제 삽입을 좀 더 유연하게 할 수 있다
와 같은 장점들을 가지고 있는 것을 알 수 있다.
이와 별개로 insertAt(pos, newNode)
와 popAt(pos)
를 보면 눈에 띄게 코드가 간결해 진 것을 확인 할 수 있는데 이는 dummy node의 추가에 의한 효과로 head와 tail이 바뀌지 않게 되면서 예외처리에 필요했던 if문들이 모두 사라진 것을 알 수 있다.
이를 통해 특정 연산을 구현하는 역할 뿐만 아니라 실수의 최소화와 코드의 간결함을 위해 dummy node의 추가는 좋은 효과를 가지고 있는 것을 확인 할 수 있다.