Algorithmus zum Finden einer minimalen Menge von Postleitzahlen, um ein Land (fast) vollständig abzudecken
Dieser Beitrag zeigt, wie man eine minimale Menge von Postleitzahlen (PLZ) auswählt, um ein Land fast vollständig abzudecken, mit Python und einigen gängigen Bibliotheken. Das Beispiel verwendet Deutschland, aber der Ansatz kann auf andere Länder mit ähnlichen Postleitzahlensystemen angepasst werden.
Das Modell, das wir hier verwenden, ist, dass ein Kreis mit konstantem Radius um jede ausgewählte Postleitzahl (= Zentrum) gezogen wird, und das Ziel ist es, so viel wie möglich der Landesfläche mit so wenigen Kreisen wie möglich abzudecken. Dies ist ein klassisches Optimierungsproblem, oft als “Set-Cover-Problem” bezeichnet, das NP-schwer ist. Daher verwenden wir einen Greedy-Approximationsalgorithmus mit einigen Erweiterungen, um in angemessener Zeit eine gute Lösung zu finden.
Dieser Algorithmus kann leicht für andere Länder und auch für andere POI-Mengen angepasst werden.
Dies ist in erster Linie ein stochastischer Algorithmus, sodass er möglicherweise nicht das absolut optimale Ergebnis liefert, aber in den meisten Fällen sehr nahe am Optimum sein sollte. Zusätzlich läuft er in Sekunden sogar auf einem Standard-Laptop (mit den Parametern wie im folgenden Beispiel).
Da dieser Code die PLZ-Tabelle als parquet-Datei einliest, siehe unseren vorherigen Beitrag Postleitzahlen und Koordinaten von GeoNames parsen
für den Code und führe ihn mit python parse_de.py -p postleitzahlen.parquet aus, um die erforderliche Datei zu erstellen.
Ergebnisse
Mit der folgenden Beispielverwendung wählen wir am Ende 429 Zentren (Postleitzahlen) aus, um 99,5% Deutschlands mit einem Radius von 35 km um jede ausgewählte Postleitzahl abzudecken.

Vollständiger Quellcode
__author__ = "Uli Köhler"
__copyright__ = "Copyright 2025, Uli Köhler"
__license__ = "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication"
import geopandas as gpd
import pandas as pd
import numpy as np
from shapely.geometry import Point
from shapely.ops import unary_union
import matplotlib.pyplot as plt
from tqdm.auto import tqdm
import cartopy.crs as ccrs
import cartopy.feature as cfeature
from cartopy.feature import ShapelyFeature
from sklearn.neighbors import KDTree
import cartopy.io.shapereader as shpreader
# Eingebettete PLZ- und Länder-Lader (selbstständig)
def load_plz_data(plz_file_path: str) -> pd.DataFrame:
"""Lade tabellarische PLZ-Daten aus einem Dateipfad (parquet oder CSV)."""
if plz_file_path.endswith('.parquet') or plz_file_path.endswith('.parq'):
return pd.read_parquet(plz_file_path)
if plz_file_path.endswith('.csv') or plz_file_path.endswith('.txt'):
return pd.read_csv(plz_file_path)
# Versuche gängige Reader als Fallback
try:
return pd.read_parquet(plz_file_path)
except Exception:
try:
return pd.read_csv(plz_file_path)
except Exception as exc:
raise RuntimeError(f'Could not read PLZ data from {plz_file_path}: {exc}') from exc
def create_plz_points(plz_df: pd.DataFrame) -> gpd.GeoDataFrame:
"""Erstelle ein GeoDataFrame mit PLZ-Punkten aus einem DataFrame.
Erwartet Längengrad/Breitengrad in Spalten mit einem der Namen ('lon','lng','longitude') und ('lat','latitude').
Stellt außerdem sicher, dass eine 'PLZ'-Spalte existiert, indem gängige Postleitzahl-Spalten kopiert werden oder als Fallback der Index verwendet wird.
"""
df = plz_df.copy()
# Tolerante Spaltensuche für Koordinaten
lon_cols = [c for c in df.columns if c.lower() in ('lon', 'lng', 'longitude')]
lat_cols = [c for c in df.columns if c.lower() in ('lat', 'latitude')]
if not lon_cols or not lat_cols:
raise ValueError('PLZ dataframe must contain lon and lat columns (e.g. lon, lat)')
lon_col = lon_cols[0]
lat_col = lat_cols[0]
df = df.dropna(subset=[lon_col, lat_col]).copy()
geometry = [Point(xy) for xy in zip(df[lon_col].astype(float), df[lat_col].astype(float))]
gdf = gpd.GeoDataFrame(df, geometry=geometry, crs="EPSG:4326")
# Behalte konventionelle Spaltennamen für nachfolgenden Code bei
if 'lon' not in gdf.columns:
gdf['lon'] = gdf[lon_col].astype(float)
if 'lat' not in gdf.columns:
gdf['lat'] = gdf[lat_col].astype(float)
# Stelle sicher, dass eine PLZ-Spalte existiert (Postleitzahl). Versuche zuerst gängige Namen, sonst Fallback auf Index als String
plz_cols = [c for c in gdf.columns if c.lower() in ('plz', 'postal_code', 'postal', 'postleitzahl')]
if plz_cols:
gdf['PLZ'] = gdf[plz_cols[0]].astype(str)
else:
# Falls es eine numerische Spalte namens 'zip' oder 'postcode' gibt, wähle diese
zip_cols = [c for c in gdf.columns if c.lower() in ('zip', 'postcode')]
if zip_cols:
gdf['PLZ'] = gdf[zip_cols[0]].astype(str)
else:
# Fallback: Erstelle PLZ aus dem Index, um einen stabilen Bezeichner zu haben
gdf = gdf.reset_index(drop=False)
gdf['PLZ'] = gdf['index'].astype(str)
gdf.drop(columns=['index'], inplace=True)
return gdf
def load_germany_from_natural_earth() -> object:
"""Lese 10m Natural Earth Länder und gib die Deutschland-Geometrie zurück (shapely)"""
# Verwende cartopys shapereader-Pfad und geopandas, um die 10m admin_0_countries-Shapefile zu laden
shp_path = shpreader.natural_earth(resolution='10m', category='cultural', name='admin_0_countries')
countries = gpd.read_file(shp_path)
# Versuche gängige ISO-Spaltennamen, um Deutschland zu finden
iso_cols = [c for c in ('ISO_A3', 'ISO3', 'adm0_a3', 'ADM0_A3', 'iso_a3') if c in countries.columns]
if iso_cols:
germany = countries[countries[iso_cols[0]] == 'DEU']
else:
# Fallback: Versuche Übereinstimmung über NAME oder SOVEREIGNT
name_cols = [c for c in ('NAME_EN', 'NAME', 'SOVEREIGNT', 'ADMIN') if c in countries.columns]
if name_cols:
germany = countries[countries[name_cols[0]].str.contains('Germany', case=False, na=False)]
else:
raise RuntimeError('Could not determine ISO or name column in Natural Earth countries')
if germany.empty:
raise RuntimeError('Germany geometry not found in Natural Earth countries')
return germany.iloc[0].geometry
def filter_plz_in_germany(plz_gdf, germany_geometry):
"""Filtere PLZ-Punkte, die innerhalb Deutschlands liegen"""
plz_gdf = plz_gdf.to_crs("EPSG:3857") # Web Mercator für Distanzberechnungen
germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:3857")[0]
# Erstelle einen Puffer um Deutschland, um PLZ nahe der Grenze einzubeziehen
buffered_germany = germany_geometry.buffer(50000) # 50km Puffer
# Filtere PLZ innerhalb des gepufferten Deutschlands
plz_in_germany = plz_gdf[plz_gdf.intersects(buffered_germany)]
return plz_in_germany
def calculate_coverage(selected_plz, germany_geometry, radius_km=30):
"""Berechne, wie viel Prozent Deutschlands von den ausgewählten PLZ abgedeckt werden"""
radius_m = radius_km * 1000
coverage = unary_union(selected_plz.geometry.buffer(radius_m))
germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:3857")[0]
covered_area = germany_geometry.intersection(coverage).area
total_area = germany_geometry.area
return covered_area / total_area * 100
def _generate_grid_points_in_polygon(polygon_3857, step_m: float) -> np.ndarray:
"""Erstelle ein regelmäßiges Gitter von Punkten (x, y) innerhalb des gegebenen Polygons (EPSG:3857)."""
xmin, ymin, xmax, ymax = polygon_3857.bounds
xs = np.arange(xmin, xmax + step_m, step_m)
ys = np.arange(ymin, ymax + step_m, step_m)
points = []
# Einfache Schleife ist OK für ~20k-60k Punkte
for x in xs:
for y in ys:
p = Point(x, y)
if polygon_3857.contains(p):
points.append((x, y))
if not points:
return np.empty((0, 2), dtype=float)
return np.array(points, dtype=float)
def select_plz_max_coverage(
plz_gdf: gpd.GeoDataFrame,
germany_geometry,
radius_km: float = 30,
coverage_target: float = 0.99,
sample_size: int = 300,
grid_step_km: float = 5.0,
exclusion_factor: float = 0.75,
max_steps: int = 10000,
patience: int = 500,
validate_every: int = 50,
random_state: int = 42,
verbose: bool = True
) -> gpd.GeoDataFrame:
"""
Einziger Algorithmus zur Auswahl von PLZ-Punkten, die die abgedeckte Fläche maximieren (approximativ).
Verwendet ein regelmäßiges Gitter von Punkten innerhalb Deutschlands (EPSG:3857) als Flächenproxy.
Gierig-randomisiert: wähle bei jedem Schritt eine Teilmenge der verbleibenden Kandidaten und wähle
denjenigen, der die meisten aktuell nicht abgedeckten Gitterpunkte innerhalb des Radius abdeckt.
Erweiterungen:
- Periodische exakte Abdeckungsprüfungen (unary_union), um vorzeitiges Abbrechen zu vermeiden.
- Ausschlussradius, um gehäufte Auswahlen zu unterdrücken: nach Auswahl eines Zentrums,
verwerfe alle verbleibenden PLZ innerhalb (exclusion_factor * radius_km).
Gibt zurück: GeoDataFrame (EPSG:3857) der ausgewählten PLZ-Punkte.
"""
rng = np.random.default_rng(random_state)
# Stelle projiziertes CRS für Distanz/Fläche sicher und bereite Geometrie vor
work_gdf = plz_gdf.to_crs("EPSG:3857").copy()
germany_3857 = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:3857")[0]
radius_m = float(radius_km) * 1000.0
exclusion_m = float(exclusion_factor) * radius_m
grid_step_m = float(grid_step_km) * 1000.0
# 1) Erstelle Gitterpunkte innerhalb Deutschlands als Abdeckungsproxy
if verbose:
print(f"Building {grid_step_km:.1f} km grid over Germany for coverage approximation...")
grid_xy = _generate_grid_points_in_polygon(germany_3857, step_m=grid_step_m)
if grid_xy.shape[0] == 0:
raise RuntimeError("Failed to generate grid points inside Germany polygon.")
if verbose:
print(f"Grid points: {grid_xy.shape[0]:,}")
# 2) KDTree auf Gitter für schnelle Radius-Abfragen
tree_grid = KDTree(grid_xy, leaf_size=40, metric='euclidean')
# 3) Berechne Nachbar-Gitterindizes für jeden PLZ-Kandidaten vorab
plz_xy = np.column_stack([work_gdf.geometry.x.values, work_gdf.geometry.y.values])
if verbose:
print("Precomputing candidate-to-grid coverage neighborhoods...")
neighbor_lists = tree_grid.query_radius(plz_xy, r=radius_m, return_distance=False)
# 3b) KDTree auf PLZ-Kandidaten für Ausschlussfilterung
tree_plz = KDTree(plz_xy, leaf_size=40, metric='euclidean')
# 4) Gierig-randomisierte Auswahl-Schleife über die nicht-abgedeckte Gittermaske
uncovered = np.ones(grid_xy.shape[0], dtype=bool)
selected_mask = np.zeros(len(work_gdf), dtype=bool)
active_mask = np.ones(len(work_gdf), dtype=bool) # noch verfügbare Kandidaten
no_improve_steps = 0
pbar = tqdm(total=max_steps, disable=not verbose, desc="Selecting centers (max-coverage)")
for step in range(max_steps):
pbar.update(1)
# Periodische exakte Abdeckungsprüfung
if step % validate_every == 0 and selected_mask.any():
selected_tmp = work_gdf.loc[selected_mask]
exact_cov = calculate_coverage(selected_tmp, germany_geometry, radius_km) / 100.0
if step % (validate_every * 2) == 0:
pbar.set_postfix_str(f"exact~{exact_cov*100:.1f}%")
if exact_cov >= coverage_target:
break
# Approximiere Abdeckungsanteil auf dem Gitter
covered_frac = 1.0 - (uncovered.sum() / uncovered.size)
if covered_frac >= coverage_target and selected_mask.any():
selected_tmp = work_gdf.loc[selected_mask]
exact_cov = calculate_coverage(selected_tmp, germany_geometry, radius_km) / 100.0
if exact_cov >= coverage_target:
break
remaining_idx = np.flatnonzero(active_mask)
if remaining_idx.size == 0:
break
# Wähle Kandidaten aus der aktiven Menge
k = int(min(sample_size, remaining_idx.size))
sample = rng.choice(remaining_idx, size=k, replace=False) if k > 0 else []
# Werte den marginalen Gewinn auf dem Gitter aus
best_gain = 0
best_idx = None
for idx in sample:
neigh = neighbor_lists[idx]
if neigh.size == 0:
continue
gain = int(uncovered[neigh].sum())
if gain > best_gain:
best_gain = gain
best_idx = idx
if best_idx is None or best_gain == 0:
no_improve_steps += 1
if no_improve_steps >= patience:
break
else:
continue
# Akzeptiere besten Kandidaten
selected_mask[best_idx] = True
# Markiere Gitterpunkte als abgedeckt
uncovered[neighbor_lists[best_idx]] = False
# Schließe nahegelegene PLZ aus, um Häufung zu reduzieren
if exclusion_m > 0:
near = tree_plz.query_radius(plz_xy[[best_idx]], r=exclusion_m, return_distance=False)[0]
active_mask[near] = False
# Entferne auch den gewählten Index
active_mask[best_idx] = False
no_improve_steps = 0
pbar.close()
selected = work_gdf.loc[selected_mask].copy()
selected = selected.set_crs("EPSG:3857")
return selected
def visualize_coverage(selected_plz, germany_geometry, radius_km=30):
"""Visualisiere die Abdeckung mit Cartopy"""
# Konvertiere zu WGS84 für die Visualisierung
selected_plz = selected_plz.to_crs("EPSG:4326")
germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:4326")[0]
# Erstelle Abbildung mit Cartopy-Projektion
fig = plt.figure(figsize=(12, 12))
ax = fig.add_subplot(1, 1, 1, projection=ccrs.EqualEarth())
# Füge Karten-Features hinzu
ax.add_feature(cfeature.LAND, facecolor='lightgray')
ax.add_feature(cfeature.OCEAN, facecolor='lightblue')
ax.add_feature(cfeature.COASTLINE, linewidth=0.5)
ax.add_feature(cfeature.BORDERS, linestyle=':', linewidth=0.5)
# Erstelle ShapelyFeature für Deutschland
germany_feature = ShapelyFeature([germany_geometry], ccrs.PlateCarree(), facecolor='none', edgecolor='red', linewidth=1)
ax.add_feature(germany_feature)
# Erstelle und zeichne Kreise (jetzt ALLE PLZ)
radius_m = radius_km * 1000
buffers_3857 = gpd.GeoSeries(selected_plz.geometry).to_crs("EPSG:3857").buffer(radius_m)
buffers_4326 = gpd.GeoSeries(buffers_3857, crs="EPSG:3857").to_crs("EPSG:4326")
# Zeichne jeden Puffer einzeln, damit Überlappungen als geschichtete Transparenzen gerendert werden
for geom in buffers_4326:
ax.add_geometries([geom], crs=ccrs.PlateCarree(), facecolor='blue', edgecolor='none', alpha=0.2, zorder=2)
# Zeichne ausgewählte PLZ-Punkte obendrauf
ax.scatter(selected_plz.geometry.x, selected_plz.geometry.y, color='red', s=5, transform=ccrs.PlateCarree(), label='Selected PLZ', zorder=3)
# Setze Ausdehnung auf Deutschland mit etwas Rand
ax.set_extent([4, 16, 47, 56], crs=ccrs.PlateCarree())
# Füge Gitterlinien hinzu
gl = ax.gridlines(draw_labels=True, linestyle='--')
gl.top_labels = False
gl.right_labels = False
plt.title(f"PLZ Coverage of Germany with {len(selected_plz)} centers ({radius_km}km radius)")
plt.legend(loc='upper right')
plt.show()
def run_analysis(plz_file_path, radius_km=30, coverage_target=0.99):
"""Hauptfunktion für die Analyse mit einem einzigen Max-Coverage-Algorithmus"""
# Lade Deutschland-Geometrie von Natural Earth
print("Loading Germany geometry from Natural Earth...")
germany_geometry = load_germany_from_natural_earth()
# Lade und verarbeite PLZ-Daten
print("Loading PLZ data...")
plz_df = load_plz_data(plz_file_path)
print(f"Loaded {len(plz_df)} German postal codes")
plz_gdf = create_plz_points(plz_df)
print("Filtering PLZ within Germany...")
plz_in_germany = filter_plz_in_germany(plz_gdf, germany_geometry)
print(f"Found {len(plz_in_germany)} PLZ within or near Germany")
# Wähle PLZ-Zentren mit einem einzigen Algorithmus aus
print("Selecting PLZ centers with max-coverage algorithm...")
selected_plz = select_plz_max_coverage(
plz_in_germany, germany_geometry, radius_km=radius_km, coverage_target=coverage_target, sample_size=300, grid_step_km=2.0, exclusion_factor=0.75, max_steps=10000, patience=500, validate_every=50, random_state=42, verbose=True
)
# Berechne Abdeckung (exakt, einzelne Vereinigung)
coverage_percent = calculate_coverage(selected_plz, germany_geometry, radius_km)
print(f"\nSelected {len(selected_plz)} PLZ centers covering {coverage_percent:.1f}% of Germany")
# Visualisiere Abdeckung
visualize_coverage(selected_plz, germany_geometry=germany_geometry, radius_km=radius_km)
# Gib Ergebnisse defensiv zurück: nur Spalten einbeziehen, die existieren
sel = selected_plz.to_crs("EPSG:4326")
desired = ['PLZ', 'lat', 'lon', 'geometry']
available = [c for c in desired if c in sel.columns or c == 'geometry']
# Stelle sicher, dass geometry vorhanden ist
if 'geometry' not in available:
available.append('geometry')
return {
'selected_plz': sel[available],
'coverage_percent': coverage_percent,
'total_plz_used': len(selected_plz)
}Dieser Code ist für Jupyter-Notebooks geeignet.
Beispielverwendung
results = run_analysis(
plz_file_path="postleitzahlen.parquet",
radius_km=35,
coverage_target=0.995
)Wie man die nächstgelegenen resultierenden PLZ-Codes zu einem gegebenen Code findet
Diese optionale Ergänzung nimmt einen Referenz-PLZ-Code eines Ortes und generiert einen DataFrame der nächsten 50 ausgewählten PLZ. Dies setzt voraus, dass du ein Jupyter-Notebook verwendet hast, um den vorherigen Code auszuführen.

# Zelle: finde die 50 ausgewählten PLZ, die einer gegebenen PLZ am nächsten liegen (Beispiel: 63110)
plz_code = '10176'
# Stelle sicher, dass die ausgewählten Ergebnisse verfügbar sind; falls nicht, führe die Analyse einmal aus (leichtgewichtiger Schutz)
if 'results' not in globals():
print('`results` not found in the notebook namespace — running `run_analysis` (this may take time)')
results = run_analysis(plz_file_path="postleitzahlen.parquet", radius_km=35, coverage_target=0.995)
selected = results['selected_plz']
# Lade vollständige PLZ-Tabelle, um die Koordinaten der Referenz-PLZ zu finden
plz_df = load_plz_data("postleitzahlen.parquet")
plz_gdf = create_plz_points(plz_df)
# Versuche exakte Übereinstimmung mit PLZ, sonst Präfix-Übereinstimmung
ref = plz_gdf[plz_gdf['PLZ'].astype(str) == str(plz_code)]
if ref.empty:
ref = plz_gdf[plz_gdf['PLZ'].astype(str).str.startswith(str(plz_code))]
if ref.empty:
raise ValueError(f"Reference PLZ {plz_code} not found in PLZ dataset")
ref_point = ref.iloc[0].geometry
# Projiziere beide in ein projiziertes CRS für metrische Distanzen (EPSG:3857)
selected_3857 = selected.to_crs("EPSG:3857").copy()
ref_3857 = gpd.GeoSeries([ref_point], crs="EPSG:4326").to_crs("EPSG:3857")[0]
# Berechne Distanzen (Meter)
selected_3857['distance_m'] = selected_3857.geometry.distance(ref_3857)
# Falls die Referenz-PLZ selbst in der ausgewählten Menge ist, schließe sie aus, damit wir die nächsten anderen Zentren erhalten
if 'PLZ' in selected_3857.columns:
selected_for_sort = selected_3857[selected_3857['PLZ'].astype(str) != str(plz_code)]
else:
selected_for_sort = selected_3857
# Nimm die 50 nächsten
nearest = selected_for_sort.nsmallest(50, 'distance_m').copy()
# Konvertiere zurück zu geografischen Koordinaten und bereite ein nutzerfreundliches DataFrame vor
nearest_geo = nearest.to_crs("EPSG:4326")
nearest_geo['lat'] = nearest_geo.geometry.y
nearest_geo['lon'] = nearest_geo.geometry.x
nearest_geo['distance_km'] = (nearest_geo['distance_m'] / 1000.0).round(3)
# Wähle Spalten zur Anzeige: bevorzuge lesbare Felder, falls vorhanden
prefer_columns = ['PLZ', 'name', 'place', 'Ortsteil', 'stadtteil', 'place_name', 'lat', 'lon', 'distance_km']
available = [c for c in prefer_columns if c in nearest_geo.columns]
# falls keine der bevorzugten Namensspalten existiert, zeige alle Nicht-Geometry-Spalten
if not available:
available = [c for c in nearest_geo.columns if c != 'geometry']
nearest_50_df = nearest_geo[available].reset_index(drop=True)
print(f"Nearest {len(nearest_50_df)} selected PLZs to {plz_code} (top {min(50, len(nearest_50_df))}):")
nearest_50_df.head(50) # zeige das DataFrame an