前言
std::vector<std::vector<int>>是 C++ 里表示二维数组最常用的方式:外层 vector 的每个元素又是一行 vector。它支持动态行数、动态列数、不规则行(锯齿数组),用起来比裸的int**安全得多。
标题里写成vector<vector<int> >,两个右尖括号之间有一个空格。这是 C++98/03 时代的必需写法:当时>>会被词法分析成右移运算符,嵌套模板必须加空格。C++11 起>>可以被正确识别为两个模板参数的结束,所以vector<vector<int>>也能编译。两种写法在现代编译器上都合法,本文按标题保留空格,代码里用不带空格的写法,读者不必纠结。
常见误解有四类。第一,vector<vector<int>> v(3);以为创建了 3×3 的矩阵,实际上只创建了 3 个空行,访问v[0][0]是未定义行为(undefined behavior, UB)。第二,把行列顺序写反,vector<vector<int>> v(cols, vector<int>(rows))得到的是 cols 行 rows 列。第三,认为二维 vector 的内存是连续的,可以整体memcpy或传给需要连续数组的接口。第四,保存了某一行的引用,随后对外层push_back导致外层扩容,引用悬垂。
本文按「构造、访问、内存布局、完整示例」四部分讲清这些点。
一、构造与维度
创建一个rows行、cols列、初值为 0 的矩阵:
std::vector<std::vector<int>> matrix(
rows, std::vector<int>(cols, 0));外层构造函数的第一个参数是行数,第二个参数是「每一行的初始值」,这里用std::vector<int>(cols, 0)构造一个长度为 cols、元素全为 0 的行。外层会把这个行对象拷贝 rows 次(实际实现可能优化,但语义上是拷贝),所以行列顺序不能反。
常见构造方式:
| 写法 | 结果 |
|---|
vector<vector<int>> v; | 空,0 行 |
vector<vector<int>> v(3); | 3 个空行,每行 0 列 |
vector<vector<int>> v(3, vector<int>(4)); | 3 行 4 列,值初始化 0 |
vector<vector<int>> v(3, vector<int>(4, 7)); | 3 行 4 列,全 7 |
vector<vector<int>> v{{1,2}, {3,4,5}}; | 2 行,列数分别为 2、3(锯齿) |
如果行数或列数在运行时才知道,用变量传入即可,但要注意类型:构造函数参数是size_type,传负数会转换成很大的无符号数,导致抛出std::length_error或std::bad_alloc。来自用户输入的维度要先校验范围。
锯齿数组是二维 vector 相对裸数组的优势:每行可以有不同的size()。例如邻接表、杨辉三角、按行读取的不定长数据,都适合用vector<vector<int>>。但这也意味着不能假定所有行等长,遍历时要用v[i].size()而不是外层的某个固定列数。
二、访问、遍历与修改
访问元素用两次下标:v[i][j]。第一次得到第 i 行(vector<int>的引用),第二次得到该行的第 j 个元素。v.size()是行数,v[i].size()是第 i 行的列数。对空容器调用v[0]是 UB,取列数前要先确认行存在。
operator[]不做边界检查,越界是 UB;at()会检查并抛出std::out_of_range,但二维访问要写v.at(i).at(j),只在外层用at并不能保护内层。调试阶段可以用at,性能敏感的循环用[],前提是下标范围自己保证。
遍历的常见写法:
for (std::size_t i = 0; i < v.size(); ++i) {
for (std::size_t j = 0; j < v[i].size(); ++j) {
// 使用 v[i][j]
}
}
for (const auto& row : v) {
for (int x : row) {
// 只读
}
}范围 for 里const auto&很关键:写成auto row会拷贝整行,矩阵大时开销明显;写成auto& row可以修改元素,但要注意不要在遍历中改变行数。内层int x按值读取没问题,若要修改元素,写成int& x。
增加一行用v.push_back(std::vector<int>(cols, 0));,增加一列则要对每一行分别push_back,并保证所有行都加,否则矩阵会变成锯齿。删除行用v.erase(v.begin() + i);,删除列要遍历每一行erase对应位置,复杂度是 O(rows * cols),因为每行都要搬移。
三、内存布局与性能
二维 vector 不是一整块连续内存。外层 vector 连续存放的是「行对象」(每个vector<int>的控制块,通常包含三个指针),每个行对象再各自指向一块独立的堆内存。因此:
- 行与行之间不保证连续,不能把
v.data()当成int*传给需要二维连续数组的接口。 - 整体拷贝、序列化、缓存友好性都不如一块连续内存。
- 外层扩容时,行对象会被搬移,但行对象内部指向的元素缓冲区通常也跟着搬移(vector 的移动构造会转移指针),所以「行内元素的地址」在一般情况下仍可能变化,不要依赖。
如果算法对缓存敏感、矩阵很大,可以用一维std::vector<int> flat(rows * cols),用flat[i * cols + j]访问。这样只有一次分配、内存连续,传参也方便。代价是要自己管理行列换算,代码不如二维直观。选择哪种,取决于矩阵规模和访问模式,不要为了「看起来像二维」而牺牲明显的局部性。
保存行引用时要小心:
std::vector<int>& row = v[0];
v.push_back(std::vector<int>(cols, 0)); // 外层可能扩容
row[0] = 1; // row 可能悬垂外层push_back导致重新分配时,所有行对象被移动到新缓冲区,原来的row引用失效。正确做法是操作完成后重新v[0],或者先reserve足够的行数,避免扩容。reserve只保证外层容量,不改变行数,也不影响行内缓冲区。
四、完整示例
下面的程序构造一个 3×4 矩阵并填充,演示锯齿数组、转置和追加行。转置前假设矩阵是矩形,因为转置后的列数等于原行数,锯齿数组需要额外处理。
// 适用于 C++17
#include <iostream>
#include <vector>
int main() {
const std::size_t rows = 3;
const std::size_t cols = 4;
// 3 行 4 列,初值 0
std::vector<std::vector<int>> matrix(
rows, std::vector<int>(cols, 0));
for (std::size_t i = 0; i < rows; ++i) {
for (std::size_t j = 0; j < cols; ++j) {
matrix[i][j] = static_cast<int>(i * cols + j);
}
}
// 锯齿数组:每行列数不同
std::vector<std::vector<int>> jagged;
jagged.push_back({1, 2, 3});
jagged.push_back({4, 5});
jagged.push_back({6});
for (const auto& row : jagged) {
for (int x : row) {
std::cout << x << ' ';
}
std::cout << '\n';
}
// 转置:cols 行 rows 列
std::vector<std::vector<int>> transposed(
cols, std::vector<int>(rows, 0));
for (std::size_t i = 0; i < rows; ++i) {
for (std::size_t j = 0; j < cols; ++j) {
transposed[j][i] = matrix[i][j];
}
}
// 追加一行
matrix.push_back(std::vector<int>(cols, 9));
std::cout << matrix.size() << ' ' << matrix[0].size() << '\n';
// 输出转置矩阵
for (const auto& row : transposed) {
for (int x : row) {
std::cout << x << ' ';
}
std::cout << '\n';
}
return 0;
}jagged.push_back({1, 2, 3});会用一个初始化列表构造临时的vector<int>,再移动进外层;这是 C++11 起支持的写法。transposed[j][i] = matrix[i][j];的下标顺序不能反:转置后的第 j 行第 i 列等于原矩阵第 i 行第 j 列。
常见坑点
1. 只指定行数就访问元素
❌std::vector<std::vector<int>> v(3); v[0][0] = 1;
✅v(3, std::vector<int>(4, 0));,每行先有列数。
2. 行列顺序写反
❌vector<vector<int>> v(cols, vector<int>(rows));却按 rows×cols 使用。
✅ 外层参数是行数,内层参数是列数,写之前先确认。
3. 遍历时用固定列数
❌for (j = 0; j < cols; ++j)访问锯齿数组的每一行。
✅ 用v[i].size()作为内层上界。
4. 范围 for 按值拷贝行
❌for (auto row : v),每行复制一次。
✅for (const auto& row : v)只读,或auto&需要修改时。
5. 认为二维 vector 内存连续
❌memcpy(dest, v.data(), rows * cols * sizeof(int));
✅ 逐行复制,或改用一维vector<int>连续存储。
6. 保存行引用后外层扩容
❌auto& row = v[0]; v.push_back(...); row[0] = 1;
✅ 扩容后重新获取v[0],或先v.reserve(n)。
7. 空矩阵取列数
❌v[0].size(),而v为空。
✅ 先判断!v.empty(),再访问第一行。
8. 删除列时只删一行
❌ 只对v[0].erase(...),其他行长度不变,矩阵变锯齿。
✅ 对每一行都删除对应列,或重新构造矩阵。
总结
| 操作 | 写法 | 注意 |
|---|
| 构造矩形 | v(rows, vector<int>(cols, 0)) | 行在前、列在后 |
| 构造锯齿 | 逐行push_back | 每行可不同长 |
| 访问 | v[i][j] | 先保证行非空 |
| 行数/列数 | v.size()/v[i].size() | 列数按行取 |
| 追加行 | v.push_back(vector<int>(cols, 0)) | 可能触发外层扩容 |
| 追加列 | 每行push_back | 所有行都要加 |
| 转置 | t[j][i] = v[i][j] | 下标别反 |
| 连续内存 | 一维vector<int>展开 | 性能优先时考虑 |
二维 vector 的用法可以归纳为「外层管行、内层管列、每行独立分配」。构造时把行列参数放对,访问前确认行非空,遍历时按v[i].size()取列数,再用reserve或一维展开处理性能问题,基本就不会踩到未定义行为和悬垂引用。