TT Lab
开始
学习 学习路径 课程

打造好服务的计算机科学 — 用测量重新学习教科书概念

用 360 个昵称敲定长度、截断、相等与日期

在 TT Lab 中继续学习

目标

用昵称列表分别统计 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 전체가 아니다).

步骤

  1. 在 /root/svccs/text/lengths.json 中写入 names.csv 昵称的 rows(行数)、utf8_bytes(UTF-8 字节总和)、code_points(码位总和)、rows_multibyte(字节数 ≠ 码位数的行数)、max_utf8_bytes(单行的最大字节数)。
  2. 在 /root/svccs/text/textkit.py 中编写 graphemes(s)——返回按上面的“字符规则”切分出的字符串列表(拼接起来就是 s)。并把 graphemes(字符数总和)、rows_cp_ne_graphemes(码位数 ≠ 字符数的行数)、longest_cluster_cp(单个字符包含的码位数的最大值)写入 /root/svccs/text/graphemes.json。
  3. 在同一个文件里编写 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。
  4. 在同一个文件里编写 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))。
  5. 在同一个文件里编写 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。
  6. 在同一个文件里编写 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。
  7. 在同一个文件里编写 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。
  8. 在同一个文件里编写 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 으로만 쓰세요.

参考

字节和码位是不同的

把 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,同一天进来的两份申请就会都通过。