深入解析:如何高效计算字符串中由相同字符组成的子串数量

在这篇文章中,我们将深入探讨一个既经典又非常实用的字符串算法问题:计算一个字符串中由相同字符组成的子串数量。这不仅仅是一道算法竞赛题,更是考察我们对字符串遍历、逻辑思维以及时间复杂度优化的绝佳练习。我们将一起分析问题的本质,探索最优解法,并深入探讨代码实现的每一个细节,确保你不仅能写出代码,还能理解背后的设计思想。

问题背景与核心目标

首先,让我们明确一下具体的目标。给定一个字符串,我们需要找出所有子串中,只包含一种字符的子串总数。请注意,这里说的“子串”指的是原字符串中字符连续的序列,这与“子序列”是不同的。

为了让你更直观地理解,让我们看一个简单的例子。

示例分析:

假设输入字符串是 "abc"

我们可以列出所有的子串:

  • 长度为1的:"a", "b", "c" (共3个)
  • 长度为2的:"ab", "bc" (共2个)
  • 长度为3的:"abc" (共1个)

题目要求的是由相同字符组成的子串。在上面的列表中,只有长度为1的 "a", "b", "c" 符合条件。"ab" 包含了不同的字符,"bc" 也一样,"abc" 也是混合的。所以答案是 3

再来看一个稍微复杂点的例子:

假设输入字符串是 "abbba"

符合条件的子串有:

  • "a" (索引0)
  • "a" (索引4)
  • "b" (索引1), "b" (索引2), "b" (索引3)
  • 连续的相同字符组成的更长子串

– 中间的三个 ‘b‘ 组成了 "bb" (1-2), "bb" (2-3) 以及 "bbb" (1-3)。

如果你把上面的所有情况加起来,你会发现这其实是一个数学组合问题。这正是我们接下来要讨论的核心解法。

初步探索:暴力解法为何不可行

在寻找最优解之前,我们通常会先想到暴力解法,也就是生成所有的子串,然后逐一检查它们是否只由一种字符组成。

对于长度为 $n$ 的字符串,子串的总数是 $O(n^2)$ 级别的(因为总共有 $n(n+1)/2$ 个子串)。如果对于每一个子串,我们再去遍历检查字符是否相同,那么总的时间复杂度就会上升到 $O(n^3)$。当字符串长度稍微增加(比如超过1000),这种方法的计算量就会变得非常巨大,导致程序运行超时。

显然,我们需要一种更聪明的方法,将时间复杂度降低到 $O(n)$

深入剖析:最优解法思路

让我们换个角度思考。与其检查每一个子串,不如统计连续相同字符的片段

想象一下,你正在扫描这个字符串。当你遇到一段连续的相同字符时,比如 "aaaa",这段字符内部会包含多少个符合条件的子串呢?

  • 长度为1的子串:"a", "a", "a", "a" (4个)
  • 长度为2的子串:"aa", "aa", "aa" (3个)
  • 长度为3的子串:"aaa", "aaa" (2个)
  • 长度为4的子串:"aaaa" (1个)

总数量 = $4 + 3 + 2 + 1 = 10$。

这正好是一个等差数列求和公式!对于一段长度为 $k$ 的连续相同字符,它包含的符合条件的子串数量为:

$$ \text{Count} = \frac{k \times (k + 1)}{2} $$

我们的策略变得非常清晰:

  • 遍历字符串,寻找当前连续相同字符的“块”。
  • 记录这个“块”的长度 $k$。
  • 一旦遇到不同的字符(或者到达字符串末尾),利用上面的公式计算当前“块”的贡献,并累加到总结果中。
  • 重置计数器,继续处理后续的字符。

这种方法只需要遍历字符串一次,因此时间复杂度是 $O(n)$,空间复杂度是 $O(1)$(只用了几个变量),这是极其高效的。

代码实现与详细注释

接下来,让我们看看如何将这个思路转化为具体的代码。为了满足不同开发环境的需求,我将为你提供 C++、Java、Python3 和 C# 四种语言的实现。

#### 1. C++ 实现

C++ 以其高性能著称,非常适合处理这类底层的字符串操作。

// C++ 实现:计算由相同字符组成的子串数量
#include 
#include 
#include 

using namespace std;

// 函数:计算并返回结果
long long countSubstrings(string s) {
    // 处理边界情况:空字符串
    if (s.empty()) return 0;

    int n = s.length();
    long long result = 0; // 使用 long long 防止大数溢出
    int count = 1; // 计数器,初始为1,因为非空字符串至少有一个字符

    // 从第二个字符开始遍历,与它的前一个字符进行比较
    for (int i = 1; i < n; i++) {
        // 如果当前字符与前一个字符相同
        if (s[i] == s[i - 1]) {
            count++;
        } 
        // 如果遇到了不同的字符,说明上一个连续的“块”结束了
        else {
            // 累加当前块的子串数量:count * (count + 1) / 2
            result += count * (count + 1) / 2;
            // 重置计数器,开始计算新的字符块
            count = 1;
        }
    }

    // 别忘了处理最后一个字符块!
    // 因为循环遇到不同的字符才结算,所以最后一段需要手动加上
    result += count * (count + 1) / 2;

    return result;
}

// Driver Code - 测试我们的逻辑
int main() {
    string s1 = "abc";
    cout << "Input: " << s1 < Output: " << countSubstrings(s1) << endl;

    string s2 = "abbba";
    cout << "Input: " << s2 < Output: " << countSubstrings(s2) << endl;

    string s3 = "bbbcbb";
    cout << "Input: " << s3 < Output: " << countSubstrings(s3) << endl;

    return 0;
}

关键点解析:

  • 注意我们使用 INLINECODE96909ff9 类型来存储 INLINECODEa048fb89。虽然字符串长度可能不大,但如果字符串非常长且全是同一个字符(例如一万个 ‘a‘),计算出的子串数量会非常巨大(约5000万),普通的 int 可能会溢出。
  • 循环后的处理:这是新手最容易犯错的地方。循环内部只在字符发生变化时结算,因此字符串末尾的最后一段连续字符不会被处理,必须在循环结束后手动加一次。

#### 2. Java 实现

在 Java 中,字符串处理通常比较直观,charAt(i) 是我们常用的方法。

// Java 实现:计算由相同字符组成的子串数量
public class SameCharacterSubstrings {

    public static long countSubstrings(String s) {
        if (s == null || s.length() == 0) {
            return 0;
        }

        long result = 0;
        int count = 1;
        int n = s.length();

        // 从索引 1 开始遍历
        for (int i = 1; i < n; i++) {
            // 比较当前字符和前一个字符
            if (s.charAt(i) == s.charAt(i - 1)) {
                count++;
            } else {
                // 字符不同,结算前一段
                result += count * (count + 1) / 2;
                count = 1;
            }
        }

        // 处理最后一段连续字符
        result += count * (count + 1) / 2;

        return result;
    }

    public static void main(String[] args) {
        String input = "bbbcbb";
        System.out.println("输入: " + input);
        System.out.println("输出: " + countSubstrings(input));
        
        // 更多测试用例
        System.out.println("测试 'aaa': " + countSubstrings("aaa")); // 期望: 6
        System.out.println("测试 'ab': " + countSubstrings("ab"));   // 期望: 2
    }
}

#### 3. Python3 实现

Python 的代码通常最为简洁,我们可以利用它强大的列表切片或者简单的遍历来解决问题。这里为了展示算法通用性,我们使用常规遍历法。

# Python3 实现:计算由相同字符组成的子串数量

def count_substrings(s):
    if not s:
        return 0
    
    n = len(s)
    result = 0
    count = 1
    
    # 遍历字符串,从第二个字符开始
    for i in range(1, n):
        # 比较当前字符与前一个字符
        if s[i] == s[i-1]:
            count += 1
        else:
            # 字符不同,结算当前连续块
            result += count * (count + 1) // 2
            count = 1
            
    # 别忘了加上最后一部分的数量
    result += count * (count + 1) // 2
    
    return result

# 测试代码
if __name__ == "__main__":
    test_str = "bbbcbb"
    print(f"输入字符串: {test_str}")
    print(f"符合条件的子串数量: {count_substrings(test_str)}")
    
    # 验证公式:"bbbcbb" 可以看作 bbb(6个) + c(1个) + bb(3个) = 10
    # 3个b: 3*4/2 = 6
    # 1个c: 1*2/2 = 1
    # 2个b: 2*3/2 = 3
    # Total = 10

#### 4. C# 实现

对于 C# 开发者,代码结构与 Java 非常相似。

using System;

class Program
{
    // 函数:计算数量
    static long CountSubstrings(string s)
    {
        if (string.IsNullOrEmpty(s)) return 0;

        long result = 0;
        int count = 1;
        int n = s.Length;

        for (int i = 1; i < n; i++)
        {
            if (s[i] == s[i - 1])
            {
                count++;
            }
            else
            {
                result += count * (count + 1) / 2;
                count = 1;
            }
        }

        // 处理最后一组连续字符
        result += count * (count + 1) / 2;

        return result;
    }

    static void Main()
    {
        string input = "bbbcbb";
        Console.WriteLine($"字符串: {input}");
        Console.WriteLine($"结果: {CountSubstrings(input)}");
    }
}

复杂度分析与最佳实践

作为一名追求卓越的开发者,我们不能仅仅满足于代码能跑通,还需要关注性能和健壮性。

#### 时间复杂度

正如我们前面分析的,算法只需要对数组进行一次完整的遍历。随着字符串长度 $n$ 的增加,操作次数线性增长。因此,时间复杂度为 $O(n)$

#### 空间复杂度

我们没有使用额外的数据结构(如哈希表或数组)来存储中间状态,只用了几个简单的变量(INLINECODE7a611b95, INLINECODE8b9c30cc, i)。因此,空间复杂度为 $O(1)$,这是最优的空间利用率。

#### 潜在的陷阱与解决方案

在编写这段代码时,有几个细节需要你特别注意:

  • 整数溢出:这是一个经典的 Bug 来源。计算公式 INLINECODEb303dea5 中,如果 INLINECODE4127f849 很大(例如接近 $2^{31}$),那么乘法运算的结果可能会超过 32 位整数的上限。在 C++ 或 Java 中,务必使用 INLINECODE2b9424d2 或 INLINECODE27a04331 类型来接收结果。
  • 空字符串处理:在函数开头,一定要检查输入字符串是否为空或 INLINECODEbc5e7551。如果不检查,直接访问 INLINECODE4769c3ee 或调用 length() 可能会导致程序崩溃(特别是 C++ 访问空字符串内存时)。
  • 单字符字符串:如果输入是 "a",循环可能不会执行(因为 INLINECODE254e8c26 从 1 开始),或者 INLINECODEd0a2a522 分支不会触发。循环结束后的 result += ... 这一步至关重要,它保证了单字符的情况也能被正确计算(结果为 1)。

进阶思考:如果字符非连续呢?

为了加深你的理解,让我们思考一个变种问题:如果我们要计算由相同字符组成的子序列数量,该如何处理?

这会变成一个更复杂的动态规划问题。例如 "abca",子序列 "aa" 就算数,即使它们不连续。这时,我们需要统计每种字符出现的总次数 $m$,然后对每种字符计算 $C(m, 1) + C(m, 2) + \dots + C(m, m)$,这其实是 $2^m – 1$。当然,这属于另一个话题了,但它能很好地帮助你理解“子串”与“子序列”的本质区别。

总结

在这篇文章中,我们通过一道经典的字符串题目,学习了如何将数学思维应用到编程算法中。通过将大问题拆解为寻找“连续相同字符块”的小问题,我们成功地将一个看似需要 $O(n^3)$ 复杂度的任务优化到了 $O(n)$。

关键在于:

  • 识别问题的数学规律(等差数列求和)。
  • 采用一次遍历的策略,减少不必要的重复计算。
  • 注意代码的边界条件(循环后的最后一次累加)和溢出风险。

希望这种从“暴力”到“优化”的思考过程能对你有所启发。下次当你面对字符串处理问题时,不妨先停下来观察一下数据分布的规律,也许一个简单的数学公式就能帮你省去大量的计算资源。快去你的代码编辑器中试一试吧!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/51597.html
点赞
0.00 平均评分 (0% 分数) - 0