芝兰生于深林,不以无人而不芳;君子修道立德,不为穷困而改节
文章目录
插入排序
插入排序(Insertion Sort)是一种简单直观的排序算法,它的工作原理类似于我们打扑克牌时对手中的牌进行排序的方式。插入排序的基本思想是将一个待排序的元素插入到已经排好序的部分的适当位置,使得插入后的序列仍然有序。
插入排序的基本步骤如下:
- 从第一个元素开始,认为第一个元素已经是排好序的部分。
- 取出下一个元素,在已经排序的元素序列中从后向前扫描。
- 如果该元素(已排序)大于新元素,将该元素移到下一位置。
- 重复步骤3,直到找到已排序的元素小于或等于新元素的位置。
- 将新元素插入到该位置后。
- 重复步骤2~5,直到所有元素都排序完成。
插入排序是一种稳定的排序算法,其时间复杂度为 O(n^2^),其中 n 是待排序元素的数量。最好情况下,如果数组已经有序,时间复杂度为 O(n);最坏情况下,如果数组逆序,时间复杂度为 O(n^2)。其空间复杂度为 O(1),因为它仅使用了常量级的额外空间。
插入排序在小规模数据或基本有序数据的排序中性能较好,但在大规模数据排序中通常不如快速排序、归并排序等高级排序算法。然而,在某些特定情况下,插入排序可能会比其他高级算法更加有效,因此它仍然是一种重要的排序方法之一。
直接插入排序
直接插入排序是一种简单直观的排序算法,它的基本思想是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增加1的有序表。直接插入排序的实现过程通常如下:
- 初始状态:假设待排序的元素存储在一个数组中,初始时将第一个元素看作是已排序的序列,其余元素看作是未排序的序列。
- 遍历未排序序列:从第二个元素开始,依次将未排序的元素插入到已排序的序列中。
- 插入操作:对于未排序序列中的每个元素,依次与已排序序列中的元素比较,找到合适的位置并插入,使得已排序序列保持有序。
- 重复步骤2和3,直到所有元素都插入到已排序序列中,排序完成。
cpp
bool insertSort(std::vector<int>& arr, int gap = 1)
{
int n = arr.size();
for (int i = 1; i < n; ++i)
{
int key = arr[i];
int j = i - 1;
// 将 arr[0..i-1] 中比 key 大的元素都向后移动一个位置
while (j >= 0 && arr[j] > key)
{
arr[j + 1] = arr[j];
--j;
}
// 将 key 插入到合适的位置
arr[j + 1] = key;
}
return true;
}
直接插入排序的时间复杂度取决于输入数据的初始状态。
- 最好情况: 如果输入数组已经是有序的,那么每次比较只需要比较一次,时间复杂度为 (O(n))。
- 最坏情况: 如果输入数组是逆序排列的,那么每次插入一个新元素时都需要比较和移动前面已排序部分的所有元素,时间复杂度为 (O(n^2^))。
- 平均情况: 平均时间复杂度也是O(n^2^),因为在平均情况下,每次插入新元素的位置不确定,需要移动的元素个数与已排序部分的长度相关。
直接插入排序的空间复杂度主要取决于需要排序的数组本身,以及一些额外的辅助空间。
- 原地排序: 直接插入排序是一种原地排序算法,它不需要额外的辅助空间,空间复杂度为 (O(1))。
- 除数组本身外的空间: 在进行直接插入排序时,只需要使用常量级别的额外空间来存储临时变量和索引,因此空间复杂度较低。
综上所述,直接插入排序的时间复杂度主要取决于输入数据的初始状态,而空间复杂度相对较低,是一种简单但不够高效的排序算法。
希尔排序
希尔排序(Shell Sort)是一种基于插入排序的排序算法,它是由Donald Shell在1959年提出的,也称为"缩小增量排序"。
希尔排序的基本思想是将原始数组分成若干个子序列,在每个子序列中进行插入排序,然后逐步缩小增量(间隔),最终完成对整个数组的排序。希尔排序的关键在于选择增量序列,并且在每轮排序中,将数组中相隔增量的元素分成一组,对各组进行插入排序。
- 增量序列的选择对希尔排序的性能有较大影响,常用的增量序列包括希尔增量序列、Hibbard增量序列、Sedgewick增量序列等。
- 希尔排序是不稳定排序算法,因为在排序过程中存在跨越多个子序列的元素交换。
希尔排序的时间复杂度取决于增量序列的选择,最坏情况下的时间复杂度为 O(n^2^),平均情况下的时间复杂度介于 (O(nlog~2~n)) 和 O(n^2^) 之间。希尔排序是一种原地排序算法,它的空间复杂度为 (O(1)),即常数级别的额外空间。
希尔排序相比直接插入排序效率高的原因主要是因为它利用了直接插入排序的优点,并通过分组插入的方式,使得数组在初始阶段就变得部分有序。
- 间隔插入: 希尔排序将整个数组按照一定的间隔分成若干个子序列,然后对每个子序列进行插入排序。通过这种方式,希尔排序可以在一定程度上解决直接插入排序中逆序对过多的问题,因为通过较大的间隔,可以更快地将较小的元素移到数组的前面。
- 逐步优化: 希尔排序通过不断减小间隔,逐步缩小子序列的规模,最终实现整个数组的基本有序。这种分阶段的逐步优化可以使得数组在排序过程中逐渐趋于有序状态,从而减少了每次插入操作的移动次数。
- 时间复杂度优化: 希尔排序的时间复杂度相比直接插入排序有所降低。尽管希尔排序的最坏时间复杂度仍然是 O(n^2^),但在一般情况下,由于数组在初始阶段就变得部分有序,因此实际的比较和移动操作会减少,从而提高了排序的效率。
算法步骤 | 说明 |
---|---|
确定增量序列 | 选择一个增量序列,通常是 n/2, n/4, n/8,..., 1。增量序列的选择会影响到希尔排序的性能。 |
按增量进行分组 | 根据选择的增量序列,将待排序的数组分成若干个子序列。 |
对各个子序列进行插入排序 | 对每个子序列进行插入排序,这里的插入排序是根据增量值进行的,而不是对整个数组进行插入排序。 |
逐步减小增量 | 重复步骤 2 和 3,直到增量为 1。最后一次排序使用增量为 1 的插入排序,这时候数组已经基本有序,插入排序的效率会比较高。 |
cpp
bool shellSort(std::vector<int>& arr)
{
int gap = arr.size();
while (gap)
{
for (int i = 1; i < arr.size(); i += gap)
{
int j = i - 1, key = arr[i];
while (j >= 0 && arr[j] > key)
{
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
gap /= 2;
}
return true;
}
综上所述,希尔排序是一种简单而有效的排序算法,适用于中等大小的数据集合。尽管其时间复杂度并不是最优的,但在实际应用中,希尔排序的性能仍然比较可观,特别是对于某些特定的增量序列。
选择排序
直接选择排序
直接选择排序(Selection Sort)是一种简单直观的排序算法,它的思想是在未排序序列中找到最小(或最大)元素,将其放置在已排序序列的末尾,然后将未排序序列的第一个元素与已排序序列的最后一个元素交换位置,如此循环,直到整个数组排序完成。
- 从待排序序列中选择最小(或最大)的元素,记下其索引。
- 将最小(或最大)元素与待排序序列的第一个元素交换位置。
- 在剩余的待排序序列中继续执行步骤1和步骤2,直到所有元素都被排序。
直接选择排序的时间复杂度是 (O(n^2)),空间复杂度是 (O(1))。它是一种不稳定的排序算法,因为在选择过程中可能会改变相同元素的相对位置。
cpp
bool selectSort(std::vector<int>& arr)
{
for (int i = 0; i < arr.size(); i++)
{
int minNum = i;
for (int j = i + 1; j < arr.size(); j++)
{
if (arr[j] < arr[minNum]) minNum = j;
}
std::swap(arr[minNum], arr[i]);
}
return true;
}
堆排序
堆排序(Heap Sort)是一种基于完全二叉树(堆)的排序算法,它利用了堆的性质进行排序。堆是一种特殊的树形数据结构,具有以下特点:
- 在堆中,每个节点的值都大于等于(最大堆)或小于等于(最小堆)其子节点的值。
- 堆是一个完全二叉树,即除了最底层之外,其他层都被完全填满,且最底层从左到右填入。
堆排序的基本思想是:
- 将待排序的序列构建成一个堆(通常是最大堆)。
- 交换堆顶元素(最大值)和堆的最后一个元素,然后重新调整堆。
- 重复步骤2,直到整个序列有序。
堆排序的步骤:
- 构建堆:从最后一个非叶子节点开始,依次向前构建最大堆。
- 交换堆顶元素和堆的最后一个元素,并调整堆。
- 重复步骤2,直到堆中只剩一个元素。
堆排序是一种原地排序算法,它的时间复杂度是 (O(n log n)),空间复杂度是 (O(1))。堆排序是一种不稳定的排序算法,因为在调整堆的过程中可能改变相同元素的相对顺序。
cpp
void adjustDown(std::vector<int>& arr, int cur, int len)
{
int leftChild = cur * 2 + 1;
while (leftChild < len)
{
int rightChild = leftChild + 1;
int maxChild = (rightChild < len && arr[rightChild] > arr[leftChild]) ? rightChild : leftChild;
if (arr[maxChild] > arr[cur])
{
std::swap(arr[maxChild], arr[cur]);
cur = maxChild;
leftChild = cur * 2 + 1;
}
else break;
}
}
void createMaxHeap(std::vector<int>& arr)
{
int len = arr.size();
for (int i = len / 2; i >= 0; i--)
{
adjustDown(arr, i, len);
}
}
bool heapSort(std::vector<int>& arr)
{
createMaxHeap(arr);
for (int i = arr.size() - 1; i > 0; i--)
{
std::swap(arr[0], arr[i]);
adjustDown(arr, 0, i);
}
return true;
}
交换排序
冒泡排序
冒泡排序是一种简单直观的排序算法,它的基本思想是通过交换相邻元素多次对数组进行遍历,每次遍历都将最大(或最小)的元素交换到数组的末尾(或开始)。这个过程类似于气泡在水中上升的过程,故称为冒泡排序。
- 从数组的第一个元素开始,依次比较相邻的两个元素,如果它们的顺序不正确(比如前面的元素大于后面的元素),则交换它们的位置。
- 继续比较相邻的元素,直到整个数组被遍历一次。
- 重复上述步骤,每次遍历都将最大(或最小)的元素交换到数组的末尾(或开始),并缩小待排序数组的范围。
冒泡排序的时间复杂度取决于数组的初始状态,最好情况下(数组已经有序)时间复杂度为 (O(n)),最坏情况下(数组逆序)时间复杂度为 (O(n^2)),平均时间复杂度也为 (O(n^2))。冒泡排序是一种原地排序算法,只需要常数级别的额外空间来存储临时变量,因此空间复杂度为 (O(1))。
冒泡排序是一种稳定的排序算法。在相邻元素相等时,不会改变它们的相对顺序,因此是稳定的。冒泡排序的实现较为简单,但由于其时间复杂度较高,在实际应用中并不常见,通常被更高效的排序算法所取代,比如快速排序、归并排序等。
cpp
bool bubbleSort(std::vector<int>& arr)
{
for (int i = 0; i < arr.size(); i++)
{
for (int j = 1; j < arr.size(); j++)
{
if (arr[j - 1] > arr[j]) std::swap(arr[j - 1], arr[j]);
}
}
return true;
}
快速排序
快速排序(Quick Sort)是一种高效的排序算法,属于交换排序的一种。它的基本思想是通过分治法(Divide and Conquer)来实现排序。
- 选择基准值: 从数组中选择一个元素作为基准值(pivot)。
- 分区操作: 将数组中小于基准值的元素放置在基准值的左侧,大于基准值的元素放置在右侧,基准值则放置在正确的位置上。
- 递归排序: 对基准值左右两侧的子数组进行递归排序,直到整个数组有序。
实现思路 | 说明 |
---|---|
选择基准值 | 通常选择数组中的第一个元素、最后一个元素或者随机一个元素作为基准值。 |
分区操作 | 使用两个指针,一个指向数组的起始位置,一个指向数组的末尾位置。通过交替移动这两个指针,并交换元素,将数组分为两个部分。 |
递归排序 | 对分区后的左右子数组分别进行递归排序。 |
快速排序的平均时间复杂度为 (O(nlogn)),最坏情况下的时间复杂度为 (O(n^2)),最好情况下的时间复杂度也为 (O(nlog n))。快速排序的性能很大程度上取决于选取的基准值。快速排序是一种原地排序算法,它的空间复杂度主要取决于递归调用栈的深度,平均空间复杂度为 (O(log n)),最坏情况下空间复杂度为 (O(n))。
快速排序是一种不稳定的排序算法,因为在分区操作过程中,相同元素的相对位置可能会发生变化。
cpp
int sort(std::vector<int>& arr, int begin, int end)
{
int key = begin;
while (begin < end)
{
while (begin < end && arr[end] >= arr[key]) end--;
while (begin < end && arr[begin] <= arr[key]) begin++;
std::swap(arr[begin], arr[end]);
}
std::swap(arr[key], arr[begin]);
return begin;
}
void _quikSort(std::vector<int>& arr, int begin, int end)
{
if (begin >= end) return;
int key = sort(arr, begin, end);
_quikSort(arr, begin, key - 1);
_quikSort(arr, key + 1, end);
}
bool quikSort(std::vector<int>& arr)
{
_quikSort(arr, 0, arr.size() - 1);
return true;
}
优化
在快速排序中使用三数取中和小区间直接插入排序的优化,可以提高快速排序的性能,尤其是在处理小规模数据时。三数取中法是一种选择基准值的方法,它可以避免选取到最坏情况下的基准值,从而提高快速排序的性能。具体步骤如下:
- 从待排序数组的起始、中间和末尾位置选择三个元素。
- 对这三个元素进行排序,并取其中间位置的元素作为基准值。
在快速排序中,当待排序的子数组规模较小时,直接插入排序的性能可能比快速排序更好。因此,可以在快速排序中添加一个判断条件,当子数组的规模小于某个阈值时,使用直接插入排序对子数组进行排序。
以下是结合三数取中和小区间直接插入排序优化的快速排序算法的代码:
cpp
int getKey(std::vector<int>& arr, int begin, int end)
{
int mid = (begin + end) / 2;
if (arr[end] > arr[begin])
{
if (arr[mid] > arr[end]) return end;
else if (arr[mid] < arr[begin]) return begin;
else return mid;
}
else
{
if (arr[mid] < arr[end]) return end;
else if (arr[mid] > arr[begin]) return begin;
else return mid;
}
}
int sort(std::vector<int>& arr, int begin, int end)
{
int cur = getKey(arr, begin, end);
std::swap(arr[cur], arr[begin]);
int key = begin;
while (begin < end)
{
while (begin < end && arr[end] >= arr[key]) end--;
while (begin < end && arr[begin] <= arr[key]) begin++;
std::swap(arr[begin], arr[end]);
}
std::swap(arr[key], arr[begin]);
return begin;
}
void _quikSort(std::vector<int>& arr, int begin, int end)
{
if (begin >= end) return;
if (end - begin < 20)
{
insertSort(arr);
}
else
{
int key = sort(arr, begin, end);
_quikSort(arr, begin, key - 1);
_quikSort(arr, key + 1, end);
}
}
bool quikSort(std::vector<int>& arr)
{
_quikSort(arr, 0, arr.size() - 1);
return true;
}
在实际代码中,要根据具体情况选择合适的阈值来确定是否使用直接插入排序,以及合适的阈值大小。此外,三数取中法的选择也可以根据实际情况进行优化和改进。
挖坑法
挖坑法也是快速排序的一种优化方法,用于在分区操作中避免不必要的元素交换。其基本思想是通过找到一个"坑",将基准值放入坑中,然后利用坑的位置来实现元素的移动和交换,从而实现分区操作。
- 选择基准值:从待排序数组中选择一个元素作为基准值。
- 分区操作:
- 将基准值保存到一个临时变量中,此时数组中留下了一个"坑"。
- 从数组的另一端开始(通常是右侧),找到一个小于基准值的元素,并将其填入坑中。
- 然后从数组的左侧开始,找到一个大于基准值的元素,并将其放入刚才填入的坑中。
- 重复上述过程,直到左右指针相遇。
- 最后将基准值放入最后一个坑中,此时基准值左侧的元素都小于基准值,右侧的元素都大于基准值。
- 递归排序:对基准值左右两侧的子数组进行递归排序。
挖坑法的优点是减少了元素的交换次数,因为每次交换都需要消耗额外的时间,通过找到"坑"来避免了多余的交换操作,提高了快速排序的效率。
cpp
int potholingSort(std::vector<int>& arr, int left, int right)
{
int cur = left, val = arr[left];
while (left < right)
{
while (left < right && arr[right] >= val) right--;
arr[cur] = arr[right];
cur = right;
while (left < right && arr[left] <= val) left++;
arr[cur] = arr[left];
cur = left;
}
arr[left] = val;
return left;
}
前后指针法
cpp
int twopointSort(std::vector<int>& arr, int left, int right)
{
int prev = left, cur = prev + 1, key = left;
while (cur <= right)
{
if (arr[cur] < arr[key]) std::swap(arr[++prev], arr[cur]);
cur++;
}
std::swap(arr[key], arr[prev]);
return prev;
}
-
函数定义:
twopointSort
函数接受一个整数向量arr
,以及数组的左右边界left
和right
。- 函数返回的是基准值最终的位置。
-
前后指针:
- 在该算法中,使用了两个指针
prev
和cur
,分别表示前后指针。 prev
初始化为left
,表示指向数组的最左边界。cur
初始化为prev + 1
,表示指向prev
右边的第一个元素。
- 在该算法中,使用了两个指针
-
分区操作:
- 使用循环遍历数组,从
cur
开始向右移动,直到达到数组的右边界right
。 - 在遍历过程中,如果当前元素
arr[cur]
小于基准值arr[key]
(这里的key
是选择的基准值的索引),则将其与prev
右边的元素交换,并将prev
指针向右移动一位。 - 最后,将基准值
arr[key]
与arr[prev]
交换,将基准值放在正确的位置上。
- 使用循环遍历数组,从
-
返回值:
- 返回的是基准值最终的位置
prev
。
- 返回的是基准值最终的位置
这种前后指针的快速排序思想在于通过两个指针的移动,将小于基准值的元素放在基准值的左边,大于基准值的元素放在基准值的右边。最终,将基准值放在正确的位置上,使得左侧的元素都小于等于基准值,右侧的元素都大于等于基准值。
非递归版
非递归版本的快速排序可以使用栈来模拟递归调用的过程,以避免使用递归函数带来的额外开销。
cpp
void sort(std::vector<int>& arr, int left, int right)
{
if (left >= right) return;
if (right - left < 20) insertSort(arr);
else
{
std::stack<std::vector<int>> st;
st.push({ left, right });
while (st.size())
{
auto tmp = st.top();
st.pop();
int begin = tmp[0], end = tmp[1];
int key = twopointSort(arr, begin, end);
if(begin < key - 1) st.push({ begin, key - 1 });
if(key + 1 < end) st.push({ key + 1, end });
}
}
}
在这个非递归版本的快速排序中,使用了一个栈 stk
来模拟递归调用的过程。每次循环中,从栈中弹出当前的左右边界,然后进行分区操作,将左右子数组的边界压入栈中,以便下一轮循环继续处理。这样就实现了快速排序的非递归版本。
归并排序
归并排序是一种经典的排序算法,它采用分治法的思想,将待排序的数组分成若干个子数组,分别对子数组进行排序,然后将排好序的子数组合并成一个整体有序的数组。
- 分解:将待排序的数组递归地分解为越来越小的子数组,直到每个子数组只包含一个元素。
- 合并:将相邻的子数组合并成一个有序的数组,然后依次将合并后的有序数组再次合并,直到合并成一个完整的有序数组。
归并排序的关键在于合并操作,合并两个有序数组的过程是比较简单的。
- 分解:将数组分成两个子数组,然后分别对左右两个子数组进行归并排序。
- 合并:将排好序的左右两个子数组合并成一个有序数组。
递归
cpp
void _Merger(std::vector<int>& arr, int begin, int end, std::vector<int>& tmp)
{
if (begin >= end) return;
int mid = (begin + end) / 2;
_Merger(arr, begin, mid, tmp);
_Merger(arr, mid + 1, end, tmp);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int cur = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2]) tmp[cur++] = arr[begin1++];
else tmp[cur++] = arr[begin2++];
}
while (begin1 <= end1) tmp[cur++] = arr[begin1++];
while (begin2 <= end2) tmp[cur++] = arr[begin2++];
memcpy(&arr[begin], &tmp[begin], sizeof(int) * (end - begin + 1));
}
在归并排序中,递归函数 mergeSort
对数组进行分解并递归地调用自身,直到分解出的子数组只有一个元素时停止递归。然后,通过 merge
函数将相邻的两个有序子数组合并为一个更大的有序数组。最终,整个数组就变得有序了。
非递归
cpp
void _MergerNonR(std::vector<int>& arr, std::vector<int>& tmp)
{
int n = 1;
while (n < arr.size())
{
for (int i = 0; i < arr.size(); i += 2 * n)
{
int begin1 = i, end1 = i + n - 1;
int begin2 = i + n, end2 = i + 2 * n - 1;
if (begin1 >= arr.size() || begin2 >= arr.size()) break;
if (end2 >= arr.size()) end2 = arr.size() - 1;
int cur = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2]) tmp[cur++] = arr[begin1++];
else tmp[cur++] = arr[begin2++];
}
while (begin1 <= end1) tmp[cur++] = arr[begin1++];
while (begin2 <= end2) tmp[cur++] = arr[begin2++];
memcpy(&arr[i], &tmp[i], sizeof(int) * (end2 - i + 1));
}
n *= 2;
}
}
在非递归版本的归并排序中,外层循环控制子数组的大小 n
,初始值为1,每次循环翻倍,直到子数组的大小超过数组的长度。内层循环每次从数组的开头开始,将相邻的子数组两两合并,并将合并后的结果放回原数组。这样就完成了非递归版本的归并排序。
总结
下面是对这些排序算法的时间复杂度、空间复杂度和稳定性的总结:
排序算法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
直接插入排序 | O(n^2) | O(n^2) | O(n) | O(1) | 稳定 |
希尔排序 | O(n log^2 n) | O(n^2) | O(n log n) | O(1) | 不稳定 |
直接选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
冒泡排序 | O(n^2) | O(n^2) | O(n) | O(1) | 稳定 |
快速排序 | O(n log n) | O(n^2) | O(n log n) | O(log n) | 不稳定 |
归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
- 时间复杂度 :
- 平均时间复杂度表示在所有可能情况下的运行时间的期望值。
- 最坏时间复杂度表示在最差情况下的运行时间。
- 最好时间复杂度表示在最好情况下的运行时间。
- 空间复杂度:表示算法在执行过程中所需的额外空间。
- 稳定性:如果排序算法能够保持相同键值的原始顺序,则称其为稳定的,否则称为不稳定的。
根据上述总结,可以选择适合特定场景和需求的排序算法。例如,对于大规模数据集合,快速排序和堆排序通常是比较好的选择,而对于小规模数据集合,直接插入排序可能更有效。同时,如果需要保持相同键值的相对位置不变,应选择稳定的排序算法。