From e10b075b065cb1b8d465154241448d906d6403c0 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 19 May 2026 22:11:31 +0300 Subject: [PATCH 1/2] [1] Task 1 --- shahovaa/zadanie1/.gitignore | 3 + shahovaa/zadanie1/README.md | 24 + shahovaa/zadanie1/benchmark.py | 359 +++ shahovaa/zadanie1/docs/data/performance.svg | 2431 +++++++++++++++++++ shahovaa/zadanie1/docs/data/results.csv | 91 + shahovaa/zadanie1/docs/data/summary.csv | 19 + shahovaa/zadanie1/docs/report.md | 112 + shahovaa/zadanie1/phonebook.py | 255 ++ shahovaa/zadanie1/requirements.txt | 1 + 9 files changed, 3295 insertions(+) create mode 100644 shahovaa/zadanie1/.gitignore create mode 100644 shahovaa/zadanie1/README.md create mode 100644 shahovaa/zadanie1/benchmark.py create mode 100644 shahovaa/zadanie1/docs/data/performance.svg create mode 100644 shahovaa/zadanie1/docs/data/results.csv create mode 100644 shahovaa/zadanie1/docs/data/summary.csv create mode 100644 shahovaa/zadanie1/docs/report.md create mode 100644 shahovaa/zadanie1/phonebook.py create mode 100644 shahovaa/zadanie1/requirements.txt diff --git a/shahovaa/zadanie1/.gitignore b/shahovaa/zadanie1/.gitignore new file mode 100644 index 0000000..4a5bb25 --- /dev/null +++ b/shahovaa/zadanie1/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.DS_Store diff --git a/shahovaa/zadanie1/README.md b/shahovaa/zadanie1/README.md new file mode 100644 index 0000000..3a1b5cf --- /dev/null +++ b/shahovaa/zadanie1/README.md @@ -0,0 +1,24 @@ +# Задание 1: структуры данных + +Реализация телефонного справочника на трех структурах данных без классов: + +- связный список; +- хеш-таблица с цепочками; +- двоичное дерево поиска. + +## Запуск + +Проверка базовых операций: + +```bash +python3 phonebook.py +``` + +Экспериментальные замеры и построение графика: + +```bash +python3 benchmark.py +``` + +По умолчанию используется `N = 10000`, `5` повторов, результаты сохраняются в +`docs/data/results.csv`, `docs/data/summary.csv` и `docs/data/performance.svg`. diff --git a/shahovaa/zadanie1/benchmark.py b/shahovaa/zadanie1/benchmark.py new file mode 100644 index 0000000..c1f1069 --- /dev/null +++ b/shahovaa/zadanie1/benchmark.py @@ -0,0 +1,359 @@ +"""Run performance experiments for the procedural phone book structures.""" + +import argparse +import csv +import html +import math +import random +import time +from pathlib import Path + +from phonebook import ( + bst_delete, + bst_find, + bst_insert, + create_hash_table, + ht_delete, + ht_find, + ht_insert, + ll_delete, + ll_find, + ll_insert, +) + + +STRUCTURES = ("LinkedList", "HashTable", "BST") +MODES = ("shuffled", "sorted") +OPERATIONS = ("insert", "find", "delete") + + +def generate_records(count): + return [(f"User_{index:05d}", f"+7-900-{index:05d}") for index in range(count)] + + +def prepare_records(count, seed): + records_sorted = generate_records(count) + records_shuffled = records_sorted[:] + random.Random(seed).shuffle(records_shuffled) + return { + "sorted": records_sorted, + "shuffled": records_shuffled, + } + + +def _insert_all(structure_name, records, bucket_count): + if structure_name == "LinkedList": + head = None + for name, phone in records: + head = ll_insert(head, name, phone) + return head + + if structure_name == "HashTable": + buckets = create_hash_table(bucket_count) + for name, phone in records: + ht_insert(buckets, name, phone) + return buckets + + if structure_name == "BST": + root = None + for name, phone in records: + root = bst_insert(root, name, phone) + return root + + raise ValueError(f"Unknown structure: {structure_name}") + + +def _find_all(structure_name, structure, names): + if structure_name == "LinkedList": + for name in names: + ll_find(structure, name) + return structure + + if structure_name == "HashTable": + for name in names: + ht_find(structure, name) + return structure + + if structure_name == "BST": + for name in names: + bst_find(structure, name) + return structure + + raise ValueError(f"Unknown structure: {structure_name}") + + +def _delete_all(structure_name, structure, names): + if structure_name == "LinkedList": + head = structure + for name in names: + head = ll_delete(head, name) + return head + + if structure_name == "HashTable": + for name in names: + ht_delete(structure, name) + return structure + + if structure_name == "BST": + root = structure + for name in names: + root = bst_delete(root, name) + return root + + raise ValueError(f"Unknown structure: {structure_name}") + + +def _elapsed(action): + start = time.perf_counter() + result = action() + end = time.perf_counter() + return result, end - start + + +def run_experiment(count=10000, repeats=5, seed=42, bucket_count=20011): + record_sets = prepare_records(count, seed) + all_names = [name for name, _phone in record_sets["sorted"]] + results = [] + + for structure_name in STRUCTURES: + for mode in MODES: + records = record_sets[mode] + names_for_sampling = [name for name, _phone in records] + + for repeat in range(1, repeats + 1): + rng = random.Random(seed + repeat * 1000 + len(structure_name) + len(mode)) + find_existing = rng.sample(names_for_sampling, min(100, count)) + find_missing = [f"None_{repeat}_{index}" for index in range(10)] + find_names = find_existing + find_missing + delete_names = rng.sample(all_names, min(50, count)) + + structure, insert_time = _elapsed( + lambda: _insert_all(structure_name, records, bucket_count) + ) + results.append( + { + "structure": structure_name, + "mode": mode, + "operation": "insert", + "repeat": repeat, + "time_sec": insert_time, + "n": count, + "bucket_count": bucket_count if structure_name == "HashTable" else "", + } + ) + + structure, find_time = _elapsed( + lambda: _find_all(structure_name, structure, find_names) + ) + results.append( + { + "structure": structure_name, + "mode": mode, + "operation": "find", + "repeat": repeat, + "time_sec": find_time, + "n": count, + "bucket_count": bucket_count if structure_name == "HashTable" else "", + } + ) + + structure, delete_time = _elapsed( + lambda: _delete_all(structure_name, structure, delete_names) + ) + results.append( + { + "structure": structure_name, + "mode": mode, + "operation": "delete", + "repeat": repeat, + "time_sec": delete_time, + "n": count, + "bucket_count": bucket_count if structure_name == "HashTable" else "", + } + ) + + return results + + +def summarize(results): + grouped = {} + for row in results: + key = (row["structure"], row["mode"], row["operation"]) + grouped.setdefault(key, []).append(row["time_sec"]) + + summary = [] + for structure_name in STRUCTURES: + for mode in MODES: + for operation in OPERATIONS: + values = grouped[(structure_name, mode, operation)] + summary.append( + { + "structure": structure_name, + "mode": mode, + "operation": operation, + "average_time_sec": sum(values) / len(values), + "measurements_sec": ";".join(f"{value:.9f}" for value in values), + } + ) + return summary + + +def write_csv(path, rows, fieldnames): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.DictWriter(file, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + +def write_chart(path, summary): + try: + import matplotlib.pyplot as plt + except ModuleNotFoundError: + write_svg_chart(path, summary) + return + + labels = [ + f"{row['structure']}\n{row['mode']}\n{row['operation']}" + for row in summary + ] + values = [row["average_time_sec"] for row in summary] + colors_by_operation = { + "insert": "#4C78A8", + "find": "#F58518", + "delete": "#54A24B", + } + colors = [colors_by_operation[row["operation"]] for row in summary] + + path.parent.mkdir(parents=True, exist_ok=True) + plt.figure(figsize=(14, 7)) + plt.bar(range(len(values)), values, color=colors) + plt.yscale("log") + plt.ylabel("Среднее время, секунд (логарифмическая шкала)") + plt.title("Сравнение операций телефонного справочника") + plt.xticks(range(len(labels)), labels, rotation=45, ha="right", fontsize=8) + plt.tight_layout() + plt.savefig(path, dpi=160) + plt.close() + + +def write_svg_chart(path, summary): + width = 1500 + height = 760 + margin_left = 90 + margin_right = 40 + margin_top = 70 + margin_bottom = 210 + plot_width = width - margin_left - margin_right + plot_height = height - margin_top - margin_bottom + baseline = margin_top + plot_height + + values = [max(row["average_time_sec"], 1e-12) for row in summary] + log_min = math.floor(math.log10(min(values))) + log_max = math.ceil(math.log10(max(values))) + if log_min == log_max: + log_min -= 1 + log_max += 1 + + def y_for(value): + log_value = math.log10(max(value, 1e-12)) + return margin_top + (log_max - log_value) / (log_max - log_min) * plot_height + + colors_by_operation = { + "insert": "#4C78A8", + "find": "#F58518", + "delete": "#54A24B", + } + slot_width = plot_width / len(summary) + bar_width = slot_width * 0.62 + + lines = [ + '', + f'', + '', + '', + f'Сравнение операций телефонного справочника', + f'', + f'', + ] + + for exponent in range(log_min, log_max + 1): + value = 10 ** exponent + y = y_for(value) + lines.append( + f'' + ) + lines.append( + f'1e{exponent}' + ) + + for index, row in enumerate(summary): + x = margin_left + index * slot_width + (slot_width - bar_width) / 2 + y = y_for(row["average_time_sec"]) + bar_height = baseline - y + color = colors_by_operation[row["operation"]] + label = f"{row['structure']} / {row['mode']} / {row['operation']}" + + lines.append( + f'' + ) + lines.append( + f'{row["average_time_sec"]:.3g}' + ) + lines.append( + f'{html.escape(label)}' + ) + + legend_x = margin_left + legend_y = height - 30 + for offset, (operation, color) in enumerate(colors_by_operation.items()): + x = legend_x + offset * 130 + lines.append(f'') + lines.append(f'{operation}') + + lines.append( + f'Среднее время, секунд (логарифмическая шкала)' + ) + lines.append("") + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines), encoding="utf-8") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--n", type=int, default=10000, help="number of generated records") + parser.add_argument("--repeats", type=int, default=5, help="number of repeated measurements") + parser.add_argument("--seed", type=int, default=42, help="random seed") + parser.add_argument("--bucket-count", type=int, default=20011, help="hash-table bucket count") + parser.add_argument("--output-dir", type=Path, default=Path("docs/data")) + args = parser.parse_args() + + results = run_experiment( + count=args.n, + repeats=args.repeats, + seed=args.seed, + bucket_count=args.bucket_count, + ) + summary = summarize(results) + + write_csv( + args.output_dir / "results.csv", + results, + ["structure", "mode", "operation", "repeat", "time_sec", "n", "bucket_count"], + ) + write_csv( + args.output_dir / "summary.csv", + summary, + ["structure", "mode", "operation", "average_time_sec", "measurements_sec"], + ) + chart_path = args.output_dir / "performance.svg" + write_chart(chart_path, summary) + + print(f"Saved detailed results to {args.output_dir / 'results.csv'}") + print(f"Saved summary to {args.output_dir / 'summary.csv'}") + print(f"Saved chart to {chart_path}") + + +if __name__ == "__main__": + main() diff --git a/shahovaa/zadanie1/docs/data/performance.svg b/shahovaa/zadanie1/docs/data/performance.svg new file mode 100644 index 0000000..5f3cc72 --- /dev/null +++ b/shahovaa/zadanie1/docs/data/performance.svg @@ -0,0 +1,2431 @@ + + + + + + + + 2026-05-19T21:32:18.823317 + image/svg+xml + + + Matplotlib v3.10.9, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/shahovaa/zadanie1/docs/data/results.csv b/shahovaa/zadanie1/docs/data/results.csv new file mode 100644 index 0000000..38ba829 --- /dev/null +++ b/shahovaa/zadanie1/docs/data/results.csv @@ -0,0 +1,91 @@ +structure,mode,operation,repeat,time_sec,n,bucket_count +LinkedList,shuffled,insert,1,1.5487497089998215,10000, +LinkedList,shuffled,find,1,0.013355207998756669,10000, +LinkedList,shuffled,delete,1,0.006138000000646571,10000, +LinkedList,shuffled,insert,2,1.6062446670002828,10000, +LinkedList,shuffled,find,2,0.014175791999150533,10000, +LinkedList,shuffled,delete,2,0.007367083000644925,10000, +LinkedList,shuffled,insert,3,1.5470056670001213,10000, +LinkedList,shuffled,find,3,0.014115500000116299,10000, +LinkedList,shuffled,delete,3,0.006011666999256704,10000, +LinkedList,shuffled,insert,4,1.5362317910003185,10000, +LinkedList,shuffled,find,4,0.01460650000080932,10000, +LinkedList,shuffled,delete,4,0.006377084000632749,10000, +LinkedList,shuffled,insert,5,1.541476624999632,10000, +LinkedList,shuffled,find,5,0.014646625000750646,10000, +LinkedList,shuffled,delete,5,0.005829540999911842,10000, +LinkedList,sorted,insert,1,1.4639895000000251,10000, +LinkedList,sorted,find,1,0.012882999999419553,10000, +LinkedList,sorted,delete,1,0.005734124999435153,10000, +LinkedList,sorted,insert,2,1.4757493329998397,10000, +LinkedList,sorted,find,2,0.013435208000373677,10000, +LinkedList,sorted,delete,2,0.006567624999661348,10000, +LinkedList,sorted,insert,3,1.474924916999953,10000, +LinkedList,sorted,find,3,0.012946166998517583,10000, +LinkedList,sorted,delete,3,0.005636875001073349,10000, +LinkedList,sorted,insert,4,1.6074728750008944,10000, +LinkedList,sorted,find,4,0.012849667000409681,10000, +LinkedList,sorted,delete,4,0.006610207999983686,10000, +LinkedList,sorted,insert,5,1.5465652919992863,10000, +LinkedList,sorted,find,5,0.012851292000050307,10000, +LinkedList,sorted,delete,5,0.005656833000102779,10000, +HashTable,shuffled,insert,1,0.005485583000336192,10000,20011 +HashTable,shuffled,find,1,5.770799907622859e-05,10000,20011 +HashTable,shuffled,delete,1,3.570800072338898e-05,10000,20011 +HashTable,shuffled,insert,2,0.006064958999559167,10000,20011 +HashTable,shuffled,find,2,5.854200026078615e-05,10000,20011 +HashTable,shuffled,delete,2,3.495800046948716e-05,10000,20011 +HashTable,shuffled,insert,3,0.005850707999343285,10000,20011 +HashTable,shuffled,find,3,5.441699977382086e-05,10000,20011 +HashTable,shuffled,delete,3,2.7292000595480204e-05,10000,20011 +HashTable,shuffled,insert,4,0.005818375000671949,10000,20011 +HashTable,shuffled,find,4,5.387499913922511e-05,10000,20011 +HashTable,shuffled,delete,4,2.683300044736825e-05,10000,20011 +HashTable,shuffled,insert,5,0.006451041999753215,10000,20011 +HashTable,shuffled,find,5,5.6000000768108293e-05,10000,20011 +HashTable,shuffled,delete,5,2.937499994004611e-05,10000,20011 +HashTable,sorted,insert,1,0.005557000000408152,10000,20011 +HashTable,sorted,find,1,5.608300125459209e-05,10000,20011 +HashTable,sorted,delete,1,2.8624999686144292e-05,10000,20011 +HashTable,sorted,insert,2,0.005895457999940845,10000,20011 +HashTable,sorted,find,2,6.0874999689986e-05,10000,20011 +HashTable,sorted,delete,2,3.199999991920777e-05,10000,20011 +HashTable,sorted,insert,3,0.005766083999333205,10000,20011 +HashTable,sorted,find,3,5.500000042957254e-05,10000,20011 +HashTable,sorted,delete,3,2.7874999432242475e-05,10000,20011 +HashTable,sorted,insert,4,0.005590124999798718,10000,20011 +HashTable,sorted,find,4,5.337499896995723e-05,10000,20011 +HashTable,sorted,delete,4,2.6959000024362467e-05,10000,20011 +HashTable,sorted,insert,5,0.007889499998782412,10000,20011 +HashTable,sorted,find,5,5.549999877985101e-05,10000,20011 +HashTable,sorted,delete,5,2.7749998480430804e-05,10000,20011 +BST,shuffled,insert,1,0.011201125000297907,10000, +BST,shuffled,find,1,9.245900037058163e-05,10000, +BST,shuffled,delete,1,6.958300036785658e-05,10000, +BST,shuffled,insert,2,0.011337707999700797,10000, +BST,shuffled,find,2,9.545799912302755e-05,10000, +BST,shuffled,delete,2,7.141599962778855e-05,10000, +BST,shuffled,insert,3,0.01119999999900756,10000, +BST,shuffled,find,3,9.308299922849983e-05,10000, +BST,shuffled,delete,3,6.779199975426309e-05,10000, +BST,shuffled,insert,4,0.011189917000592686,10000, +BST,shuffled,find,4,9.675000001152512e-05,10000, +BST,shuffled,delete,4,6.624999878113158e-05,10000, +BST,shuffled,insert,5,0.01118529100131127,10000, +BST,shuffled,find,5,8.670799979881849e-05,10000, +BST,shuffled,delete,5,6.904200017743278e-05,10000, +BST,sorted,insert,1,2.2425066659998265,10000, +BST,sorted,find,1,0.018234625000332016,10000, +BST,sorted,delete,1,0.010230416999547742,10000, +BST,sorted,insert,2,2.26542979199985,10000, +BST,sorted,find,2,0.021546082998611382,10000, +BST,sorted,delete,2,0.011778292000599322,10000, +BST,sorted,insert,3,2.246992708000107,10000, +BST,sorted,find,3,0.01936033300080453,10000, +BST,sorted,delete,3,0.010003166000387864,10000, +BST,sorted,insert,4,2.2515108749994397,10000, +BST,sorted,find,4,0.021122417001606664,10000, +BST,sorted,delete,4,0.01173120800012839,10000, +BST,sorted,insert,5,2.2457697090012516,10000, +BST,sorted,find,5,0.01902170900029887,10000, +BST,sorted,delete,5,0.010273834001054638,10000, diff --git a/shahovaa/zadanie1/docs/data/summary.csv b/shahovaa/zadanie1/docs/data/summary.csv new file mode 100644 index 0000000..91ac219 --- /dev/null +++ b/shahovaa/zadanie1/docs/data/summary.csv @@ -0,0 +1,19 @@ +structure,mode,operation,average_time_sec,measurements_sec +LinkedList,shuffled,insert,1.5559416918000353,1.548749709;1.606244667;1.547005667;1.536231791;1.541476625 +LinkedList,shuffled,find,0.014179924999916693,0.013355208;0.014175792;0.014115500;0.014606500;0.014646625 +LinkedList,shuffled,delete,0.006344675000218558,0.006138000;0.007367083;0.006011667;0.006377084;0.005829541 +LinkedList,sorted,insert,1.5137403833999996,1.463989500;1.475749333;1.474924917;1.607472875;1.546565292 +LinkedList,sorted,find,0.01299306679975416,0.012883000;0.013435208;0.012946167;0.012849667;0.012851292 +LinkedList,sorted,delete,0.006041133200051263,0.005734125;0.006567625;0.005636875;0.006610208;0.005656833 +HashTable,shuffled,insert,0.005934133399932762,0.005485583;0.006064959;0.005850708;0.005818375;0.006451042 +HashTable,shuffled,find,5.61083998036338e-05,0.000057708;0.000058542;0.000054417;0.000053875;0.000056000 +HashTable,shuffled,delete,3.083320043515414e-05,0.000035708;0.000034958;0.000027292;0.000026833;0.000029375 +HashTable,sorted,insert,0.006139633399652666,0.005557000;0.005895458;0.005766084;0.005590125;0.007889500 +HashTable,sorted,find,5.6166599824791776e-05,0.000056083;0.000060875;0.000055000;0.000053375;0.000055500 +HashTable,sorted,delete,2.8641799508477563e-05,0.000028625;0.000032000;0.000027875;0.000026959;0.000027750 +BST,shuffled,insert,0.011222808200182044,0.011201125;0.011337708;0.011200000;0.011189917;0.011185291 +BST,shuffled,find,9.289159970649052e-05,0.000092459;0.000095458;0.000093083;0.000096750;0.000086708 +BST,shuffled,delete,6.881659974169451e-05,0.000069583;0.000071416;0.000067792;0.000066250;0.000069042 +BST,sorted,insert,2.250441950000095,2.242506666;2.265429792;2.246992708;2.251510875;2.245769709 +BST,sorted,find,0.019857033400330692,0.018234625;0.021546083;0.019360333;0.021122417;0.019021709 +BST,sorted,delete,0.010803383400343591,0.010230417;0.011778292;0.010003166;0.011731208;0.010273834 diff --git a/shahovaa/zadanie1/docs/report.md b/shahovaa/zadanie1/docs/report.md new file mode 100644 index 0000000..d8fb3fe --- /dev/null +++ b/shahovaa/zadanie1/docs/report.md @@ -0,0 +1,112 @@ +# Отчет по заданию 1: структуры данных + +## Цель + +Реализовать три структуры данных с нуля в процедурной парадигме и сравнить +скорость основных операций телефонного справочника: + +- `insert(name, phone)` - добавить или обновить запись; +- `find(name)` - найти телефон по имени; +- `delete(name)` - удалить запись; +- `list_all()` - получить все записи, отсортированные по имени. + +Классы не использовались. Узлы связного списка и дерева представлены +словарями, хеш-таблица представлена списком бакетов. + +## Реализация + +Код находится в файле `phonebook.py`. + +Реализованы функции: + +- связный список: `ll_insert`, `ll_find`, `ll_delete`, `ll_list_all`; +- хеш-таблица: `create_hash_table`, `ht_insert`, `ht_find`, `ht_delete`, `ht_list_all`; +- двоичное дерево поиска: `bst_insert`, `bst_find`, `bst_delete`, `bst_list_all`. + +Для хеш-таблицы используется метод цепочек: каждый бакет хранит голову +связного списка. Хеш-функция написана вручную, чтобы результат не зависел от +рандомизации встроенной функции `hash()` в Python. + +Для BST вставка, поиск, удаление и обход написаны без классов. Обход +`bst_list_all` реализован итеративно, чтобы отсортированный вход на 10000 +элементов не приводил к переполнению стека рекурсии. + +## Методика эксперимента + +Скрипт эксперимента находится в файле `benchmark.py`. + +Параметры запуска: + +- количество записей: `N = 10000`; +- число повторов каждого эксперимента: `5`; +- имена: `User_00000`, `User_00001`, ..., `User_09999`; +- два режима входных данных: `shuffled` и `sorted`; +- поиск: 100 существующих имен и 10 отсутствующих; +- удаление: 50 случайных существующих имен; +- размер хеш-таблицы: `20011` бакетов. + +После вставки структура не пересоздается: поиск и удаление выполняются на той +же заполненной структуре. Для каждого режима и каждой структуры создается новая +структура. + +Файлы с результатами: + +- `docs/data/results.csv` - все отдельные замеры; +- `docs/data/summary.csv` - среднее время и список всех пяти замеров; +- `docs/data/performance.svg` - столбчатая диаграмма средних значений. + +![График производительности](data/performance.svg) + +## Средние результаты + +Время указано в секундах. + +| Структура | Режим | Вставка | Поиск | Удаление | +|---|---:|---:|---:|---:| +| LinkedList | shuffled | 1.555942 | 0.014180 | 0.006345 | +| LinkedList | sorted | 1.513740 | 0.012993 | 0.006041 | +| HashTable | shuffled | 0.005934 | 0.000056 | 0.000031 | +| HashTable | sorted | 0.006140 | 0.000056 | 0.000029 | +| BST | shuffled | 0.011223 | 0.000093 | 0.000069 | +| BST | sorted | 2.250442 | 0.019857 | 0.010803 | + +## Анализ + +Связный список оказался самым медленным на вставке и поиске. Причина в том, что +для корректной операции `insert` нужно проверить, есть ли уже запись с таким +именем. При уникальных именах почти каждая вставка проходит по всему текущему +списку, поэтому суммарная сложность вставки всех записей становится `O(n^2)`. +Порядок входных данных почти не влияет на результат, потому что структура не +использует порядок ключей. + +Хеш-таблица показала лучшие результаты почти во всех операциях. При хорошем +распределении по бакетам вставка, поиск и удаление близки к `O(1)`. Порядок +входных данных почти не влияет на время, так как индекс бакета определяется +хешем имени, а не расположением записи во входном списке. + +BST хорошо работает на перемешанных данных: дерево получается сравнительно +сбалансированным, поэтому операции близки к `O(log n)`. На отсортированном +входе обычное двоичное дерево поиска вырождается в цепочку: каждый новый ключ +становится правым потомком предыдущего. Из-за этого вставка всех записей +становится `O(n^2)`, а поиск и удаление приближаются к поведению связного +списка. + +Удаление у хеш-таблицы быстрое по той же причине, что и поиск: сначала +вычисляется бакет, затем просматривается короткая цепочка. В BST удаление +быстрое на перемешанном дереве, но на вырожденном дереве оно замедляется. +В связном списке удаление требует линейного поиска удаляемого элемента. + +## Вывод + +Для частого поиска, обновления и удаления по точному имени лучше выбирать +хеш-таблицу. Она быстрее всего в эксперименте и почти не зависит от порядка +вставки. + +Если нужно часто получать данные в отсортированном порядке, дерево поиска дает +удобный `in-order` обход без отдельной сортировки. Но обычный BST чувствителен +к порядку входных данных, поэтому на практике лучше использовать +самобалансирующееся дерево или готовую структуру из библиотеки. + +Связный список подходит только для маленьких наборов данных или учебных задач. +Для телефонного справочника с частым поиском он неудачен, потому что каждая +операция поиска требует последовательного прохода по элементам. diff --git a/shahovaa/zadanie1/phonebook.py b/shahovaa/zadanie1/phonebook.py new file mode 100644 index 0000000..1cfbe59 --- /dev/null +++ b/shahovaa/zadanie1/phonebook.py @@ -0,0 +1,255 @@ +"""Procedural phone book data structures for assignment 1. + +The task explicitly asks to avoid classes, so every structure is represented +with plain dictionaries, lists and functions. +""" + + +def _make_ll_node(name, phone, next_node=None): + return {"name": name, "phone": phone, "next": next_node} + + +def ll_insert(head, name, phone): + """Insert or update a record in a linked list, returning the head.""" + if head is None: + return _make_ll_node(name, phone) + + current = head + while current is not None: + if current["name"] == name: + current["phone"] = phone + return head + if current["next"] is None: + break + current = current["next"] + + current["next"] = _make_ll_node(name, phone) + return head + + +def ll_find(head, name): + """Return a phone by name or None if there is no such record.""" + current = head + while current is not None: + if current["name"] == name: + return current["phone"] + current = current["next"] + return None + + +def ll_delete(head, name): + """Delete a record by name, returning the possibly changed head.""" + previous = None + current = head + + while current is not None: + if current["name"] == name: + if previous is None: + return current["next"] + previous["next"] = current["next"] + return head + + previous = current + current = current["next"] + + return head + + +def ll_list_all(head): + """Return all linked-list records sorted by name.""" + records = [] + current = head + while current is not None: + records.append((current["name"], current["phone"])) + current = current["next"] + return sorted(records, key=lambda item: item[0]) + + +def create_hash_table(size=20011): + """Create a fixed-size hash table with separate chaining.""" + return [None for _ in range(size)] + + +def _hash_name(name, bucket_count): + """Stable polynomial hash, unlike Python's randomized built-in hash().""" + value = 0 + for char in name: + value = (value * 31 + ord(char)) % bucket_count + return value + + +def ht_insert(buckets, name, phone): + """Insert or update a record in the hash table.""" + index = _hash_name(name, len(buckets)) + buckets[index] = ll_insert(buckets[index], name, phone) + + +def ht_find(buckets, name): + """Return a phone by name or None if there is no such record.""" + index = _hash_name(name, len(buckets)) + return ll_find(buckets[index], name) + + +def ht_delete(buckets, name): + """Delete a record by name if it exists.""" + index = _hash_name(name, len(buckets)) + buckets[index] = ll_delete(buckets[index], name) + + +def ht_list_all(buckets): + """Return all hash-table records sorted by name.""" + records = [] + for head in buckets: + current = head + while current is not None: + records.append((current["name"], current["phone"])) + current = current["next"] + return sorted(records, key=lambda item: item[0]) + + +def _make_bst_node(name, phone): + return {"name": name, "phone": phone, "left": None, "right": None} + + +def bst_insert(root, name, phone): + """Insert or update a record in a binary search tree.""" + if root is None: + return _make_bst_node(name, phone) + + current = root + while True: + if name == current["name"]: + current["phone"] = phone + return root + + if name < current["name"]: + if current["left"] is None: + current["left"] = _make_bst_node(name, phone) + return root + current = current["left"] + else: + if current["right"] is None: + current["right"] = _make_bst_node(name, phone) + return root + current = current["right"] + + +def bst_find(root, name): + """Return a phone by name or None if there is no such record.""" + current = root + while current is not None: + if name == current["name"]: + return current["phone"] + if name < current["name"]: + current = current["left"] + else: + current = current["right"] + return None + + +def _detach_min(node): + """Detach the minimal node from a subtree and return (new_subtree, min).""" + parent = None + current = node + + while current["left"] is not None: + parent = current + current = current["left"] + + if parent is None: + return current["right"], current + + parent["left"] = current["right"] + current["right"] = None + return node, current + + +def bst_delete(root, name): + """Delete a record from the tree, returning the possibly changed root.""" + parent = None + current = root + + while current is not None and current["name"] != name: + parent = current + if name < current["name"]: + current = current["left"] + else: + current = current["right"] + + if current is None: + return root + + if current["left"] is None: + replacement = current["right"] + elif current["right"] is None: + replacement = current["left"] + else: + new_right, successor = _detach_min(current["right"]) + successor["left"] = current["left"] + successor["right"] = new_right + replacement = successor + + if parent is None: + return replacement + + if parent["left"] is current: + parent["left"] = replacement + else: + parent["right"] = replacement + + return root + + +def bst_list_all(root): + """Return all BST records sorted by name using in-order traversal.""" + records = [] + stack = [] + current = root + + while current is not None or stack: + while current is not None: + stack.append(current) + current = current["left"] + + current = stack.pop() + records.append((current["name"], current["phone"])) + current = current["right"] + + return records + + +def _assert_basic_operations(): + records = [("Boris", "222"), ("Anna", "111"), ("Denis", "444")] + expected_sorted = [("Anna", "111"), ("Boris", "222"), ("Denis", "444")] + + head = None + for name, phone in records: + head = ll_insert(head, name, phone) + assert ll_find(head, "Anna") == "111" + head = ll_insert(head, "Anna", "333") + assert ll_find(head, "Anna") == "333" + head = ll_delete(head, "Anna") + assert ll_find(head, "Anna") is None + assert ll_list_all(head) == [("Boris", "222"), ("Denis", "444")] + + table = create_hash_table(17) + for name, phone in records: + ht_insert(table, name, phone) + assert ht_find(table, "Denis") == "444" + ht_insert(table, "Denis", "555") + assert ht_find(table, "Denis") == "555" + ht_delete(table, "Missing") + assert ("Anna", "111") in ht_list_all(table) + + root = None + for name, phone in records: + root = bst_insert(root, name, phone) + assert bst_list_all(root) == expected_sorted + root = bst_delete(root, "Boris") + assert bst_find(root, "Boris") is None + assert bst_list_all(root) == [("Anna", "111"), ("Denis", "444")] + + +if __name__ == "__main__": + _assert_basic_operations() + print("All phonebook checks passed.") diff --git a/shahovaa/zadanie1/requirements.txt b/shahovaa/zadanie1/requirements.txt new file mode 100644 index 0000000..a9006fd --- /dev/null +++ b/shahovaa/zadanie1/requirements.txt @@ -0,0 +1 @@ +matplotlib>=3.8 From 3fa79f06c3539ca159fd4d9774de001b10831c99 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 19 May 2026 22:39:51 +0300 Subject: [PATCH 2/2] [2] Task 2 --- shahovaa/zadanie 2/.gitignore | 4 + shahovaa/zadanie 2/README.md | 67 ++++++ shahovaa/zadanie 2/data/mazes/empty.txt | 50 +++++ shahovaa/zadanie 2/data/mazes/large.txt | 100 +++++++++ shahovaa/zadanie 2/data/mazes/medium.txt | 50 +++++ shahovaa/zadanie 2/data/mazes/no_exit.txt | 30 +++ shahovaa/zadanie 2/data/mazes/small.txt | 10 + shahovaa/zadanie 2/main.py | 94 ++++++++ shahovaa/zadanie 2/maze_solver/__init__.py | 34 +++ shahovaa/zadanie 2/maze_solver/builders.py | 75 +++++++ shahovaa/zadanie 2/maze_solver/commands.py | 79 +++++++ shahovaa/zadanie 2/maze_solver/models.py | 81 +++++++ shahovaa/zadanie 2/maze_solver/observers.py | 40 ++++ shahovaa/zadanie 2/maze_solver/solver.py | 57 +++++ shahovaa/zadanie 2/maze_solver/strategies.py | 150 +++++++++++++ .../zadanie 2/reports/charts/empty_time.svg | 28 +++ .../reports/charts/empty_visited.svg | 28 +++ .../zadanie 2/reports/charts/large_time.svg | 28 +++ .../reports/charts/large_visited.svg | 28 +++ .../zadanie 2/reports/charts/medium_time.svg | 28 +++ .../reports/charts/medium_visited.svg | 28 +++ .../zadanie 2/reports/charts/no_exit_time.svg | 28 +++ .../reports/charts/no_exit_visited.svg | 28 +++ .../zadanie 2/reports/charts/small_time.svg | 28 +++ .../reports/charts/small_visited.svg | 28 +++ shahovaa/zadanie 2/reports/report.md | 208 ++++++++++++++++++ shahovaa/zadanie 2/reports/results.csv | 21 ++ shahovaa/zadanie 2/scripts/__init__.py | 1 + shahovaa/zadanie 2/scripts/generate_mazes.py | 126 +++++++++++ shahovaa/zadanie 2/scripts/run_experiments.py | 194 ++++++++++++++++ shahovaa/zadanie 2/tests/__init__.py | 1 + shahovaa/zadanie 2/tests/test_solver.py | 64 ++++++ 32 files changed, 1816 insertions(+) create mode 100644 shahovaa/zadanie 2/.gitignore create mode 100644 shahovaa/zadanie 2/README.md create mode 100644 shahovaa/zadanie 2/data/mazes/empty.txt create mode 100644 shahovaa/zadanie 2/data/mazes/large.txt create mode 100644 shahovaa/zadanie 2/data/mazes/medium.txt create mode 100644 shahovaa/zadanie 2/data/mazes/no_exit.txt create mode 100644 shahovaa/zadanie 2/data/mazes/small.txt create mode 100644 shahovaa/zadanie 2/main.py create mode 100644 shahovaa/zadanie 2/maze_solver/__init__.py create mode 100644 shahovaa/zadanie 2/maze_solver/builders.py create mode 100644 shahovaa/zadanie 2/maze_solver/commands.py create mode 100644 shahovaa/zadanie 2/maze_solver/models.py create mode 100644 shahovaa/zadanie 2/maze_solver/observers.py create mode 100644 shahovaa/zadanie 2/maze_solver/solver.py create mode 100644 shahovaa/zadanie 2/maze_solver/strategies.py create mode 100644 shahovaa/zadanie 2/reports/charts/empty_time.svg create mode 100644 shahovaa/zadanie 2/reports/charts/empty_visited.svg create mode 100644 shahovaa/zadanie 2/reports/charts/large_time.svg create mode 100644 shahovaa/zadanie 2/reports/charts/large_visited.svg create mode 100644 shahovaa/zadanie 2/reports/charts/medium_time.svg create mode 100644 shahovaa/zadanie 2/reports/charts/medium_visited.svg create mode 100644 shahovaa/zadanie 2/reports/charts/no_exit_time.svg create mode 100644 shahovaa/zadanie 2/reports/charts/no_exit_visited.svg create mode 100644 shahovaa/zadanie 2/reports/charts/small_time.svg create mode 100644 shahovaa/zadanie 2/reports/charts/small_visited.svg create mode 100644 shahovaa/zadanie 2/reports/report.md create mode 100644 shahovaa/zadanie 2/reports/results.csv create mode 100644 shahovaa/zadanie 2/scripts/__init__.py create mode 100644 shahovaa/zadanie 2/scripts/generate_mazes.py create mode 100644 shahovaa/zadanie 2/scripts/run_experiments.py create mode 100644 shahovaa/zadanie 2/tests/__init__.py create mode 100644 shahovaa/zadanie 2/tests/test_solver.py diff --git a/shahovaa/zadanie 2/.gitignore b/shahovaa/zadanie 2/.gitignore new file mode 100644 index 0000000..218a8cd --- /dev/null +++ b/shahovaa/zadanie 2/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/shahovaa/zadanie 2/README.md b/shahovaa/zadanie 2/README.md new file mode 100644 index 0000000..0b6d252 --- /dev/null +++ b/shahovaa/zadanie 2/README.md @@ -0,0 +1,67 @@ +# Поиск выхода из лабиринта + +Объектно-ориентированная реализация поиска пути в лабиринте с паттернами GoF: +Builder, Strategy, Observer и Command. + +## Что реализовано + +- модель `Cell` и `Maze`; +- загрузка лабиринта из текстового файла через `TextFileMazeBuilder`; +- стратегии поиска пути: BFS, DFS, A*, Дейкстра; +- `MazeSolver`, который измеряет время, число посещенных клеток и длину пути; +- консольный `Observer` для сообщений и отрисовки; +- `MoveCommand` и `Player` для ручного режима с undo; +- генератор тестовых лабиринтов; +- экспериментальный скрипт, CSV и SVG-графики; +- отчет: `reports/report.md`. + +## Формат лабиринта + +```text +# - стена + - проход +S - старт +E - выход +2, 3, ~ - проходимые клетки с увеличенным весом +``` + +Все строки в файле лабиринта должны иметь одинаковую длину. + +## Запуск + +```bash +python3 scripts/generate_mazes.py +python3 main.py --maze data/mazes/small.txt --strategy astar --render +``` + +Доступные стратегии: + +```text +bfs +dfs +astar +dijkstra +``` + +Ручной режим с командами `W/A/S/D`, undo через `Z`: + +```bash +python3 main.py --maze data/mazes/small.txt --manual +``` + +## Эксперименты + +```bash +python3 scripts/run_experiments.py +``` + +Скрипт перегенерирует лабиринты, запускает каждую стратегию 10 раз и сохраняет: + +- `reports/results.csv`; +- SVG-графики в `reports/charts/`. + +## Проверка + +```bash +python3 -m unittest +``` diff --git a/shahovaa/zadanie 2/data/mazes/empty.txt b/shahovaa/zadanie 2/data/mazes/empty.txt new file mode 100644 index 0000000..c83b6cd --- /dev/null +++ b/shahovaa/zadanie 2/data/mazes/empty.txt @@ -0,0 +1,50 @@ +################################################## +#S # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# E# +################################################## diff --git a/shahovaa/zadanie 2/data/mazes/large.txt b/shahovaa/zadanie 2/data/mazes/large.txt new file mode 100644 index 0000000..901f0a6 --- /dev/null +++ b/shahovaa/zadanie 2/data/mazes/large.txt @@ -0,0 +1,100 @@ +#################################################################################################### +#S # # # # # # # # # # # ## +### # # ### # # # ####### # # # ### ######### ### # ##### # ############### ### # # # ### ##### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # ## +# ########### # # # # ##### ### # ### ##### ################### # # # # ##### ####### # ### ##### ## +# # # # # # # # # # # # # # # # # # # # # # # ## +# ### ##### ### ### ##### ##### ### ### ##### ####### # # ### ##### ##### # ### # # # # # # # # # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +##### # ######### ### # ### # ### ### # # ####### # ### # # # # # ### # ##### # # # ### ##### # #### +# # # # # # # # # # # # # # # # # # # # # # # ## +# ##### ##### ########### # ####### ##### ######### # ### # # ##### ####### ### # # # ### # ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # ## +# # # ##### # # # # ### # # # # ### # ##### ### ### ########### # ##### ### # ### # ### # ####### ## +# # # # # # # # # # # # # # # # # # # # # # # # ## +# ####### # # ####### # # ####### ####### ### ##### # ############### ### # # # # ####### # # ###### +# # # # # # # # # # # # # # # # # # # # # # # # ## +##### # # # ##### # # ####### ########### # ### # ### # # # ####### ### # ####### # ### # # ##### ## +# # # # # # # # # # # # # # # # # # # # # # # ## +# # ### ##### # ############# # # # ### ### # ########### ####### # ### # ######### # ### # ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # ## +# ### ### ####### ### # ### # # ##### ### ##### ####### # # ### ### # ######### # # ### ### # ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # ####### ####### ### # ### ##### # # # # # ##### ##### ### ### # # # ### # ### ### ##### # # # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ######### # # # ### ##### ### # ### ### # ##### # ####### ### ##### # # # ##### # ########### # ## +# # # # # # # E# # # # # # # # # # # # # # # # ## +### ##### # # ####### ### # ### ### ##### ### # # # ### # # ####### # ### ######### # ##### ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### ##### ####### # # ##### ######### # # ### ##### # ##### ### # # ####### # # ### # ####### # ## +# # # # # # # # # # # # # # # # # # # # # # # # ## +####### # # ### ### ### # # # # ############# ##### # # # ##### ### ### ### # # ##### # # # ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ####### # # ### ### ### ########### # # # ##### # ##### ### ### # ##### ##### # # # # # # # ###### +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # ##### # ### ### ########### ############# # # # # # # # ### # ### ##### # # # ##### # # ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ##### # # # ### # # ### # # # # # ####### ### # ##### # ### # ####### # # # # # ### # # ####### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ##### # # # # # ##### ########### # ########### # # ##### ### ##### ### # # # ####### ######### ## +# # # # # # # # # # # # # # # # # # # # ## +### ##### # # ##### ##### ########### ##### ### # # ##### ### # # ######### ####### # # ######### ## +# # # # # # # # # # # # # # # # # # # # # ## +# ##### ### ######### # ### ##### ### # # # ####### ####### # ####### ### # # ####### # # ##### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # ### ### # ##### # # ### # # ### ### # # # ####### ### # # ##### # ### ### # # ### ### # # ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### # # ####### # ### # ### # ############### # ##### # # ##### # ### # # ######### ##### ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +### # ### # # # # ### ##### # ####### ### # ######### # # # # # ##### # # ######### # # ####### # ## +# # # # # # # # # # # # # # # # # # # # # # ## +# ### ####### ########### ######### ##### # ### # # ### ####### # ##### ####### # ### ### # ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ######### # # ##### # ### ##### ### # ##### # # # # ### ### ### # ### # ### ### # # # ### # # #### +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### # # # ### # ##### # ##### ######### # # # ### ### # # ### ### # ##### ########### # ### ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +### # # # ### ##### # # # ### ### # # # # ### ############# ### # # ### ########### ##### # ##### ## +# # # # # # # # # # # # # # # # # # # # # # ## +# ### ############### # ### # # ##### ##### ### ######### ############# # ####### ##### # # # ###### +# # # # # # # # # # # # # # # # # # # ## +### ############# # # # # ####### ##### # ####### ######### # ### ######### # # ##### ### # # ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # ### # # # # # ######### ####### ### ### # # ### # ### ### # ### ### # ### ##### # # ### ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### ### # # ##### # ############### ### ##### # ### ####### # # ### # # ### # # ##### # # # # # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +### # # # # # # ##### # ####### # ##### # ### ##### ### # # ##### # # ##### # ##### # # # ####### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # # # ################### ########### # # ### # ### ##### # # # # ########### ##### ### # # # # ## +# # # # # # # # # # # # # # # # # # # # ## +# ### ##### ### ######### ########### ### # ########### ### # # ### # # # # # ######### # ### ###### +# # # # # # # # # # # # # # # # # # # # # # # ## +### # # ##### ### ### # # # # ####### # ##### # ##### # # ### ### ######### ####### # # ##### # # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### # # # ### # ### # # ##### ### ##### # ##### # ### # # # # ### # # ### # ############# ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +### # ### # # # # # ####### # # # ### ####### # ######### # ### # ### # # ### ##### # # ####### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### # # ######### # ####### # ######### ### # # # ### ### # ##### ### # ##### # ##### # ### ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # ### # # ##### # ##### # # # ####### ### # ### ### ##### # # # ### # ### # ### # # ######### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### ### ### ####### ### # # ### ####### # ### ### ##### # # # ### ### # ### # ######### # ##### ## +# # # # # # # # # # # # # # # # # # # # # # # # # ## +# ####### ##### # ### # ### ####### ################# ### # # # ##### ### # # # # ### ####### ###### +# # # # # # # # # # # # # # # # # # # # # ## +##### ##### ##### ##### # ####### # ##### # ### ##### # ### # # ### ########### # # # # # # ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# # # # ####### # # # ####### # # # ##### ### ##### ##### ### ########### ### # # # ##### # # ### ## +# # # # # # # # # # # # # # # # # # # # # # # # ## +# # # # # # # ##### ##### # ### # # # ########### ######### ### ########### ### ##### # ### # ###### +# # # # # # # # # # # # # # # # # # # # # ## +### ##### ##### ##### # # # ##### ##### # ################# # ### # ### ######### # # ### # # ### ## +# # # # # # # # # # # # # # # # # # # # # # # # # ## +# ### # ##### ### ### # # # # # ### ############# ##### # ##### ##### # # ### # ### # # # # ### # ## +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # ## +# ####### # ##### # ### # ### # ##### ####### # ### # ####### ### ##### # # # # # ##### # # # ### ## +# # # # # # # # # # # # # ## +#################################################################################################### +#################################################################################################### diff --git a/shahovaa/zadanie 2/data/mazes/medium.txt b/shahovaa/zadanie 2/data/mazes/medium.txt new file mode 100644 index 0000000..33b95a7 --- /dev/null +++ b/shahovaa/zadanie 2/data/mazes/medium.txt @@ -0,0 +1,50 @@ +################################################## +#S # # E# # # ## +### # ##### # # ########### ### # # # ### ##### ## +# # # # # # # # # # # # ## +# ### ### # ### ############### ### # # ### # # ## +# # # # # # # # # # # ## +### ### ##### # ##### ### # # # # ##### # ##### ## +# # # # # # # # # # # # # # # # ## +# ### ### # # ### # ### ### # ### # # ### # ###### +# # # # # # # # # # # # # ## +# # ##### ##### # # # # ####### ### ########### ## +# # # # # # # # # # # # ## +####### # # ##### # # # # ### ### # # ### ######## +# # # # # # # # # # # # ## +# ### ##### ##### # # ##### # # ############### ## +# # # # # # # # # # # # # ## +# # ### # ### # ### ### # ### ### # # ##### # #### +# # # # # # # # # # # # # # ## +# ### ########### ### # ### ### # # ### # # ### ## +# # # # # # # # # # # # ## +# # ### # ##### # # ######### # # # # # ####### ## +# # # # # # # # # # # # # ## +# ######### # # # ### ##### # # ##### ### ##### ## +# # # # # # # # # # # # ## +# ##### # # # ### ##### # ######### ### ##### # ## +# # # # # # # # # # # # # ## +# # ##### # ### # # # ########### ### # # ### # ## +# # # # # # # # # # # # # # # ## +# ### # ### # ##### # # ### # ######### # # ###### +# # # # # # # # # # # ## +### # # # ########### ### ############### ##### ## +# # # # # # # # # # # ## +# ##### ##### # ### ### # # ### # ### # # # # # ## +# # # # # # # # # # # # # # ## +# ####### # # # # ####### ### ##### ### ##### # ## +# # # # # # # # # # # # # # ## +# ##### ### # ### # ### # # # # # ##### # # ### ## +# # # # # # # # # # # # ## +######### # ######### ####### ########### # # # ## +# # # # # # # # ## +# ####### ##### # # ####### ######### # ####### ## +# # # # # # # # # # # # ## +### # # ### # # # ##### # # # ##### ### # # ###### +# # # # # # # # # # # # # ## +# ############# # # # ##### ######### ### # # # ## +# # # # # # # # # # # # # # # ## +# ##### # # # ##### # # # ### # # # ### # # # # ## +# # # # # # # # # ## +################################################## +################################################## diff --git a/shahovaa/zadanie 2/data/mazes/no_exit.txt b/shahovaa/zadanie 2/data/mazes/no_exit.txt new file mode 100644 index 0000000..28c6043 --- /dev/null +++ b/shahovaa/zadanie 2/data/mazes/no_exit.txt @@ -0,0 +1,30 @@ +############################## +#S############################ +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################## +############################E# +############################## diff --git a/shahovaa/zadanie 2/data/mazes/small.txt b/shahovaa/zadanie 2/data/mazes/small.txt new file mode 100644 index 0000000..f14cd33 --- /dev/null +++ b/shahovaa/zadanie 2/data/mazes/small.txt @@ -0,0 +1,10 @@ +########## +#S #E# +# #### # # +# # # # +# # #### # +# # # +# ###### # +# # +######## # +########## diff --git a/shahovaa/zadanie 2/main.py b/shahovaa/zadanie 2/main.py new file mode 100644 index 0000000..62f5f6b --- /dev/null +++ b/shahovaa/zadanie 2/main.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import argparse + +from maze_solver import ( + AStarStrategy, + BFSStrategy, + ConsoleView, + DFSStrategy, + DijkstraStrategy, + Direction, + MazeSolver, + MoveCommand, + Player, + TextFileMazeBuilder, +) + + +STRATEGIES = { + "bfs": BFSStrategy, + "dfs": DFSStrategy, + "astar": AStarStrategy, + "dijkstra": DijkstraStrategy, +} + + +def main() -> None: + parser = argparse.ArgumentParser(description="Find a path through a text maze.") + parser.add_argument("--maze", default="data/mazes/small.txt") + parser.add_argument( + "--strategy", + choices=sorted(STRATEGIES), + default="astar", + help="Path-finding algorithm.", + ) + parser.add_argument("--render", action="store_true", help="Print maze with path.") + parser.add_argument( + "--manual", + action="store_true", + help="Manual W/A/S/D mode with Z undo and Q quit.", + ) + args = parser.parse_args() + + maze = TextFileMazeBuilder().build_from_file(args.maze) + strategy = STRATEGIES[args.strategy]() + solver = MazeSolver(maze, strategy) + view = ConsoleView() + solver.add_observer(view) + + stats = solver.solve() + print( + f"Summary: strategy={stats.strategy_name}, time={stats.time_ms:.3f} ms, " + f"visited={stats.visited_cells}, path_length={stats.path_length}" + ) + + if args.render: + print(view.render(maze, path=stats.path)) + + if args.manual: + run_manual_mode(maze, view) + + +def run_manual_mode(maze, view: ConsoleView) -> None: + player = Player.at_start(maze) + history: list[MoveCommand] = [] + + while True: + print(view.render(maze, player_position=player.current_cell)) + if player.current_cell == maze.exit: + print("Exit reached.") + return + + key = input("Move W/A/S/D, undo Z, quit Q: ").strip().lower() + if key == "q": + return + if key == "z": + if history: + history.pop().undo() + continue + + try: + command = MoveCommand(player, Direction.from_key(key)) + except ValueError as exc: + print(exc) + continue + + if command.execute(): + history.append(command) + else: + print("Move blocked.") + + +if __name__ == "__main__": + main() diff --git a/shahovaa/zadanie 2/maze_solver/__init__.py b/shahovaa/zadanie 2/maze_solver/__init__.py new file mode 100644 index 0000000..ce72ea5 --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/__init__.py @@ -0,0 +1,34 @@ +from .builders import MazeBuilder, TextFileMazeBuilder +from .commands import Direction, MoveCommand, Player +from .models import Cell, Maze +from .observers import ConsoleView, Event, Observer +from .solver import MazeSolver, SearchStats +from .strategies import ( + AStarStrategy, + BFSStrategy, + DFSStrategy, + DijkstraStrategy, + PathFindingStrategy, + PathResult, +) + +__all__ = [ + "AStarStrategy", + "BFSStrategy", + "Cell", + "ConsoleView", + "DFSStrategy", + "DijkstraStrategy", + "Direction", + "Event", + "Maze", + "MazeBuilder", + "MazeSolver", + "MoveCommand", + "Observer", + "PathFindingStrategy", + "PathResult", + "Player", + "SearchStats", + "TextFileMazeBuilder", +] diff --git a/shahovaa/zadanie 2/maze_solver/builders.py b/shahovaa/zadanie 2/maze_solver/builders.py new file mode 100644 index 0000000..1316ae3 --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/builders.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path + +from .models import Cell, Maze + + +class MazeBuilder(ABC): + @abstractmethod + def build_from_file(self, filename: str | Path) -> Maze: + raise NotImplementedError + + def buildFromFile(self, filename: str | Path) -> Maze: + return self.build_from_file(filename) + + +class TextFileMazeBuilder(MazeBuilder): + WALL = "#" + START = "S" + EXIT = "E" + PASSAGES = {" ", "."} + WEIGHTS = {"1": 1, "2": 2, "3": 3, "~": 3} + + def build_from_file(self, filename: str | Path) -> Maze: + path = Path(filename) + rows = path.read_text(encoding="utf-8").splitlines() + + if not rows: + raise ValueError(f"Maze file is empty: {path}") + + width = len(rows[0]) + if width == 0: + raise ValueError("Maze width must be greater than zero") + if any(len(row) != width for row in rows): + raise ValueError("All maze rows must have the same width") + + cells: list[list[Cell]] = [] + start: Cell | None = None + exit: Cell | None = None + + for y, row in enumerate(rows): + cell_row: list[Cell] = [] + for x, char in enumerate(row): + cell = self._create_cell(x, y, char) + if cell.is_start: + if start is not None: + raise ValueError("Maze must contain exactly one start cell") + start = cell + if cell.is_exit: + if exit is not None: + raise ValueError("Maze must contain exactly one exit cell") + exit = cell + cell_row.append(cell) + cells.append(cell_row) + + if start is None: + raise ValueError("Maze must contain a start cell marked with 'S'") + if exit is None: + raise ValueError("Maze must contain an exit cell marked with 'E'") + + return Maze(cells, start, exit) + + def _create_cell(self, x: int, y: int, char: str) -> Cell: + if char == self.WALL: + return Cell(x=x, y=y, is_wall=True, symbol=char) + if char == self.START: + return Cell(x=x, y=y, is_start=True, symbol=char) + if char == self.EXIT: + return Cell(x=x, y=y, is_exit=True, symbol=char) + if char in self.PASSAGES: + return Cell(x=x, y=y, symbol=" ") + if char in self.WEIGHTS: + return Cell(x=x, y=y, weight=self.WEIGHTS[char], symbol=char) + raise ValueError(f"Unsupported maze symbol {char!r} at ({x}, {y})") diff --git a/shahovaa/zadanie 2/maze_solver/commands.py b/shahovaa/zadanie 2/maze_solver/commands.py new file mode 100644 index 0000000..e93341c --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/commands.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum + +from .models import Cell, Maze + + +class Direction(Enum): + UP = (0, -1) + RIGHT = (1, 0) + DOWN = (0, 1) + LEFT = (-1, 0) + + @classmethod + def from_key(cls, key: str) -> "Direction": + mapping = { + "w": cls.UP, + "d": cls.RIGHT, + "s": cls.DOWN, + "a": cls.LEFT, + } + try: + return mapping[key.lower()] + except KeyError as exc: + raise ValueError("Use W/A/S/D for movement") from exc + + +@dataclass +class Player: + maze: Maze + current_cell: Cell + + @classmethod + def at_start(cls, maze: Maze) -> "Player": + return cls(maze=maze, current_cell=maze.start) + + def move_to(self, cell: Cell) -> None: + if not cell.is_passable(): + raise ValueError("Player cannot move into a wall") + self.current_cell = cell + + +class Command(ABC): + @abstractmethod + def execute(self) -> bool: + raise NotImplementedError + + @abstractmethod + def undo(self) -> bool: + raise NotImplementedError + + +class MoveCommand(Command): + def __init__(self, player: Player, direction: Direction) -> None: + self.player = player + self.direction = direction + self.previous_cell: Cell | None = None + self.executed = False + + def execute(self) -> bool: + dx, dy = self.direction.value + current = self.player.current_cell + target = self.player.maze.get_cell(current.x + dx, current.y + dy) + if target is None or not target.is_passable(): + return False + + self.previous_cell = current + self.player.move_to(target) + self.executed = True + return True + + def undo(self) -> bool: + if not self.executed or self.previous_cell is None: + return False + self.player.move_to(self.previous_cell) + self.executed = False + return True diff --git a/shahovaa/zadanie 2/maze_solver/models.py b/shahovaa/zadanie 2/maze_solver/models.py new file mode 100644 index 0000000..e6523a1 --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/models.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Cell: + x: int + y: int + is_wall: bool = False + is_start: bool = False + is_exit: bool = False + weight: int = 1 + symbol: str = " " + + def is_passable(self) -> bool: + return not self.is_wall + + def isPassable(self) -> bool: + return self.is_passable() + + +class Maze: + def __init__(self, cells: list[list[Cell]], start: Cell, exit: Cell) -> None: + if not cells or not cells[0]: + raise ValueError("Maze must contain at least one cell") + + width = len(cells[0]) + if any(len(row) != width for row in cells): + raise ValueError("Maze rows must have equal width") + + self.cells = cells + self.height = len(cells) + self.width = width + self.start = start + self.exit = exit + + def get_cell(self, x: int, y: int) -> Cell | None: + if 0 <= x < self.width and 0 <= y < self.height: + return self.cells[y][x] + return None + + def getCell(self, x: int, y: int) -> Cell | None: + return self.get_cell(x, y) + + def get_neighbors(self, cell: Cell) -> list[Cell]: + neighbors: list[Cell] = [] + for dx, dy in ((0, -1), (1, 0), (0, 1), (-1, 0)): + neighbor = self.get_cell(cell.x + dx, cell.y + dy) + if neighbor is not None and neighbor.is_passable(): + neighbors.append(neighbor) + return neighbors + + def getNeighbors(self, cell: Cell) -> list[Cell]: + return self.get_neighbors(cell) + + def to_text(self, path: list[Cell] | None = None, player: Cell | None = None) -> str: + path_cells = {(cell.x, cell.y) for cell in path or []} + lines: list[str] = [] + + for row in self.cells: + chars: list[str] = [] + for cell in row: + position = (cell.x, cell.y) + if player is not None and position == (player.x, player.y): + chars.append("@") + elif cell.is_start: + chars.append("S") + elif cell.is_exit: + chars.append("E") + elif cell.is_wall: + chars.append("#") + elif position in path_cells: + chars.append(".") + elif cell.weight > 1: + chars.append(str(cell.weight)) + else: + chars.append(" ") + lines.append("".join(chars)) + + return "\n".join(lines) diff --git a/shahovaa/zadanie 2/maze_solver/observers.py b/shahovaa/zadanie 2/maze_solver/observers.py new file mode 100644 index 0000000..3fecea2 --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/observers.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +from .models import Cell, Maze + + +@dataclass(frozen=True) +class Event: + event_type: str + payload: dict[str, Any] = field(default_factory=dict) + + +class Observer(ABC): + @abstractmethod + def update(self, event: Event) -> None: + raise NotImplementedError + + +class ConsoleView(Observer): + def update(self, event: Event) -> None: + if event.event_type == "search_started": + print(f"Search started: {event.payload['strategy']}") + elif event.event_type in {"path_found", "path_not_found"}: + stats = event.payload["stats"] + print( + f"{event.event_type}: strategy={stats.strategy_name}, " + f"time={stats.time_ms:.3f} ms, visited={stats.visited_cells}, " + f"path_length={stats.path_length}" + ) + + def render( + self, + maze: Maze, + player_position: Cell | None = None, + path: list[Cell] | None = None, + ) -> str: + return maze.to_text(path=path, player=player_position) diff --git a/shahovaa/zadanie 2/maze_solver/solver.py b/shahovaa/zadanie 2/maze_solver/solver.py new file mode 100644 index 0000000..e95d6e9 --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/solver.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass + +from .models import Cell, Maze +from .observers import Event, Observer +from .strategies import PathFindingStrategy + + +@dataclass(frozen=True) +class SearchStats: + strategy_name: str + time_ms: float + visited_cells: int + path_length: int + path: list[Cell] + + +class MazeSolver: + def __init__(self, maze: Maze, strategy: PathFindingStrategy) -> None: + self.maze = maze + self.strategy = strategy + self._observers: list[Observer] = [] + + def set_strategy(self, strategy: PathFindingStrategy) -> None: + self.strategy = strategy + + def setStrategy(self, strategy: PathFindingStrategy) -> None: + self.set_strategy(strategy) + + def add_observer(self, observer: Observer) -> None: + self._observers.append(observer) + + def remove_observer(self, observer: Observer) -> None: + self._observers.remove(observer) + + def solve(self) -> SearchStats: + self._notify(Event("search_started", {"strategy": self.strategy.name})) + started_at = time.perf_counter() + result = self.strategy.find_path(self.maze, self.maze.start, self.maze.exit) + elapsed_ms = (time.perf_counter() - started_at) * 1000 + + stats = SearchStats( + strategy_name=self.strategy.name, + time_ms=elapsed_ms, + visited_cells=result.visited_count, + path_length=len(result.path), + path=result.path, + ) + event_name = "path_found" if result.path else "path_not_found" + self._notify(Event(event_name, {"stats": stats})) + return stats + + def _notify(self, event: Event) -> None: + for observer in self._observers: + observer.update(event) diff --git a/shahovaa/zadanie 2/maze_solver/strategies.py b/shahovaa/zadanie 2/maze_solver/strategies.py new file mode 100644 index 0000000..739b7ec --- /dev/null +++ b/shahovaa/zadanie 2/maze_solver/strategies.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import heapq +from abc import ABC, abstractmethod +from collections import deque +from dataclasses import dataclass +from itertools import count + +from .models import Cell, Maze + + +@dataclass(frozen=True) +class PathResult: + path: list[Cell] + visited_count: int + + +class PathFindingStrategy(ABC): + name = "abstract" + + @abstractmethod + def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + raise NotImplementedError + + def findPath(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + return self.find_path(maze, start, exit) + + +class BFSStrategy(PathFindingStrategy): + name = "BFS" + + def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + queue: deque[Cell] = deque([start]) + parents: dict[Cell, Cell | None] = {start: None} + visited = {start} + + while queue: + current = queue.popleft() + if current == exit: + return PathResult(_reconstruct_path(parents, exit), len(visited)) + + for neighbor in maze.get_neighbors(current): + if neighbor not in visited: + visited.add(neighbor) + parents[neighbor] = current + queue.append(neighbor) + + return PathResult([], len(visited)) + + +class DFSStrategy(PathFindingStrategy): + name = "DFS" + + def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + stack = [start] + parents: dict[Cell, Cell | None] = {start: None} + visited = {start} + + while stack: + current = stack.pop() + if current == exit: + return PathResult(_reconstruct_path(parents, exit), len(visited)) + + for neighbor in reversed(maze.get_neighbors(current)): + if neighbor not in visited: + visited.add(neighbor) + parents[neighbor] = current + stack.append(neighbor) + + return PathResult([], len(visited)) + + +class DijkstraStrategy(PathFindingStrategy): + name = "Dijkstra" + + def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + tie_breaker = count() + heap: list[tuple[int, int, Cell]] = [(0, next(tie_breaker), start)] + distances: dict[Cell, int] = {start: 0} + parents: dict[Cell, Cell | None] = {start: None} + visited: set[Cell] = set() + + while heap: + current_distance, _, current = heapq.heappop(heap) + if current in visited: + continue + visited.add(current) + + if current == exit: + return PathResult(_reconstruct_path(parents, exit), len(visited)) + + for neighbor in maze.get_neighbors(current): + new_distance = current_distance + neighbor.weight + if new_distance < distances.get(neighbor, 10**12): + distances[neighbor] = new_distance + parents[neighbor] = current + heapq.heappush(heap, (new_distance, next(tie_breaker), neighbor)) + + return PathResult([], len(visited)) + + +class AStarStrategy(PathFindingStrategy): + name = "A*" + + def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult: + tie_breaker = count() + start_heuristic = _manhattan(start, exit) + heap: list[tuple[int, int, int, Cell]] = [ + (start_heuristic, start_heuristic, next(tie_breaker), start) + ] + g_score: dict[Cell, int] = {start: 0} + parents: dict[Cell, Cell | None] = {start: None} + visited: set[Cell] = set() + + while heap: + _, _, _, current = heapq.heappop(heap) + if current in visited: + continue + visited.add(current) + + if current == exit: + return PathResult(_reconstruct_path(parents, exit), len(visited)) + + for neighbor in maze.get_neighbors(current): + tentative_score = g_score[current] + neighbor.weight + if tentative_score < g_score.get(neighbor, 10**12): + g_score[neighbor] = tentative_score + parents[neighbor] = current + heuristic = _manhattan(neighbor, exit) + priority = tentative_score + heuristic + heapq.heappush( + heap, + (priority, heuristic, next(tie_breaker), neighbor), + ) + + return PathResult([], len(visited)) + + +def _reconstruct_path(parents: dict[Cell, Cell | None], end: Cell) -> list[Cell]: + path: list[Cell] = [] + current: Cell | None = end + while current is not None: + path.append(current) + current = parents[current] + path.reverse() + return path + + +def _manhattan(first: Cell, second: Cell) -> int: + return abs(first.x - second.x) + abs(first.y - second.y) diff --git a/shahovaa/zadanie 2/reports/charts/empty_time.svg b/shahovaa/zadanie 2/reports/charts/empty_time.svg new file mode 100644 index 0000000..8a21a08 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/empty_time.svg @@ -0,0 +1,28 @@ + + +Пустой 50x50: среднее время, мс + + + +0.00 + +1.01 + +2.02 + +3.03 + +4.04 + +2.89 +BFS + +0.14 +DFS + +0.24 +A* + +4.04 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/empty_visited.svg b/shahovaa/zadanie 2/reports/charts/empty_visited.svg new file mode 100644 index 0000000..0133eaf --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/empty_visited.svg @@ -0,0 +1,28 @@ + + +Пустой 50x50: посещенные клетки + + + +0.00 + +576.00 + +1152.00 + +1728.00 + +2304.00 + +2304.00 +BFS + +187.00 +DFS + +95.00 +A* + +2304.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/large_time.svg b/shahovaa/zadanie 2/reports/charts/large_time.svg new file mode 100644 index 0000000..775b0b0 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/large_time.svg @@ -0,0 +1,28 @@ + + +Большой 100x100: среднее время, мс + + + +0.00 + +2.16 + +4.33 + +6.49 + +8.66 + +5.30 +BFS + +2.50 +DFS + +8.66 +A* + +7.15 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/large_visited.svg b/shahovaa/zadanie 2/reports/charts/large_visited.svg new file mode 100644 index 0000000..08114dd --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/large_visited.svg @@ -0,0 +1,28 @@ + + +Большой 100x100: посещенные клетки + + + +0.00 + +1200.25 + +2400.50 + +3600.75 + +4801.00 + +4801.00 +BFS + +2155.00 +DFS + +4791.00 +A* + +4800.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/medium_time.svg b/shahovaa/zadanie 2/reports/charts/medium_time.svg new file mode 100644 index 0000000..2e4caff --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/medium_time.svg @@ -0,0 +1,28 @@ + + +Средний 50x50: среднее время, мс + + + +0.00 + +0.50 + +1.00 + +1.51 + +2.01 + +1.28 +BFS + +0.91 +DFS + +2.01 +A* + +1.70 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/medium_visited.svg b/shahovaa/zadanie 2/reports/charts/medium_visited.svg new file mode 100644 index 0000000..6dd8bd2 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/medium_visited.svg @@ -0,0 +1,28 @@ + + +Средний 50x50: посещенные клетки + + + +0.00 + +287.75 + +575.50 + +863.25 + +1151.00 + +1151.00 +BFS + +784.00 +DFS + +1133.00 +A* + +1151.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/no_exit_time.svg b/shahovaa/zadanie 2/reports/charts/no_exit_time.svg new file mode 100644 index 0000000..8cf0299 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/no_exit_time.svg @@ -0,0 +1,28 @@ + + +Без пути 30x30: среднее время, мс + + + +0.00 + +0.00 + +0.00 + +0.00 + +0.00 + +0.00 +BFS + +0.00 +DFS + +0.00 +A* + +0.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/no_exit_visited.svg b/shahovaa/zadanie 2/reports/charts/no_exit_visited.svg new file mode 100644 index 0000000..2e3b4a6 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/no_exit_visited.svg @@ -0,0 +1,28 @@ + + +Без пути 30x30: посещенные клетки + + + +0.00 + +0.25 + +0.50 + +0.75 + +1.00 + +1.00 +BFS + +1.00 +DFS + +1.00 +A* + +1.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/small_time.svg b/shahovaa/zadanie 2/reports/charts/small_time.svg new file mode 100644 index 0000000..57896e1 --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/small_time.svg @@ -0,0 +1,28 @@ + + +Маленький 10x10: среднее время, мс + + + +0.00 + +0.02 + +0.03 + +0.05 + +0.07 + +0.04 +BFS + +0.03 +DFS + +0.07 +A* + +0.06 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/charts/small_visited.svg b/shahovaa/zadanie 2/reports/charts/small_visited.svg new file mode 100644 index 0000000..cadf1bc --- /dev/null +++ b/shahovaa/zadanie 2/reports/charts/small_visited.svg @@ -0,0 +1,28 @@ + + +Маленький 10x10: посещенные клетки + + + +0.00 + +9.25 + +18.50 + +27.75 + +37.00 + +37.00 +BFS + +24.00 +DFS + +31.00 +A* + +37.00 +Dijkstra + \ No newline at end of file diff --git a/shahovaa/zadanie 2/reports/report.md b/shahovaa/zadanie 2/reports/report.md new file mode 100644 index 0000000..a2f2fe9 --- /dev/null +++ b/shahovaa/zadanie 2/reports/report.md @@ -0,0 +1,208 @@ +# Отчет по заданию: поиск выхода из лабиринта + +## 1. Описание задачи и выбранных паттернов + +Цель работы - реализовать расширяемую программу для загрузки лабиринта из файла, +поиска пути от старта `S` до выхода `E`, визуализации результата и сравнения +алгоритмов на лабиринтах разной сложности. + +В проекте реализованы четыре паттерна GoF: + +| Паттерн | Где реализован | Зачем нужен | +|---|---|---| +| Builder | `MazeBuilder`, `TextFileMazeBuilder` | Изолирует парсинг и валидацию файла от остального приложения. | +| Strategy | `PathFindingStrategy`, `BFSStrategy`, `DFSStrategy`, `AStarStrategy`, `DijkstraStrategy` | Позволяет менять алгоритм поиска без изменения `MazeSolver`. | +| Observer | `Observer`, `ConsoleView`, события `search_started`, `path_found`, `path_not_found` | Отделяет вычисления от отображения в консоли. | +| Command | `Command`, `MoveCommand`, `Player` | Инкапсулирует ход игрока и поддерживает отмену хода. | + +Диаграмма классов: + +```mermaid +classDiagram + class Cell { + +int x + +int y + +bool is_wall + +bool is_start + +bool is_exit + +int weight + +is_passable() bool + } + + class Maze { + +list cells + +int width + +int height + +Cell start + +Cell exit + +get_cell(x, y) Cell + +get_neighbors(cell) list + +to_text(path, player) str + } + + class MazeBuilder { + <> + +build_from_file(filename) Maze + } + + class TextFileMazeBuilder { + +build_from_file(filename) Maze + } + + class PathFindingStrategy { + <> + +find_path(maze, start, exit) PathResult + } + + class BFSStrategy + class DFSStrategy + class AStarStrategy + class DijkstraStrategy + + class SearchStats { + +str strategy_name + +float time_ms + +int visited_cells + +int path_length + +list path + } + + class MazeSolver { + +set_strategy(strategy) + +add_observer(observer) + +solve() SearchStats + } + + class Observer { + <> + +update(event) + } + + class ConsoleView { + +update(event) + +render(maze, player_position, path) str + } + + class Command { + <> + +execute() bool + +undo() bool + } + + class MoveCommand + class Player + + MazeBuilder <|.. TextFileMazeBuilder + MazeBuilder --> Maze : creates + PathFindingStrategy <|.. BFSStrategy + PathFindingStrategy <|.. DFSStrategy + PathFindingStrategy <|.. AStarStrategy + PathFindingStrategy <|.. DijkstraStrategy + MazeSolver --> PathFindingStrategy : uses + MazeSolver --> Maze : uses + MazeSolver --> Observer : notifies + Observer <|.. ConsoleView + Command <|.. MoveCommand + MoveCommand --> Player + Player --> Cell +``` + +## 2. Ключевые классы + +Основные файлы проекта: + +| Файл | Назначение | +|---|---| +| `maze_solver/models.py` | Классы `Cell` и `Maze`, поиск соседей, текстовая отрисовка. | +| `maze_solver/builders.py` | Интерфейс Builder и загрузка лабиринта из `.txt`. | +| `maze_solver/strategies.py` | BFS, DFS, A* и Дейкстра. | +| `maze_solver/solver.py` | Оркестратор поиска и сбор статистики. | +| `maze_solver/observers.py` | Observer и консольное представление. | +| `maze_solver/commands.py` | Command, игрок и undo перемещения. | +| `main.py` | CLI для запуска поиска и ручного режима. | +| `scripts/run_experiments.py` | Замеры и построение SVG-графиков. | + +Пример запуска: + +```bash +python3 main.py --maze data/mazes/small.txt --strategy astar --render +``` + +## 3. Результаты экспериментов + +Для каждого лабиринта и каждой стратегии выполнено 10 запусков. В таблице указаны +средние значения. Длина пути считается в клетках, включая старт и выход. + +| Лабиринт | Стратегия | Время, мс | Посещено клеток | Длина пути | Путь найден | +|---|---:|---:|---:|---:|---| +| Маленький 10x10 | BFS | 0.0423 | 37.0 | 20.0 | да | +| Маленький 10x10 | DFS | 0.0273 | 24.0 | 22.0 | да | +| Маленький 10x10 | A* | 0.0677 | 31.0 | 20.0 | да | +| Маленький 10x10 | Dijkstra | 0.0551 | 37.0 | 20.0 | да | +| Средний 50x50 | BFS | 1.2769 | 1151.0 | 709.0 | да | +| Средний 50x50 | DFS | 0.9106 | 784.0 | 709.0 | да | +| Средний 50x50 | A* | 2.0089 | 1133.0 | 709.0 | да | +| Средний 50x50 | Dijkstra | 1.7041 | 1151.0 | 709.0 | да | +| Большой 100x100 | BFS | 5.2983 | 4801.0 | 1685.0 | да | +| Большой 100x100 | DFS | 2.5044 | 2155.0 | 1685.0 | да | +| Большой 100x100 | A* | 8.6574 | 4791.0 | 1685.0 | да | +| Большой 100x100 | Dijkstra | 7.1532 | 4800.0 | 1685.0 | да | +| Пустой 50x50 | BFS | 2.8927 | 2304.0 | 95.0 | да | +| Пустой 50x50 | DFS | 0.1404 | 187.0 | 95.0 | да | +| Пустой 50x50 | A* | 0.2374 | 95.0 | 95.0 | да | +| Пустой 50x50 | Dijkstra | 4.0408 | 2304.0 | 95.0 | да | +| Без пути 30x30 | BFS | 0.0015 | 1.0 | 0.0 | нет | +| Без пути 30x30 | DFS | 0.0013 | 1.0 | 0.0 | нет | +| Без пути 30x30 | A* | 0.0016 | 1.0 | 0.0 | нет | +| Без пути 30x30 | Dijkstra | 0.0018 | 1.0 | 0.0 | нет | + +CSV с результатами сохранен в `reports/results.csv`. + +Графики: + +| Лабиринт | Время | Посещенные клетки | +|---|---|---| +| Маленький | ![](charts/small_time.svg) | ![](charts/small_visited.svg) | +| Средний | ![](charts/medium_time.svg) | ![](charts/medium_visited.svg) | +| Большой | ![](charts/large_time.svg) | ![](charts/large_visited.svg) | +| Пустой | ![](charts/empty_time.svg) | ![](charts/empty_visited.svg) | +| Без пути | ![](charts/no_exit_time.svg) | ![](charts/no_exit_visited.svg) | + +## 4. Анализ эффективности + +BFS гарантирует кратчайший путь в невзвешенном лабиринте. Это видно на маленьком +лабиринте: BFS, A* и Дейкстра нашли путь длиной 20, а DFS нашел более длинный путь +длиной 22. Недостаток BFS - широкий фронт поиска, из-за чего в пустом лабиринте он +посетил все 2304 доступные клетки. + +DFS не гарантирует кратчайший путь, но часто работает быстро, потому что уходит +глубоко по одному направлению. На маленьком лабиринте это дало путь хуже оптимального. +На сгенерированных идеальных лабиринтах путь между двумя клетками единственный, поэтому +DFS, BFS, A* и Дейкстра получили одинаковую длину пути. + +A* использует манхэттенскую эвристику. На пустом лабиринте он посетил только 95 клеток, +то есть фактически прошел по оптимальному маршруту. В запутанных идеальных лабиринтах +эвристика помогает слабее: прямое направление к выходу часто упирается в стены, поэтому +A* посещает почти столько же клеток, сколько BFS, а из-за приоритетной очереди тратит +больше времени. + +Дейкстра в невзвешенном лабиринте по результату близок к BFS, но работает медленнее +из-за приоритетной очереди. Его преимущество проявляется при взвешенных клетках. +В проекте Builder уже поддерживает символы `2`, `3` и `~` как клетки с повышенной +стоимостью прохода, поэтому Дейкстру и A* можно использовать для дополнительного +сравнения на взвешенных картах. + +Лабиринт "Без пути" проверяет корректную обработку отсутствия решения: стратегии +возвращают пустой путь, а `MazeSolver` фиксирует длину 0. + +## 5. Выводы + +ООП позволило разделить предметную модель, загрузку данных, алгоритмы и интерфейс. +Паттерн Builder делает формат входного файла заменяемым: можно добавить JSON-builder, +не меняя `Maze` и стратегии. Strategy позволяет добавлять новые алгоритмы без правок +в `MazeSolver`. Observer отделяет вычисления от вывода, а Command показывает, как +инкапсулировать пользовательские действия и поддержать undo. + +Без этих паттернов код быстро стал бы монолитным: парсинг файла, поиск, статистика, +печать и ручное управление оказались бы в одном месте. Тогда добавление нового формата, +алгоритма или режима отображения требовало бы менять уже работающую логику. diff --git a/shahovaa/zadanie 2/reports/results.csv b/shahovaa/zadanie 2/reports/results.csv new file mode 100644 index 0000000..2acb085 --- /dev/null +++ b/shahovaa/zadanie 2/reports/results.csv @@ -0,0 +1,21 @@ +лабиринт,стратегия,время_мс,посещено_клеток,длина_пути,путь_найден,запусков +Маленький 10x10,BFS,0.0423,37.0,20.0,да,10 +Маленький 10x10,DFS,0.0273,24.0,22.0,да,10 +Маленький 10x10,A*,0.0677,31.0,20.0,да,10 +Маленький 10x10,Dijkstra,0.0551,37.0,20.0,да,10 +Средний 50x50,BFS,1.2769,1151.0,709.0,да,10 +Средний 50x50,DFS,0.9106,784.0,709.0,да,10 +Средний 50x50,A*,2.0089,1133.0,709.0,да,10 +Средний 50x50,Dijkstra,1.7041,1151.0,709.0,да,10 +Большой 100x100,BFS,5.2983,4801.0,1685.0,да,10 +Большой 100x100,DFS,2.5044,2155.0,1685.0,да,10 +Большой 100x100,A*,8.6574,4791.0,1685.0,да,10 +Большой 100x100,Dijkstra,7.1532,4800.0,1685.0,да,10 +Пустой 50x50,BFS,2.8927,2304.0,95.0,да,10 +Пустой 50x50,DFS,0.1404,187.0,95.0,да,10 +Пустой 50x50,A*,0.2374,95.0,95.0,да,10 +Пустой 50x50,Dijkstra,4.0408,2304.0,95.0,да,10 +Без пути 30x30,BFS,0.0015,1.0,0.0,нет,10 +Без пути 30x30,DFS,0.0013,1.0,0.0,нет,10 +Без пути 30x30,A*,0.0016,1.0,0.0,нет,10 +Без пути 30x30,Dijkstra,0.0018,1.0,0.0,нет,10 diff --git a/shahovaa/zadanie 2/scripts/__init__.py b/shahovaa/zadanie 2/scripts/__init__.py new file mode 100644 index 0000000..05aaea9 --- /dev/null +++ b/shahovaa/zadanie 2/scripts/__init__.py @@ -0,0 +1 @@ +"""Helper scripts for maze generation and experiments.""" diff --git a/shahovaa/zadanie 2/scripts/generate_mazes.py b/shahovaa/zadanie 2/scripts/generate_mazes.py new file mode 100644 index 0000000..d403792 --- /dev/null +++ b/shahovaa/zadanie 2/scripts/generate_mazes.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import random +from collections import deque +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MAZE_DIR = ROOT / "data" / "mazes" + + +def main() -> None: + generate_all() + + +def generate_all() -> None: + MAZE_DIR.mkdir(parents=True, exist_ok=True) + _write("small.txt", _small_maze()) + _write("medium.txt", _perfect_maze(50, 50, seed=2026)) + _write("large.txt", _perfect_maze(100, 100, seed=2027)) + _write("empty.txt", _empty_maze(50, 50)) + _write("no_exit.txt", _no_path_maze(30, 30)) + + +def _write(filename: str, rows: list[str]) -> None: + (MAZE_DIR / filename).write_text("\n".join(rows) + "\n", encoding="utf-8") + + +def _small_maze() -> list[str]: + return [ + "##########", + "#S #E#", + "# #### # #", + "# # # #", + "# # #### #", + "# # #", + "# ###### #", + "# #", + "######## #", + "##########", + ] + + +def _empty_maze(width: int, height: int) -> list[str]: + grid = _bordered_grid(width, height, fill=" ") + grid[1][1] = "S" + grid[height - 2][width - 2] = "E" + return _to_rows(grid) + + +def _no_path_maze(width: int, height: int) -> list[str]: + grid = [["#" for _ in range(width)] for _ in range(height)] + grid[1][1] = "S" + grid[height - 2][width - 2] = "E" + return _to_rows(grid) + + +def _perfect_maze(width: int, height: int, seed: int) -> list[str]: + if width < 5 or height < 5: + raise ValueError("Maze must be at least 5x5") + + randomizer = random.Random(seed) + grid = [["#" for _ in range(width)] for _ in range(height)] + start = (1, 1) + stack = [start] + grid[start[1]][start[0]] = " " + + while stack: + x, y = stack[-1] + candidates = [] + for dx, dy in ((0, -2), (2, 0), (0, 2), (-2, 0)): + nx, ny = x + dx, y + dy + if 1 <= nx < width - 1 and 1 <= ny < height - 1 and grid[ny][nx] == "#": + candidates.append((nx, ny, dx, dy)) + + if not candidates: + stack.pop() + continue + + nx, ny, dx, dy = randomizer.choice(candidates) + grid[y + dy // 2][x + dx // 2] = " " + grid[ny][nx] = " " + stack.append((nx, ny)) + + exit_x, exit_y = _farthest_open_cell(grid, start) + grid[start[1]][start[0]] = "S" + grid[exit_y][exit_x] = "E" + return _to_rows(grid) + + +def _farthest_open_cell(grid: list[list[str]], start: tuple[int, int]) -> tuple[int, int]: + queue = deque([start]) + distances = {start: 0} + farthest = start + + while queue: + x, y = queue.popleft() + if distances[(x, y)] > distances[farthest]: + farthest = (x, y) + + for dx, dy in ((0, -1), (1, 0), (0, 1), (-1, 0)): + nx, ny = x + dx, y + dy + if (nx, ny) not in distances and grid[ny][nx] != "#": + distances[(nx, ny)] = distances[(x, y)] + 1 + queue.append((nx, ny)) + + return farthest + + +def _bordered_grid(width: int, height: int, fill: str) -> list[list[str]]: + grid = [[fill for _ in range(width)] for _ in range(height)] + for x in range(width): + grid[0][x] = "#" + grid[height - 1][x] = "#" + for y in range(height): + grid[y][0] = "#" + grid[y][width - 1] = "#" + return grid + + +def _to_rows(grid: list[list[str]]) -> list[str]: + return ["".join(row) for row in grid] + + +if __name__ == "__main__": + main() diff --git a/shahovaa/zadanie 2/scripts/run_experiments.py b/shahovaa/zadanie 2/scripts/run_experiments.py new file mode 100644 index 0000000..46bd475 --- /dev/null +++ b/shahovaa/zadanie 2/scripts/run_experiments.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import csv +import statistics +import sys +from collections import defaultdict +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from maze_solver import ( # noqa: E402 + AStarStrategy, + BFSStrategy, + DFSStrategy, + DijkstraStrategy, + MazeSolver, + TextFileMazeBuilder, +) +from scripts.generate_mazes import generate_all # noqa: E402 + + +MAZES = [ + ("small", "Маленький 10x10", ROOT / "data" / "mazes" / "small.txt"), + ("medium", "Средний 50x50", ROOT / "data" / "mazes" / "medium.txt"), + ("large", "Большой 100x100", ROOT / "data" / "mazes" / "large.txt"), + ("empty", "Пустой 50x50", ROOT / "data" / "mazes" / "empty.txt"), + ("no_exit", "Без пути 30x30", ROOT / "data" / "mazes" / "no_exit.txt"), +] + +STRATEGIES = [BFSStrategy, DFSStrategy, AStarStrategy, DijkstraStrategy] +REPORTS_DIR = ROOT / "reports" +CHARTS_DIR = REPORTS_DIR / "charts" + + +def main(runs: int = 10) -> None: + generate_all() + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + CHARTS_DIR.mkdir(parents=True, exist_ok=True) + + rows = _run_experiments(runs) + _write_csv(rows) + _write_charts(rows) + print(f"Wrote {REPORTS_DIR / 'results.csv'}") + print(f"Wrote SVG charts to {CHARTS_DIR}") + + +def _run_experiments(runs: int) -> list[dict[str, object]]: + builder = TextFileMazeBuilder() + rows: list[dict[str, object]] = [] + + for maze_key, maze_name, maze_path in MAZES: + maze = builder.build_from_file(maze_path) + for strategy_type in STRATEGIES: + measurements = [] + for _ in range(runs): + stats = MazeSolver(maze, strategy_type()).solve() + measurements.append(stats) + + avg_time = statistics.fmean(item.time_ms for item in measurements) + avg_visited = statistics.fmean(item.visited_cells for item in measurements) + avg_path = statistics.fmean(item.path_length for item in measurements) + found = measurements[-1].path_length > 0 + rows.append( + { + "key": maze_key, + "лабиринт": maze_name, + "стратегия": measurements[-1].strategy_name, + "время_мс": f"{avg_time:.4f}", + "посещено_клеток": f"{avg_visited:.1f}", + "длина_пути": f"{avg_path:.1f}", + "путь_найден": "да" if found else "нет", + "запусков": runs, + } + ) + + return rows + + +def _write_csv(rows: list[dict[str, object]]) -> None: + csv_path = REPORTS_DIR / "results.csv" + headers = [ + "лабиринт", + "стратегия", + "время_мс", + "посещено_клеток", + "длина_пути", + "путь_найден", + "запусков", + ] + with csv_path.open("w", encoding="utf-8", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=headers) + writer.writeheader() + for row in rows: + writer.writerow({header: row[header] for header in headers}) + + +def _write_charts(rows: list[dict[str, object]]) -> None: + grouped: dict[str, list[dict[str, object]]] = defaultdict(list) + for row in rows: + grouped[str(row["key"])].append(row) + + for maze_key, group in grouped.items(): + title = str(group[0]["лабиринт"]) + _write_bar_chart( + CHARTS_DIR / f"{maze_key}_time.svg", + title=f"{title}: среднее время, мс", + rows=group, + metric="время_мс", + color="#2f6fbb", + ) + _write_bar_chart( + CHARTS_DIR / f"{maze_key}_visited.svg", + title=f"{title}: посещенные клетки", + rows=group, + metric="посещено_клеток", + color="#2f8f5b", + ) + + +def _write_bar_chart( + path: Path, + title: str, + rows: list[dict[str, object]], + metric: str, + color: str, +) -> None: + width = 780 + height = 360 + left = 72 + right = 28 + top = 54 + bottom = 58 + chart_width = width - left - right + chart_height = height - top - bottom + values = [float(row[metric]) for row in rows] + max_value = max(values) if values else 1.0 + max_value = max_value or 1.0 + bar_area = chart_width / len(rows) + bar_width = min(96, bar_area * 0.58) + + parts = [ + f'', + '', + f'{_escape(title)}', + f'', + f'', + ] + + for tick in range(5): + ratio = tick / 4 + y = height - bottom - ratio * chart_height + value = max_value * ratio + parts.append( + f'' + ) + parts.append( + f'{value:.2f}' + ) + + for index, row in enumerate(rows): + value = float(row[metric]) + ratio = value / max_value + bar_height = ratio * chart_height + x = left + index * bar_area + (bar_area - bar_width) / 2 + y = height - bottom - bar_height + label = str(row["стратегия"]) + parts.append( + f'' + ) + parts.append( + f'{value:.2f}' + ) + parts.append( + f'{_escape(label)}' + ) + + parts.append("") + path.write_text("\n".join(parts), encoding="utf-8") + + +def _escape(value: str) -> str: + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +if __name__ == "__main__": + main() diff --git a/shahovaa/zadanie 2/tests/__init__.py b/shahovaa/zadanie 2/tests/__init__.py new file mode 100644 index 0000000..a76b1eb --- /dev/null +++ b/shahovaa/zadanie 2/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the maze solver project.""" diff --git a/shahovaa/zadanie 2/tests/test_solver.py b/shahovaa/zadanie 2/tests/test_solver.py new file mode 100644 index 0000000..9fd5267 --- /dev/null +++ b/shahovaa/zadanie 2/tests/test_solver.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from maze_solver import ( + AStarStrategy, + BFSStrategy, + Direction, + MazeSolver, + MoveCommand, + Player, + TextFileMazeBuilder, +) + + +SIMPLE_MAZE = """\ +####### +#S E# +# ### # +# # +#######""" + + +class MazeSolverTest(unittest.TestCase): + def build_maze(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "maze.txt" + path.write_text(SIMPLE_MAZE, encoding="utf-8") + return TextFileMazeBuilder().build_from_file(path) + + def test_builder_reads_start_exit_and_neighbors(self) -> None: + maze = self.build_maze() + + self.assertEqual((maze.start.x, maze.start.y), (1, 1)) + self.assertEqual((maze.exit.x, maze.exit.y), (5, 1)) + self.assertTrue(maze.get_cell(2, 1).is_passable()) + self.assertFalse(maze.get_cell(0, 0).is_passable()) + + def test_bfs_and_astar_find_shortest_path(self) -> None: + maze = self.build_maze() + + bfs_stats = MazeSolver(maze, BFSStrategy()).solve() + astar_stats = MazeSolver(maze, AStarStrategy()).solve() + + self.assertEqual(bfs_stats.path_length, 5) + self.assertEqual(astar_stats.path_length, 5) + self.assertEqual(bfs_stats.path[0], maze.start) + self.assertEqual(bfs_stats.path[-1], maze.exit) + + def test_move_command_can_execute_and_undo(self) -> None: + maze = self.build_maze() + player = Player.at_start(maze) + command = MoveCommand(player, Direction.RIGHT) + + self.assertTrue(command.execute()) + self.assertEqual((player.current_cell.x, player.current_cell.y), (2, 1)) + self.assertTrue(command.undo()) + self.assertEqual(player.current_cell, maze.start) + + +if __name__ == "__main__": + unittest.main()