1. 题目解析与解题思路
这道题目要求我们生成特定长度的"开心字符串",并返回其中字典序第k小的字符串。开心字符串的定义是:字符串中不包含任何两个相邻的相同字符。换句话说,对于字符串中的任意两个相邻字符s[i]和s[i+1],都必须满足s[i] ≠ s[i+1]。
1.1 问题分析
首先我们需要明确几个关键点:
- 字符串长度固定为n
- 只能使用字符'a'、'b'、'c'
- 不能有相邻的相同字符
- 需要返回所有可能字符串中字典序第k小的那个
举个例子,当n=3时,所有可能的开心字符串按字典序排列为:
- "aba"
- "abc"
- "aca"
- "acb"
- "bab"
- "bac"
- "bca"
- "bcb"
- "cab"
- "cac"
- "cba"
- "cbc"
总共有12种可能(3×2×2=12),如果k=5,则返回"bab"。
1.2 解题思路概述
这个问题可以有以下几种解法:
- 暴力递归回溯法:生成所有可能的开心字符串,排序后取第k个
- 优化的递归法:在生成过程中按字典序生成,直接找到第k个
- 位运算法:利用数学规律直接构造第k个字符串
接下来我们将详细分析每种方法的实现细节和优劣。
2. 暴力递归回溯解法
2.1 基本思路
暴力递归回溯法的核心思想是:
- 从空字符串开始,逐个位置尝试添加字符
- 每次添加时确保不与前一个字符相同
- 当字符串长度达到n时,将其加入结果列表
- 最后对结果列表排序,取第k-1个元素(因为列表从0开始)
这种方法虽然直观,但效率不高,因为需要生成所有可能的字符串,然后排序。
2.2 实现细节
2.2.1 使用StringBuffer的显式回溯
java复制class Solution {
List<String> list = new ArrayList<>();
StringBuffer cur = new StringBuffer();
public String getHappyString(int n, int k) {
dfs(cur, n);
if(k > list.size()) return "";
Collections.sort(list);
return list.get(k-1);
}
private void dfs(StringBuffer cur, int n) {
int len = cur.length();
if(len >= 2 && cur.charAt(len-1) == cur.charAt(len-2)) return;
if(len == n) {
list.add(cur.toString());
return;
}
// 尝试添加'a'
cur.append("a");
dfs(cur, n);
cur.deleteCharAt(cur.length()-1); // 回溯
// 尝试添加'b'
cur.append("b");
dfs(cur, n);
cur.deleteCharAt(cur.length()-1); // 回溯
// 尝试添加'c'
cur.append("c");
dfs(cur, n);
cur.deleteCharAt(cur.length()-1); // 回溯
}
}
这种方法使用了StringBuffer来构建字符串,每次递归调用后需要显式地删除最后一个字符(回溯)。这种方法的缺点是:
- 需要显式回溯,代码略显冗长
- StringBuffer的操作比数组操作慢
- 需要额外的排序步骤
2.2.2 使用数组的隐式回溯
java复制class Solution {
List<String> list = new ArrayList<>();
char[] cur;
public String getHappyString(int n, int k) {
cur = new char[n];
dfs(0, cur, n);
if(k > list.size()) return "";
Collections.sort(list);
return list.get(k-1);
}
private void dfs(int i, char[] cur, int n) {
if(i >= 2 && cur[i-1] == cur[i-2]) return;
if(i == n) {
list.add(new String(cur));
return;
}
// 尝试添加'a'
cur[i] = 'a';
dfs(i+1, cur, n);
// 尝试添加'b'
cur[i] = 'b';
dfs(i+1, cur, n);
// 尝试添加'c'
cur[i] = 'c';
dfs(i+1, cur, n);
}
}
这种方法使用字符数组代替StringBuffer,利用数组索引的特性实现了隐式回溯:
- 不需要显式删除字符,因为后续写入会覆盖之前的值
- 数组操作比StringBuffer更快
- 代码更简洁
注意:虽然这种方法比StringBuffer版本快,但仍然需要生成所有可能的字符串然后排序,时间复杂度为O(3×2^(n-1)),当n较大时效率仍然不高。
3. 位运算优化解法
3.1 数学规律分析
我们可以发现开心字符串的生成有明确的数学规律:
- 第一个字符有3种选择(a,b,c)
- 后续每个字符有2种选择(不能与前一个相同)
- 总共有3×2^(n-1)种可能的字符串
更关键的是,这些字符串的字典序排列有明确的规律,我们可以利用k的值直接构造出第k个字符串,而不需要生成所有字符串。
3.2 位运算解法实现
java复制class Solution {
public String getHappyString(int n, int k) {
// 检查k是否超出范围
if(k > 3 << (n-1)) return "";
k--; // 转换为0-based索引
char[] ret = new char[n];
// 确定第一个字符
ret[0] = (char)('a' + (k >> (n-1)));
// 确定后续字符
for(int i = 1; i < n; i++) {
ret[i] = (char)('a' + (k >> (n-1-i) & 1));
if(ret[i] >= ret[i-1]) ret[i]++;
}
return new String(ret);
}
}
3.3 代码解析
-
范围检查:
java复制if(k > 3 << (n-1)) return "";3 << (n-1)等于3×2^(n-1),即所有可能的开心字符串数量。如果k超过这个值,直接返回空字符串。
-
转换为0-based索引:
java复制
k--;因为我们的计算从0开始更直观。
-
确定第一个字符:
java复制ret[0] = (char)('a' + (k >> (n-1)));将k右移(n-1)位,相当于k除以2^(n-1),得到0、1或2,对应'a'、'b'或'c'。
-
确定后续字符:
java复制ret[i] = (char)('a' + (k >> (n-1-i) & 1)); if(ret[i] >= ret[i-1]) ret[i]++;- 取出k的相应位(0或1)
- 如果结果大于等于前一个字符,则加1,确保不重复且选择正确的字符
3.4 示例解析
以n=4,k=12为例:
- 3 << (4-1) = 24,k=12 ≤ 24,继续
- k-- → 11
- ret[0] = 'a' + (11 >> 3) = 'a' + 1 = 'b'
- 循环i=1:
- (11 >> 2) & 1 = 2 & 1 = 0 → 'a'
- 'a' >= 'b'? 否 → ret[1]='a'
- i=2:
- (11 >> 1) & 1 = 5 & 1 = 1 → 'a' +1='b'
- 'b' >= 'a'? 是 → ret[2]='c'
- i=3:
- (11 >> 0) & 1 = 11 & 1 = 1 → 'a' +1='b'
- 'b' >= 'c'? 否 → ret[3]='b'
- 最终结果:"bacb"
4. 性能对比与优化建议
4.1 时间复杂度分析
-
暴力递归法:
- 生成所有字符串:O(3×2^(n-1))
- 排序:O(m log m),其中m=3×2^(n-1)
- 总时间复杂度:O(n×2^n)
-
位运算法:
- 直接构造字符串:O(n)
- 总时间复杂度:O(n)
4.2 空间复杂度分析
-
暴力递归法:
- 存储所有字符串:O(n×2^n)
-
位运算法:
- 只存储结果字符串:O(n)
4.3 实际测试结果
在实际LeetCode测试中:
- 暴力递归-StringBuffer版本:76ms,击败5.11%
- 暴力递归-数组版本:12ms,击败28.98%
- 位运算版本:1ms,击败100%
4.4 优化建议
- 对于小规模n(n≤10),可以使用暴力递归法,代码更直观
- 对于大规模n,必须使用位运算法,否则会超时
- 在实际面试中,可以先提出暴力解法,然后优化到位运算法,展示问题分析能力
5. 常见问题与调试技巧
5.1 常见错误
- 忘记处理k超出范围的情况
- 在暴力递归法中忘记回溯(特别是使用StringBuffer时)
- 在位运算法中忘记k--转换索引
- 字符比较时混淆了字符和数字的转换
5.2 调试技巧
-
对于递归法,可以打印递归树帮助理解:
java复制private void dfs(int i, char[] cur, int n) { System.out.println("i=" + i + ", cur=" + new String(cur)); // ...原有代码... } -
对于位运算法,可以打印中间结果:
java复制System.out.println("k=" + k + ", shift=" + (k >> (n-1)) + ", char=" + ret[0]); -
使用小例子手动验证,如n=2,k=3等
5.3 边界情况测试
- n=1,k=1/2/3/4
- n=2,k=6(刚好等于总数)
- n=3,k=13(超出范围)
- 大n(如n=10)测试性能
6. 算法扩展与应用
6.1 类似问题
- 生成所有不含连续相同字符的字符串
- 生成字典序第k小的排列
- 受限条件下的字符串生成问题
6.2 实际应用场景
- 密码生成器(避免连续相同字符)
- 随机ID生成(特定格式要求)
- 测试用例生成(边界条件测试)
6.3 算法变种
- 使用更多字符(如a-z)
- 更复杂的限制条件(如不能连续三个字符形成特定模式)
- 不固定长度的情况
7. 个人实现心得
在实际实现这道题目时,我最初采用了暴力递归的方法,因为这种方法思路直接,容易理解和实现。但是在测试较大n值时遇到了性能问题,这促使我寻找更优的解法。
位运算解法的关键在于发现开心字符串的字典序排列与二进制数的关系。通过分析,我发现:
- 第一个字符的选择可以通过k的高位直接确定
- 后续字符的选择可以通过k的剩余位确定
- 需要小心处理字符不重复的条件
最tricky的部分是如何处理字符不重复的条件。最初我尝试直接使用二进制位对应字符,但发现无法满足不重复的要求。后来通过调整,当字符可能重复时加1,完美解决了这个问题。
这种从暴力解法出发,通过分析问题本质找到数学规律,最终实现最优解的过程,是算法问题解决的典型思路。在实际编程中,我们应该:
- 先实现一个可行解,确保正确性
- 分析问题特性,寻找优化空间
- 通过数学推导或模式识别,找到更优算法
- 仔细处理边界条件和特殊情况
这道题目很好地展示了如何将看似复杂的字符串生成问题转化为位运算问题,这种思维方式在解决其他算法问题时也非常有用。