벡터 : 배열의 확장 개념 / 배열처럼 인덱스로 접근 가능 / 배열로 구현
insert, erase : 전부 하나씩 밀어야하기 때문에 O(n)
=> 해결방법 : circular array 로 구현할 경우 맨 앞의 원소에 대한 insert, erase 가 O(1) 이 걸린다 ( 하지만 이것도 맨 앞이 아니라면 O(n)이 걸린다 )
사용 공간 : O(N) 이 사용됨 ( N : 처음 지정한 배열의 크기 )
(주의할 것! N 과 n 은 다르므로 구분짓자. )
더블링 stategy 로 벡터를 구현시 Amotized Analysis 를 통해 O(1) 이 걸린다.
몰론 벡터를 배열말고도 링크드 리스트로도 구현 가능.
자료구조 안에서 하나의 오브젝트가 어디에 있는지 "위치"를 추상화 해놓은 개념 ( 임의의 자료구조의 '주소'를 모델링한 개념 )
내부적으로 어떻게 구현되어 있는지 상관하지 않고(신경쓸 필요x), 외부에서 해당 자료구조에서 원하는 위치에 대해 access
Postition ADT 가 구현된 것 : 포인터 변수, 이터레이터 변수 등등
어느 객체가 어디에 있다는 것은 구현을 어떻게 했는가에 따라서 실제로 어디있는지 달리진다.
iterator : 맨앞과 맨뒤의 원소를 가리키는 방법이 구현된 변수
( 자료구조 범위에서 벗어나는 내용임 )
iterator 는 내부적으로 자료구조가 어떻게, 어떤걸로 구현되어서 어떤 방법으로 position을 access 할수있는지 상관하지 않고 맨앞과 맨뒤에 원소의 position에 access 할수있는 변수다.
p.element( ) : 유일한 메서드인 element 메서드는 주어진 위치에 해당하는 값을 리턴해줌 ( c++에서 *p 연산자와 동일한 기능 )
→ list adt를 배우기 위한 기초 개념이라고 생각하자!
position(위치)를 기반으로 데이터를 access 하는 추상 자료형
외부에서는 해당 자료구조를 position 이라는 개념으로 호출하지만, 내부적으로는 인덱스, 노드형 포인터, .. 등등으로 구현된 자료구조에 알맞게 position 이 변환되서 탐색,삽입 삭제들의 연산을 할수있다(access할수있다).
따라서 특정 원소의 위치가 어딨는지 알려주는 변수인 iterator 를 외부에서 활용하면 리스트의 내부 안에서의 데이터의 위치를 찾아내고, 그 데이터에 대해 insert, erase 등의 연산을 할 수가있다!
(이때 iterator는 맨 앞과 맨 뒤의 원소가 대해서만 어딨는지 알려줄 수 있다.)
일반 링크드 리스트와 다른 점 : Node의 data에 해당하는 부분이 Position으로 되어있음
즉, 특정 데이터의 주소가 연결된 것이 바로 List 이다. → a sequence of positions storing arbitrary object
size(), empty()
begin(), end()
=> 리스트는 iterator를 활용해 2가지 함수를 지원.
insert(p,e), erase(p) : p => position p에다 e를 삽입, 삭제
insertFront(e), insertBack(e) : 그 리스트의 특정한 위치(맨앞, 맨뒤) 에 e를 삽입
📢 List에서 사용되는 중요한 개념이 바로 반복자(Iterators) 이다.
반복자는 컨테이너 원소의 position 리턴하므로 포인터와 동일한 역할을 한다.
때문에 반복자는 컨테이너에 저장된 원소에 접근하기 위한 도구로 사용된다.
ex. 반복자를 통해 접근하려는 원소의 주소를 알아내어 이를 이용해 연산을 수행하곤 한다.
리스트에서 반복자는 begin(), end() 함수를 통해 초기화된다.
이때 유의할 것은 end() 함수를 통해 리턴되는 것이
마지막 원소의 주소가 아닌 마지막 원소의 다음번 주소라는 것이다.
즉, end() 멤버 함수를 통해 얻어지는 반복자는 결과적으로 아무 의미가 없는 것을 가리키고 있는 것이며,
이 반복자가 가리키는 것을 참조하면 예상치 못한 오류가 발생하게 된다.
(이는 일반적인 컨테이너들에 다 해당되는 특징임)
Q. 왜 end() 함수는 마지막 원소의 다음 주소를 리턴하는 것인가?
위 구조는 half open range [begin, end)
로 정의되곤 하는데, 이는 다양한 오류를 핸들링하기 쉬우며
empty 일때 begin() = end() 를 가능하게 한다는 점 등에서 많은 장점이 있다.
head -> prev = NULL
tail -> next = NULL
new 로 할당받은 position을 q 라고 하자. (q는 새로 할당받는 노드형 포인터)
insertion 의 수도코드
(q : 노드 p의 앞에다 삽입할 새로운 노드 q => node* q = new node;)
q.data <- x
q.prev <- p.prev
q.next <- p
q.prev.next <- q
q.prev <- q
n <- n + 1
insertion 의 시간복잡도 : O(1)
수도코드 다시 표현
(위의 수도코드는 교수님 설명 코드이고, 이 수도코드는 ppt에 적혀있는 코드임)
"u <-> p" 인 링크드리스트에 데이터 e를 가지는 새로운 노드 v를 추가해서
"u <-> v <-> p" 가 되게 하는 상황
Algorithm insert(p, e): {insert e before p}
Create a new node v // O(1)
v -> element = e // O(1)
u = p -> prev // O(1)
v -> next = p; p -> prev = v {link in v before p} // O(1)
v -> prev = u; u -> next = v {link in v after u} // O(1)
insert : 이미 주어진 위치에다 데이터를 삽입
find and insert : 어떤 특정 위치를 일일이 노가다로 찾아내서 데이터를 그곳에 삽입
비유)
insert : 100번지 집 앞에다 데이터를 넣어라
find and insert : 홍길동이 사는 집을 찾아서 그 집 앞에다 데이터를 넣어라
우리가 하고 있는 것은 insert 이다.
insert 는 어떤 특정 위치(주어진 위치 p)의 앞에다가 데이터를 삽입하는 것인지, 특정 위치를 찾아내서 데이터를 삽입하는 함수가 아니다.
( find and insert 였다면 O(1) 가 아닌 O(n) 이였을 것이다. )
p라는 위치가 주어지지 않았다면 find and insert 방식으로 진행한다.
즉, 맨 앞에서 부터 원하는 지점까지 도달할때 까지( = 원하는 데이터를 가지는 노드를 찾을 때까지) find를 해야하므로 O(n) 이 걸린다.
=> ex) find and insert 방식으로 "홍길동" 데이터를 가지는 노드 찾고, 그 노드 앞에다 새로운 노드 "안녕" 삽입하기
노드형 포인터 tmp 를 하나 만들고,
head 에서 부터 tmp = tmp->next; 연산을 계속 반복문으로 진행하면서
tmp의 데이터가 "홍길동" 일때 까지 반복문을 돈다. 결국 tmp의 데이터가 홍길동이 될때 break 가 되고 홍길동 노드를 찾은 것이다.
홍길동 노드의 주소를 tmp 가 저장하고 있게 된다.
그러면 tmp의 prev 가 그 앞의 노드고, tmp 의 prev와 tmp 사이에 새로운 노드를(이때 노드의 데이터 값은 "안녕") 삽입하면 된다.
이 경우는 최악의 경우 O(n)이 걸린다.
=> 여기서 우리가 배우는 insert 연산은 이러한 find and insert 연산과 다르다!!
erase(p) 구현하기
O(1)
더블리 링크드 리스트에서 position를줬을 때 그 position 앞에 새로운 노드를 insert 하거나 그 주소에 있는 것을 delete 하는 것은 모두 O(1) 이 걸린다.
마찬가지로 find and erase 연산이라면 최악의 경우 O(n) 이 걸린다.
교수님 설명 수도코드
p.prev.next = p.next
p.next.prev = p.prev
delete p ( 또는 free p )
n = n - 1
ppt 수도코드
u <-> p <-> v 인 링크드리스트에서 p를 삭제시키고
u <-> v 인 상태로 만드는 삭제 연산이다.
Algorithm erase(p):
u = p -> prev // O(1)
v = p -> next // O(1)
u -> next = v {linking out p} // O(1)
v -> prev = u // O(1)
공간 : O(n) 사용
List ADT : 리스트의 모든 연산은 O(1) 이 걸린다.
Postition ADT : Position을 주고 그곳에 access 하는 것은 O(1) 이 걸린다.
position 을 그냥 포인터, 즉 주소(address) 라고 생각하자!
싱글리 링크드 리스트는 insert 할 특정위치(position) (=insert할 특정주소) 가 주어졌다고 한들, 그 뒤에다 insert 하는건 O(1) 일지 몰라도 그 앞에다 insert 하는것은 O(n) 이 걸린다 => position이 주어져도 find and insert 가 진행된다.
싱글리/더블리 링크드 리스트 모두 특정 위치(position) ( = insert할 특정 주소)가 주어지면 insert 연산이 O(1)이 된다.
특정 위치가 주어지지 않으면 그 특정 위치를 찾는 과정이 포함되기 때문에 O(n) 이 걸린다.
이전에 다루었던 싱글리 리스트는 position 값이 주어지지 않고,
head 와 tail 밖에 주어지지 않았다.
head 와 tail 은 유일하게 position이 주어졌기 때문에 head 와 tail 에 insert하는 연산은 O(1) 이다.
다만, 싱글리 리스트에서 tail에 대한 delete 연산은 그 앞에 직전노드를 못찾기 때문에 O(n)이 걸린다.
더블리 리스트처럼 tail의 앞 노드를 찾는 방법이 있다면 delete/insert 연산이 O(1) 이 걸린다.
심지어 싱글리 리스트도 임의의 위치의 다음에다 insert 하는 것은 O(1)이 걸린다.
그러나 임의의 위치 앞에다 insert 하는 것은 O(n)이 걸린다. (그 앞 위치를 바로 못찾으니까)
이전의 싱글리 링크드 리스트는 insert는 특정 위치를 찾는 과정을 포함하기 때문에 O(n) 이지만,
여기서는 특정 위치 찾는 과정이 제외되었기 때문에 O(1) 이다.
head와 tail 에 insert 하는 연산은 O(1) 이다.
다만 tail 바로 앞 노드에 대한 insert 와 delete 는 tail 바로 앞의 위치를 못찾기 때문에 O(n) 이 걸린다. (싱글리 리스트는 prev 를 가지고 있지 않기때문)
=> 더블리 링크드 리스트는 tail 바로 앞 노드를 알고 있기 때문에 insert 와 delete 가 O(1) 이 걸린다!
싱글리 링크드 리스트 insert/delete => p앞에 insert/delete 하는 경우 : O(n)
싱글리 링크드 리스트 insert/delete => p뒤에 insert/delete 하는 경우 : O(1)
더블리 링크드 리스트 insert/delete => p앞에 insert/delete 하는 경우 : O(1)
더블리 링크드 리스트 insert/delete => p뒤에 insert/delete 하는 경우 : O(1)
임의의 위치(position) (=insert 또는 delete할 주소) 가 없는 경우는, 그래도 유일하게 head 와 tail 이라는 position만을 보유하고 있다.
싱글리 링크드 리스트 중간 어떤 위치에 대한 insert / delete : O(n)
싱글리 리스트 head 또는 head 뒤에 insert : O(1)
싱글리 리스트 tail 에 insert : O(1) => append() 메소드임
싱글리 리스트 tail 을 delete : O(n)
싱글리 링크드 리스트 tail 바로 앞에 insert / delete : O(n)