복's
[ C언어 ]Red-Black Tree 구현하기 본문
BST(Binary Search Tree)의 종류로 BST의 알고리즘을 수정한 알고리즘이다.
[ 📌 그렇다면 왜? ]
- 극단적으로 치우쳐 Balance가 무너지는 것을 방지하기 위해서
- BST의 경우 Search, Insert, Delete 연산이 모두 O(Tree Height) 만큼의 시간 복잡도를 갖는다. 하지만 극단적인 케이스에는 Tree Height가 최악의 케이스가 나올 수 있기 때문이다.
- Balance가 무너진 트리에서 연산을 계속 하게 될 경우를 피하기 위해서
두 그림의 트리는 Depth가 확연히 다름을 알 수 있다.
예를 들어서 3을 찾으려면 BST 같은 경우에는 10 ~ 3까지 가야 하는데, RBtree는 7 -> 5 -> 3 으로 3을 찾을 수 있다.
- 최악의 경우에도 트리의 높이에 비례해 O(log N)의 수행능력을 갖게된다.
- BST의 특성은 유지한채 Rotation 시에도 O(1)의 시간 복잡도를 갖는다.
[ 📌 노드의 구성 ]
- 색 (Color): 노드의 색상 (Red & Black)
- 키 (Key): 값
- 부모 (Parent): 부모 노드
- 왼쪽 (Left): 왼쪽 자식 노드
- 오른쪽 (Right): 오른쪽 자식 노드
- NIL(NULL)
- 모든 Leaf Node는 NIL 노드이다.
- Root노드의 Parent 노드는 NIL 노드이다.
- 노드는 내부 노드와 NIL 노드로 구성된다.
[ 📌 RBtree의 조건 ]
이 규칙들 때문에 발생하는 예외 케이스들 때문에 나는 RBtree가 뭔가 정리는 되어있지만 정형화되어 있는 알고리즘은 아닌거 같다는 생각이 많이 들었다. (물론 모든 케이스에 대한 대처가 되어 있으니 생각과 다르지만)
- 각 노드는 Red 혹은 Black의 색을 갖는다.
- 루트(Root)노드는 Black이다.
- 리프(Leaf)노드는 Black이다.
- Red노드의 자식은 Black이다. (= Red 노드는 연속해서 나올 수 없다.)
- Root노드 부터 혹은 임의의 노드로 부터 Leaf노드 까지의 모든 경로에서 나오는 Black의 개수는 모든 경로에서 동일하게 나와야 한다.
- 높이(Height): 임의의 노드로부터 Leaf 노드까지 가장 긴 경로에 포함된 Edge의 개수
- 블랙 높이(Black Height): 리프노드까지의 경로상 Black노드의 개수 (BH >= H / 2: Red가 연속될 수 없기에 나오는 공식)
위 그림을 보면 위에서 서술한 RB Tree의 규칙이 적용되어 있음을 알 수 있다.
[ 📌 ROTATION ]
- 그림 처럼 노드를 회전 시키는 건데 두 번째 파라미터로 넘어가는 노드를 베이스로 회전한다.
- O(1) 시간 복잡도를 갖는다.
- 복잡해 보이지만 사실 노드 사이의 관계를 끊고 맺는 작업이기 때문에 시간 복잡도가 O(1) 밖에 나오지 않는다.
⚙︎ Left - Rotate Sudo Code
left_rotate(T, x) // T: tree, z: new node
y = right[x] // y를 x의 right node로 만든다.
right[x] = left[y] // y의 left node를 x의 right node로 만든다.
p[left[y]] = x // y의 left node의 부모를 x로 설정한다.
p[y] = p[x] // y의 parent node를 x의 parent node로 설정한다.
// 1. x의 부모가 nil node인 경우 y를 root node로 설정한다.
// 2. x가 x부모의 left node면 y가 x 부모의 left node가 되고,
// 3. x가 x부모의 right node면 y가 x 부모의 right node가 된다.
if p[x] == nil[T]
then root[T] = y
else if x == left[p[x]]
then left[p[x]] = y
else
right[p[x]] = y
left[y] = x // y의 left node는 x가 된다.
p[x] = y // x의 paernt node는 y가 된다.
⚙︎ Sudo Code 기반으로 작성한 C코드 (Left - Rotate)
void rotate_left(rbtree *t, node_t *x){
node_t *y = x -> right;
node_t *nil = get_nil_node(t);
x -> right = y -> left;
y -> parent = x -> parent;
if (y -> left != nil){
y -> left -> parent = x;
}
if(x -> parent == nil){
t -> root = y;
} else if(x == x -> parent -> left){
x -> parent -> left = y;
} else {
x -> parent -> right = y;
}
y -> left = x;
x -> parent = y;
}
Right Rotate의 과정은 Left와 대칭되기 때문에 코드가 다르지 않다.
[ 📌 INSERT ]
- 노드의 삽입은 BST와 같이 연산한다.
- 노드 삽입이 끝난 후 트리의 균형을 잡기 위한 함수 Fixup()을 호출하게 된다.
- Root 노드는 Black 이다. ===> 삽입하는 노드는 항상 Red이기에 위반이 가능한 사항이다.
- Red 노드는 연속해서는 안된다. ===> 항상 Red만 삽입하기 때문에 나올 수 있는 케이스
⚙︎ Insert & Fixup Sudo Code
insert(T, z) // T: tree, z: new node
y = nil[T] // y는 node를 타고 내려면서 이전 node를 저장 (insert 위치)
x = root[T] // x를 root node로 설정
while x != nil[T] // leaf node 까지 반복
do y = x
// 새로운 node key와 x node의 키 비교 후 적절한 위치
// left, right 판단
if key[z] < key[x]
then x = left[x]
else
x = right[x]
p[z] = y // 새로운 node의 parent는 y node
// 1. y가 leaf node 인 경우 새로운 node가 root node가 된다.
// 2. z의 값이 y의 값보다 작다면 y의 left node는 z가 된다.
// 3. 위 조건이 아니라면 y의 right node는 z가 된다.
if y == nil[T]
then root[T] = z
else if key[z] < key[y]
then left[y] = z
else
right[y] = z
// new noded의 leaf node 설정 및 RED로 색 배정
left[z] = nil[T]
right[z] = nil[T]
color[z] = RED
RB_Fixup()
fixup(T, z) // T: tree, z: new node
while color[p[z]] == RED // z의 parent node가 RED 라면
if p[z] == left[p[p[z]]] // case: 1, 2, 3
then y = right[p[p[z]]] // y: uncle node
if color[y] == RED // case1: parent & uncle node가 RED, grand parent node가 BLACK
then color[p[z]] = BLACK // parent node를 BLACK으로
color[y] = BLACK // uncle node를 BLACK으로 변경
color[p[p[z]]] = RED // grand parent node를 RED로 변경
z = p[p[z]] // z를 다시 갱신 후 while 시작
else if z == right[p[z]] // case: 2, 3
then z = p[z] // z의 부모를 z로 설정 (case 2)
left_rotate(T, z) // z를 기준으로 left_rotate (case 2)
color[p[z]] = BLACK // z의 parent node를 BLACK으로 설정(case 3)
color[p[p[z]]] = RED // z의 grand parent node를 BLACK으로 설정(case 3)
right_rotate(T, p[p[z]]) // grand parent 기준으로 right_rotate
else case 4, 5, 6
color[root[T]] = BLACK // case1의 상태에서 탈출 시 root가 RED로 끝날 수 있음
Case 1 ~ 3: 부모의 왼쪽 자식인 케이스
Case 4 ~ 6: 부모의 오른쪽 자식인 케이스
⚙︎ Case 1: 부모의 형제가 Red인 케이스
- z (나) : Red
- p[z] (나의 부모) : Red
- y (삼촌) : Red
- p[p[z]] (할아버지): Black
자식이 Red이기 때문에 기존까지 트리가 정상적으로 유지 되었다면 당연하게 Black이다. (Red - Red Violation) - 나의 부모가 할아버지의 왼쪽 자식인 케이스
❖ Case 1 해결법
- 부모와 삼촌 그리고 할어버지의 색을 반전(Red -> Black & Black -> Red)
- 할어버지 노드를 새로운 z(나)로 설정
- 새로운 z가 Red로 바뀌면서 그 부모의 노드와 관계에서 위반 사항이 생겼을 수 있기 때문에 Loop를 1, 2번을 Root까지 반복한다.
(위반 사항이 없다면 반복하지 않아도 된다.) - 반복중 최악의 케이스를 마주해 Root 노드까지 올라가게 된다면 Root 노드를 Black으로 변경 하면서 종료한다.
⚙︎ Case 2, 3: 삼촌이 Black인 케이스 (NIL 노드일 수 있음)
- z (나) 와 p[z] (부모): Red
- y (삼촌) : Black (NIL 가능성 있음)
❖ Case 2 , 3 해결법
- z가 부모의 오른쪽 노드일 경우 p[z]의 부모를 기준으로 Left-Rotate를 실행한다. (Case 2)
- Red - Red가 선형으로 만들어지면 Case3에 해당하게 되는데 새로운 z를 기준으로 p[p[z]] 기준으로 Right-Rotate를 실행한다.
물론 노드의 색도 바꿔줘야 하는데 그림처럼 바꿔주면 된다.
Case2 의 경우에는 Case2 -> Case3으로 만들어서 해결해야 하며, Case3의 경우에는 Right-Rotate 한 번에 해결할 수 있다.
Case 4, 5, 6은 대칭적인 문제기 때문에 크게 다를거 없다.
⚙︎ Sudo Code 기반으로 작성한 C코드 (Insert & Fixup)
node_t *rbtree_insert(rbtree *t, const key_t key) {
node_t *nil = get_nil_node(t);
node_t *new_node = get_new_node(t, key);
node_t *insert_point = nil; // 삽입할 위치를 기억하기 위해서 탐색중인 노드의 이전 노드에 위치함
node_t *cur_node = get_root_node(t);
// 삽입 위치 탐색 반복문 key 값에 따라서 트리를 타고 내려감
while(cur_node != nil){
insert_point = cur_node;
if(key < cur_node -> key){
cur_node = cur_node -> left;
} else {
cur_node = cur_node -> right;
}
}
new_node -> parent = insert_point;
if(insert_point == nil){ // 첫 노드인 경우 루트 노드로 설정
t -> root = new_node;
} else if(key < insert_point -> key){ // 삽입 위치에서 오른쪽, 왼쪽 자식이 될지 구분
insert_point -> left = new_node;
} else {
insert_point -> right = new_node;
}
rebuild_after_insert(t, new_node);
return t -> root;
}
void rebuild_after_insert(rbtree *t, node_t *z){
node_t *y = t -> nil;
while(z -> parent -> color == RBTREE_RED){
if(z -> parent == z -> parent -> parent -> left){
y = z -> parent -> parent -> right;
if(y -> color == RBTREE_RED){
z -> parent -> color = RBTREE_BLACK;
y -> color = RBTREE_BLACK;
z -> parent -> parent -> color = RBTREE_RED;
z = z -> parent -> parent;
} else {
if(z == z -> parent -> right){
z = z -> parent;
rotate_left(t, z);
}
z -> parent -> color = RBTREE_BLACK;
z -> parent -> parent -> color = RBTREE_RED;
rotate_right(t, z -> parent -> parent);
}
} else {
y = z -> parent -> parent -> left;
if(y -> color == RBTREE_RED){
z -> parent -> color = RBTREE_BLACK;
y -> color = RBTREE_BLACK;
z -> parent -> parent -> color = RBTREE_RED;
z = z -> parent -> parent;
} else {
if(z == z -> parent -> left){
z = z -> parent;
rotate_right(t, z);
}
z -> parent -> color = RBTREE_BLACK;
z -> parent -> parent -> color = RBTREE_RED;
rotate_left(t, z -> parent -> parent);
}
}
}
t -> root -> color = RBTREE_BLACK;
}
각 Case들은 해결 하는 방법이 있지만 단 1번의 과정으로 해결되지 않고 다른 케이스를 유발하는 형태로 되어있다.
물론 다른 위반사항이 파생되지 않을 수 있음 (Case 4 ~ 6도 동일하다.)
가장 Worst Case는 Case1이 해결되지 않아 Root 노드까지 가서 Root 노드를 Red로 만들고 다시 Black으로 만드는 케이스지만 크게 문제되지 않는다고 생각된다.
[ 📌 TRANSPLANT ]
⚙︎ Transplant Sudo Code
삭제를 구현하기 앞서서 먼저 알아야할 함수가 있다.
노드를 삭제할 때 해당 노드를 대체하는 노드가 들어오는 케이스가 생기는데 기존에 있던 노드의 연결관계를 새로 대체되는 노드가 갖게 연결해주는 함수이다.
⚙︎ Sudo Code 기반으로 작성한 C코드 (Transplant)
void rbtree_transplant(rbtree *t, node_t * u, node_t * v) {
if(u -> parent == t -> nil) {
t -> root = v;
} else if(u == u -> parent -> left) {
u -> parent -> left = v;
} else {
u -> parent -> right = v;
}
v -> parent = u -> parent;
}
[ 📌 DELETE ]
- 노드를 삭제하는 것 자체는 BST와 같다.
- 삭제시 Red노드를 삭제하는건 문제를 만들지 않는다.
- 삭제시 Black을 삭제시 문제를 야기한다. (Red - Red or Black Height 위반 사항)
⚙︎ Delete & Fixup Sudo Code
delete(T, z) // T: tree, z: new node
if left[z] == nil[T] or right[z] == nil // child node가 없거나 1개일 경우
y = z // 삭제될 타겟이 z
else // child node가 2개일 경우
y = tree-successor(z) // 삭제될 타겟이 successor
// successor의 최대 자식은 1명임
if left[y] != nil[T] // x: y의 자식으로 y삭제시 x가 y자리를 대체해야함
x = left[y]
else
x = right[y]
p[x] = p[y] // y 삭제 준비: x를 y자리 대체하기 위해 부모연결
// y가 root node인 경우
// y의 부모에서 left, right 판단 후 연결 (x가 y의 위치를 대체하는 코드)
if p[y] == nil[T]
root[T] = x
else if y == left[p[y]]
left[p[y]] = x
else
right[p[y]] = x
if y != z // 삭제한 y가 원래 삭제하려던 z와 다른 경우
key[z] = key[y] // data copy into z
if color[y] = BLACK // 이진검색 트리의 조건을 만족하기 때문에 RED노드를 삭제하는건 문제가 되지 않음
RB-Delete-Fixup(T, x)
return y
delete_fixup(T, x) // T: tree, z: delete node
while x != root[T] and color[x] == BLACK // 루트가 아니고 x의 컬러가 BLACK일 때
if x == left[p[x]] // x가 parent node의 left node인 경우
w = right[p[x]] // 형제 노드 설정
if color[w] == RED // w가 RED인 경우(case 1)
color[w] = BLACK // (case 1)
color[p[x]] = Red // (case 1)
left_rotate(T, p[x]) // (case 1)
w = right[p[x]] // 트리 레벨은 낮아졌으나 x는 Double BLACK, w는 BLACK인 상황 (case 1)
if color[left[w]] == BLACK and color[right[w]] == BLACK
color[x] = RED // (case 2)
x = p[x] // (case 2)
else if color[right[w]] == BLACK // w의 right child가 BLACK
color[left[w]] = BLACK // (case 3)
color[w] = RED // (case 3)
right_rotate(T, w) // w기준으로 right rotate (case 3)
w = right[p[x]] // (case 3)
color[w] = color[p[x]] // (case 4)
color[p[x]] = BLACK // (case 4)
color[right[w]] = BLACK // (case 4)
left_rotate(T, p[x]) // (case 4)
x = root[T] // while 문 벗어나기 위해 x에 root 대입(case 4)
else case 5, 6, 7 ,8
color[x] = Black
이게 내가 맨 처음에 본 수도코드인데.....
이대로 코드를 제대로 구현하지 못했는지 정상동작 하지 않아서 다른 책에있는 수도코드를 참조하게 되었다.
- Case 1 ~ 4: 부모의 왼쪽 자식 노드인 케이스
- Case 5 ~ 8: 부모의 오른쪽 자식 노드인 케이스
⚙︎ Case 1: 부모의 형제가 Red인 케이스
- 형제 노드가 Red인 케이스
- 형제 노드의 자식이 Black 인 케이스 (자식 노드가 NIL일 수 없다 -> Black Height 가 맞아야함)
- Double Black은 말 그대로 Black으로 2개 카운트 한다는 말이다.
- 자식이 둘 인 Black 노드를 삭제 하면서 일어나는 케이스 (Black 노드는 삭제되면 RB Tree의 규칙을 위반하게 만드는데 균형을 잡기 위해서 Double Black 이나 Red & Black(Extra Black) 의 개념이 도입된다.)
❖ Case 1 해결법
- p[x] 기준으로 Left-Rotate를 실행한다.
- Case2, 3, 4 중 하나의 케이스로 만든다.
⚙︎ Case 2: 형제 노드가 Black, 형제의 두 자식이 Black인 케이스
- Case2 는 Case1에서 왔는지 삭제시 발생 되었는지에 따라서 해결되는 과정이 다르다.
- 여전히 노드 x는 Double Black 상태를 유지한다.
- 노드 B의 경우 색을 특정할 수 없는 상황이다. (Case1에서 온 경우 Red로 본다.)
❖ Case 2 해결법
- 나와 내 형제 노드에서 Black을 뺏는다.(x 에게서는 Extra Black 제거, w는 Red로 변경)
- p[x] 에 Extra Black을 준다. (여기서 케이스가 나뉜다.)
- From Case1: p[x]의 색을 Red라고 특정할 수 있으며, 이 경우 Red & Black이 되어서 바로 종료
- From Delete: p[x]가 Black 이라면 다시 Double Black 상태가 되어서 루프가 필요하다. (다행인점은 Root 쪽으로 이동)
⚙︎ Case 3: 형제 노드가 Black, 형제의 왼쪽 노드가 Red
- Case 3 -> Case 4로 만드는 과정
- 그림에는 쓰지 못했지만 x는 Double Black 상태
❖ Case 3 해결법
- w 기준으로 Right-Rotate 실행
- w와 그 왼쪽 노드의 색 변경
⚙︎ Case 4: 형제 노드가 Black, 오른쪽 자식이 Red
❖ Case 4 해결법
- p[x]와 w의 right 노드 색 반전
- p[x] 기준으로 Left-Rotate 시행
- x의 Extra Black 제거 후 종료
그림들을 보면 Color Change 라고 써두었는데 노드의 색을 두 노드가 교환 하는게 아니라 변경하는 거지만 큰 차이 없다고 생각했다.
⚙︎ Sudo Code 기반으로 작성한 C코드 (Delete & Fixup)
int rbtree_erase(rbtree *t, node_t *p) {
node_t *nil = get_nil_node(t);
node_t *x = NULL;
node_t *y = NULL;
color_t y_org_color;
y = p;
y_org_color = y -> color;
if(p -> left == nil) { // left child node가 없음
x = p -> right;
rbtree_transplant(t, p, p -> right);
} else if(p -> right == nil) { // right child node가 없음
x = p -> left;
rbtree_transplant(t, p, p -> left);
} else { // child node가 2개일 경우
y = p -> right; // successor를 찾기 위해서 해당 node의 right부터 서브트리 탐색
while(y -> left != nil){
y = y -> left; // 삭제될 타겟이 successor
}
y_org_color = y -> color;
x = y -> right; // x: y의 자식으로 y삭제시 x가 y자리를 대체해야함
if(y -> parent == p) {
x -> parent = y;
} else {
rbtree_transplant(t, y, y -> right);
y -> right = p -> right;
y -> right -> parent = y;
}
rbtree_transplant(t, p, y);
y -> left = p -> left;
y -> left -> parent = y;
y -> color = p -> color;
}
if(y_org_color == RBTREE_BLACK) { // 이진검색 트리의 조건을 만족하기 때문에 RED노드를 삭제하는건 문제가 되지 않음
rebuild_after_erase(t, x);
}
free(p);
return 0;
}
void rebuild_after_erase(rbtree *t, node_t *x) {
node_t *w = NULL;
while(x != t -> root && x -> color == RBTREE_BLACK) {
if(x == x -> parent -> left){
w = x -> parent -> right;
// case 1 ~ 4
if(w -> color == RBTREE_RED){ // case1: 형제 w가 RED
w -> color = RBTREE_BLACK;
x -> parent -> color = RBTREE_RED;
rotate_left(t, x -> parent);
w = x -> parent -> right;
}
// case2: 형제 w의 두 자식노드가 BLACK
if(w -> left -> color == RBTREE_BLACK && w -> right -> color == RBTREE_BLACK) {
w -> color = RBTREE_RED;
x = x -> parent;
} else { // case3: 형제 w는 BLACK, w의 left 자식은 RED, w의 right 자식은 BLACK
if(w -> right -> color == RBTREE_BLACK) {
w -> left -> color = RBTREE_BLACK;
w -> color = RBTREE_RED;
rotate_right(t, w);
w = x -> parent -> right;
}
w -> color = x -> parent -> color;
x -> parent -> color = RBTREE_BLACK;
w -> right -> color = RBTREE_BLACK;
rotate_left(t, x -> parent);
x = t -> root;
}
} else { // case 5 ~8
w = x -> parent -> left;
// case5 : 형제 w가 RED
if(w -> color == RBTREE_RED){
w -> color = RBTREE_BLACK;
x -> parent -> color = RBTREE_RED;
rotate_right(t, x -> parent);
w = x -> parent -> left;
}
// case6 : 형제 w는 BLACK, w의 두 지식노드가 BLACK
if(w -> right -> color == RBTREE_BLACK && w -> left -> color == RBTREE_BLACK) {
w -> color = RBTREE_RED;
x = x -> parent;
} else { // case7: 형제 w는 BLACK, w의 left 자식은 RED, right 자식은 BLACK
if (w -> left -> color == RBTREE_BLACK) {
w -> right -> color = RBTREE_BLACK;
w -> color = RBTREE_RED;
rotate_left(t, w);
w = x -> parent -> left;
}
// case8 : 형제 w는 BLACK, w의 right 자식은 RED
w -> color = x -> parent -> color;
x -> parent -> color = RBTREE_BLACK;
w -> left -> color = RBTREE_BLACK;
rotate_right(t, x -> parent);
x = t -> root;
}
}
}
x -> color = RBTREE_BLACK;
}
Delete의 경우에는 순환 과정이 더 복잡한데, Case2번의 경우 계속해서 문제를 야기 시키는 것도 있지만 자기 다시 Case2로 돌아가는 문제점도 생긴다.
특이한 점은 Case1 -> Case2의 경우 부모 노드가 Red라고 확정 지을 수 있어서 바로 끝낼 수 있다.
사실 혼자서 진행하다가 문제가 생겼었다.... Rotate 함수에 문제가 있었는데 문제를 식별하지 못해서 많은 시간을 썼었고, 구현에 실패해서책에있는 Sudo Code를 통해서 다시 구현하게 되었는데.... 진짜 멘탈 갈릴뻔했다.(사실 멘탈 터짐)
조금 더 사람들이랑 붙어서 많은 시간을 보내야 겠다고 생각했다.
혼자서 소화 가능한 양이라고 생각했던게 크게 잘못 되었던게 아닌가 싶다.
⚠️ 나의 RB Tree가 메모리 누수가 생기고 있었던 이유!!! (2023.09.07 추가 작성)
Tree와 그 노드들을 free 해줄 때 Tree에 있는 NIL 노드를 free 해주지 않아서 생겼었다.
void delete_rbtree(rbtree *t) {
node_t *root = get_root_node(t);
node_t *nil = get_nil_node(t);
if(root != t -> nil){
delete_all(t, root, nil);
}
free(t -> nil); ===> 요 부분이 메모리 누수의 원인
free(t);
}
트리 생성시 밑의 코드처럼 메모리 할당을 받아서 사용했는데, 이를 헤제해주지 않아서 생겼던 누수였다.
node_t *nil = (node_t *) malloc(sizeof(node_t));
nil -> color = RBTREE_BLACK;
tree -> nil = tree -> root = nil;
또한 free 해주고 NULL을 넣어주는게 Dangling Pointer 방지를 위한 안전빵 이라는데(?)
void delete_all(rbtree *tree, node_t* node, node_t *nil){
if(node -> left != nil){
delete_all(tree, node -> left, nil);
}
if(node -> right != nil){
delete_all(tree, node -> right, nil);
}
free(node);
node = NULL; ===> 새로 추가한 부분
}
'언어 > C 언어' 카테고리의 다른 글
[ C언어 ] Pint OS - Alarm Test 분석 (0) | 2023.10.01 |
---|---|
[ C언어 ] Pint OS - priority-sema.c 테스트 (0) | 2023.09.30 |
[ C언어 ] Pint OS - Alarm Clock 구현 (1) | 2023.09.25 |
[ C언어 ] Malloc-lab 구현하기 (0) | 2023.09.22 |
[ C 언어 ] 포인터(Pointer) (1) | 2023.09.06 |