零、list库中的常用函数
函数名称 | 函数功能 |
---|---|
size() | 容器大小 |
max_size() | 容器最大大小 |
resize(int num) | 更改容器大小 |
empty() | 容器判空 |
push_front(const T& x) | 头部添加元素 |
push_back(const T& x) | 尾部添加元素 |
insert(iterator it , int n , const T&x) | 任意位置插入一个元素 |
pop_front() | 头部删除元素 |
pop_back() | 尾部删除元素 |
erase(iterator it) | 任意位置删除一个元素 |
front() | 访问第一个元素 |
back() | 访问最后一个元素 |
list1.merge(list2) | 将list1与list2中的元素合并(默认升序排列) |
一、列表与向量各自的优缺点
1.列表(List)
优点:
- 动态大小:
- 列表可以动态调整大小,适合需要频繁插入和删除操作的场景。
- 不需要预分配空间,节省存储。
- 高效的插入和删除:
- 在已知位置插入或删除元素的时间复杂度为 O ( 1 ) O(1) O(1)(链表),不需要移动其他元素。
- 内存分配灵活:
- 由于元素分散存储,插入时无需连续的内存块。
- 稳定的指针或迭代器:
- 链表的插入和删除不会影响其他元素的指针或迭代器。
缺点:
- 访问效率低:
- 访问列表中第 k k k 个元素的时间复杂度为 O ( k ) O(k) O(k),因为需要从头开始遍历。
- 额外存储开销:
- 链表需要额外存储每个节点的指针,内存开销较大。
- 不支持随机访问:
- 列表只能通过遍历进行访问,无法通过索引直接定位元素。
- 缓存性能差:
- 由于节点分散在内存中,不利于利用 CPU 缓存优化性能。
2.向量(Vector)
优点:
- 支持随机访问:
- 可以通过索引直接访问任意元素,时间复杂度为 $O(1)4。
- 内存连续:
- 数据存储在一块连续的内存中,利于缓存优化,访问速度快。
- 空间利用率高:
- 不需要为每个元素存储额外的指针。
- 支持动态扩展:
- 向量可以动态扩展,当容量不足时会自动分配更大的内存。
- 标准库支持丰富:
- 向量是 STL(C++ 标准库)中最常用的容器之一,具有丰富的操作接口。
缺点:
- 插入和删除效率低:
- 在非尾部位置插入或删除元素时,需要移动大量元素, 时间复杂度为 O ( n ) O(n) O(n)。
- 扩展时的开销:
- 当容量不足时,向量需要重新分配内存并复制已有元素,增加了时间和空间开销。
- 需要连续内存:
- 向量需要在内存中分配一块连续的空间,当空间不足时可能导致分配失败。
- 迭代器可能失效:
- 当向量发生扩展或元素移动时,指针或迭代器可能失效。
适用场景对比
特性 | 列表 | 向量 |
---|---|---|
随机访问 | 不支持 | 支持,效率高 |
插入/删除效率 | 高(在任意位置操作) | 低(需要移动其他元素) |
动态扩展 | 插入时自动调整,不需要额外操作 | 插入时可能需要重新分配内存 |
内存使用 | 高,需额外存储指针 | 低,存储空间紧凑 |
缓存性能 | 差(分散存储) | 好(连续存储) |
迭代器稳定性 | 稳定(插入或删除不会影响其他指针) | 不稳定(插入、删除、扩展可能失效) |
总结
- 选择列表(List):
- 需要频繁的插入和删除操作,尤其是在中间位置。
- 数据量大且随机访问需求低。
- 对内存连续性和缓存性能要求不高。
- 选择向量(Vector):
- 需要频繁随机访问元素。
- 插入和删除操作较少,主要操作是遍历或访问。
- 数据量适中,扩展操作不会频繁发生。
二、插入与删除
插入:
template <typename T> ListNodePosi<T> ListNode<T>::insertAsPred( T const & e ) //前插入算法
{ // 在当前节点之前插入一个新节点,O(1) 处理处方序
ListNodePosi<T> x = new ListNode( e, pred, this ); // 创建新的节点 x,值为e,前连接之前节点 pred,后连接当前节点 this
pred->succ = x; // 更新原前一个节点 pred 的后连接为 x
pred = x; // 更新当前节点的前连接为 x,与上一步的次序不可以颠倒
return x; // 返回新插入的节点 x
}
删除:
template <typename T> T List<T>::remove( ListNodePosi<T> p )
{ // 从链表中删除指定节点,处理处方序 O(1)
T e = p->data; // 记录将要删除节点的数据值,以便返回
p->pred->succ = p->succ; // 更新前一节点的后连接,跳过将要删除的节点
p->succ->pred = p->pred; // 更新后一节点的前连接,跳过将要删除的节点
delete p; // 释放将要删除的节点的内存
_size--; // 链表节点数量减一
return e; // 返回被删除节点的数据值
}
三、选择排序
1.选择排序的平移法
#include <iostream>
#include <vector>
using namespace std;
void selectionSortShift(vector<int>& arr)
{
int n = arr.size();
for (int i = 0; i < n - 1; ++i)
{
int minIndex = i; // 记录最小值的索引
for (int j = i + 1; j < n; ++j)
{
if (arr[j] < arr[minIndex])
{
minIndex = j; // 找到最小值的索引
}
}
int minValue = arr[minIndex]; // 记录最小值
for (int k = minIndex; k > i; --k)
{
arr[k] = arr[k - 1]; // 平移元素
}
arr[i] = minValue; // 插入最小值到位置 i
}
}
int main()
{
vector<int> arr = {64, 25, 12, 22, 11};
selectionSortShift(arr);
for (int num : arr)
{
cout << num << " ";
}
cout << endl;
return 0;
}
2.选择排序的交换法
#include <iostream>
#include <vector>
using namespace std;
void selectionSortSwap(vector<int>& arr)
{
int n = arr.size();
for (int i = 0; i < n - 1; ++i)
{
int minIndex = i; // 记录最小值的索引
for (int j = i + 1; j < n; ++j)
{
if (arr[j] < arr[minIndex])
{
minIndex = j; // 找到最小值的索引
}
}
swap(arr[i], arr[minIndex]); // 将最小值与位置 i 的值交换
}
}
int main() {
vector<int> arr = {64, 25, 12, 22, 11};
selectionSortSwap(arr);
for (int num : arr)
{
cout << num << " ";
}
cout << endl;
return 0;
}
3.两种方法的比较
特性 | 平移法 | 交换法 |
---|---|---|
代码复杂度 | 较高,需要平移元素 | 较低,只需交换两个元素 |
适用场景 | 顺序表(数组),适合顺序访问和批量操作 | 顺序表(数组)和链表均适用 |
性能 | 平移操作可能增加额外开销,尤其是大数组 | 仅交换两元素,效率较高 |
易用性 | 插入元素逻辑清晰,但代码较冗长 | 更直观,代码实现更简洁 |
- 平移法适合顺序表,主要在需要维护顺序的场景下使用。
- 交换法简单直接,适合需要对多种数据结构通用的场景,尤其适合数组和链表
4.稳定性分析
在排序算法中,稳定性指的是:如果序列中存在多个值相等的元素,排序后它们的相对顺序是否保持不变。对于选择排序的两种实现方法(平移法和交换法),我们通过“有多个重复元素同时命中”的情况来分析其稳定性。
特性 | 平移法 | 交换法 |
---|---|---|
处理重复元素 | 重复元素的相对顺序保持不变 | 重复元素的相对顺序可能改变 |
稳定性 | 稳定 | 不稳定 |
实现复杂度 | 需要移动多个元素,操作较复杂 | 只需交换两个元素,操作简单 |
适用场景 | 要求排序稳定性的场景 | 不需要排序稳定性的场景 |
推荐使用场景
- 平移法:适用于需要保持重复元素相对顺序的场景,比如对数据记录按多个字段排序时,要求次要字段排序结果保持主字段的顺序。
- 交换法:适用于对稳定性要求不高的场景,比如快速排序中的划分操作,或者只关心排序结果而不关心原有顺序的场景。
5.复杂度分析
选择排序的核心思想是:每轮遍历未排序部分,找到最小(或最大)值并将其放到已排序部分的末尾。时间复杂度主要取决于两部分操作:
- 遍历未排序部分找到最小值(比较操作)。
- 将最小值放到正确位置(交换或平移操作)。
一般过程
- 第 1 轮:遍历 n n n 个元素,找到最小值。
- 第 2 轮:遍历剩余 n − 1 n−1 n−1 个元素,找到最小值。
- …
- 第 n − 1 n−1 n−1 轮:只需比较 1 次。
总的比较次数:
T
(
n
)
=
(
n
−
1
)
+
(
n
−
2
)
+
⋯
+
1
=
n
(
n
−
1
)
2
=
O
(
n
2
)
T(n) = (n-1) + (n-2) + \dots + 1 = \frac{n(n-1)}{2} = O(n^2)
T(n)=(n−1)+(n−2)+⋯+1=2n(n−1)=O(n2)
总的交换次数(最多为
n
−
1
n−1
n−1 次)远少于比较次数,对整体复杂度影响不大。
最好情况分析
最好情况:输入数据已按顺序排列
- 比较操作:即使输入是有序的,选择排序仍需要比较来确认最小值,因此比较次数不会减少: T b e s t ( n ) = n ( n − 1 ) 2 = O ( n 2 ) T_{best}(n) = \frac{n(n-1)}{2} = O(n^2) Tbest(n)=2n(n−1)=O(n2)
- 交换操作:由于序列已排序,不需要实际的交换操作(或平移),交换次数为 0。
结论: 最好情况下,选择排序的时间复杂度仍然是 O ( n 2 ) O(n^2) O(n2),但交换操作的开销为零。
最坏情况分析
最坏情况:输入数据完全逆序排列
- 比较操作:无论输入是否逆序,选择排序都需要遍历所有未排序部分,比较次数不变: T w o r s t ( n ) = n ( n − 1 ) 2 = O ( n 2 ) T_{worst}(n) = \frac{n(n-1)}{2} = O(n^2) Tworst(n)=2n(n−1)=O(n2)
- 交换操作:
- 每轮都需要交换最小值到当前排序位置,总共进行 n − 1 n−1 n−1 次交换。
- 比较和交换的总复杂度主要由比较操作决定,仍然是 O ( n 2 ) O(n^2) O(n2)。
结论: 最坏情况下,选择排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2),交换次数最多为 n − 1 n-1 n−1 次。
选择排序的复杂度总结
情况 | 比较次数 | 交换次数 | 总时间复杂度 |
---|---|---|---|
最好情况 | n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1) | 0 | O ( n 2 ) O(n^2) O(n2) |
最坏情况 | n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1) | n − 1 n-1 n−1 | O ( n 2 ) O(n^2) O(n2) |
平均情况 | n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1) | ≈ n / 2 \approx n/2 ≈n/2 | O ( n 2 ) O(n^2) O(n2) |
选择排序的特点
- 比较次数固定:无论输入数据是否有序,比较次数始终为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1)。
- 交换次数较少:对于交换操作,选择排序比冒泡排序更高效,尤其是在数据基本有序时。
- 适用场景:适用于小规模数据排序,对内存有限的场景较友好。
6.与冒泡排序的差别
(1) 排序思想
排序算法 | 核心思想 |
---|---|
选择排序 | 每一轮从未排序部分选择最小(或最大)的元素,放到已排序部分的末尾。 |
冒泡排序 | 每一轮从未排序部分中比较相邻两个元素,较大(或较小)的元素向后(或向前)冒泡,最终将最大(或最小)元素移到正确位置。 |
(2)时间复杂度
算法 | 最好情况 | 最坏情况 | 平均情况 |
---|---|---|---|
选择排序 | O ( n 2 ) O(n^2) O(n2)(比较固定) | O ( n 2 ) O(n^2) O(n2)(比较固定) | O ( n 2 ) O(n^2) O(n2) |
冒泡排序 | O ( n ) O(n) O(n)(已排序,0 次交换) | O ( n 2 ) O(n^2) O(n2)(完全逆序) | O ( n 2 ) O(n^2) O(n2) |
- 选择排序的比较次数始终为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1),不受输入序列的初始状态影响,且交换次数较少(最多 n − 1 n−1 n−1 次)。
- 冒泡排序在已排序的情况下可以通过优化(加标志位)提前结束,最好的情况下复杂度为 O ( n ) O(n) O(n)。
(3)稳定性
算法 | 稳定性 | 说明 |
---|---|---|
选择排序 | 不稳定 | 如果最小元素通过交换或平移操作插入到目标位置,可能破坏相同元素的相对顺序。 |
冒泡排序 | 稳定 | 相邻元素的交换不会改变相同元素的相对顺序,因此稳定。 |
(4)适用场景
算法 | 适用场景 |
---|---|
选择排序 | 数据量较小,且对排序稳定性要求不高的场景;需要减少交换次数时(如写入成本较高的情况)。 |
冒泡排序 | 数据量较小,且对排序稳定性有要求的场景;初始数据可能接近有序的情况(优化后的冒泡排序效果更佳)。 |
(5) 算法特点与性能对比
特性 | 选择排序 | 冒泡排序 |
---|---|---|
实现难度 | 较简单,逻辑清晰 | 简单易懂,但优化版实现稍复杂 |
比较次数 | 固定为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1) | 最坏情况下为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1),最优为 O ( n ) O(n) O(n) |
交换次数 | 最多 n − 1 n-1 n−1 次 | 最坏情况下为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1),较多 |
稳定性 | 不稳定 | 稳定 |
内存占用 | 原地排序,空间复杂度 O ( 1 ) O(1) O(1) | 原地排序,空间复杂度 O ( 1 ) O(1) O(1) |
优化空间 | 几乎没有优化空间 | 可通过标志位优化,提前结束排序 |
四、插入排序
1.插入排序的思想
插入排序是一种简单直观的排序算法,主要思想是:将数据分为已排序和未排序两部分,每次从未排序部分取一个元素,将其插入到已排序部分的适当位置,直到所有元素都被排序。
2.伪代码
for i = 1 to n-1 do: # 从第二个元素开始
key = arr[i] # 保存当前待插入的元素
j = i - 1 # 从已排序部分的最后一个元素开始比较
while j >= 0 and arr[j] > key do:
arr[j + 1] = arr[j] # 如果当前元素大于待插入元素,向右移动
j = j - 1
end while
arr[j + 1] = key # 在正确位置插入元素
end for
3.算法复杂度
时间复杂度:
-
最坏情况:序列完全逆序,每次插入都需要比较并移动所有已排序元素:
T w o r s t ( n ) = n ( n − 1 ) 2 = O ( n 2 ) T_{worst}(n) = \frac{n(n-1)}{2} = O(n^2) Tworst(n)=2n(n−1)=O(n2) -
最好情况:序列已排序,不需要移动元素,只需比较一次
T b e s t ( n ) = O ( n ) T_{best}(n) = O(n) Tbest(n)=O(n) -
平均情况:约为 O ( n 2 ) O(n^2) O(n2)。
空间复杂度:
- 插入排序是原地排序算法,只需常量级额外空间,空间复杂度为 O ( 1 ) O(1) O(1)。
4.插入排序的在线特性
插入排序是一种在线算法,这意味着它可以处理数据流,即当新的数据到达时,无需重新对全部数据进行排序,而是将新数据直接插入到已排序的部分中,使整个数据集始终保持有序。
在插入排序中,数据的排序是逐步完成的:
- 初始时,只有第一个元素被视为已排序。
- 每次从未排序部分取一个新元素,插入到已排序部分的适当位置。
- 这一过程无需依赖于所有数据已经存在,只需要将新到的数据正确插入即可。
因此,插入排序天然适合处理动态数据流场景。
5.查找与插入的平衡
(1)查找插入位置
基本方法:顺序查找
- 从已排序部分的末尾向前扫描,通过逐一比较找到插入位置。
- 时间复杂度:最坏情况下需要比较 O ( n ) O(n) O(n)次(新元素比所有已排序元素都小)。
优化方法:二分查找
- 利用已排序部分的有序性,通过二分查找快速定位插入位置。
- 时间复杂度:比较次数降为 O ( log n ) O(\log n) O(logn)。
平衡点:
- 顺序查找简单直观,适合数据量较小的情况。
- 二分查找减少了比较次数,但无法减少插入操作的时间复杂度(仍需要移动元素)。
(2)插入新元素
插入新元素的操作通常是将插入位置后的元素依次右移,为新元素腾出空间。
插入操作的特点
- 插入操作的时间复杂度取决于插入位置的远近:
- 最好情况(新元素插入到末尾):需要移动 0 个元素,复杂度 O ( 1 ) O(1) O(1)。
- 最坏情况(新元素插入到开头):需要移动 n − 1 n-1 n−1 个元素,复杂度 O ( n ) O(n) O(n)。
- 平均情况下,插入操作的时间复杂度约为 O ( n / 2 ) O(n/2) O(n/2)。
平衡点:
- 插入操作的效率取决于数据结构:
- 对于数组或顺序表,插入需要大规模移动元素,插入代价较高。
- 对于链表,可以直接调整指针,无需移动元素,插入代价较低。
五、逆序对
1.概念
在一个数组或序列中,逆序对是指数组中满足以下条件的元素对
(
i
,
j
)
(i, j)
(i,j):
i
<
j
且
a
[
i
]
>
a
[
j
]
i < j \quad \text{且} \quad a[i] > a[j]
i<j且a[i]>a[j]
换句话说,数组中前面的元素大于后面的元素,且它们的下标满足
i
<
j
i < j
i<j,这样的元素对就称为一个逆序对。
2.性质
- 在序列中交换一对逆序元素,逆序对总数必然减少。
- 在序列中交换一对紧邻的逆序元素,逆序对总数恰好减一。
3.用逆序对分析冒泡与插入
(1)冒泡排序与逆序对
冒泡排序处理逆序对的方式:
- 每次冒泡循环中,最多可以消除一个逆序对(将相邻的两个逆序元素交换位置)。
- 如果一个数组的逆序对数量为 k,则冒泡排序至少需要 k 次交换操作来消除这些逆序对。
- 总比较次数是固定的,为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1),无论逆序对的初始数量是多少。
冒泡排序特点:
- 每轮只能消除相邻逆序对,因此性能较差。
- 逆序对的数量直接影响冒泡排序的交换次数,但总比较次数固定。
(2)插入排序与逆序对
插入排序处理逆序对的方式
- 插入排序在每次插入一个元素时,可以一次性消除该元素与已排序部分所有逆序对。
- 如果一个数组的逆序对数量为 k,则插入排序至多需要 k 次移动操作来消除这些逆序对。
插入排序特点:
- 每次插入操作可以消除多个逆序对,因此性能更高。
- 逆序对的数量影响插入排序的移动次数和比较次数。
(3)冒泡排序与插入排序的比较(基于逆序对)
特性 | 冒泡排序 | 插入排序 |
---|---|---|
比较次数 | 固定为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1),与逆序对无关 | 受逆序对数量影响,最优为 O ( n ) O(n) O(n) 最差为 O ( n 2 ) O(n^2) O(n2) |
交换/移动次数 | 等于逆序对数量 k k k | 等于逆序对数量 k k k |
处理逆序对的方式 | 每轮只能消除一个相邻逆序对 | 每次插入可消除多个逆序对 |
性能优化空间 | 无法通过减少逆序对优化性能 | 可利用部分有序性显著减少操作次数 |
稳定性 | 稳定 | 稳定 |
4.插入排序是输入敏感的。
六、游标
在数据结构中,游标(Cursor)通常用于链表或其他动态数据结构的操作中,特别是在某些没有指针概念的语言中(如传统的 C 中用数组模拟链表时)。游标操作的核心在于利用索引或位置值来模拟指针的功能,从而实现链表或其他动态结构的操作。以下是游标的初始化、插入、删除操作的含义和实现原理:
1. 游标的初始化
游标的初始化是为整个数据结构的游标表或链表创建一个初始状态,通常包括以下内容:
- 分配空间:为节点数组分配内存。
- 建立空闲链表:将所有未使用的节点连接起来,形成一个空闲链表,用于管理未被分配的节点。
- 初始化头节点和尾节点:设置头节点和尾节点的游标值(通常为特殊值,如 -1 表示空节点)。
操作含义:
- 初始化时,所有节点被标记为未使用状态,并通过游标连接成一个空闲链表。
- 设置游标指向第一个有效位置(或空闲节点的起点)。
示例(数组模拟):
#define MAX_SIZE 100
typedef struct {
int data;
int next;
} Node;
Node cursorSpace[MAX_SIZE];
// 初始化
void initializeCursor() {
for (int i = 0; i < MAX_SIZE - 1; i++) {
cursorSpace[i].next = i + 1; // 将空闲节点连接起来
}
cursorSpace[MAX_SIZE - 1].next = -1; // -1 表示空
}
2. 游标的插入操作
插入操作用于在链表或游标结构中增加一个新的节点。关键步骤包括:
- 从空闲链表中分配节点:通过游标找到一个空闲节点。
- 设置新节点的值和下一个指向:将新节点插入到指定位置。
- 更新前驱节点的指向:将新节点连接到链表中。
操作含义:
- 插入操作将新数据添加到指定的位置,同时维护链表的逻辑顺序。
示例(数组模拟):
int allocateNode() {
int p = cursorSpace[0].next; // 找到空闲链表的第一个节点
if (p != -1) {
cursorSpace[0].next = cursorSpace[p].next; // 更新空闲链表
}
return p; // 返回分配的节点下标
}
void insertNode(int position, int value) {
int newNode = allocateNode();
if (newNode == -1) return; // 无可用空间
cursorSpace[newNode].data = value;
cursorSpace[newNode].next = cursorSpace[position].next; // 新节点指向当前位置的下一个节点
cursorSpace[position].next = newNode; // 将新节点插入链表
}
3. 游标的删除操作
删除操作用于从链表或游标结构中移除指定的节点。关键步骤包括:
- 找到待删除节点的前驱节点:通过遍历链表找到前驱节点。
- 更新前驱节点的指向:跳过待删除节点。
- 释放删除的节点:将其归还到空闲链表中。
操作含义:
- 删除操作从链表中移除一个节点,并将其标记为未使用状态,以便重新分配。
示例(数组模拟):
void freeNode(int p) {
cursorSpace[p].next = cursorSpace[0].next; // 将节点加入空闲链表
cursorSpace[0].next = p;
}
void deleteNode(int position) {
int prev = 0; // 假设 0 是头节点的游标
while (cursorSpace[prev].next != position) {
prev = cursorSpace[prev].next; // 找到前驱节点
}
cursorSpace[prev].next = cursorSpace[position].next; // 跳过待删除节点
freeNode(position); // 释放节点
}