From 1300dc9a2a856fbaf4bb6c0bad047d2546344631 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 00:48:58 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20S=C3=A4uberung=20des=20Graphen=20gegen?= =?UTF-8?q?=20quadratische=20Laufzeit=20absichern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Fassung im Plan suchte je Kante den Grad über alle Kanten und begann nach jedem Fund von vorn. Am echten Skelett mit rund 12.000 Rohästen wäre das nicht langsam, sondern praktisch stehengeblieben. Jetzt wird der Grad einmal gezählt und fortgeschrieben, ein Durchgang räumt alle Fransen auf einmal weg, und ein Test mit über 1.500 Kanten nagelt es fest. Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-09-16-strassen.md | 82 ++++++++++++++++--- 1 file changed, 69 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-09-16-strassen.md b/docs/superpowers/plans/2026-09-16-strassen.md index 27d12d9..f261e9b 100644 --- a/docs/superpowers/plans/2026-09-16-strassen.md +++ b/docs/superpowers/plans/2026-09-16-strassen.md @@ -532,6 +532,29 @@ def test_breite_ignoriert_die_kreuzungsenden(): assert out.edges[0].klass == "wohnstrasse" +def test_saeubern_bleibt_bei_vielen_fransen_schnell(): + """Wache gegen quadratische Laufzeit. + + Das echte Skelett hat rund 12.000 Rohäste, die allermeisten davon Fransen. Eine Fassung, die + je Kante den Grad über alle Kanten sucht und nach jedem Fund von vorn beginnt, rechnet daran + Stunden — hier fällt sie sofort auf. + """ + import time + + nodes = [Node(punkt(x, 0)) for x in range(0, 2001, 2)] + edges = [Edge(i, i + 1, gerade(nodes[i].point, nodes[i + 1].point)) + for i in range(len(nodes) - 1)] + for i in range(1, len(nodes) - 1, 2): # an jeden zweiten Knoten eine Franse + nodes.append(Node(punkt(nodes[i].point[0], 3))) + edges.append(Edge(i, len(nodes) - 1, gerade(nodes[i].point, nodes[-1].point))) + graph = Graph(nodes=nodes, edges=edges) + assert len(graph.edges) > 1500 + start = time.perf_counter() + out = clean(graph) + assert time.perf_counter() - start < 5.0 + assert len(out.edges) == 1, "nach den Fransen bleibt eine durchgehende Kante" + + def test_klasse_ist_die_naechstliegende_regelbreite(): distance = np.full((200, 200), 8.0) # 16 m graph = Graph(nodes=[Node(punkt(SLICE["x0"] + 20, SLICE["y1"] - 100)), @@ -553,6 +576,8 @@ Erwartet: `ImportError: cannot import name 'clean'` An `tools/extract/leonida/roads.py` anhängen: ```python +from collections import Counter + from shapely.geometry import LineString from .coords import SLICE @@ -595,25 +620,55 @@ def _length(edge: Edge) -> float: return LineString(edge.points).length +def _degrees(graph: Graph) -> Counter: + grade = Counter() + for edge in graph.edges: + grade[edge.a] += 1 + grade[edge.b] += 1 + return grade + + def _drop_spurs(graph: Graph) -> None: - changed = True - while changed: - changed = False - for k, edge in enumerate(graph.edges): - frei = graph.degree(edge.a) == 1 or graph.degree(edge.b) == 1 - if frei and _length(edge) < SPUR_MIN: - del graph.edges[k] - changed = True - break + """Fransen weg, in Durchgängen statt Fund für Fund. + + Der Grad wird einmal gezählt und beim Entfernen fortgeschrieben, statt ihn je Kante neu über + alle Kanten zu suchen, und ein Durchgang räumt alle Fransen auf einmal weg, statt nach jedem + Fund von vorn zu beginnen. Beides zusammen macht aus einer quadratischen eine lineare + Rechnung je Durchgang — das echte Skelett hat rund 12.000 Rohäste, und die naive Fassung + rechnete daran Stunden. Die Zahl der Durchgänge ist die Tiefe der Fransenketten, also klein. + """ + grade = _degrees(graph) + while True: + bleibt, entfernt = [], 0 + for edge in graph.edges: + if (grade[edge.a] == 1 or grade[edge.b] == 1) and _length(edge) < SPUR_MIN: + grade[edge.a] -= 1 + grade[edge.b] -= 1 + entfernt += 1 + else: + bleibt.append(edge) + graph.edges = bleibt + if not entfernt: + return + + +def _incident_index(graph: Graph) -> dict[int, list[int]]: + index: dict[int, list[int]] = {} + for k, edge in enumerate(graph.edges): + index.setdefault(edge.a, []).append(k) + if edge.b != edge.a: + index.setdefault(edge.b, []).append(k) + return index def _merge_chains(graph: Graph) -> None: + """Knoten mit genau zwei Kanten auflösen. Der Index entsteht je Verschmelzung einmal neu — + das ist vertretbar, weil es nach dem Fransenschnitt nur noch wenige solcher Knoten gibt.""" changed = True while changed: changed = False - for node in range(len(graph.nodes)): - incident = graph.incident(node) - if len(incident) != 2 or graph.degree(node) != 2: + for node, incident in _incident_index(graph).items(): + if len(incident) != 2: continue first, second = (graph.edges[i] for i in incident) points = _join(first, second, graph.nodes[node].point) @@ -640,10 +695,11 @@ def _contract_short(graph: Graph) -> None: changed = True while changed: changed = False + grade = _degrees(graph) for k, edge in enumerate(graph.edges): if edge.a == edge.b or _length(edge) >= CONTRACT_MAX: continue - if graph.degree(edge.a) < 3 or graph.degree(edge.b) < 3: + if grade[edge.a] < 3 or grade[edge.b] < 3: continue ax, ay = graph.nodes[edge.a].point bx, by = graph.nodes[edge.b].point