LeetCode 1415. 长度为 n 的开心字符串中字典序第 k 小的字符串

LeetCode 1415. 长度为 n 的开心字符串中字典序第 k 小的字符串

题目描述

一个「开心字符串」定义为:

  • 仅由小写英文字母 'a'、'b'、'c' 组成。
  • 字符串中任意相邻两个字符不相同。

给你两个整数 n 和 k,你需要将长度为 n 的所有开心字符串按字典序排序,并返回排在第 k 位的字符串。如果长度为 n 的开心字符串少于 k 个,则返回空字符串。

示例:

复制代码
输入:n = 3, k = 4
输出:"acb"
解释:长度为 3 的开心字符串共 12 个,按字典序排序为:
"aba", "abc", "aca", "acb", "bab", "bac", "bca", "bcb", "cab", "cac", "cba", "cbc"
第 4 个是 "acb"。

思路分析

长度为 n 的开心字符串总数是固定的:

  • 第一个字符有 3 种选择。
  • 之后每个字符有 2 种选择(不能与前一个相同)。
    因此总数为 3 × 2^(n-1)。

我们可以利用这个规律,直接构造出第 k 个字符串 ,而不需要生成所有字符串。

因为每个位置的字符选择可以看作一个「分支」,k 的值隐含了从根到叶子的路径信息。

数学映射

将 k 转换为 0‑based 索引(k--)。

将长度为 n 的所有开心字符串按字典序排列后,它们可以被划分为 3 大块,每块对应首字符为 'a'、'b'、'c'。每块的大小恰好是 2^(n-1)。

因此,首字符可以由 k / 2^(n-1) 确定。

之后,在确定的块内,剩余位置的选择可以继续用同样的思路分解:

每个后续位置都有 2 种可能(避开前一个字符),它们又构成大小为 2^(n-1-i) 的子块。

利用 k 的二进制位来表示这些分支:右移 (n-1-i) 位后取最低位,得到 0 或 1,对应当前位置的「基础偏移」。

避开相邻相同字符的映射技巧

当我们用偏移量构造当前字符时,直接 'a' + offset 可能会与前一个字符相等或冲突。

实际上,可选的两个字符是按字典序排列的(较小的对应 offset=0,较大的对应 offset=1),而前一个字符被排除。

我们可以先计算一个临时字符 c = 'a' + offset,然后检查它是否 大于等于 前一个字符:

  • 如果 c >= prev,说明它落在了前一个字符或之后的字符上,需要再加 1 跳过前一个字符。
  • 否则,直接使用 c。

这个条件可以正确处理所有情况(可代入验证)。

代码实现(C++)

cpp 复制代码
class Solution {
public:
    string getHappyString(int n, int k) {
        // 1. 判断 k 是否超出总数
        if (k > 3 << (n - 1)) return "";
        
        // 2. 转为 0-based 索引
        k--;
        
        // 3. 初始化结果字符串为全 'a'
        string ans(n, 'a');
        
        // 4. 确定第一个字符
        ans[0] += k >> (n - 1);
        
        // 5. 确定后续每个字符
        for (int i = 1; i < n; i++) {
            // 提取当前位的偏移 (0 或 1)
            ans[i] += (k >> (n - 1 - i)) & 1;
            // 调整:如果当前字符 >= 前一个字符,需要加1
            if (ans[i] >= ans[i - 1])
                ans[i]++;
        }
        
        return ans;
    }
};

代码详解

  1. 总数判断
    3 << (n - 1) 等价于 3 * 2^(n-1),若 k 大于总数则返回空串。

  2. 0‑based 索引

    因为 k 从 1 开始计数,而后续位运算希望从 0 开始,所以 k--。

  3. 初始化字符串

    创建一个长度为 n 的字符串,全部初始化为 'a',之后通过增加偏移量来得到 'b' 或 'c'。

  4. 首字符确定
    k >> (n - 1) 得到 k 除以 2^(n-1) 的整数部分,结果为 0、1 或 2,分别对应 'a'、'b'、'c'。

    加到 ans[0] 上即得到正确首字符。

  5. 后续字符确定

    对于第 i 个位置(i 从 1 开始):

    • k >> (n - 1 - i) 右移后,最低位表示当前块内的分支选择(0 或 1)。
    • & 1 取出这一位,加到 ans[i] 上,得到临时字符(可能是 'a' 或 'b',也可能是 'a'+1 即 'b' 或 'a'+2 即 'c' 吗?注意:临时字符只加 0 或 1,所以只能是 'a' 或 'b'。这符合预期:当前位置可选的两个字符正是较小和较大的两个字符,它们与 'a' 的差值恰好是 0 和 1(但需要跳过前一个字符,所以最终可能变成 'c')。
    • 调整:如果这个临时字符 大于等于 前一个字符 ans[i-1],则说明它落在了前一个字符或它的下一个字符上,由于前一个字符被禁止,所以必须再 +1,跳到下一个可用字符。
    • 这个调整逻辑将偏移量 0/1 正确映射到两个合法的可选字符上。

举例验证

以 n = 3, k = 4 为例:

  • 总数 = 3 << 2 = 12,k=4 ≤12,继续。
  • k-- → 3(0‑based)。
  • 首字符:3 >> 2 = 0 → ans[0] = 'a'。
  • i=1:(3 >> 1) & 1 = 1 → ans[1] += 1 得到 'b'。
    因为 'b' >= 'a' 成立,所以 ans[1]++ → 'c'。此时字符串为 "ac?"。
  • i=2:(3 >> 0) & 1 = 1 → ans[2] += 1 得到 'b'。
    检查 'b' >= 'c'?不成立,保持 'b'。最终结果为 "acb",与预期一致。

复杂度分析

  • 时间复杂度:O(n),仅需一次遍历。
  • 空间复杂度:O(1),除了结果字符串外只使用常数空间。

总结

这种方法利用数学计数和位运算,直接从 k 解码出字符串的每一位,避免生成所有字符串。

关键在于理解 k 的二进制位如何映射到分支选择,以及巧妙的「≥ 前一个字符则加 1」调整规则,使得相邻字符不相同。

代码简洁高效,是这道题的最优解之一。

相关推荐
hanlin032 小时前
刷题笔记:力扣第32题-最长有效括号
笔记·算法·leetcode
All for pursuit.2 小时前
【动态规划-21】312.戳气球
数据结构·c++·算法·leetcode·动态规划
碧海银沙音频科技研究院4 小时前
基于炬芯ATS3089X自研AEC声学回声消除算法
算法
通问AI5 小时前
在两台 Mac Studio 上跑 800B 模型:MLX + RDMA over Thunderbolt 实测与四道门选型法
算法
ZywOo6235 小时前
《算法竞赛进阶指南》0x03 前缀和与差分
算法
Scintilla_y5 小时前
DAY2 滑动窗口
算法·leetcode·散列表
lvwangshu5 小时前
笛卡尔树
算法·图论
026d235 小时前
状态信号只知“其然”不知“其所以然”:变工况机械故障诊断的物理盲区
算法
姜鱼问生6 小时前
备份和服务器在同一台机器上,等于没备份
算法·架构
姜鱼问生6 小时前
别再 chmod 777 了,用 ACL 给它开一扇窗
后端·算法·架构