[250416] clojure PersistentVector - 작업중

Table of Contents

1 Bit-partitioned Trie

Bit-partitioned Trie는 Clojure의 Persistent Vector 구현에서 핵심적인 역할. 이 구조는 인덱스를 비트 단위로 분할하여 트리를 탐색하는 방식으로, 효율적인 인덱싱, 연산, 그리고 불변성을 유지하는 데 기여

  1. Trie 구조의 기본 개념

Trie(트라이)는 디지털 트리라고도 불리며, 주로 문자열과 같은 키를 저장하고 검색하는 데 사용되는 트리 기반 자료 구조 일반적인 Trie에서는 각 노드가 키의 일부(예: 문자열의 한 문자)를 나타내며, 루트에서 리프까지의 경로를 따라가면 전체 키를 구성할 수 있음.

  1. 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 배열로 구성됩니다. 주요 필드는 다음과 같습니다:

  1. 주요 구성 요소
  2. cnt : 벡터에 저장된 요소 개수 (int).
  3. shift : 트리의 깊이를 나타내는 값. 각 레벨은 5비트 단위로 인덱스 분할됨. (int).
  4. root : 트리의 루트 노드. Node 타입으로, 자식 노드나 리프 노드를 가리킵니다.
  5. tail : 벡터의 마지막 요소를 저장하는 배열(빠른 접근 및 업데이트를 위해 최적화) (Object[]).
  6. _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();
}
  1. 트리 구조

트리 구조를 이루는 주요 필드는 아래와 같다.

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 자료 구조 케이스별 예시

  1. 벡터 크기가 32 이하일때 (cnt <= 32)
  2. 모든 요소는 tail 배열에 저장됨.
  3. root 노드는 비어있으며, 트리 구조는 사용되지 않음.
  4. 이 경우 벡터는 배열처럼 단순하게 동작, 복잡한 트리구조없이 빠르게 요소를 추가하거나 접근할 수 있다.

소스코드

// 초기화
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]
  1. 벡터 크기가 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]
  1. 3. 벡터 크기가 훨씬 클 때 (예: cnt = 1025)
  2. 벡터 크기가 커질수록 트리의 깊이가 증가.
  3. 예를 들어, cnt = 1025일 때 트리 깊이는 2(322 = 1024).
  4. root는 내부 노드를 참조하며, 각 내부 노드는 최대 32개의 하위 노드를 가짐.
  5. 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 경로복사

  1. 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이 가득 찬 경우:
    • tail을 새로운 리프 노드(tailnode)로 변환하고 트리에 삽입한다.
    • 트리의 깊이를 늘려야 할 때 ((cnt >>> 5) > (1 << shift)):
      • 새로운 루트 노드(newroot)를 생성한다.
      • 기존 root와 새로 만든 경로(newPath)를 연결한다.
      • 트리의 깊이를 나타내는 shift를 5 증가시킨다.
    • 깊이를 늘릴 필요가 없으면:
      • pushTail을 호출해 tailnode를 기존 트리에 추가한다.
      • 새로운 PersistentVector는 업데이트된 root와 새 빈 tail(단일 요소 val 포함)을 가진다.
  1. 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을 발생시킨다.

5 참고문헌

Author: Younghwan Nam

Created: 2025-04-23 Wed 18:00

Emacs 27.2 (Org mode 9.4.4)

Validate