엄청 오랜만에 블로그에 글을 올린다.
그동안 많은 일이 있었다.
먼저 나는 개인의 역량 성장을 기대하면서 다우기술 인턴을 거쳐, 한 스타트업 회사에서 감사하고, 즐거운 마음으로 DevOps 엔지니어로서 근무를 했었다.
그러나, 원치 않게 회사의 사정이 좋지 않게 되어 올해 권고사직을 하게 되었다.
그 후, 그동안 못보고 있던 정보처리기사 실기 시험 기회가 어느새 유효기간이 거의 임박하였고,
마지막으로 올해 초에 딱 1번 실기 시험을 응시할 수 있어 “후회없이 준비하고 응시하는 시험이 되자” 라는 목표를 세워 약 8주간 열심히 준비하였다.
그리고, 얼마 전에 정보처리기사 실기 시험을 쳤다.
정확한 결과는 6월 중순에 나와봐야 알겠지만, 가채점 해본 결과 커트라인을 넘었기 때문에, 합격을 할 것 같다. (제발 합격하게 해주세요 🙏)
앞으로는 이직 준비와 더불어서 기술 공부한 내용과 그동안 DevOps 엔지니어로 근무하면서 개인적으로 기록해놨던 내용들을 손봐서 블로그에 자주 올려보려고 한다.
]]>P.S. github.io 블로그에 검색 업데이트 안되는 등 이슈가 많아서 앞으로 올릴 글은 velog에 포스팅하기로 했다.
네트워크
인터넷
회사 혹은 소규모의 네트워크에서 전세계 네트워크와 연결된 상태
다양한 애플리케이션 서비스가 제공되고 있다
종단 시스템은 보통 ISP(Internet Service Provider)에 의해 연결
KT 등…

네트워크 토폴로지는 일종의 네트워크의 구성 형태를 말함.

Ring형: 기기끼리 서로를 연결, 일종의 Ring처럼 보임.
- 장점: 기기와 기기간의 연결이기 때문에, 회선 설치가 수월하다.
- 단점: 어떤 장비에 불량이 생겼을 경우 해당 특정 장비로서 통신하기 어려울 수 있다.
Bus형: 하나의 메인 회선을 설치하고, 메인 회선에다가 전부 달아놓는 형태.

Star형: 가정에서 많이 볼 수 있음. 허브가 있고, 허브 포트에 기기를 전부 연결하는 형태. 다만, 허브의 위치에 따라서 회선을 전부 끌어와야되기 때문에 설치가 불편할 수 있다.
Mesh형: 회선의 종류가 많다. 하나의 장비가 여러개 회선을 가지고 다수의 장비에 연결될 수 있다. 실제로 가장 빠르고, 만약에 특정 장비 하나가 고장나더라도 통신이 가능. 데이터를 보낼 수 있는 경로가 많아지는데, 문제는 설치 내용이 많아지므로 복잡해진다.
OSI 7계층 vs TCP/IP


물리 층의 주요기능
인터페이스와 매체의 물리적인 특성 : 장치와 전송매체 간의 인터페이스 특성을 규정
비트의 표현 : 비트를 전송하기 위해 전기적 또는 광학적인 신호로 부호화
데이터 속도 : 신호가 유지되는 비트의 주기를 규정
비트의 동기화 : 송신자와 수신자는 같은 클록을 사용

CS8900A의 예

MAC(Medium Access Control) 필요
자유경쟁 (선착순)
2개의 전달하고자 하는 양 단에서 이진 비트로 뭔가 보낼 때 상대방도 뭔가 보낸다면 충돌이 발생, 그래서 회선을 경쟁적으로 쓰도록 함.(자유경쟁) 아래는 경쟁을 위한 알고리즘.
Aloha
Slotted Aloha - 간격 할당
CSMA(Carrier Sense Multiple Access)
CSMA/CD(CSMA/Collison Detection)
충돌을 감지한 다음에 일정 시간을 대기했다가 다시 보내는 형태
Token

CSMA/CD → IEEE 802.3
한 slot의 크기 잼 신호
= 51.2 us = 64byte 전송소요 시간
오름차순 정렬되어 있는 리스트 내에서 특정 값의 인덱스를 찾는 알고리즘
main > java > sort > BinarySearch.java
package sort;
public class BinarySearch {
public int search(int[] arr, int target) { // arr은 이미 정렬되어있음이 보장되어있는 array 데이터 집합
// 1. 데이터의 중간 인덱스 값을 찾는다.
// 2. 중간 인덱스 위치를 기준으로 arr 을 절반으로 나눈다.
// 3. 나눠진 절반의 리스트에서 target 값을 찾는다.
int l = 0; // left
int r = arr.length - 1; // right
int m;
// left가 right보다 작을 때까지만 수행,
while (l <= r) { // l이 r보다 커진다는 것은 사실상 모든 arr의 데이터를 다 봤다는 의미
// 중간 인덱스 찾기
m = l + ((r - l) / 2); // overflow exception 막기 위해 이렇게 구함... 이렇게 하면, 똑같이 l과 r의 중간 인덱스가 나오지만 overflow exception 방지
// (l + r) / 2 를 해서 중간 인덱스를 구할 수도 있겠지만, int는 2**-31 ~ 2**31-1의 값까지만 저장이 가능
// 그렇기 때문에, 각각 l과 r의 범위가 int안에 있다 하더라도 이 숫자의 합이 int의 범위가 발생하면 overflow exception 발생
if (arr[m] == target) { // arr[m]이 우리가 찾고자 하는 타겟 값과 동일하다면,
return m;
}
// target 값이 아닌 경우는 2가지 케이스 존재
if (arr[m] < target) { // target 값이 더 큰 경우
l = m + 1; // m을 기준으로 오른쪽에 있는 값들만 보면 되므로 l의 인덱스는 m + 1
} else { // target 값이 더 작은 경우
r = m - 1; // m을 기준으로 해서 arr의 왼쪽을 봐주면 되기 때문에, r을 m - 1로 셋팅
}
}
return -1;
}
}
안정(stable) 정렬 vs 불안정(unstable) 정렬
중복된 값의 순서 보장 여부

실제로는 동일한 1이지만, 구분하기 위해 1a, 1b 으로 표시
이 리스트를 정렬했을 때는,

위 그림과 같이, 2가지 경우가 가능
위의 경우는 동일한 1에 대해서 처음에 a, b 순서가 동일하게 유지되었고, 아래 케이스도 정렬은 되었지만 숫자 1의 순서는 다르다.
이렇게 중복된 값의 순서 보장 여부에 따라서, 순서가 보장이 되는 경우에는 ‘안정 정렬’, 보장이 되지 않는 경우에는 ‘불안정 정렬’이라고 한다.

In-place 정렬 vs Out-of-place 정렬
원본 데이터 내 정렬 여부
- In-place 정렬 : 원본 데이터 내에서 정렬이 이루어지는 경우
- Out-of-place 정렬 : 원본 데이터가 아닌 새로운 배열로 정렬된 output 결과를 만드는 경우
그래서, 정렬을 볼 때에는 정렬의 시간 복잡도와 안정 정렬 여부 그리고, 구현 방식 이렇게 3가지 관점에서 차이를 비교해볼 수 있다.
리스트의 처음부터 끝까지 이러한 싸이클을 1번 반복하게 되면, 이 리스트의 가장 끝에는 리스트 안에 존재하는 가장 큰 값이 위치하게된다. 하지만, 이 값을 제외한 앞의 값들은 여전히 정렬되지 않은 상태이므로, 다시 처음부터 비교하고 위치를 바꿔주는 연산을 실시한다.
이런 식으로 리스트 끝까지 반복하게 되면, 마찬가지로 마지막 인덱스의 바로 앞에는
이번 수행에서 가장 큰 값이었던 7이 위치하게된다.
그러면, 이 마지막 두 칸을 제외하고, 다시 처음부터 앞에서 비교와 위치를 바꾸는 swap 연산을 수행을 하게 된다.
이렇게 한 싸이클을 마무리 할 때마다, 현재 리스트에 위치한 가장 큰 값이 뒤에 하나 하나씩 쌓이게 될 것이다.
그래서, 마지막으로 정렬해야 할 데이터의 크기가 하나가 된다면,
배열 내에 모든 데이터가 정렬된 상태가 될 것이다.
다시 정리를 해보자면,
두 값이 정렬되어 있지 않다면 위치를 교환
시간복잡도 $O(N^2)$
직관적이고 단순한 알고리즘
그러나, Bubble sort는 느리기 때문에, 실제로 사용하기는 다소 어렵다는 단점도 존재함.
![]() |
|---|
| 출처 : 위키백과 |
정렬되는 모양이 마치 거품이 있는 것 같다고해서 버블 정렬이라는 이름이 붙음
BubbleSort.java
package sort;
public class BubbleSort implements ISort {
@Override
public void sort(int[] arr) {
// 안정 정렬
// 인플레이스 정렬
for (int i = 0; i < arr.length - 1; i++) { // 전체 리스트
for (int j=0; j < arr.length-1-i; j++) { // 정렬된 리스트를 제외시키고 반복
if (arr[j] > arr[j + 1]) { // 앞에 있는 값이 뒤에 값보다 크면, 값을 바꿔줘야하는 경우
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
}
}
근데, 우리는 정렬되어있지 않은 리스트를 정렬하려고 하는건데 그렇다면, 이미 정렬된 서브 리스트는 어디서 나오는걸지부터 생각해봐야한다.
만약에 사이즈가 1인 배열에 있다면 이 배열에는 어떤 값이 들어있어도 정렬된 상태라고 볼 수 있다. 배열이 1칸이라면 그 안에 5든, 1이든, 100이든 어떤 값이 들어있든 이 리스트는 정렬이 되어있는 상태인 것이다. 그래서, 삽입 정렬은 여기에서 출발하게된다.
리스트에서 가장 앞에 있는 하나의 원소를 이미 정렬된 서브 리스트로 보고, 정렬을 시작하게된다.
그렇기 때문에, 실질적인 정렬 로직은 리스트의 2번째(index로는 1번째) 위치에 있는 값부터 정렬을 시작하게된다.
이미 정렬되어있는 서브 리스트 내에서 값 비교를 통해서 자신이 삽입되어야 할 위치 (여기서는 0번째 인덱스에 숫자 4가 삽입된 모습). 4는 5보다 작기 때문에 5의 앞에 삽입된 거라고 볼 수 있다.
그러면, 사이즈가 2인 정렬된 서브 리스트가 생기게 되는 것이다.
그렇다면, 이번에는 2번 인덱스에 위치한(3번째 값) 1을 정렬할 차례다. 1을 앞에 정렬된 서브 리스트 내의 원소들과 값을 비교해서 위치를 찾아보면,
1의 위치는 서브 리스트의 가장 앞이 되겠다. 이제 정렬된 서브 리스트의 크기는 총 3개가 되었다.
그 다음은 9의 위치를 찾을 차례다.
마찬가지로, 서브 리스트들과 값 비교를 했을 때 9의 위치를 탐색해보면, 9는 현재 위치에 그대로 있게 된다.
그래서, 4칸의 정렬된 서브 리스트를 얻게 되었다.
이런식으로, 리스트의 끝까지 순환하면서 자신의 위치를 찾아가게 되고, 리스트 전체를 순환하게 되면, 모든 원소들이 정렬된 제자리를 찾아가게 되는게 삽입 정렬의 기본 매커니즘이다.
출처 : 위키백과
그래서 삽입 정렬의 특징을 보자면,
단순한 알고리즘
평균 → $O(n^2)$
InsertionSort.java
package sort;
public class InsertionSort implements ISort {
@Override
public void sort(int[] arr) {
// int n = arr.length;
for (int i = 1; i < arr.length; i++) { // 1. 가장 앞에 있는 0번째 데이터는 이미 정렬되어있는 서브리스트로 보기 때문에 i=1부터 시작
// for (int i = 1; i < n; ++i) {
int key = arr[i]; // 2. 삽입 위치를 찾아줄 데이터
int j = i - 1; // 3. 0 ~ j까지가 정렬된 서브 리스트의 인덱스로 보는 것
// 6. 그런데 이 때, 키 값보다 정렬된 배열의 값이 클 때까지만 해주면 됨, 데이터를 이동하는 작업을 0번까지 할 필요도 없음. (아래)
while (j >= 0 && arr[j] > key) { // 6. 그래서, arr[j]가 key보다 클 때까지 라는 조건을 하나 더 추가
// 6. (그러면, key 값보다 정렬된 배열에 있는 값이 작아지면, 더 이상 값 이동을 하지 않게 됨)
arr[j + 1] = arr[j]; // 5. 데이터를 한 칸씩 오른쪽으로 이동
j = j - 1; // 4. 역순으로 값 비교 (이렇게 하면, j부터 0까지 역순으로 서브 리스트를 순환하게 됨)
}
arr[j + 1] = key; // 7. 그리고 그 위치에 arr[j + 1] = key 해주면, 해당 위치에 데이터가 삽입됨.
}
}
}
부분 리스트의 사이즈가 1이 될 때까지 반복해서 분할
분할된 부분 리스트들을 합치면서, 정렬을 할 때도 부분 리스트들을 한번에 다 정렬해서 결과를 얻는게 아니라 분할과 동일한 크기로 부분 리스트들을 합쳐나가게된다.
합치는 과정을 원래 크기의 배열이 될 때까지 반복해나감.
분할 정복 (Divide and Conquer) 알고리즘
![]() |
|---|
| 출처 : 위키백과 |
이렇게 하나의 문제를 동일한 유형의 작은 문제들로 분할한 다음에 작은 문제에 대한 결과들을 조합해서 큰 문제를 해결하는 방식의 알고리즘을 ‘분할 정복 (Divide and Conquer) 알고리즘’ 이라고 함.
분할 정복은 알고리즘 풀이 기법에 있어서 적지 않게 쓰이는 패턴이고, 분할 정복 알고리즘은 보통 재귀함수로 구현이 됐다는 특징이 있다.
그래서, Merge Sort도 재귀함수를 통해서 구현을 하게 될 것이다.
시간복잡도 $O(NlogN)$
우선 분할 과정은 한번 시행할 때마다 리스트의 크기가 2분의 1씩 감소를 하게된다. 그래서, 처음 n개의 element에서 한 번 분할을 하게 되면 2분의 n개의 리스트 2개를 얻게 되고, 그 상태에서 또 분할을 수행하게 되면 4분의 n크기의 서브 리스트 4개를 얻을 수 있게된다.
이러한 분할 연산은 배열의 크기가 1이 될 때까지 반복해야되는데, 리스트의 크기가 2라면 이 분할은 1번 그리고, 4개라면 2번, 리스트의 개수가 총 8개라면 3번을 반복해야 크기가 1인 부분 리스트들을 얻을 수 있다.
즉, n개의 element가 있는 상황에서 n이 2k 이라면 밑이 2인 log를 취했을 때, k만큼 반복해야 크기가 1인 배열로 분할가능하다는 추론이 가능할 것이다.
그래서, 분할 연산에서 $logN$의 시간 복잡도를 소요하게된다.
그리고, 분할이 된 상태에서 다시 배열을 합칠 때 각 element들을 비교하면서 정렬을 하기 때문에, 비교 연산이 수행되어야한다. 이 비교 연산도 시간 복잡도에 포함이 된다.
그래서, 리스트의 크기가 1인 상태일 때 크기가 1인 배열이 총 n개가 있게 될 것이다. 이 n개의 크기를 비교하면서, 2개씩 합쳐진 상태의 리스트를 만들거기 때문에, n번의 비교 연산이 발생하게된다.
그리고, $\frac{n}{4}$크기의 배열이 4개인 분할 상태에서도, $\frac{n}{4}$크기의 서브 리스트 4개의 개수만큼 비교 연산을 또 수행한다. 결국, 마찬가지로 n번의 비교 연산이 발생한다는 의미다.
그리고, 서브 리스트의 크기가 $\frac{n}{2}$개일 때도, 서브 리스트의 개수는 총 2개고, 이걸 다시 크기가 n인 배열 하나로 합칠 것이다. 결국, 각 depth에서 n개의 element만큼 크기 비교 연산을 수행하게된다.
그래서, 분할 연산을 할 때 총 depth가 $logN$이라는 값을 구할 수 있었고, 각 depth마다 비교 연산을 n번 수행하게 되기 때문에, Merge Sort의 시간 복잡도는 $NlogN$이 된다.
MergeSort.java
Merge Sort를 구현할 수 있는 방법에는 in-place sort로 구현하는 방법과 out-of-place sort로 구현하는 방법 2가지가 있는데, 여기서는 in-place sort 방법으로 구현한다.
out-of-place sort는 in-place sort보다 난이도가 상대적으로 더 쉽기 때문에, in-place sort를 할 줄 알면 out-of-place sort 혼자서도 충분히 할 수 있을거라고 함.
package sort;
public class MergeSort implements ISort {
// 안정 정렬
@Override
public void sort(int[] arr) {
// 1. in-place sort
mergeSort(arr, 0, arr.length - 1); // 정렬해야할 arr, 정렬할 인덱스를 넣어줌(0번째, arr의 가장 끝 인덱스)
}
// 2. 분할하는 작업
private void mergeSort(int[] arr, int low, int high) {
// 2-5. 그리고, merge sort에서는 종료 조건을 넣어주는게 중요하다.
// (종료 조건을 제대로 넣어주지 않으면 무한하게 호출될 수 있는 위험성 존재)
if (low >= high) { // low 와 high 의 인덱스가 같다는 것 -> 배열의 크기가 1이 되는 경우
return;
}
// 2-1. 분할을 할 때는 절반씩 분할을 하기 때문에, 중간값을 찾는 과정이 필요하다.
// 실제로 arr 을 쪼개서 수많은 배열로 따로 만드는 게 아니라
// arr 가 둘씩 분할되는 인덱스 위치를 찾음
int mid = low + ((high - low) / 2); // 2-2. 리스트의 중간 위치 인덱스. 중간 인덱스를 기준으로 분할
mergeSort(arr, low, mid); // 2-3. (재귀 호출) // 중간 인덱스를 기준으로 왼쪽 부분
mergeSort(arr, mid + 1, high); // 2-4. (재귀 호출) // 중간 인덱스를 기준으로 오른쪽 부분,
// 이렇게 하면 동일한 크기의 절반씩 인덱스가 나올 것임
// low, mid, high 에는 분할된 인덱스의 값이 있음
merge(arr, low, mid, high); // 2-6. merge 하면서 정렬
}
// 3. 합병하면서 정렬하는 작업
private void merge(int arr[], int low, int mid, int high) {
// 3-1. 배열을 합칠 때는 보조 배열을 하나 두고 사용하게됨.
int[] temp = new int[high - low + 1]; // 보조 배열
int idx = 0; // 보조 배열의 인덱스
int left = low; // 3-2. 분할된 왼쪽 리스트의 시작 인덱스
int right = mid + 1; // 3-3. 분할된 오른쪽 리스트의 시작 인덱스
while (left <= mid && right <= high) { // 3-4. left가 mid보다 작거나 같을 때 그리고, right가 high보다 작거나 같을 때까지 합병
// left 나 right 인덱스 둘중 하나라도 리스트에 있는 값을 모두 꺼내게 되면, while 문이 종료
if (arr[left] <= arr[right]) { // 3-5. 오름차순 정렬해서 데이터를 쌓아야 하므로 작은 값부터
temp[idx] = arr[left];
left++;
} else { // 3-6. arr[right]가 arr[left]보다 더 작은 값인 경우
temp[idx] = arr[right];
right++;
}
idx++;
}
// 3-7. 그리고, 둘 중 하나라도 리스트에 있는 값을 꺼내면 while문이 종료가 되기 때문에,
// 왼쪽 리스트나 오른쪽 리스트에 아직 값이 남아 있을 수가 있다.
while (left <= mid) { // 왼쪽 리스트에 아직 값이 남아 있는 경우
temp[idx] = arr[left];
idx++;
left++;
} // 이렇게 하면, 남아있는 값을 모두 temp array에 쌓게 될 것임.
while (right <= high) { // 오른쪽 리스트에 아직 값이 남아 있는 경우
temp[idx] = arr[right];
idx++;
right++;
}
// ▲ 만약에 left나 right 둘 중 하나가 자신의 리스트를 다 소진했다면, 이 2, 3번째 while문을 굳이 타진 않을 것이다.
// 3-8. 여기까지 했을 때, temp라는 보조 배열에는 우리가 값을 합쳐서 정렬한 값이 들어있을 것이기 때문에,
// temp 라는 보조배열에 있는 값을 arr 로 복사
for (int i = low; i <= high; i++) {
arr[i] = temp[i - low];
}
}
}
시간복잡도 $O(NlogN)$
퀵 정렬도 Merge Sort와 마찬가지로, 배열을 둘씩 분할하면서 정렬하는 과정을 거치기 때문에 시간복잡도는 $O(NlogN)$이 됨.
하지만, 같은 $O(NlogN)$이더라도 실제 정렬에는 훨씬 더 짧은 시간이 소요됨.
참조 지역성(locality of reference)
컴퓨터의 하드웨어 특성 때문에 더 빠른 성질을 가질 수 있게 된다.
참조 지역성(locality of reference)의 원리로 설명하게 되는데, 퀵 정렬은 알고리즘 특성상 동일한 배열 내에서 자리를 이동시킴.
그래서, 인접한 데이터들 사이에 이동이 발생하기 때문에, 제일 처음 배열에 접근할 때만 실제 메모리에서 데이터를 가져오고, 이후에는 캐시로 배열에 접근할 수 있기 때문에, 메모리로 접근할 때보다 빠른 속도로 접근이 가능.
한번 결정된 pivot 값은 이후의 연산에서 제외
정렬이 진행될수록(분할이 될수록), 계산해야할 데이터의 수가 점점 줄어드는 특성이 있음.
계산해야할 데이터의 수가 줄어들면, 당연히 정렬 속도를 줄이는데도 영향을 줌.
추가적인 메모리 공간 사용 X
불안정 정렬
Divide and conquer
퀵 정렬은 pivot값을 정하는 것부터 시작을 하게 된다. pivot값으로 정할 인덱스는 리스트에서 가장 앞에 위치한 원소여도 되고, 가장 뒤에 위치한 원소여도 된다. 혹은 위 그림처럼 가장 중간에 위치한 원소로 설정할 수도 있다.
그래서, pivot값을 기준으로 원소를 재배치하게 된다. pivot값의 왼쪽에는 pivot값보다 작은 값이 위치하게되고, pivot값의 오른쪽에는 pivot값보다 큰 값이 위치하게된다. 이 때, pivot값 자체가 중요한거라서 pivot값의 인덱스는 바뀔 수가 있다.
pivot값을 기준으로 생긴 왼쪽과 오른쪽 2개의 서브리스트를 볼 수 있는데, 이 때 각각 서브리스트에서도 마찬가지로 앞에서 했던 로직을 반복해준다.
그래서, 왼쪽의 서브리스트에서는 pivot값이 중간에 있는 1이 되고, 오른쪽에서는 pivot값을 6으로 설정할 수 있다.
마찬가지로, 정해진 pivot값을 기준으로 pivot값의 왼쪽에는 pivot값보다 작은 값, 오른쪽에는 pivot값보다 큰 값을 넣어주면 위 그림과 같이 다시 재정렬이 이뤄질 것이다.
이제 오른쪽의 서브리스트는 원소의 개수가 하나만 남았기 때문에, 더이상 분할할 수 없는 단위가 됐으므로, 오른쪽의 서브리스트는 퀵 정렬이 종료됨.
왼쪽에는 아직 여전히 서브리스트가 남아있기 때문에, 여기서도 pivot값을 구해서 정렬한다.
여기서 pivot값은 서브리스트의 중간에 위치한 값인 3이 될 수 있다.
3을 기준으로 3보다 작은 값은 pivot값의 왼쪽으로, 큰 값은 pivot의 오른쪽으로 위치시켜준다.그러면, 각 서브리스트의 크기도 여기서 1이 되기 때문에, 더이상 분할할 수 있는 서브리스트도 없고, 실제로 각 element들도 아래와 같이 순서대로 정렬된 모습을 볼 수 있다.
출처: 위키백과 퀵 정렬의 데이터가 이동하는 모습은 위와 같다.
여기서는 pivot값을 중간값이 아니라 서브리스트에서 가장 오른쪽에 위치한 값을 pivot값으로 설정했을 때 퀵 정렬이 이뤄지는 모습이다.
평균 → $O(NlogN)$
데이터를 절반씩 분할하면서 줄여나가기 때문에, $logN$ 만큼의 분할이 이뤄지게 되고, 각 분할에서 $N$번의 값 비교를 통해서 자신의 위치를 찾아가기 때문에, 시간 복잡도가 $NlogN$이 나옴
최악
정렬된 리스트 → $O(N^2)$
최악의 경우, 정렬하고자 하는 리스트가 이미 정렬된 상태라면, 시간 복잡도는 $N^2$까지 늘어날 수 있게 된다.
예를 들어서, 오름차순 되어있는 리스트에서 위키백과 gif 이미지처럼
리스트의 가장 앞이나 가장 뒤에 있는 값을 pivot값으로 정하게 되면, pivot값을 기준으로 리스트를 분할하게 될텐데
분할 결과를 보면 이런식으로 될 것이다. 왜냐하면, pivot값의 왼쪽에 넣을 작은 값이 없기 때문이다.
그러면 여기서 동일한 기준으로 pivot값이 결정될 것이기 때문에,
이런식으로 분할이 될 것이다.
다시 말해서, 이 배열의 pivot값이 리스트에서 최솟값이나 최대값으로 지정이 되어버려서 분할이 절반씩 이뤄지지 않는 상태가 될 때 퀵 정렬이 효율을 내지 못한다고 볼 수 있다.
그러면, 절반씩 분할할 때 $logN$번의 분할 횟수가 나온다는 것을 도출을 해서, $NlogN$이라는 시간 복잡도가 나왔던건데, 분할 횟수가 데이터의 갯수만큼 즉, $N$번만큼 발생하게 되므로, $logN$이 $N$으로 증가하게 되고, 그래서 시간 복잡도가 $N^2$ 만큼이 나오게 되는 것이다.
이러한 치우침 분할을 극복하기 위해서, pivot값을 고를 때 몇 가지 pivot값의 후보군을 두고, 그 중에 가장 중간값을 찾아서 pivot으로 사용하는 알고리즘을 쓰기도 한다. (이것을 Median of Three라고 함)
우리는 단순하게 가장 중간 인덱스에 위치한 값을 pivot으로 선택해서 구현.
QuickSort.java
package sort;
public class QuickSort implements ISort {
@Override
public void sort(int[] arr) { // 1.
// 재귀호출하면서 정렬
quickSort(arr, 0, arr.length - 1); // 정렬할 배열, 가장 낮은 인덱스, 가장 큰 인덱스
}
private void quickSort(int[] arr, int low, int high) {
// 2.
// 분할된 배열의 크기가 1이 될 때까지 반복하도록
if (low >= high) { // low가 high보다 크거나 같아지면,
return; // 퀵 소트 종료
}
// 3.
// pivot값의 인덱스를 찾아줌
int pivot = low + ((high - low) / 2); // pivot의 인덱스는 중간값을 가져옴
// pivot 인덱스에 위치한 값 pivotValue
int pivotValue = arr[pivot];
// 4.
int left = low;
int right = high;
// 피봇값을 기준으로 피봇값의 왼쪽에는 피봇값보다 작은 값
// 오른쪽엔 피봇값보다 큰 값을 넣는 작업
while (left <= right) { // left가 right보다 작을 때까지
// 5. 왼쪽 값이 피봇값보다 작으면
while (arr[left] < pivotValue) {
left++; // 왼쪽값이 pivot값보다 작은 경우이므로 위치를 바꿀 필요가 없으니 그대로 두고, 왼쪽 인덱스를 증가시킴
}
// 6. (오른쪽에서도 동일한 동작 진행)
// 오른쪽 값이 피봇값보다 크면
while (arr[right] > pivotValue) {
right--; // 위치를 바꿀 필요가 없기 때문에 계속 오른쪽 인덱스를 감소
}
// 7. 왼쪽 인덱스와 오른쪽 인덱스가 교차 하지 않은 상황이라면, 왼쪽 값과 오른쪽 값 swap
if (left <= right) {
int tmp = arr[right];
arr[right] = arr[left]; // arr[left]값을 arr[right]에 넣어주고,
arr[left] = tmp; // 원래 arr[right]에 들어있던 tmp값을 arr[left]에 넣어줌
left++; // left는 한 칸씩 증가이동 시킴.
right--; // right는 한 칸씩 감소이동 시킴.
}
} // 이렇게 해서 while문을 종료시키고나면, pivot값의 왼쪽에는 pivot값보다 작은 값이 들어있게 되고, pivot값의 오른쪽에는 pivot값보다 큰 값이 들어있게된다.
이것을 그림으로 보면,

예를 들어, pivot값을 5라고 설정했을 때, 처음 left의 인덱스는 1이 있는 위치까지 이동을 할 것이다. 그리고, right는 6까지만 오게 될 것이다.

왼쪽 인덱스와 오른쪽 인덱스가 교차하지 않은 상태이므로, 두 값을 swap해준다. (2와 5의 위치 바뀜)
그리고 swap을 하게되면,

다시 while문의 위로 돌아오게 될 것이다. 여기서 left는 한 칸 더 이동을 시킬 수 있게 되고, 오른쪽은 여전히 이동을 시키지 않은 상태가 된다. 여기서도, 아직 두 인덱스가 교차하지 않았기 때문에, 두 값을 swap한다.

이렇게해서, pivot값을 기준으로 왼쪽과 오른쪽으로 작은 값과 큰 값이 나눠지게 된다. while문이 빠져나왔을 때는 pivot을 제외한 왼쪽 리스트와 오른쪽 리스트에서 이 작업을 반복을 하게 되면, 퀵 정렬이 되겠다.
최종
맨 마지막 부분(quickSort 호출 부분) 두줄 추가됨.
package sort;
public class QuickSort implements ISort {
@Override
public void sort(int[] arr) { // 1.
// 재귀호출하면서 정렬
quickSort(arr, 0, arr.length - 1); // 정렬할 배열, 가장 낮은 인덱스, 가장 큰 인덱스
}
private void quickSort(int[] arr, int low, int high) {
// 2.
// 분할된 배열의 크기가 1이 될 때까지 반복하도록
if (low >= high) { // low가 high보다 크거나 같아지면,
return; // 퀵 소트 종료
}
// 3.
// pivot값의 인덱스를 찾아줌
int pivot = low + ((high - low) / 2); // pivot의 인덱스는 중간값을 가져옴
// pivot 인덱스에 위치한 값 pivotValue
int pivotValue = arr[pivot];
// 4.
int left = low;
int right = high;
// 피봇값을 기준으로 피봇값의 왼쪽에는 피봇값보다 작은 값
// 오른쪽엔 피봇값보다 큰 값을 넣는 작업
while (left <= right) { // left가 right보다 작을 때까지
// 5. 왼쪽 값이 피봇값보다 작으면
while (arr[left] < pivotValue) {
left++; // 왼쪽값이 pivot값보다 작은 경우이므로 위치를 바꿀 필요가 없으니 그대로 두고, 왼쪽 인덱스를 증가시킴
}
// 6. (오른쪽에서도 동일한 동작 진행)
// 오른쪽 값이 피봇값보다 크면
while (arr[right] > pivotValue) {
right--; // 위치를 바꿀 필요가 없기 때문에 계속 오른쪽 인덱스를 감소
}
// 7. 왼쪽 인덱스와 오른쪽 인덱스가 교차 하지 않은 상황이라면, 왼쪽 값과 오른쪽 값 swap
if (left <= right) {
int tmp = arr[right];
arr[right] = arr[left]; // arr[left]값을 arr[right]에 넣어주고,
arr[left] = tmp; // 원래 arr[right]에 들어있던 tmp값을 arr[left]에 넣어줌
left++; // left는 한 칸씩 증가이동 시킴.
right--; // right는 한 칸씩 감소이동 시킴.
}
} // 이렇게 해서 while문을 종료시키고나면, pivot값의 왼쪽에는 pivot값보다 작은 값이 들어있게 되고, pivot값의 오른쪽에는 pivot값보다 큰 값이 들어있게된다.
// 8. while문이 종료된 시점에서 quickSort를 한번 더 호출
quickSort(arr, low, right); // 왼쪽 서브리스트를 정렬시켜주고,
quickSort(arr, left, high); // 오른쪽 서브리스트 정렬
// 배열의 사이즈가 1이 될 때까지, quickSort를 재귀적으로 계속 호출한다.
}
}

KEY - VALUE 형식
KEY는 중복되지 않는 값으로 설정해야함.
VALUE는 KEY를 기준으로 관리되기 때문
main > java > Main.java
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
Map<String, String> map = new HashMap<>();
map.put("Hello", "World");
map.put("ABC", "DEF");
map.put("Computer", "Science");
System.out.println(map.get("Hello"));
System.out.println(map.get("ABC"));
System.out.println(map.get("Computer"));
}
}

임의의 데이터(키)를 특정 값(해시값)으로 매핑시키는 함수
![]() |
|---|
String의 hashCode() |
해시 함수를 통해 데이터의 위치가 관리되기 때문에, 리스트처럼 데이터를 전체 스캔을 하면서 찾을 필요가 없다. 따라서, Hash Function을 통한 데이터의 접근은 시간 복잡도 $O(1)$을 갖게 된다.
(위와 같이 각 문자열의 해시 값을 출력해봄)
좋은 해시 함수
키 값이 다른데, 해시 함수의 결과값이 동일한 경우

main > java > hashtable > MyLinkedHashTable.java
충돌이 발생했을 경우 chaining 방식을 이용해서 충돌을 해결하도록 할 것이다.
- 해시 충돌이 발생했을 경우 LinkedList를 이용한 chaining 방식으로 해결하도록 (가장 기본적인 방법)
- 데이터는 Node라는 이름의 key-value 쌍을 이뤄서 저장하도록 할 것이다.
(해시 맵과 해시 테이블은 같은 개념)
package hashtable;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
public class MyLinkedHashTable<K, V> implements IHashTable<K, V> {
private static final int DEFAULT_BUCKET_SIZE = 1024;
private List<Node>[] buckets; // hashTable -> chaining
private int size;
private int bucketSize;
// 2. 생성자
public MyLinkedHashTable() {
this.buckets = new List[DEFAULT_BUCKET_SIZE]; // 2의 10제곱
this.bucketSize = DEFAULT_BUCKET_SIZE;
this.size = 0;
// for문을 돌면서 버킷 칸을 다시 초기화 해줘야함
for (int i = 0; i < bucketSize; i++) {
this.buckets[i] = new LinkedList<>();
} // 이렇게 for문으로 버킷을 각 칸마다 초기화 해주지 않으면,
// 디폴트로 null이 들어가기 때문에, 데이터를 넣을 수가 없어서 초기화해줘야함
}
// 3. 사용자가 원하는 크기의 생성자를 만들 수도 있도록
public MyLinkedHashTable(int bucketSize) {
this.buckets = new List[bucketSize];
this.bucketSize = bucketSize;
this.size = 0;
for(int i = 0; i < bucketSize; i++) {
this.buckets[i] = new LinkedList<>();
}
}
// 5. 추가 연산
@Override
public void put(K key, V value) {
int idx = this.hash(key); // hash function을 이용해서 가져온 idx로 버킷에 접근하면,
List<Node> bucket = this.buckets[idx]; // 우리가 데이터를 넣어야할 LinkedList가 있는 버킷 위치가 나옴
for (Node node : bucket) {
// (중복된 key일 경우 새로 들어온 value로 덮어 쓰도록 처리)
if (node.key.equals(key)) {
node.data = value;
return;
}
}
Node node = new Node(key, value); // 그리고, 노드를 새로 생성을 해서,
bucket.add(node); // 버킷에다가 데이터를 넣어주면 됨
this.size++;
}
// 6. 데이터를 가져오는 연산
@Override
public V get(K key) {
int idx = this.hash(key);
List<Node> bucket = this.buckets[idx]; // hash function을 이용해서 가져온 index값으로 버킷에 조회, List<Node> bucket을 가져옴
for (Node node : bucket) { // 여러 개의 데이터가 있을 수 있기 때문에
// key가 동일하다면, (우리가 가져와야 할 값이기 때문에)
if (node.key.equals(key)) {
return node.data; // node.data를 가져오고
}
}
return null; // 끝까지 순환했는데도 데이터를 가져오지 못했다면 return null
}
// 7. 지우는 연산
@Override
public boolean delete(K key) {
int idx = this.hash(key);
List<Node> bucket = this.buckets[idx];
// Iterator는 데이터 집합에서 값을 하나씩 꺼내오면서 순환할 수 있도록 도와주는 객체
for (Iterator<Node> iter = bucket.iterator(); iter.hasNext(); ) { // iterator가 데이터가 남아 있지 않을 때까지 실행을 시킴
Node node = iter.next(); // 데이터를 하나씩 가져오는 것
if (node.key.equals(key)) { // 가져오면서 입력받은 키와 동일하다면
iter.remove();
this.size--;
return true; // 정상적 종료가 됐다면
}
}
return false; // 정상적 종료가 되지 않았다면
}
// 8. key를 입력으로 받아서 key의 데이터가 해시 테이블에 존재하는지, 아닌지 판별
@Override
public boolean contains(K key) {
int idx = this.hash(key);
List<Node> bucket = this.buckets[idx]; // 그 key값에 해당하는 버킷을 가져오고
for (Node node : bucket) {
// node의 key가 입력받은 key와 동일하다면,
if (node.key.equals(key)) {
return true; // 해당 데이터는 존재하다고 판별
}
}
return false; // for이 종료될 때까지 찾지 못한 경우
}
@Override
public int size() {
return this.size;
}
// 4. hash function : 반환하는 값은 나오는 인덱스 값을 반환하도록
private int hash(K key) { // key로 들어오는게 어떤 타입일지는 모름 generic이기 때문 (언어의 로우 레벨에서는 타입을 하나씩 타 들어가서 하기 때문에, 이거랑 다를 수 있음)
int hash = 0;
// 문자열 한 글자씩 순환을 하면서, 이 순환된 값을 아스키 int값으로 가져와서,
for (Character ch : key.toString().toCharArray()) {
hash += (int) ch; // 해시에다가 각 값을 더해줌 (hash값에 더함)
} // 해시 값을 더하면, 버킷 사이즈보다 값이 커질 수가 있으니
return hash % this.bucketSize; // 이 연산을 해서 어떤 값이 나오던 간에 무조건 버킷사이즈보다 작은 값이 나오도록 바꿔줌
} // 여러가지 방법들이 있지만, 이 방법이 로우 레벨의 자바나 파이썬 런타임에서 많이 쓰는 방식이기 때문
// 1.
private class Node {
K key;
V data;
Node(K key, V data) {
this.key = key;
this.data = data;
}
}
}

해시 충돌을 아예 피할 수 있으면 좋겠지만, 해시 충돌은 절대 피할 수 없다.
필연적으로 발생할 수 밖에 없는데, 컴퓨터 사이언스에서는 이 이유를 비둘기 집 원리로 설명한다.
### 비둘기 집 원리
$N + 1$개의 물건을 $N$개의 상자에 넣을 때 적어도 어느 한 상자에는 두 개 이상의 물건이 들어있다

임의의 사람 $N$명이 모였을 때 그 중에 생일이 같은 2명이 존재할 확률
생일의 가능한 가짓수는 366개
2월 29일 포함
366명이 모여야 생일이 같은 경우가 있을까?
실제로는 23명만 나와도 50.7%의 확률로 존재
50명인 경우, 97%

특정 키에 hash function으로 넣은 해시 값을 index화 해서 데이터를 저장함.

그런데, 다른 키를 넣었는데도 또, 똑같은 해시 값이 나왔다면 똑같은 인덱스 위치를 가리키게 될 것이다. 그리고 이 때, 이미 저장하고 있는 데이터가 있다면, 데이터가 저장된 버킷이 마치 LinkedList처럼 다음 노드를 가리키게 해서, 그 노드에 새로운 데이터를 저장을 하도록 만든다.

그리고, 또 다른 키에서 또 동일한 해시 값이 나온다면, 버킷에 또 새로운 노드를 연결해서 데이터를 삽입하게 될 것이다.
이 모습이 마치 체인이 연결된 것 같다고 해서, Chaining 방법이라고 부르는 것이다.

그래서, 해시 테이블 전체 모양을 보면, 위 그림과 같은 형태로 도식화 할 수 있다.
이런 Chaining 방식으로 해시 충돌을 회피하게 될 경우에는 결국, Hash Function으로 index의 위치를 찾는데 $O(1)$의 시간 복잡도가 걸린다고 해도, index의 위치에서 값을 가져올 때 해당 값을 찾기 위해 또 리스트를 하나씩 탐색하는 과정을 거쳐야 한다.
이 스캔 과정은 리스트에서 설명했다시피 뒤에 노드가 길어질수록 탐색 시간도 같이 늘어나는 $O(N)$의 시간 복잡도를 갖는다.
그렇기 때문에, 최악의 경우 해시 테이블임에도 $O(N)$의 시간 복잡도가 나올 수 있게 되는 것이다.
이렇게 리스트로 Chaining을 구현하는 것이 전통적인 Chaining 연결 방식이지만, 최근 자바에서는 리스트 대신 트리 자료구조를 써서 시간 복잡도를 조금 더 단축시키기도 했다.
충돌 발생시 다른 버킷에 데이터를 저장
선형 탐색
해시 충돌 시 $n$칸을 건너뛴 다음 버킷에 저장

예를 들어, 위 그림처럼 10번 ~ 19번까지 인덱스가 있을 때, 10, 14, 18번 인덱스에 데이터가 들어있는 상황이다.
근데 다른 키를 넣었을 때, 10번 인덱스와 동일한 인덱스를 가리키는 해시 값이 나왔지만, 10번 인덱스에는 이미 다른 데이터가 들어있으니까 이 버킷을 건너뛴 다음 11번 인덱스에 값을 저장하게 된다.
계산 단순
검색 시간이 많이 소요
만약에, 위 그림 설명 상황에서 11번 버킷에 데이터가 또 있는 상황이라면 1칸 더 건너 뛰어서 12번 인덱스로 들어가야 한다, 근데 또 12번에 있다면 13번..
이런 방식으로 버킷의 위치를 탐색하다보면, 결국 시간 복잡도가 $O(N)$까지 늘어날 수 있기 때문에 검색 시간이 많이 소요될 수 있다.
더 큰 문제는…
데이터들이 특정 위치에만 밀집(clustering)
데이터들이 특정 위치에만 밀집하게되는 경우가 생길 수 있다.
좋은 Hash Function은 키를 고르게 분포시킨다고 했는데, 데이터들이 밀집되지 않고 고르게 분포시키는게 왜 중요하냐면, 밀집 될수록, 충돌로 데이터의 위치를 재탐색해야할 일이 많아지기 때문이다. 이것은 결국 성능의 저하를 의미하게 되고, 데이터가 밀집되는 것은 결코 좋은 현상이 아니다.
예를 들어서, 이렇게 10번 인덱스에는 값이 있기 때문에, 11, 12번 위치의 인덱스까지 해시 충돌로 인해서 Open Addressing방식으로 다시 검색된 위치에 데이터가 들어있는 버킷이다.
이렇게 데이터가 들어가있는 상황에서 또 다른 키 값을 넣었을 때, 이번에는 인덱스의 위치가 12가 나오게 되는데 이렇게 되면, 12번 버킷에는 또 데이터가 들어있기 때문에 13번 위치에 데이터를 저장을 할 수 밖에 없다.
그러면, 위 그림처럼 12번을 건너뛴 13번 위치에 데이터를 저장을 해야될 것이다. 그럼 지금 이 그림 상에서는 10번부터 14번까지의 키가 나온다면 전부 다 선형 탐색으로 다시 위치를 찾아줘야하는 인덱스가 되는 것이다.
이렇게 데이터가 밀집되는 현상을 clustering이라고 한다.
그래서, 이 clustering 현상을 피하기 위해서 ‘제곱 탐색’이라는 방법도 나왔다.
제곱 탐색
$N^2$칸(1, 4, 9, 16, …)을 건너뛴 버킷에 데이터를 저장
데이터들이 특정 위치에 밀집하는 문제를 해결
데이터가 촘촘히 밀집되는게 아니라, 데이터 간의 간격이 넓어지기 때문에 이전과 같은 clustering 현상을 피할 수 있을 것이다.
하지만 처음 해시 값이 같다면 여전히 …
처음 Hash Function을 통해 나온 Hash값이 동일하다면 마찬가지로, 건너뛴 칸의 개수도 똑같기 때문에, 똑같이 $N$번의 탐색을 할 수 있다는 문제가 여전히 발생을 하게 된다.
그래서 나온 방법은 ‘이중 해시’라는 방법이다.
이중 해시
해시 값에 다른 해시 함수를 한번 더 적용
Hashfunction1(): 최초의 해시 값을 구함
Hashfunction2(): 충돌 발생시 이동 폭을 구함
2번째 해시 함수는 충돌이 발생했을 때 돌리는 해시 함수이다.
충돌이 발생했을 때, 몇 칸을 이동할지를 이 Hash Function을 통해서 구하게 된다.
최초 해시 값이 같더라도 이동 폭이 다르기 때문에 clustering 문제 해결 가능
이 방법은 버킷을 탐색하는데 있어서 규칙성을 아예 없애버린 방법
순서
Main.java
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
// string 타입 요소 추가
list.add("Hello");
list.add("World");
list.add("GRACE");
System.out.println(list);
list.remove("Hello"); // 요소 제거
System.out.println(list);
System.out.println(list.get(0)); // index로 출력
}
}

ArrayList는 배열 기반이기 때문에, 배열과 동일한 방식으로 index를 사용하게 된다. index를 통한 데이터 접근은 random access 방식이기 때문에 리스트의 데이터가 100개가 있든 1000개가 있든 인덱스만 알고 있다면, 항상 동일한 시간을 소요해서 데이터에 접근을 할 수 있다.
또 ArrayList는 컴퓨터의 실제 물리적인 메모리 공간에서도 연속적인 공간을 사용하기 때문에, 컴퓨터가 연산을 하기 쉬운 구조를 갖고 있다. 그래서, 이전 인덱스에서 다음 인덱스로 접근하는 속도가 빠르다.
반면에, LinkedList는 데이터를 논리적으로 연결을 시켜서 마치 연속된 형태의 데이터처럼 쓸 수 있도록 구현은 되있지만, 실제 컴퓨터 메모리 상에서는 물리적인 공간을 연속적으로 차지하고 있는 형태가 아니기 때문에, 데이터 간의 이동 자체에는 시간이 더 소요된다.

위와 같이 삽입 하고자 하는 경우, 삽입 하고자 하는 인덱스의 위치로부터 데이터가 위치한 제일 뒤의 인덱스까지 모두 한 칸씩 뒤로 데이터를 미뤄주는 작업을 먼저 해야한다. 그림과 같이 사이의 데이터가 2개라면, 데이터를 옮겨주는 연산을 2번 해야하고, 1000개라면 1000번… N개라면 N번 데이터를 옮겨주는 연산을 해야하기 때문에, 시간 복잡도는 $O(N)$이 된다.

인덱스 기반의 데이터를 삭제할 때도 마찬가지로, 데이터를 이동 시켜주는 작업을 진행해야한다. (마찬가지로, 시간 복잡도 $O(N)$으로 똑같음)

ArrayList에서 index를 통한 데이터 탐색은, Random Access로 해당 위치에 Direct로 접근을 하게 된다. 그렇기 때문에, 데이터가 아무리 많아져도 한 번에 해당 위치에 접근해서 데이터를 가져올 수 있기 때문에 index를 통한 데이터 탐색 속도는 매우 빠르다. (이렇게 데이터의 개수와 상관없이 연산의 속도가 일정하다면, 시간 복잡도는 $O(1)$임)
LinkedList는 이런 방식의 접근이 불가능하다.
ArrayList
main > java > list > IList.java
package list;
public interface IList<T> {
// 제네릭으로 <T>가 들어간 이유 :: int가 들어갈 수도 있고, String이 들어갈 수도 있고
// 어떤 타입이 들어갈지 모르기 때문에, 관형적으로 T라는 이름을 써줌.
void add(T t);
void insert(int index, T t);
void clear();
boolean delete(T t);
boolean deleteByIndex(int index);
T get(int index);
int indexOf(T t);
boolean isEmpty();
boolean contains(T t);
int size();
}
main > java > list > MyArrayList.java
package list;
import java.util.Arrays;
public class MyArrayList<T> implements IList<T> {
private static final int DEFAULT_SIZE = 50;
private int size; // ArrayList에 데이터가 몇개 들어있는지 확인해주는 변수
private T[] elements; // 어떤 타입이 들어올지 모르기 때문에 T타입의 array 선언
// 생성자
public MyArrayList() {
this.size = 0;
this.elements = (T[]) new Object[DEFAULT_SIZE];
}
// 추가 함수 : 가장 뒤에 데이터를 넣는 함수
@Override
public void add(T t) {
if (this.size == this.elements.length) { // 배열이 꽉 차면,
// 사이즈를 2배로 늘려준 다음에 기존 elements에 있는 데이터를 전부 다 옮김
this.elements = Arrays.copyOf(this.elements, this.size * 2);
}
this.elements[this.size++] = t;
}
// 우리가 정해준 인덱스에다가 데이터를 삽입하는 함수
@Override
public void insert(int index, T t) {
if (this.size == this.elements.length) {
this.elements = Arrays.copyOf(this.elements, this.size * 2);
} // array의 사이즈가 꽉 차 있을 경우면, array 크기 늘림
for (int i = index; i < this.size; i++) {
this.elements[i+1] = this.elements[i];
} // 삽입 하려고 하는 인덱스부터 현재 데이터가 있는 시점까지 한 칸씩 뒤로 민다.
this.elements[index] = t; // 삽입하고자 하는 데이터를 해당 인덱스에 삽입해주고,
this.size++; // 사이즈 1 늘려줌
}
@Override
public void clear() {
this.size = 0;
this.elements = (T[]) new Object[DEFAULT_SIZE];
}
// 내가 삭제하고 싶은 데이터를 찾아서 삭제하는 메소드
@Override
public boolean delete(T t) {
for (int i = 0; i < this.size; i++) {
if (this.elements[i].equals(t)) { // 이 조건에 부합한다면, 삭제하고자 하는 데이터라는 것임
// 지우는 작업이므로 한 칸씩 앞으로 땡겨 옴
for (int j = i; j < this.size-1; j++) { // 지우고자 하는 element의 인덱스가 i에 걸려있으므로 i부터
this.elements[j] = this.elements[j+1]; // 앞으로 땡겨옴
}
this.size--;
return true;
}
}
return false;
}
// 위치 기반으로 해서 해당 위치에 있는 데이터를 지우는 메소드
@Override
public boolean deleteByIndex(int index) {
if (index < 0 || index > this.size - 1) {
return false;
} // 인덱스가 잘못 들어오는 경우 처리해주는 부분
for (int i = index; i < this.size-1; i++) { // 아래에서 i+1까지 갈 것이기 때문에 this.size-1까지로 지정
this.elements[i] = this.elements[i+1];
}
this.size--;
return true; // 여기까지 오면, 삭제가 정상적으로 이뤄진 것이므로 true 반환
}
// element의 인덱스로 접근을 해서 데이터를 가져오는 메소드
@Override
public T get(int index) {
if (index < 0 || index > this.size - 1) { // (this.size <= index)
throw new IndexOutOfBoundsException();
}
return this.elements[index];
}
// 데이터가 있는지 없는지 뿐만 아니라,
// 데이터가 있으면 어느 위치의 index에 존재하는지, 그 index 위치 찾아서 반환
@Override
public int indexOf(T t) {
for (int i=0; i<this.size; i++) {
if (this.elements[i].equals(t)) { // elements[i]가 t와 동일하다면,
return i; // index값 반환
}
}
return -1; // 끝까지, 찾지 못한 경우 -1 리턴
}
@Override
public boolean isEmpty() {
return this.size == 0;
}
// parameter(매개변수)로 받은 데이터 t가 우리가 구현한 MyArrayList안에 존재하는지 확인해서,
// 데이터가 있으면 true, 없으면 false 반환
@Override
public boolean contains(T t) {
for (int i=0; i<this.size; i++) {
if (this.elements[i].equals(t)) { // elements[i]가 t와 동일하다면,
return true;
}
}
return false;
}
// contains 함수의 가능한 다른 답안
// @Override
// public boolean contains(T t) {
// for(T elem : this.elements) { // for-each문
// if (elem.equals(t)) {
// return true;
// }
// }
// return false;
// }
@Override
public int size() {
return this.size;
}
}

LinkedList는 Node라는 객체로 구성되어있다.
Node는 데이터를 저장할 수 있는 필드와 다음 노드를 가리키는 ‘next point a field’를 가지게 되고, 이 노드들이 모두 연결된 형태를 ‘링크드 리스트(LinkedList)’ 라고 한다.
여기서 가장 앞에 위치한 노드는 ‘Head’ 라고 하고,
가장 끝에 위치한 노드를 ‘Tail’ 이라고 한다. Tail은 자신이 가리킬 다음 노드가 없기 때문에, Tail의 next point는 Null을 가리키고 있게 된다.

링크드 리스트는 Array랑 다르게, 인덱스를 통한 랜덤 엑세스가 불가능하다. 자신을 가리키고 있는 Next Pointer만을 통해서 접근을 할 수 있기 때문에, N개의 노드를 가지고 있는 링크드 리스트 검색의 시간 복잡도는 $O(N)$이다.
왜냐하면, 우리가 찾아가고자 하는 위치를 Head에서부터 Tail까지 순회하면서 데이터를 찾아나가야 하기 때문.

맨 마지막인 Tail 노드의 다음에 붙인다고 가정
마찬가지로, 추가 작업도 앞에서부터 하나씩 찾아가면서 제일 끝 노드에 간 다음에, Null을 가리키고 있는 포인터에 우리가 추가하고자 하는 노드 데이터를 넣어줘야하기 때문에, 찾아가는 과정에서 $O(N)$의 시간 복잡도를 소요하게 된다.

그래서 데이터를 추가하게 되면, 최종적으로 위와 같은 형태의 링크드 리스트가 완성될 것이다.

제일 끝이 아니라 중간에 넣는다고 가정
중간에 넣을 때는 ArrayList와는 다르게 데이터를 다 밀어줄 필요는 없다.
간단하게 포인터만 바꿔주면 삽입이 되기는 하지만, 이 때 삽입하기 위해 찾아가는 과정이 $O(N)$이므로, 시간 복잡도는 $O(N)$이 된다.

넣고자하는 데이터의 위치를 기준으로 해서 해당 위치를 기준으로 이전 노드의 포인터가 자신을 가리키게 하고, 이전 노드가 가리키고 있던 Next 노드를 내가 Next 노드로 가지게 되면, 위 그림과 같은 연결이 완성될 것이다. 이런식으로 삽입이 이루어지게 된다.

삽입을 할 때, 뒤나 중간이 아닌 위와 같이 Head에만 삽입하는 경우가 있을 수도 있다.
만약에 Array에서 무조건 0번째에 데이터를 넣는다고 하면, 전체 데이터를 모두 한 번씩 뒤로 옮겨야 하기 때문에, 무조건 N번의 수행이 발생할 수 밖에 없으므로 매우 비효율적이다.
근데, 링크드 리스트 같은 경우에는 가장 앞에 넣는 경우 Head의 포인터만 설정을 해주면 되기 때문에, 오히려 간단하다.

링크드 리스트에서 중간에 삭제하고자 하는 노드가 있다면, 이 노드를 찾아가는 과정은 마찬가지로 $O(N)$일 것이다. 근데, 이 때 링크드 리스트의 삭제에서는 (Array와 다르게 추가적으로 $O(N)$이 소요되는, 삭제하고 데이터들을 옮겨주는 작업이 필요없이) 포인터만 조금 바꿔주면 데이터의 삭제가 이뤄지게 된다.

우리가 삭제하고자 하는 노드를 기준으로 해서 해당 노드의 이전 노드가 자신을 가리키는 것이 아니라, 자신이 가리키고 있던 Next 노드를 가리키게 한다.

그리고, 자신의 Next는 아무것도 가리키지 않게 될 것이다.
자바에서는, 아무것도 참조하지 않는 해당 객체를 가비지 컬렉터가 수거해가면서 자동으로 삭제가 이뤄지게 된다.
| LinkedList | Array | |
|---|---|---|
| 추가 | $O(N)$ | $O(1)$, $O(N)$ |
| 삽입 | $O(N)$ | $O(N)$ |
| 삭제 | $O(1)$ | $O(N)$ |
| 검색 | $O(N)$ | $O(1)$ |
추가 → 데이터를 제일 뒤에 붙이는 추가 작업
LinkedList의 경우에는 데이터를 하나씩 찾아가기 때문에, $O(N)$
Array의 경우에는 인덱스를 통해서 데이터를 찾아가서 추가를 하기 때문에, 제일 뒤에 있는 데이터를 찾아가기 위해 다 끝까지 순회할 필요가 없으므로 $O(1)$의 시간 복잡도를 갖는다.
배열이 꽉 차있는 경우에 배열의 사이즈를 재할당해서 데이터를 옮기는 과정이 있기 때문에, 이런 경우에는 $O(N)$까지 시간복잡도가 늘어날 수 있음.
링크드 리스트 같은 경우는 데이터를 제일 앞에다 넣는 경우 무조건 $O(1)$의 시간 복잡도를 갖는다.
main > java > list > IList.java
package list;
public interface IList<T> {
void add(T t);
void insert(int index, T t);
void clear();
boolean delete(T t);
boolean deleteByIndex(int index);
T get(int index);
int indexOf(T t);
boolean isEmpty();
boolean contains(T t);
int size();
}
링크드 리스트를 구현을 할 때는 Dummy node라는 개념을 써서 링크드 리스트를 구현한다.
Dummy node는 링크드 리스트가 존재할 때 Head는 어떠한 상황에서도 빈 상태로 있기 때문에, 링크드 리스트에 아무 데이터를 넣지 않은 상황이어도 Head node는 미리 이미 존재하고 있는 상황이고, Head에는 데이터를 넣을 수 없는 상황이다. (Head 다음부터 데이터를 넣는 것이다)
이렇게 하면, 코드의 구현에 있어서 좀 더 간결해진다는 장점이 있다. (if문으로 일일히 조건을 정의해줄 필요가 없어지기 때문)
main > java > list > MyLinkedList.java
package list;
public class MyLinkedList<T> implements IList<T> {
// 2.
private int size;
private Node head;
// 3. MyLinkedList의 생성자
public MyLinkedList() {
this.size = 0;
this.head = new Node(null); // dummy head node
}
// 4. 추가 함수
@Override
public void add(T t) {
Node current = this.head;
while (current.next != null) {
current = current.next;
} // current.next가 null이라는 것은 그 다음이 없다는 것이므로,
// 그 다음에 내 데이터가 없을 때까지 쭉 노드를 타고 들어가는 작업을 의미
Node node = new Node(t);
current.next = node;
this.size++;
}
// 5. 삽입 함수 (Array와 달리 포인터 몇개만 바꿔주는걸로 insert 작업 가능)
@Override
public void insert(int index, T t) {
if (index > this.size || index < 0) { // (index >= this.size || index < 0) 이렇게 this.size와 동일한 값이 들어오면,
// add 메소드랑 똑같은 작업을 하게 되므로 위와 같이 조건을 정의한다.
throw new IndexOutOfBoundsException();
}
Node prev = this.head; // 이전 노드 : prev
Node current = prev.next;
int i = 0;
while (i++ < index) { // index까지 while문 실행
prev = prev.next;
current = current.next;
} // index의 위치까지 하나씩 옮겨가면서 이동
// i가 index의 위치까지 가게되면,
Node newNode = new Node(t, current); // 새로 만들어진 노드는 중간에 들어가는 것이기 때문에, 이 노드의 next는 current를 가리키고,
prev.next = newNode; // 새로 만들어진 노드가 이전꺼의 다음꺼가 되도록
this.size++;
}
// 6. LinkedList에 있는 데이터를 모두 비워주는 메소드
@Override
public void clear() {
this.size = 0;
this.head.next = null; // this.head.next가 null을 가리키게 되면, 노드의 연결이 끊어지게 되면서 데이터들 비워짐
}
// 7. 파라미터로 받은 데이터 t가 위치한 노드를 찾아서 그 노드를 지워주는 메소드
@Override
public boolean delete(T t) {
Node prev = this.head;
Node current = prev.next;
while (current != null) { // 처음부터 끝까지 순환
if (current.data.equals(t)) { // 여기에 걸리면 current 노드는 우리가 지워야 할 target이 되는 노드
prev.next = current.next;
current.next = null; // 우리가 지우고자 하는 노드는 next로 아무 것도 가리키지 않게
this.size--;
return true; // 삭제가 정상적으로 완료되었다는 의미로 true 리턴
}
prev = prev.next;
current = current.next;
}
return false;
}
// 8. index와 일치하는 데이터 찾은 후, 해당 데이터 삭제
@Override
public boolean deleteByIndex(int index) {
if (index >= this.size || index < 0) {
throw new IndexOutOfBoundsException();
} // index가 잘못 들어올 경우에 대한 예외처리
Node prev = this.head;
Node current = prev.next;
int i = 0;
while (i++ < index) {
prev = prev.next;
current = current.next;
} // 우리가 목표로 하는 index의 위치까지 하나씩 타고 들어감
prev.next = current.next;
current.next = null;
this.size--;
return true;
}
// 9. index와 일치하는 노드에 들어있는 데이터를 가져오는 메소드
@Override
public T get(int index) {
if (index >= this.size || index < 0) {
throw new IndexOutOfBoundsException();
} // index가 잘못 들어올 경우에 대한 예외처리
Node current = this.head.next; // head를 dummy node로 사용하기 때문에, 굳이 head부터 볼 필요는 없다
// head의 next가 처음 데이터가 들어있는 위치이기 때문에, this.head.next부터 데이터 탐색 시작
int i = 0;
while (i++ < index) {
current = current.next;
}
return current.data;
// // this.head부터 시작하고 싶다면 아래와 같이..
// Node current = this.head;
// int i = -1; // 왜냐하면, head는 실제로 데이터가 들어있는 부분이 아니므로 -1부터 시작
// while (i++ < index) {
// current = current.next;
// }
// return current.data;
// }
}
// 10. 파라미터로 받은 데이터 t가 있는 노드의 index를 반환
@Override
public int indexOf(T t) {
Node current = this.head.next;
int index = 0;
while (current != null) { // 처음부터 끝까지 돌거임
if (current.data != null && current.data.equals(t)) {
return index;
}
current = current.next;
index++; // 노드 한 칸 옮길 때마다 index도 같이 갱신
}
return -1;
}
// 11. 비어있는지 확인
@Override
public boolean isEmpty() {
return this.head.next == null; // LinkedList스럽게 null여부로 체크
}
// 12. 파라미터로 넘겨 받은 데이터 t가 LinkedList에 포함되어있는지 확인
@Override
public boolean contains(T t) {
Node current = this.head.next; // 노드의 연결을 변경해줄 필요가 없으니까 current만 가지고 진행
while (current != null) {
if (current.data != null && current.data.equals(t)) {
return true;
}
current = current.next;
}
return false;
}
// 13. 사이즈 확인하는 함수
@Override
public int size() {
return this.size;
}
// 1. Node 클래스
private class Node {
T data; // 데이터를 담을 수 있는 필드
Node next; // 다음 Node를 가리키고 있는 포인터 next
// 생성자
Node(T data) {
this.data = data;
}
// 생성자
Node(T data, Node next) {
this.data = data;
this.next = next;
}
}
}

Double Linked List를 그림으로 나타내면 위와 같은 모양이다.
일반 Linked List에서는 HEAD 노드를 가지고, 하나씩 찾아 들어가는 구조였다.
Double Linked List에서는 HEAD 노드와 TAIL 노드를 각각 따로 가지고 있게 된다.

NEXT 포인터 뿐만 아니라 이전 노드를 가리키고 있는 PREV 포인터 함께 가지고 있다.

Double Linked List에서도 Single Linked List와 마찬가지로 dummy node를 사용한 구현을 할 것이다. Double Linked List가 처음 초기화 됐고, 아무 데이터도 들어오지 않은 상태라면 위 그림과 같이 HEAD 더미와 TAIL 더미 노드를 하나씩 각각 가지고 있을 것이다.
그리고, HEAD의 Next는 TAIL을 가리키고, TAIL의 Prev는 HEAD를 가리키고 있는 모양이 나온다.

Double Linked List에 실제로 데이터가 들어오면, 위 그림과 같은 모양이 될 것이다.
데이터 노드는 더미 노드인 HEAD와 TAIL노드 사이에 각각 위치한다.
실질적으로, 가장 앞에 있게 되는 데이터 노드는 더미 노드인 HEAD의 다음 노드가 될 것이고, 가장 마지막에 있는 데이터 노드는 더미 노드인 TAIL 노드의 바로 앞에 있는 노드가 될 것이다.

데이터를 가장 마지막 노드에 추가해줘야 한다면, 위 그림과 같이 TAIL 더미 노드의 바로 앞 노드에 데이터를 삽입해주면 된다.
Double Linked List에서는 TAIL 노드를 가지고 있기 때문에, TAIL을 기준으로 가장 마지막 노드를 찾아서 거기에다가 데이터를 추가해주기만 하면 된다.
Double Linked List에 데이터가 수 천, 수 백개가 들어있어도 언제나 TAIL 통해서 한번에 찾아갈 수 있기 때문에 시간 복잡도는 $O(1)$을 가지게 된다.

배열은 인덱스를 통해 한 번에 접근 가능, Single Linked List 경우에는 HEAD를 통해 타고 들어가는 방식으로 접근, Double Linked List는 Single Linked List와 마찬가지로 노드 연결을 타고 들어가는 방식으로 접근이 가능.
예를 들어, 위 그림과 같이 4개의 데이터가 들어있는 Double Linked List를 가정했을 때, 1번째 인덱스에 있는 데이터를 가져 오고자 한다면,

앞쪽부터, Next 포인터로 이동을 하면서 index의 위치까지 이동해서 데이터를 가져올 수 있다. 여기까지 Single Linked List와의 차이점은 없어보이지만,

만약에 3번째 인덱스에 위치한 데이터를 가져온다고 하면, Double Linked List는 TAIL을 가지고 있기 때문에, HEAD가 아닌 TAIL에서부터 Prev 노드를 통해 이동해서 데이터를 가져오는 다소 효율적인 접근을 할 수 있다.
Double Linked List에서는 절반을 나눈 포인트를 기준으로해서 HEAD에서 가까우면 HEAD를 통해서, TAIL에서 가까우면 TAIL부터 접근해서 데이터를 가져올 수 있게 된다.
데이터가 만약 10개가 들어있다면 최대 5번의 탐색을 통해서 데이터를 가져올 수 있게 된다.
즉, N개의 데이터가 들어있다면 $\frac{N}{2}$번의 탐색을 통해서 데이터에 접근을 할 수 있게 되는 것이다.
시간 복잡도로 표현하면, 접근 표기법의 특징에 따라 상수항인 $\frac{N}{2}$은 날려버리기 때문에 표기시에는 단순하게 $O(N)$으로 표현한다.
Single Linked List일 때와 비교하면 시간 복잡도가 동일한 $O(N)$으로 표현하기는 하지만, 실제 연산 소요시간은 Double Linked List가 절반 정도 더 짧을 것으로 예상해볼 수 있다.

index를 통해 위치를 찾아가는 것은 index를 통한 접근 (검색) 부분에 내용 있음.
위 그림 상에 curr이 가리키는 위치가 우리가 데이터를 삽입하고자 하는 index의 위치라고 가정해보자.

그리고, 그 위치에다가 새로 삽입될 데이터가 있는 new Node() 데이터를 넣어줄 것이다.

방금 생성한 new Node()는 현재 curr이 가리키고 있는 노드의 이전 노드를 자신의 이전 노드로 가지고, 현재 curr이 가리키고 있는 노드는 자신의 Next 노드로 가져야지 new Node()가 현재 인덱스 위치에 삽입이 될 것이다.

그 다음으로 현재 curr이 가리키고 있는 노드와 curr의 기존의 Prev였던 노드 간의 연결을 정리해주는 작업을 해준다.

그래서, curr앞에 있던 prev노드는 Next 노드로 curr이 아니라 우리가 새로 생성한 노드를 가리키면 될 것이고, curr노드는 Prev 포인터로 기존의 prev가 아닌 새로 생성한 노드(new Node)를 가리키게 바꿔준다.

Double Linked List에 데이터가 삽입이 되면 기존의 prev와 curr사이에 우리가 생성해준 new Node가 위 그림과 같이 들어간 모양이 완성이 되야한다.
중간에 연결이 잘못되거나 끊기면 더 이상 next나 prev로는 이동을 할 수 밖에 없기 때문에, 그 이후의 데이터나 그 이전의 데이터가 모두 날아가버릴 수 있다. 그래서, LinkedList에선 연결을 잘 맺어주는게 굉장히 중요하다.

인덱스로 들어오는 과정은 위 내용들과 동일하다.

위 그림과 같이, 우리가 삭제할 target 노드(초록색)를 기준으로 이전 노드인 prev 노드와 다음 노드인 next 노드인 연결해주는 작업을 한다. prev의 Next 포인터는 curr의 next를 가리키게 하고, next 노드는 Prev 포인터로 curr이 아닌 curr의 prev를 가리키게 한다.

그리고, curr에서 연결되고 있던 것들은 모두 끊어준다.

결과적으로는 위와 같은 모양이 나올 것이다.
main > java > list > MyDoubleLinkedList.java
package list;
public class MyDoubleLinkedList<T> implements IList<T> {
// 2.
private Node head;
private Node tail;
private int size;
// 위 멤버 변수들을 초기화해주는 생성자 작성
public MyDoubleLinkedList() {
this.size = 0;
this.head = new Node(null); // dummy node
this.tail = new Node(null); // dummy node
this.head.next = this.tail;
this.tail.prev = this.head;
// size()
// clear()
// add()
}
// 5. 추가
@Override
public void add(T t) {
Node last = this.tail.prev; // this.tail.prev가 가리키고 있는게 마지막 노드 last
Node newNode = new Node(t, last, tail); // 새로운 노드는 데이터 t를 가지고, last노드를 자신의 prev로, tail을 자신의 next노드로 가짐
last.next = newNode; // last의 next는 새로운 노드를 가리키게
this.tail.prev = newNode; // tail노드의 prev도 새로 생성한 노드를 가리키게
this.size++;
// 그 다음 get(index)
}
// 7. 삽입
@Override
public void insert(int index, T t) {
if (index > this.size || index < 0) {
throw new IndexOutOfBoundsException();
}
Node prev = null;
Node current = null;
int i = 0;
if (index < this.size / 2) { // index가 head에서 더 가까운 경우
prev = this.head;
current = this.head.next;
while (i++ < index) {
prev = prev.next;
current = current.next;
}
} else { // index가 tail에서 더 가까운 경우
current = this.tail;
prev = this.tail.prev;
while (i++ < (this.size - index)) {
current = current.prev;
prev = prev.prev;
}
}
Node newNode = new Node(t, prev, current);
current.prev = newNode;
prev.next = newNode;
this.size++;
// 그 다음 delete by index
}
// 4. Double Linked List에 아무 데이터도 들어오지 않은 상태로 초기화하는 메소드
@Override
public void clear() {
this.size = 0;
this.head.next = this.tail;
this.head.prev = null;
this.tail.next = null;
this.tail.prev = this.head;
}
@Override
public boolean delete(T t) {
Node prev = this.head;
Node current = prev.next;
while (current != null) {
if (current.data.equals(t)) {
prev.next = current.next;
current.next.prev = prev;
current.next = null;
current.prev = null;
this.size--;
return true;
}
prev = prev.next;
current = current.next;
}
return false;
}
// 8. index를 통한 삭제
@Override
public boolean deleteByIndex(int index) {
if (index >= this.size || index < 0) {
throw new IndexOutOfBoundsException();
}
Node prev = null;
Node current = null;
Node next = null;
int i = 0;
if (index < this.size / 2) { // index가 헤드에서 더 가까우면
prev = this.head;
current = this.head.next;
while (i++ < index) {
prev = prev.next;
current = current.next;
} // 이 while문이 종료되면, current노드 :: 우리가 지우고 싶어하는 index의 위치가 들어있을 것임
prev.next = current.next; // prev.next가 원래는 current를 가리키고 있었을 것이므로, current의 next를 가리키도록 바꿔줌
current.next.prev = prev; // current.next의 prev포인터는 원래 current를 가리키고 있었으므로, prev를 가리키도록 변경
current.next = null;
current.prev = null;
} else {
// tail에서 역으로 찾아가는 경우
current = this.tail.prev;
next = this.tail;
while (i++ < (this.size - index - 1)) {
next = next.prev;
current = current.prev;
} // 이 while문이 종료되면, next는 우리가 지우고자 하는 current의 다음 노드,
next.prev = current.prev; // 이 노드의 이전 노드는 원래 current를 가리키고 있었으므로, current보다 1칸 앞에 있는 current.prev를 가리키도록
current.prev.next = next; // current의 prev의 next포인터는 원래 current를 가리키고 있었으므로, next를 가리키도록
current.next = null;
current.prev = null;
}
this.size--;
return true;
}
// 6. index 접근을 해서 데이터 가져오는 메소드
@Override
public T get(int index) {
if (index >= this.size || index < 0) {
throw new IndexOutOfBoundsException();
} // 잘못된 인덱스가 들어온 경우에 대한 예외처리
int i = 0;
Node current = null;
if (index < this.size / 2) { // index가 헤드에서 더 가까우면
current = this.head.next;
while (i++ < index) {
current = current.next;
}
} else { // index가 tail에서 더 가까운 경우
current = this.tail.prev;
while (i++ < (this.size - index - 1)) { // 여기서는 index가 역순으로 가니까
current = current.prev;
}
}
return current.data;
// 그 다음 insert(index)
}
@Override
public int indexOf(T t) {
Node current = this.head.next;
int index = 0;
while (current != null) {
if (current.data != null && current.data.equals(t)) {
return index;
}
current = current.next;
index++;
}
return -1;
}
@Override
public boolean isEmpty() {
return this.head.next == this.tail;
}
// 9.
@Override
public boolean contains(T t) { // O(N) -> O(N/2)
Node current = this.head.next;
while (current != null) {
if (current.data != null && current.data.equals(t)) {
return true;
}
current = current.next;
}
return false;
}
// 3.
@Override
public int size() {
return this.size;
}
// 1. 노드 기반으로 구현하기 때문에, 아래와 같이 내부 클래스 생성
private class Node {
T data;
Node prev;
Node next;
Node(T data) {
this.data = data;
}
Node(T data, Node prev, Node next) {
this.data = data;
this.prev = prev;
this.next = next;
}
}
}
Queue의 주요 동작으로는

데이터가 들어갈 때는 제일 뒤(rear)쪽에다가 데이터가 들어가고, 데이터가 빠질 때는 가장 앞(front)쪽 부터 데이터가 빠지는 모양임.

배열로 큐를 구현할 경우에 앞에 있는 데이터가 빠질 때마다 데이터를 앞으로 한 칸씩 땡겨줘야 한다. 이럴 경우, N개의 데이터 만큼 앞으로 땡겨오게 되므로, 시간 복잡도가 $O(N)$ 까지 상승을 하게된다.
그렇기 때문에, 큐는 선형 구조이지만 배열 기반으로 구현해서 쓰는 것은 비효율적이라 하지 않는다.
Linked List(링크드 리스트)를 기반으로 해서 큐를 직접 구현
IQueue.java
package queue;
public interface IQueue<T> {
void offer(T data);
T poll();
T peek();
int size();
void clear();
boolean isEmpty();
}
MyLinkedQueue.java
package queue;
public class MyLinkedQueue<T> implements IQueue<T> {
private Node head;
public Node tail;
private int size;
// 생성자
public MyLinkedQueue() {
this.size = 0;
this.head = new Node(null); // dummy node
this.tail = this.head;
}
// 큐에 데이터를 넣는 함수
@Override
public void offer(T data) {
Node node = new Node(data);
this.tail.next = node;
this.tail = this.tail.next;
this.size++;
}
// 큐에서 데이터를 빼오는 dequeue 연산을 하는 함수
@Override
public T poll() {
if (this.isEmpty()) {
throw new IllegalStateException();
} // 큐가 비어있는 상태에서 데이터를 빼려고 하면 예외 발생하도록.
Node node = this.head.next;
this.head.next = node.next; // head를 가져올 데이터의 다음 데이터로 바꿈
node.next = null; // 화살표를 끊어줌
this.size--; // 데이터가 하나 없어지니까 사이즈 줄임
if (this.head.next == null) {
// 데이터가 완전히 비어있는 상태가 되었을 때, 처음과 같이 초기화 시킴
this.tail = this.head;
}
// 그냥 반환을 하면 안되므로 위와 같이 노드를 없애고,
// 연결을 다시 정상적으로 만들어 주는 작업을 거친 후 반환
return node.data;
}
// 데이터를 그대로 둔 상태에서 가장 앞에 있는 노드가 뭔지 확인하는 함수
@Override
public T peek() {
if (this.isEmpty()) {
throw new IllegalStateException();
}
return this.head.next.data; // head의 next는 가장 먼저 들어온 데이터를 갖고 있기 때문
}
@Override
public int size() {
return this.size;
}
// 처음 상태로 돌려주는 작업을 하는 함수
@Override
public void clear() {
this.head.next = null;
this.tail = head;
this.size = 0;
}
@Override
public boolean isEmpty() {
return this.head.next == null;
// this.head의 next가 null이라면, 데이터가 들어있지 않은 상태
}
private class Node {
T data;
Node next;
Node(T data) { this.data = data; }
Node(T data, Node next) {
this.data = data;
this.next = next;
}
}
}
reliable, in-order byte-stream data transfer
- TCP는 내가 보낸 메시지가 유실되지 않고 그대로(reliable), 내가 메시지를 보낸 순서를 지키면서(in-order) 목적지까지 감.
flow control
- receiver의 능력에 맞춰서 sender의 전송속도를 조절해준다.
congestion control
- 중간 네트워크 상황에 맞춰서 그 네트워크가 받을 수 있는 능력치 만큼으로 보내줌.
connectionless
connection 없음.
unreliable data transfer
no flow control
no congestion control
쉽게말해.. 아무것도 안해준다는 얘기
유실되도 상관없고, 내 맘대로 보낸다 할 때 씀
현재 인터넷에서 사용자들한테 제공하는 데이터 통신 서비스는 TCP 아니면 UDP임.
TCP는 해주는게 많은 만큼 상대적으로 비용이 많이 소요된다.
서로 다른 객체간의 의사소통 방식 규칙
데이터를 출발지에서부터 목적지까지 쭉 전달하는데 과연 어떤 방식으로 라우터가 전달을 하는가?
메시지를 전달하는 방식에는 크게 2가지가 있다.
출발지에서부터 목적지까지 가는 길을 미리 예약을 해놓고, 그 특정 사용자만을 위해서 사용하게 만들어 놓은 것 (예전, 유선 전화망 같이)

인터넷에서 사용하는 것은 Circuit Switching이 아니라 Packet Switching임
패킷 스위칭은 단순히 사용자(user)가 보내는 메시지들.. 패킷을 패킷 단위로 받아서 그때그때 올바른 방향으로 전송해주는 것

왜 Packet Switching을 사용하는가?

패킷 스위칭은 써킷 스위칭과 다르게 몇명까지의 개념이 없다. 들어온대로 처리하니까 제약이 없는 것이다.
대부분 웹 브라우저의 경우, 동시에 몰리지만 않는다면 각자의 리듬에 맞게 요청을 보내고, 응답을 받기 때문에 패킷 스위칭이 유리함.
패킷 스위칭이기 때문에 어쩔 수 없이 생기는 문제들

어쩔 수 없이 생기는 Processing Delay ☞ 새로운 패킷을 받으면 그 패킷을 검사하고, 그 패킷의 목적지가 어딘지를 확인하는데 생기는 Delay
성능이 좋은 라우터를 사면 Processing Delay 개선됨.
queueing delay ☞ 나가는 속도보다 들어오는 속도가 더 빠를 경우를 대비해서 줄이 생길 것임, 줄에 대기하고 있는 애들도 임시로 저장 한 후 처리를 해줘야 하므로 queue라는 장소가 존재하게 되는데 만약, 많은 것들이 앞에 먼저 대기하고 있는 상태라면 내 차례가 올 때까지 기다려야 한다. 그 기다리는 시간이 바로 ‘queueing delay’ 임.
queueing delay는 사용자들 패턴에 달려있는거라 우리가 어떻게 할 수가 없다.
네트워크 상에서 생기는 delay는 결국에 queueing delay에서 대부분 모든 일이 발생한다.
그리고, 대기가 다 끝나고 나가는 순간에 생기는 딜레이도 있다.
결국 패킷이라는 것은 데이터이기 때문에, 비트의 집합이다.

]]>비트의 집합이라서 예를 들어, 패킷이 100bit 짜리라고 하면, 첫번째 bit부터 맨 마지막 100bit까지 모두 나가야 할 것이다.
Transmission delay ☞ 첫번째 비트에서 맨 마지막 비트까지 온전히 나가는데 까지 걸린 시간을 말한다.
케이블 공사를 해서 밴드 수를 확 늘려버리면 Transmission delay가 개선된다. (그래서 kt나 sk에서 가끔씩 대규모 공사를 하는 것이다.)
Propagation delay ☞ 마지막 비트가 링크에 올라와서 다음 라우터에 도달할 때까지 걸리는 시간을 말함. (그렇기 때문에 결국에는, 단순히 링크의 길이를 빛의 속도로 나눈 것임)
Sector 0번은 최외곽 실린더의 첫 트랙에 있는 첫 번째 섹터이다physical formatting (Low-level formatting)
디스크를 컨트롤러가 읽고 쓸 수 있도록 섹터들로 나누는 과정
각 섹터는 header + 실제 data(보통 512 bytes) + trailer로 구성
header와 trailer는 sector number, ECC (Error-Correcting Code) 등의 정보가 저장되며 controller가 직접 접근 및 운영
ECC는 실제 데이터를 작게 요약한 코드임. (일종의 지문(fingerprint) 같은 것)
에러가 발생했을 때 ECC를 어느 정도 규모를 두느냐에 따라서 그 에러를 수정까지 할 수 있는 경우가 있고, 에러를 검출만 하고 수정은 못하는 경우도 있다.
Partitioning
Logical formatting
Booting
sector 0 (boot block)을 load하여 실행sector 0은 “full Bootstrap loader program”
Access time의 구성
Disk bandwidth
Disk Scheduling
seek time을 최소화하는 것이 목표
Seek time ≈ seek distance
≈: 거의 같다(근사 기호)
큐에 다음과 같은 실린더 위치의 요청이 존재하는 경우 디스크 헤드 53번에서 시작한 각 알고리즘의 수행 결과는? (실린더 위치는 0 - 199)
98, 183, 37, 122, 14, 124, 65, 67
FCFS
First Come First Service
들어온 순서대로 처리해주는 방법
순서대로 처리하다보니 헤드의 이동거리가 길어져 비효율적
SSTF
Shortest Seek Time First
현재 head의 위치에서 (큐에 들어와 있는 것 중) 제일 가까운 요청을 가장 먼저 처리하는 방법
- starvation 문제
디스크 헤드의 이동거리는 줄어들지만, starvation 문제가 발생할 수 있는게 문제다.
큐에 이러한 요청들이 들어와서 가까운 위치에 있는 걸 처리하고, 멀리있는 쪽 차례가 올 것 같을 때, 큐에 낮은 주소의 요청들이 계속해서 많이 들어온다면 head가 멀리 있는 쪽으로 영원히 가지 못할 수 있을 것이다.
SCAN
엘리베이터 스케줄링이라고도 부름.
- disk arm이 디스크이 한쪽 끝에서 다른쪽 끝으로 이동하며 가는 길목에 있는 모든 요청을 처리한다
- 다른 한쪽 끝에 도달하면 역방향으로 이동하며 오는 길목에 있는 모든 요청을 처리하며 다시 반대쪽 끝으로 이동한다
- 문제점: 실린더 위치에 따라 대기 시간이 다르다
C-SCAN
헤드가 한쪽 끝에서 다른쪽 끝으로 이동하며 가는 길목에 있는 모든 요청을 처리
다른쪽 끝에 도달했으면 요청을 처리하지 않고 곧바로 출발점으로 다시 이동
SCAN보다 균일한 대기 시간을 제공한다
이동거리는 다소 길어질 수가 있지만…
위 그림을 보면, 183번 이후에 요청이 없음에도 제일 끝까지 갔다가 턴을 하고, 14번 보다 더 낮은 주소에는 요청이 없음에도 0번까지 들어온 다음에 턴을 함.
N-SCAN
- SCAN의 변형 알고리즘
- 일단 arm이 한 방향으로 움직이기 시작하면 그 시점 이후에 도착한 job은 되돌아올 때 service
LOOK
C-LOOK
LOOK and C-LOOK
SCAN이나 C-SCAN은 헤드가 디스크 끝에서 끝으로 이동
LOOK과 C-LOOK은 헤드가 진행 중이다가 그 방향에 더 이상 기다리는 요청이 없으면 헤드의 이동방향을 즉시 반대로 이동한다
C-LOOK 위 그림을 보면, 반대 방향으로 올 때는 처리를 하지 않고 이동만 하는데, 큐에 있는 제일 낮은 주소인 14번까지만 가서 거기서부터 처리하면서 밖으로 빠져나옴.
그러면, 어떤 디스크 스케줄링 알고리즘이 좋은 알고리즘인가?
SCAN, C-SCAN 및 그 응용 알고리즘은 LOOK, C-LOOK 등이 일반적으로 디스크 입출력이 많은 시스템에서 효율적인 것으로 알려져 있음
현대의 디스크 시스템에서는, 디스크 헤드의 이동거리를 줄이는데 비교적 용이한 SCAN에 기반한 알고리즘들을 많이 쓰고 있다.
File의 할당 방법에 따라 디스크 요청이 영향을 받음
디스크 스케줄링 알고리즘은 필요할 경우 다른 알고리즘으로 쉽게 교체할 수 있도록 OS와 별도의 모듈로 작성되는 것이 바람직하다
Disk를 사용하는 두 가지 이유
memory의 volatile한 특성 → file system
*volatile : 휘발성의
프로그램 실행을 위한 memory 공간 부족 → swap space (swap area)
Swap-space
Virtual memory system에서는 디스크를 memory의 연장 공간으로 사용
파일시스템 내부에 둘 수도 있으나 별도 partition 사용이 일반적
공간 효율성보다는 속도 효율성이 우선
어차피 프로세스가 끝나면 사라져 버릴 내용이기 때문에, 공간 효율성이 중요하지 않음
일반 파일보다 훨씬 짧은 시간만 존재하고 자주 참조됨
따라서, block의 크기 및 저장 방식이 일반 파일시스템과 다름

RAID (Redundant Array of Independent Disks)
RAID의 사용 목적
디스크 처리 속도 향상
신뢰성 (reliability) 향상
동일 정보를 여러 디스크에 중복 저장
하나의 디스크가 고장(failure)시 다른 디스크에서 읽어옴
(Mirroring, shadowing)
단순한 중복 저장이 아니라 일부 디스크에 parity를 저장하여 공간의 효율성을 높일 수 있다
parity : 오류가 생겼는지 알아내고, 복구할 수 있을 정도의 중복 저장만 아주 간략하게 해주는 기법 (중복 저장을 대단히 낮게 함)

출처 : https://www.acmicpc.net/problem/2606
| 시간 제한 | 메모리 제한 | 제출 | 정답 | 맞힌 사람 | 정답 비율 |
|---|---|---|---|---|---|
| 1 초 | 128 MB | 103322 | 49294 | 33259 | 45.976% |
신종 바이러스인 웜 바이러스는 네트워크를 통해 전파된다. 한 컴퓨터가 웜 바이러스에 걸리면 그 컴퓨터와 네트워크 상에서 연결되어 있는 모든 컴퓨터는 웜 바이러스에 걸리게 된다.
예를 들어 7대의 컴퓨터가 <그림 1>과 같이 네트워크 상에서 연결되어 있다고 하자. 1번 컴퓨터가 웜 바이러스에 걸리면 웜 바이러스는 2번과 5번 컴퓨터를 거쳐 3번과 6번 컴퓨터까지 전파되어 2, 3, 5, 6 네 대의 컴퓨터는 웜 바이러스에 걸리게 된다. 하지만 4번과 7번 컴퓨터는 1번 컴퓨터와 네트워크상에서 연결되어 있지 않기 때문에 영향을 받지 않는다.

어느 날 1번 컴퓨터가 웜 바이러스에 걸렸다. 컴퓨터의 수와 네트워크 상에서 서로 연결되어 있는 정보가 주어질 때, 1번 컴퓨터를 통해 웜 바이러스에 걸리게 되는 컴퓨터의 수를 출력하는 프로그램을 작성하시오.
첫째 줄에는 컴퓨터의 수가 주어진다. 컴퓨터의 수는 100 이하이고 각 컴퓨터에는 1번 부터 차례대로 번호가 매겨진다. 둘째 줄에는 네트워크 상에서 직접 연결되어 있는 컴퓨터 쌍의 수가 주어진다. 이어서 그 수만큼 한 줄에 한 쌍씩 네트워크 상에서 직접 연결되어 있는 컴퓨터의 번호 쌍이 주어진다.
1번 컴퓨터가 웜 바이러스에 걸렸을 때, 1번 컴퓨터를 통해 웜 바이러스에 걸리게 되는 컴퓨터의 수를 첫째 줄에 출력한다.
7
6
1 2
2 3
1 5
5 2
5 6
4 7
4
네트워크 그래프를 구현해서 하는 것부터 나에게는 다소 생소하고, 어려운 부분이었어서 그래프 구현하는 것부터 차근차근 검색 및 공부하면서 이 문제를 풀었다.
그리고, 이 문제는 DFS(깊이 우선 탐색) 말고도 BFS(너비 우선 탐색) 혹은 유니온 파인드 알고리즘을 이용해서 풀 수도 있다.
나는 기초를 탄탄히 하고자 DFS를 이용해서 풀었다.
# 백준 2606 바이러스
# 첫째 줄 : 컴퓨터의 수
com_num = int(input())
# 둘째 줄 : 네트워크 상에서 직접 연결되어 있는 컴퓨터 쌍의 수 (간선의 수)
pair_num = int(input())
network = {}
# 딕셔너리로 network 그래프 만들기
for i in range(pair_num): # 쌍의 수 만큼 그래프가 구현되어야 함
a, b = map(int, input().split())
# a라는 수를 가진 노드가 network 그래프에 있으면,
if a in network:
# a에다가 b값을 넣어준다.
network[a].add(b)
else: # a라는 수를 가진 노드가 network 그래프에 없으면,
network[a] = set([b]) # a라는 노드를 만든 후 거기에 set형식으로 b값을 넣어준다.
# b라는 수를 가진 노드가 network 그래프에 있으면
if b in network:
# b에다가 a값을 넣어준다.
network[b].add(a)
else: # b라는 수를 가진 노드가 network 그래프에 없으면,
network[b] = set([a]) # b라는 노드를 만든 후 거기에 set형식으로 a값을 넣어준다.
## DFS(깊이우선탐색) 하는 함수 기능 구현
# (참고 링크 : https://data-marketing-bk.tistory.com/44)
def dfs(graph, start_node):
# 기본은 항상 두개의 리스트를 별도로 관리해주는 것
need_visit, visited = list(), list()
# 시작 노드를 지정하기
need_visit.append(start_node)
# 만약 아직도 방문이 필요한 노드가 있다면,
while need_visit:
# 그 중에서 가장 마지막 데이터를 추출 (스택 구조의 활용)
node = need_visit.pop()
# 만약 그 노드가 방문한 목록에 없다면
if node not in visited:
# 방문한 목록에 추가하기
visited.append(node)
# 그리고, 인접 노드들을 방문 예정 리스트에 추가
need_visit.extend(graph[node])
# dfs 시작 노드에 해당하는 1개 노드에 대해서는 차감해서 리턴
return len(visited) - 1
# 바이러스 최종 결과 출력
print(dfs(network, 1))
이 문제를 처음 봤던 작년이 떠오른다.
그래프를 어떻게 구현하는건지 이해가 어려웠을 뿐 아니라, 가르침 받을 수 없던 나로썬 이 문제가 ‘실버’ 레벨 정도로 쉬운 문제라는게 이해가 참 되지 않았었다.알고리즘을 잘하는 친구한테 그래프 구현하는 방법에 대해서 배우고 이해하게 되니, 이제는 이게 쉬운 문제라는 것을 조금이나마 공감하게 되었다.
이렇게 또 작지만 한 발자국 나아가게 된 것 같다.
출처 : https://www.acmicpc.net/problem/4949
| 시간 제한 | 메모리 제한 | 제출 | 정답 | 맞힌 사람 | 정답 비율 |
|---|---|---|---|---|---|
| 1 초 | 128 MB | 65253 | 21538 | 16968 | 32.271% |
세계는 균형이 잘 잡혀있어야 한다. 양과 음, 빛과 어둠 그리고 왼쪽 괄호와 오른쪽 괄호처럼 말이다.
정민이의 임무는 어떤 문자열이 주어졌을 때, 괄호들의 균형이 잘 맞춰져 있는지 판단하는 프로그램을 짜는 것이다.
문자열에 포함되는 괄호는 소괄호(“()”) 와 대괄호(“[]”)로 2종류이고, 문자열이 균형을 이루는 조건은 아래와 같다.
정민이를 도와 문자열이 주어졌을 때 균형잡힌 문자열인지 아닌지를 판단해보자.
하나 또는 여러줄에 걸쳐서 문자열이 주어진다. 각 문자열은 영문 알파벳, 공백, 소괄호(“( )”) 대괄호(“[ ]”)등으로 이루어져 있으며, 길이는 100글자보다 작거나 같다. 각 줄은 마침표(“.”)로 끝난다.
입력의 종료조건으로 맨 마지막에 점 하나(“.”)가 들어온다.
각 줄마다 해당 문자열이 균형을 이루고 있으면 “yes”를, 아니면 “no”를 출력한다.
So when I die (the [first] I will see in (heaven) is a score list).
[ first in ] ( first out ).
Half Moon tonight (At least it is better than no Moon at all].
A rope may form )( a trail in a maze.
Help( I[m being held prisoner in a fortune cookie factory)].
([ (([( [ ] ) ( ) (( ))] )) ]).
.
.
yes
yes
no
no
no
yes
yes
7번째의 “ .”와 같이 괄호가 하나도 없는 경우도 균형잡힌 문자열로 간주할 수 있다.
ICPC > Regionals > Asia Pacific > Japan > Japan Domestic Contest > 2011 Japan Domestic Contest B번
while True:
str = input()
stack = []
# 마침표가 나오면 break 걸어서 입력 후 괄호를 판별하는 for문으로 넘어가도록 한다.
# (stack이 비어있는 상태에서 마침표로만 끝나도 yes로 간주)
if str == '.':
break
# 괄호 판별
for c in str:
# 여는 괄호가 나온다면 무조건 stack에 추가
if c == '[' or c == '(':
stack.append(c)
elif c == ']':
# 스택에 비어있지 않으면서 짝이 맞는 '[' 괄호가 있다면
if len(stack) != 0 and stack[-1] == '[':
stack.pop() # stack 리스트를 비운다.
# 스택에 짝 조건에 맞는 괄호가 없는 경우
else:
stack.append(c) # 그대로 ']'를 stack에 추가
break
elif c == ')':
# 스택에 비어있지 않으면서 짝이 맞는 '(' 괄호가 있다면
if len(stack) != 0 and stack[-1] == '(':
stack.pop() # stack 리스트를 비운다.
# 스택에 짝 조건에 맞는 괄호가 없는 경우
else:
stack.append(c) # 그대로 ')'를 stack에 추가
break
# 최종 결과 판별
# stack이 비어있다면,
if len(stack) == 0:
print('yes')
# stack이 비어있지 않다면,
else:
print('no')
전에 풀었던 백준 9012번: ‘괄호’ 문제와 비슷한 유형의 문제라서, 괄호 문제를 풀었다면 이 문제를 푸는데 큰 어려움은 없을 것 같다.
풀이도 마찬가지로 대괄호 조건이 하나 더 추가되었을 뿐이지 괄호 문제와 거의 같은 방식이다.
짝이 맞는 괄호가 나오면 stack 리스트를 비우고,
짝이 맞는 괄호가 없다면 stack 리스트 안에 요소가 그대로 들어있게 된다.최종적으로, stack 리스트가 비어있는지, 안 비어있는지에 따라서, 짝이 맞는 괄호인지(‘yes’) 아닌지(‘no’)를 판별한다.
스택은 기본적인 자료구조이기도 하지만, 문제를 풀면서 보면 참 재밌는 유형의 문제가 많은 것 같다.