MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++深入字符串、向量和数组

2024-10-085.5k 阅读

字符串(String)

C++ 中的字符串表示

在 C++ 中,字符串的表示主要有两种方式:C 风格字符串和 C++ 标准库中的 std::string

C 风格字符串

C 风格字符串本质上是一个以空字符 '\0' 结尾的字符数组。例如:

char greeting[] = "Hello, World!";

这里 greeting 是一个字符数组,它包含了字符串中的所有字符以及结尾的空字符。可以通过以下方式访问数组中的字符:

#include <iostream>

int main() {
    char greeting[] = "Hello, World!";
    for (int i = 0; greeting[i] != '\0'; ++i) {
        std::cout << greeting[i];
    }
    std::cout << std::endl;
    return 0;
}

这种方式虽然简单直接,但在使用过程中有一些局限性。例如,手动管理内存很容易导致缓冲区溢出错误。假设我们想要复制一个 C 风格字符串到另一个数组中:

#include <iostream>
#include <cstring>

int main() {
    char source[] = "Long string that might cause problems";
    char destination[10]; // 这个数组大小太小
    std::strcpy(destination, source); // 可能导致缓冲区溢出
    std::cout << destination << std::endl;
    return 0;
}

在上述代码中,destination 数组的大小不足以容纳 source 字符串,调用 std::strcpy 会导致未定义行为。

std::string

std::string 是 C++ 标准库提供的字符串类,它极大地简化了字符串的操作。std::string 会自动管理内存,避免了很多常见的错误。

#include <iostream>
#include <string>

int main() {
    std::string str = "Hello, World!";
    std::cout << str << std::endl;
    return 0;
}

std::string 提供了丰富的成员函数,用于字符串的操作。比如字符串拼接:

#include <iostream>
#include <string>

int main() {
    std::string str1 = "Hello";
    std::string str2 = " World";
    std::string result = str1 + str2;
    std::cout << result << std::endl;

    str1.append(str2);
    std::cout << str1 << std::endl;
    return 0;
}

上述代码展示了两种字符串拼接的方式,一种是使用 + 运算符,另一种是使用 append 成员函数。

字符串的内存管理

C 风格字符串的内存管理

C 风格字符串如果是在栈上定义,其内存空间在函数结束时会自动释放。例如:

void someFunction() {
    char str[] = "Local string";
    // 函数结束时,str 的内存会自动释放
}

如果是在堆上分配的 C 风格字符串,就需要手动释放内存,否则会导致内存泄漏。例如:

#include <iostream>
#include <cstring>

int main() {
    char *dynamicStr = new char[20];
    std::strcpy(dynamicStr, "Dynamic string");
    std::cout << dynamicStr << std::endl;
    delete[] dynamicStr; // 手动释放内存
    return 0;
}

忘记调用 delete[] 会导致 dynamicStr 所占用的内存无法被回收。

std::string 的内存管理

std::string 内部使用动态内存分配来存储字符串内容。当 std::string 对象被销毁时,其析构函数会自动释放所占用的内存。例如:

#include <iostream>
#include <string>

void someFunction() {
    std::string localStr = "Local std::string";
    // 函数结束时,localStr 的析构函数会自动释放内存
}

int main() {
    someFunction();
    return 0;
}

std::string 还会根据字符串的长度动态调整其内部缓冲区的大小。当字符串长度增加时,如果当前缓冲区不足以容纳新的内容,std::string 会重新分配一块更大的内存,并将原内容复制过去。

字符串操作函数

C 风格字符串函数

C 标准库提供了一系列用于操作 C 风格字符串的函数,定义在 <cstring> 头文件中。

  • strcpy:用于将一个字符串复制到另一个字符串。其原型为 char* strcpy(char* destination, const char* source);。如前文所述,使用时需要确保目标缓冲区足够大。
  • strcat:用于将一个字符串追加到另一个字符串的末尾。原型为 char* strcat(char* destination, const char* source);。同样要注意目标缓冲区的大小。
  • strcmp:用于比较两个字符串。原型为 int strcmp(const char* str1, const char* str2);。如果 str1 小于 str2,返回负整数;如果相等,返回 0;如果 str1 大于 str2,返回正整数。

std::string 成员函数

std::string 类提供了大量功能丰富的成员函数。

  • length:返回字符串的长度(不包括结尾的空字符)。例如:
#include <iostream>
#include <string>

int main() {
    std::string str = "Hello";
    std::cout << "Length of the string: " << str.length() << std::endl;
    return 0;
}
  • substr:用于提取子字符串。原型为 string substr(size_t pos = 0, size_t len = npos) const;pos 是起始位置,len 是子字符串的长度。例如:
#include <iostream>
#include <string>

int main() {
    std::string str = "Hello, World!";
    std::string sub = str.substr(7, 5); // 从位置 7 开始,长度为 5
    std::cout << sub << std::endl;
    return 0;
}
  • find:用于查找子字符串在字符串中的位置。原型为 size_t find(const string& str, size_t pos = 0) const;pos 是开始查找的位置。例如:
#include <iostream>
#include <string>

int main() {
    std::string str = "Hello, World!";
    size_t pos = str.find("World");
    if (pos != std::string::npos) {
        std::cout << "Found at position: " << pos << std::endl;
    } else {
        std::cout << "Not found" << std::endl;
    }
    return 0;
}

向量(Vector)

向量的基本概念

std::vector 是 C++ 标准库中的动态数组容器。与普通数组不同,std::vector 可以动态调整大小,在运行时根据需要分配和释放内存。

向量的创建和初始化

默认初始化

可以创建一个空的 std::vector

#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers;
    std::cout << "Size of the vector: " << numbers.size() << std::endl;
    return 0;
}

这里创建了一个 std::vector<int> 类型的 numbers 向量,初始大小为 0。

初始化列表初始化

C++11 引入了初始化列表,可以方便地初始化 std::vector

#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};
    for (int num : numbers) {
        std::cout << num << " ";
    }
    std::cout << std::endl;
    return 0;
}

上述代码通过初始化列表为 numbers 向量赋予了初始值。

拷贝初始化

可以通过拷贝另一个 std::vector 来创建新的向量:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> original = {1, 2, 3};
    std::vector<int> copy = original;
    for (int num : copy) {
        std::cout << num << " ";
    }
    std::cout << std::endl;
    return 0;
}

这里 copy 向量是 original 向量的一个副本。

向量的内存管理

std::vector 内部使用连续的内存空间来存储元素。当向量的大小增加时,如果当前分配的内存不足以容纳新的元素,std::vector 会重新分配一块更大的内存,将原有的元素复制到新的内存位置,然后释放旧的内存。

向量有两个重要的属性:sizecapacitysize 表示当前向量中实际存储的元素个数,而 capacity 表示向量在不重新分配内存的情况下能够容纳的最大元素个数。例如:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers;
    std::cout << "Initial size: " << numbers.size() << ", Initial capacity: " << numbers.capacity() << std::endl;
    for (int i = 0; i < 10; ++i) {
        numbers.push_back(i);
        std::cout << "Size: " << numbers.size() << ", Capacity: " << numbers.capacity() << std::endl;
    }
    return 0;
}

在上述代码中,可以观察到随着元素的不断添加,size 不断增加,而 capacity 会在特定的时机(通常是 size 达到 capacity 时)翻倍。

向量的操作函数

元素访问

可以通过下标运算符 []at 成员函数来访问向量中的元素。[] 运算符不进行边界检查,如果访问越界会导致未定义行为,而 at 成员函数会在访问越界时抛出 std::out_of_range 异常。例如:

#include <iostream>
#include <vector>
#include <exception>

int main() {
    std::vector<int> numbers = {1, 2, 3};
    try {
        std::cout << "Element at index 1 using []: " << numbers[1] << std::endl;
        std::cout << "Element at index 1 using at(): " << numbers.at(1) << std::endl;
        std::cout << "Element at index 3 using at(): " << numbers.at(3) << std::endl; // 这会抛出异常
    } catch (const std::out_of_range& e) {
        std::cerr << "Out of range error: " << e.what() << std::endl;
    }
    return 0;
}

添加和删除元素

  • push_back:在向量的末尾添加一个元素。例如:
#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers;
    numbers.push_back(1);
    numbers.push_back(2);
    for (int num : numbers) {
        std::cout << num << " ";
    }
    std::cout << std::endl;
    return 0;
}
  • pop_back:删除向量末尾的元素。例如:
#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers = {1, 2, 3};
    numbers.pop_back();
    for (int num : numbers) {
        std::cout << num << " ";
    }
    std::cout << std::endl;
    return 0;
}
  • erase:删除指定位置或指定范围的元素。例如:
#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};
    numbers.erase(numbers.begin() + 2); // 删除索引为 2 的元素
    for (int num : numbers) {
        std::cout << num << " ";
    }
    std::cout << std::endl;

    numbers.erase(numbers.begin(), numbers.begin() + 2); // 删除前两个元素
    for (int num : numbers) {
        std::cout << num << " ";
    }
    std::cout << std::endl;
    return 0;
}

数组(Array)

数组的基本概念

C++ 中的数组是一种固定大小的连续内存区域,用于存储相同类型的多个元素。数组的大小在编译时就已经确定,不能在运行时动态改变。

数组的声明和初始化

静态数组声明

可以声明一个静态数组,例如:

int numbers[5];

这里声明了一个名为 numbers 的整数数组,大小为 5。数组的元素默认是未初始化的,如果需要初始化,可以使用以下方式:

int numbers[5] = {1, 2, 3, 4, 5};

也可以部分初始化,未初始化的元素会被初始化为 0(对于整数类型):

int numbers[5] = {1, 2};

此时 numbers[0] 为 1,numbers[1] 为 2,numbers[2]numbers[3]numbers[4] 为 0。

动态数组声明

使用 new 关键字可以在堆上分配动态数组:

int *dynamicNumbers = new int[5];

这里 dynamicNumbers 是一个指向堆上分配的整数数组的指针。动态数组在使用完毕后需要手动释放内存,使用 delete[] 操作符:

delete[] dynamicNumbers;

数组的内存布局

数组在内存中是连续存储的,这使得对数组元素的访问非常高效。例如,对于一个 int 类型的数组:

#include <iostream>

int main() {
    int numbers[3] = {1, 2, 3};
    std::cout << "Address of numbers[0]: " << &numbers[0] << std::endl;
    std::cout << "Address of numbers[1]: " << &numbers[1] << std::endl;
    std::cout << "Address of numbers[2]: " << &numbers[2] << std::endl;
    return 0;
}

可以看到数组元素的地址是连续的,相邻元素的地址相差 sizeof(int) 字节。

数组与指针的关系

在 C++ 中,数组名在大多数情况下会隐式转换为指向数组首元素的指针。例如:

#include <iostream>

int main() {
    int numbers[3] = {1, 2, 3};
    int *ptr = numbers;
    std::cout << "Value at ptr: " << *ptr << std::endl;
    std::cout << "Value at numbers[0]: " << numbers[0] << std::endl;
    return 0;
}

这里 ptrnumbers 指向相同的内存位置。通过指针可以像访问数组元素一样访问内存中的数据,例如 *(ptr + 1) 等价于 numbers[1]

多维数组

二维数组

二维数组可以看作是数组的数组。例如,声明一个二维整数数组:

int matrix[3][4];

这里 matrix 是一个 3 行 4 列的二维数组。可以这样初始化:

int matrix[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

访问二维数组的元素使用两个下标,例如 matrix[1][2] 表示第二行第三列的元素。

多维数组的内存布局

多维数组在内存中也是连续存储的。以二维数组为例,先存储第一行的所有元素,然后是第二行,以此类推。例如对于上述的 matrix 数组,内存布局如下:

1 2 3 4 5 6 7 8 9 10 11 12

数组的局限与 std::vector 的优势

数组的主要局限在于其大小固定,在运行时不能动态调整。这在很多情况下会带来不便,例如在处理未知大小的数据集合时。而 std::vector 可以动态调整大小,并且提供了丰富的成员函数来操作数据,如添加、删除元素等。同时,std::vector 还负责自动管理内存,避免了手动内存管理可能出现的错误,如内存泄漏和缓冲区溢出。

例如,假设要从用户输入中读取一些整数并存储起来,如果使用数组,需要预先知道最大可能的输入数量:

#include <iostream>

int main() {
    int numbers[100]; // 假设最多输入 100 个整数
    int count = 0;
    int num;
    while (std::cin >> num) {
        if (count >= 100) {
            std::cerr << "Array is full" << std::endl;
            break;
        }
        numbers[count++] = num;
    }
    return 0;
}

而使用 std::vector 就可以避免这种限制:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers;
    int num;
    while (std::cin >> num) {
        numbers.push_back(num);
    }
    return 0;
}

这样 std::vector 可以根据实际输入动态调整大小,更加灵活和安全。

综上所述,在 C++ 编程中,根据具体的需求选择合适的数据结构非常重要。字符串、向量和数组各有其特点和适用场景,深入理解它们的本质和操作方式,有助于编写出高效、健壮的代码。