2025, Oct 18 23:17

Исправляем баг со срезом: пары в Python без совпадающих ролей

Почему генерация пар в Python ломается: из‑за среза с индекса 2 игнорируется первая роль. Разбираем причину, показываем исправление и рабочий код. Пример кода.

Случайное составление пар кажется пустяком, пока одно ограничение не переворачивает результат. В нашей задаче нужно собрать пары, которых раньше не было, и при этом участники не должны иметь одинаковую роль. Реализация почти достигает цели, но из‑за тонкой ошибки со срезом в пары просачиваются люди с одинаковыми ролями.

Как воспроизвести проблему

Ниже приведена реализация, которая читает уже встречавшиеся пары из CSV, перемешивает список участников и формирует новые пары, отсекая дубликаты, исключения и пересечения по ролям. Подвох в том, как из каждого кортежа извлекаются роли.

import random
import pandas as p

# Список всех участников и их ролей
roster = [
    ("Samantha Reyes", "Innovation", "Product Owner"),
    ("Ethan McAllister", "Data Scientist"),
    ("Priya Deshmukh", "Data Architect", "SMT"),
    ("Marcus Liu", "Stream 3"),
    ("Elena Petrova", "SMT", "Stream 3"),
]

# Загрузка ранее сгенерированных пар из CSV‑файла
def read_seen_duos(csv_path):
    df = p.read_csv(csv_path)
    duo_cache = set()
    for _, row in df.iterrows():
        duo = (row['name1'], row['name2'])
        duo_rev = (row['name2'], row['name1'])
        duo_cache.add(duo)
        duo_cache.add(duo_rev)
    return duo_cache

# Добавление новых пар в CSV‑файл
def append_duos_to_csv(csv_path, duos):
    df = p.DataFrame(duos, columns=['name1', 'name2'])
    df.to_csv(csv_path, index=False, mode='a', header=False)

# Путь к файлу с ранее сгенерированными парами
pairs_csv_path = 'prev_pairs2.csv'

# Инициализация кэша уже встречавшихся пар
prior_duos = read_seen_duos(pairs_csv_path)

# Исключения для логики формирования пар
banned_duos = []

def build_fresh_duos_with_trace(roster, prior_duos, banned_duos):
    banned_set = set(banned_duos) | set((a[1], a[0]) for a in banned_duos)
    max_tries = 10000
    attempts = 0

    while attempts < max_tries:
        random.shuffle(roster)
        duos = []
        engaged = set()
        leftovers = []

        for i in range(0, len(roster) - 1, 2):
            member1, member2 = roster[i], roster[i + 1]
            tags1 = set(member1[2:]) if len(member1) > 2 else set()
            tags2 = set(member2[2:]) if len(member2) > 2 else set()
            duo_key = (member1[0], member2[0])
            duo_key_rev = (member2[0], member1[0])

            if duo_key in banned_set or duo_key_rev in banned_set:
                print(f"Skipping pair due to exclusion: {member1[0]} - {member2[0]}")
                continue
            if duo_key in prior_duos or duo_key_rev in prior_duos:
                print(f"Skipping pair due to seen pair: {member1[0]} - {member2[0]}")
                continue
            if tags1 & tags2:
                print(f"Skipping pair due to role overlap: {member1[0]} ({tags1}) - {member2[0]} ({tags2})")
                continue
            if member1[0] in engaged or member2[0] in engaged:
                print(f"Skipping pair due to duplicate usage: {member1[0]} - {member2[0]}")
                continue

            duos.append((member1, member2))
            engaged.update([member1[0], member2[0]])

        leftovers = [person for person in roster if person[0] not in engaged]
        if not leftovers:
            duo_keys = set((d[0][0], d[1][0]) for d in duos)
            if not any((k in prior_duos or (k[1], k[0]) in prior_duos) for k in duo_keys):
                prior_duos.update(duo_keys)
                return duos

        attempts += 1

    print("Unable to generate unique pairs with the given restrictions.")
    print(f"Skipped individuals: {[person[0] for person in leftovers]}")
    raise ValueError("Unable to generate unique pairs with the given restrictions.")

# Генерация пар
result_duos = build_fresh_duos_with_trace(roster, prior_duos, banned_duos)

# Вывод пар
for d in result_duos:
    print(f"{d[0][0]} - {d[1][0]}")
print("-----")
print(f"Total pairs: {len(result_duos)}")

# Сохранение
append_duos_to_csv(pairs_csv_path, [(d[0][0], d[1][0]) for d in result_duos])

В чём реальная проблема

Кортежи в списке участников устроены так: под индексом 0 хранится имя, а под индексами 1 и далее — роли. Поскольку в Python индексация начинается с нуля, срез с индекса 2 означает «начиная с третьего элемента». Иначе говоря, первая роль с индексом 1 незаметно исключается. Когда проверка пересечений опирается на такие множества ролей, двое людей, совпадающих только по первой роли, проходят фильтр и попадают в одну пару — именно это вы и наблюдали.

Исправление

Формируйте множества ролей, начиная с индекса 1, чтобы учесть все роли. Дополнительная проверка длины не нужна: срез с 1 у одноэлементного кортежа вернёт пустой кортеж, который превратится в пустое множество.

def build_fresh_duos_with_trace(roster, prior_duos, banned_duos):
    banned_set = set(banned_duos) | set((a[1], a[0]) for a in banned_duos)
    max_tries = 10000
    attempts = 0

    while attempts < max_tries:
        random.shuffle(roster)
        duos = []
        engaged = set()
        leftovers = []

        for i in range(0, len(roster) - 1, 2):
            member1, member2 = roster[i], roster[i + 1]
            # Исправление: учитываем все роли, начиная с индекса 1
            tags1 = set(member1[1:])
            tags2 = set(member2[1:])
            duo_key = (member1[0], member2[0])
            duo_key_rev = (member2[0], member1[0])

            if duo_key in banned_set or duo_key_rev in banned_set:
                print(f"Skipping pair due to exclusion: {member1[0]} - {member2[0]}")
                continue
            if duo_key in prior_duos or duo_key_rev in prior_duos:
                print(f"Skipping pair due to seen pair: {member1[0]} - {member2[0]}")
                continue
            if tags1 & tags2:
                print(f"Skipping pair due to role overlap: {member1[0]} ({tags1}) - {member2[0]} ({tags2})")
                continue
            if member1[0] in engaged or member2[0] in engaged:
                print(f"Skipping pair due to duplicate usage: {member1[0]} - {member2[0]}")
                continue

            duos.append((member1, member2))
            engaged.update([member1[0], member2[0]])

        leftovers = [person for person in roster if person[0] not in engaged]
        if not leftovers:
            duo_keys = set((d[0][0], d[1][0]) for d in duos)
            if not any((k in prior_duos or (k[1], k[0]) in prior_duos) for k in duo_keys):
                prior_duos.update(duo_keys)
                return duos

        attempts += 1

    print("Unable to generate unique pairs with the given restrictions.")
    print(f"Skipped individuals: {[person[0] for person in leftovers]}")
    raise ValueError("Unable to generate unique pairs with the given restrictions.")

Почему это важно

Предположения о структуре данных быстро просачиваются в управляющую логику. В данном случае уникальность и проверка пересечений опираются на корректное понимание структуры кортежа. Ошибка на один индекс в срезе легко ускользает при ревью, но прямо подрывает инвариант подбора и засоряет историю неверными парами. Исправление среза возвращает задуманный смысл без изменения стратегии формирования пар и поведения ввода/вывода.

Итоги

Когда роли начинаются со второго элемента, всегда делайте срез с индекса 1, чтобы собрать полный набор атрибутов для фильтрации. В этом контексте set(person[1:]) достаточно и устойчив к кортежам переменной длины. С таким подходом проверка пересечений работает как задумано, и генератор выдаёт только новые пары с разными ролями.

Статья основана на вопросе на StackOverflow от Imam и ответе Rafalon.