第一次写这么多内容,可否支持一下🙏。
单链表目录
- 前言
- 流程规划
- 初始编码
- 1. 第一个问题:`explicit` 是什么
- 2. `std::size_t` 是什么意思
- 3. 为什么不把 4 直接写在 `capacity_` 之后
- 4. `data_ = std::make_unique<int[]>(capacity_);` 是什么意思
- 4.1 没有写 `new`,怎么开辟内存?
- 初始状态接口
- 1.noexcept什么意思
- const
- 访问元素函数
- 允许修改
- 1.int& at引用问题
- 2.超出异常out_of_range
- 只允许读取
- 动态数组扩容
- 1.data_[i]难道不是unique_ptr吗,为什么可以for循环复制
- 2.data_ = std::move(new_data);什么意思
- 尾部插入与自动扩容
- 为什么new_capacity = capacity_ * 2;
- 插入删除查找
- 1.插入
- 循环应该从 size_ 开始,而不是 capacity_
- 删除
- 查找
- 完整代码呈现
前言
有没有像我一样,在学完 C 语言后就学习数据结构的同学?之前我一直以为我的数据结构已经可以了,但现在学完了 C++ 和很多知识后,不少内容都有些疏漏和遗忘,但又不想重新学习一遍数据结构。那么我们不妨大胆一点,直接跟进 C++ 和操作系统等知识,以一个未来工作的视角去看待数据结构的编写。
因此,这篇博客中,数据结构的很多基础内容讲的比较少
这篇文章或许会很长,但它会一步一步地把所有顺序表以及新增的 C++ 内容全部讲清楚。这可是我一步一步追着 AI 刨根问底的结果。或许各位大佬都比我厉害,不少知识可能赘述过多,还请谅解,毕竟,这也是对我自己的一次自测。
本篇内容多以代码实现及个人理解为主,理论知识只会偶尔涉及。
流程规划
我们都清楚,顺序表的主要形式是:
写代码之前,首先明确我们顺序表的整体结构、接口数量等,且创建一个cpp文件。
| 接口 | 功能 |
|---|---|
push_back | 尾部插入 |
insert | 指定位置插入 |
erase | 删除指定位置元素 |
find | 查找元素 |
at | 访问指定位置的元素 |
size | 返回当前元素数量 |
capacity | 返回当前容量 |
reserve | 扩大容量,减少重复分配 |
依据这个表格,我们一步一步进行编码。
初始编码
简单过一下代码即可,暂时不用全部理解。
#include<iostream>#include<cstddef>#include<memory>classSeqList{private:// 当前元素数量std::size_t size_;// 当前数组容量std::size_t capacity_;// 管理动态数组std::unique_ptr<int[]>data_;public:// 构造函数:创建一个指定初始容量的顺序表explicitSeqList(std::size_t initial_capacity=4):size_(0),capacity_(initial_capacity){if(capacity_>0){data_=std::make_unique<int[]>(capacity_);}}};1. 第一个问题:explicit是什么
这在基础课程中几乎不涉及。
这是一个 C++ 关键字,主要用来禁止编译器擅自进行隐式类型转换。
为方便理解,我们先以一个简单代码举例。
在如下代码中,我们设计了一个Student类,其中Student(int age)是一个构造函数。调用它时需要给age一个值。见main函数,可知Student s = 18;把Student类型的变量s当成普通整数去赋值了。这其实不是标准写法,但编译器明白你想干什么,于是用隐式转换的方式,在系统内部转换为Student s = Student(18);,于是成功运行。
#include<iostream>classStudent{public:Student(intage){std::cout<<"年龄:"<<age<<std::endl;}};intmain(){Student s=18;}而如果我们加上explicit,Student s = 18;就不允许了,因为它禁止隐式转换。
#include<iostream>classStudent{public:explicitStudent(intage){std::cout<<"年龄:"<<age<<std::endl;}};intmain(){// Student s = 18; // 编译错误Students(18);// 正确Student t{18};// 正确}2.std::size_t是什么意思
std::size_t在这里提一嘴,此类型包含在#include <cstddef>里面,通常表示非负整数。
后面的initial_capacity是一个变量名,表示初始容量。
3. 为什么不把 4 直接写在capacity_之后
// 写法一explicitSeqList(std::size_t initial_capacity=4):size_(0),capacity_(initial_capacity)// 写法二explicitSeqList():size_(0),capacity_(4)完全可以!而且如果你只希望顺序表的默认容量固定为 4,这种写法反而更直观。
但是,这两种写法也有区别:
| 创建方式 | 写法一 | 写法二 |
|---|---|---|
SeqList list; | 容量为 4 | 容量为 4 |
SeqList list(10); | 容量为 10 | 编译错误 |
SeqList list(100); | 容量为 100 | 编译错误 |
因此,为了灵活更改,我们一般不要把容量写死。
4.data_ = std::make_unique<int[]>(capacity_);是什么意思
需要#include <memory>。
std::make_unique出现于 C++17。
动态申请一块能够存放capacity_个int元素的数组,并让智能指针data_管理这块内存。
4.1 没有写new,怎么开辟内存?
这是现代 C++ 与所学基础 C++ 的区别。
int*data=newint[4];这种写法,大家肯定熟悉。
而我们今天不这么写了。
data_=std::make_unique<int[]>(4);虽然没有直接写new,但std::make_unique在内部负责完成动态内存分配,并把分配得到的资源交给智能指针管理。
而且还有一个好处:make_unique出来的数组元素会被初始化为 0,而new一般不会。
初始状态接口
// 获取当前元素数量std::size_tsize()constnoexcept{returnsize_;}// 获取当前容量std::size_tcapacity()constnoexcept{returncapacity_;}// 判断顺序表是否为空boolempty()constnoexcept{returnsize_==0;}此段代码主要实现,获取元素数量,获取容量,判断是否为空。
元素数量与容量并不相同,可以向上翻看图片内容
1.noexcept什么意思
noexcept是一个来自C++11的关键字
一般放在函数声明之后。
作用是阻止异常从函数里抛出去。
这里与throw的作用做一个对比。
| 关键字 | 作用 | 通俗理解 |
|---|---|---|
throw | 抛出异常 | 把问题交出去 |
noexcept | 声明函数不会让异常跑出去 | 保证问题不会以异常的形式传出去 |
为了更好理解,这里给出一个例子(在讲noexcept之前,我们先弄清楚throw)
intdivide(inta,intb){if(b==0){throw1;}returna/b;}该实例是一个除法问题,其中除数不能为0,因此当输入参数b为0时,明显有异常,throw把异常抛出函数,throw 1 会让程序跳过 divide() 中剩下的代码,去寻找能够处理这个异常的 catch。
如果找到,就进入对应的 catch;如果没有找到,最终会调用std::terminate(),一般会终止。
try//尝试执行代码,并在指定位置接住异常、处理问题{intresult=divide(10,0);std::cout<<result;}catch(inte){std::cout<<"除数不能为 0";}程序不必因此整体退出,还可以继续执行后面的代码。
所以,抛出异常不是为了让程序报错退出,而是为了把无法正常处理的问题交给其他代码处理。
这样我们清楚了抛出异常的过程
接下来就可以看看noexcept了
回到我们原本的代码
std::size_tsize()constnoexcept{returnsize_;}它的作用只是返回顺序表当前有多少个元素。
没有必要抛出异常,于是你可以写 noexcept,明确告诉 C++
这个函数保证不会让异常跑到函数外面。
对于 size() 这样的简单查询函数,我们可以保证其执行过程中不会抛出异常。调用者因此可以依赖这一异常保证,编译器也可能利用 noexcept 信息进行优化。
但如果你写了noexcept,又让异常跑了出来,C++ 就会调用std::terminate(),通常直接使程序终止
const
为什么这里要加const,这是为了告诉C++,此段函数不会对顺序表做出修改,只读取顺序表数据。
这算是一道防线,如果没有const。
std::size_tsize()noexcept{size_=0;// 编译器允许returnsize_;}因此,加上const限制,防止意外修改。
访问元素函数
允许修改
#include<stdexcept>//添加头文件//使用 std::out_of_range 需要包含头文件:// 访问元素:只允许读取constint&at(std::size_t index)const{if(index>=size_){throwstd::out_of_range("index out of range");}returndata_[index];}1.int& at引用问题
以引用的方式传入顺序表数据,不需要复制原件,直接对顺序表本身做修改。
2.超出异常out_of_range
std::out_of_range是C++标准库提供的一种异常类型,用来表示访问的数据超出了允许的范围。
至于为什么要用out_of_range,正常情况下
throw1;// 抛出一个整数别人不知道1是什么,因此有局限性。
而
throwstd::out_of_range("Index out of range");一眼就可以知道是什么异常,更重要的是调用者可以专门捕获这种异常。
try{intvalue=list.get(5);}catch(conststd::out_of_range&e){std::cout<<e.what();//e.what():获取异常附带的错误描述。}const std::out_of_range& e用引用接收这个异常对象。
只允许读取
// 访问元素:只允许读取constint&at(std::size_t index)const{if(index>=size_){throwstd::out_of_range("index out of range");}returndata_[index];}此段函数与上面的相似,不再做细致解析
动态数组扩容
#include<utility>// 扩大容量voidreserve(std::size_t new_capacity){if(new_capacity<=capacity_){return;}autonew_data=std::make_unique<int[]>(new_capacity);for(std::size_t i=0;i<size_;++i){new_data[i]=data_[i];}data_=std::move(new_data);// move需要添加 #include <utility>capacity_=new_capacity;}扩容结构,用户传进来一个新容量,判断,如果该容量小于原容量,直接撤掉。如果大于原容量,就建立一个新的new_data并为其开辟空间,方法在初始编码中有涉及。
然后通过for遍历容器,并将原本data里面的数据调整到new_data里面
1.data_[i]难道不是unique_ptr吗,为什么可以for循环复制
data_是管理整个数组的智能指针,而 data_[i] 是数组中第 i+1 个元素。它们不是同一个东西
| 写法 | 含义 | 类型 |
|---|---|---|
data_ | 管理数组的智能指针 | std::unique_ptr<int[]> |
| data_[0] | 第一个元素 | int |
| data_[1] | 第二个元素 | int |
正因此,可以通过for循环去复制元素。
2.data_ = std::move(new_data);什么意思
让new_data接管data的内存。
std::unique_ptr<int[]>data_;它负责管理顺序表当前使用的数组。
现在可以把它们理解成:
data_ 原来的数组管理者。
new_data 新数组的管理者。
通过move,转接管理权。
而由于data_和new_data都是unique_ptr
因此不可复制
即
data_=new_data;// 错误:试图复制data_=std::move(new_data);// 正确:转移管理权尾部插入与自动扩容
// 在末尾添加元素voidpush_back(intvalue){if(size_==capacity_){std::size_t new_capacity=1;if(capacity_>0){new_capacity=capacity_*2;}reserve(new_capacity);}data_[size_]=value;++size_;}这段代码逻辑比较简单,主要就是检查容量,扩展容量,更新元素
size_ 表示当前元素数量,capacity_ 表示当前容量。
当两者相等时,说明数组已经装满,不能直接添加新元素,需要先扩容。
为什么new_capacity = capacity_ * 2;
这里先把新容量设为 1。
如果当前容量大于 0,就把容量扩大为原来的两倍。
例如:当前容量为 0,新容量设为 1。当前容量为 4,新容量变为 8。当前容量为 8,新容量变为 16。
这样做可以避免每添加一个元素就重新申请数组,减少频繁扩容带来的开销。
插入删除查找
1.插入
voidinsert(std::size_t index,intvalue){if(index>size_){throwstd::out_of_range("index out of range");}if(size_==capacity_){std::size_t new_capacity=1;if(capacity_>0){new_capacity=capacity_*2;}reserve(new_capacity);}for(std::size_t i=size_;i>index;i--){data_[i]=data_[i-1];}data_[index]=value;size_++;}有关插入涉及更多的是数据结构基础与函数调用。
我们设计两个传入参数,分别是插入位置和插入元素值。
函数的最开始是要检查输入是否异常,如果index大于size_直接抛出越界异常即可。
如果size_等于capacity_,我们需要扩大容量,方法与push_back函数类似,不再重复。
把异常情况排除之后,我们就可以考虑真正的插入操作了。
从后往前遍历,直到插入位置,这期间的元素都要向后移动一位,腾出插入位置。
循环应该从 size_ 开始,而不是 capacity_
有没有过这种想法,如果容量为10,大小为4,那么如果我要在位置7插入数据的话,难道就不对吗
这里恰好涉及顺序表的一个核心概念:容量 capacity_ 和元素数量 size_ 是两个不同的东西,但合法插入位置由 size_ 决定,而不是由 capacity_ 决定。
capacity_ = 10
size_ = 4
下标: 0 1 2 3. 4 5 6 7 8 9
数据: 10 20 30 40 · · · · · ·
└──── 有效元素 ────┘
合法位置为0,1,2,3,4
4为末位,因此也是合法的
如果想在7位置插入数据呢。
顺序表的规则是在已有的序列中插入数据,而隔位插入是非法的,如果你确实想让下标 7 存储 ,那是另一种接口设计,需要明确规定中间空位如何处理。
删除
voiderase(std::size_t index){if(index>=size_){throwstd::out_of_range("index out of range");}for(std::size_t i=index+1;i<size_;i++){data_[i-1]=data_[i];}size_--;}你的底层数组由std::unique_ptr<int[]>管理。
这里的data_[index]是一个 int 元素,不是通过 new 单独创建的对象。
因此:既不符合删除单个元素的方式,也无法实现需要的效果。顺序表删除元素,本质上是移动元素并减少 size_,不需要手动释放单个元素的内存。
查找
intfind(intvalue)constnoexcept{for(std::size_t i=0;i<size_;i++){if(data_[i]==value){returni;}}return-1;}完整代码呈现
#include<utility>#include<cstddef>#include<iostream>#include<memory>#include<stdexcept>classSeqList{private:// 当前元素数量std::size_t size_;// 当前数组容量std::size_t capacity_;// 管理动态数组std::unique_ptr<int[]>data_;public:// 构造函数explicitSeqList(std::size_t initial_capacity=4):size_(0),capacity_(initial_capacity){if(capacity_>0){data_=std::make_unique<int[]>(capacity_);}}// 获取当前元素数量std::size_tsize()constnoexcept{returnsize_;}// 获取当前容量std::size_tcapacity()constnoexcept{returncapacity_;}// 判断顺序表是否为空boolempty()constnoexcept{returnsize_==0;}// 访问元素:允许修改int&at(std::size_t index){if(index>=size_){throwstd::out_of_range("index out of range");}returndata_[index];}// 访问元素:只允许读取constint&at(std::size_t index)const{if(index>=size_){throwstd::out_of_range("index out of range");}returndata_[index];}// 扩大容量voidreserve(std::size_t new_capacity){if(new_capacity<=capacity_){return;}autonew_data=std::make_unique<int[]>(new_capacity);for(std::size_t i=0;i<size_;++i){new_data[i]=data_[i];}data_=std::move(new_data);capacity_=new_capacity;}// 在末尾添加元素voidpush_back(intvalue){if(size_==capacity_){std::size_t new_capacity=1;if(capacity_>0){new_capacity=capacity_*2;}reserve(new_capacity);}data_[size_]=value;++size_;}// 在指定位置插入元素voidinsert(std::size_t index,intvalue){if(index>size_){throwstd::out_of_range("index out of range");}if(size_==capacity_){std::size_t new_capacity=1;if(capacity_>0){new_capacity=capacity_*2;}reserve(new_capacity);}for(std::size_t i=size_;i>index;i--){data_[i]=data_[i-1];}data_[index]=value;size_++;}// 删除指定位置的元素voiderase(std::size_t index){if(index>=size_){throwstd::out_of_range("index out of range");}for(std::size_t i=index+1;i<size_;i++){data_[i-1]=data_[i];}size_--;}// 查找元素,返回下标;不存在时返回 -1intfind(intvalue)constnoexcept{for(std::size_t i=0;i<size_;i++){if(data_[i]==value){returni;}}return-1;}// 遍历输出顺序表voidprint()const{for(std::size_t i=0;i<size_;++i){std::cout<<data_[i]<<" ";}std::cout<<'\n';}};intmain(){SeqList list;list.push_back(10);list.push_back(20);list.push_back(30);list.print();list.insert(2,88);list.print();list.erase(3);list.print();list.find(10);//测试自动扩容list.push_back(10);list.push_back(20);list.push_back(30);// 修改元素list.at(1)=25;list.print();// 测试越界访问boolexception_caught=false;// 测试越界访问try{list.at(list.size());std::cout<<"Error: expected an exception\n";}catch(conststd::out_of_range&){std::cout<<"Out-of-range test passed\n";}list.print();std::cout<<"Basic tests passed.\n";}如果这篇内容对你有帮助,欢迎点赞支持一下 🙏。