用 360 个昵称敲定长度、截断、相等与日期
目标
用昵称列表分别统计 UTF-8 字节数、码位数和人眼所见的字符数,按字节上限和字符上限不破坏字符地截断,并用规范化和大小写折叠统计用户名冲突。接着把 UTC 事件按用户当地日期汇总,把跨越夏令时的“每天 09:00”预约换成 UTC 时刻,最后把一切汇总成一个注册审核函数。
为什么重要
如果界面、服务器、存储层统计的长度各不相同,“最多 6 个字”就会变成三条规则。按字节截断会破坏字符,按码位截断,家庭 emoji 就只剩下一个人,重音符号也会掉落。相同的名字,必须先决定把规范等价(NFC)、兼容等价(NFKC)、大小写折叠(casefold)中的哪一种视为相同,才能数成一个。日期也一样,如果不确定“是哪里的今天”,每日数字就会漏到相邻的日期。所以评分器不只看你写下的数字,而是把你的函数放到不在材料里的输入上重新运行,与参考实现对照。
材料
位于 /opt/fixtures/svccs/text/ 之下。只读取,不要修改。CSV 都是 UTF-8,第一行是表头(请用 csv 模块并指定 newline="" 来读取)。
names.csv user_id,nickname 기존 회원 닉네임 360줄(완성형·조합형 한글, 결합 문자, 이모지, 전각, ß …)
signups.csv row,nickname 새 가입 신청 80줄. 빈 칸도 있다
users.csv user_id,tz 이벤트를 낸 사용자와 IANA 시간대 이름
events.csv event_id,user_id,ts_utc UTC 로 찍힌 이벤트. ts_utc 는 2026-03-07T23:40:00Z 꼴
params.json byte_limit · max_graphemes · schedule{zones, start, days, local_time}
字符规则(本实验的简化规则)
문자열을 앞에서부터 코드 포인트 하나씩 본다. 맨 앞 코드 포인트는 새 글자를 시작한다.
그 뒤로는 아래 가운데 하나라도 맞으면 앞 글자에 붙이고, 아니면 새 글자를 시작한다.
1. 지금 코드 포인트가 결합 표시(unicodedata.category 가 Mn 또는 Me)·변형 선택자
(U+FE00~U+FE0F)·피부색 수식자(U+1F3FB~U+1F3FF)·ZWJ(U+200D) 가운데 하나
2. 바로 앞 코드 포인트가 ZWJ
3. 한글 묶음 — 앞이 L 이고 지금이 L·V·LV·LVT / 앞이 V 나 LV 이고 지금이 V·T /
앞이 T 나 LVT 이고 지금이 T
L = U+1100~U+115F, V = U+1160~U+11A7, T = U+11A8~U+11FF,
완성형 U+AC00~U+D7A3 은 (코드 − 0xAC00) % 28 == 0 이면 LV, 아니면 LVT
국기(지역 표시 문자 쌍)·Prepend·SpacingMark 는 다루지 않는다(UAX #29 전체가 아니다).
步骤
- 在
/root/svccs/text/lengths.json中写入names.csv昵称的rows(行数)、utf8_bytes(UTF-8 字节总和)、code_points(码位总和)、rows_multibyte(字节数 ≠ 码位数的行数)、max_utf8_bytes(单行的最大字节数)。 - 在
/root/svccs/text/textkit.py中编写graphemes(s)——返回按上面的“字符规则”切分出的字符串列表(拼接起来就是 s)。并把graphemes(字符数总和)、rows_cp_ne_graphemes(码位数 ≠ 字符数的行数)、longest_cluster_cp(单个字符包含的码位数的最大值)写入/root/svccs/text/graphemes.json。 - 在同一个文件里编写
cut_bytes(s, limit)——在不拆开码位的前提下,UTF-8 不超过 limit 字节的最长前缀。把params.json的byte_limit记作 L,把byte_limit、rows_over_limit(字节数超过 L 的行数)、rows_naive_broken(对s.encode("utf-8")[:L]做严格解码时会抛出 UnicodeDecodeError 的行数)、utf8_bytes_after_cut(对所有行执行cut_bytes(s, L)之后的字节总和)写入/root/svccs/text/cut.json。 - 在同一个文件里编写
fit(s, max_bytes, max_graphemes)——只整个地放入字符(第 2 步的规则),同时遵守字符数 ≤ max_graphemes、字节数 ≤ max_bytes 的最长前缀。令 B =byte_limit,G =max_graphemes,把max_bytes、max_graphemes、rows_changed(fit的结果与原文不同的行数)、split_by_cut_bytes(cut_bytes(s, B)的末尾不在字符边界上的行数)、split_by_cp_slice(s[:G]的末尾不在字符边界上的行数)写入/root/svccs/text/fit.json。字符边界 = 把各个字符从前往后拼接时出现的码位位置(包含 0 和 len(s))。 - 在同一个文件里编写
username_key(s)=NFKC(casefold(NFKC(s)))。把互不相同的个数distinct_raw(原文)、distinct_nfc(NFC)、distinct_nfc_lower(NFC 之后 lower)、distinct_nfc_casefold(NFC 之后 casefold)、distinct_key(username_key),以及collision_pairs(拥有相同键的行之间所能组成的对数,对每个键累加 n·(n−1)/2)、collision_groups(拥有相同键的 user_id 列表,只保留 2 个及以上的)写入/root/svccs/text/unique.json。 - 在同一个文件里编写
local_date(ts_utc, tz)——把events.csv形式的 UTC 时刻转换成 IANA 时区 tz 的当地日期"YYYY-MM-DD"。把每个事件按users.csv中该用户的时区汇总,把events(行数)、by_local_date(当地日期 → 事件数)、by_utc_date(ts_utc 的前 10 个字符 → 事件数)、events_on_other_date(当地日期 ≠ UTC 日期的事件数)写入/root/svccs/text/daily.json。 - 在同一个文件里编写
daily_at(tz, start_date, days, hhmm)——从 start_date("YYYY-MM-DD")起的 days 天里,每天当地时间 hhmm("09:00"形式)的时刻,以"YYYY-MM-DDTHH:MM:SSZ"(UTC)的列表返回。用params.json的schedule,对每个时区把{"utc": 목록, "gaps_hours": 이웃한 두 순간의 실제 간격(시간, 넷째 자리) 목록, "short_days": 24 미만인 간격 수, "long_days": 24 초과인 간격 수}(utc 是列表;gaps_hours 是相邻两个时刻之间的实际间隔列表,单位为小时,保留四位小数;short_days 是小于 24 的间隔个数;long_days 是大于 24 的间隔个数)写入/root/svccs/text/schedule.json。 - 在同一个文件里编写
check_signup(nickname, taken, max_bytes, max_graphemes),按下面的“注册规则”依次审核signups.csv,把各状态的个数ok、taken、too_many_chars、too_many_bytes、empty以及按行顺序的verdicts列表写入/root/svccs/text/signups.json(上限为byte_limit、max_graphemes)。
注册规则
shown = NFC(nickname) 저장·표시는 NFC 로 한다
1. shown 이 공백(str.isspace)·ZWJ·규칙 1의 결합류로만 되어 있거나 비었으면 "empty"
2. graphemes(shown) 의 개수가 max_graphemes 를 넘으면 "too_many_chars"
3. shown 의 UTF-8 바이트가 max_bytes 를 넘으면 "too_many_bytes"
4. username_key(nickname) 이 taken 안에 있으면 "taken"
5. 아니면 "ok"
taken 은 처음에 names.csv 모든 닉네임의 키이고, "ok" 를 받은 신청의 키가 차례로 더해진다.
채점기는 taken 을 목록(list)으로 넘길 수도 있다 — in 으로만 쓰세요.
参考
- 评分器会导入
textkit.py。读取文件并生成结果的代码,请放在单独的脚本里,或者用python3 - <<'PY'。 - 字节数是
len(s.encode("utf-8")),码位数是len(s)。规范化用unicodedata.normalize("NFC", s),时区用zoneinfo.ZoneInfo(tz)和astimezone。 - 常见错误:把
len()当作字节数写下;把字节切片用errors="replace"解码,导致末尾留下 U+FFFD;不做 NFC 就判断重复;按 UTC 日期做每日汇总;在 UTC 时刻上加timedelta(hours=24)来生成预约;对同一个 tzinfo 的 aware datetime 相减,却写下间隔总是 24 小时。 - 产出会在会话结束后消失。需要的话请另行保存。
字节和码位是不同的
把 names.csv 昵称的行数、UTF-8 字节总和、码位总和、两者不同的行数、最大字节数,以 rows、utf8_bytes、code_points、rows_multibyte、max_utf8_bytes 写入 /root/svccs/text/lengths.json。
Python 的 str 是码位的序列,所以 len() 是码位数。字节数是 s.encode("utf-8") 的长度。韩文预组合形的一个音节是 3 字节,ASCII 是 1 字节。
数出人眼所见的字符
在 /root/svccs/text/textkit.py 中按“字符规则”编写 graphemes(s),并把 graphemes、rows_cp_ne_graphemes、longest_cluster_cp 写入 /root/svccs/text/graphemes.json。评分器会直接调用这个函数,用混有组合字符、ZWJ、字母的变形字符串对照。
只需要决定当前码位是否附着在前一个字符上。unicodedata.combining() 对变体选择符(U+FE0F)和 ZWJ 返回 0,所以仅凭它是不够的——要同时看 category 和范围。L 类字母之后的 V 类字母会附着,而收音(T 类)之后的 L 类字母是新的字符。
按字节上限截断,但不破坏字符
在 textkit.py 中编写 cut_bytes(s, limit),并用 byte_limit 把 byte_limit、rows_over_limit、rows_naive_broken、utf8_bytes_after_cut 写入 /root/svccs/text/cut.json。评分器会用变形字符串和多种上限调用 cut_bytes。
UTF-8 中一个码位占 1–4 字节,后续字节都是 10xxxxxx 的形式。逐个放入码位并累加字节数,在即将超过时停下来即可。按字节截断后再用 errors="replace" 读取,末尾会留下 U+FFFD。
按字符为单位截断
在 textkit.py 中编写 fit(s, max_bytes, max_graphemes),并把 max_bytes、max_graphemes、rows_changed、split_by_cut_bytes、split_by_cp_slice 写入 /root/svccs/text/fit.json。评分器会用变形字符串和多种上限调用 fit。
把用 graphemes(s) 切分出的字符逐个放入,在字符数和字节数两个上限中任意一个即将被超过时停下。即使是按码位安全截断的结果,也可能让家庭 emoji 只剩下一个人,或者把重音符号摘掉——这样的行数就是 split_by_cut_bytes。
确定什么叫相同的名字
在 textkit.py 中编写 username_key(s) = NFKC(casefold(NFKC(s))),并把 distinct_raw、distinct_nfc、distinct_nfc_lower、distinct_nfc_casefold、distinct_key、collision_pairs、collision_groups 写入 /root/svccs/text/unique.json。评分器也会用变形字符串调用 username_key。
NFC 对齐预组合形和字母分解形,casefold 把 ß 折叠为 ss,NFKC 对齐全角字符、连字、开尔文符号之类的兼容字符。每叠加一条规则,互不相同的个数都应该减少。对数是:每 n 个相同的键对应 n·(n−1)/2。
把 UTC 事件按当地日期汇总
在 textkit.py 中编写 local_date(ts_utc, tz),把 events.csv 按用户时区的日期汇总,把 events、by_local_date、by_utc_date、events_on_other_date 写入 /root/svccs/text/daily.json。评分器会用其他时区和日期的变形事件调用 local_date。
把 ts_utc 读成 UTC 的 aware datetime(末尾的 Z 就是 UTC),用 astimezone(ZoneInfo(tz)) 转换后,看 .date()。首尔比 UTC 早,所以如果按 UTC 日期汇总,首尔的清晨就会被算到前一天。
把每天 09:00 的当地预约换成 UTC
在 textkit.py 中编写 daily_at(tz, start_date, days, hhmm),并用 params.json 的 schedule,为每个时区把 utc、gaps_hours、short_days、long_days 写入 /root/svccs/text/schedule.json。评分器会用其他时区和期间调用 daily_at。
如果把第一个时刻换成 UTC,再每次加 24 小时,在夏令时转换之后,当地时间就会偏移一小时。要为每个当地日期重新生成 hhmm,再换成 UTC。间隔要用 UTC 时刻相减——如果对持有同一个 ZoneInfo 的 aware datetime 相减,tzinfo 会被忽略,得到的总是 24 小时。
汇总为一个注册审核函数
在 textkit.py 中按“注册规则”编写 check_signup(nickname, taken, max_bytes, max_graphemes),依次审核 signups.csv,把 ok、taken、too_many_chars、too_many_bytes、empty、verdicts 写入 /root/svccs/text/signups.json。评分器也会用变形申请和 taken 列表调用 check_signup。
判定顺序本身就是规则的一部分。字节上限要在转换为 NFC 之后再测量——以分解形送来的韩文,按原文直接测量会变成三倍。如果不把已受理申请的键加进 taken,同一天进来的两份申请就会都通过。