数据结构
数据结构与算法
1算法(algorithm)
1.1算法定义
在有限时间内解决特定问题的一组指令或者步骤
特性:
- 明确问题,包含输入输出
- 可行性,能在有限步骤和时间空间内完成
- 每一步定义明确,在相同输入和运行条件下,输出结果始终相同
1.2数据结构定义(data structure)
组织和存储数据的方式,涵盖数据内容,数据之间关系和操作方法
设计目标:
- 空间占用尽量小------------节省内存
- 数据操作快速---------------数据访问控制
- 逻辑和数据表示简洁------高效运行
1.3数据结构与算法的关系
- 数据结构为算法提供了结构化存储的数据和操作数据的方法
- 数据结构本身仅存储数据,结合算法才能解决特定问题
- 算法通常可以基于不同数据结构实现,但执行效率相差很大,选择合适数据结构很关键
2复杂度
2.1算法效率评估
算法设计中,先后追求两个层面目标
-
找到问题解法:在规定的要求内找到真确解
-
寻找最优解法:高效解决方法
算法效率的维度:
- 时间效率:运行时间
- 空间效率:占用内存空间大小
效率评估方法:实际测试,理论估算
2.1.1实际测试
实际测试最直观的方法就是在设备上运行并记录运行时间和内存占用情况,反映真实情况但存在很大局限性。
一,测试环境的干扰:硬件配置、算法并行程度、算法内存操作机密。种种因素需要我们在各种机器测试并统计平均效率(不现实)
二,完整测试耗费资源:数据量的不同算法效率会浮动。因此,需要测试各种规模的输入数据
2.1.2理论估算
通过计算评估算法的效率:渐进复杂度分析(描述输入数据大小的增加,算法执行时间和空间增长的趋势)
复杂度分析相较于实际测试的优点
- 不需要实际运行代码
- 分析结果适用于所有平台
- 可以体现不同数据量下的算法效率
2.2迭代和递归
在程序中实现重复执行任务,两种基本的程序控制结构:迭代、递归
2.2.1迭代
迭代(iteration)是重复执行某个任务的控制结构。在迭代中程序会满足一定条件下重复执行某段代码,直至不在满足
1. for循环
//适合在预先知道迭代次数时使用
/* for 循环 */
int forLoop(int n) {
int res = 0;
// 循环求和 1, 2, ..., n-1, n
for (int i = 1; i <= n; ++i) {
res += i;
}
return res;
}
2. while循环
//while中程序每轮先检查条件决定是否执行
/* while 循环 */
int whileLoop(int n) {
int res = 0;
int i = 1; // 初始化条件变量
// 循环求和 1, 2, ..., n-1, n
while (i <= n) {
res += i;
i++; // 更新条件变量
}
return res;
}
while比for的自由度更高
/* while 循环(两次更新) */
int whileLoopII(int n) {
int res = 0;
int i = 1; // 初始化条件变量
// 循环求和 1, 4, 10, ...
while (i <= n) {
res += i;
// 更新条件变量
i++;
i *= 2;
}
return res;
}
3. 嵌套循环
以for为例:
/* 双层 for 循环 */
string nestedForLoop(int n) {
ostringstream res;
// 循环 i = 1, 2, ..., n-1, n
for (int i = 1; i <= n; ++i) {
// 循环 j = 1, 2, ..., n-1, n
for (int j = 1; j <= n; ++j) {
res << "(" << i << ", " << j << "), ";
}
}
return res.str();
}
2.2.2递归
递归(recursion)----算法策略,通过函数调用自身解决问题
- 递:不断调用,通常传入更小或者更简化参数,直至终止
- 归:触发终止条件后逐层返回(每一层)
- 终止条件:
- 递归调用:函数调用自身通常传入更小或者更简化参数
- 返回结果:将当前层的结果返回到上一层
/* 递归 */
int recur(int n) {
// 终止条件
if (n == 1)
return 1;
// 递:递归调用
int res = recur(n - 1);
// 归:返回结果
return n + res;
}
迭代和递归不同:思考和解决问题的范式
- 迭代:”自下而上“,最基础开始
- 递归:”自上而下“,将问题分解为子问题,子问题和原问题有相同形式,重复过程直到基本情况
- 设f(n) = 1+ 2 + ... + n
- 迭代:从1遍历到n,每轮进行求和
- 递归:将问题分解为f(n) = f(n-1) + n,不断递归下去知道f(1) = 1
1.调用栈
递归函数每次调用自身时,每次都会为新开启的函数分配内存--存储局部变量,调用地址和其他信息。
- 函数上下文数据都存储在称为”栈空间“,函数返回后会被释放。so,递归比迭代更耗费空间内存。
- 因为额外开销,所以比循环的时间效率更低
//在实际中,过深的递归可能导致栈溢出错误
2.尾递归
若函数在返回前最后一步才进行递归调用,那么该函数可以被编译或者解释器优化,这种情况叫做尾递归(tail recursion)。
- 普通递归:当函数返回上一层后,需要继续执行代码,所以系统需要保存上一层调用的上下文。
- 尾递归:递归调用是函数返回前的最后一个操作
/* 尾递归 */
int tailRecur(int n, int res) {
// 终止条件
if (n == 0)
return res;
// 尾递归调用
return tailRecur(n - 1, res + n);
}
- 普通递归:求和操作在归的过程中执行,每层返回都需要执行求和
- 尾递归:求和在递的过程中执行
3.递归树
当问题需要分化处理时,递归比迭代的思路更加直观,以斐波那契数列为例:
- 数列中每个数字时前两个数字的和:f(n) = f(n - 1) + f(n - 2)
/* 斐波那契数列:递归 */
int fib(int n) {
// 终止条件 f(1) = 0, f(2) = 1
if (n == 1 || n == 2)
return n - 1;
// 递归调用 f(n) = f(n-1) + f(n-2)
int res = fib(n - 1) + fib(n - 2);
// 返回结果 f(n)
return res;
}
2.2.3差异
迭代 | 递归 | |
---|---|---|
实现方式 | 循环结构 | 函数效用自身 |
时间效率 | 效率比较高,没有函数调用的开销 | 每次函数调用都会产生开销 |
内存使用 | 通常使用固定内存大小 | 累积调用可能会使用大量的栈帧空间 |
适用问题 | 简单循环任务 | 适用于分解问题,如树、图、分治、回溯等 |
- 递:当函数被调用时,系统会在“调用栈”上为该函数分配新的栈帧,用于存储函数的局部变量、参数、返回地址等数据。
- 归:当函数完成执行并返回时,对应的栈帧会被从“调用栈”上移除,恢复之前函数的执行环境。
使用一个显式栈模拟调用栈的行为,从而将递归转化为迭代:
/* 使用迭代模拟递归 */
int forLoopRecur(int n) {
// 使用一个显式的栈来模拟系统调用栈
stack<int> stack;
int res = 0;
// 递:递归调用
for (int i = n; i > 0; i--) {
// 通过“入栈操作”模拟“递”
stack.push(i);
}
// 归:返回结果
while (!stack.empty()) {
// 通过“出栈操作”模拟“归”
res += stack.top();
stack.pop();
}
// res = 1+2+3+...+n
return res;
}
2.3时间复杂度
评估一段代码的运行时间:
- 确定运行的平台:硬件,语言,系统
- 评估各个操作所占用的时间
- 统计代码中所有的计算操作:总和
// 在某运行平台下
void algorithm(int n) {
int a = 2; // 1 ns
a = a + 1; // 1 ns
a = a * 2; // 10 ns
// 循环 n 次
for (int i = 0; i < n; i++) { // 1 ns
cout << 0 << endl; // 5 ns
}
}
以上的方法总运行时间为1+1+10+(1+5)*n = 6n + 12 //但实际上这种统计方式不现实
2.3.1时间增长趋势
// 算法 A 的时间复杂度:常数阶
void algorithm_A(int n) {
cout << 0 << endl;
}
// 算法 B 的时间复杂度:线性阶
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
cout << 0 << endl;
}
}
// 算法 C 的时间复杂度:常数阶
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
cout << 0 << endl;
}
}
-
算法
A
只有 1 个打印操作,算法运行时间不随着 𝑛 增大而增长。我们称此算法的时间复杂度为“常数阶”。 -
算法
B
中的打印操作需要循环 𝑛 次,算法运行时间随着 𝑛 增大呈线性增长。此算法的时间复杂度被称为“线性阶”。 -
算法
C
中的打印操作需要循环 1000000 次,虽然运行时间很长,但它与输入数据大小 𝑛 无关。因此C
的时间复杂度和A
相同,仍为“常数阶”。 -
时间复杂度能有效评估算法效率。算法
B
在n>1时比A
效率低,算法B
在n>1000000时比算法C
更慢 -
时间复杂度的推算方法更简单。
-
时间复杂度也存在局限性:相同复杂度的形况下,运行时间差别很大,例如
A
和C
,在输入数据较小情况下,B
优于C
2.3.2函数渐进上界
void algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// 循环 n 次
for (int i = 0; i < n; i++) { // +1(每轮都执行 i ++)
cout << 0 << endl; // +1
}
}
上面函数的操作数量为:T(n) = 3 + 2n
将线性阶的时间复杂度记为 𝑂(𝑛) ,这个数学符号称为大 𝑂 记号(big-𝑂 notation),表示函数 𝑇(𝑛) 的渐近上界(asymptotic upper bound)。
2.3.3推算渐进上界
在确定f(n)之后可以得到时间复杂度O(f(n)),之后分为两步:1.统计操作数量,2.判断渐进上界
1.第一步:统计操作数量
若存在正实数 𝑐 和实数 𝑛0 ,使得对于所有的 𝑛>𝑛0 ,均有 𝑇(𝑛)≤𝑐⋅𝑓(𝑛) ,则可认为 𝑓(𝑛) 给出了 𝑇(𝑛) 的一个渐近上界,记为 𝑇(𝑛)=𝑂(𝑓(𝑛)) 。
- 忽略 𝑇(𝑛) 中的常数项。因为它们都与 𝑛 无关,所以对时间复杂度不产生影响。
- 省略所有系数。例如,循环 2𝑛 次、5𝑛+1 次等,都可以简化记为 𝑛 次,因为 𝑛 前面的系数对时间复杂度没有影响。
- 循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第
1.
点和第2.
点的技巧。
例:
void algorithm(int n) {
int a = 1; // +0(技巧 1)
a = a + n; // +0(技巧 1)
// +n(技巧 2)
for (int i = 0; i < 5 * n + 1; i++) {
cout << 0 << endl;
}
// +n*n(技巧 3)
for (int i = 0; i < 2 * n; i++) {
for (int j = 0; j < n + 1; j++) {
cout << 0 << endl;
}
}
}
总操作数:
$$
T(n) = 2n(n+1) + (5n + 1) +2 = 2n^2+7n+3
$$
$$
T(n) = n^2 + n
$$
2.判断渐进上界
时间复杂度主要是T(n)中的最高项决定,例:
操作数量T(n) | 时间复杂度O(f(n)) |
---|---|
100 | O(1) |
3n+2 | O(n) |
2n^2 + 3n | O(n^2) |
n^3 + 2n^2 | O(n3) |
2.3.4常见类型
常见时间复杂度:
$$
O(1)<O(logn)<O(n)<o(nlogn)<O(n2)<O(2n)<O(n!)
$$
1.常数阶O(1)
操作数量尽管很大,但与n无关
2.线性阶O(n)
线性阶操作数量相对于n线性增长,通常出现在单层循环中
/* 线性阶 */
int linear(int n) {
int count = 0;
for (int i = 0; i < n; i++)
count++;
return count;
}
遍历数组或者链表时间复杂度为O(n)
/* 线性阶(遍历数组) */
int arrayTraversal(vector<int> &nums) {
int count = 0;
// 循环次数与数组长度成正比
for (int num : nums) {
count++;
}
return count;
}
3.平方阶
通常出现在嵌套中:
/* 平方阶 */
int quadratic(int n) {
int count = 0;
// 循环次数与数据大小 n 成平方关系
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
count++;
}
}
return count;
}
以冒泡为例,外层执行n-1,内层执行n-1,n-2,......,2,1次,那么时间复杂度为O(n^2)
/* 平方阶(冒泡排序) */
int bubbleSort(vector<int> &nums) {
int count = 0; // 计数器
// 外循环:未排序区间为 [0, i]
for (int i = nums.size() - 1; i > 0; i--) {
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
count += 3; // 元素交换包含 3 个单元操作
}
}
}
return count;
}
4.指数阶
指数增长例子:细胞分裂
/* 指数阶(循环实现) */
int exponential(int n) {
int count = 0, base = 1;
// 细胞每轮一分为二,形成数列 1, 2, 4, 8, ..., 2^(n-1)
for (int i = 0; i < n; i++) {
for (int j = 0; j < base; j++) {
count++;
}
base *= 2;
}
// count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1
return count;
}
指数算法在穷举(暴力搜索,回溯)中比较常见
5.对数阶
对数阶是每轮缩减的情况,log2n:
/* 对数阶(循环实现) */
int logarithmic(int n) {
int count = 0;
while (n > 1) {
n = n / 2;
count++;
}
return count;
}
对数阶也常出现在递归函数中:
/* 对数阶(递归实现) */
int logRecur(int n) {
if (n <= 1)
return 0;
return logRecur(n / 2) + 1;
}
6.线性对数阶
线性对数阶经常出现在嵌套循环中,两层循环的时间复杂度分别为 𝑂(log𝑛) 和 𝑂(𝑛) :
/* 线性对数阶 */
int linearLogRecur(int n) {
if (n <= 1)
return 1;
int count = linearLogRecur(n / 2) + linearLogRecur(n / 2);
for (int i = 0; i < n; i++) {
count++;
}
return count;
}
主流排序算法的时间复杂度通常为 𝑂(𝑛log𝑛) ,例如快速排序、归并排序、堆排序等。
7.阶乘阶
阶乘相似“全排列” n!
/* 阶乘阶(递归实现) */
int factorialRecur(int n) {
if (n == 0)
return 1;
int count = 0;
// 从 1 个分裂出 n 个
for (int i = 0; i < n; i++) {
count += factorialRecur(n - 1);
}
return count;
}
2.3.5最佳,最差,平均时间复杂度
算法的效率不固定,假设一个长度为n的数组要返回1的索引,那么1的位置久回影响算法的效率
- 当
nums = [?, ?, ..., 1]
,即当末尾元素是 1 时,需要完整遍历数组,达到最差时间复杂度 𝑂(𝑛) 。 - 当
nums = [1, ?, ?, ...]
,即当首个元素为 1 时,无论数组多长都不需要继续遍历,达到最佳时间复杂度 Ω(1) 。
“最差时间复杂度”对应函数渐近上界,使用大 𝑂 记号表示。相应地,“最佳时间复杂度”对应函数渐近下界,用 Ω 记号表示:
/* 生成一个数组,元素为 { 1, 2, ..., n },顺序被打乱 */
vector<int> randomNumbers(int n) {
vector<int> nums(n);
// 生成数组 nums = { 1, 2, 3, ..., n }
for (int i = 0; i < n; i++) {
nums[i] = i + 1;
}
// 使用系统时间生成随机种子
unsigned seed = chrono::system_clock::now().time_since_epoch().count();
// 随机打乱数组元素
shuffle(nums.begin(), nums.end(), default_random_engine(seed));
return nums;
}
/* 查找数组 nums 中数字 1 所在索引 */
int findOne(vector<int> &nums) {
for (int i = 0; i < nums.size(); i++) {
// 当元素 1 在数组头部时,达到最佳时间复杂度 O(1)
// 当元素 1 在数组尾部时,达到最差时间复杂度 O(n)
if (nums[i] == 1)
return i;
}
return -1;
}
最差时间复杂度更为实用,因为它给出了一个效率安全值
2.4空间复杂度
空间复杂度(space complexity)用于衡量算法占用内存空间随着数据量变大时的增长趋势。
2.4.1算法相关空间
算法在运行过程中所使用的内存空间主要有一下几种:
- 输入空间:存储算法的输入数据
- 输出空间:存储算法的输出数据
- 暂存空间:存储算法在运行过程中的变量,对象,函数上下文数据等
一般空间复杂度的计算范围是输出空间加上暂存空间
暂存空间:
- 暂存数据:保存算法运行过程中的变量,常量,对象等
- 栈帧空间:保存调用函数的上下文数据。系统会在每次调用函数的时候在栈顶部创建一个栈帧,函数返回后栈帧空间被释放。
- 指令空间:保存编译后的程序指令
/* 结构体 */
struct Node {
int val;
Node *next;
Node(int x) : val(x), next(nullptr) {}
};
/* 函数 */
int func() {
// 执行某些操作...
return 0;
}
int algorithm(int n) { // 输入数据
const int a = 0; // 暂存数据(常量)
int b = 0; // 暂存数据(变量)
Node* node = new Node(0); // 暂存数据(对象)
int c = func(); // 栈帧空间(调用函数)
return a + b + c; // 输出数据
}
2.4.2推算方法
与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
最差空间复杂度中的“最差”有两层含义:
- 以最差输入数据为准:当 𝑛<10 时,空间复杂度为 𝑂(1) ;但当 𝑛>10 时,初始化的数组
nums
占用 𝑂(𝑛) 空间,因此最差空间复杂度为 𝑂(𝑛) 。 - 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用 𝑂(1) 空间;当初始化数组
nums
时,程序占用 𝑂(𝑛) 空间,因此最差空间复杂度为 𝑂(𝑛) 。
void algorithm(int n) {
int a = 0; // O(1)
vector<int> b(10000); // O(1)
if (n > 10)
vector<int> nums(n); // O(n)
}
在递归函数中,需要注意统计栈帧空间。
int func() {
// 执行某些操作
return 0;
}
/* 循环的空间复杂度为 O(1) */
void loop(int n) {
for (int i = 0; i < n; i++) {
func();
}
}
/* 递归的空间复杂度为 O(n) */
void recur(int n) {
if (n == 1) return;
return recur(n - 1);
}
函数 loop()
和 recur()
的时间复杂度都为 𝑂(𝑛) ,但空间复杂度不同。
- 函数
loop()
在循环中调用了 𝑛 次function()
,每轮中的function()
都返回并释放了栈帧空间,因此空间复杂度仍为 𝑂(1) 。 - 递归函数
recur()
在运行过程中会同时存在 𝑛 个未返回的recur()
,从而占用 𝑂(𝑛) 的栈帧空间。
2.4.3常见类型
$$
O(1)<O(logn)<O(n)<O(n2)<O(2n)
$$
1.常数阶
在循环中初始化变量或者调用函数占用的内存,进入下一个循环后就会被释放,不会累积占用空间,复杂度为O(1):
/* 函数 */
int func() {
// 执行某些操作
return 0;
}
/* 常数阶 */
void constant(int n) {
// 常量、变量、对象占用 O(1) 空间
const int a = 0;
int b = 0;
vector<int> nums(10000);
ListNode node(0);
// 循环中的变量占用 O(1) 空间
for (int i = 0; i < n; i++) {
int c = 0;
}
// 循环中的函数占用 O(1) 空间
for (int i = 0; i < n; i++) {
func();
}
}
2.线性阶
线性阶常见于元素数量与n成正比的数组,链表,栈,队列等:
/* 线性阶 */
void linear(int n) {
// 长度为 n 的数组占用 O(n) 空间
vector<int> nums(n);
// 长度为 n 的列表占用 O(n) 空间
vector<ListNode> nodes;
for (int i = 0; i < n; i++) {
nodes.push_back(ListNode(i));
}
// 长度为 n 的哈希表占用 O(n) 空间
unordered_map<int, string> map;
for (int i = 0; i < n; i++) {
map[i] = to_string(i);
}
}
3.平方阶
常见与矩阵和图,元素数量和n成平方关系:
/* 平方阶 */
void quadratic(int n) {
// 二维列表占用 O(n^2) 空间
vector<vector<int>> numMatrix;
for (int i = 0; i < n; i++) {
vector<int> tmp;
for (int j = 0; j < n; j++) {
tmp.push_back(0);
}
numMatrix.push_back(tmp);
}
}
函数的递归深度为 𝑛 ,在每个递归函数中都初始化了一个数组,长度分别为 𝑛、𝑛−1、…、2、1 ,平均长度为 𝑛/2 ,因此总体占用 𝑂(𝑛2) 空间:
/* 平方阶(递归实现) */
int quadraticRecur(int n) {
if (n <= 0)
return 0;
vector<int> nums(n);
cout << "递归 n = " << n << " 中的 nums 长度 = " << nums.size() << endl;
return quadraticRecur(n - 1);
}
4.指数阶
指数阶常见于二叉树。观察图 2-19 ,层数为 𝑛 的“满二叉树”的节点数量为 2𝑛−1 ,占用 𝑂(2𝑛) 空间:
/* 指数阶(建立满二叉树) */
TreeNode *buildTree(int n) {
if (n == 0)
return nullptr;
TreeNode *root = new TreeNode(0);
root->left = buildTree(n - 1);
root->right = buildTree(n - 1);
return root;
}
5.对数阶
对数阶常见于分治算法。例如归并排序,输入长度为 𝑛 的数组,每轮递归将数组从中点处划分为两半,形成高度为 log𝑛 的递归树,使用 𝑂(log𝑛) 栈帧空间。
再例如将数字转化为字符串,输入一个正整数 𝑛 ,它的位数为 ⌊log10𝑛⌋+1 ,即对应字符串长度为 ⌊log10𝑛⌋+1 ,因此空间复杂度为 𝑂(log10𝑛+1)=𝑂(log𝑛) 。
2.4.4权衡时间与空间
降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要。
3数据结构
3.1数据结构分类
常见的数据结构包括数组、链表、栈、队列、哈希表、树、堆、图,它们可以从“逻辑结构”和“物理结构”两个维度进行分类。
3.1.1逻辑结构:线性与非线性
逻辑结构揭示数据元素之间的逻辑关系
-
线性数据结构:数组、链表、栈、队列、哈希表,元素之间是一对一的顺序关系。
-
非线性数据结构:树、堆、图、哈希表。
非线性结构可以进一步划分为树状和网状结构
- 树形结构:树、堆、哈希表,元素之间是一对多的关系。
- 网状结构:图,元素之间是多对多的关系。
3.1.2物理结构:连续和分散
算法运行过程,正在处理的数据主要存储在内存中,系统通过内存地址来访问目标位置的数据。
内存是所有程序共享资源,当一块内存被占用是,通常情况下其他程序就无法使用了,因此,在数据结构算法设计中,内存资源分配是很重要的因素。
物理结构反应了数据在计算机内存中的存储方式,可以分为连续空间存储(数组)和非连续空间存储(链表)。两种物理结构在时间空间效率呈现互补特点
注:所有数据结构都是基于数组,链表或者二者结合实现,、
- 基于数组可实现:栈、队列、哈希表、树、堆、图、矩阵、张量(维度 ≥3 的数组)等。
- 基于链表可实现:栈、队列、哈希表、树、堆、图等。
3.2基本数据类型
基本数据类型:CPU可以直接运行:
- 整数类型:
int
,long
,byte
,short
. - 浮点数类型:
double
,float
,用于表示小数 - 字符类型:
char
,用于表示各种语言的字母、标点符号甚至表情符号。 - 布尔类型:
bool
,用于表示是与否
基本数据类型以二进制的形式存储在计算机中。一个二进制为1bit,1字节(byte) = 8(bit)
请注意,上表针对的是 Java 的基本数据类型的情况。每种编程语言都有各自的数据类型定义,它们的占用空间、取值范围和默认值可能会有所不同。
- 在 Python 中,整数类型
int
可以是任意大小,只受限于可用内存;浮点数float
是双精度 64 位;没有char
类型,单个字符实际上是长度为 1 的字符串str
。 - C 和 C++ 未明确规定基本数据类型的大小,而因实现和平台各异。表 3-1 遵循 LP64 数据模型,其用于包括 Linux 和 macOS 在内的 Unix 64 位操作系统。
- 字符
char
的大小在 C 和 C++ 中为 1 字节,在大多数编程语言中取决于特定的字符编码方法,详见“字符编码”章节。 - 即使表示布尔量仅需 1 位(0 或 1),它在内存中通常也存储为 1 字节。这是因为现代计算机 CPU 通常将 1 字节作为最小寻址内存单元。
基本数据类型是数据的内容类型,而数据结构为数据提供了组织方式
3.3数字编码
3.3.1原码、反码和补码
- 原码:数字的二进制的最高位是符号位,0+ , 1- 其余位数表示数字的值
- 反码:正数的反码和原码相同,负数的反码是对原码除了符号位之外所有位取反
- 补码:正数的补码和原码相同,负数的补码是在原码的基础上加1
原码(sign-magnitude)虽然最直观,但存在一些局限性。一方面,负数的原码不能直接用于运算。例如在原码下计算 1+(−2) ,得到的结果是 −3 ,这显然是不对的。
$$
1+(-2)\->0000\ 0001+1000\ 0010\=1000\ 0011\->-3
$$
数字零的原码有+0和-0两种表示方式,如果不处理会带来歧异。但是要处理正零和负零歧异需要引入额外的判断操作,降低计算机运行效率
反码的过程也存在类似问题,所以引入了补码(recover complement)
$$
-0\ \ ->1000\ 0000(原码)\\ \ \ \ \ \ \ \ \ \
=1111\ 1111(反码)\\ \ \ \ \ \ \
=1\ 0000\ 0000(补码)
$$
负零的补码加一会产生进位,但是byte
类型的长度只有8位,所以溢出到第9位的1会被放弃。所以此时与正零的补码相同
根据转换方法,补码1000 0000的原码为0000 0000,但这个原码表示的数字应该为0,计算机规定补码1000 0000代表-128
$$
(-127)+(-1)\
->1111\ 1111(原码)+1000\ 0001(原码)\
=1000\ 0000(反码)+1111\ 1110(反码)\
=1000\ 0001(补码)+1111\ 1111(补码)\
=1000\ 1000(补码)\
->-128
$$
3.3.2浮点数编码
int
和 float
长度相同,都是 4 字节 ,但为什么 float
的取值范围远大于 int
?因为按理说 float
需要表示小数,取值范围应该变小。
这是因为浮点数 float
采用了不同的表示方式。记一个 32 比特长度的二进制数为:
$$
b_{31}b_{30}b_{29}·····b_{1}b_{0}
$$
根据 IEEE 754 标准,32-bit 长度的 float
由以下三个部分构成。
- 符号位 S :占 1 位 ,对应 b31 。
- 指数位 E :占 8 位 ,对应 b30b29…b23 。
- 分数位 N :占 23 位 ,对应 b22b21…b0 。
4.数组与链表
4.1数组
数组(array)是一种线性数据结构,其将相同类型的元素存储在连续的内存空间中。我们将元素在数组中的位置称为该元素的索引(index)。
4.1.1数组常用操作
1.初始化数组
/* 初始化数组 */
// 存储在栈上
int arr[5];
int nums[5] = { 1, 3, 2, 5, 4 };
// 存储在堆上(需要手动释放空间)
int* arr1 = new int[5];
int* nums1 = new int[5] { 1, 3, 2, 5, 4 };
2.访问元素
索引的本质是内存地址的偏移量,所以个元素地址偏移量为0,索引是0
/* 随机访问元素 */
int randomAccess(int *nums, int size) {
// 在区间 [0, size) 中随机抽取一个数字
int randomIndex = rand() % size;
// 获取并返回随机元素
int randomNum = nums[randomIndex];
return randomNum;
}
3.插入元素
/* 在数组的索引 index 处插入元素 num */
void insert(int *nums, int size, int num, int index) {
// 把索引 index 以及之后的所有元素向后移动一位
for (int i = size - 1; i > index; i--) {
nums[i] = nums[i - 1];
}
// 将 num 赋给 index 处的元素
nums[index] = num;
}
4.删除元素
/* 删除索引 index 处的元素 */
void remove(int *nums, int size, int index) {
// 把索引 index 之后的所有元素向前移动一位
for (int i = index; i < size - 1; i++) {
nums[i] = nums[i + 1];
}
}
数组的插入与删除存在缺陷:
- 时间复杂度高:数组的插入和删除的平均时间复杂度均为 𝑂(𝑛) ,其中 𝑛 为数组长度。
- 丢失元素:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会丢失。
- 内存浪费:我们可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是“无意义”的,但这样做会造成部分内存空间浪费。
5.遍历数组
/* 遍历数组 */
void traverse(int *nums, int size) {
int count = 0;
// 通过索引遍历数组
for (int i = 0; i < size; i++) {
count += nums[i];
}
}
6.查找元素
在数组中查找需要遍历元素,因为数组是线性数据结构,所以称为线性查找
/* 在数组中查找指定元素 */
int find(int *nums, int size, int target) {
for (int i = 0; i < size; i++) {
if (nums[i] == target)
return i;
}
return -1;
}
7.扩容
在复杂的系统环境中,程序难以保证数组之后的内存空间是可用的,从而无法安全地扩展数组容量。因此在大多数编程语言中,数组的长度是不可变的。
/* 扩展数组长度 */
int *extend(int *nums, int size, int enlarge) {
// 初始化一个扩展长度后的数组
int *res = new int[size + enlarge];
// 将原数组中的所有元素复制到新数组
for (int i = 0; i < size; i++) {
res[i] = nums[i];
}
// 释放内存
delete[] nums;
// 返回扩展后的新数组
return res;
}
4.1.2数组的优点和局限性
- 空间效率高:数组为数据分配了连续的内存块,无须额外的结构开销。
- 支持随机访问:数组允许在 𝑂(1) 时间内访问任何元素。
- 缓存局部性:当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。
- 插入与删除效率低:当数组中元素较多时,插入与删除操作需要移动大量的元素。
- 长度不可变:数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
- 空间浪费:如果数组分配的大小超过实际所需,那么多余的空间就被浪费了。
4.1.3数组的应用
- 随机访问:如果我们想随机抽取一些样本,那么可以用数组存储,并生成一个随机序列,根据索引实现随机抽样。
- 排序和搜索:数组是排序和搜索算法最常用的数据结构。快速排序、归并排序、二分查找等都主要在数组上进行。
- 查找表:当需要快速查找一个元素或其对应关系时,可以使用数组作为查找表。假如我们想实现字符到 ASCII 码的映射,则可以将字符的 ASCII 码值作为索引,对应的元素存放在数组中的对应位置。
- 机器学习:神经网络中大量使用了向量、矩阵、张量之间的线性代数运算,这些数据都是以数组的形式构建的。数组是神经网络编程中最常使用的数据结构。
- 数据结构实现:数组可以用于实现栈、队列、哈希表、堆、图等数据结构。例如,图的邻接矩阵表示实际上是一个二维数组。
4.2链表
链表(linked list)是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。
链表的组成单位是节点(node),每个节点有两项数据,值和指向下一节点的引用
- 链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。
- 尾节点指向的是“空”,它在 Java、C++ 和 Python 中分别被记为
null
、nullptr
和None
。 - 在 C、C++、Go 和 Rust 等支持指针的语言中,上述“引用”应被替换为“指针”。
/* 链表节点结构体 */
struct ListNode {
int val; // 节点值
ListNode *next; // 指向下一节点的指针
ListNode(int x) : val(x), next(nullptr) {} // 构造函数
};
4.2.1链表常用操作
1.初始化链表
首先初始化链表各个对象,然后构建各个节点之间的引用
/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
// 初始化各个节点
ListNode* n0 = new ListNode(1);
ListNode* n1 = new ListNode(3);
ListNode* n2 = new ListNode(2);
ListNode* n3 = new ListNode(5);
ListNode* n4 = new ListNode(4);
// 构建节点之间的引用
n0->next = n1;
n1->next = n2;
n2->next = n3;
n3->next = n4;
2.插入节点
在链表中插入节点,只需要改变两个节点的引用
/* 在链表的节点 n0 之后插入节点 P */
void insert(ListNode *n0, ListNode *P) {
ListNode *n1 = n0->next;
P->next = n1;
n0->next = P;
}
3.删除节点
删除节点只需要改变一个节点的引用
/* 删除链表的节点 n0 之后的首个节点 */
void remove(ListNode *n0) {
if (n0->next == nullptr)
return;
// n0 -> P -> n1
ListNode *P = n0->next;
ListNode *n1 = P->next;
n0->next = n1;
// 释放内存
delete P;
}
4.访问节点
链表访问节点的效率低,时间复杂度为O(1)
/* 访问链表中索引为 index 的节点 */
ListNode *access(ListNode *head, int index) {
for (int i = 0; i < index; i++) {
if (head == nullptr)
return nullptr;
head = head->next;
}
return head;
}
5.查找节点
遍历链表,查找其中值为 target
的节点,输出该节点在链表中的索引。此过程也属于线性查找。
/* 在链表中查找值为 target 的首个节点 */
int find(ListNode *head, int target) {
int index = 0;
while (head != nullptr) {
if (head->val == target)
return index;
head = head->next;
index++;
}
return -1;
}
4.2.2常见链表类型
- 单向链表:即前面介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空
None
。 - 环形链表:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
- 双向链表:与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
4.3列表
列表(list)是一个抽象的数据结构概念,它表示元素的有序集合,支持元素访问、修改、添加、删除和遍历等操作,无须使用者考虑容量限制的问题。列表可以基于链表或数组实现。
- 链表天然可以看作一个列表,其支持元素增删查改操作,并且可以灵活动态扩容。
- 数组也支持元素增删查改,但由于其长度不可变,因此只能看作一个具有长度限制的列表。
当用数组实现列表时,长度不可变会导致列表实用性降低。因此一般使用动态数组实现列表
4.3.1列表常用操作
1.初始化列表
/* 初始化列表 */
// 需注意,C++ 中 vector 即是本文描述的 nums
// 无初始值
vector<int> nums1;
// 有初始值
vector<int> nums = { 1, 3, 2, 5, 4 };
2.访问元素
列表的本质是数组,所以可以在O(1)的时间内访问和更新元素
/* 访问元素 */
int num = nums[1]; // 访问索引 1 处的元素
/* 更新元素 */
nums[1] = 0; // 将索引 1 处的元素更新为 0
3.插入与删除
/* 清空列表 */
nums.clear();
/* 在尾部添加元素 */
nums.push_back(1);
nums.push_back(3);
nums.push_back(2);
nums.push_back(5);
nums.push_back(4);
/* 在中间插入元素 */
nums.insert(nums.begin() + 3, 6); // 在索引 3 处插入数字 6
/* 删除元素 */
nums.erase(nums.begin() + 3); // 删除索引 3 处的元素
4.遍历列表
/* 通过索引遍历列表 */
int count = 0;
for (int i = 0; i < nums.size(); i++) {
count += nums[i];
}
/* 直接遍历列表元素 */
count = 0;
for (int num : nums) {
count += num;
}
5.拼接列表
/* 拼接两个列表 */
vector<int> nums1 = { 6, 8, 7, 10, 9 };
// 将列表 nums1 拼接到 nums 之后
nums.insert(nums.end(), nums1.begin(), nums1.end());
6.列表排序
/* 排序列表 */
sort(nums.begin(), nums.end()); // 排序后,列表元素从小到大排列
4.3.2列表实现
设计:
- 初始容量:选取一个合理的数组初始容量。
- 数量记录:声明一个变量
size
,用于记录列表当前元素数量,并随着元素插入和删除实时更新。根据此变量,我们可以定位列表尾部,以及判断是否需要扩容。 - 扩容机制:若插入元素时列表容量已满,则需要进行扩容。先根据扩容倍数创建一个更大的数组,再将当前数组的所有元素依次移动至新数组。
/*列表*/
class MyList{
private :
int *arr; //储存元素
int arrCapacity = 10 //列表容量
int arrSize = 0; //列表长度
int extendS = 2; //扩容倍数
public :
MyList(){
arr = new int[arrCapacity];
}
~MyList(){
delete[] arr;
}
//获取列表长度
int size(){
return arrSize;
}
//获取列表容量
int capacity(){
return arrCapacity;
}
//访问元素
int get(int index){
if(index < 0 || index >= size())
throw out_of_range("索引越界");
return arr[index];
}
//更新元素
void set(int index,int num){
if(index < 0 || index >= size())
throw out_of_range("索引越界");
arr[index] = num;
}
//尾插
void add(int num){
if(size() == capacity())
extendS();
arr[size()] = num;
arrSize++;
}
//在中间插入元素
void insert(int index,int num){
if(index < 0 || index >= size())
throw out_of_range("索引越界");
if(size() == capacity())
extendS();
//将索引之后的元素都往前移动一位
for(int j = size() - 1; j >= index; j--){
arr[j + 1] = arr[j];
}
arr[index] = num;
//更新元素数量
arrSize++;
}
//删除元素
int remove(int index){
if(index < 0 || index >= size())
throw out_of_range("索引越界");
int num = arr[index];
for(int j = index; j < size() - 1; j++){
arr[j] = arr[j + 1];
}
arrSize--;
return num;
}
//列表扩容
void extendCapacity(){
int newCapacity = capacity() * extendS;
int *tmp = arr;
arr = new int[newCapacity];
for(int i = 0;i < size() ; i++){
arr[i] = tmp[i];
}
delete[] tmp;
arrCapacity = newCapacity;
}
//将列表转换为Vector
vector<int> toVector(){
vector<int> vec(size());
for(int i = 0;i < size(); i++){
vec[i] = arr[i];
}
return vec;
}
};
4.4内存和缓存
4.4.1计算机存储设备
硬盘(hard disk)、内存(random-access memory, RAM)、缓存(cache memory)。
硬盘用于长期存储大量数据,内存用于临时存储程序运行中正在处理的数据,而缓存则用于存储经常访问的数据和指令
4.4.2数据结构的内存效率
在程序运行同时,随着反复申请与释放内存,空闲内存的碎片化程度会越来越高
5.栈和队列
5.1栈
栈(stack)是一种遵循先入后出的线性数据结构。堆叠的顶部是栈顶,底部为栈底,元素添加到栈顶的操作叫入栈,删除栈顶元素叫出栈。
5.1.1栈的常用操作
/* 初始化栈 */
stack<int> stack;
/* 元素入栈 */
stack.push(1);
stack.push(3);
stack.push(2);
stack.push(5);
stack.push(4);
/* 访问栈顶元素 */
int top = stack.top();
/* 元素出栈 */
stack.pop(); // 无返回值
/* 获取栈的长度 */
int size = stack.size();
/* 判断是否为空 */
bool empty = stack.empty();
具体方法名需要根据所使用的编程语言确定。若有些语言没有内置栈,可以将该语言的数组或者链表当作栈来使用,并在逻辑上忽略与栈无关的操作。
5.1.2栈的实现
栈遵循先入后出的原则,因此栈可以视为一种受限制的数组或者链表。
1.基于链表的实现
入栈操作,只需要将元素插入链表头部,称为头插法。对于出栈操作,需要将头节点从链表中删除。
class LinkedListStack{
private:
ListNode *stackTop;
int stkSize;
public:
LinkedListStack(){
stackTop = nullptr;
stkSize = 0;
}
~LinkedListStack(){
freeMemoryLinkedList(stackTop);
}
int size(){
return stkSize;
}
bool isEmpty(){
return size() == 0;
}
void push(int num){
ListNode *node = new ListNode(num);
node->next = stackTop;
stackTop = node;
stkSize++;
}
int pop(){
int num = top();
ListNode *tmp = stackTop;
stackTop = stackTop->next;
delete tmp;
stkSize--;
return num;
}
int top(){
if(isEmpty){
throw out_of_range("empty");
}
return stackTop->val;
}
vector<int> toVector(){
ListNode *node = stackTop;
vector<int> res(size());
for(int i = res.size() - 1;i >= 0;i++){
res[i] = node->val;
node = node->next;
}
return res;
}
}
2.基于数组的实现
使用数组实现栈时,将数组的尾部作为栈顶,入栈出栈时间复杂度为O(1)
/* 基于数组实现的栈 */
class ArrayStack {
private:
vector<int> stack;
public:
/* 获取栈的长度 */
int size() {
return stack.size();
}
/* 判断栈是否为空 */
bool isEmpty() {
return stack.size() == 0;
}
/* 入栈 */
void push(int num) {
stack.push_back(num);
}
/* 出栈 */
int pop() {
int num = top();
stack.pop_back();
return num;
}
/* 访问栈顶元素 */
int top() {
if (isEmpty())
throw out_of_range("栈为空");
return stack.back();
}
/* 返回 Vector */
vector<int> toVector() {
return stack;
}
};
5.1.3两种实现对比
时间效率
基于数组的实现中,入栈出栈都是在预先分配好的内存中进行,所以效率较高。
但当入栈的容量超过数组数量,就会触发扩容,导致时间复杂度便为O(n)
基于链表的实现中,链表的扩容灵活,但是在入栈操作时需要初始化节点并修改指针,因此效率较低,若入栈本身对象,那么可以省去初始化步骤,从而提高效率。
空间效率
初始化列表时,系统会分配初始容量,该容量可能超过实际需求,所以扩容机制一般是按照一定倍率,扩容后也可能超过实际需求。因此,基于数组实现的栈可能造成一定的空间浪费。
链表的节点需要额外存储指针,因此链表节点占用的空间相对而言较大。
5.2队列
队列(queue)遵循先入先出的线性数据结构。模拟排队现象
队列的头部“队首”,尾部“队尾”,插入队尾“入队”,删除队首“出队”
5.2.1队列常用操作
/* 初始化队列 */
queue<int> queue;
/* 元素入队 */
queue.push(1);
queue.push(3);
queue.push(2);
queue.push(5);
queue.push(4);
/* 访问队首元素 */
int front = queue.front();
/* 元素出队 */
queue.pop();
/* 获取队列的长度 */
int size = queue.size();
/* 判断队列是否为空 */
bool empty = queue.empty();
5.2.2队列的实现
1.基于链表的实现
将链表的头节点和尾节点分别视为队首和队尾,队首仅删除节点,队尾仅添加节点
class LinkedListQueue{
private:
ListNode *front, *rear;
int queSize;
public:
LinkedListQueue(){
front = nullptr;
rear = nullptr;
queSize = 0;
}
~LinkedListQueue(){
freeMemoryLinkedList(front);
}
int size(){
return queSize;
}
bool isEmppty(){
return queSize == 0;
}
void push(int num){
ListNode *node = new ListNode(num);
if(front == nullptr){
front = node;
rear = node;
}
//队列不为空,将该节点添加到尾节点后面
else{
rear->next = node;
raar = node;
}
queSize++;
}
int pop(){
int num = peek();
//删除头节点
ListNode *tmp = front;
front = front ->next;
delete tmp;
queSize--;
reutrn num;
}
//访问队首元素
int peek(){
if(size() == 0){
throw out_of_range("队列为空");
}
return front->val;
}
vector<int> tovector(){
ListNode *node = front;
vector<int> res(size());
for(int i = 0;i < res.size();i++)
{
res[i] = node->val;
node = node->next;
}
return res;
}
};
2.基于数组的实现
使用一个变量指针front
指向队首元素的索引,size
记录队列长度,定义rear = front + size
计算出rear
指向队尾元素之后的一下个位置。
此设计中数组有效区间[front,rear - 1]
- 入队:将元素复制到
rear
索引,并将size
增加1 - 出队:只需将
front
增加1,并将size
减少1
在不断入队和出队的过程中front
和rear
都在移动,当他们到达数组尾部的时候都无法移动你了。对于这样的环形数组我们需要让front
或rear
越过数组尾部的时候,直接回到数组头部继续遍历。
class ArrayQueue{
private:
int *nums;
int front;
int queSize;
int queCapacity;
public:
ArrayQueue(int capacity){
nums = new int[capacity];
queCapacity = capacity;
front = queSize = 0;
}
~ArrayQueue(){
delete[] nums;
}
int capacity(){
return queCapacity;
}
int size(){
return queSize;
}
bool isEmpty(){
return size() == 0;
}
void push(int num){
if(queSize == queCapacity){
cout << "limited" << endl;
return;
}
//计算队尾指针,通过取余操作实现rear越过数组尾部后回到头
int rear = (front + queSize) % queCapacity;
nums[rear] = num;
queSize++;
}
int pop(){
int num = peek();
//队首指针向后移动一位,如果越过队尾,则返回数组头部
front = (front + 1) % queCapacity;
queSize--;
return num;
}
int peek(){
if(isEmpty()){
throw out_of_range("empty");
}
return nums[front];
}
vector<int> toVector(){
vector<int> arr(queSize);
for(int i = 0, j = front;i < queSize; i++, j++){
arr[i] = nums[j % queCapacity];
}
return arr;
}
};
5.3双向队列
双向队列允许在头部和尾部执行插入和删除
5.3.1双向队列的常用操作
/* 初始化双向队列 */
deque<int> deque;
/* 元素入队 */
deque.push_back(2); // 添加至队尾
deque.push_back(5);
deque.push_back(4);
deque.push_front(3); // 添加至队首
deque.push_front(1);
/* 访问元素 */
int front = deque.front(); // 队首元素
int back = deque.back(); // 队尾元素
/* 元素出队 */
deque.pop_front(); // 队首元素出队
deque.pop_back(); // 队尾元素出队
/* 获取双向队列的长度 */
int size = deque.size();
/* 判断双向队列是否为空 */
bool empty = deque.empty();
5.3.2双向队列实现
1.基于双向链表的实现
将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能。
struct DoubleListNode{
int val;
DoubleListNode *next;
DoubleListNode *prev;
DoubleListNode(int val) : val(val), prev(nullptr),next(nullptr){
}
};
class LinkedListDeque{
private:
DoubleListNode *front, *rear;
int queSize = 0;
public:
LinkedListDeque() : front(nullptr), rear(nullptr){
}
~LinkedListDeque(){
DoubleListNode *pre,*cur = front;
while(cur != nullptr){
pre = cur;
cur = cur->next;
delete pre;
}
}
int size(){
return queSize;
}
bool isEmpty(){
return size() == 0;
}
void push(int num, bool isfront){
DoubleListNode *node = new DoubleListNode(num);
if(isEmpty())
front = rear = node;
else if(isFront){
front->prev = node;
node->next = front;
front = node;
}else{
rear->next = node;
node->prev = rear;
rear = node;
}
queSize++;
}
void pushFirst(int num){
push(num, true);
}
void pushLast(int num){
push(num,false);
}
int pop(bool isFront){
if(isEmpty()){
throw out_of_range("empty");
}
int val;
if(isFront){
val = front->val;
DoubleListNode *fNext = front->next;
if(fNext != nullptr){
fNext->prev = nullptr;
fNext->next = nullptr;
}
delete front;
front = fNext;
}else{
val = rear->val;
DoubleListNode *rPrev = rear->prev;
if(rPrev != nullptr){
rPrev->prev = nullptr;
rPrev->next = nullptr;
}
delete rear;
rear = rPrev;
}
queSize--;
return val;
}
int popFirst(){
return pop(true);
}
int popLast(){
return pop(false);
}
int peekFirst(){
if(isEmpty()){
throw out_of_range("empty");
}
return front->val;
}
int peekLast(){
if(isEmpty()){
throw out_of_range("empty");
}
return rear->val;
}
vector<int> toVector(){
DoublyListNode *node = front;
vector<int> res(size());
for (int i = 0; i < res.size(); i++) {
res[i] = node->val;
node = node->next;
}
return res;
}
};
2.基于数组的实现
在队列的基础上,只需要怎加队首入队,和队尾出队的方法
class ArrayDeque{
private:
vector<int> nums;
int fronts;
int queSize;
public:
ArrayDeque(int capacity){
nums.resize(Capacity);
front = queSize = 0;
}
int capacity(){
return nums.size();
}
int size(){
return queSize;
}
bool isEmpty(){
return queSize == 0;
}
int index(int i){
//通过取余操作实现数组首位相连
return (i + capacity()) % capacity();
}
int pushFirst(int num){
if(queSize == capacity()){
cout << "limited" << endl;
return;
}
//队首指针左移
//越过数组头之后返回到尾部
front = index(front - 1);
nums[front] = num;
queSize++;
}
void pushLast(int num){
if(queSize == capacity()){
cout << "limited" << endl;
return;
}
//队尾指针索引+1
int rear = index(front + queSize);
nums[rear] = num;
queSize++;
}
int popFirst(){
int num = peekFirst();
front = index(front + 1);
queSize--;
return num;
}
int popLast(){
int num = peekLast();
queSize--;
return num;
}
int peekFirst(){
if(isEmpty()){
throw out_of_range("limited");
}
return nums[fronts];
}
int peekLast(){
if(isEmpty()){
throw out_of_range("limited");
}
int last = index(front + queSize - 1);
return nums[last];
}
vector<int> toVector() {
// 仅转换有效长度范围内的列表元素
vector<int> res(queSize);
for (int i = 0, j = front; i < queSize; i++, j++) {
res[i] = nums[index(j)];
}
return res;
}
};