[250416] clojure PersistentVector - 작업중
Table of Contents
1 Bit-partitioned Trie
Bit-partitioned Trie는 Clojure의 Persistent Vector 구현에서 핵심적인 역할. 이 구조는 인덱스를 비트 단위로 분할하여 트리를 탐색하는 방식으로, 효율적인 인덱싱, 연산, 그리고 불변성을 유지하는 데 기여
- Trie 구조의 기본 개념
Trie(트라이)는 디지털 트리라고도 불리며, 주로 문자열과 같은 키를 저장하고 검색하는 데 사용되는 트리 기반 자료 구조 일반적인 Trie에서는 각 노드가 키의 일부(예: 문자열의 한 문자)를 나타내며, 루트에서 리프까지의 경로를 따라가면 전체 키를 구성할 수 있음.
- Persistent Vector와 Bit-partitioned Trie
Clojure의 Persistent Vector는 Bit-partitioned Trie를 기반으로 한 32-ary 트리 구조를 사용. 이는 각 노드가 최대 32개의 자식을 가질 수 있다는 의미. 여기서 인덱스는 32진수로 변환되며, 각 자릿수는 트리의 레벨에 대응함.
인덱스의 비트 분할
인덱스 33 예를 들자:
- 33을 32진수로 변환하면
11
이 된다. - 이는 트리에서 루트 노드의 두 번째 자식(인덱스 1)을 선택한 뒤, 그 자식 노드에서 다시 두 번째 자식(인덱스 1)을 선택하는 과정에 해당.
- 트리 탐색 과정
조회
- 33의 이진수 표현은
100001
이다. - 5비트씩 분할하면
00001, 00001
이 된다. - 탐색 과정
- 루트 노드에서 1번째 자식(0부터 세므로 두 번째)을 선택.
- 그 자식 노드에서 다시 1번째 자식을 선택하여 리프 노드에 도달.
- 리프 노드에서 해당 인덱스의 값을 반환.
업데이트
조회와 비슷하지만 불변성을 유지하기 위해 새로운 트리를 생성
- 인덱스를 따라가며 변경이 필요한 경로의 노드를 복사.
- 변경되지 않은 노드는 기존 트리와 공유하여 메모리 효율성을 유지
- 예를 들어, 인덱스 33의 값을 변경하려면 해당 경로상의 노드를 복사하여 새로운 트리를 만들고, 나머지 부분은 기존 트리와 공유.
- 장점
- 효율적인 인덱싱 : 인덱스를 비트단위로 분할하여 트리를 탐색하므로, 접근시간이
O(log₃₂ N)
으로 매우 빠름. 이는 이진트리의 경우O(log₂ N)
보다 빠르다. - 메모리 효율성: 구조적 공유(structural sharing)를 통해 변경되지 않은 노드를 재사용하므로 메모리 사용량을 최적화.
- 불변성 : 모든 연산이 새로운 트리를 생성하므로, 기존 데이터는 변경되지 않고 함수형 프로그래밍의 불변성을 보장.
- 꼬리최적화
Bit-partitioned Trie 와는 관계없지만 PersistentVector는 벡터 끝에 요소를 추가할 때 성능을 더욱 향상 시키기 위해 tail 최적화 기법을 사용한다. 마지막 리프 노드(tail)는 트리 구조 밖에 별도로 유지되며, 요소를 추가할 때 전체 트리를 재구성하지 않고 tail에 직접 추가합니다. tail이 가득 차면 새로운 리프 노드로 변환되어 트리에 통합됩니다. 이는 Bit-partitioned Trie와 결합하여 추가 연산의 효율성을 크게 높인다.
2 clojure PersistentVector
PersistentVector는 32-ary 트리(각 노드가 최대 32개의 자식을 가짐)와 tail 배열로 구성됩니다. 주요 필드는 다음과 같습니다:
- 주요 구성 요소
cnt
: 벡터에 저장된 요소 개수 (int
).shift
: 트리의 깊이를 나타내는 값. 각 레벨은 5비트 단위로 인덱스 분할됨. (int
).root
: 트리의 루트 노드. Node 타입으로, 자식 노드나 리프 노드를 가리킵니다.tail
: 벡터의 마지막 요소를 저장하는 배열(빠른 접근 및 업데이트를 위해 최적화) (Object[]
)._meta
: 메타데이터를 저장하는 필드 (IPersistentMap
).
final int cnt; public final int shift; public final Node root; public final Object[] tail; final IPersistentMap _meta;
Node 클래스
Node
는 트리의 노드를 말함.
edit
:AtomicReference<Thread>
타입으로, 동시성 제어(특히 TransientVector에서 사용)array
: 최대 32개의 요소 또는 자식 노드를 저장하는 배열 (Object[]
).
기본상수
EMPTY_NODE
: 빈 노드로, 크기 32의 배열을 가짐.EMPTY
: 빈 PersistentVector 인스턴스 (cnt=0, shift=5, root=EMPTY_NODE, tail=[]
).
public static class Node implements Serializable { transient public final AtomicReference<Thread> edit; public final Object[] array; public Node(AtomicReference<Thread> edit, Object[] array){ this.edit = edit; this.array = array; } Node(AtomicReference<Thread> edit){ this.edit = edit; this.array = new Object[32]; } } // ... final static AtomicReference<Thread> NOEDIT = new AtomicReference<Thread>(null); public final static Node EMPTY_NODE = new Node(NOEDIT, new Object[32]); final int cnt; public final int shift; public final Node root; public final Object[] tail; final IPersistentMap _meta; public final static PersistentVector EMPTY = new PersistentVector(0, 5, EMPTY_NODE, new Object[]{}); private static final IFn TRANSIENT_VECTOR_CONJ = new AFn() { public Object invoke(Object coll, Object val) { return ((ITransientVector)coll).conj(val); } public Object invoke(Object coll) { return coll; } }; static public PersistentVector adopt(Object [] items){ return new PersistentVector(items.length, 5, EMPTY_NODE, items); } static public PersistentVector create(IReduceInit items) { TransientVector ret = EMPTY.asTransient(); items.reduce(TRANSIENT_VECTOR_CONJ, ret); return ret.persistent(); }
- 트리 구조
트리 구조를 이루는 주요 필드는 아래와 같다.
public final int shift; public final Node root; public final Object[] tail;
- shift: 트리의 깊이를 결정하며, 인덱스를 5비트 단위로 분할. (5비트는
2^5 = 32
를 의미) - root: 트리의 루트 노드
- tail: 벡터의 마지막 요소를 저장하는 배열로, 추가 연산을 최적화
Node 클래스
각 노드는 Node 클래스로 정의되며, 아래와 같은 구조를 가진다:
public static class Node implements Serializable { transient public final AtomicReference<Thread> edit; public final Object[] array; }
- array: 최대 32개의 요소 또는 자식 노드를 저장하는 배열
3 PersistentVector 자료 구조 케이스별 예시
- 벡터 크기가 32 이하일때 (
cnt <= 32
) - 모든 요소는
tail
배열에 저장됨. root
노드는 비어있으며, 트리 구조는 사용되지 않음.- 이 경우 벡터는 배열처럼 단순하게 동작, 복잡한 트리구조없이 빠르게 요소를 추가하거나 접근할 수 있다.
소스코드
// 초기화 public PersistentVector(int cnt, int shift, Node root, Object[] tail){ this._meta = null; this.cnt = cnt; this.shift = shift; this.root = root; this.tail = tail; } // 빈 벡터 생성 public static final PersistentVector EMPTY = new PersistentVector(0, 5, EMPTY_NODE, new Object[]{}); // 요소를 추가할 때 tail에 저장되며, cnt - tailoff() < 32 조건 확인: public PersistentVector cons(Object val){ if(cnt - tailoff() < 32) { Object[] newTail = new Object[tail.length + 1]; System.arraycopy(tail, 0, newTail, 0, tail.length); newTail[tail.length] = val; return new PersistentVector(meta(), cnt + 1, shift, root, newTail); } // 32를 초과하면 트리 구조로 전환 (다음 케이스에서 설명) } // 여기서 tailoff()는 tail이 시작되는 오프셋을 계산: final int tailoff(){ if(cnt < 32) return 0; return ((cnt-1) >>> 5) << 5; }
예시 (cnt = 5)
- tail 배열에 5개의 요소가 저장되고, root는 비어 있습니다.
PersistentVector (cnt=5) ├── root: 빈 노드 └── tail: [0, 1, 2, 3, 4]
- 벡터 크기가 32 초과일때 (
cnt = 33
)
구조
tail
의 크기가 32를 초과하면 트리 구조가 활용됨- root 노드는 내부 노드가 되며, 자식 노드를 통해 리프 노드를 가리킴.
- 첫 번째 리프 노드: 인덱스 0~31까지의 요소를 저장 (32개).
- tail은 새로운 요소(인덱스 32)를 저장하며, 아직 트리에 통합되지 않은 마지막 요소.
// 요소 추가 시 cnt - tailoff() >= 32이면 root에 새 노드를 추가: public PersistentVector cons(Object val){ if(cnt - tailoff() < 32) { // tail에 공간이 있을 때 } else { Node tailnode = new Node(root.edit, tail); Node newroot; if((cnt >>> 5) > (1 << shift)) { newroot = new Node(root.edit); newroot.array[0] = root; newroot.array[1] = newPath(root.edit, shift, tailnode); shift += 5; } else { newroot = pushTail(shift, root, tailnode); } return new PersistentVector(meta(), cnt + 1, shift, newroot, new Object[]{val}); } } // tailoff()는 tail의 시작 지점을 계산: final int tailoff(){ if(cnt < 32) return 0; return ((cnt-1) >>> 5) << 5; // 33일 때 32 반환 } // 인덱스 접근 시 arrayFor 메서드가 사용 public Object[] arrayFor(int i){ if(i >= 0 && i < cnt){ if(i >= tailoff()) // i >= 32면 tail 반환 return tail; Node node = root; for(int level = shift; level > 0; level -= 5) node = (Node) node.array[(i >>> level) & 0x01f]; return node.array; } throw new IndexOutOfBoundsException(); }
예시 (cnt = 33)
- root는 두 개의 리프 노드를 참조하고, tail은 마지막 요소를 저장
PersistentVector (cnt=33) ├── root (내부 노드) │ └── array[0] → 리프 노드: [0, 1, ..., 31] └── tail: [32]
- 3. 벡터 크기가 훨씬 클 때 (예: cnt = 1025)
- 벡터 크기가 커질수록 트리의 깊이가 증가.
- 예를 들어, cnt = 1025일 때 트리 깊이는 2(322 = 1024).
- root는 내부 노드를 참조하며, 각 내부 노드는 최대 32개의 하위 노드를 가짐.
- tail은 항상 마지막 32개 이하의 요소를 저장.
// shift 값은 트리의 깊이를 제어 public PersistentVector(int cnt, int shift, Node root, Object[] tail){ this.cnt = cnt; this.shift = shift; // 트리 깊이에 따라 5, 10, 15 등으로 증가 this.root = root; this.tail = tail; } // pushTail 메서드는 트리에 새 리프 노드를 추가 private PersistentVector pushTail(int level, Node parent, Node tailnode){ int subidx = ((cnt - 1) >>> level) & 0x01f; Node ret = new Node(parent.edit, parent.array.clone()); Node nodeToInsert; if(level == 5) { nodeToInsert = tailnode; } else { Node child = (Node) parent.array[subidx]; nodeToInsert = (child != null) ? pushTail(level - 5, child, tailnode) : newPath(root.edit, level - 5, tailnode); } ret.array[subidx] = nodeToInsert; return ret; } final int tailoff(){ if(cnt < 32) return 0; return ((cnt-1) >>> 5) << 5; // 1025일 때 992 반환 }
예시 (cnt = 1025)
PersistentVector (cnt=1025) ├── root (내부 노드) │ ├── array[0] → 내부 노드 │ │ ├── array[0] → 리프 노드: [0, 1, ..., 31] │ │ ├── array[1] → 리프 노드: [32, 33, ..., 63] │ │ └── ... (최대 array[31]까지, [992, 993, ..., 1023]) │ ├── array[1] → 내부 노드 │ │ ├── array[0] → 리프 노드: [1024] │ └── array[2] ~ array[31]: null └── tail: [1024]
4 경로복사
conj
동작 방식
- tail에 공간이 있을 때:
- tail은 최대 32개의 요소를 저장할 수 있는 배열
- 새로운 요소를 tail에 추가하기 위해 기존 tail을 복사하고, 새 요소를 포함한 새로운 tail을 생성
- 트리 구조는 변경되지 않고, 새로운 PersistentVector는 업데이트된 tail만 참조
- 시간 복잡도: O(1)
- tail이 가득 찼을 때:
- tail에 32개의 요소가 모두 차면, 이 tail을 새로운 리프 노드로 변환하여 기존 트리에 통합.
- 새로운 빈 tail을 생성하고, 여기에 새 요소를 추가.
- 트리에 새 리프 노드를 추가하기 위해 루트에서 리프까지의 경로를 따라 새로운 노드를 생성하며, 변경되지 않은 부분은 기존 트리와 공유.
- 시간 복잡도:
O(log₃₂ n)
소스코드
public PersistentVector cons(Object val) { if (cnt - tailoff() < 32) { Object[] newTail = new Object[tail.length + 1]; System.arraycopy(tail, 0, newTail, 0, tail.length); newTail[tail.length] = val; return new PersistentVector(meta(), cnt + 1, shift, root, newTail); } else { Node tailnode = new Node(root.edit, tail); Node newroot; if ((cnt >>> 5) > (1 << shift)) { newroot = new Node(root.edit); newroot.array[0] = root; newroot.array[1] = newPath(root.edit, shift, tailnode); shift += 5; } else { newroot = pushTail(shift, root, tailnode); } return new PersistentVector(meta(), cnt + 1, shift, newroot, new Object[]{val}); } }
설명
- tail에 공간이 있을 경우 (cnt - tailoff() < 32):
- tail은 최대 32개의 요소를 저장할 수 있는 배열. 여유 공간이 있으면:
- 기존 tail을 복사해 새로운 배열(newTail)을 만든다.
- 새 요소(val)를 newTail의 끝에 추가한다.
- 새로운 PersistentVector를 반환하며, root는 그대로 유지되고 tail만 업데이트된다.
- tail은 최대 32개의 요소를 저장할 수 있는 배열. 여유 공간이 있으면:
- tail이 가득 찬 경우:
- tail을 새로운 리프 노드(tailnode)로 변환하고 트리에 삽입한다.
- 트리의 깊이를 늘려야 할 때 ((cnt >>> 5) > (1 << shift)):
- 새로운 루트 노드(newroot)를 생성한다.
- 기존 root와 새로 만든 경로(newPath)를 연결한다.
- 트리의 깊이를 나타내는 shift를 5 증가시킨다.
- 깊이를 늘릴 필요가 없으면:
- pushTail을 호출해 tailnode를 기존 트리에 추가한다.
- 새로운 PersistentVector는 업데이트된 root와 새 빈 tail(단일 요소 val 포함)을 가진다.
assoc
불변성을 유지하면서 값을 변경하기 위해 경로 복사를 사용.
- 인덱스가 tail에 있을 때:
- tail 배열을 복사하고, 해당 인덱스의 값을 업데이트.
- 새로운 PersistentVector는 변경된 tail을 참조하며, 트리 구조는 그대로 유지.
- 시간 복잡도: O(1)
- 인덱스가 트리에 있을 때:
- 루트에서 업데이트할 인덱스가 위치한 리프 노드까지의 경로를 따라 새로운 노드를 생성.
- 변경된 리프 노드에 새 값을 설정.
- 경로상의 모든 노드를 복사하여 새 트리를 만들고, 변경되지 않은 부분은 기존 트리와 공유.
- 시간 복잡도:
O(log₃₂ n)
예시
- 요소 33개(cnt = 33)가 있는 벡터에서 인덱스 0을 업데이트:
- 인덱스 0은 트리의 리프 노드에 위치.
- 루트에서 해당 리프까지의 경로를 복사하여 새 노드를 만들고, 새 리프 노드에서 인덱스 0의 값을 변경.
- 새로운 PersistentVector는 새 루트와 업데이트된 리프 노드를 참조하며, 나머지 부분은 기존 트리와 공유됨.
소스코드
public PersistentVector assocN(int i, Object val) { if (i >= 0 && i < cnt) { if (i >= tailoff()) { Object[] newTail = new Object[tail.length]; System.arraycopy(tail, 0, newTail, 0, tail.length); newTail[i & 0x01f] = val; return new PersistentVector(meta(), cnt, shift, root, newTail); } return new PersistentVector(meta(), cnt, shift, doAssoc(shift, root, i, val), tail); } if (i == cnt) return cons(val); throw new IndexOutOfBoundsException(); } // 경로복사가 수행되는 곳. // 동작방식 // assoc 연산이 호출되면 doAssoc가 트리의 루트에서 시작 // 각 레벨에서 노드를 복사하고, 변경이 필요한 경로를 따라 하위 노드로 내려감 // 리프 노드에 도달하면 값을 업데이트하고, 새로운 노드를 반환하며 상위 노드로 거슬러 올라감 // 결과적으로, 변경된 경로상의 노드만 새로 생성되고 나머지는 기존 트리와 공유됨 private static Node doAssoc(int level, Node node, int i, Object val) { // 현재 노드를 복사해 새로운 노드(ret)를 생성한다. // node.array.clone()을 통해 배열을 복사하므로 원본과 독립적인 노드가 만들어진다. 이는 경로 복사의 핵심이다. Node ret = new Node(node.edit, node.array.clone()); // 리프 노드 업데이트 if (level == 0) { // level이 0이면 리프 노드에 도달한 것이므로, 지정된 인덱스(i & 0x01f)에 새 값(val)을 설정한다. // 0x01f는 16진수 값으로, 10진수로는 31에 해당, 11111이며, 5비트가 모두 1로 설정된 값 // PersistentVector는 32-ary 트리(각 노드가 최대 32개의 자식을 가짐)를 사용하므로, 인덱스를 5비트 단위로 분할하여 처리 // 이는 2^5 = 32 이기 때문 // 코드에서 i & 0x01f 연산은 인덱스 i의 하위 5비트를 추출. // 이 값은 현재 레벨에서 선택할 슬롯(0부터 31까지)을 결정 // 인덱스 i = 33의 이진수는 100001. // 100001 & 11111 = 00001이므로, i & 0x01f = 1 // 즉, 현재 레벨에서 1번 슬롯을 선택 ret.array[i & 0x01f] = val; } else { // 재귀적 하위 노드 처리 // 리프가 아닌 경우, 인덱스를 계산하여 하위 노드로 재귀 호출을 수행. // 이 과정에서 필요한 경로상의 노드들이 순차적으로 복사됨. // 현재 노드의 하위 노드 인덱스 계산 // i >>> level 은 인덱스 i를 level 만큼 오른쪽으로 이동하여 하위 레벨을 선택 // 예시 i = 33, level = 5인 경우 // - (33 >>> 5) & 0x01f // - 33 >>> 5 = 00001 (1). // - 00001 & 0x01f = 00001 (1). // - 결과: 슬롯 1을 선택 (root.array[1]). // // 예시 i = 33, level = 0인 경우 // - (33 >>> 0) & 0x01f // - 33 >>> 0 = 100001 (33, 이동 없음). // - 100001 & 11111 = 00001 (1). // - 결과: 슬롯 1을 선택 (leaf.array[1]). int subidx = (i >>> level) & 0x01f; // level - 5 는 현재 레벨에서 5비트 오른쪽으로 이동하여 하위 레벨을 선택 // 예를 들어, level = 5인 경우, level - 5 = 0이 되어 리프 노드 레벨에 도달 // level 이 0 일 때까지 재귀적으로 호출됨. ret.array[subidx] = doAssoc(level - 5, (Node) node.array[subidx], i, val); } return ret; }
설명
- 인덱스가 tail에 있을 경우 (i >= tailoff()):
- tail 배열을 복사해 새로운 newTail을 만든다.
- 인덱스 i에 해당하는 위치(i & 0x01f)에 새 값(val)을 설정한다.
- 새로운 PersistentVector를 반환하며, root는 그대로 유지되고 tail만 업데이트된다.
- 인덱스가 트리에 있을 경우:
- doAssoc 메서드를 호출해 트리에서 해당 인덱스까지의 경로를 복사하며 값을 업데이트한다.
- 현재 노드를 복사해 새로운 노드(ret)를 생성한다.
- 리프 노드에 도달하면(level == 0), 해당 인덱스의 값을 업데이트한다.
- 리프가 아닌 경우, 인덱스를 계산(subidx)해 하위 노드로 재귀 호출한다.
- 새로운 PersistentVector는 업데이트된 root와 기존 tail을 가진다.
- 특수 경우:
- i == cnt이면 conj와 동일하게 동작한다.
- 유효하지 않은 인덱스면 IndexOutOfBoundsException을 발생시킨다.