Approach (brief):- Sort events by timestamp (O(n log n)).- Iterate calendar months from first event month to last event month.- Maintain an active set of user_ids by applying events strictly before the month start to get active users-at-start.- For each month, count cancel events whose timestamps are within that month; after counting, apply those events so later months see updated active set.- Return churn per month = cancellations_in_month / active_at_start (use 0 or None when active_at_start==0; here we return 0.0).python
from datetime import datetime, timedelta
from collections import defaultdict
def month_start(dt):
return datetime(dt.year, dt.month, 1)
def next_month(dt):
# return first day of next month
y, m = dt.year, dt.month
if m == 12:
return datetime(y+1, 1, 1)
return datetime(y, m+1, 1)
def parse_time(t):
# accept datetime or ISO string
if isinstance(t, datetime):
return t
return datetime.fromisoformat(t)
def compute_monthly_churn(subscriptions):
"""
subscriptions: list of (user_id, event_type, event_time)
event_type: 'start' or 'cancel'
event_time: datetime or ISO string
Returns: dict mapping YYYY-MM (e.g. '2023-07') -> churn_rate (float)
"""
# normalize and sort events
events = [(uid, etype, parse_time(t)) for uid, etype, t in subscriptions]
if not events:
return {}
events.sort(key=lambda x: x[2])
# calendar range
first_month = month_start(events[0][2])
last_month = month_start(events[-1][2])
# pointers and state
i = 0
n = len(events)
active = set()
result = {}
cur_month = first_month
while cur_month <= last_month:
start = cur_month
end = next_month(cur_month)
# apply events strictly before month start to build active-at-start
while i < n and events[i][2] < start:
uid, etype, _ = events[i]
if etype == 'start':
active.add(uid)
elif etype == 'cancel':
active.discard(uid)
i += 1
active_at_start = len(active)
# count cancellations during the month; also apply events in-month for next months
cancels = 0
j = i
while j < n and events[j][2] < end:
uid, etype, _ = events[j]
if etype == 'cancel':
cancels += 1
active.discard(uid)
elif etype == 'start':
active.add(uid)
j += 1
# churn definition: cancellations during month / active users at start of that month
churn = 0.0
if active_at_start > 0:
churn = cancels / active_at_start
# store with YYYY-MM key
key = f"{start.year:04d}-{start.month:02d}"
result[key] = churn
# advance pointer and month
i = j
cur_month = end
return result
Key points:- Sorting dominates complexity: O(n log n). Single pass over events across months is O(n) extra.- Space: O(u) for active set (u = unique users) plus O(n) for sorted events.Edge cases:- Multiple starts/cancels per user: code processes sequentially; last state determines activity.- Cancel without prior start: cancel simply discards from active (safe).- Events exactly at month boundary: events at timestamp == month_start are considered "in-month" (since we only applied events with time < month_start to compute active_at_start). Adjust if business rule differs.- No active users at start: we return 0.0 (could return None or NaN depending on product choice).- Timezones: assumes naive datetimes or consistent timezone-aware datetimes; convert to a common timezone before use.- Sparse months with no events between first and last: we still emit churn=0.0 for those months.