强迫症整理圣手的字典序试炼(二):第k小之章
我的室友阿整——江湖人称“整齐哥”——上一回用他那套“镜像分离·点名归位·定海神针”大法,轻松碾碎了第1小回文排列的试炼。可就在他准备给信纸裱框时,背面浮现的太极图和那句“找第k小”直接把他定在了原地,像一台突然蓝屏的精密仪器。
现在,他盯着那张挑不出半点排版毛病的信纸,瞳孔像失去焦距的镜头。空气凝固了整整十秒——对阿整来说,十秒够他把书架按索书号重排三遍。终于,他喉咙里挤出了一声近乎系统报错的叹息:
“他们……居然连落款字体大小都是正文的0.618倍,完美的黄金分割!”
我差点把薯条呛进鼻子里。“喂,关注点不应该是这任务的难度吗?人家要你找第k小啊!”
阿整缓缓转过头,眼神里混合着敬畏与恐惧:“你不懂。一个连字号比例都算到黄金分割的组织——难度是附赠的,秩序才是本体。”
一、冷静,以及分治
阿整拾起地上的马克笔,用酒精棉片擦了擦笔身,以笔帽的logo朝向正北的方式插回笔筒。他闭上眼,再睁开时,眼里的混乱已经被一种冰冷的秩序取代。
“第k小。”他合上牛皮笔记本“整齐宪法”,“不过是字典序树上的精准定位,本质上和整理书架没有区别。”
他转身在白板上画了起来——原字符串s,长度n,左半边长度m=n//2:“回文排列的结构是不变的。左半任意排列,中间可能夹一个奇点,右半是左半的镜像。和上次一样,右半边是赠品,不参与决策。”
他用笔帽敲了敲白板:“所以问题降维——要找到第k个回文排列,只需求出左半部分字符的第k个字典序排列。”
先统计左半部分每个字符的出现次数:
n = len(s)
half_len = n // 2
# 用数组统计左半部分字符频率(索引 0-25 对应 a-z)
cnt = [0] * 26
for ch in s[:half_len]:
cnt[ord(ch) - ord('a')] += 1
“字符可能重复,排列不可区分——这是一个多重集排列问题,不是简单的全排列。同样的字母,谁先谁后都长得一样,不能重复计数。” 阿整看了看我,又补了句:“说人话就是:‘aab’和’aab’哪怕交换两个a的位置,也只算同一种排列,不能重复数。”
接着,他在白板上写下:
给定字符多重集,求:该集合按字典序构成的第k个排列是什么?
然后重重画了个圈。
“真正的难点不在于算总数,而在于——” 他转过身,十指交叉,指节发出整齐的咔嗒声,“——怎么从零开始,一位一位地把第k小拼出来。”
“想象你面前是一棵巨大的字典树,每一条从根到叶子的路径就是一个排列,按字母顺序排好。你要找第k条路径。你不可能把整棵树画出来——排列数量随长度爆炸式增长,画完树宇宙都热寂了。”
他在白板上画了一个节点,分出几个分支。
“你要做的是:站在根节点,算一算第一条分支下面藏了多少条路径。如果k比这个数小,就钻进这个分支;如果k比这个数大,就减掉这个分支的数量,跳过去看下一条分支。一层一层往下走,每一步都知道该选哪个字母——走到叶子,答案自然就拼出来了。”
他放下笔,双手抱胸,左右手又各敲了四下。
“所以问题就拆成两步:一,怎么快速算出一个分支下面藏了多少条路径——也就是给定字符集还剩多少种排列;二,怎么沿着这棵树逐层往下钻——也就是每一步根据k决定选哪个字母、要不要跳到下一个分支。”
“第二步是通用框架,第一步——算排列数——有两种打法” 他在白板上画了一条分叉线,左边写"比例递推",右边写"组合截断"。
二、比例递推——按比例切蛋糕
阿整盯着白板上的字典树,树枝画得整整齐齐,分叉角度完全一致。“第一种打法:比例递推。”他在白板左半边写下标题,“核心就一句话——你已经知道整块蛋糕有多大,就不用每次都重新称重,按比例切就行。”
写完之后他又退后两步,歪头端详了三秒,上前把“推”字的提手旁和右边的“隹”间距调大了0.5毫米。
“第一步,工欲善其事,必先利其器。” 排列数公式的核心是阶乘——先做好阶乘预计算,用空间换时间:
# 预计算阶乘表:fact[i] = i!,避免重复计算大数阶乘
fact = [1] * (half_len + 1)
for i in range(1, half_len + 1):
fact[i] = fact[i - 1] * i
“阶乘是排列数的基本粒子。提前算好,随用随取,避免重复劳动——重复计算是对CPU的侮辱,CPU也有尊严。”
接着是排列数公式,他写下一个内部函数calc_perms:
# 多重集排列数 = length! / (cnt₁! × cnt₂! × ...)
def calc_perms(length: int, counter: list) -> int:
res = fact[length]
for v in counter:
res //= fact[v]
return res
“分子把所有人的可能性全部摊开,分母把同种字符内部的重复排列干掉。整除一定成立——任何余数都是对秩序的亵渎。”
“如果总排列数都不到k,那就直接返回空字符串——就像发现书架上缺了一本绝版书,用镜子补上虚像。” 阿整在这里轻轻点了点头,仿佛在向自己致敬。
# 计算左半部分的所有可能排列数
total_perms = calc_perms(half_len, cnt)
# 如果总排列数都不够 k,直接返回空字符串(无解)
if k > total_perms:
return ""
接着,他在白板上画了一个巨大的圆,又用红笔从圆心画出几条辐射线,把圆切成几个扇形。
“高潮来了。”他压低声音,“把total_perms 个排列看作一整块大饼。现在要按第一个位置字母把它切块——a开头的、b开头的、c开头的……每一块的大小,就是‘以这个字母开头的排列数量’。”
“怎么切?不用重新拿公式算。”他用笔帽戳了戳饼图,“多重集排列有一个天然对称性:在所有排列里,每个位置上的字符出现机会是均等的。换句话说,第一个位置是c的概率,就是c的个数除以总长度。”
他在饼图旁边奋笔疾书:
c块的大小 = 总饼 × (cnt[c] / remaining_length)
“比例递推的灵魂。”他转过身,眼神里燃起一丝布道者的狂热,“不需要每次重新跑一遍 calc_perms。你已经知道总饼有多大,只要乘上这个字符的占比,就直接得到了这个分支的精确大小。而且这个除法——”
他深吸一口气,“——永远整除。你看,对称性的终极体现就是,连余数都被消灭了,干干净净。”
他舔了舔嘴唇,写下循环框架:
half = [] # 存放最终拼出来的左半部分
remaining_length = half_len # 还剩多少个位置要填
current_perms = total_perms # 当前剩余的总排列数,初始为整块饼
for i in range(half_len):
for j in range(26): # 按字典序,从a到z试探
if cnt[j] == 0:
continue
# 分支大小 = 当前总饼 × 字符占比
new_perms = current_perms * cnt[j] // remaining_length
if k <= new_perms:
# 第k个就在这个分支里!选定字母j
half.append(ascii_lowercase[j])
cnt[j] -= 1
remaining_length -= 1
current_perms = new_perms # 钻进分支
break
else:
# 不在这个分支,跳过整块饼
k -= new_perms # 减去这块饼,继续看下一个字母
他放下笔,语气里带着一丝克制的得意:“站在每个位置,用比例切出分支大小。k落在里面就钻进去,落在外面就跳过去,同时把k平移到新分支里。循环结束,half里就是第k小的左半排列。没有重复计算,每一步只有一次乘法一次除法。”
最后镜像合体——右半是左半的倒影,中间奇点有就供上,没有就留白:
left = ''.join(half)
mid = s[n // 2] if n % 2 else ''
return left + mid + left[::-1]
测试用例绿灯通过。阿整盯着屏幕,十指指尖两两相对,搭成一个对称的拱门。
“这个解法,我叫它‘蛋糕不用切两次’。你不需要知道世界上有多少种蛋糕,你只需要拿好刀,按比例切下去,k会自己掉进属于它的那一块。”
三、组合截断——或者叫“够用就行”
但阿整的强迫症不允许他只拥有一种解法。他盯着那段代码里的 current_perms = new_perms,眉头又拧起来,像看到书架上一本书的封面偏了0.3毫米。
“每次都要把上一轮的状态传递下来,像一个幽灵缠在手上。虽然没错,但不够干净。”他把键盘往前一推,“有没有更独立的方法?
他翻开“整齐宪法”,翻到排列数公式那一页——左半边是阶乘推导,右半边不知什么时候被他对称地补了一栏“组合视角”,两边工整得像镜像,连涂改痕迹都没有,仿佛他在用笔尖遵守某种排版宪法。
总排列数 = C(m, cnt[a]) × C(m-cnt[a], cnt[b]) × …
“一样的结果,换个角度。”他用笔尖戳着公式,像是在给每个字母分配坐标,“先从m个空位里选cnt[a]个位置放a,再从剩下的位置里选cnt[b]个放b,一层一层往下,直到所有字符分配完毕。这是组合数的连乘积——”
他顿了顿,瞳孔里闪过一道锐光,像是发现了某种被隐藏的对称性,“而且,组合数天生就是整数乘除——每一步乘一个数再除一个数,步步整除,干干净净。连阶乘表都不用提前准备!”
“更妙的是——”他把笔一横,“如果我只关心排列数是不是大于等于k,那中途就可以截断。一旦当前乘积已经 >= k,后面根本不用继续乘,直接返回k。因为知道‘这个分支够大’就够了,精确值毫无意义,宇宙不需要知道你能排出一亿种还是十亿种。”
他告诉我这叫“截断美学”——够用就停,多算一个字都是对内存的不忠。接着,他写下组合数函数,从第一行就开始贯彻这个美学:
def comb(n: int, m: int) -> int:
m = min(m, n - m) # 对称优化:C(n,m) = C(n, n-m),选小的算
res = 1
for i in range(1, m + 1):
res = res * (n + 1 - i) // i
if res >= k: # 截断!精确值已经不重要了
return k
return res
“m = min(m, n-m)——组合数天生左右对称,选小的那半边算,循环次数最少。对称性从最小的细节抓起。”
他十指交叉,指节咔嗒作响:“常规组合数能算出天文数字,但我要的只是一个布尔问题的答案——‘这个分支够不够大’。够了就停,不浪费一个字长的内存。这叫对计算机的尊重。”
接着,基于组合数,他继续写下排列数函数 perm(sz)。思路一如既往地整洁:按字母顺序,从a到z,每个字母依次从剩余空位里认领自己的位置——每次乘一个组合数,全部乘完就是总排列数。
def perm(sz: int) -> int:
res = 1
for c in cnt:
if c == 0:
continue
res *= comb(sz, c) # 从当前空位中选c个位置给这个字母
if res >= k: # 截断!后面的不用算了
return k
sz -= c # 空位减少,交给下一个字母
return res
他顿了一下,像是在检查代码对齐情况:“每个字母独立挑位置,谁也不依赖谁。这是搭积木,不是切蛋糕——每一步都是干净的组合选择,不存在 current_perms 那个需要一路传递的幽灵。”
先用一次 perm 判断k是否超出总数:
if perm(half_len) < k:
return ""
然后就是逐位构造——对于左半部分的每一个待填位置,按a到z尝试每个字母:
假设这一位放j,先临时扣掉一个j 的配额,再调用 perm(剩余长度) 计算剩下位置的排列数。如果这个数 p >= k,说明第k个就在这个分支里,选它;否则 k -= p,恢复j的计数,继续试下一个字母。
“先扣,再算,够就定,不够就还。”他舔了舔嘴唇,指法精准得像在做显微手术:
half = [''] * half_len
for i in range(half_len):
for j in range(26):
if cnt[j] == 0:
continue
cnt[j] -= 1 # 先扣:假设这一位选 j
p = perm(half_len - i - 1) # 再算:剩下位置能排出多少
if k <= p: # 够!就选它
half[i] = ascii_lowercase[j]
break
else: # 不够,跳过整批
k -= p # 平移k
cnt[j] += 1 # 恢复!刚才扣的不算
最后老规矩,镜像合体:
left = ''.join(half)
mid = s[n // 2] if n % 2 else ''
return left + mid + left[::-1]
测试用例全部绿灯。阿整双臂在胸前交叉成一个完美的X,然后缓缓展开,像在空气中画了一个对称的结。
“这个解法,我叫它‘够用就行’。你不需要算清楚每一层能排出多少种,你只需要知道它够不够装下第k个。够了就停——剩下的事,交给截断。”
四、冗余备份
所有测试用例跑完,阿整向后一靠,椅背发出“咔嗒”一声,恰好和他颈椎的舒展声同步。他把整齐宪法放进抽屉,转向我,十指交叉,转向我,显然准备好了总结陈词。
我打了个哈欠。
“其实吧,”我吸着可乐,“你刚才吭哧半天写的两种解法——那个比例递推里的 current_perms * cnt[j] // remaining_length,本质上就是把组合截断里 perm 函数的第一步拆出来单算。同一件事,一个带着状态跑,一个每次都重新乘一遍。数学上完全等价。”
阿整的瞳孔骤缩。他拿起笔,疯狂地在白板上推导。五分钟后,笔掉在地上。这次没滚成圆——直接摔成了两截。
“等价……”他嗓子都劈了,“我折腾了两章……结果是一个解法?!”
“也不能这么说,”我嚼着薯条,“至少你证明了你有两种方式干同一件事。这在强迫症圈子里,叫冗余备份。”

本作品采用
知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议
进行许可。