
看到 t3code 这四个字符你会想到什么我的第一反应是这大概率是一个和“编码”相关的项目代号而且名字里带了个 3。既然拿到的上下文非常少我干脆把 T3 理解为 Ternary把整个项目做成一套围绕三进制编码Ternary Code的实践从最基础的进制转换开始到平衡三进制这种“负数不需要负号”的设计再到能跑的 Python 实现最后聊聊三进制在纠错码、三态逻辑和存储效率里的真实身影。这篇文章就是我折腾 t3code 的完整记录适合对计算机底层、编码理论、算法实现感兴趣的开发者也适合想找一个“非主流但科学”的题目练手的人。1. 为什么我要折腾“三进制编码”而不是继续用二进制1.1 二进制是工程上的胜利不是逻辑上的必然我们从小接触的计算机知识几乎都在说“0 和 1”时间久了很容易产生一种错觉二进制是唯一能承载信息的编码方式。但从信息论角度讲任何基数 rr ≥ 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 取余、逆序排列”比如十进制 1010 ÷ 3 3 余 13 ÷ 3 1 余 01 ÷ 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。为了书写方便我们通常用字母 TTen 或 Tern表示 -1。为什么叫“平衡”因为 -1 和 1 关于 0 对称正数和负数在表示上是对偶的。比如十进制 5它的普通三进制是 12_31×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 的二进制、普通三进制、平衡三进制列在一起十进制二进制普通三进制平衡三进制0000111121021T3111010410011115101121TT6110201T07111211T1810002210T91001100100101010101101你可以看到普通三进制和平衡三进制在“没有 2 出现”的时候长得一样比如 3、4、9、10。一旦某一位需要表示 2平衡三进制就会变成“高位加一、本位变成负一”。这个规则后来直接变成了我代码里的核心算法逢 2 进位。2.3 两种三进制之间的关系普通三进制和平衡三进制并不是两个割裂的系统。普通三进制更像是人类习惯的“字节中间层”方便阅读平衡三进制才是更适合数学运算的“原生格式”。从普通三进制转到平衡三进制本质就是做一轮“逢 2 进位、逢 3 进位”的整理。比如 45 的普通三进制是 1200_3转换时第二位9 位上的 2 变成 T同时向第一位27 位进 127 位变成 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需要一张很小的表来映射“本位值和进位值”。比如当前位求和本位值向上进位-30-1-21-1-1-100001102-11301这个表理论上很漂亮但实现时有个坑进位可能不是 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还有高阻态 Zhigh-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 MM 是总状态数求导后最优基数落在 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、硬件的高阻态、单峰函数的三分搜索身边到处都是三的痕迹。如果你也想做类似的小项目我建议别急着堆复杂功能先把普通三进制和平衡三进制的转换写对、写稳再把自动化测试补上这比直接冲加法器、冲击优化要靠谱得多。