import random
from collections import defaultdict, Counter
import csv
# =========================
# 기본 설정
# =========================
TEAMS = [
"두산 베어스", "한화 이글스", "KIA 타이거즈", "키움 히어로즈", "KT 위즈",
"LG 트윈스", "롯데 자이언츠", "NC 다이노스", "삼성 라이온즈", "SSG 랜더스",
]
ABBR = {
"두산 베어스": "두산",
"한화 이글스": "한화",
"KIA 타이거즈": "KIA",
"키움 히어로즈": "키움",
"KT 위즈": "KT",
"LG 트윈스": "LG",
"롯데 자이언츠": "롯데",
"NC 다이노스": "NC",
"삼성 라이온즈": "삼성",
"SSG 랜더스": "SSG",
}
N = len(TEAMS)
TEAM_IDX = {t: i for i, t in enumerate(TEAMS)}
IDX_TEAM = {i: t for t, i in TEAM_IDX.items()}
# =========================
# 1. 원형 방식으로 기본 완전매칭 9개 생성
# =========================
def circle_method_pairings(n: int):
arr = list(range(n))
rounds = []
for _ in range(n - 1):
pairs = []
for i in range(n // 2):
a = arr[i]
b = arr[-(i + 1)]
pairs.append(tuple(sorted((a, b))))
rounds.append(sorted(pairs))
# 0번 고정 나머지 회전
arr = [arr[0]] + [arr[-1]] + arr[1:-1]
return rounds # 9개의 "리그 전체 매치업 세트"
def apply_perm_to_round(round_pairs, perm):
return sorted([tuple(sorted((perm[a], perm[b]))) for a, b in round_pairs])
def apply_perm_to_rounds(rounds, perm):
return [apply_perm_to_round(r, perm) for r in rounds]
# =========================
# 2. 중복 없는 완전매칭 풀 생성
# =========================
def generate_unique_cycles(base_rounds, num_cycles, avoid=None, attempts=50000):
"""
base_rounds: circle_method_pairings 결과 (9개 완전매칭)
num_cycles: 몇 번 '라운드 전체'를 만들지 (4 → 36개 등)
avoid: 이미 사용된 완전매칭(tuple) 집합
"""
if avoid is None:
avoid = set()
out = []
seen = set(avoid)
need = num_cycles * len(base_rounds)
tries = 0
while len(out) < need and tries < attempts:
tries += 1
perm_arr = list(range(N))
random.shuffle(perm_arr)
perm = {i: perm_arr[i] for i in range(N)}
rounds = apply_perm_to_rounds(base_rounds, perm)
random.shuffle(rounds)
# 완전매칭 중복 체크
if any(tuple(r) in seen for r in rounds):
continue
out.extend(rounds)
for r in rounds:
seen.add(tuple(r))
if len(out) != need:
raise RuntimeError(
f"유일한 완전매칭 수집 실패: 필요 {need}, 확보 {len(out)}"
)
return out
# =========================
# 3. 클러스터 방지 + 연속 같은 상대 금지 스케줄러
# =========================
def schedule_with_min_gap_fixed(pool, min_gap, prev_last_opp=None, max_restarts=800):
"""
pool: 완전매칭 리스트 (각 원소 = [(a,b), ...] 길이 5)
min_gap: 같은 맞대결 간 최소 간격 (슬롯 단위)
prev_last_opp: 앞 블록(3G->2G 경계)에서 마지막 상대 정보 (dict)
"""
base = [list(m) for m in pool]
gap = min_gap
restarts = 0
while True:
remaining = base[:]
random.shuffle(remaining)
res = []
cooldown = defaultdict(int) # pair -> 남은 쿨다운 슬롯 수
last_opp = {i: None for i in range(N)} if prev_last_opp is None else prev_last_opp.copy()
success = True
while remaining:
feasible = []
for r in remaining:
ok = True
# 1) 같은 맞대결 쿨다운 체크
for a, b in r:
key = (a, b) if a < b else (b, a)
if cooldown[key] > 0:
ok = False
break
if not ok:
continue
# 2) 직전 슬롯에서 같은 상대와 연속 경기 금지
for a, b in r:
if last_opp[a] == b or last_opp[b] == a:
ok = False
break
if ok:
feasible.append(r)
if not feasible:
success = False
break
r = random.choice(feasible)
res.append(r)
# 쿨다운 감소
for k in list(cooldown.keys()):
if cooldown[k] > 0:
cooldown[k] -= 1
# 이번 라운드 쌍들에 새 쿨다운 부여
for a, b in r:
key = (a, b) if a < b else (b, a)
cooldown[key] = gap
# 마지막 상대 업데이트
for a, b in r:
last_opp[a] = b
last_opp[b] = a
remaining.remove(r)
if success:
return res, last_opp
# 실패 → 리스타트 & gap 살짝 완화
restarts += 1
if restarts > max_restarts:
gap = max(1, gap - 1)
restarts = 0
# =========================
# 4. 시즌 스케줄 생성
# =========================
def generate_season_2034(seed=2034_777):
random.seed(seed)
base_rounds = circle_method_pairings(N)
# 3연전용 36 슬롯, 2연전용 18 슬롯 (완전매칭 중복 없음)
three_pool = generate_unique_cycles(base_rounds, 4, avoid=set())
two_pool = generate_unique_cycles(base_rounds, 2, avoid=set(map(tuple, three_pool)))
# 클러스터 방지: 3G = min_gap 6, 2G = min_gap 4
three_slots, last_after_3 = schedule_with_min_gap_fixed(three_pool, 6, prev_last_opp=None)
two_slots, _ = schedule_with_min_gap_fixed(two_pool, 4, prev_last_opp=last_after_3)
slot_types = ["3G"] * len(three_slots) + ["2G"] * len(two_slots)
series_len = [3] * len(three_slots) + [2] * len(two_slots)
all_slots = three_slots + two_slots
# 홈/원정 8/8을 위해 시리즈 단위 쿼터 관리
pair_q3 = {}
pair_q2 = {}
for a in range(N):
for b in range(a + 1, N):
pair_q3[(a, b)] = {a: 2, b: 2} # 3연전 시리즈 4번 → 각 2번씩 홈
pair_q2[(a, b)] = {a: 1, b: 1} # 2연전 시리즈 2번 → 각 1번씩 홈
series_by_slot = defaultdict(list) # slot_idx -> [(home_idx, away_idx), ...]
series_rows = []
for slot_idx, (pairs, ptype, slen) in enumerate(zip(all_slots, slot_types, series_len), start=1):
for a, b in pairs:
key = (min(a, b), max(a, b))
if ptype == "3G":
if pair_q3[key][a] > pair_q3[key][b]:
host = a
elif pair_q3[key][b] > pair_q3[key][a]:
host = b
else:
host = random.choice([a, b])
pair_q3[key][host] -= 1
else:
if pair_q2[key][a] > pair_q2[key][b]:
host = a
elif pair_q2[key][b] > pair_q2[key][a]:
host = b
else:
host = random.choice([a, b])
pair_q2[key][host] -= 1
away = b if host == a else a
series_by_slot[slot_idx].append((host, away))
series_rows.append({
"slot": slot_idx,
"ptype": ptype,
"length": slen,
"home_idx": host,
"away_idx": away,
"home": IDX_TEAM[host],
"away": IDX_TEAM[away],
})
# 팀별 홈/원정 검증 (총 72/72)
home_tot = Counter()
away_tot = Counter()
for s in series_rows:
slen = s["length"]
home_tot[s["home"]] += slen
away_tot[s["away"]] += slen
for t in TEAMS:
if not (home_tot[t] == 72 and away_tot[t] == 72):
raise RuntimeError(f"{t} 홈/원정 합 72/72 아님: home={home_tot[t]}, away={away_tot[t]}")
# 시리즈 → 일(day) 단위로 확장
days = [] # days[d] = [(home_idx, away_idx), ...] 하루 5경기
for slot_idx, (pairs, ptype, slen) in enumerate(zip(all_slots, slot_types, series_len), start=1):
games = series_by_slot[slot_idx] # 이 슬롯의 5매치업
for _ in range(slen): # 3연전이면 3일, 2연전이면 2일
days.append(list(games))
# 3연전 블록: 36 슬롯 * 3일 = 108일 → 6일 × 18주
# 2연전 블록: 18 슬롯 * 2일 = 36일 → 4일 × 9주
days_3g = days[:36 * 3]
days_2g = days[36 * 3:]
weeks = [] # 27주
# WEEK 1~18 (3G, 6일)
for w in range(18):
week_days = days_3g[w * 6:(w + 1) * 6]
weeks.append({
"week": w + 1,
"type": "3G",
"days": week_days,
})
# WEEK 19~27 (2G, 4일)
for j in range(9):
week_days = days_2g[j * 4:(j + 1) * 4]
weeks.append({
"week": 19 + j,
"type": "2G",
"days": week_days,
})
return weeks
# =========================
# 5. 출력 유틸 (Markdown / CSV)
# =========================
def export_markdown(weeks, season, filename=None):
if filename is None:
filename = f"KBO_{season}_vs_27weeks_SPREAD.md"
lines = ["#### 정규시즌"]
for w in weeks:
lines.append(f"##### WEEK {w['week']}")
for day_games in w["days"]:
for home_idx, away_idx in day_games:
home = IDX_TEAM[home_idx]
away = IDX_TEAM[away_idx]
lines.append(f"{ABBR[home]} vs {ABBR[away]}")
lines.append("") # 일자 사이 빈 줄
text = "\n".join(lines).strip() + "\n"
with open(filename, "w", encoding="utf-8") as f:
f.write(text)
return filename
def export_csv(weeks, season, filename=None):
if filename is None:
filename = f"KBO_{season}_vs_27weeks_SPREAD.csv"
with open(filename, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["Season", "Week", "DayInWeek", "Type", "Home", "Away"])
for w in weeks:
week_no = w["week"]
wtype = "3G week" if w["type"] == "3G" else "2G week"
for d, day_games in enumerate(w["days"], start=1):
for home_idx, away_idx in day_games:
home = IDX_TEAM[home_idx]
away = IDX_TEAM[away_idx]
writer.writerow([season, week_no, d, wtype, home, away])
return filename
def print_week_preview(weeks, week_no):
print(f"##### WEEK {week_no}")
w = next(w for w in weeks if w["week"] == week_no)
for day_games in w["days"]:
for home_idx, away_idx in day_games:
home = IDX_TEAM[home_idx]
away = IDX_TEAM[away_idx]
print(f"{ABBR[home]} vs {ABBR[away]}")
print("")
# =========================
# Main: 2034 시즌 생성 & 저장
# =========================
if __name__ == "__main__":
SEASON = 2034
weeks = generate_season_2034(seed=2034_777)
# 마크다운 / CSV 파일로 저장
md_file = export_markdown(weeks, SEASON)
csv_file = export_csv(weeks, SEASON)
print(f"생성 완료! markdown: {md_file}, csv: {csv_file}")
print()
print_week_preview(weeks, 1) # WEEK 1 프리뷰
print_week_preview(weeks, 19) # WEEK 19 프리뷰 (2연전 블록 시작)
To embed this project on your website, copy the following code and paste it into your website's HTML: