基于列表的选择排序与插入排序

零、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)

优点
  1. 动态大小
    • 列表可以动态调整大小,适合需要频繁插入和删除操作的场景。
    • 不需要预分配空间,节省存储。
  2. 高效的插入和删除
    • 在已知位置插入或删除元素的时间复杂度为 O ( 1 ) O(1) O(1)(链表),不需要移动其他元素。
  3. 内存分配灵活
    • 由于元素分散存储,插入时无需连续的内存块。
  4. 稳定的指针或迭代器
    • 链表的插入和删除不会影响其他元素的指针或迭代器。
缺点
  1. 访问效率低
    • 访问列表中第 k k k 个元素的时间复杂度为 O ( k ) O(k) O(k),因为需要从头开始遍历。
  2. 额外存储开销
    • 链表需要额外存储每个节点的指针,内存开销较大。
  3. 不支持随机访问
    • 列表只能通过遍历进行访问,无法通过索引直接定位元素。
  4. 缓存性能差
    • 由于节点分散在内存中,不利于利用 CPU 缓存优化性能。

2.向量(Vector)

优点
  1. 支持随机访问
    • 可以通过索引直接访问任意元素,时间复杂度为 $O(1)4。
  2. 内存连续
    • 数据存储在一块连续的内存中,利于缓存优化,访问速度快。
  3. 空间利用率高
    • 不需要为每个元素存储额外的指针。
  4. 支持动态扩展
    • 向量可以动态扩展,当容量不足时会自动分配更大的内存。
  5. 标准库支持丰富
    • 向量是 STL(C++ 标准库)中最常用的容器之一,具有丰富的操作接口。
缺点
  1. 插入和删除效率低
    • 在非尾部位置插入或删除元素时,需要移动大量元素, 时间复杂度为 O ( n ) O(n) O(n)
  2. 扩展时的开销
    • 当容量不足时,向量需要重新分配内存并复制已有元素,增加了时间和空间开销。
  3. 需要连续内存
    • 向量需要在内存中分配一块连续的空间,当空间不足时可能导致分配失败。
  4. 迭代器可能失效
    • 当向量发生扩展或元素移动时,指针或迭代器可能失效。

适用场景对比

特性列表向量
随机访问不支持支持,效率高
插入/删除效率高(在任意位置操作)低(需要移动其他元素)
动态扩展插入时自动调整,不需要额外操作插入时可能需要重新分配内存
内存使用高,需额外存储指针低,存储空间紧凑
缓存性能差(分散存储)好(连续存储)
迭代器稳定性稳定(插入或删除不会影响其他指针)不稳定(插入、删除、扩展可能失效)

总结

  • 选择列表(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. 遍历未排序部分找到最小值(比较操作)。
  2. 将最小值放到正确位置(交换或平移操作)。

一般过程

  • 第 1 轮:遍历 n n n 个元素,找到最小值。
  • 第 2 轮:遍历剩余 n − 1 n−1 n1 个元素,找到最小值。
  • n − 1 n−1 n1 轮:只需比较 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)=(n1)+(n2)++1=2n(n1)=O(n2)
总的交换次数(最多为 n − 1 n−1 n1 次)远少于比较次数,对整体复杂度影响不大。


最好情况分析

最好情况:输入数据已按顺序排列

  • 比较操作:即使输入是有序的,选择排序仍需要比较来确认最小值,因此比较次数不会减少: 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(n1)=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(n1)=O(n2)
  • 交换操作:
    • 每轮都需要交换最小值到当前排序位置,总共进行 n − 1 n−1 n1 次交换。
    • 比较和交换的总复杂度主要由比较操作决定,仍然是 O ( n 2 ) O(n^2) O(n2)

结论: 最坏情况下,选择排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2),交换次数最多为 n − 1 n-1 n1 次。


选择排序的复杂度总结
情况比较次数交换次数总时间复杂度
最好情况 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n1)0 O ( n 2 ) O(n^2) O(n2)
最坏情况 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n1) n − 1 n-1 n1 O ( n 2 ) O(n^2) O(n2)
平均情况 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n1) ≈ n / 2 \approx n/2 n/2 O ( n 2 ) O(n^2) O(n2)

选择排序的特点
  1. 比较次数固定:无论输入数据是否有序,比较次数始终为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n1)
  2. 交换次数较少:对于交换操作,选择排序比冒泡排序更高效,尤其是在数据基本有序时。
  3. 适用场景:适用于小规模数据排序,对内存有限的场景较友好。

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(n1),不受输入序列的初始状态影响,且交换次数较少(最多 n − 1 n−1 n1 次)。
  • 冒泡排序在已排序的情况下可以通过优化(加标志位)提前结束,最好的情况下复杂度为 O ( n ) O(n) O(n)

(3)稳定性
算法稳定性说明
选择排序不稳定如果最小元素通过交换或平移操作插入到目标位置,可能破坏相同元素的相对顺序。
冒泡排序稳定相邻元素的交换不会改变相同元素的相对顺序,因此稳定。

(4)适用场景
算法适用场景
选择排序数据量较小,且对排序稳定性要求不高的场景;需要减少交换次数时(如写入成本较高的情况)。
冒泡排序数据量较小,且对排序稳定性有要求的场景;初始数据可能接近有序的情况(优化后的冒泡排序效果更佳)。

(5) 算法特点与性能对比
特性选择排序冒泡排序
实现难度较简单,逻辑清晰简单易懂,但优化版实现稍复杂
比较次数固定为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n1)最坏情况下为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n1),最优为 O ( n ) O(n) O(n)
交换次数最多 n − 1 n-1 n1最坏情况下为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n1),较多
稳定性不稳定稳定
内存占用原地排序,空间复杂度 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(n1)=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 n1 个元素,复杂度 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<ja[i]>a[j]
​ 换句话说,数组中前面的元素大于后面的元素,且它们的下标满足 i < j i < j i<j,这样的元素对就称为一个逆序对

2.性质

  • 在序列中交换一对逆序元素,逆序对总数必然减少。
  • 在序列中交换一对紧邻的逆序元素,逆序对总数恰好减一。

3.用逆序对分析冒泡与插入

(1)冒泡排序与逆序对

冒泡排序处理逆序对的方式:

  • 每次冒泡循环中,最多可以消除一个逆序对(将相邻的两个逆序元素交换位置)。
  • 如果一个数组的逆序对数量为 k,则冒泡排序至少需要 k 次交换操作来消除这些逆序对。
  • 总比较次数是固定的,为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n1),无论逆序对的初始数量是多少。

冒泡排序特点

  • 每轮只能消除相邻逆序对,因此性能较差。
  • 逆序对的数量直接影响冒泡排序的交换次数,但总比较次数固定。
(2)插入排序与逆序对

插入排序处理逆序对的方式

  • 插入排序在每次插入一个元素时,可以一次性消除该元素与已排序部分所有逆序对。
  • 如果一个数组的逆序对数量为 k,则插入排序至多需要 k 次移动操作来消除这些逆序对。

插入排序特点

  • 每次插入操作可以消除多个逆序对,因此性能更高。
  • 逆序对的数量影响插入排序的移动次数比较次数

(3)冒泡排序与插入排序的比较(基于逆序对)
特性冒泡排序插入排序
比较次数固定为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n1),与逆序对无关受逆序对数量影响,最优为 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. 游标的插入操作

​ 插入操作用于在链表或游标结构中增加一个新的节点。关键步骤包括:

  1. 从空闲链表中分配节点:通过游标找到一个空闲节点。
  2. 设置新节点的值和下一个指向:将新节点插入到指定位置。
  3. 更新前驱节点的指向:将新节点连接到链表中。

操作含义

  • 插入操作将新数据添加到指定的位置,同时维护链表的逻辑顺序。

示例(数组模拟)

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. 游标的删除操作

删除操作用于从链表或游标结构中移除指定的节点。关键步骤包括:

  1. 找到待删除节点的前驱节点:通过遍历链表找到前驱节点。
  2. 更新前驱节点的指向:跳过待删除节点。
  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); // 释放节点
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

橙汁味的风

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值