C-1: 트리와 이진트리 — "노드가 자식 둘을 가리키면, 데이터가 가지를 친다"
목차 29
안녕하세요! 코딩테스트와 CS의 길잡이, 홍순구 튜터입니다.
지난 시간엔 해시 테이블로 "값 자체로 위치를 계산해 평균 O(1)에 찾기"까지 왔습니다. 이걸로 카테고리 B, 선형 자료구조가 끝났어요. 스택·큐·연결 리스트·해시 — 모양은 달라도 전부 데이터를 한 줄로 늘어놓는 그릇이었습니다. 쌓거나, 줄 세우거나, 이어 붙이거나, 칸에 흩어 두거나.
오늘부터는 데이터가 한 줄이 아니라 가지를 치는 비선형 구조로 넘어갑니다. 첫 주자는 트리(tree)예요. 연결 리스트의 노드가 next 하나로 "다음 하나"만 가리켰다면, 트리의 노드는 왼쪽·오른쪽 두 포인터로 "자식 여럿"을 가리킵니다. 가계도처럼 위에서 아래로 갈라지는 구조죠. 지난 시간에 잡은 노드와 포인터 감각이 그대로 이어지니, 한 노드가 여러 노드를 가리키는 그림을 떠올리며 시작해 봅시다.
트리는 난이도가 한 칸 올라갑니다. 특히 트리를 훑는 순회를 재귀로 짜는 대목이 첫 고비예요. 하지만 원리는 단순합니다. "트리는 루트 하나에 작은 트리(서브트리)가 매달린 구조"라는 한 문장이면 순회도 탐색도 다 풀립니다.
오늘의 여정 — "노드가 자식 둘을 가리키면, 데이터가 가지를 친다"
[1] 트리 용어 → 루트·리프·부모/자식·높이·깊이 (가계도의 언어)
[2] 이진트리 만들기 → 노드 하나에 왼쪽·오른쪽 두 포인터 (연결 리스트의 확장)
[3] 전위 순회 → 나 → 왼 → 오, 재귀로 트리를 타고 내려간다
[4] 중위·후위 순회 → '나를 언제 방문하느냐'만 바꾼다 (중위 = 정렬)
[5] 반복 순회 → 재귀의 숨은 스택을 손으로 꺼낸다
[6] BST 삽입 → 왼쪽은 작게, 오른쪽은 크게
[7] BST 탐색 → 절반씩 버려 평균 O(log n), 치우치면 최악 O(n)
[8] 트리로 답 구하기 → 높이·노드 수·지름 (자식 결과를 모아 부모 답)
자, 데이터가 가지를 치는 첫 그릇을 만들러 가 봅시다. 출발합니다!
💡 오늘 수업의 핵심 — "트리를 노드와 두 포인터로 직접 만들고, 세 가지 순회(전위·중위·후위)를 재귀와 반복으로 짜며, 이진탐색트리의 삽입·탐색이 평균 O(log n)인 까닭과 한쪽으로 치우치면 최악 O(n)으로 무너지는 이유를 손으로 확인한다"
🎯 학습 목표
- 트리 용어(루트·리프·높이·깊이)를 잡고, 노드의 왼쪽·오른쪽 두 포인터로 이진트리를 직접 만듭니다.
- 전위·중위·후위 순회를 재귀로 짜고, 재귀의 숨은 스택을 명시적 스택으로 바꿔 반복 순회까지 구현합니다(순회는 시간 O(n)·공간 O(h)).
- 이진탐색트리(BST)의 삽입·탐색이 평균 O(log n)·최악 O(n)인 이유를 트리 높이로 설명하고, 트리의 지름 같은 문제를 한 번의 순회로 O(n)에 풉니다.
Step 1: "가지를 치는 데이터" — 트리 용어
지금까지 배운 그릇은 전부 한 줄이었습니다. 배열은 원소가 옆으로 나란히, 연결 리스트는 노드가 앞뒤로 이어졌죠. 오늘의 트리는 다릅니다. 한 노드가 아래로 여러 갈래로 갈라져요. 가장 익숙한 예가 가계도입니다. 맨 위 조상 한 명에서 자식들로, 그 자식의 자식들로 아래로 뻗어 나가죠.
트리를 다루려면 먼저 그 언어를 익혀야 합니다. 용어가 곧 지도예요.
(A)
/ \
(B) (C)
/ \ \
(D) (E) (F)
루트(root) : 맨 위, 부모가 없는 노드 → A
리프(leaf) : 자식이 없는 노드 → D, E, F (잎)
부모-자식 : A는 B·C의 부모(parent), B·C는 A의 자식(child)
간선(edge) : 부모와 자식을 잇는 선
서브트리 : 어떤 노드와 그 아래 전부 (B와 D·E는 하나의 서브트리)
여기에 두 가지 수치가 더 붙습니다. 깊이(depth)는 루트에서 그 노드까지 내려온 간선의 수예요(루트의 깊이는 0). 높이(height)는 반대로, 그 노드에서 가장 먼 리프까지 내려가는 간선의 수입니다(리프의 높이는 0). 트리 전체의 높이는 루트의 높이고요. 위 그림에서 A→D는 간선 2개라 D의 깊이는 2, 트리의 높이도 2입니다.
오늘 다루는 건 그중에서도 이진트리(binary tree)입니다. 자식이 최대 둘(왼쪽·오른쪽)로 제한된 트리예요. 코딩테스트에 나오는 트리는 대부분 이 이진트리라, 여기에 집중합니다.
🎯 코테에서는 트리 문제가 "이 트리의 무엇을 구하나"로 시작합니다. 높이·노드 수·특정 경로·최댓값처럼요. 그리고 트리는 사실 사이클(고리) 없이 연결된 특수한 그래프인데, 이 관계는 그래프를 배울 때 다시 만납니다. 오늘은 "위에서 아래로 갈라지는 구조"로만 봐도 충분해요.
💡 한 줄 정리
트리는 한 노드가 아래로 여러 자식으로 갈라지는 비선형 구조다. 루트(맨 위)·리프(잎)·부모/자식·깊이(위에서 내려온 간선 수)·높이(아래로 내려갈 간선 수)가 트리를 읽는 기본 언어이고, 자식이 최대 둘인 이진트리를 주로 다룬다.
🙋 학생 질문 — "튜터님, 트리랑 연결 리스트는 뭐가 다른 건가요? 둘 다 노드로 잇는 거잖아요."
정확히 짚었어요. 둘 다 "노드 + 포인터"라는 뼈대는 같습니다. 차이는 포인터가 몇 개냐예요.
연결 리스트의 노드는 next 하나만 가집니다. 그래서 데이터가 한 줄로만 이어지죠. 트리의 노드는 왼쪽·오른쪽 두 개(일반 트리면 더 많이)를 가져서, 한 노드에서 여러 갈래로 갈라집니다. 그게 "선형 vs 비선형"의 갈림이에요.
재미있는 건, 연결 리스트가 사실 트리의 특수한 경우라는 겁니다. 자식이 항상 하나뿐인(오른쪽으로만 이어지는) 트리를 상상하면, 그게 바로 연결 리스트예요. 실제로 오늘 Step 7에서 BST가 한쪽으로만 자라면 연결 리스트와 똑같아지는 걸 보게 됩니다. 그러니 지난 시간에 익힌 노드·포인터 감각을 그대로 가져오되, "이번엔 포인터가 둘"이라고만 바꿔 생각하면 됩니다.
Step 2: "노드 하나에 두 포인터" — 이진트리를 코드로
이제 용어를 코드로 옮깁니다. 연결 리스트의 노드는 값 하나와 next 하나였죠. 트리의 노드는 값 하나에 왼쪽·오른쪽 두 포인터를 답니다. 딱 그 차이뿐이에요.
# structures/binary_tree.py
class TreeNode:
"""이진트리의 노드: 값 하나와 왼쪽·오른쪽 자식을 가리키는 포인터 둘.
연결 리스트 노드가 next 하나였다면, 트리 노드는 left·right 둘이다.
자식이 없으면 그 포인터는 None이다(리프 노드는 둘 다 None).
"""
def __init__(self, val, left=None, right=None):
self.val = val
self.left = left # 왼쪽 자식(없으면 None)
self.right = right # 오른쪽 자식(없으면 None)
노드가 있으면 트리는 노드를 이어 붙여 만듭니다. 아래 표본 트리를 손으로 조립해 볼게요. 리프부터 만들어 위로 붙여 올라갑니다.
def build_sample_tree():
n4, n5, n6 = TreeNode(4), TreeNode(5), TreeNode(6)
n2 = TreeNode(2, n4, n5)
n3 = TreeNode(3, None, n6)
return TreeNode(1, n2, n3)
이 코드가 만드는 트리는 이렇게 생겼습니다. 오늘 순회 예시에 계속 쓸 트리라 눈에 익혀 두세요.
1
/ \
2 3
/ \ \
4 5 6
값 1이 루트고, 왼쪽 자식 2 아래에 4·5가, 오른쪽 자식 3 아래에 6이 매달렸습니다. 3은 왼쪽 자식이 없어 TreeNode(3, None, n6)처럼 왼쪽을 None으로 뒀어요. 노드 하나에 접근하는 건 O(1), 트리 전체를 만드는 건 노드 수만큼이라 O(n)입니다.
실전 코테에선 트리를 이렇게 클래스로 짜기도 하지만, 노드가 문제로 이미 주어지거나(LeetCode의 TreeNode), 부모-자식 관계를 딕셔너리로 표현하기도 합니다. 어느 쪽이든 "노드 하나가 자식들을 가리킨다"는 뼈대는 같아요.
🎯 코테에서는 LeetCode의 트리 문제가 바로 이
TreeNode클래스(val·left·right)를 그대로 줍니다. 문제 설명에 클래스가 미리 정의돼 있고, 여러분은 그 노드를 받아 순회하거나 값을 구하면 돼요. 그래서 노드 구조를 미리 파악해 두는 게 트리 문제의 출발점입니다.
💡 한 줄 정리
이진트리의 노드는 값 하나와 왼쪽·오른쪽 두 포인터를 가진다. 자식이 없으면 그 포인터는 None(리프는 둘 다 None)이다. 노드를 이어 붙여 트리를 만들고, 노드 접근은 O(1)·트리 구성은 O(n)이다.
🙋 학생 질문 — "튜터님, 자식이 셋 이상인 트리는 없나요?"
있습니다. 자식 수에 제한이 없는 트리를 일반 트리(general tree)라고 해요. 폴더 구조를 떠올려 보세요. 한 폴더 안에 하위 폴더가 둘일 수도, 열 개일 수도 있죠. 그런 트리는 노드가 left·right 대신 자식들의 리스트(children = [...])를 가집니다.
그런데 코딩테스트와 자료구조 학습에서는 자식이 최대 둘인 이진트리가 압도적으로 많이 나옵니다. 이유는 둘이에요. 첫째, 왼쪽·오른쪽이라는 두 갈래만 있으면 "작으면 왼쪽, 크면 오른쪽"(오늘 배울 BST) 같은 규칙을 걸기 좋습니다. 둘째, 자식이 많은 일반 트리도 "첫 자식 - 다음 형제" 식으로 이진트리로 바꿔 표현할 수 있어요. 그래서 이진트리만 확실히 잡으면 대부분의 트리 문제가 풀립니다.
Step 3: "트리를 훑는 법 ① 전위 순회" — 재귀로 타고 내려간다
트리를 만들었으면 이제 훑어야(순회, traversal) 합니다. 순회란 모든 노드를 빠짐없이 한 번씩 방문하는 거예요. 그런데 트리는 한 줄이 아니라 갈래가 있어서, "어느 순서로 방문하느냐"를 정해야 합니다.
여기서 재귀(recursion)가 등장합니다. 재귀란 함수가 자기 자신을 부르는 기법이에요. 트리와 궁합이 완벽한데, 이유가 있습니다. 트리는 "루트 하나 + 왼쪽 서브트리 + 오른쪽 서브트리"인데, 그 서브트리도 또 하나의 트리거든요. 그러니 "트리를 순회한다"를 "루트를 방문하고, 왼쪽 서브트리를 순회하고, 오른쪽 서브트리를 순회한다"로 적으면, 자기 자신을 다시 부르는 재귀가 자연스럽게 나옵니다. 끝나는 조건은 하나예요. 빈 트리(None)에 닿으면 아무것도 안 하고 돌아옵니다.
가장 먼저 배울 순회는 전위 순회(preorder)입니다. "나 → 왼쪽 → 오른쪽" 순서로, 루트를 가장 먼저 찍어요.
# structures/binary_tree.py
def preorder(node):
"""전위 순회: 나 → 왼쪽 → 오른쪽. 방문값을 리스트로 모아 돌려준다.
루트를 가장 먼저 찍고 왼쪽 서브트리, 그다음 오른쪽 서브트리로 내려간다.
표본 트리면 [1, 2, 4, 5, 3, 6]. 시간 O(n), 공간 O(h)(재귀 깊이).
"""
if node is None:
return []
return [node.val] + preorder(node.left) + preorder(node.right)
딱 두 줄입니다. "빈 트리면 빈 리스트", 아니면 "내 값 + 왼쪽 순회 결과 + 오른쪽 순회 결과". 이 함수가 표본 트리를 타고 내려가는 과정을 따라가 볼게요. 들여쓰기가 깊어질수록 트리 아래로 내려간 겁니다.
preorder(1) 이 표본 트리를 타고 내려가는 순서
preorder(1): 1 방문 → [1]
preorder(2): 2 방문 → [1, 2]
preorder(4): 4 방문 → [1, 2, 4]
preorder(5): 5 방문 → [1, 2, 4, 5]
preorder(3): 3 방문 → [1, 2, 4, 5, 3]
preorder(6): 6 방문 → [1, 2, 4, 5, 3, 6]
최종 방문 순서: 1 → 2 → 4 → 5 → 3 → 6
루트 1을 먼저 찍고, 왼쪽 서브트리(2·4·5)를 통째로 끝낸 뒤, 오른쪽 서브트리(3·6)로 넘어갑니다. 모든 노드를 한 번씩 보니 시간은 O(n)이에요. 공간은 재귀 호출이 트리 높이만큼 쌓이므로 O(h)입니다(h = 트리 높이). 균형 잡힌 트리면 h ≈ log n, 한쪽으로 치우치면 h ≈ n이고요. 재귀 자체는 나중에 별도 시간에 본격적으로 파고드는데, 트리 순회엔 지금 이만큼이면 충분합니다.
🎯 코테에서는 트리 문제의 태반이 순회입니다. "각 노드에서 무언가 하고 자식으로 내려간다"가 뼈대예요. 전위 순회는 "부모를 먼저 처리해야 하는" 일(트리를 위에서 아래로 복사·출력)에 맞습니다.
💡 한 줄 정리
순회는 모든 노드를 한 번씩 방문하는 것이고, 트리가 "루트 + 서브트리들"이라는 재귀 구조라 재귀로 짜면 자연스럽다. 전위 순회는 "나 → 왼쪽 → 오른쪽"으로 루트를 먼저 찍으며, 시간 O(n)·공간 O(h)다.
🙋 학생 질문 — "튜터님, 재귀가 어렵게 느껴져요. 그냥 반복문으로 훑으면 안 되나요?"
솔직한 질문 고마워요. 결론부터 말하면, 반복문으로도 됩니다. 다만 트리에선 재귀가 훨씬 짧고 자연스러워요.
이유는 트리 자체가 재귀적이기 때문입니다. "트리 = 루트 + 서브트리들"인데, 서브트리도 또 트리죠. 이 구조를 그대로 코드로 옮기면 "루트 처리 + 서브트리 순회(=자기 자신 호출)"라는 재귀가 나옵니다. 반복문으로 하려면 "지금 어디까지 내려갔는지"를 우리가 직접 기억해야 하는데, 그걸 담아 둘 그릇이 필요해요. 그 그릇이 바로 지난 시간에 없었던, 그보다 전에 배운 스택입니다.
사실 재귀도 내부적으로는 컴퓨터가 "호출 스택"이라는 스택에 쌓았다 꺼내며 도는 거예요. 그래서 Step 5에서 그 숨은 스택을 우리가 직접 꺼내 반복문으로 같은 순회를 만들어 봅니다. 그때 "아, 재귀가 이걸 대신 해 주고 있었구나"가 확실해질 거예요.
Step 4: "트리를 훑는 법 ② 중위·후위 순회" — 나를 언제 방문하느냐
전위 순회를 이해했다면 나머지 둘은 거저입니다. 코드가 거의 똑같고, "나(루트)를 언제 찍느냐"만 바뀌거든요. 왼쪽을 오른쪽보다 먼저 보는 건 셋 다 같습니다.
중위 순회(inorder)는 "왼쪽 → 나 → 오른쪽"으로, 나를 가운데에 찍습니다. 후위 순회(postorder)는 "왼쪽 → 오른쪽 → 나"로, 자식을 다 본 뒤 맨 마지막에 나를 찍고요.
# structures/binary_tree.py
def inorder(node):
if node is None:
return []
return inorder(node.left) + [node.val] + inorder(node.right)
def postorder(node):
if node is None:
return []
return postorder(node.left) + postorder(node.right) + [node.val]
[node.val]이 앞·가운데·뒤 어디에 오느냐만 다르죠. 세 순회를 표본 트리에 돌리면 방문 순서가 이렇게 갈립니다.
| 순회 | 방문 순서 | 표본 트리 결과 |
|---|---|---|
| 전위(preorder) | 나 → 왼 → 오 | [1, 2, 4, 5, 3, 6] |
| 중위(inorder) | 왼 → 나 → 오 | [4, 2, 5, 1, 3, 6] |
| 후위(postorder) | 왼 → 오 → 나 | [4, 5, 2, 6, 3, 1] |
셋 다 모든 노드를 한 번씩 보므로 시간 O(n)·공간 O(h)로 똑같습니다. 그럼 왜 셋으로 나눌까요? 처리 순서가 문제마다 다르기 때문이에요.
특히 중위 순회는 오늘 뒤에 배울 이진탐색트리(BST)에서 결정적으로 쓰입니다. BST를 중위 순회하면 값이 오름차순으로 정렬돼 나오거든요. 이 성질이 왜 나오는지는 Step 6·7에서 확인합니다. 후위 순회는 "자식을 다 끝내야 나를 처리할 수 있는" 일에 맞아요. 폴더 용량을 계산할 때 하위 폴더 용량을 다 더한 뒤 상위 폴더 용량이 나오는 것처럼요.
🎯 코테에서는 "BST가 유효한지 검증"하는 문제에 중위 순회가 단골로 나옵니다. 중위 순회 결과가 오름차순이면 올바른 BST죠. 후위 순회는 트리를 안전하게 지우거나(자식부터 지워야 함), 수식 트리를 계산할 때 쓰입니다.
💡 한 줄 정리
전위·중위·후위 순회는 "나(루트)를 언제 방문하느냐"만 다르고, 왼쪽이 오른쪽보다 먼저인 건 같다. 셋 다 O(n)·O(h)이며, 특히 중위 순회는 BST를 정렬된 순서로 뽑아 준다.
🙋 학생 질문 — "튜터님, 세 순회 순서가 자꾸 헷갈려요. 외우는 요령이 있나요?"
좋은 요청이에요. 이름에 답이 들어 있습니다.
전위·중위·후위의 "전·중·후"는 루트(나)를 언제 찍느냐를 가리켜요. 전위(pre)는 루트를 앞에, 중위(in)는 가운데에, 후위(post)는 뒤에. 그리고 왼쪽은 항상 오른쪽보다 먼저라는 규칙만 고정해 두면 됩니다.
- 전위: (나) → 왼 → 오
- 중위: 왼 → (나) → 오
- 후위: 왼 → 오 → (나)
그러니 "왼-오는 순서 고정, 나만 앞·중간·뒤로 이동"이라고 기억하세요. 코드로도 [node.val]을 세 조각(왼쪽 결과·오른쪽 결과) 사이 어디에 끼우느냐로만 갈립니다. 셋을 따로 외우지 말고 "나의 위치만 옮긴다"로 묶어 두면 헷갈리지 않아요.
Step 5: "재귀 없이 훑기" — 스택으로 반복 순회
Step 3에서 예고한 걸 지킬 시간입니다. 재귀는 사실 컴퓨터가 호출 스택에 노드를 쌓았다 꺼내는 일을 대신해 준 거예요. 그 스택을 우리가 직접 만들면, 재귀 없이 반복문만으로 같은 순회를 낼 수 있습니다. 그보다 앞서 배운 스택이 여기서 정면으로 돌아옵니다.
전위 순회를 스택으로 바꿔 볼게요. 루트를 스택에 넣고, 하나 꺼내 방문한 뒤 그 자식들을 다시 넣습니다. 핵심은 넣는 순서예요. 스택은 나중에 넣은 게 먼저 나오는(LIFO) 구조라, 왼쪽을 먼저 꺼내려면 오른쪽을 먼저, 왼쪽을 나중에 넣어야 합니다.
# structures/binary_tree.py
def preorder_iterative(root):
if root is None:
return []
result = []
stack = [root]
while stack:
node = stack.pop()
result.append(node.val)
if node.right is not None:
stack.append(node.right) # 오른쪽을 먼저 넣고
if node.left is not None:
stack.append(node.left) # 왼쪽을 나중에 넣어야 먼저 꺼내진다
return result
스택이 어떻게 변하는지 한 걸음씩 따라가 봅시다.
명시적 스택으로 전위 순회 (스택은 나중에 넣은 게 먼저 나온다)
스택 [1] → 1 방문, 오른쪽(3)·왼쪽(2) 넣기 → 스택 [3, 2]
스택 [3, 2] → 2 방문, 오른쪽(5)·왼쪽(4) 넣기 → 스택 [3, 5, 4]
스택 [3,5,4] → 4 방문 (자식 없음) → 스택 [3, 5]
스택 [3, 5] → 5 방문 → 스택 [3]
스택 [3] → 3 방문, 오른쪽(6) 넣기 → 스택 [6]
스택 [6] → 6 방문 → 스택 []
방문 순서: 1 → 2 → 4 → 5 → 3 → 6 (재귀 전위와 똑같다)
결과가 재귀판과 한 글자도 다르지 않죠. 빅오도 같습니다. 모든 노드를 한 번씩 보니 시간 O(n), 스택에 최대 트리 높이만큼 쌓이니 공간 O(h)예요.
그럼 재귀가 더 짧은데 왜 굳이 반복으로 바꿀까요? 아주 깊은 트리 때문입니다. 파이썬은 재귀 깊이에 기본 한도(약 1000)가 있어서, 노드 수십만 개가 한쪽으로 치우친 트리를 재귀로 순회하면 한도를 넘어 오류가 납니다. 이럴 때 스택으로 바꾸거나, 코테에선 sys.setrecursionlimit으로 한도를 올려요. "재귀가 안전하지 않을 만큼 깊어지면 반복으로"가 실전의 감각입니다.
🎯 코테에서는 트리가 아주 깊을 수 있는 문제(노드 10만 이상, 편향 가능)에서 재귀가
RecursionError로 터지는 경우가 있습니다. 그때 이 반복 순회나sys.setrecursionlimit(10**6)한 줄이 해결책이에요. 원리(재귀 = 숨은 스택)를 알면 이 전환이 막히지 않습니다.
💡 한 줄 정리
재귀는 내부적으로 호출 스택에 노드를 쌓았다 꺼내는 것이고, 그 스택을 명시적으로 만들면 반복문으로 같은 순회를 낸다. 전위는 오른쪽·왼쪽 순으로 넣어(LIFO) 왼쪽을 먼저 꺼낸다. 빅오는 재귀와 같은 O(n)·O(h)이고, 아주 깊은 트리에서 재귀 한도를 피하는 안전장치가 된다.
🙋 학생 질문 — "튜터님, 왜 오른쪽을 먼저 넣나요? 왼쪽을 먼저 보고 싶은데 헷갈려요."
바로 그 점이 스택의 성질이라 헷갈리기 쉬워요. 천천히 볼게요.
스택은 나중에 넣은 게 먼저 나옵니다(LIFO). 우리가 원하는 방문 순서는 "왼쪽 먼저, 오른쪽 나중"이죠. 그러려면 스택에서 왼쪽이 먼저 꺼내져야 합니다. 먼저 꺼내지려면? 나중에 넣어야 해요.
그래서 순서가 뒤집힙니다. 오른쪽을 먼저 스택에 넣어 바닥에 깔고, 왼쪽을 나중에 넣어 위에 올려요. 그러면 다음 번에 pop할 때 위에 있는 왼쪽이 먼저 나옵니다. 위 추적표에서 1을 방문한 뒤 [3, 2]가 된 걸 보세요. 3(오른쪽)이 바닥, 2(왼쪽)가 위라, 다음에 2가 먼저 꺼내지죠. "원하는 순서의 반대로 넣는다"가 스택을 쓸 때의 요령입니다.
Step 6: "정렬된 트리" — BST 삽입
지금까지의 이진트리는 값을 아무 데나 넣었습니다. 여기에 규칙 하나를 걸면, 검색이 폭발적으로 빨라지는 특별한 트리가 됩니다. 이진탐색트리(Binary Search Tree, BST)예요.
규칙은 단 하나입니다. 모든 노드에서, 왼쪽 서브트리의 값들은 나보다 작고, 오른쪽 서브트리의 값들은 나보다 크다. 노드 모양은 앞의 TreeNode를 그대로 씁니다(BST는 규칙만 다른 이진트리예요). 값을 넣을 때 이 규칙을 지키며 자리를 찾아 내려갑니다. 작으면 왼쪽, 크면 오른쪽으로 가다가 빈 자리(None)에 닿으면 거기에 새 노드를 답니다.
# structures/bst.py
def bst_insert(root, val):
if root is None:
return TreeNode(val)
if val < root.val:
root.left = bst_insert(root.left, val)
elif val > root.val:
root.right = bst_insert(root.right, val)
# val == root.val 이면 아무것도 안 함(중복 무시)
return root
5, 3, 8, 1, 4, 7, 9를 차례로 넣으면 어떻게 자리를 잡는지 따라가 볼게요.
BST 만들기 — 5, 3, 8, 1, 4, 7, 9 를 차례로 (작으면 왼쪽, 크면 오른쪽)
5 를 뿌리로. 3<5 → 왼쪽. 8>5 → 오른쪽.
1<5 → 왼쪽, 1<3 → 3의 왼쪽. 4<5 → 왼쪽, 4>3 → 3의 오른쪽.
7>5 → 오른쪽, 7<8 → 8의 왼쪽. 9>5 → 오른쪽, 9>8 → 8의 오른쪽.
5
/ \
3 8
/ \ / \
1 4 7 9
넣은 순서는 뒤죽박죽이었지만, 규칙대로 내려가니 값이 크기순으로 자리를 잡았습니다. 여기서 Step 4의 예고가 회수돼요. 이 트리를 중위 순회(왼 → 나 → 오)하면 [1, 3, 4, 5, 7, 8, 9], 정확히 오름차순이 나옵니다. "왼쪽이 나보다 작다"는 규칙이 곧 "왼쪽을 먼저 찍으면 작은 값부터 나온다"이기 때문이죠.
새 값을 넣는 비용은 빈 자리를 찾아 내려간 길이, 즉 트리 높이만큼입니다. 균형 잡힌 트리면 평균 O(log n)이에요. 참고로 같은 값이 또 들어오면 위 코드는 무시합니다(중복 없는 집합처럼). 파이썬에는 균형 BST 표준 라이브러리가 없어서, 실전에서 "정렬 상태를 유지하며 넣고 찾기"가 필요하면 보통 정렬 리스트와 bisect(정렬된 배열을 절반씩 탐색하는 도구, 나중에 배웁니다)를 씁니다. BST의 진짜 값어치는 다음 Step에서 볼 "절반씩 버리며 찾는" 사고를 트리로 익히는 데 있어요.
🎯 코테에서는 BST에 값을 삽입하거나, 주어진 트리가 올바른 BST인지 검증하는 문제가 나옵니다. "왼쪽 < 나 < 오른쪽" 규칙 한 줄이 삽입·검증·탐색을 전부 관통해요.
💡 한 줄 정리
BST는 "왼쪽 서브트리는 나보다 작고, 오른쪽 서브트리는 나보다 크다"는 규칙을 건 이진트리다. 값을 넣을 때 작으면 왼쪽·크면 오른쪽으로 내려가 빈 자리에 단다(평균 O(log n)). 중위 순회하면 오름차순으로 나온다.
🙋 학생 질문 — "튜터님, 이미 있는 값을 또 넣으면 어떻게 되나요?"
좋은 질문이에요. 위 코드는 같은 값은 무시합니다. val < root.val도 아니고 val > root.val도 아니면(즉 같으면) 어느 쪽으로도 안 내려가고 그냥 원래 트리를 돌려주거든요. 그래서 중복 없는 집합(set)처럼 동작해요.
문제에 따라 처리 방식이 달라집니다. 중복을 세야 하는 문제라면 노드에 "이 값이 몇 번 들어왔나" 카운트를 함께 두고요. 중복을 허용하는 BST라면 같은 값을 관례적으로 오른쪽(또는 왼쪽) 한쪽으로 보냅니다. 어느 쪽이든 "같을 때 어떻게 할지"를 문제 요구에 맞춰 정하면 돼요. 오늘은 가장 단순한 "무시"로 두고, 중복 없는 값들을 다룹니다.
Step 7: "절반씩 버리며 찾는다" — BST 탐색과 균형의 함정
BST 규칙의 보상을 받을 시간입니다. 값을 찾을 때, 규칙 덕분에 한 노드에서 한쪽으로만 내려가면 돼요. 찾는 값이 지금 노드보다 작으면 오른쪽은 통째로 버리고 왼쪽으로, 크면 왼쪽을 버리고 오른쪽으로. 매 걸음 후보의 절반이 사라집니다.
# structures/bst.py
def bst_search(root, val):
node = root
while node is not None:
if val == node.val:
return True
node = node.left if val < node.val else node.right
return False
이 "절반씩 버린다"가 바로 이진 탐색(binary search)의 사고예요. 정렬된 데이터를 절반씩 좁혀 O(log n)에 찾는 이 발상은, 나중에 정렬된 배열 위에서 본격적으로 다시 배웁니다. BST는 그 사고를 트리 모양으로 구현한 거고요. 원소가 100만 개여도 균형 잡힌 BST면 약 20번(log₂ 100만 ≈ 20) 만에 찾습니다.
핵심 연산의 빅오를 표로 정리하면 이렇습니다.
| 연산 | 평균 | 최악(치우침) |
|---|---|---|
| 삽입 | O(log n) | O(n) |
| 탐색 | O(log n) | O(n) |
| 삭제 | O(log n) | O(n) |
그런데 최악 O(n)이 눈에 띄죠. 여기가 BST의 함정입니다. 평균 O(log n)은 트리가 좌우로 균형 잡혔을 때(높이 ≈ log n) 이야기예요. 만약 값을 정렬된 순서로 넣으면 어떻게 될까요?
같은 값 1,2,3,4,5 라도 넣는 순서에 따라 모양이 갈린다
[균형] 3, 2, 4, 1, 5 처럼 가운데부터 넣으면
3
/ \
2 4
/ \
1 5
→ 높이 2, 탐색은 절반씩 버려 O(log n)
[치우침] 1, 2, 3, 4, 5 를 정렬 순서로 넣으면
1
\
2
\
3
\
4
\
5
→ 높이 4, 한쪽으로만 자라 사실상 연결 리스트, 탐색 O(n)
정렬된 순서로 넣으면 매번 오른쪽으로만 내려가, 트리가 한쪽으로 자라 일자가 됩니다. 이건 사실상 연결 리스트라 탐색이 O(n)으로 무너져요. "1초에 약 1억 연산" 잣대로 보면, 100만 원소를 균형 트리에선 20번에 찾지만 일자 트리에선 최악 100만 번을 봐야 합니다. 당락이 갈리죠.
⚠️ 이 최악을 막으려고, 실무의 트리는 스스로 균형을 맞추는 변형을 씁니다. 자바의
TreeMap이 쓰는 레드-블랙 트리, 데이터베이스 인덱스의 B-트리가 그것이에요. 넣고 뺄 때마다 좌우 높이를 자동으로 맞춰 높이를 항상 log n 근처로 유지합니다. 지난 시간에 흘려 둔 이야기 기억나나요? 자바HashMap이 한 칸의 체인이 너무 길어지면 그 체인을 균형 트리로 바꿔 최악을 O(log n)으로 막는다고 했죠. 그 균형 트리가 바로 이런 자가 균형 BST입니다.
💡 한 줄 정리
BST 탐색은 "작으면 왼쪽, 크면 오른쪽"으로 매 걸음 후보의 절반을 버려 평균 O(log n)이다(이진 탐색의 트리판). 단 정렬된 순서로 넣으면 한쪽으로 자라 일자가 되어 최악 O(n)으로 무너진다. 그래서 실무는 자가 균형 트리로 높이를 log n 근처로 지킨다.
🙋 학생 질문 — "튜터님, 그럼 검색엔 항상 BST를 쓰면 되는 거 아닌가요?"
날카로운 질문이에요. 답은 "아니요, 상황에 따라"입니다.
두 가지를 짚을게요. 첫째, 방금 봤듯 BST는 균형이 깨지면 O(n)으로 무너집니다. 균형을 보장하려면 레드-블랙 트리 같은 자가 균형 트리가 필요한데, 이건 구현이 꽤 복잡해요. 그래서 파이썬 표준 라이브러리엔 아예 없습니다.
둘째, "그냥 값이 있나 없나"만 빠르게 알고 싶다면 BST보다 해시(dict·set)가 낫습니다. 지난 시간에 배운 해시는 평균 O(1)이라, O(log n)인 BST보다도 빨라요. 그래서 파이썬 코테에선 검색용으로 BST를 직접 짜는 일이 거의 없고 dict·set을 씁니다.
그럼 BST는 언제 쓸까요? "정렬된 순서가 함께 필요할 때"입니다. 해시는 순서가 없지만, BST는 중위 순회로 정렬 순서를 주고 "특정 값보다 큰 것 중 가장 작은 값" 같은 범위 질의에 강해요. "빠른 존재 확인만 → 해시, 정렬·범위까지 → 트리"로 갈라 기억하세요.
Step 8: "트리로 답 구하기" — 높이·노드 수·지름
마지막으로, 트리를 순회하며 답을 계산하는 패턴을 봅니다. 핵심 발상은 하나예요. "자식들에게 답을 물어 받아서, 그걸 모아 내 답을 만든다." 후위 순회(자식 먼저, 나 나중)의 정신이죠.
가장 단순한 예가 노드 개수와 높이입니다.
# structures/binary_tree.py
def count_nodes(node):
if node is None:
return 0
return 1 + count_nodes(node.left) + count_nodes(node.right)
def tree_height(node):
if node is None:
return -1
return 1 + max(tree_height(node.left), tree_height(node.right))
count_nodes는 "나 하나 + 왼쪽 개수 + 오른쪽 개수"로 셉니다. tree_height는 "1 + 자식 중 더 높은 쪽"이고요. 빈 트리를 -1, 리프를 0으로 두면 이 식이 깔끔하게 맞습니다.
⚠️ 높이의 정의는 문제마다 갈립니다. 여기선 간선 수 기준(빈 트리 -1, 리프 0)이지만, LeetCode의 "최대 깊이"(문제 104)는 노드 수로 세서 리프를 1, 빈 트리를 0으로 봐요. 둘 다 맞습니다. 코테에선 그 문제가 어느 정의를 쓰는지 예시로 먼저 확인하세요.
이제 조금 더 나아간 문제, 트리의 지름(diameter)입니다. 지름이란 트리에서 임의의 두 노드를 잇는 가장 긴 경로의 간선 수예요. 표본 트리에서 가장 긴 경로는 4 → 2 → 1 → 3 → 6으로 간선 4개입니다.
트리의 지름 = 두 노드를 잇는 가장 긴 경로 (표본 트리)
1
/ \
2 3
/ \ \
4 5 6
가장 긴 경로: 4 → 2 → 1 → 3 → 6 (간선 4개)
요령: 각 노드에서 '왼쪽 아래로 가장 먼 깊이 + 오른쪽 아래로 가장 먼 깊이'가
그 노드를 지나는 최장 경로. 1 에서 왼쪽 2간선 + 오른쪽 2간선 = 4.
핵심은 "각 노드를 지나는 가장 긴 경로 = 왼쪽 깊이 + 오른쪽 깊이"라는 점입니다. 그래서 한 번의 후위 순회로 각 노드의 깊이를 아래에서 위로 올려보내면서, 그때그때 "왼쪽 + 오른쪽"으로 지름 후보를 갱신하면 됩니다.
def tree_diameter(root):
best = 0
def depth(node): # 이 노드 아래로 가장 먼 리프까지의 간선 수(없으면 -1)
nonlocal best
if node is None:
return -1
left = depth(node.left)
right = depth(node.right)
best = max(best, (left + 1) + (right + 1)) # 이 노드를 지나는 경로 길이
return 1 + max(left, right)
depth(root)
return best
각 노드에서 자식의 깊이(left·right)를 받아, 그 노드를 지나는 경로 길이로 best를 갱신하고, 자기 깊이는 위로 올려보냅니다. 모든 노드를 한 번씩만 보므로 O(n)이에요. 만약 노드마다 높이를 매번 따로 구하면 O(n²)로 느려지는데, 깊이를 올려보내며 한 번에 처리해 O(n)으로 줄인 겁니다. 이렇게 자식의 결과를 모아 부모의 답을 키우는 방식이, 나중에 배울 동적 계획법(트리 DP)의 맛보기예요.
이걸로 오늘의 두 트랙이 마무리됩니다. 트리를 직접 재귀로 순회해 원리를 봤고, 실전 코테에서도 이 재귀 몇 줄을 거의 그대로 쓰거나 트리를 딕셔너리로 표현합니다. 원리를 손으로 훑어 본 사람이, 문제에서 트리가 나왔을 때 "순회로 풀까, 규칙(BST)으로 풀까"를 곧장 판단해요.
🎯 코테에서는 트리의 지름(LeetCode 543), 최대 깊이(104), 노드 수(222)가 단골입니다. 전부 "자식의 결과를 모아 내 답을 만드는" 후위 순회 패턴이에요. 이 패턴 하나로 트리 계산 문제의 상당수가 풀립니다.
💡 한 줄 정리
트리로 답을 구하는 패턴은 "자식들에게 답을 물어 받아 내 답을 만든다"(후위 순회)이다. 노드 수·높이는 한 줄 재귀로, 트리의 지름은 깊이를 올려보내며 한 번의 순회로 O(n)에 구한다. 이 "자식 결과를 모아 부모 답"이 트리 DP의 맛보기다.
🙋 학생 질문 — "튜터님, 트리의 지름은 왜 루트를 안 지날 수도 있나요?"
정확히 헷갈리기 쉬운 지점을 짚었어요. 가장 긴 경로가 꼭 루트를 지나는 건 아닙니다.
예를 들어 루트의 한쪽 서브트리는 아주 깊고, 반대쪽은 비어 있다고 해 봐요. 그러면 가장 긴 경로가 그 깊은 서브트리 안에서만 만들어져, 루트를 안 지날 수 있습니다. 그래서 "루트에서 왼쪽 깊이 + 오른쪽 깊이"만 보면 틀려요.
tree_diameter가 모든 노드에서 "그 노드를 지나는 경로"를 따져 best로 갱신하는 게 그 때문입니다. 어떤 노드가 최장 경로의 꼭대기일지 미리 알 수 없으니, 전부 확인하며 가장 큰 값을 남기는 거죠. 후위 순회로 아래에서 위로 올라오며 모든 노드를 한 번씩 후보로 세우니, 루트를 지나든 안 지나든 놓치지 않습니다.
마무리
오늘은 데이터가 가지를 치는 첫 그릇, 트리를 만들었습니다. 노드에 왼쪽·오른쪽 두 포인터를 달아 이진트리를 세우고, 세 가지 순회로 훑고, "왼쪽은 작게 오른쪽은 크게" 규칙을 건 BST로 평균 O(log n) 탐색까지 왔어요. 지난 시간 흘려 둔 "노드는 다음 하나"가, 오늘 "노드는 자식 여럿"으로 넓어졌습니다.
오늘 배운 핵심 세 가지
- 💡 하나 — 트리는 노드가 자식 둘(왼쪽·오른쪽)을 가리키는 비선형 구조다. 연결 리스트의 노드에 포인터를 하나 더 단 확장이고, 그래서 데이터가 한 줄이 아니라 가지를 친다. 노드 접근 O(1), 트리 구성 O(n).
- 💡 둘 — 순회는 "나를 언제 방문하느냐"로 전위·중위·후위가 갈린다. 트리가 재귀 구조라 재귀로 짜면 자연스럽고(O(n)·O(h)), 재귀의 숨은 스택을 직접 꺼내면 반복 순회가 된다. 중위 순회는 BST를 정렬된 순서로 뽑아 준다.
- 💡 셋 — BST는 "왼쪽 < 나 < 오른쪽" 규칙으로 절반씩 버리며 평균 O(log n)에 찾는다. 단 정렬된 순서로 넣어 한쪽으로 치우치면 일자가 되어 최악 O(n)으로 무너진다. 그래서 균형이 관건이고, 실무는 자가 균형 트리로 높이를 지킨다.
다음 시간 예고
다음 시간엔 트리 중에서도 아주 특별한 모양을 다룹니다. 완전 이진트리(complete binary tree) — 위에서 아래로, 왼쪽에서 오른쪽으로 빈틈없이 채워진 트리예요. 이 규칙적인 모양 덕분에, 놀랍게도 포인터 없이 배열 한 장에 트리를 통째로 담을 수 있습니다. 이걸 이용해 최댓값(또는 최솟값)을 O(log n)에 뽑아 내는 자료구조가 힙(heap)이고, 그 위에 세운 게 우선순위 큐예요. 오늘 배운 트리 높이와 O(log n)의 감각이 그대로 이어지니, "트리를 배열에 어떻게 담지?"를 한번 상상해 보고 오세요.
과제
오늘 배운 트리를 직접 손으로 다뤄 보는 문제들입니다. 풀어 본 뒤 예시 답안과 맞춰 보세요. 각 풀이의 시간·공간 복잡도를 빅오로 적는 것도 잊지 마세요.
[기초] 리프 노드의 개수
이진트리의 루트가 주어질 때, 리프 노드(자식이 하나도 없는 노드)의 개수를 세는 함수를 작성하세요. 예를 들어 오늘의 표본 트리(루트 1)라면 리프는 4·5·6으로 3개입니다.
힌트는 오늘 본 count_nodes예요. 거기선 "나 하나 + 왼쪽 + 오른쪽"으로 모든 노드를 셌죠. 여기선 "나"를 셀지 말지가 조건에 달렸습니다. 왼쪽도 오른쪽도 없는 노드일 때만 1을 세도록 바꿔 보세요. 시간·공간 복잡도도 빅오로 적으세요. (트리 순회의 가장 기본 유형입니다.)
[응용] 좌우 대칭 트리 판별
이진트리의 루트가 주어질 때, 그 트리가 좌우로 대칭(거울에 비친 듯)인지 판별하는 함수를 작성하세요(대칭이면 True). 예를 들어 루트의 왼쪽 서브트리와 오른쪽 서브트리가 서로의 거울상이면 대칭입니다.
핵심은 "한 트리를 혼자 순회"하는 게 아니라 두 서브트리(왼쪽·오른쪽)를 동시에, 거울처럼 마주 보며 비교하는 거예요. 왼쪽의 왼쪽 자식과 오른쪽의 오른쪽 자식을, 왼쪽의 오른쪽 자식과 오른쪽의 왼쪽 자식을 짝지어 비교합니다. 두 노드를 받아 "값이 같고, 바깥끼리·안쪽끼리 거울로 같은가"를 재귀로 따져 보세요. 시간·공간 복잡도를 빅오로 적으세요. (LeetCode "Symmetric Tree" 패턴입니다.)
[심화] 올바른 이진탐색트리인지 검증
이진트리의 루트가 주어질 때, 그 트리가 올바른 BST인지(모든 노드에서 왼쪽 < 나 < 오른쪽 규칙을 지키는지) 검증하는 함수를 작성하세요.
오늘 Step 4·6에서 "BST를 중위 순회하면 오름차순으로 나온다"를 봤죠. 그걸 뒤집으면 검증이 됩니다. 중위 순회 결과가 엄격히 오름차순이면 올바른 BST예요. 중위 순회로 값들을 뽑은 뒤, 이웃한 값이 항상 앞 < 뒤인지 확인해 보세요. 시간·공간 복잡도를 빅오로 적고, "각 노드가 지켜야 할 값의 범위(min, max)를 물려주며 검증하는" 다른 풀이도 가능한지 생각해 보세요. (LeetCode "Validate Binary Search Tree" 패턴입니다.)
생각해볼 주제
정답이 하나가 아닌 질문들입니다. 혼자 고민해도 좋고, 스터디에서 토론해도 좋아요.
1. BST는 평균 O(log n)인데, 왜 "최악 O(n)"을 늘 함께 적을까?
BST의 삽입·탐색은 보통 평균 O(log n)이지만, 책과 면접관은 꼭 "최악 O(n)"을 함께 묻습니다. 어떤 상황에서 BST가 O(n)으로 무너질까요? 값을 정렬된 순서로 넣어 한쪽으로 치우친 경우를 떠올려 보세요. 지난 시간의 해시가 "충돌이 몰리면 최악 O(n)"이었던 것과 닮은 구조입니다. 그리고 이 최악을 막는 자가 균형 트리(레드-블랙 트리·AVL 트리·B-트리)가 무엇을 자동으로 하는지, 왜 데이터베이스 인덱스가 그런 트리를 쓰는지도 함께 정리해 보세요.
2. 트리 순회, 재귀로 짤까 반복으로 짤까?
같은 순회를 재귀로도, 명시적 스택 반복으로도 짤 수 있었습니다. 재귀는 짧고 트리 구조를 그대로 옮겨 읽기 쉽지만, 아주 깊은 트리에선 호출 스택 한도를 넘어 오류가 날 수 있어요. 반복은 길지만 그 한도에서 자유롭고요. 어떤 상황에 어느 쪽을 고를지, 그리고 "재귀가 사실은 스택을 쓰고 있다"는 사실이 이 선택에서 무엇을 알려 주는지 생각해 보세요. 코테에서 sys.setrecursionlimit을 올리는 것과 반복으로 바꾸는 것은 각각 어떤 트레이드오프가 있을까요?
3. 연결 리스트는 트리의 특수형, 트리는 그래프의 특수형 — 자료구조의 계보
오늘 "자식이 하나뿐인 트리 = 연결 리스트", "치우친 BST = 일자 = 연결 리스트"를 봤습니다. 또 트리는 "사이클 없이 연결된 그래프"라고도 했죠. 이렇게 자료구조들이 서로의 특수형·일반형으로 이어진다는 걸, 지금까지 배운 것(배열·연결 리스트·해시·트리)을 돌아보며 정리해 보세요. 그리고 "직접 구현으로 원리, 내장으로 실전"이라는 두 트랙이 다음 카테고리(힙·그래프)에서 어떻게 이어질지도 그려 보세요 — 힙은 트리를 배열에 담고, 그래프는 노드와 포인터를 더 자유롭게 잇습니다. 오늘의 노드·포인터·순회가 그 바닥에 어떻게 깔리는지가 이 질문의 핵심이에요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 리프 노드의 개수
채점 포인트
| 항목 | 확인 |
|---|---|
| 리프 판별 | 왼쪽·오른쪽이 둘 다 None인 노드만 1로 센다 |
| 재귀 구조 | 리프가 아니면 두 자식의 리프 수를 더해 올린다 |
| 빈 트리 처리 | None이면 0을 돌려준다(재귀의 종료 조건) |
| 빅오 | 모든 노드를 한 번씩 봐 시간 O(n)·공간 O(h) |
풀이 예시
오늘 본 count_nodes는 "나 하나 + 왼쪽 + 오른쪽"으로 모든 노드를 셌죠. 리프만 세려면 "나"를 셀지 말지를 조건으로 바꾸면 됩니다. 왼쪽도 오른쪽도 없는 노드일 때만 1을 세요.
def count_leaves(root):
if root is None:
return 0
if root.left is None and root.right is None:
return 1
return count_leaves(root.left) + count_leaves(root.right)
세 갈래로 갈립니다. 빈 트리(None)면 0, 자식이 없는 리프면 1, 그 외에는 두 자식에게 각자 리프 수를 물어 더해요. 표본 트리(루트 1)라면 리프는 4·5·6으로 3이 나옵니다.
빅오는 모든 노드를 한 번씩 방문하니 시간 O(n)입니다. 공간은 재귀가 트리 높이만큼 쌓여 O(h)예요(균형이면 log n, 치우치면 n).
💡 튜터의 한마디: "트리 계산 문제는 '종료 조건(빈 트리)'과 '나를 어떻게 처리할지'만 정하면 나머지는 자식에게 맡깁니다." count_nodes에서 조건 한 줄만 바꿔 count_leaves가 된 것처럼, "높이가 짝수인 노드 수", "값이 10 이상인 노드 수" 같은 변형도 전부 같은 뼈대예요. 순회 뼈대를 익혀 두면 조건만 갈아 끼우게 됩니다.
🎯 [과제 2 예시답안] 좌우 대칭 트리 판별
채점 포인트
| 항목 | 확인 |
|---|---|
| 두 트리 동시 비교 | 한 트리를 혼자 순회하지 않고 왼쪽·오른쪽 서브트리를 짝지어 비교 |
| 거울 짝짓기 | 왼쪽의 바깥(left)↔오른쪽의 바깥(right), 안쪽끼리 교차 비교 |
| 종료 조건 | 둘 다 None이면 True, 하나만 None이면 False |
| 빅오 | 각 노드를 한 번씩 봐 시간 O(n)·공간 O(h) |
풀이 예시
핵심은 "한 트리를 혼자 훑는" 게 아니라 두 서브트리를 거울처럼 마주 보며 비교하는 겁니다. 대칭이려면 왼쪽 서브트리의 바깥쪽과 오른쪽 서브트리의 바깥쪽이 같고, 안쪽끼리도 같아야 해요. 그래서 두 노드를 받아 비교하는 도우미 함수를 재귀로 짭니다.
# structures/exercises_c1.py
def is_symmetric(root):
def mirror(a, b):
if a is None and b is None:
return True
if a is None or b is None:
return False
return (a.val == b.val
and mirror(a.left, b.right) # 바깥끼리
and mirror(a.right, b.left)) # 안쪽끼리
if root is None:
return True
return mirror(root.left, root.right)
mirror(a, b)는 두 노드가 서로의 거울상인지 봅니다. 둘 다 없으면 True(대칭), 하나만 없으면 False(모양이 어긋남), 둘 다 있으면 값이 같은지 + 바깥끼리(a.left와 b.right) + 안쪽끼리(a.right와 b.left)가 거울로 같은지를 재귀로 따져요. 바깥과 안쪽을 교차해 비교하는 게 거울의 핵심입니다.
거울 비교 — mirror(왼쪽 서브트리, 오른쪽 서브트리)
1
/ \
2 2
/ \ / \
3 4 4 3 → 대칭 (True)
왼쪽 2 의 바깥(3) ↔ 오른쪽 2 의 바깥(3) 같다
왼쪽 2 의 안쪽(4) ↔ 오른쪽 2 의 안쪽(4) 같다
빅오는 두 서브트리의 노드를 한 번씩 짝지어 보니 시간 O(n), 재귀 깊이만큼 공간 O(h)입니다.
💡 튜터의 한마디: "두 트리를 동시에 도는 문제는 인자를 두 개 받는 도우미를 만드는 게 정석입니다." 대칭 판별, 두 트리가 같은지(same tree), 한 트리가 다른 트리의 서브트리인지 같은 문제가 전부 이 "노드 둘을 받아 재귀"로 풀려요. 혼자 순회(인자 하나)와 짝 순회(인자 둘)를 구분하는 눈이 트리 문제의 한 축입니다.
🎯 [과제 3 예시답안] 올바른 이진탐색트리인지 검증
채점 포인트
| 항목 | 확인 |
|---|---|
| 중위 순회 활용 | "BST를 중위 순회하면 오름차순"이라는 성질을 뒤집어 검증 |
| 엄격한 증가 | 이웃한 값이 늘 앞 < 뒤인지(같은 값도 위반) 확인 |
| 함정 인지 | 오른쪽 서브트리 '안'에 루트보다 작은 값이 숨는 경우를 잡는다 |
| 빅오 | 중위 순회 O(n) + 한 번 훑기 O(n) = 시간 O(n)·공간 O(n) |
풀이 예시
Step 4·6에서 "BST를 중위 순회하면 값이 오름차순으로 나온다"를 봤죠. 이걸 뒤집으면 검증이 됩니다. 중위 순회 결과가 엄격히 오름차순이면 올바른 BST예요.
# structures/exercises_c1.py
from structures.binary_tree import inorder
def is_valid_bst(root):
vals = inorder(root)
return all(vals[i] < vals[i + 1] for i in range(len(vals) - 1))
중위 순회로 값들을 뽑은 뒤, 이웃한 값이 항상 앞 < 뒤인지 확인합니다. <(작다)여야 하는 게 중요해요. <=로 하면 같은 값이 이웃해도 통과시켜, 중복을 허용하지 않는 BST 규칙을 어깁니다.
이 방식이 강한 이유는 오른쪽 서브트리 깊숙이 숨은 위반도 잡기 때문이에요. 흔한 함정을 보세요.
함정 — 이웃만 봐선 놓치는 위반
5
/ \
3 8
/
4 → 8 은 5보다 크니 OK 처럼 보이지만,
4 는 5의 오른쪽 서브트리 안에 있는데 5보다 작다 → BST 아님
중위 순회: [3, 5, 4, 8] → 5 < 4 가 깨져 False
"루트와 직접 자식만" 비교하면 이 4를 놓치지만, 중위 순회는 트리 전체를 정렬 순서로 펼치니 [3, 5, 4, 8]에서 5 < 4가 깨지는 걸 바로 잡아냅니다. 빅오는 중위 순회 O(n) + 이웃 비교 O(n)이라 시간 O(n), 값들을 리스트에 담아 공간 O(n)이에요.
다른 풀이도 있습니다. 각 노드에 "이 노드가 가질 수 있는 값의 범위 (min, max)"를 물려주며 내려가는 방식이에요. 왼쪽으로 갈 땐 max를 나로 좁히고, 오른쪽으로 갈 땐 min을 나로 좁혀, 범위를 벗어난 노드가 있으면 False. 이건 중위 순회처럼 리스트를 다 만들지 않아 공간이 O(h)로 더 적고, 위반을 만나면 곧장 멈출 수 있습니다.
💡 튜터의 한마디: "BST 문제에서 막히면 '중위 순회하면 정렬'을 떠올리세요." 검증뿐 아니라 "BST에서 k번째로 작은 값", "두 노드의 값 차 최소" 같은 문제도 중위 순회로 정렬 순서를 얻으면 풀립니다. 중위 순회와 BST의 이 연결고리가 트리 문제의 단골 열쇠예요.
🤔 [생각해볼 주제 1] BST는 평균 O(log n)인데, 왜 "최악 O(n)"을 늘 함께 적을까
문제 상황 요약
BST의 삽입·탐색은 보통 평균 O(log n)이지만, 책과 면접관은 꼭 "최악 O(n)"을 함께 묻습니다. 어떤 상황에서 BST가 O(n)으로 무너지고, 그 최악을 무엇으로 막을까요?
튜터의 가이드 및 해설
BST의 평균 O(log n)은 "트리가 좌우로 균형 잡혔다"는 가정 위에 서 있습니다. 삽입·탐색 비용이 곧 트리 높이인데, 균형 잡힌 트리의 높이는 log n이거든요. 그 가정이 깨지면 최악 O(n)입니다.
가정이 깨지는 대표 경우가 정렬된 순서로 값을 넣는 겁니다. 1, 2, 3, 4, 5를 차례로 넣으면 매번 오른쪽으로만 내려가, 트리가 한쪽으로 자라 일자가 돼요. 이건 사실상 연결 리스트라 높이가 n-1이 되고, 탐색이 O(n)으로 무너집니다. 코딩테스트에서 "정렬된 입력을 그대로 BST에 넣었다가 시간 초과"가 나는 게 이 때문이에요.
지난 시간의 해시를 떠올리면 구조가 닮았습니다. 해시도 평균 O(1)이지만 "키가 한 칸으로 몰리면" 최악 O(n)이었죠. BST도 "노드가 한쪽으로 몰리면" 최악 O(n)입니다. 둘 다 "평균은 좋은 분산이라는 가정 위에 서고, 그 가정이 깨지면 최악"이라는 같은 이야기예요.
이 최악을 막는 게 자가 균형 트리(self-balancing tree)입니다. 레드-블랙 트리·AVL 트리는 넣고 뺄 때마다 좌우 높이 차를 확인해, 한쪽으로 치우치면 노드를 회전시켜 높이를 항상 log n 근처로 되돌려요. 그래서 최악에도 O(log n)을 보장합니다. 한 줄로 답하면, "BST의 평균 O(log n)은 균형이라는 가정 위에 서고, 정렬 입력으로 치우치면 일자가 되어 최악 O(n)이다. 그래서 실무는 자가 균형 트리로 높이를 강제로 지킨다"입니다.
🎯 코테·면접에선 이렇게 나온다
"BST 탐색은 몇 인가요?"에 O(log n)만 답하면 절반입니다. "언제 O(n)이 되죠?"라는 후속에 "정렬 입력으로 치우칠 때"를, "그럼 어떻게 막죠?"에 "자가 균형 트리(레드-블랙·AVL)"를 답해야 완성이에요. "평균 O(log n), 최악 O(n), 균형 트리로 최악 방지"를 한 묶음으로 외워 두세요.
💡 실무에선
데이터베이스 인덱스가 바로 이 균형 트리 위에서 돕니다. 대부분의 관계형 DB 인덱스는 B-트리(자식이 여럿인 균형 트리)를 써서, 수억 건 데이터에서도 몇 번의 디스크 접근으로 찾아요. 자바의 TreeMap·TreeSet도 레드-블랙 트리라 항상 O(log n)을 보장합니다. "정렬된 순서를 유지하면서 O(log n)을 보장하려면 균형이 필수"라는 게, 자료구조를 실제 시스템에 얹을 때의 핵심 감각이에요.
🤔 [생각해볼 주제 2] 트리 순회, 재귀로 짤까 반복으로 짤까
문제 상황 요약
같은 순회를 재귀로도, 명시적 스택 반복으로도 짤 수 있었습니다. 어떤 상황에 어느 쪽을 고르고, "재귀가 사실 스택을 쓴다"는 사실이 이 선택에서 무엇을 알려 줄까요?
튜터의 가이드 및 해설
둘의 차이를 가독성과 깊이 한계 두 축으로 갈라 보면 선택 기준이 또렷해집니다.
가독성은 재귀가 압도적으로 유리합니다. 트리가 "루트 + 서브트리들"이라는 재귀 구조라, 재귀 코드는 그 구조를 거의 그대로 옮겨요. preorder가 두 줄이었던 걸 떠올려 보세요. 반복 버전은 스택을 직접 만들고 넣는 순서까지 신경 써야 해서 더 길고, 읽는 사람이 "왜 오른쪽을 먼저 넣지?"를 한 번 더 생각해야 합니다.
깊이 한계는 반복이 유리합니다. 파이썬은 재귀 깊이에 기본 한도(약 1000)가 있어서, 노드가 수십만 개인데 트리가 한쪽으로 치우쳐 있으면 재귀가 그 한도를 넘어 RecursionError로 터져요. 반복은 우리가 만든 리스트(스택)에 쌓으니 그 한도와 무관합니다.
여기서 "재귀가 사실 스택을 쓴다"는 사실이 실마리를 줍니다. 재귀의 깊이 한계는 컴퓨터의 호출 스택이 넘치기 때문인데, 우리가 그 스택을 직접 관리하면(반복) 그 한계를 우회하는 거예요. 즉 둘은 "누가 스택을 관리하느냐"의 차이일 뿐, 하는 일은 같습니다. 그래서 판단은 이렇게 갈려요. 평범한 깊이(수천 이하)면 짧고 읽기 쉬운 재귀, 아주 깊을 수 있으면 한계에서 자유로운 반복.
🎯 코테·면접에선 이렇게 나온다
코테에선 대부분 재귀로 짜되, 트리·그래프가 깊을 수 있는 문제(노드 10만 이상)에선 맨 위에 sys.setrecursionlimit(10**6)을 넣는 게 파이썬의 관용구예요. 면접에선 "재귀와 반복 순회의 차이"를 물으면 "재귀는 호출 스택을 쓰고, 반복은 그 스택을 명시적으로 만든 것"이라고 답하면 깔끔합니다. setrecursionlimit을 올리는 것은 간단하지만 한도를 너무 높이면 진짜 스택 메모리가 부족해질 수 있고, 반복 전환은 안전하지만 코드가 길어지는 트레이드오프가 있습니다.
💡 실무에선
실무 언어 중에는 아예 재귀 깊이 한계가 문제되지 않게 "꼬리 재귀 최적화"를 해 주는 것도 있지만, 파이썬은 하지 않아요. 그래서 깊은 자료구조를 다루는 파이썬 코드는 재귀 대신 반복(명시적 스택·큐)으로 짜는 경우가 많습니다. "이 구조가 얼마나 깊어질 수 있나"를 먼저 가늠하고 재귀/반복을 고르는 습관이, 스택 오버플로로 밤새우는 일을 막아 줍니다.
🤔 [생각해볼 주제 3] 연결 리스트는 트리의 특수형, 트리는 그래프의 특수형 — 자료구조의 계보
문제 상황 요약
오늘 "자식이 하나뿐인 트리 = 연결 리스트", "치우친 BST = 일자 = 연결 리스트"를 봤고, "트리는 사이클 없이 연결된 그래프"라고도 했습니다. 자료구조들이 서로의 특수형·일반형으로 이어진다는 걸, 지금까지 배운 것을 돌아보며 정리해 봅시다.
튜터의 가이드 및 해설
자료구조는 따로 떨어진 섬이 아니라, "포인터를 몇 개, 얼마나 자유롭게 잇느냐"로 이어지는 하나의 계보입니다. 그 축으로 지금까지 배운 걸 꿰어 볼게요.
시작은 노드와 포인터였습니다. 연결 리스트의 노드는 next 하나로, 데이터가 한 줄로만 이어졌어요. 여기에 포인터를 하나 더(왼쪽·오른쪽) 달면 트리가 되어 가지를 칩니다. 그래서 "자식이 하나뿐인 트리"는 다시 연결 리스트로 되돌아가고, 오늘 본 "치우친 BST가 일자가 되는" 현상이 그 증거였죠. 여기서 한 발 더 나아가 "위아래 방향도, 사이클도 자유롭게" 잇도록 풀면 그래프가 됩니다. 트리는 그중 "사이클 없이 연결된" 특수한 그래프고요.
정리하면 연결 리스트 ⊂ 트리 ⊂ 그래프라는 포함 관계입니다. 포인터를 하나에서 여럿으로, 방향을 한쪽에서 자유롭게 풀수록 더 일반적인 구조가 돼요. 배열·해시는 이 사슬과 다른 축(연속된 메모리·값으로 위치 계산)이지만, "무엇을 담아 어떻게 잇고 찾느냐"라는 같은 질문에 대한 다른 답들입니다.
이 계보가 학습에도 그대로 이어집니다. 오늘의 노드·포인터·순회가 바닥에 깔려야, 다음에 배울 것들이 얹혀요. 한 줄로 답하면, 자료구조는 "노드를 얼마나 자유롭게 잇느냐"의 스펙트럼이고, 연결 리스트에서 트리로, 트리에서 그래프로 갈수록 표현력이 커지는 대신 다루기 복잡해진다입니다.
🎯 코테·면접에선 이렇게 나온다
면접에서 "트리와 그래프의 관계는?"이라고 물으면 "트리는 사이클 없이 연결된 그래프(노드 n개에 간선 n-1개)"라고 답하면 정확해요. 코테에선 이 계보가 풀이 도구로 이어집니다. 트리 순회(오늘)를 알면 그래프 순회(DFS/BFS)가 "방문 체크만 더한 확장"으로 와닿고, 스택·큐가 그 순회의 엔진이 되죠. "직접 구현으로 원리, 내장으로 실전"의 두 트랙도 그대로 이어져, 원리를 손으로 본 사람이 라이브러리를 더 정확히 씁니다.
💡 실무에선
실무에서 자료구조를 직접 짤 일은 드물지만, "이 데이터의 관계가 한 줄인가(리스트), 갈라지나(트리), 얽히나(그래프)"를 알아보는 눈은 매일 씁니다. 댓글의 대댓글은 트리, 친구 관계는 그래프, 작업의 선후 관계는 방향 그래프처럼요. 그 모양을 알아보면 어떤 알고리즘을 쓸지가 따라 나옵니다. 다음 카테고리에서 힙은 트리를 배열에 담아 우선순위를, 그래프는 노드를 자유롭게 이어 관계를 다루는데, 전부 오늘의 노드·포인터 위에 서 있어요.