k번째 원소를 확인/변경하기 위해 O(k)가 필요하다
배열과 다르게 O(1) 접근이 불가
임의의 위치에 원소를 추가/삭제 = O(1)
원소들이 메모리 상에 연속해있지 않아 Cache hit rate가 낮지만 할당이 다소 쉽다
배열 | 연결리스트 | |
---|---|---|
k번째 원소 접근 | O(1) | O(k) |
임의 위치 원소 추가/제거 | O(N) | O(1) |
메모리 상의 배치 | 연속 | 불연속 |
추가적으로 필요한 공간(Overhead) | - | O(N) |
연결리스트에서 임의 위치 원소에서 추가/제거 를 하는 것은 O(1) 이지만 해당 위치까지 이동을 처음부터 해야하므로 결국 O(N) + O(1) -> O(N)이 된다
배열은 추가적으로 필요한 공간이 없다, 굳이 필요하면 배열의 길이 정보를 저장할 int 변수 1개 정도가 있다
연결리스트는 이전, 다음 원소의 주소값을 가지고 있어야 한다
-> ex> 64비트 컴퓨터라면 주소값이64비트(8바이트) 단위이므로 8N 바이트가 추가로 필요하게 된다
-> N에 비례하는 만큼의 메모리를 추가로 사용하게 된다
struct NODE{
struct NODE *prev, *next;
int data;
}
연결리스트의 구현은 위와 같이 노드 구조체 혹은 클래스를 만들어서 원소가 생성될 때 동적할당을 하는 방식이다
but 시간이 중요한 코딩테스트에서는 STL list를 사용해보도록 하자
#include <bits/stdc++.h>
using namespace std;
int main(void) {
list<int> L = {1,2}; // 1 2
cout << L.size() << '\n';
list<int>::iterator t = L.begin(); // t는 1을 가리키는 중
L.push_front(10); // 10 1 2
cout << *t << '\n'; // t가 가리키는 값 = 1을 출력
L.push_back(5); // 10 1 2 5
L.insert(t, 6); // t가 가리키는 곳 앞에 6을 삽입, 10 6 1 2 5
t++; // t를 1칸 앞으로 전진, 현재 t가 가리키는 값은 2
t = L.erase(t); // t가 가리키는 값을 제거, 그 다음 원소인 5의 위치를 반환
// 10 6 1 5, t가 가리키는 값은 5
cout << *t << '\n'; // 5
for(auto i : L) cout << i << ' ';
cout << '\n';
// c++ 11 미만이라면 아래와 같이 순회 구현
for(list<int>::iterator it = L.begin(); it != L.end(); it++)
cout << *it << ' ';
}
#include <string>
#include <vector>
#include <list>
#include <iostream>
using namespace std;
int main() {
list<int> l;
list<int>::iterator iter;
iter = l.begin();
l.push_back(1);
l.push_back(2);
l.push_back(3);
l.push_back(2);
for(iter = l.begin(); iter != l.end(); iter++) {
if(*iter == 3)
break;
}
l.erase(iter); // iter은 현재 3 가리키고 있음
for (iter = l.begin(); iter != l.end(); iter++)
{
cout << *iter << "\n";
}
cout << "/////\n";
cout << l.front() << "\n"; // 맨 앞, 뒤 원소
cout << l.back() << "\n";
return 0;
}
// 출력
1
2
2
/////
1
2
-> 동일한 노드가 나올 때 까지 계속 다음 노드로 이동한다
공간복잡도 O(1), 시간복잡도 O(N)
시작점을 어딘가에 따로 하나 저장해두고 계속 다음 노드로 이동하면 된다!
중간에 만나는 두 연결리스트의 시작점들이 주어졌을 때 만나는 지점을 구하는 방법은?
-> 두 시작점 각각에 대해 끝까지 이동을 시켜 각 길이를 구한다
다시 두 시작점으로 돌아와 긴쪽은 둘의 차이만큼 먼저 이동을 시켜놓은 다음 두 시작점이 만날 때 까지 한 칸씩 이동을 시킨다.
공간복잡도 O(1), 시간복잡도 O(A+B)
연결리스트 안에 사이클이 있는지 판단하기
-> Floyd's cycle-finding algorithm
한 칸씩 이동하는 커서, 두 칸씩 이동하는 커서를 동일 시작점에서 이동을 시작하면 사이클이 있는 경우 반드시 두 커서가 만나게된다
사이클이 없는 경우는 두 커서가 만나지 못하고 연결리스트의 끝에 도달한다
위 방식 사용 시 거치는 노드를 모두 저장할 필요가 없다!
공간복잡도 O(1), 시간복잡도 O(N)
list를 사용하며 탐색, 삽입, 삭제 시 iterator를 유용하게 사용할 수 있다
list <int> L;
if (L.begin() == L.end())
printf("equal\n"); // here
else
printf("differ\n");
list<int>::iterator beg = L.begin();
list<int>::iterator en = L.end();
if (beg == en)
printf("beg == en\n"); // here
else
printf("beg != en\n");
L.insert(L.end(), 1);
if (beg == en)
printf("beg == en\n"); // here
else
printf("beg != en\n");
if (en == L.begin())
printf("en == L.begin()\n");
else
printf("en != L.begin()\n"); // here
if (beg == L.begin())
printf("beg == L.begin()\n");
else
printf("beg != L.begin()\n"); // here
if(en == L.end())
printf("en == L.end()\n"); // here
else
printf("en != L.end()\n");
if (beg == L.end())
printf("beg == L.end()\n"); // here
else
printf("beg != L.end()\n");
iterator를 활용하여 list.insert, erase를 하는 경우를 그림으로 나타낸 것이다
insert의 2번 예제의 경우 iterator가 b를 가리키고 있는 상태이다
이때 list.insert(iterator, 'c')를 하면 기존 b의 위치에 c가 삽입이 된다
위 iterator는 변하지 않고 b를 가리키는 값이 유지된다!
erase의 경우 현재 iterator가 가리키고 있는 위치의 값을 삭제한다
erase 2번 예제에서 b를 가리키고 있으므로 b가 삭제가 되며 iterator가 결과적으로 end값이 된다
기존에 b 다음에 다른 원소가 있었다면 iterator가 b를 가리키다가 b가 삭제되면서 b 다음에 있던 원소를 가리키게 된다
!! 1번 예제에서 처럼 iterator가 현재 end를 가리키고 있는 상태에서 erase를 하면 에러가 발생하므로 주의하자!!!