Алгоритм пошуку мінімального набору поштових індексів для (майже) повного покриття країни
Ця публікація показує, як обрати мінімальний набір поштових індексів (PLZ) для майже повного покриття країни, використовуючи Python та деякі поширені бібліотеки. Приклад використовує Німеччину, але підхід можна адаптувати для інших країн із подібними системами поштових індексів.
Модель, яку ми тут використовуємо, полягає в тому, що навколо кожного обраного поштового індексу (= центру) малюється коло постійного радіуса, а метою є покрити якомога більшу частину площі країни якомога меншою кількістю кіл. Це класична задача оптимізації, яку часто називають «задачей покриття множини» (set cover problem), і вона є NP-складною. Тому ми використовуємо жадібний алгоритм апроксимації з певними вдосконаленнями, щоб знайти хороший розв’язок за розумний час.
Цей алгоритм можна легко адаптувати для інших країн, а також для інших наборів точок інтересу (POI).
Це переважно стохастичний алгоритм, тому він може не дати абсолютно оптимального результату, але в більшості випадків він має бути дуже близьким до оптимального. Крім того, він виконується за кілька секунд навіть на стандартному ноутбуці (з параметрами, як у прикладі нижче).
Оскільки цей код зчитує таблицю PLZ як файл parquet, дивіться нашу попередню публікацію Postleitzahlen und Koordinaten von GeoNames parsen
із кодом і запустіть його командою python parse_de.py -p postleitzahlen.parquet, щоб створити потрібний файл.
Результати
З використанням прикладу нижче ми в підсумку обираємо 429 центрів (поштових індексів), щоб покрити 99.5% Німеччини з радіусом 35 км навколо кожного обраного поштового індексу.

Повний вихідний код
__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
# Вбудовані завантажувачі PLZ та країн (автономний модуль)
def load_plz_data(plz_file_path: str) -> pd.DataFrame:
"""Завантажити табличні дані поштових індексів із шляху до файлу (parquet або 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)
# Спробувати поширені читачі як запасний варіант
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'Не вдалося прочитати дані PLZ з {plz_file_path}: {exc}') from exc
def create_plz_points(plz_df: pd.DataFrame) -> gpd.GeoDataFrame:
"""Створити GeoDataFrame точок PLZ із DataFrame.
Очікує довготу/широту у стовпцях з іменами одного з ('lon','lng','longitude') та ('lat','latitude').
Також гарантує наявність стовпця 'PLZ', копіюючи поширені стовпці поштових індексів або використовуючи індекс як запасний варіант.
"""
df = plz_df.copy()
# Толерантний пошук стовпців для координат
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")
# Зберегти стандартні імена стовпців для подальшого коду
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)
# Гарантувати наявність стовпця PLZ (поштовий індекс). Спочатку спробувати поширені імена, інакше використати індекс як рядок
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:
# Якщо є числовий стовпець з іменем на зразок 'zip' або 'postcode', обрати його
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:
# запасний варіант: створити PLZ з індексу, щоб мати стабільний ідентифікатор
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:
"""Прочитати країни Natural Earth 10m і повернути геометрію Німеччини (shapely)"""
# Використати шлях shapereader cartopy та geopandas для завантаження 10m admin_0_countries shapefile
shp_path = shpreader.natural_earth(resolution='10m', category='cultural', name='admin_0_countries')
countries = gpd.read_file(shp_path)
# спробувати поширені імена стовпців ISO для пошуку Німеччини
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:
# запасний варіант: спробувати пошук за NAME або 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):
"""Відфільтрувати точки PLZ, що знаходяться в межах Німеччини"""
plz_gdf = plz_gdf.to_crs("EPSG:3857") # Web Mercator для обчислень відстаней
germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:3857")[0]
# Створити буфер навколо Німеччини, щоб включити PLZ поблизу кордону
buffered_germany = germany_geometry.buffer(50000) # буфер 50 км
# Відфільтрувати PLZ у межах буферизованої Німеччини
plz_in_germany = plz_gdf[plz_gdf.intersects(buffered_germany)]
return plz_in_germany
def calculate_coverage(selected_plz, germany_geometry, radius_km=30):
"""Обчислити, який відсоток Німеччини покрито обраними PLZ"""
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:
"""Згенерувати регулярну сітку точок (x, y) у межах заданого полігону (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 = []
# Простий цикл підходить для ~20k-60k точок
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:
"""
Єдиний алгоритм для вибору точок PLZ, що максимізують покриту площу (апроксимація).
Використовує регулярну сітку точок усередині Німеччини (EPSG:3857) як проксі площі.
Жадібний рандомізований: на кожному кроці вибирає підмножину кандидатів, що залишилися, і обирає
той, що покриває найбільше ще непокритих точок сітки в межах радіуса.
Вдосконалення:
- Періодичні точні перевірки покриття (unary_union), щоб уникнути передчасної зупинки.
- Радіус виключення для придушення кластерного вибору: після обрання центру
відкидає всі PLZ, що залишилися, у межах (exclusion_factor * radius_km).
Повертає: GeoDataFrame (EPSG:3857) обраних точок PLZ.
"""
rng = np.random.default_rng(random_state)
# Забезпечити проєктовану CRS для відстаней/площі та підготувати геометрію
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) Побудувати точки сітки всередині Німеччини як проксі покриття
if verbose:
print(f"Будую сітку {grid_step_km:.1f} км над Німеччиною для апроксимації покриття...")
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_xy.shape[0]:,}")
# 2) KDTree на сітці для швидких запитів за радіусом
tree_grid = KDTree(grid_xy, leaf_size=40, metric='euclidean')
# 3) Попередньо обчислити індекси сусідніх точок сітки для кожного кандидата PLZ
plz_xy = np.column_stack([work_gdf.geometry.x.values, work_gdf.geometry.y.values])
if verbose:
print("Попередньо обчислюю околи покриття кандидат-сітка...")
neighbor_lists = tree_grid.query_radius(plz_xy, r=radius_m, return_distance=False)
# 3b) KDTree на кандидатах PLZ для фільтрації виключення
tree_plz = KDTree(plz_xy, leaf_size=40, metric='euclidean')
# 4) Жадібний рандомізований цикл вибору за маскою непокритих точок сітки
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) # кандидати, що ще доступні
no_improve_steps = 0
pbar = tqdm(total=max_steps, disable=not verbose, desc="Обираю центри (макс-покриття)")
for step in range(max_steps):
pbar.update(1)
# Періодична точна перевірка покриття
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_cov*100:.1f}%")
if exact_cov >= coverage_target:
break
# Апроксимована частка покриття на сітці
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
# Вибрати кандидатів з активної множини
k = int(min(sample_size, remaining_idx.size))
sample = rng.choice(remaining_idx, size=k, replace=False) if k > 0 else []
# Оцінити граничний приріст на сітці
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
# Прийняти найкращого кандидата
selected_mask[best_idx] = True
# Позначити точки сітки як покриті
uncovered[neighbor_lists[best_idx]] = False
# Виключити сусідні PLZ, щоб зменшити кластеризацію
if exclusion_m > 0:
near = tree_plz.query_radius(plz_xy[[best_idx]], r=exclusion_m, return_distance=False)[0]
active_mask[near] = False
# Також видалити обраний індекс
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):
"""Візуалізувати покриття за допомогою Cartopy"""
# Перетворити у WGS84 для візуалізації
selected_plz = selected_plz.to_crs("EPSG:4326")
germany_geometry = gpd.GeoSeries([germany_geometry], crs="EPSG:4326").to_crs("EPSG:4326")[0]
# Створити фігуру з проєкцією Cartopy
fig = plt.figure(figsize=(12, 12))
ax = fig.add_subplot(1, 1, 1, projection=ccrs.EqualEarth())
# Додати елементи карти
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)
# Створити ShapelyFeature для Німеччини
germany_feature = ShapelyFeature([germany_geometry], ccrs.PlateCarree(), facecolor='none', edgecolor='red', linewidth=1)
ax.add_feature(germany_feature)
# Створити та побудувати кола (тепер усі 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")
# Малювати кожен буфер окремо, щоб перекриття рендерилися як шаруваті прозорості
for geom in buffers_4326:
ax.add_geometries([geom], crs=ccrs.PlateCarree(), facecolor='blue', edgecolor='none', alpha=0.2, zorder=2)
# Побудувати обрані точки PLZ поверх
ax.scatter(selected_plz.geometry.x, selected_plz.geometry.y, color='red', s=5, transform=ccrs.PlateCarree(), label='Обрані PLZ', zorder=3)
# Встановити екстент для Німеччини з певним відступом
ax.set_extent([4, 16, 47, 56], crs=ccrs.PlateCarree())
# Додати лінії сітки
gl = ax.gridlines(draw_labels=True, linestyle='--')
gl.top_labels = False
gl.right_labels = False
plt.title(f"Покриття PLZ Німеччини з {len(selected_plz)} центрами (радіус {radius_km} км)")
plt.legend(loc='upper right')
plt.show()
def run_analysis(plz_file_path, radius_km=30, coverage_target=0.99):
"""Головна функція для аналізу з використанням єдиного алгоритму макс-покриття"""
# Завантажити геометрію Німеччини з Natural Earth
print("Завантажую геометрію Німеччини з Natural Earth...")
germany_geometry = load_germany_from_natural_earth()
# Завантажити та обробити дані PLZ
print("Завантажую дані PLZ...")
plz_df = load_plz_data(plz_file_path)
print(f"Завантажено {len(plz_df)} німецьких поштових індексів")
plz_gdf = create_plz_points(plz_df)
print("Фільтрую PLZ у межах Німеччини...")
plz_in_germany = filter_plz_in_germany(plz_gdf, germany_geometry)
print(f"Знайдено {len(plz_in_germany)} PLZ у межах або поблизу Німеччини")
# Обрати центри PLZ за єдиним алгоритмом
print("Обираю центри PLZ алгоритмом макс-покриття...")
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
)
# Обчислити покриття (точне, єдине об'єднання)
coverage_percent = calculate_coverage(selected_plz, germany_geometry, radius_km)
print(f"\nОбрано {len(selected_plz)} центрів PLZ, що покривають {coverage_percent:.1f}% Німеччини")
# Візуалізувати покриття
visualize_coverage(selected_plz, germany_geometry=germany_geometry, radius_km=radius_km)
# Повернути результати захисно: включити лише наявні стовпці
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']
# Забезпечити наявність geometry
if 'geometry' not in available:
available.append('geometry')
return {
'selected_plz': sel[available],
'coverage_percent': coverage_percent,
'total_plz_used': len(selected_plz)
}Цей код підходить для Jupyter Notebook.
Приклад використання
results = run_analysis(
plz_file_path="postleitzahlen.parquet",
radius_km=35,
coverage_target=0.995
)Як знайти найближчі результуючі коди PLZ до заданого коду
Це необов’язкове доповнення бере еталонний код PLZ певного місця і генерує DataFrame із 50 найближчих обраних PLZ. Передбачається, що ви використали Jupyter Notebook для запуску попереднього коду.

# Комірка: знайти 50 обраних PLZ, найближчих до заданого PLZ (приклад: 63110)
plz_code = '10176'
# Забезпечити наявність обраних результатів; якщо ні, запустити аналіз один раз (легкий захист)
if 'results' not in globals():
print('`results` не знайдено в просторі імен ноутбука — запускаю `run_analysis` (це може зайняти час)')
results = run_analysis(plz_file_path="postleitzahlen.parquet", radius_km=35, coverage_target=0.995)
selected = results['selected_plz']
# Завантажити повну таблицю PLZ, щоб знайти координати еталонного PLZ
plz_df = load_plz_data("postleitzahlen.parquet")
plz_gdf = create_plz_points(plz_df)
# Спробувати точний збіг за PLZ, інакше збіг за префіксом
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
# Перепроєктувати обидва у проєктовану CRS для метричних відстаней (EPSG:3857)
selected_3857 = selected.to_crs("EPSG:3857").copy()
ref_3857 = gpd.GeoSeries([ref_point], crs="EPSG:4326").to_crs("EPSG:3857")[0]
# Обчислити відстані (метри)
selected_3857['distance_m'] = selected_3857.geometry.distance(ref_3857)
# Якщо сам еталонний PLZ є в обраній множині, виключити його, щоб отримати найближчі інші центри
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
# Взяти 50 найближчих
nearest = selected_for_sort.nsmallest(50, 'distance_m').copy()
# Перетворити назад у географічні координати та підготувати зручний DataFrame
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)
# Обрати стовпці для показу: надавати перевагу людяним полям, якщо вони є
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]
# якщо жоден з бажаних стовпців імені не існує, показати всі стовпці, крім geometry
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"Найближчі {len(nearest_50_df)} обраних PLZ до {plz_code} (перші {min(50, len(nearest_50_df))}):")
nearest_50_df.head(50) # відобразити DataFrame