거품 정렬, 선택 정렬 , 삽입 정렬 , (합병 정렬, 퀵 정렬, 힙 정렬) , 이분 탐색, dfs, bfs, 동적 계획법
1-1. 정렬 방식
1-2. 시간 복잡도
1-3. 장점
1-4. 단점
2-1. 정렬 과정
2-2. 정렬 방식
2-2 (1) 최대 힙의 삽입
- 힙에 새로운 요소가 들어오면 일단 새로운 노드를 힙의 마지막 노드에 이어서 삽입
- 새로운 노드를 부모 노드들과 교환해서 힙의 성질을 만족 시킴 (부모 노드가 더 작을 시 교환하는 방식으로 진행)
2-2 (2) 최대 힙의 삭제
- 최대 힙에서 최댓값은 루트 노드이므로 루트 노드가 삭제됨
- 최대 힙에서 삭제 연산은 최댓값을 가진 요소를 삭제하는 것
- 삭제된 루트 노드에는 힙의 마지막 노드를 가져옴
- 힙을 재구성
2-3. 시간 복잡도
2-4. 장점 (유용한 순간)
3-1. 정렬 방식(오름차순)
3-2. 시간 복잡도
3-3. 장점
3-4. 단점
** k+1의 요소를 찾기 위해 나머지 모든 요소들을 탐색하는 selection sort와 비교했을 때 k+1번쨰 요소를 배치하는데 필요한 만큼의 요소만 탐색하는 insert sort가 훨 효율적으로 실행된다 볼 수 있음
4-1. 정렬 과정
4-2. 정렬 방식 (오름차순)
4-3. 시간 복잡도
4-4. 장점
4-5. 단점
7. 이분탐색
- 처음부터 끝까지 돌면서 탐색하는 것보다 훨씬 빠르다는 장점을 지님
- (전체 탐색 O(N) , 이분 탐색 O(logN))
public static int binarySearch(int[] A, int n) {
int first = 0;
int last = A.length - 1;
int mid = 0;
while (first <= last) {
mid = (first + last) / 2;
if (n == A[mid])
return 1;
else {
if (n < A[mid])
last = mid - 1;
else
first = mid + 1;
}
}
return 0;
}
- 깊이 우선 탐색 DFS
- 갈 수 있는 만큼 최대한 깊이 가고, 더 이상 갈 곳이 없으면 이전 정점으로 돌아가는 것
- stack과 재귀 함수를 사용하여 구현
(1) 인접 행렬로 구현 했을 경우
int[][] adjArray = new int[n+1][n+1];
// 두 정점 사이에 여러 개의 간선이 있을 수 있다.
// 입력으로 주어지는 간선은 양방향이다.
for(int i = 0; i < m; i++) {
int v1 = sc.nextInt();
int v2 = sc.nextInt();
adjArray[v1][v2] = 1;
adjArray[v2][v1] = 1;
}
System.out.println("DFS - 인접행렬 / 재귀로 구현");
dfs_array_recursion(v, adjArray, visited);
Arrays.fill(visited, false); // 스택 DFS를 위해 visited 배열 초기화
System.out.println("\n\nDFS - 인접행렬 / 스택으로 구현");
dfs_array_stack(v, adjArray, visited, true);
- 시간 복잡도 : dfs 하나 당 N번의 loop를 돌게 되므로 O(n)의 시간 복잡도를 가짐 , 여기서 N개의 정점을 모두 방문해야하므로 n*O(n)
--> 인접행렬로 구현 했을 경우 O(n^2)의 시간 복잡도를 가짐
> #### (2) 인접 리스트로 구현했을 경우
LinkedList[] adjList = new LinkedList[n + 1];
for (int i = 0; i <= n; i++) {
adjList[i] = new LinkedList();
}
// 두 정점 사이에 여러 개의 간선이 있을 수 있다.
// 입력으로 주어지는 간선은 양방향이다.
for (int i = 0; i < m; i++) {
int v1 = sc.nextInt();
int v2 = sc.nextInt();
adjList[v1].add(v2);
adjList[v2].add(v1);
}
for (int i = 1; i <= n; i++) { // 방문 순서를 위해 오름차순 정렬
Collections.sort(adjList[i]);
}
System.out.println("DFS - 인접리스트");
dfs_list(v, adjList, c);
> #### (2) 인접 리스트로 구현했을 경우 (재귀)
public static void dfs_list(int v, LinkedList[] adjList, boolean[] visited) {
visited[v] = true; // 정점 방문 표시
System.out.print(v + " "); // 정점 출력
Iterator<Integer> iter = adjList[v].listIterator(); // 정점 인접리스트 순회
while (iter.hasNext()) {
int w = iter.next();
if (!visited[w]) // 방문하지 않은 정점이라면
dfs_list(w, adjList, visited); // 다시 DFS
}
}
- 시간 복잡도 : dfs가 총 N번 호출되긴하나 인접 행렬과 달리 인접 리스트로 구현하게 되면 dfs 하나당 각 정점에 연결되어 있는 간선의 개수만큼 탐색을 하므로 예측이 불가능 , but dfs가 끝난 후를 생각하면 모든 정점을 한번씩 다 방문하고 모든 간선을 한번씩 다 검사했다 볼 수 있으므로 O(n+e)의 시간이 걸렸다고 할 수 있음
--> 인접 리스트로 구현했을 경우 O(n+e)의 시간 복잡도를 가짐
<br>
> ### 9. 너비 우선 탐색 BFS
- queue를 이횽하여 지금 위치에 갈 수 있는 정점을 모두 queue에 넣는 것
### 9-1. 장점
- 너비를 우선으로 탐색하므로 답이 되는 경로가 여러개인 경우에도 최단 경로를 얻을 수 있음
- 경로가 무한하게 깊어져도 최단 경로를 반드시 찾을 수 있음
- 노드 수가 적고 깊이가 얕은 해가 존재할 때 유리
### 9-2. 단점
- dfs와 달리 큐를 이용하여 다음에 탐색할 정점들을 저장하므로 더 큰 저장 공간이 필요함
### 9-3. 구현
- 인접 행렬로 구현했는지 인접 리스트로 구현했는지에 따라 구현 방법 상이
> #### (1) 인접행렬로 구현했을 경우
int[][] adjArray = new int[n+1][n+1];
// 두 정점 사이에 여러 개의 간선이 있을 수 있다.
// 입력으로 주어지는 간선은 양방향이다.
for(int i = 0; i < m; i++) {
int v1 = sc.nextInt();
int v2 = sc.nextInt();
adjArray[v1][v2] = 1;
adjArray[v2][v1] = 1;
}
System.out.println("BFS - 인접행렬");
bfs_array(v, adjArray, visited);
public static void bfs_array(int v, int[][] adjArray, boolean[] visited) {
Queue q = new LinkedList<>();
int n = adjArray.length - 1;
q.add(v);
visited[v] = true;
while (!q.isEmpty()) {
v = q.poll();
System.out.print(v + " ");
for (int i = 1; i <= n; i++) {
if (adjArray[v][i] == 1 && !visited[i]) {
q.add(i);
visited[i] = true;
}
}
}
}
- 시간 복잡도 : 정점 한개당 N번의 for loop를 돌기 때문에 O(n)의 시간이 걸림, 이 for loop는 큐에 아무것도 없을 때 까지 (== 모든 정점을 방문할 때까지) 실행되므로 n번 반복 실행됨
--> 인접 행렬로 구현했을 경우 O(n^2) 시간 복잡도를 가짐
> #### (2) 인접 리스트로 구현했을 경우
LinkedList[] adjList = new LinkedList[n + 1];
for (int i = 0; i <= n; i++) {
adjList[i] = new LinkedList();
}
// 두 정점 사이에 여러 개의 간선이 있을 수 있다.
// 입력으로 주어지는 간선은 양방향이다.
for (int i = 0; i < m; i++) {
int v1 = sc.nextInt();
int v2 = sc.nextInt();
adjList[v1].add(v2);
adjList[v2].add(v1);
}
for (int i = 1; i <= n; i++) {
Collections.sort(adjList[i]); // 방문 순서를 위해 오름차순 정렬
}
System.out.println("BFS - 인접리스트");
bfs_list(v, adjList, visited);
public static void bfs_list(int v, LinkedList[] adjList, boolean[] visited) {
Queue queue = new LinkedList();
visited[v] = true;
queue.add(v);
while(queue.size() != 0) {
v = queue.poll();
System.out.print(v + " ");
Iterator<Integer> iter = adjList[v].listIterator();
while(iter.hasNext()) {
int w = iter.next();
if(!visited[w]) {
visited[w] = true;
queue.add(w);
}
}
}
}
- 시간 복잡도 : 리스트로 구현된 dfs와 비슷한 논리로 시간 복잡도를 구하게 됨, bfs를 다 끝낸 후를 생각하면 모든 간선에 대해 한번씩 검사를 하고 각 정점을 한번씩 모두 방문
--> 인접리스트로 구현했을 경우 O(n+e)의 시간 복잡도를 가짐
==> 두 알고리즘 모두 인접행렬을 사용하여 만든 그래프의 경우에는 O(n^2)의 시간복잡도를 가지고 인접리스트를 사용하여 만든 그래프의 경우에는 O(n+e)의 시간복잡도를 가짐
<br>
> ### 10. 동적 계획 법 ( DP - Dynamic Programming )
- 큰 문제를 작은 문제로 나누고 작은 문제들의 답을 이용해 큰 문제의 답을 구하는 방법론
- 이전 문제의 답을 기억하면서 푸는 것이라 할 수 있음 (memorization)
- dp 문제는 재귀 문제와 매우 비슷한데 일반적 재귀와 달리 dp를 사용하면 한 번 계산한 문제의 답을 다시 계산하지 않아도 되기 때문에 시간을 단축할 수 있음
### 10-1. 조건
- 최적 부분 구조 : 큰 문제의 답에 작은 문제의 답이 포함되어 있는 것
- 부분 문제 반복 : 한 번 구했던 답이 나중에도 사용되며 이 답은 변하지 않아야 한다는 것
ex) 피보나치 수열
### 10-2. 풀이 종류
#### (1) Bottom-Up
- 작은 문제부터 해결하는 것
- 보통 for 문을 사용해 재귀 없이 해결하여 시간과 메모리를 절약할 수 있다는 장점이 있음
- but 직관적으로 이해하기 힘들 수 있다는 단점 존재
ex ) dp 테이블 초기화 , 피보나치를 예를 들면 1, 2는 구할 수 없으니 초기화 해준 뒤 for문을 통해 3부터(작은 문제) 시작해서 점화식을 이용해 n까지 채워나가는 경우
def fibonacci_dp(num):
f = [0, 1]
for i in range(2, num+1):
f.append(f[i-1] + f[i-2])
return f
#### (2) Top-Down
- 큰 문제에서 부터 작은 문제로 내려가면서 큰 문제의 답을 구하는 방법
- 보통 재귀로 문제 해결
- 재귀를 돌면서 전에 계산해둔 값이 있으면 쓰고 없으면 계산하는 식
- 최대 재귀 깊이 초과 에러가 발생할 수 있으며 메모리와 시간을 bottom-up에 비해 많이 잡아 먹는다는 단점이 있음
- but 점화식을 이해하기 쉽다는 장점도 존재
ex) memorization할 수 있는 리스트 초기화 후 피보나치 함수를 재귀로 구현한 뒤 게산한 값이 있으면 값을 반환 없다면 점화식에 따라 피보나치 결과를 반환하는 방식으로 재귀 함수에 n(큰 문제)를 매개변수로 넘겨주고 재귀를 돌리는 방식의 경우
def fibonacci_memori(num):
global memo
if num >= 2 and len(memo) <= num:
memo.append(fibonacci_memori(num-1) + fibonacci_memori(num-2))
return memo[num]
memo = [0, 1]
### 10-3. 비교 ?
- 더 좋은 방법을 명확히 택하기 어렵지만 dp 풀이 방식은 top-down이 자연스러운 접근이 가능한 경우가 많음
but 효율성을 고려할 경우 bottom-up 을 사용하는 것이 더 좋음
- 하위 문제가 복잡하여 점화식으로 풀어낼 방법이 떠오르지 않을 경우에도 top-down으로 접근하는 경우가 많음
### 10-4. DP 문제
1) 문제 구별
(1) 큰 문제의 답에 작은 문제의 답이 포함되어 있는지
- 재귀 스러운 문제인가 ?
- 큰 문제를 같은 형태의 작은 문제로 나눌 수 있는가 ?
(2) 하위 문제의 계산이 반복되는지
- 한 번 구했던 문제의 답이 나중에도 사용되는가 ?
- 작은 문제의 답은 항상 같은가 ?
(3) 최적화, 최대화, 최소화, 경우의 수를 구하는 유형인지
- 위의 두 개를 생각해 봤는데도 헷갈릴 경우 생각해보기
- 보통 이런 유형의 문제일 때 dp로 푸는 경우가 많음
2) DP vs 완전탐색 vs 그리디
(1) 그리디
- 현재 선택 지 중에서 가장 좋은 방법만 선택
- 작은 문제를 풀 때 그 상황에서 가장 좋아보이는 것만 선택해도 큰 문제를 풀 수 있을 때
- 내가 생각한 딱 하나의 최적의 방법이 끝까지 반례 없이 적용될 때
(2) 완전탐색
- 모든 경우의 수를 탐색
- 아무런 규칙이 없어서 모든 경우의 수를 탐색해야 할 때
==> 완전 탐색이라기엔 시간이 부족하고 그리디라하기엔 항상 탐욕적으로 취해서는 답이 나오지 않는 경우 dp
3) 풀이 방법
- 문제의 조건대로 손으로 그려보며 반복 되는 부분 찾기
- dp테이블 dp[i]는 무엇을 의미하는지 정의
- 디테일한 점화식 설계( dp[i] = dp[i+1] + dp[i-2] )
- 필요에 따라 dp 테이블 확장 ( dp[i] --> dp[i][j] )
- 중복되는 부분을 어떻게 활용할수 있을지 고민 ( 어떤 방법으로도 점화식이 세워지지 않을 경우 계산 과정이나 정의의 순서를 뒤집어 생각 ) ex 1+1 = 2 x 2 = 1 + 1 로 두고 위 과정 반복
4) 에시 문제 (백준 1로 만들기 #1463)
n = int(input()) # 1로 만들 값
d = [0] * (N+1) # memorization
for i in range(2, n+1):
d[i] = d[i-1] + 1 # i라는 수의 1까지 최소 연산 횟수는 그 전의 수의 최소 연산 횟수 + 1회
if i % 2 == 0:
# i라는 수의 최소 연산 횟수는 그 전의 수의 최소 연산 횟수 + 1회,
# i를 2로 나눈 수의 최소 연산 횟수 + 1회 중에 최솟값
d[i] = min(d[i], d[i//2] + 1)
if i % 3 == 0:
d[i] = min(d[i], d[i//3] + 1)
print(d[n])
알고리즘 문제는 자꾸 파이썬으로 풀어버릇했더니 또 파이썬으로 써놨읍니다 sorry ~
끝