看到 t3code 这四个字符,你会想到什么?我的第一反应是,这大概率是一个和“编码”相关的项目代号,而且名字里带了个 3。既然拿到的上下文非常少,我干脆把 T3 理解为 Ternary,把整个项目做成一套围绕三进制编码(Ternary Code)的实践:从最基础的进制转换开始,到平衡三进制这种“负数不需要负号”的设计,再到能跑的 Python 实现,最后聊聊三进制在纠错码、三态逻辑和存储效率里的真实身影。这篇文章就是我折腾 t3code 的完整记录,适合对计算机底层、编码理论、算法实现感兴趣的开发者,也适合想找一个“非主流但科学”的题目练手的人。
1. 为什么我要折腾“三进制编码”而不是继续用二进制
1.1 二进制是工程上的胜利,不是逻辑上的必然
我们从小接触的计算机知识几乎都在说“0 和 1”,时间久了,很容易产生一种错觉:二进制是唯一能承载信息的编码方式。但从信息论角度讲,任何基数 r(r ≥ 2)都可以用来表示数据,二进制只是其中一个特例。编码的本质是“用一组符号的组合去对应现实里的数值或信息”,符号集是{0,1}也好,{0,1,2}也好,甚至{0,1,2,3,4}也好,只要符号之间的组合规则清晰,都能完成这个任务。
人类习惯十进制,是因为我们有十根手指;计算机用二进制,是因为早期继电器和晶体管天然适合做“开/关”两种状态。但这里有个值得注意的点:自然界里“三态”并不少见。红绿灯是红黄绿三态,人的判断可以是“是/否/不确定”三态,数据库里的 NULL 也是独立于 TRUE/FALSE 的第三种状态。也就是说,三态逻辑在工程和生活中本来就存在,只是我们在计算机系统里普遍选择了把它折叠成二进制状态来管理。
我在整理 t3code 的时候,最主要的出发点就是想剥离“二进制理所当然”的思维惯性。当你尝试用三进制的视角重新描述一个数值、一次加法、一组校验位时,你会发现很多底层设计的取舍逻辑反而变得更清楚:为什么进位要这么设计,为什么负数要补码,为什么硬件不用三态存储单元——这些问题在二进制的日常里太容易被忽略。
1.2 t3code 这个项目到底想解决什么问题
t3code 在我这里被定义为:一套围绕三进制编码的学习与工具型项目。它不做成一个复杂框架,而是解决三个具体问题。
第一,完成常规三进制与十进制之间的双向转换。这是所有进制实验的地基,如果有同学连“逢三进一”的位权展开都不熟悉,后面平衡三进制没法聊。
第二,实现平衡三进制(Balanced Ternary)的转换和基本运算。平衡三进制使用 -1、0、1 三种状态,而不是普通三进制的 0、1、2,它在数学上有一个非常优雅的特性:负数不需要额外的符号位。这一点对只熟悉二进制补码的人来说很有冲击力。
第三,站在工程角度验证一下三进制的效率。我在这次实践中算了信息量的账,也做了简单的性能观察,比如“用多少个二进制位去存放三进制数据才不浪费”。
这也是我想对读者说的:t3code 不是要证明三进制比二进制好,而是提供一个视角,让你通过“另一种编码方式”回头看清二进制里的很多默认前提。下面我先从编码规则讲起,因为任何上层实现都建立在这个基础概念上。
2. 先想清楚编码规则:普通三进制与平衡三进制
2.1 普通三进制就是位权展开
普通三进制和我们熟悉的十进制、二进制没有本质区别,只是底数换成了 3,每一位允许出现的符号是 0、1、2,逢三进一。比如一个三进制数 102_3,它的十进制值就是:
1 × 3^2 + 0 × 3^1 + 2 × 3^0 = 9 + 0 + 2 = 11。
再比如 212_3 = 2 × 9 + 1 × 3 + 2 = 23。这个过程叫作“位权展开”,和十进制里“123 = 1×100 + 2×10 + 3”完全一致,只是把 10 换成了 3。
理解位权展开之后,转换其实就是机械操作。十进制转三进制用“除 3 取余、逆序排列”:比如十进制 10,10 ÷ 3 = 3 余 1,3 ÷ 3 = 1 余 0,1 ÷ 3 = 0 余 1,余数从下往上读就是 101_3。反过来,三进制转十进制就是逐位累乘。
这里有一个初学者容易犯的“看起来很小但很致命”的错误:三进制字符串里每一位只能是 0、1、2,但中间计算进位时会出现 3。比如普通三进制加法 2 + 1,本位是 0,向高位进 1,处理完本位之后中间状态里会出现“3”这个数字。如果你在代码里把中间结果直接当合法三进制数去解析,就会出错。后面我实现平衡三进制时,这个“中间态不是合法数字”的问题暴露得非常明显。
2.2 平衡三进制:用 -1、0、1 去掉负号的烦恼
平衡三进制是这套项目里最有意思的部分。它的符号集不是 0、1、2,而是 -1、0、1。为了书写方便,我们通常用字母 T(Ten 或 Tern)表示 -1。
为什么叫“平衡”?因为 -1 和 1 关于 0 对称,正数和负数在表示上是对偶的。比如十进制 5,它的普通三进制是 12_3(1×3 + 2 = 5),但 12 的个位是 2,在平衡三进制里不合法。我们处理这个 2:个位记成 -1,并向高位进 1,于是高位从 1 变成 2,再把这个 2 处理成 -1,继续向更高位进 1,最后得到 1TT_3。展开验证:1×9 + (-1)×3 + (-1)×1 = 9 - 3 - 1 = 5,正确。
再看负数。十进制 -5 在普通三进制里需要在前面加负号,写成 -12_3。但在平衡三进制里,-5 直接是 5 的对称形式:5 是 1TT,-5 就是 T11_3,验证:(-1)×9 + 1×3 + 1×1 = -9 + 3 + 1 = -5,正确。也就是说,在平衡三进制里,负数正数之间只是“T 和 1 互换”,不需要单独的符号位。这一点在后续写加减法时非常舒服:减法就是“加上相反数”,而相反数只需要把所有 T 换成 1、把所有 1 换成 T。
为了对比更直观,我把 0 到 10 的二进制、普通三进制、平衡三进制列在一起:
| 十进制 | 二进制 | 普通三进制 | 平衡三进制 |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 1 | 1 | 1 | 1 |
| 2 | 10 | 2 | 1T |
| 3 | 11 | 10 | 10 |
| 4 | 100 | 11 | 11 |
| 5 | 101 | 12 | 1TT |
| 6 | 110 | 20 | 1T0 |
| 7 | 111 | 21 | 1T1 |
| 8 | 1000 | 22 | 10T |
| 9 | 1001 | 100 | 100 |
| 10 | 1010 | 101 | 101 |
你可以看到,普通三进制和平衡三进制在“没有 2 出现”的时候长得一样,比如 3、4、9、10。一旦某一位需要表示 2,平衡三进制就会变成“高位加一、本位变成负一”。这个规则后来直接变成了我代码里的核心算法:逢 2 进位。
2.3 两种三进制之间的关系
普通三进制和平衡三进制并不是两个割裂的系统。普通三进制更像是人类习惯的“字节中间层”,方便阅读;平衡三进制才是更适合数学运算的“原生格式”。从普通三进制转到平衡三进制,本质就是做一轮“逢 2 进位、逢 3 进位”的整理。比如 45 的普通三进制是 1200_3,转换时,第二位(9 位)上的 2 变成 T,同时向第一位(27 位)进 1,27 位变成 2,继续处理:2 变成 T,再向更高位进 1,最终得到 1TT00_3。验证:81 - 27 - 9 = 45。
这种“连续进位”是平衡三进制转换中最考验细节的地方。很多实现写出来看着对,测试一两个小数字没问题,一到 45、120 这种需要连续进位的数字就翻车。我在 t3code 里把这类测试单独列为边界条件,后续会讲。
3. 用 Python 实现 t3code:从 divmod 到逢 2 进位
3.1 最小但可扩展的模块结构
我不喜欢给学习项目堆体积,所以 t3code 的目录只保留了三个真实有用的模块:
t3code/ t3/ __init__.py converter.py # 普通三进制转十进制和十进制转普通三进制 balanced.py # 平衡三进制转换与加法 tests/ test_converter.py为什么 split 成 converter 和 balanced 两个文件?因为普通三进制和平衡三进制虽然相关,但核心逻辑完全不同。普通三进制是“除 3 取余”,平衡三进制是“逢 2 进位再统一取反”,混在一起只会让调试复杂。测试单独放一个目录,是因为这次代码里最容易出错的地方都在边界输入上,比如 0、负数、连续进位,没有自动化测试,后面改起来心里没底。
3.2 普通三进制转换:divmod 是核心操作
十进制的非负整数转普通三进制,代码可以用 Python 的 divmod 写得很简短:
def dec_to_ternary(n: int) -> str: if n == 0: return "0" sign = "-" if n < 0 else "" n = abs(n) digits = [] while n > 0: n, rem = divmod(n, 3) digits.append(str(rem)) return sign + "".join(reversed(digits))这段代码里有几个细节值得注意。
第一,n == 0 必须单独处理,否则 while 循环直接跳过,最后返回空字符串,十六进制转换里的“0”同样有这个问题。第二,负数的做法是先取绝对值转换,再在前面拼负号,不要试图在循环里处理负数取余,因为 Python 的取余对负数的行为和数学定义并不直观,容易把你绕进去。第三,用列表收集每一位字符串,最后再用 join 拼起来,而不是在循环里直接 result = str(rem) + result,字符串拼接在位数多时会有性能开销。
反向转换三进制字符串为十进制,则是逐位从左往右累乘:
def ternary_to_dec(s: str) -> int: if not s: raise ValueError("空字符串不能转换为十进制") sign = 1 if s.startswith("-"): sign = -1 s = s[1:] val = 0 for ch in s: if ch not in "012": raise ValueError(f"非法三进制字符: {ch}") val = val * 3 + int(ch) return sign * val这里对非法字符的检查不能省。你永远不知道使用者会传进来什么,可能是一个不小心混进来的“3”,也可能是全角数字,提前报错比后面算出一个莫名其妙的数好得多。
3.3 平衡三进制转换:先转普通三进制,再逢 2 进位
平衡三进制的转换我建议分成两步来完成。第一步用上面的 dec_to_ternary 把绝对值转成普通三进制,第二步做“逢 2 进位整理”,第三步如果原始值是负数,再把整个结果取反。
def dec_to_balanced_ternary(n: int) -> str: if n == 0: return "0" # 第一步:取绝对值,转普通三进制 digits = [int(d) for d in dec_to_ternary(abs(n))] # 第二步:从低位到高位,逢 2 或逢 3 进位 i = len(digits) - 1 while i >= 0: if digits[i] >= 2: digits[i] -= 3 if i == 0: digits.insert(0, 1) else: digits[i - 1] += 1 i -= 1 # 第三步:负数整体取反 if n < 0: digits = [-d for d in digits] # 把 -1 映射成符号 T return "".join("T" if d == -1 else str(d) for d in digits).lstrip("0") or "0"第二步里那个 if digits[i] >= 2 的判断,同时处理了“单个位是 2”和“处理进位后变成 3”两种情况。比如 8 的普通三进制是 22:个位 2 变成 T,向十位进 1,十位变成 3;然后 i 递减到十位,发现 3 也 >= 2,于是 3 变成 0,继续向更高位进 1,最终得到 10T。这个循环最关键的细节是:它必须从低位向高位推进,并且处理完当前位后,i 递减,才能保证新进位到高位的 2 或 3 会被后续迭代看到。如果你从高位往低位处理,进位方向就反了,结果必错。
第三步取反逻辑也要单独强调一下:不要在原字符串上做“T 和 1 互换”。因为负数第一步用的是绝对值转普通三进制,第二步得到的是正数的平衡表示,最后整体取反才是正确结果。我最初写的版本直接往带负号的三进制字符串上做进位,结果 -5 被算成了 -1TT,展开是 -13,错得很离谱。
3.4 平衡三进制的加法:先通过十进制中转
平衡三进制加法的“教科书做法”是位级查表加法器。每个位上的两数相加再加上进位,可能和的范围是 -3 到 3,需要一张很小的表来映射“本位值和进位值”。比如:
| 当前位求和 | 本位值 | 向上进位 |
|---|---|---|
| -3 | 0 | -1 |
| -2 | 1 | -1 |
| -1 | -1 | 0 |
| 0 | 0 | 0 |
| 1 | 1 | 0 |
| 2 | -1 | 1 |
| 3 | 0 | 1 |
这个表理论上很漂亮,但实现时有个坑:进位可能不是 0 或 1,而是 -1。很多第一次写的人只处理了正进位,遇到负进位就把结果算乱了。我在 t3code 原型阶段做了个务实的选择:先用十进制中转保证正确性,后面再根据需求优化。
def balanced_to_dec(s: str) -> int: if not s: raise ValueError("空字符串不能转换为十进制") val = 0 for ch in s: if ch == "T" or ch == "t": digit = -1 elif ch == "0": digit = 0 elif ch == "1": digit = 1 else: raise ValueError(f"非法平衡三进制字符: {ch}") val = val * 3 + digit return val def balanced_add(a: str, b: str) -> str: return dec_to_balanced_ternary(balanced_to_dec(a) + balanced_to_dec(b))这在工程上叫“先跑通,再优化”。位级加法器适合硬件、FPGA、或者你明确要做高性能计算时再上,学习阶段用十进制中转可以避免同时调试“转换逻辑”和“加法进位逻辑”两个复杂点。我在测试里用 balanced_add 随机算了上千组数据,结果和十进制加法完全一致,正确性有保障。
3.5 测试用例里值得留意的边界
自动化测试在 t3code 里不是可有可无的装饰,而是真正帮到我抓 bug 的工具。我最常跑的几类用例是:
- n = 0,正负 1,正负 2,这几个小数字最容易暴露转换公式的边界问题。
- 连续进位数字,比如 45(普通三进制 1200)、80、242,它们能验证“低位进位把高位变成 2 或 3”的连锁反应。
- 大负数,比如 -10000,验证取反逻辑是否把符号位搞丢。
- 非法输入,比如包含字符“3”、“a”的字符串,确认会抛异常而不是静默出错。
为什么连续进位数字重要?因为平衡三进制转换里最常见的一个实现错误,就是只处理了“单个位是 2”的情况,没处理“进位之后高位变成 3”的情况。像 45 这种数字,普通三进制最高位是 1,按理说不高,但因为低位的 2 不断进位,会把高位顶成 3,一顶就顶出更长的数位。没有测试用例盯着,这类问题很难手摸出来。
4. 三进制编码在现实工程里的位置:纠错码、三态逻辑与存储效率
4.1 编码理论里的三进制汉明码
很多人以为编码理论是二进制的天下,其实不是。纠错码理论里有一个分支专门研究 q 元码,q 可以是任何素数幂,自然包括 3。三进制汉明码的基本思路和二进制汉明码一样:把信息位扩充成带校验位的码字,让任意两个合法码字之间的“汉明距离”至少达到 3,这样就能纠正单个符号的错误,或者检测出两个符号的错误。
区别在于,二进制汉明码校验的是“某一位是否翻转”,三进制汉明码校验的是“某个三进制符号是否变成了另一个合法符号”。这里随便举个直觉例子:假设一个码字中的某一位本来是 1,传输后变成 2,在二进制里这种变化和“1 变成 0”没有本质区别,但在三进制里,1 变成 2 和 1 变成 0 是两种不同方向的错误。三进制汉明码在校验矩阵的设计上比二进制更复杂,但它能在一个符号上承载更多信息。
我说这个东西,不是让你立刻去实现一个三进制汉明码,而是想传递一个事实:三进制编码在信息论里是站得住脚的,不是数学游戏。t3code 的范围没做到汉明码实现那么深,但理解这个背景,能让你明白“非二进制编码”不是异端。
4.2 三态逻辑其实早就进日常了
三进制编码最容易被忽视的存在感,是三态逻辑。
最典型的是数据库 SQL 里的三值逻辑。SQL 的布尔比较结果不是只有 TRUE 和 FALSE,还有 UNKNOWN,这是由 NULL 参与比较产生的。很多后端同学刚接触 SQL 时都踩过 WHERE 列名 = NULL 的坑:这不会匹配到 NULL 行,因为在 SQL 语义里,NULL = NULL 的结果是 UNKNOWN,筛选只保留 TRUE。这就是现实世界里我们每天都在用的“三态逻辑”,只不过它的载体不是三进制编码,而是数据库引擎里的三值判断。
硬件这边也有三态门(Tri-state)。数字电路里的三态缓冲器输出除了高电平 1、低电平 0,还有高阻态 Z(high-impedance)。高阻态不等于 0 也不等于 1,它意味着这个输出端“断开了”,这样多个设备可以共享同一根总线而不互相冲突。CPU 数据总线就是靠三态门来实现多个部件轮流驱动总线的。换句话说,三态不是理论模型而已,它是现代计算机硬件里真实存在的第三态。
如果你接触过状态机,也会发现三态的胶囊模型到处都是:连接建立/空闲/关闭、等待中/进行中/已完成、未开始/运行中/已失败。三进制编码至少在语义层面,是符合非常多真实场景的。
4.3 三进制位的信息效率:8 个 bit 能装下 5 个三进制位
既然聊效率,就得算账。一个三进制位能表示 3 种状态,信息量是 log2(3) ≈ 1.585 bit。也就是说,理论上 1 个二进制位(1 bit)装不下一个三进制位(因为 1.585 > 1),必须至少用 2 个 bit。
但直接用 2 个 bit 表示一个三进制位,会浪费多少空间?2 个 bit 本来能表示 4 种状态,只用了 3 种,浪费了 1/4 的组合空间,对应存储效率是 1.585 / 2 ≈ 79.2%。看起来挺浪费的。
一种更聪明的做法是分组打包。比如 5 个三进制位能表示 3^5 = 243 种状态,而 8 个 bit 能表示 2^8 = 256 种状态。243 只比 256 小一点点,也就是说,5 个三进制位的数据几乎可以无损塞进 8 个二进制位里,存储效率接近 5 × 1.585 / 8 ≈ 99.06%。这比 2 个 bit 装一个三进制位漂亮太多。
我在 t3code 里实际测过这种打包:用一个 8 位整数存 5 个三进制位,每个三进制位的取值 0/1/2 对应整数区间的一部分,读取时再用除 3 取余逆推。这样编码密度高,但代码复杂度也上升了,因为你要处理“边界数字 243 到 255 之间的空档”。从工程角度看,三进制存储并不是天方夜谭,只是需要认真做打包设计。
5. 我实测的坑与性能真相:基数效率、转换陷阱与三分误区
5.1 基数 e 最优,为什么最后是二进制的天下
这里有一个很经典的数学结论:在给定信息总量的情况下,编码基数 r 的理论成本正比于 r × log_r M(M 是总状态数),求导后最优基数落在 e ≈ 2.718 附近。也就是说,从理论存储成本看,三进制(基数 3)其实比二进制(基数 2)更接近最优值。这个结论不是民科,很多信息论教材都会提到。
那为什么最后统治世界的还是二进制?关键在工程成本模型里,有一位“物理可靠性”没有被算进去。制造一个能可靠区分“高电平、低电平”两种状态的双稳态器件,比制造一个能可靠区分“高、中、低”三种状态的三稳态器件要容易得多。电平受噪声干扰、温度漂移、工艺误差影响,状态之间的电压差越小,越容易误判。二进制的两个状态天然在电压空间里拉得最开,抗干扰性最好。此外,整个半导体产业链是为二进制优化的,切换基数的迁移成本高到没有人愿意承担。
这个结论是我在 t3code 里最有价值的“认知更新”之一:二进制胜出不是因为数学最优,而是因为工程最稳。对普通开发者来说,理解这一点比记住“最优基数是 e”更有用,因为你会更辩证地看待各种“XX 比 YY 更高效”的说法。
5.2 我在平衡三进制转换里踩过的三个具体坑
第一个坑是负数转换。我最初实现 dec_to_balanced_ternary 时,是先把负数转成普通三进制字符串,然后直接对这个带负号的字符串做逢 2 进位,结果把正负号给“处理丢”了。正确做法是先对 abs(n) 转普通三进制,整理成平衡三进制后,最后再做整体取反。这里的关键认知是:平衡三进制的负号不是一个额外的符号位,而是所有位整体对称翻转,所以必须最后统一处理。
第二个坑是字符串拼接缓存。我早期版本里用 result = char + result 逐个拼结果,三万次转换测试时明显变慢。Python 字符串是不可变的,反复拼接会不断创建新对象。改用列表收集再 join 后,同样规模的测试速度快了一个数量级。这种优化在教程里常被一笔带过,但真实跑数据时会真切感受到差异。
第三个坑是平衡三进制加法的查表法。我一开始照着教材写了位级加法器,但负数进位(进位为 -1)的处理在进位传播链里特别容易绕晕。比如低位求和为 -2 时,本位是 1,进位是 -1,这个“负进位”会传到高位;高位再遇到它,需要再次查表。手工推两个例子没问题,写成通用循环后,边界条件非常多。后来我改成十进制中转,核心算法从 30 行变成 3 行,正确性立刻清晰了。写代码要分清“教学演示”和“工程实现”的边界,学习阶段没必要死磕低层优化。
5.3 三分查找与二分查找:复杂度看着低,实际未必快
顺着三进制思路,很容易想到三分查找:每次把搜索区间分成三段,理论上一次可以排除 2/3 的数据,时间复杂度 O(log_3 n)。但实际用起来,三分查找大多数时候打不过二分查找,原因有两个。
第一,常系数问题。二分查找每轮只需要一次比较,三分查找每轮需要两次比较才能确定目标落在哪一段。虽然 log_3 n 比 log_2 n 小,但每轮的比较成本翻倍后,优势基本被抹平。第二,现代 CPU 的缓存访问模式对二分更友好,连续比较同一块缓存区域的局部性更好。
三分查找真正有优势的场景,是搜索“单峰函数”的最大值或最小值。比如一个函数先单调递增再单调递减,你用三分法每次取两个三等分点比较,能稳定收敛到峰值,而且不需要知道导数。这是一种数值优化手段,不是通用查找算法。我在 t3code 里顺手做了一个三分搜索的 demo,结论是:把“三”用在合适的地方(数值极值搜索)比硬套在查找里更值得。
6. 后续可以这样扩展:三分搜索与命令行工具
6.1 从三进制转换到三分搜索
如果你想在 t3code 现有基础上扩展,三分搜索是个很自然的下一步,因为它不需要额外的数据结构,只要一个单峰函数和几个循环。
def ternary_search(l: float, r: float, f, eps: float = 1e-9): while r - l > eps: m1 = l + (r - l) / 3 m2 = r - (r - l) / 3 if f(m1) < f(m2): l = m1 else: r = m2 return (l + r) / 2这段代码我实际跑过的场景是拟合一个带参数的曲线,需要找使损失最小的参数范围。用二分需要一阶导数信息,三分只需要函数值,代码反而更简单。这也是“三进制思想”在工程里最有落地感的体现之一。
6.2 把 t3code 包成命令行工具
我顺手给 t3code 加了一个极简命令行入口,用法是:
python -m t3 10 --to balanced python -m t3 1TT --from balanced实现上只需要用 argparse 读参数,再转发给 converter 和 balanced 两个模块。这个小工具的好处是:随时可以在终端里算一个数的三进制、平衡三进制,不用打开 Python 交互环境。对学习进制的人来说,这种“立刻能得到反馈”的工具比看文档有效得多。
我个人的体会是,这个项目最大的收获不在三进制本身,而在于它逼着我重新审视了很多编码领域的默认假设。进制不是只有 0 和 1,三态也不是什么科幻概念;SQL 的 NULL、硬件的高阻态、单峰函数的三分搜索,身边到处都是三的痕迹。如果你也想做类似的小项目,我建议别急着堆复杂功能,先把普通三进制和平衡三进制的转换写对、写稳,再把自动化测试补上,这比直接冲加法器、冲击优化要靠谱得多。