알고리즘

Tree (2)

Disciple428 2024. 2. 21. 17:55

순회 ≠ 탐색

순회는 노드 아래 쪽의 서브 트리만 돌지만, 탐색은 원칙적으로 전부 다 돌아야 한다.

 

  • 수식 트리

수식을 표현하는 이진 트리

수식 이진 트리라고 부르기도 한다.

연산자는 루트 노드거나 가지 노드이고 피연산자는 모두 잎 노드이다!

 

수식 트리를

중위 순회하면 수식을 중위 표기법으로 쓰게 된다.

후위 순회, 전위 순회하면 각각 후위 표기법, 전위 표기법으로 쓰게 된다.

 

  • 이진 탐색 트리

탐색작업을 효율적으로 하기 위한 자료구조

모든 원소는 서로 다른 유일한 키를 갖는다.

왼쪽 서브트리의 key < 루트 노드의 key < 오른쪽 서브트리의 key

왼쪽 서브트리와 오른쪽 서브트리도 이진 탐색 트리다.

 

중위 순회하면 오름차순으로 정렬된 값을 얻을 수 있다.

루트 노드를 중심으로 왼쪽은 루트보다 값이 작고 오른쪽은 루트보다 값이 크다.

 

  • 이진 탐색 트리 - 연산

루트에서 시작할 때 탐색할 키 값을 루트 노드의 키 값과 비교한다.

키 값이 루트 노드의 키 값과 같으면 원하는 원소를 찾았으므로 탐색연산이 성공한다.

키 값 < 루트 노드의 키 값 : 루트 노드의 왼쪽 서브트리에서 탐색 연산을 수행

키 값 > 루트 노드의 키 값 : 루트 노드의 오른쪽 서브트리에서 탐색 연산을 수행

 

노드를 삽입하는 경우 (삽입 연산)

1. 먼저 탐색 연산을 수행한다.

→ 삽입할 원소와 같은 원소가 트리에 있으면 삽입할 수 없으므로, 같은 원소가 트리에 있는지 확인

→ 탐색 실패가 결정되는 위치가 삽입할 위치가 된다.

2. 탐색 실패한 위치에 원소를 삽입한다.

 

  • 이진 탐색 트리의 성능

탐색, 삽입, 삭제 시간은 트리의 높이만큼 시간이 걸린다.

→ O(h) , h = 이진 탐색 트리(BST)의 깊이

 

평균의 경우 (이진 트리가 균형적으로 생성되어 있는 경우)

→ O(log n)

 

최악의 경우 (한쪽으로 치우친 경사 이진 트리의 경우)

→ O(n) , 순차 탐색과 시간복잡도가 같다.

 

  • 힙 (Heap)

완전 이진 트리로 구현된 자료구조로서 키 값이 가장 큰 노드나 키 값이 가장 작은 노드를 찾기에 적합하다.

힙의 키를 우선순위로 활용하여 우선순위 큐를 구현할 수 있다.

 

최대 힙 (max heap)

→ 키 값이 가장 큰 노드를 찾기 위한 완전 이진 트리

부모 노드의 키 값 > 자식 노드의 키 값 (형제 끼리는 상관없음)

루트 노드의 키 값이 가장 크다.

 

최소 힙 (min heap)

→ 키 값이 가장 작은 노드를 찾기 위한 완전 이진 트리

부모 노드의 키 값 < 자식 노드의 키 값

루트 노드의 키 값이 가장 작다.

 

완전 이진 트리가 아니거나 키 값의 대소 관계가 일정하지 않으면 힙이 아니다!

 

  • 힙 연산 - 삭제

힙에서는 루트 노드의 원소만을 삭제할 수 있다.

루트 노드의 원소를 삭제하여 반환

힙의 종류에 따라 최대값 또는 최소값을 구할 수 있다.

# 최대힙
def enq(n):
    global last
    last += 1
    # 마지막 노드 추가 (완전이진트리를 유지하기 위해)
    h[last] = n     # 마지막 노드에 데이터 삽입
    # 부모 > 자식 조건을 만족하도록 유지해야 함
    # 비교를 위해 부모 노드 번호를 계산
    c = last
    p = c//2
    while p >= 1 and h[p] < h[c]:   # 부모가 있는데, 더 작으면
        h[p], h[c] = h[c], h[p]     # 교환
        c = p
        p = c//2

def deq():
    global last
    tmp = h[1]   # 루트의 키값 보관
    h[1] = h[last]
    last -= 1
    p = 1       # 새로 옮긴 루트
    c = p * 2
    while c <= last:    # 자식이 있으면
        if c+1 <= last and h[c] < h[c+1]:   # 오른쪽 자식이 있고 더 크면
            c += 1
        if h[p] < h[c]:
            h[p], h[c] = h[c], h[p]
            p = c
            c = p * 2
        else:
            break
    return tmp

N = 10  # 필요한 노드 수
h = [0]*(N+1)   # 최대 힙
last = 0    # 힙의 마지막 노드 번호
enq(2)
enq(5)
enq(3)
enq(6)
enq(4)

while last > 0:
    print(deq())

# 6
# 5
# 4
# 3
# 2

 

  • 이진 탐색 트리 사용 이유

→ 시간 복잡도를 logN으로 줄일 수 있어서? (X 효율적이게 되는건 맞지만 정확한 답은 아니다.)

→ 이진탐색과 비교시, 시간 복잡도는 logN으로 동일한데 배열에 저장해 놓고 정렬해서 쓰지 않고 이진 탐색 트리를 왜 쓰는가?

이진 탐색 트리는 정렬 비용을 최소화해 효율적인 탐색 가능하기 때문!

 

기존의 이진탐색은 한가지를 간과했다. 정렬비용을 생각하지 않고 탐색만 고려한 것

list에서 삽입 ,삭제가 이루어지지 않아도 추가 정렬할 필요가 없다면 이진탐색만 해도 ㄱㅊ

그러나 현실에서는 데이터 추가 삽입, 삭제가 계속 일어나는 곳에서 탐색(검색) 필요

이진탐색은 항상 정렬된 상태가 유지 되어야 함. 이 때 ‘정렬비용’ 추가 발생

(이진 탐색 트리를 사용하는 이유)

 

  • 정렬 비용 : 연속적인 데이터의 앞 쪽에서 데이터 삽입/삭제시 발생 ( 시간 복잡도 O(n))

정렬된 상태를 유지하려면 or 중간에 new 데이터(새로운 인덱스) 삽입 하려면, 삽입점 뒤의 자료들은 모두 한 칸씩 옮겨야 한다. 맨 앞에 추가된다면 모든 데이터 n개의 인덱스 수정 n번 발생.

 

lst = [1, 2, 3] 값은 연속적인 메모리 공간에 저장

lst = [1, 2, 3] 의 인덱스는 lst 내의 값들을 가르키는 ‘주소’ 이므로 연속적 공간에 저장되지 않음

lst에 100을 추가한다면 연속적인 공간에 1, 2, 3 다음에 100을 저장.

 

  • 정렬 비용의 시간복잡도

list에서 데이터 갯수만큼 비례하여 시간 비용 발생하므로 데이터 삽입/삭제의 시간복잡도 O(n)

Tree에서는 트리의 높이만큼 시간 비용 발생 (logN)

 

이진 탐색 트리의 성능을 살펴보자

 

  • 편향 이진트리

이진 트리에서 균형을 이루는 것이 왜 필요할까?

→ 좌우 노드의 갯수가 최대한 균형을 이루어야 잘 분할 되어있는 것이므로 권장됨

= 트리의 높이를 최소화

= 시간복잡도 최소화

 

어떤 값을 반복하며 검색하는 상황에서 부모를 기준으로

왼쪽으로 탐색하면 작은 값으로

오른쪽으로 탐색하면 큰 값으로

따라가며 탐색 하게 된다!

→ 가장 멀리 떨어진 값으로 따라가더라도 트리 높이만큼 반복을 수행한다.

(편향 이진 트리가 좋지 않은 이유)

 

편향 이진 트리가 길어진다면 그냥 list 에서 삽입/삭제/탐색이 이루어지는 것과 다를 게 없다.

 

  • 검색 알고리즘

이진 탐색 트리 , 해싱

두가지를 현실에서 가장 많이 사용한다.

 

완전 이진 트리에서 새로운 데이터의 삽입/삭제가 일어나는 와중에 max value, min value 알고 싶을 때 사용한다.

 

  • 이진힙(binary heap)

구현이 가장 간단하여 이해하기가 용이하다.

 

단 2가지 유의점이 있다. (중요)

1) 완전 이진 트리 구조 유지

  • 새로운 데이터 추가/삭제 과정 중에 완전 이진 트리 구조를 유지해야 함(1차 배열)
    → 1차 배열 형태로 저장

idx 1번 ~ N번 까지 중간에 빈 값 없이 모든 값 저장을 보장
데이터가 추가되어 N+1 개가 되더라도 N+1개 저장됨
→ 데이터 삭제한다면 끝을 가르키는 last는 N → N-1로 수정

  • hsize : 맨 마지막 값이자 전체 자리수 나타내는 변수(stack의 top 같은 개념)
    • 루트 값을 어딘가 저장해 백업
    • 마지막 노드의 값을 루트 자리에 이동
    • 마지막 N 가르키던 hsize N-1 가르키도록 처리

 

2) 부모 - 자식 관계

트리 내에서 어떤 노드든지 부모 - 자식 관계 유지할 것

최대힙 : 부모>자식

최소힙 : 부모<자식 인 관계를 유지해야 함.

→ 충족되지 않는다면 서로 자리를 바꾼다.

 

1)번 먼저 처리 후 2)번을 처리한다.

 

# 최대힙
# 힙의 저장소 (H[0]은 사용하지 않음)
H = [0] * 100
hsize = 0  # 마지막 노드 번호(idx)이자 전체 노드 수 의미

# 최대힙 연산 종류: push, pop
# 힙은 공통적으로 관례적으로 쓰이는 이름 없지만 스택과 비슷하여 push, pop 을 주로 사용

def push(item):
    global hsize
    '''1. 완전 이진 트리 구조 유지'''
    hsize += 1  # 마지막 위치
    H[hsize] = item

    '''2. 부모 - 자식 관계 조정'''
    # hsize 바꾸면 안되니 부모 자식 가르키는 새로운 변수 p, c
    p, c = hsize // 2, hsize

    '''종료조건 1. 부모가 자식보다 큰 경우 2. 자식이 루트가 되는 경우'''
    # 반복 해나간다. 더이상 부모<->자식 바꿀 필요 없을 때 까지 또는 바꿀 것이 없을 때 까지
    while p > 0 and H[p] < H[c]:
        H[p], H[c] = H[c], [p]
        p, c = p // 2, p

def pop():
    # 설명 생략 알아서 해봐라
    pass

from heapq import heappop, heappush
data = [69, 10, 30, 2, 16, 8, 31, 22]

# 힙저장소를 리스트로 제공
H = []

for val in data:
    heappush(H, val)

while H:
    print(heappop(H))

# 2
# 8
# 10
# 16
# 22
# 30
# 31
# 69

다른 방법도 있다!

from queue import PriorityQueue
data = [69, 10, 30, 2, 16, 8, 31, 22]

Q = PriorityQueue()
for val in data:
    Q.put(val)

while not Q.empty():  # 그냥 Q: 이렇게 하면 안된다. empty 를 사용해야 함
    print(Q.get())

# 2
# 8
# 10
# 16
# 22
# 30
# 31
# 69

객체 지향적이라 heapq 보다 좀 더 쓰기가 편하다. 대신 처리시간이 좀 걸리는 방법이다.