dtree-encoding 1.6.0__tar.gz → 1.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dtree_encoding-1.6.0 → dtree_encoding-1.7.0}/PKG-INFO +17 -7
- {dtree_encoding-1.6.0 → dtree_encoding-1.7.0}/README.md +16 -6
- {dtree_encoding-1.6.0 → dtree_encoding-1.7.0}/dtree/__init__.py +1 -1
- dtree_encoding-1.7.0/dtree/bench.py +580 -0
- {dtree_encoding-1.6.0 → dtree_encoding-1.7.0}/dtree/dtree.py +67 -1
- {dtree_encoding-1.6.0 → dtree_encoding-1.7.0}/dtree_encoding.egg-info/PKG-INFO +17 -7
- {dtree_encoding-1.6.0 → dtree_encoding-1.7.0}/dtree_encoding.egg-info/SOURCES.txt +1 -0
- {dtree_encoding-1.6.0 → dtree_encoding-1.7.0}/pyproject.toml +1 -1
- {dtree_encoding-1.6.0 → dtree_encoding-1.7.0}/tests/test_dtree.py +52 -0
- {dtree_encoding-1.6.0 → dtree_encoding-1.7.0}/dtree_encoding.egg-info/dependency_links.txt +0 -0
- {dtree_encoding-1.6.0 → dtree_encoding-1.7.0}/dtree_encoding.egg-info/top_level.txt +0 -0
- {dtree_encoding-1.6.0 → dtree_encoding-1.7.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dtree-encoding
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0
|
|
4
4
|
Summary: DTree: incremental encoding of the topology of directed trees by two projections (Q1, Q2): O(1) reachability, and/or queries on the topology of the tree, dynamic (incremental) updates
|
|
5
5
|
Author-email: Enis Olgac <enis@digraphs.info>
|
|
6
6
|
License: MIT
|
|
@@ -75,9 +75,12 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
|
|
|
75
75
|
| `PathToRoot`, `RootToPath` | `path_to_root`, `root_to_path` (plus `path(u, v)`) |
|
|
76
76
|
| `GetNode`, `TryGetNode`, `DNode` | `get_node`, `try_get_node`, `DNode` (node handles; `insert` returns one) |
|
|
77
77
|
| `SubtreeLast` | `subtree_last` (last node of a subtree in Q1) |
|
|
78
|
+
| `NextInQ1` | `next_in_q1` (next node in Q1, the display order; `None` at the end) |
|
|
78
79
|
| `VMin`, `VMax`, `UMin`, `UMax` (of nodes) | `vmin`, `vmax`, `umin`, `umax` |
|
|
79
80
|
| `ReachesAll`, `MayReachSome`, `ReachesSome` | `reaches_all`, `may_reach_some`, `reaches_some` |
|
|
80
81
|
| `ReachedByAll`, `MayBeReachedBySome`, `ReachedBySome` | `reached_by_all`, `may_be_reached_by_some`, `reached_by_some` |
|
|
82
|
+
| `Add`, `Discard`, `Contains` (of a constructed node) | `add`, `discard`, `node in s` |
|
|
83
|
+
| `UMin.Covering` | `covering` (topmost member that reaches a node; `None` if none) |
|
|
81
84
|
|
|
82
85
|
## Semantics
|
|
83
86
|
|
|
@@ -88,7 +91,7 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
|
|
|
88
91
|
- `delete(v)` re-attaches v's children to v's parent, in v's place among its siblings.
|
|
89
92
|
- `delete_subtree(v)` removes v with all its descendants and returns them in Q1 order.
|
|
90
93
|
- `cut_n_paste(u, p)` moves the subtree rooted at u under p as p's last child; p must not lie inside u's subtree.
|
|
91
|
-
- And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
|
|
94
|
+
- And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). Members are added with `add` and removed with `discard`. A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
|
|
92
95
|
- Projection positions are fractions: a new node gets the mediant (a+c)/(b+d) of its neighbors, so normally no other node moves. Only rare alternating insertion patterns trigger a local renumbering. Tree depth and width are unbounded. `t.Q1.index(v)` returns a position as a `Fraction`.
|
|
93
96
|
|
|
94
97
|
## Tests
|
|
@@ -101,14 +104,21 @@ Every operation is checked against a brute-force parent/children model on random
|
|
|
101
104
|
|
|
102
105
|
## Benchmarks
|
|
103
106
|
|
|
107
|
+
The benchmarks come with the package, so you can measure on your own machine:
|
|
108
|
+
|
|
104
109
|
```
|
|
105
|
-
python
|
|
106
|
-
python
|
|
107
|
-
python
|
|
108
|
-
python
|
|
110
|
+
python -m dtree.bench # trees of 10,000 and 100,000 nodes
|
|
111
|
+
python -m dtree.bench --quick # a few seconds
|
|
112
|
+
python -m dtree.bench --dynamic # only the dynamic updates and the tree view
|
|
113
|
+
python -m dtree.bench --note "idle machine" # remark for the report
|
|
114
|
+
python -m dtree.bench 1000000 # custom sizes
|
|
109
115
|
```
|
|
110
116
|
|
|
111
|
-
|
|
117
|
+
In a clone of the repository, `python python/benchmarks/bench.py` runs the same code from the sources.
|
|
118
|
+
|
|
119
|
+
The script prints Markdown tables: build time and renumbering counts per tree shape (random, chain, star at the root, star at an inner node, alternating worst case), list growth at either end, and timings for `can_reach`, `depth`, `delete`, `cut_n_paste` and `delete_subtree`, and comparisons with a classical parent walk: reachability by tree depth, and/or queries with constructed nodes, dynamic updates (insertions with 1 or 10 queries each, and static pre/post numbers at depths 100 and 1,000), and a tree view with expand/collapse. Each dynamic measurement is the best of three runs. Each `DList` keeps the counters `renumber_count` and `renumber_moved`, so the same figures are available in your own code via `t.Q1` and `t.Q2`.
|
|
120
|
+
|
|
121
|
+
Every run also writes its tables as a Markdown report to `benchmarks/python/`—in the current directory, or at the repository root when run inside a clone— named after date and time (for example `2026-10-17_14-18.md`), with a header giving the DTree and Python versions, the operating system and the processor; `--no-save` skips it. The C# benchmarks (`dotnet run --project csharp/DTree.Tests -c Release -- bench`) write theirs to `benchmarks/csharp/`.
|
|
112
122
|
|
|
113
123
|
## Provenance
|
|
114
124
|
|
|
@@ -52,9 +52,12 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
|
|
|
52
52
|
| `PathToRoot`, `RootToPath` | `path_to_root`, `root_to_path` (plus `path(u, v)`) |
|
|
53
53
|
| `GetNode`, `TryGetNode`, `DNode` | `get_node`, `try_get_node`, `DNode` (node handles; `insert` returns one) |
|
|
54
54
|
| `SubtreeLast` | `subtree_last` (last node of a subtree in Q1) |
|
|
55
|
+
| `NextInQ1` | `next_in_q1` (next node in Q1, the display order; `None` at the end) |
|
|
55
56
|
| `VMin`, `VMax`, `UMin`, `UMax` (of nodes) | `vmin`, `vmax`, `umin`, `umax` |
|
|
56
57
|
| `ReachesAll`, `MayReachSome`, `ReachesSome` | `reaches_all`, `may_reach_some`, `reaches_some` |
|
|
57
58
|
| `ReachedByAll`, `MayBeReachedBySome`, `ReachedBySome` | `reached_by_all`, `may_be_reached_by_some`, `reached_by_some` |
|
|
59
|
+
| `Add`, `Discard`, `Contains` (of a constructed node) | `add`, `discard`, `node in s` |
|
|
60
|
+
| `UMin.Covering` | `covering` (topmost member that reaches a node; `None` if none) |
|
|
58
61
|
|
|
59
62
|
## Semantics
|
|
60
63
|
|
|
@@ -65,7 +68,7 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
|
|
|
65
68
|
- `delete(v)` re-attaches v's children to v's parent, in v's place among its siblings.
|
|
66
69
|
- `delete_subtree(v)` removes v with all its descendants and returns them in Q1 order.
|
|
67
70
|
- `cut_n_paste(u, p)` moves the subtree rooted at u under p as p's last child; p must not lie inside u's subtree.
|
|
68
|
-
- And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
|
|
71
|
+
- And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). Members are added with `add` and removed with `discard`. A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
|
|
69
72
|
- Projection positions are fractions: a new node gets the mediant (a+c)/(b+d) of its neighbors, so normally no other node moves. Only rare alternating insertion patterns trigger a local renumbering. Tree depth and width are unbounded. `t.Q1.index(v)` returns a position as a `Fraction`.
|
|
70
73
|
|
|
71
74
|
## Tests
|
|
@@ -78,14 +81,21 @@ Every operation is checked against a brute-force parent/children model on random
|
|
|
78
81
|
|
|
79
82
|
## Benchmarks
|
|
80
83
|
|
|
84
|
+
The benchmarks come with the package, so you can measure on your own machine:
|
|
85
|
+
|
|
81
86
|
```
|
|
82
|
-
python
|
|
83
|
-
python
|
|
84
|
-
python
|
|
85
|
-
python
|
|
87
|
+
python -m dtree.bench # trees of 10,000 and 100,000 nodes
|
|
88
|
+
python -m dtree.bench --quick # a few seconds
|
|
89
|
+
python -m dtree.bench --dynamic # only the dynamic updates and the tree view
|
|
90
|
+
python -m dtree.bench --note "idle machine" # remark for the report
|
|
91
|
+
python -m dtree.bench 1000000 # custom sizes
|
|
86
92
|
```
|
|
87
93
|
|
|
88
|
-
|
|
94
|
+
In a clone of the repository, `python python/benchmarks/bench.py` runs the same code from the sources.
|
|
95
|
+
|
|
96
|
+
The script prints Markdown tables: build time and renumbering counts per tree shape (random, chain, star at the root, star at an inner node, alternating worst case), list growth at either end, and timings for `can_reach`, `depth`, `delete`, `cut_n_paste` and `delete_subtree`, and comparisons with a classical parent walk: reachability by tree depth, and/or queries with constructed nodes, dynamic updates (insertions with 1 or 10 queries each, and static pre/post numbers at depths 100 and 1,000), and a tree view with expand/collapse. Each dynamic measurement is the best of three runs. Each `DList` keeps the counters `renumber_count` and `renumber_moved`, so the same figures are available in your own code via `t.Q1` and `t.Q2`.
|
|
97
|
+
|
|
98
|
+
Every run also writes its tables as a Markdown report to `benchmarks/python/`—in the current directory, or at the repository root when run inside a clone— named after date and time (for example `2026-10-17_14-18.md`), with a header giving the DTree and Python versions, the operating system and the processor; `--no-save` skips it. The C# benchmarks (`dotnet run --project csharp/DTree.Tests -c Release -- bench`) write theirs to `benchmarks/csharp/`.
|
|
89
99
|
|
|
90
100
|
## Provenance
|
|
91
101
|
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""Performance benchmarks for DTree.
|
|
2
|
+
|
|
3
|
+
Usage, after ``pip install dtree-encoding`` (or from the repository's python/ folder,
|
|
4
|
+
where ``python benchmarks/bench.py`` does the same):
|
|
5
|
+
|
|
6
|
+
python -m dtree.bench # sizes 10,000 and 100,000
|
|
7
|
+
python -m dtree.bench 1000 1000000 # custom sizes
|
|
8
|
+
python -m dtree.bench --quick # small sizes, a few seconds
|
|
9
|
+
python -m dtree.bench --dynamic # only the dynamic updates and the tree view
|
|
10
|
+
python -m dtree.bench --note "idle machine after restart" # remark for the report
|
|
11
|
+
python -m dtree.bench --no-save # do not write a report
|
|
12
|
+
|
|
13
|
+
Every run also writes its tables as a Markdown report, named after date and time
|
|
14
|
+
(e.g. 2026-10-17_14-18.md), with a header giving the DTree and Python versions, the
|
|
15
|
+
operating system and the processor: to benchmarks/python/ at the repository root when
|
|
16
|
+
run from a clone, otherwise to benchmarks/python/ in the current directory.
|
|
17
|
+
|
|
18
|
+
For each tree shape it reports the build time and how often positions in Q1
|
|
19
|
+
and Q2 had to be renumbered, then times the queries and structural changes.
|
|
20
|
+
Finally it compares reachability with the parent walk of classical doubly linked
|
|
21
|
+
trees, for single pairs and for and/or queries (VMin, VMax, UMin, UMax), over increasing depth,
|
|
22
|
+
and under dynamic updates, with insertions and queries alternating (including static
|
|
23
|
+
pre/post numbers recomputed after each insertion), and in a use case: a tree view whose
|
|
24
|
+
nodes are inserted, collapsed and expanded while rows are shown.
|
|
25
|
+
Timings depend on the machine; the renumbering counts do not.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import datetime
|
|
30
|
+
import io
|
|
31
|
+
import os
|
|
32
|
+
import platform
|
|
33
|
+
import random
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
|
|
37
|
+
import dtree
|
|
38
|
+
from dtree import SINK, SOURCE, DList, DTree
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _base_dir():
|
|
42
|
+
"""The repository root when run from a clone, otherwise the current directory."""
|
|
43
|
+
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
44
|
+
if os.path.isdir(os.path.join(root, "csharp")) and os.path.isdir(os.path.join(root, "python")):
|
|
45
|
+
return root
|
|
46
|
+
return os.getcwd()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
REPO = _base_dir()
|
|
50
|
+
REPORTS = os.path.join(REPO, "benchmarks", "python")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _Tee(io.TextIOBase):
|
|
54
|
+
"""Writes to the console and keeps a copy for the report."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, console):
|
|
57
|
+
self.console, self.copy = console, io.StringIO()
|
|
58
|
+
|
|
59
|
+
def write(self, text):
|
|
60
|
+
self.console.write(text)
|
|
61
|
+
self.console.flush()
|
|
62
|
+
self.copy.write(text)
|
|
63
|
+
return len(text)
|
|
64
|
+
|
|
65
|
+
def flush(self):
|
|
66
|
+
self.console.flush()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _processor():
|
|
70
|
+
name = platform.processor() or platform.machine()
|
|
71
|
+
try: # Linux: the model name is more telling than the architecture
|
|
72
|
+
with open("/proc/cpuinfo", encoding="utf-8") as fh:
|
|
73
|
+
for line in fh:
|
|
74
|
+
if line.startswith("model name"):
|
|
75
|
+
return line.split(":", 1)[1].strip()
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
return name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write_report(body, started, args_text, note):
|
|
82
|
+
os.makedirs(REPORTS, exist_ok=True)
|
|
83
|
+
path = os.path.join(REPORTS, started.strftime("%Y-%m-%d_%H-%M") + ".md")
|
|
84
|
+
head = [
|
|
85
|
+
f"# DTree benchmarks, Python—{started.strftime('%Y-%m-%d %H:%M')}",
|
|
86
|
+
"",
|
|
87
|
+
f"- DTree {dtree.__version__}, Python {sys.version.split()[0]} ({platform.python_implementation()})",
|
|
88
|
+
f"- {platform.system()} {platform.release()} ({platform.platform()})",
|
|
89
|
+
f"- Processor: {_processor()}, {os.cpu_count()} logical cores",
|
|
90
|
+
f"- Command: `python -m dtree.bench{(' ' + args_text) if args_text else ''}`",
|
|
91
|
+
]
|
|
92
|
+
if note:
|
|
93
|
+
head.append(f"- Note: {note}")
|
|
94
|
+
lines = body.splitlines()
|
|
95
|
+
if lines and lines[0].startswith("DTree benchmarks"):
|
|
96
|
+
lines = lines[1:] # the console title is replaced by the header above
|
|
97
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
98
|
+
fh.write("\n".join(head) + "\n" + "\n".join(lines).rstrip() + "\n")
|
|
99
|
+
return path
|
|
100
|
+
|
|
101
|
+
SHAPES = {
|
|
102
|
+
# name: function(i, names, rnd) -> parent of the new node n{i}
|
|
103
|
+
"random": lambda i, names, rnd: rnd.choice(names),
|
|
104
|
+
"chain": lambda i, names, rnd: names[-1],
|
|
105
|
+
"star (root)": lambda i, names, rnd: "n0",
|
|
106
|
+
"star (inner node)": lambda i, names, rnd: "n1",
|
|
107
|
+
# worst case for rational positions: always the newer half-gap, alternating sides
|
|
108
|
+
"alternating": lambda i, names, rnd: f"n{2 * ((i - 1) // 2)}",
|
|
109
|
+
# a chain of 3,000, then new leaves under the spine nodes from the top down, repeatedly:
|
|
110
|
+
# finding the end of a subtree by walking down the last children would be O(depth) here
|
|
111
|
+
"top-down comb": lambda i, names, rnd: names[-1] if i < 3000 else f"n{(i - 3000) % 3000}",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build(shape, n, seed=1):
|
|
116
|
+
rnd = random.Random(seed)
|
|
117
|
+
t, names = DTree("B"), ["n0"]
|
|
118
|
+
pick = SHAPES[shape]
|
|
119
|
+
start = time.perf_counter()
|
|
120
|
+
for i in range(1, n):
|
|
121
|
+
if shape == "star (inner node)" and i <= 2:
|
|
122
|
+
parent = "n0" # n1 and n2 under the root, so n1 has a right neighbor
|
|
123
|
+
else:
|
|
124
|
+
parent = pick(i, names, rnd)
|
|
125
|
+
t.insert(parent, f"n{i}")
|
|
126
|
+
names.append(f"n{i}")
|
|
127
|
+
return t, names, time.perf_counter() - start
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def stats(q):
|
|
131
|
+
return q.renumber_count, q.renumber_moved
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def row(*cells):
|
|
135
|
+
print("| " + " | ".join(str(c) for c in cells) + " |")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def bench_build(sizes):
|
|
139
|
+
print("\n### Building trees\n")
|
|
140
|
+
row("shape", "nodes", "time [s]", "renumberings Q1 / Q2", "positions moved per insertion")
|
|
141
|
+
row("---", "---:", "---:", "---:", "---:")
|
|
142
|
+
for shape in SHAPES:
|
|
143
|
+
for n in sizes:
|
|
144
|
+
t, _, dt = build(shape, n)
|
|
145
|
+
c1, m1 = stats(t.Q1)
|
|
146
|
+
c2, m2 = stats(t.Q2)
|
|
147
|
+
row(shape, f"{n:,}", f"{dt:.2f}", f"{c1:,} / {c2:,}", f"{(m1 + m2) / (2 * n):.2f}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def bench_lists(sizes):
|
|
151
|
+
print("\n### Growing a single list (DList)\n")
|
|
152
|
+
row("pattern", "nodes", "time [s]", "renumberings")
|
|
153
|
+
row("---", "---:", "---:", "---:")
|
|
154
|
+
for pattern in ("append", "prepend", "alternate"):
|
|
155
|
+
for n in sizes:
|
|
156
|
+
q = DList("Q")
|
|
157
|
+
start = time.perf_counter()
|
|
158
|
+
for i in range(n):
|
|
159
|
+
right = pattern == "append" or (pattern == "alternate" and i % 2)
|
|
160
|
+
if right:
|
|
161
|
+
q.insert_before(f"v{i}", SINK)
|
|
162
|
+
else:
|
|
163
|
+
q.insert_after(f"v{i}", SOURCE)
|
|
164
|
+
row(pattern, f"{n:,}", f"{time.perf_counter() - start:.2f}", f"{q.renumber_count:,}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def bench_operations(n, queries=1_000_000):
|
|
168
|
+
print(f"\n### Queries and changes on a random tree of {n:,} nodes\n")
|
|
169
|
+
row("operation", "count", "time [s]", "per operation [µs]")
|
|
170
|
+
row("---", "---:", "---:", "---:")
|
|
171
|
+
rnd = random.Random(2)
|
|
172
|
+
|
|
173
|
+
def timed(name, count, fn):
|
|
174
|
+
start = time.perf_counter()
|
|
175
|
+
fn()
|
|
176
|
+
dt = time.perf_counter() - start
|
|
177
|
+
row(name, f"{count:,}", f"{dt:.2f}", f"{dt / count * 1e6:.1f}")
|
|
178
|
+
|
|
179
|
+
t, names, _ = build("random", n)
|
|
180
|
+
pairs = [(rnd.choice(names), rnd.choice(names)) for _ in range(queries)]
|
|
181
|
+
timed("can_reach (names)", queries, lambda: sum(t.can_reach(u, v) for u, v in pairs))
|
|
182
|
+
handles = [(t.get_node(u), t.get_node(v)) for u, v in pairs]
|
|
183
|
+
timed("can_reach (handles)", queries, lambda: sum(t.can_reach(u, v) for u, v in handles))
|
|
184
|
+
timed("depth", len(names), lambda: sum(t.depth(v) for v in names))
|
|
185
|
+
|
|
186
|
+
k = max(1, n // 10)
|
|
187
|
+
t, names, _ = build("random", n)
|
|
188
|
+
victims = rnd.sample(names[1:], k)
|
|
189
|
+
timed("delete", k, lambda: [t.delete(v) for v in victims])
|
|
190
|
+
|
|
191
|
+
t, names, _ = build("random", n)
|
|
192
|
+
moves = []
|
|
193
|
+
while len(moves) < max(1, n // 100):
|
|
194
|
+
u, p = rnd.choice(names[1:]), rnd.choice(names)
|
|
195
|
+
moves.append((u, p))
|
|
196
|
+
|
|
197
|
+
def do_moves():
|
|
198
|
+
for u, p in moves:
|
|
199
|
+
if t.contains(u) and t.contains(p) and u != p and not t.can_reach(u, p):
|
|
200
|
+
t.cut_n_paste(u, p)
|
|
201
|
+
|
|
202
|
+
timed("cut_n_paste", len(moves), do_moves)
|
|
203
|
+
|
|
204
|
+
t, names, _ = build("random", n)
|
|
205
|
+
leaves = [v for v in names if t.is_leaf(v)][: max(1, n // 10)]
|
|
206
|
+
parents = list({t.get_parent(v) for v in leaves if t.get_parent(v)})
|
|
207
|
+
timed("delete_subtree", len(parents), lambda: [t.delete_subtree(v) for v in parents if t.contains(v) and not t.is_root(v)])
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def build_with_depth(n, depth, seed=1):
|
|
211
|
+
"""A spine (chain) of `depth` nodes; all other nodes hang at random under spine nodes."""
|
|
212
|
+
rnd = random.Random(seed)
|
|
213
|
+
t, names = DTree("D"), ["n0"]
|
|
214
|
+
for i in range(1, n):
|
|
215
|
+
if i < depth:
|
|
216
|
+
parent = names[-1] # extend the spine
|
|
217
|
+
else:
|
|
218
|
+
parent = names[rnd.randrange(min(depth, len(names)))]
|
|
219
|
+
t.insert(parent, f"n{i}")
|
|
220
|
+
names.append(f"n{i}")
|
|
221
|
+
return t, names
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def parent_walk(parent, u, v):
|
|
225
|
+
"""What classical doubly linked trees do: climb from v towards the root, looking for u."""
|
|
226
|
+
w = parent.get(v)
|
|
227
|
+
while w is not None:
|
|
228
|
+
if w == u:
|
|
229
|
+
return True
|
|
230
|
+
w = parent.get(w)
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def ancestors_in(parent, v, members):
|
|
235
|
+
"""Climb once from v towards the root and count the ancestors of v that lie in members."""
|
|
236
|
+
hits, w = 0, parent.get(v)
|
|
237
|
+
while w is not None:
|
|
238
|
+
if w in members:
|
|
239
|
+
hits += 1
|
|
240
|
+
w = parent.get(w)
|
|
241
|
+
return hits
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def bench_depth(n, depths, queries):
|
|
245
|
+
print(f"\n### Reachability by depth: DTree vs parent walk ({n:,} nodes, {queries:,} random pairs)\n")
|
|
246
|
+
row("depth", "DTree [µs]", "parent walk [µs]", "factor")
|
|
247
|
+
row("---:", "---:", "---:", "---:")
|
|
248
|
+
for depth in depths:
|
|
249
|
+
t, names = build_with_depth(n, depth)
|
|
250
|
+
parent = t._parent
|
|
251
|
+
rnd = random.Random(2)
|
|
252
|
+
pairs = [(rnd.choice(names), rnd.choice(names)) for _ in range(queries)]
|
|
253
|
+
start = time.perf_counter()
|
|
254
|
+
a = [t.can_reach(u, v) for u, v in pairs]
|
|
255
|
+
d = time.perf_counter() - start
|
|
256
|
+
start = time.perf_counter()
|
|
257
|
+
b = [parent_walk(parent, u, v) for u, v in pairs]
|
|
258
|
+
w = time.perf_counter() - start
|
|
259
|
+
assert a == b
|
|
260
|
+
row(f"{depth:,}", f"{d / queries * 1e6:.2f}", f"{w / queries * 1e6:.2f}", factor(w, d))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def bench_sets(n, depths, k, queries):
|
|
264
|
+
print(f"\n### And/or queries: constructed nodes vs parent walk ({n:,} nodes, set of {k:,} nodes, {queries:,} query nodes)\n")
|
|
265
|
+
row("depth", "question", "DTree [µs]", "parent walk [µs]", "factor")
|
|
266
|
+
row("---:", "---", "---:", "---:", "---:")
|
|
267
|
+
for depth in depths:
|
|
268
|
+
t, names = build_with_depth(n, depth)
|
|
269
|
+
parent = t._parent
|
|
270
|
+
rnd = random.Random(4)
|
|
271
|
+
S = rnd.sample(names, k)
|
|
272
|
+
U = [rnd.choice(names) for _ in range(queries)]
|
|
273
|
+
start = time.perf_counter()
|
|
274
|
+
vmin, vmax, umin, umax = t.vmin(S), t.vmax(S), t.umin(S), t.umax(S)
|
|
275
|
+
build = time.perf_counter() - start
|
|
276
|
+
members = set(S)
|
|
277
|
+
# "reaches": climb from every member looking for u. "reached by": climb once from u,
|
|
278
|
+
# looking each ancestor up in a hash set of the members (the best a parent walk can do).
|
|
279
|
+
for name, v_q, walk_q in (
|
|
280
|
+
("reaches all", vmin.reaches_all, lambda u: all(parent_walk(parent, u, v) for v in S)),
|
|
281
|
+
("reaches some", vmax.reaches_some, lambda u: any(parent_walk(parent, u, v) for v in S)),
|
|
282
|
+
("reached by all", umax.reached_by_all, lambda u: ancestors_in(parent, u, members) == len(members)),
|
|
283
|
+
("reached by some", umin.reached_by_some, lambda u: ancestors_in(parent, u, members) > 0),
|
|
284
|
+
):
|
|
285
|
+
start = time.perf_counter()
|
|
286
|
+
a = [v_q(u) for u in U]
|
|
287
|
+
d = time.perf_counter() - start
|
|
288
|
+
start = time.perf_counter()
|
|
289
|
+
b = [walk_q(u) for u in U]
|
|
290
|
+
w = time.perf_counter() - start
|
|
291
|
+
assert a == b, name
|
|
292
|
+
row(f"{depth:,}", name, f"{d / queries * 1e6:.2f}", f"{w / queries * 1e6:.1f}", factor(w, d))
|
|
293
|
+
print(f"\n(Building VMin, VMax, UMin and UMax for {k:,} nodes: {build * 1e3:.1f} ms, once per set.)")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def factor(slow, fast):
|
|
297
|
+
return f"{slow / fast:.1f}×" if slow < 10 * fast else f"{slow / fast:,.0f}×"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def dynamic_workload(n, depth, queries_per_insert, seed=5):
|
|
301
|
+
"""The same sequence for every contender: insertions (a spine of `depth` nodes, the
|
|
302
|
+
others attached at random to spine nodes) with random queries among the nodes present."""
|
|
303
|
+
rnd = random.Random(seed)
|
|
304
|
+
steps = []
|
|
305
|
+
for i in range(1, n):
|
|
306
|
+
parent = i - 1 if i < depth else rnd.randrange(min(depth, i))
|
|
307
|
+
qs = [(rnd.randrange(i + 1), rnd.randrange(i + 1)) for _ in range(queries_per_insert)]
|
|
308
|
+
steps.append((parent, i, qs))
|
|
309
|
+
return steps
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def run_dtree(steps):
|
|
313
|
+
"""DTree with node handles (insert returns the new node's handle)."""
|
|
314
|
+
t, answers = DTree("D"), []
|
|
315
|
+
nodes = [None] * (len(steps) + 1)
|
|
316
|
+
for p, c, qs in steps:
|
|
317
|
+
if c == 1:
|
|
318
|
+
nodes[1] = t.insert("n0", "n1")
|
|
319
|
+
nodes[0] = t.get_node("n0")
|
|
320
|
+
else:
|
|
321
|
+
nodes[c] = t.insert(nodes[p], f"n{c}")
|
|
322
|
+
for u, v in qs:
|
|
323
|
+
answers.append(t.can_reach(nodes[u], nodes[v]))
|
|
324
|
+
return answers
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def run_parent_walk(steps):
|
|
328
|
+
parent, answers = {}, []
|
|
329
|
+
for p, c, qs in steps:
|
|
330
|
+
parent[c] = p
|
|
331
|
+
for u, v in qs:
|
|
332
|
+
answers.append(parent_walk(parent, u, v))
|
|
333
|
+
return answers
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def run_static_numbers(steps):
|
|
337
|
+
"""Pre- and post-order numbers from a traversal, recomputed before a query whenever the
|
|
338
|
+
tree has changed: O(1) queries, but every insertion invalidates all numbers."""
|
|
339
|
+
children, answers = {}, []
|
|
340
|
+
pre = post = None
|
|
341
|
+
for p, c, qs in steps:
|
|
342
|
+
children.setdefault(p, []).append(c)
|
|
343
|
+
pre = None # the insertion invalidates the numbers
|
|
344
|
+
for u, v in qs:
|
|
345
|
+
if pre is None:
|
|
346
|
+
pre, post, clock = {}, {}, 0
|
|
347
|
+
stack = [(0, False)]
|
|
348
|
+
while stack:
|
|
349
|
+
x, done = stack.pop()
|
|
350
|
+
if done:
|
|
351
|
+
post[x] = clock
|
|
352
|
+
clock += 1
|
|
353
|
+
continue
|
|
354
|
+
pre[x] = clock
|
|
355
|
+
clock += 1
|
|
356
|
+
stack.append((x, True))
|
|
357
|
+
stack.extend((k, False) for k in reversed(children.get(x, ())))
|
|
358
|
+
answers.append(pre[u] < pre[v] and post[v] < post[u])
|
|
359
|
+
return answers
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def timed(run, steps):
|
|
363
|
+
start = time.perf_counter()
|
|
364
|
+
answers = run(steps)
|
|
365
|
+
return time.perf_counter() - start, answers
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def best(run, steps):
|
|
369
|
+
"""Best of three runs, or a single run if it takes over two seconds (noise is small then)."""
|
|
370
|
+
sec, answers = timed(run, steps)
|
|
371
|
+
if sec < 2:
|
|
372
|
+
for _ in range(2):
|
|
373
|
+
sec = min(sec, timed(run, steps)[0])
|
|
374
|
+
return sec, answers
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def bench_dynamic(n, depths, static_sizes, static_depths=(100, 1_000)):
|
|
378
|
+
timed(run_dtree, dynamic_workload(1_000, 10, 1)) # warm-up
|
|
379
|
+
timed(run_parent_walk, dynamic_workload(1_000, 10, 1))
|
|
380
|
+
print(f"\n### Dynamic updates: insertions and queries alternating ({n:,} nodes)\n")
|
|
381
|
+
print("A step is one insertion followed by 1 or 10 queries between random nodes present at "
|
|
382
|
+
"that moment; µs per step, best of three runs.\n")
|
|
383
|
+
row("depth", "DTree, 1 query", "parent walk, 1 query", "factor",
|
|
384
|
+
"DTree, 10 queries", "parent walk, 10 queries", "factor")
|
|
385
|
+
row("---:", "---:", "---:", "---:", "---:", "---:", "---:")
|
|
386
|
+
for depth in depths:
|
|
387
|
+
cells = [f"{depth:,}"]
|
|
388
|
+
for q in (1, 10):
|
|
389
|
+
steps = dynamic_workload(n, depth, q)
|
|
390
|
+
d, a = best(run_dtree, steps)
|
|
391
|
+
w, b = best(run_parent_walk, steps)
|
|
392
|
+
assert a == b
|
|
393
|
+
cells += [f"{d / len(steps) * 1e6:.2f}", f"{w / len(steps) * 1e6:.2f}", factor(w, d)]
|
|
394
|
+
row(*cells)
|
|
395
|
+
print("\n### Dynamic updates with static pre/post numbers, recomputed after each insertion "
|
|
396
|
+
"(1 query per insertion)\n")
|
|
397
|
+
print("The tree keeps the given depth as it grows: more nodes make it wider, not deeper. "
|
|
398
|
+
"Total time in ms.\n")
|
|
399
|
+
row("depth", "nodes", "DTree [ms]", "parent walk [ms]", "static numbers [ms]")
|
|
400
|
+
row("---:", "---:", "---:", "---:", "---:")
|
|
401
|
+
for depth in static_depths:
|
|
402
|
+
for m in static_sizes:
|
|
403
|
+
steps = dynamic_workload(m, depth, 1)
|
|
404
|
+
d, a = best(run_dtree, steps)
|
|
405
|
+
w, b = best(run_parent_walk, steps)
|
|
406
|
+
r, c = timed(run_static_numbers, steps)
|
|
407
|
+
assert a == b == c
|
|
408
|
+
row(f"{depth:,}", f"{m:,}", f"{d * 1e3:,.1f}", f"{w * 1e3:,.1f}", f"{r * 1e3:,.0f}")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# -- use case: a tree view with expand/collapse --------------------------------------------
|
|
412
|
+
|
|
413
|
+
def tree_view_workload(n, depth, steps, seed=6):
|
|
414
|
+
"""Changes to a tree built by build_with_depth(n, depth): each step either inserts a leaf
|
|
415
|
+
under a random spine node or toggles (collapses or expands) a random spine node, and then
|
|
416
|
+
names a random node v whose row is to be shown."""
|
|
417
|
+
rnd = random.Random(seed)
|
|
418
|
+
ops, size = [], n
|
|
419
|
+
for _ in range(steps):
|
|
420
|
+
if rnd.random() < 0.5:
|
|
421
|
+
op = ("insert", f"n{rnd.randrange(depth)}", f"n{size}")
|
|
422
|
+
size += 1
|
|
423
|
+
else:
|
|
424
|
+
op = ("toggle", f"n{rnd.randrange(depth)}", None)
|
|
425
|
+
ops.append(op + (f"n{rnd.randrange(size)}",))
|
|
426
|
+
return ops
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def tree_view_dtree(t, ops, rows):
|
|
430
|
+
"""DTree: the collapsed nodes are one UMin. The visible row that contains v is
|
|
431
|
+
collapsed.covering(v) (v itself if None); the rows below it follow Q1, jumping over the
|
|
432
|
+
subtree of every collapsed node with subtree_last."""
|
|
433
|
+
collapsed, out = t.umin(), []
|
|
434
|
+
nxt, last = t.next_in_q1, t.subtree_last
|
|
435
|
+
for kind, a, b, v in ops:
|
|
436
|
+
if kind == "insert":
|
|
437
|
+
t.insert(a, b)
|
|
438
|
+
elif a in collapsed:
|
|
439
|
+
collapsed.discard(a)
|
|
440
|
+
else:
|
|
441
|
+
collapsed.add(a)
|
|
442
|
+
x = collapsed.covering(v) or v
|
|
443
|
+
shown = [x]
|
|
444
|
+
while len(shown) < rows:
|
|
445
|
+
if x in collapsed:
|
|
446
|
+
x = last(x)
|
|
447
|
+
x = nxt(x)
|
|
448
|
+
if x is None:
|
|
449
|
+
break
|
|
450
|
+
shown.append(x)
|
|
451
|
+
out.append(shown)
|
|
452
|
+
return out
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def tree_view_walk(tree, ops, rows):
|
|
456
|
+
"""Classical tree model: parent link, child list and next-sibling link per node, and a set of
|
|
457
|
+
collapsed nodes. The visible row that contains v is its topmost collapsed ancestor (one climb
|
|
458
|
+
to the root); the next row is the first child of an expanded node, otherwise the next sibling
|
|
459
|
+
of the node or of its nearest ancestor that has one."""
|
|
460
|
+
parent, kids, nsib = tree
|
|
461
|
+
collapsed, out = set(), []
|
|
462
|
+
for kind, a, b, v in ops:
|
|
463
|
+
if kind == "insert":
|
|
464
|
+
parent[b] = a
|
|
465
|
+
ks = kids.setdefault(a, [])
|
|
466
|
+
if ks:
|
|
467
|
+
nsib[ks[-1]] = b
|
|
468
|
+
ks.append(b)
|
|
469
|
+
elif a in collapsed:
|
|
470
|
+
collapsed.discard(a)
|
|
471
|
+
else:
|
|
472
|
+
collapsed.add(a)
|
|
473
|
+
top, w = None, parent[v]
|
|
474
|
+
while w is not None:
|
|
475
|
+
if w in collapsed:
|
|
476
|
+
top = w
|
|
477
|
+
w = parent[w]
|
|
478
|
+
x = top or v
|
|
479
|
+
shown = [x]
|
|
480
|
+
while len(shown) < rows:
|
|
481
|
+
ks = kids.get(x)
|
|
482
|
+
if ks and x not in collapsed:
|
|
483
|
+
x = ks[0]
|
|
484
|
+
else:
|
|
485
|
+
while x is not None and x not in nsib:
|
|
486
|
+
x = parent[x]
|
|
487
|
+
if x is None:
|
|
488
|
+
break
|
|
489
|
+
x = nsib[x]
|
|
490
|
+
shown.append(x)
|
|
491
|
+
out.append(shown)
|
|
492
|
+
return out
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def classical_copy(t):
|
|
496
|
+
parent, kids, nsib = {}, {}, {}
|
|
497
|
+
for v in t.q1:
|
|
498
|
+
parent[v] = t.get_parent(v)
|
|
499
|
+
ks = t.children(v)
|
|
500
|
+
if ks:
|
|
501
|
+
kids[v] = ks
|
|
502
|
+
nsib.update(zip(ks, ks[1:]))
|
|
503
|
+
return parent, kids, nsib
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def bench_tree_view(n, depths, steps, rows=20):
|
|
507
|
+
print(f"\n### Use case: tree view with expand/collapse ({n:,} nodes, {steps:,} steps)\n")
|
|
508
|
+
print("Each step changes the tree (inserts a leaf or collapses/expands a node, half and half), "
|
|
509
|
+
f"then shows a random node: it finds the visible row that contains it and lists the {rows} "
|
|
510
|
+
"visible rows from there. µs per step, best of three runs.\n")
|
|
511
|
+
row("depth", "DTree [µs per step]", "parent walk [µs per step]", "factor")
|
|
512
|
+
row("---:", "---:", "---:", "---:")
|
|
513
|
+
for depth in depths:
|
|
514
|
+
ops = tree_view_workload(n, depth, steps)
|
|
515
|
+
t, _ = build_with_depth(n, depth)
|
|
516
|
+
base = classical_copy(t)
|
|
517
|
+
d = w = float("inf")
|
|
518
|
+
for _ in range(3):
|
|
519
|
+
t, _ = build_with_depth(n, depth)
|
|
520
|
+
sec, a = timed(lambda o: tree_view_dtree(t, o, rows), ops)
|
|
521
|
+
d = min(d, sec)
|
|
522
|
+
tree = ({**base[0]}, {k: list(v) for k, v in base[1].items()}, {**base[2]})
|
|
523
|
+
sec, b = timed(lambda o: tree_view_walk(tree, o, rows), ops)
|
|
524
|
+
w = min(w, sec)
|
|
525
|
+
assert a == b
|
|
526
|
+
row(f"{depth:,}", f"{d / steps * 1e6:.1f}", f"{w / steps * 1e6:.1f}", factor(w, d))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def main():
|
|
530
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
531
|
+
ap.add_argument("sizes", nargs="*", type=int, help="tree sizes (default: 10000 100000)")
|
|
532
|
+
ap.add_argument("--quick", action="store_true", help="small sizes for a fast check")
|
|
533
|
+
ap.add_argument("--dynamic", action="store_true", help="only the dynamic updates and the tree view")
|
|
534
|
+
ap.add_argument("--note", default="", help="remark written into the report (e.g. machine state)")
|
|
535
|
+
ap.add_argument("--no-save", action="store_true", help="do not write a Markdown report")
|
|
536
|
+
args = ap.parse_args()
|
|
537
|
+
started = datetime.datetime.now()
|
|
538
|
+
tee = None
|
|
539
|
+
if not args.no_save:
|
|
540
|
+
tee = _Tee(sys.stdout)
|
|
541
|
+
sys.stdout = tee
|
|
542
|
+
try:
|
|
543
|
+
run(args)
|
|
544
|
+
finally:
|
|
545
|
+
if tee is not None:
|
|
546
|
+
sys.stdout = tee.console
|
|
547
|
+
shown = " ".join(f'"{a}"' if " " in a else a for a in sys.argv[1:])
|
|
548
|
+
path = write_report(tee.copy.getvalue(), started, shown, args.note)
|
|
549
|
+
print(f"\nReport written to {os.path.relpath(path, REPO)}")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def run(args):
|
|
553
|
+
if args.dynamic:
|
|
554
|
+
print(f"DTree benchmarks, Python {sys.version.split()[0]}")
|
|
555
|
+
if args.quick:
|
|
556
|
+
bench_dynamic(5_000, [10, 100, 1_000], [500, 1_000, 2_000])
|
|
557
|
+
bench_tree_view(5_000, [10, 100, 1_000], 1_000)
|
|
558
|
+
else:
|
|
559
|
+
bench_dynamic(20_000, [10, 100, 1_000, 10_000], [1_000, 2_000, 5_000])
|
|
560
|
+
bench_tree_view(20_000, [10, 100, 1_000, 10_000], 5_000)
|
|
561
|
+
return
|
|
562
|
+
sizes = [1_000, 10_000] if args.quick else (args.sizes or [10_000, 100_000])
|
|
563
|
+
print(f"DTree benchmarks, Python {sys.version.split()[0]}")
|
|
564
|
+
bench_build(sizes)
|
|
565
|
+
bench_lists(sizes)
|
|
566
|
+
bench_operations(max(sizes), queries=100_000 if args.quick else 1_000_000)
|
|
567
|
+
if args.quick:
|
|
568
|
+
bench_depth(10_000, [10, 100, 1_000, 10_000], 20_000)
|
|
569
|
+
bench_sets(10_000, [10, 100, 1_000], 100, 200)
|
|
570
|
+
bench_dynamic(5_000, [10, 100, 1_000], [500, 1_000, 2_000])
|
|
571
|
+
bench_tree_view(5_000, [10, 100, 1_000], 1_000)
|
|
572
|
+
else:
|
|
573
|
+
bench_depth(100_000, [10, 100, 1_000, 10_000, 100_000], 20_000)
|
|
574
|
+
bench_sets(100_000, [10, 100, 1_000, 10_000], 1_000, 200)
|
|
575
|
+
bench_dynamic(20_000, [10, 100, 1_000, 10_000], [1_000, 2_000, 5_000])
|
|
576
|
+
bench_tree_view(20_000, [10, 100, 1_000, 10_000], 5_000)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
if __name__ == "__main__":
|
|
580
|
+
main()
|
|
@@ -466,6 +466,12 @@ class DTree:
|
|
|
466
466
|
w = w.kids[0]
|
|
467
467
|
return w
|
|
468
468
|
|
|
469
|
+
def next_in_q1(self, node) -> Optional[str]:
|
|
470
|
+
"""The node after ``node`` in Q1, as a name, or None after the last node. Q1 is the
|
|
471
|
+
display order of a tree view (parents before children, siblings in insertion order)."""
|
|
472
|
+
c = self._node(node).c1.next
|
|
473
|
+
return c.node.name if c is not None and c.node is not None else None
|
|
474
|
+
|
|
469
475
|
def subtree_last(self, node):
|
|
470
476
|
"""Last node of the subtree of ``node`` in Q1, as a name (the subtree is the block from
|
|
471
477
|
``node`` to it). Usually O(1) when asked again for the same node."""
|
|
@@ -785,7 +791,8 @@ class _VNode:
|
|
|
785
791
|
the minimal (or maximal) index there; the two may be different nodes, so the constructed
|
|
786
792
|
node usually corresponds to no real node. Insertions and renumbering keep the order of
|
|
787
793
|
existing nodes, so it stays valid while the tree grows. After a deletion or a cut_n_paste it is
|
|
788
|
-
recomputed from the remaining members on its next use.
|
|
794
|
+
recomputed from the remaining members on its next use. Members are added with ``add`` and
|
|
795
|
+
removed with ``discard``.
|
|
789
796
|
"""
|
|
790
797
|
|
|
791
798
|
_lowest = True # VMin keeps the leftmost member, VMax the rightmost
|
|
@@ -829,9 +836,49 @@ class _VNode:
|
|
|
829
836
|
self._added(node)
|
|
830
837
|
return self
|
|
831
838
|
|
|
839
|
+
def discard(self, node) -> "_VNode":
|
|
840
|
+
"""Remove a node (name or handle) from the set; no error if it is not a member.
|
|
841
|
+
|
|
842
|
+
O(log k) to find it plus the list removal; if it held an index (the minimal or
|
|
843
|
+
maximal position in Q1 or Q2), the index nodes are recomputed in O(k). Returns the
|
|
844
|
+
constructed node itself. Typical use: expanding a node in a tree view, after it was
|
|
845
|
+
added to the set of collapsed nodes.
|
|
846
|
+
"""
|
|
847
|
+
node = self._t._name(node)
|
|
848
|
+
self._check()
|
|
849
|
+
if node not in self._seen:
|
|
850
|
+
return self
|
|
851
|
+
self._seen.discard(node)
|
|
852
|
+
q1, m = self._t._q1, self._members
|
|
853
|
+
lo, hi = 0, len(m) # members are sorted by Q1 order
|
|
854
|
+
while lo < hi:
|
|
855
|
+
mid = (lo + hi) // 2
|
|
856
|
+
if q1.precedes(m[mid], node):
|
|
857
|
+
lo = mid + 1
|
|
858
|
+
else:
|
|
859
|
+
hi = mid
|
|
860
|
+
del m[lo]
|
|
861
|
+
if node == self._c1 or node == self._c2:
|
|
862
|
+
t = self._t
|
|
863
|
+
self._c1 = self._c2 = None
|
|
864
|
+
for v in self._members:
|
|
865
|
+
if self._better(t._q1, v, self._c1):
|
|
866
|
+
self._c1 = v
|
|
867
|
+
if self._better(t._q2, v, self._c2):
|
|
868
|
+
self._c2 = v
|
|
869
|
+
self._discarded(node)
|
|
870
|
+
return self
|
|
871
|
+
|
|
872
|
+
def __contains__(self, node) -> bool:
|
|
873
|
+
self._check()
|
|
874
|
+
return self._t._name(node) in self._seen
|
|
875
|
+
|
|
832
876
|
def _added(self, node: str) -> None:
|
|
833
877
|
"""Hook for subclasses that keep more than the index nodes."""
|
|
834
878
|
|
|
879
|
+
def _discarded(self, node: str) -> None:
|
|
880
|
+
"""Hook for subclasses that keep more than the index nodes."""
|
|
881
|
+
|
|
835
882
|
def _rebuilt(self) -> None:
|
|
836
883
|
"""Hook for subclasses that keep more than the index nodes."""
|
|
837
884
|
|
|
@@ -989,6 +1036,10 @@ class UMin(_VNode):
|
|
|
989
1036
|
del m[i] # node is above them: they are no longer topmost
|
|
990
1037
|
m.insert(i, node)
|
|
991
1038
|
|
|
1039
|
+
def _discarded(self, node: str) -> None:
|
|
1040
|
+
if node in self._tops: # members it covered may become topmost
|
|
1041
|
+
self._rebuilt()
|
|
1042
|
+
|
|
992
1043
|
def _rebuilt(self) -> None:
|
|
993
1044
|
self._tops = []
|
|
994
1045
|
t = self._t
|
|
@@ -1011,3 +1062,18 @@ class UMin(_VNode):
|
|
|
1011
1062
|
if not self.may_be_reached_by_some(v):
|
|
1012
1063
|
return False
|
|
1013
1064
|
return self._covered(v)
|
|
1065
|
+
|
|
1066
|
+
def covering(self, v) -> Optional[str]:
|
|
1067
|
+
"""The topmost member of the set that reaches v, or None if no member does (O(log k)).
|
|
1068
|
+
|
|
1069
|
+
With the collapsed nodes of a tree view as a UMin, this is the visible row that
|
|
1070
|
+
contains v: None means v itself is visible.
|
|
1071
|
+
"""
|
|
1072
|
+
v = self._t._name(v)
|
|
1073
|
+
self._check()
|
|
1074
|
+
if not self._t.contains(v):
|
|
1075
|
+
return None
|
|
1076
|
+
i = self._last_top_before(v)
|
|
1077
|
+
if i > 0 and self._t.can_reach(self._tops[i - 1], v):
|
|
1078
|
+
return self._tops[i - 1]
|
|
1079
|
+
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dtree-encoding
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0
|
|
4
4
|
Summary: DTree: incremental encoding of the topology of directed trees by two projections (Q1, Q2): O(1) reachability, and/or queries on the topology of the tree, dynamic (incremental) updates
|
|
5
5
|
Author-email: Enis Olgac <enis@digraphs.info>
|
|
6
6
|
License: MIT
|
|
@@ -75,9 +75,12 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
|
|
|
75
75
|
| `PathToRoot`, `RootToPath` | `path_to_root`, `root_to_path` (plus `path(u, v)`) |
|
|
76
76
|
| `GetNode`, `TryGetNode`, `DNode` | `get_node`, `try_get_node`, `DNode` (node handles; `insert` returns one) |
|
|
77
77
|
| `SubtreeLast` | `subtree_last` (last node of a subtree in Q1) |
|
|
78
|
+
| `NextInQ1` | `next_in_q1` (next node in Q1, the display order; `None` at the end) |
|
|
78
79
|
| `VMin`, `VMax`, `UMin`, `UMax` (of nodes) | `vmin`, `vmax`, `umin`, `umax` |
|
|
79
80
|
| `ReachesAll`, `MayReachSome`, `ReachesSome` | `reaches_all`, `may_reach_some`, `reaches_some` |
|
|
80
81
|
| `ReachedByAll`, `MayBeReachedBySome`, `ReachedBySome` | `reached_by_all`, `may_be_reached_by_some`, `reached_by_some` |
|
|
82
|
+
| `Add`, `Discard`, `Contains` (of a constructed node) | `add`, `discard`, `node in s` |
|
|
83
|
+
| `UMin.Covering` | `covering` (topmost member that reaches a node; `None` if none) |
|
|
81
84
|
|
|
82
85
|
## Semantics
|
|
83
86
|
|
|
@@ -88,7 +91,7 @@ t = DTree.from_file("Test.csx") # e.g. examples/Test.csx from the repository
|
|
|
88
91
|
- `delete(v)` re-attaches v's children to v's parent, in v's place among its siblings.
|
|
89
92
|
- `delete_subtree(v)` removes v with all its descendants and returns them in Q1 order.
|
|
90
93
|
- `cut_n_paste(u, p)` moves the subtree rooted at u under p as p's last child; p must not lie inside u's subtree.
|
|
91
|
-
- And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
|
|
94
|
+
- And/or queries on the topology: the constructed nodes `vmin(S)`, `vmax(S)` (S as target) and `umin(S)`, `umax(S)` (S as source) take the minimum or maximum of each index over S, separately in Q1 and Q2. `reaches_all` and `reached_by_all` are exact O(1) tests; `may_reach_some` and `may_be_reached_by_some` are only filters, and `reaches_some` and `reached_by_some` are exact in O(log k). Members are added with `add` and removed with `discard`. A constructed node stays valid under insertions and is recomputed automatically after `delete`, `delete_subtree` or `cut_n_paste`. See [and/or queries on the topology](https://github.com/EnisOlgac/DTree/blob/main/docs/and-or-queries.md).
|
|
92
95
|
- Projection positions are fractions: a new node gets the mediant (a+c)/(b+d) of its neighbors, so normally no other node moves. Only rare alternating insertion patterns trigger a local renumbering. Tree depth and width are unbounded. `t.Q1.index(v)` returns a position as a `Fraction`.
|
|
93
96
|
|
|
94
97
|
## Tests
|
|
@@ -101,14 +104,21 @@ Every operation is checked against a brute-force parent/children model on random
|
|
|
101
104
|
|
|
102
105
|
## Benchmarks
|
|
103
106
|
|
|
107
|
+
The benchmarks come with the package, so you can measure on your own machine:
|
|
108
|
+
|
|
104
109
|
```
|
|
105
|
-
python
|
|
106
|
-
python
|
|
107
|
-
python
|
|
108
|
-
python
|
|
110
|
+
python -m dtree.bench # trees of 10,000 and 100,000 nodes
|
|
111
|
+
python -m dtree.bench --quick # a few seconds
|
|
112
|
+
python -m dtree.bench --dynamic # only the dynamic updates and the tree view
|
|
113
|
+
python -m dtree.bench --note "idle machine" # remark for the report
|
|
114
|
+
python -m dtree.bench 1000000 # custom sizes
|
|
109
115
|
```
|
|
110
116
|
|
|
111
|
-
|
|
117
|
+
In a clone of the repository, `python python/benchmarks/bench.py` runs the same code from the sources.
|
|
118
|
+
|
|
119
|
+
The script prints Markdown tables: build time and renumbering counts per tree shape (random, chain, star at the root, star at an inner node, alternating worst case), list growth at either end, and timings for `can_reach`, `depth`, `delete`, `cut_n_paste` and `delete_subtree`, and comparisons with a classical parent walk: reachability by tree depth, and/or queries with constructed nodes, dynamic updates (insertions with 1 or 10 queries each, and static pre/post numbers at depths 100 and 1,000), and a tree view with expand/collapse. Each dynamic measurement is the best of three runs. Each `DList` keeps the counters `renumber_count` and `renumber_moved`, so the same figures are available in your own code via `t.Q1` and `t.Q2`.
|
|
120
|
+
|
|
121
|
+
Every run also writes its tables as a Markdown report to `benchmarks/python/`—in the current directory, or at the repository root when run inside a clone— named after date and time (for example `2026-10-17_14-18.md`), with a header giving the DTree and Python versions, the operating system and the processor; `--no-save` skips it. The C# benchmarks (`dotnet run --project csharp/DTree.Tests -c Release -- bench`) write theirs to `benchmarks/csharp/`.
|
|
112
122
|
|
|
113
123
|
## Provenance
|
|
114
124
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dtree-encoding"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.7.0"
|
|
8
8
|
description = "DTree: incremental encoding of the topology of directed trees by two projections (Q1, Q2): O(1) reachability, and/or queries on the topology of the tree, dynamic (incremental) updates"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -332,6 +332,58 @@ class TestVNodes(unittest.TestCase):
|
|
|
332
332
|
self.check_vnodes(t, live, **v_s)
|
|
333
333
|
self.assertEqual(sorted(v_s["umin"].members), sorted(live))
|
|
334
334
|
|
|
335
|
+
def test_discard(self):
|
|
336
|
+
# random adds and discards, with inserts, deletes and moves in between
|
|
337
|
+
for seed in range(15):
|
|
338
|
+
t, ref, rnd = random_tree(60, seed)
|
|
339
|
+
v_s = dict(vmin=t.vmin(), vmax=t.vmax(), umin=t.umin(), umax=t.umax())
|
|
340
|
+
S, k = set(), 60
|
|
341
|
+
for step in range(120):
|
|
342
|
+
nodes = t.nodes
|
|
343
|
+
r = rnd.random()
|
|
344
|
+
if r < 0.35:
|
|
345
|
+
v = rnd.choice(nodes)
|
|
346
|
+
for x in v_s.values():
|
|
347
|
+
x.add(v if rnd.random() < 0.5 else t.get_node(v))
|
|
348
|
+
S.add(v)
|
|
349
|
+
elif r < 0.65 and S:
|
|
350
|
+
v = rnd.choice(sorted(S))
|
|
351
|
+
for x in v_s.values():
|
|
352
|
+
x.discard(v)
|
|
353
|
+
S.discard(v)
|
|
354
|
+
elif r < 0.8:
|
|
355
|
+
t.insert(rnd.choice(nodes), f"x{k}")
|
|
356
|
+
k += 1
|
|
357
|
+
elif r < 0.9 and len(nodes) > 10:
|
|
358
|
+
v = rnd.choice(nodes[1:])
|
|
359
|
+
t.delete(v)
|
|
360
|
+
S.discard(v)
|
|
361
|
+
elif len(nodes) > 10:
|
|
362
|
+
u = rnd.choice(nodes[1:])
|
|
363
|
+
banned = set(t.subtree(u))
|
|
364
|
+
t.cut_n_paste(u, rnd.choice([w for w in nodes if w not in banned]))
|
|
365
|
+
live = sorted(S)
|
|
366
|
+
self.check_vnodes(t, live, **v_s)
|
|
367
|
+
self.assertEqual(sorted(v_s["umin"].members), live)
|
|
368
|
+
for v in live[:3]:
|
|
369
|
+
self.assertIn(v, v_s["vmax"])
|
|
370
|
+
for v in t.nodes: # topmost member that reaches v
|
|
371
|
+
above = [a for a in t.path_to_root(v)[1:] if a in S]
|
|
372
|
+
self.assertEqual(v_s["umin"].covering(v), above[-1] if above else None)
|
|
373
|
+
# discarding a non-member is a no-op
|
|
374
|
+
t = DTree()
|
|
375
|
+
t.insert("a", "b")
|
|
376
|
+
u = t.umin(["b"])
|
|
377
|
+
u.discard("a").discard("nobody")
|
|
378
|
+
self.assertEqual(u.members, ["b"])
|
|
379
|
+
|
|
380
|
+
def test_next_in_q1(self):
|
|
381
|
+
t, ref, rnd = random_tree(80, 7)
|
|
382
|
+
q1 = t.q1
|
|
383
|
+
for a, b in zip(q1, q1[1:] + [None]):
|
|
384
|
+
self.assertEqual(t.next_in_q1(a), b)
|
|
385
|
+
self.assertEqual(t.next_in_q1(t.get_node(a)), b)
|
|
386
|
+
|
|
335
387
|
def test_incremental_add(self):
|
|
336
388
|
t, ref, rnd = random_tree(100, 3)
|
|
337
389
|
v_s = dict(vmin=t.vmin(), vmax=t.vmax(), umin=t.umin(), umax=t.umax())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|