65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的比较排序算法详解

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.69/5 (24投票s)

2010年5月11日

CPOL

9分钟阅读

viewsIcon

121611

downloadIcon

1994

以 C# 编写的各种基于比较的排序算法的简化视图,包含每次迭代和传递的详细说明。

引言

在计算机科学和数学中,排序算法是一种用于将列表中的元素按特定顺序排列的算法。这些算法大致分为两大类:基于比较的排序算法,包括冒泡排序 (BubbleSort)、选择排序 (SelectionSort)、插入排序 (InsertionSort)、希尔排序 (ShellSort)、归并排序 (MergeSort)、堆排序 (HeapSort) 和快速排序 (QuickSort),它们是最常用的;以及非基于比较的排序算法,例如基数排序 (RadixSort)、桶排序 (BucketSort) 等。本文的重点是以简化的方式提供各种基于比较的排序算法,并附带优化、清理、易于理解的代码(附有注释和说明)。

背景

在深入研究各种基于比较的排序算法的过程中,我意识到大多数算法都是使用 C/C++ 中的指针来说明的,并且由于明显的原因,很少用 C# 编写。然而,作为一名 C# 开发者,我知道如果想用自己的编程语言将算法与其规范关联起来,并以简单有效的方式进行编程,需要经历多少痛苦。

本文是我在这些排序算法的编码实践中的一个汇编列表。我不会以图形或图像的方式来说明这些算法的工作原理,因为维基百科是最佳来源,事实上,我的知识也继承自那里。然而,我的重点将是纯粹从程序员的角度出发,实现这些算法在 C# 中的应用,以及在考虑最佳情况、最坏情况和平均情况复杂度时需要注意的事项。

使用代码

在“基于比较的排序算法”这个大类下,我们有几个子类,例如交换排序 (Exchange sorts)、选择排序 (Selection sorts)、插入排序 (Insertion sorts)、归并排序 (Merge sorts) 等,我们将在稍后通过实现来逐一了解。

第一步是创建样本数据集(在本例中是数组)。还要创建两种交换算法,一种使用临时变量,一种不使用临时变量,以供我们的排序机制使用。

long[] inputArray = new long[1000];

Random rnd = new Random();

for (int i = 0; i < inputArray.Length; i++)
{
    inputArray[i] = rnd.Next();
}

private void Swap(ref long valOne, ref long valTwo)
{
    valOne = valOne + valTwo;
    valTwo = valOne - valTwo;
    valOne = valOne - valTwo;
}

private void SwapWithTemp(ref long valOne, ref long valTwo)
{
    long temp = valOne;
    valOne = valTwo;
    valTwo = temp;
}

这是样本数据(随机)以及我们将要用于排序算法的交换机制。

交换排序

现在让我们从“基于交换的排序”类别开始。这里有两个最常用的算法。一个是简单且平凡的“冒泡排序”,另一个是该组中最快的“快速排序”。

冒泡排序

这是一种直接且简单的对数据进行排序的方法。该算法从数据集的开头开始。它比较前两个元素,如果第一个大于第二个,则交换它们。它继续对数据集末尾的每一对相邻元素执行此操作。然后它再次从前两个元素开始,重复进行,直到在最后一趟中没有发生交换。此算法效率极低,很少使用。

  • 最佳情况 - O(n)
  • 平均情况 - O(n^2)
  • 最坏情况 - O(n^2)
  • 稳定性 - 稳定
private void BubbleSort(long[] inputArray)
{
    for (int iterator = 0; iterator < inputArray.Length; iterator++)
    {
        for (int index = 0; index < inputArray.Length - 1; index++)
        {
            if (inputArray[index] > inputArray[index + 1])
            {
                Swap(ref inputArray[index], ref inputArray[index+1]);
            }
        }
    }
}

快速排序

这是一种分治算法,依赖于分区操作,即,要对数组进行分区,请选择一个称为枢轴的元素,将所有小于枢轴的元素移到它前面,将所有大于枢轴的元素移到它后面。这可以在线性时间和原地完成。然后,递归地对子列表进行排序。

  • 最佳情况 - O(n log n)
  • 平均情况 - O(n log n)
  • 最坏情况 - O(n^2)
  • 稳定性 - 取决于枢轴的处理方式
private void QuickSort(long[] inputArray)
{
    int left = 0;
    int right = inputArray.Length - 1;
    InternalQuickSort(inputArray, left, right);
}

<summary>
// Internal recursive sort algorithm for quick sort
// using divide and conquer. Sorting is done based on pivot
</summary>
<param name="inputArray"></param>
<param name="left"></param>
<param name="right"></param>
private void InternalQuickSort(long[] inputArray, int left, int right)
{
    int pivotNewIndex = Partition(inputArray, left, right);
    long pivot = inputArray[(left + right) / 2];
    if (left < pivotNewIndex-1)
        InternalQuickSort(inputArray, left, pivotNewIndex - 1);
    if (pivotNewIndex < right)
        InternalQuickSort(inputArray, pivotNewIndex, right);
}

//This operation returns a new pivot everytime it is called recursively
//and swaps the array elements based on pivot value comparison
private int Partition(long[] inputArray, int left, int right)
{
    int i = left, j = right;
    long pivot = inputArray[(left + right) / 2];

    while (i <= j)
    {
        while (inputArray[i] < pivot)
            i++;
        while (inputArray[j] < pivot)
            j--;
        if (i <= j)
        {
            SwapWithTemp(ref inputArray[i], ref inputArray[j]);
            i++; j--;
        }
    }
    return i;
}

选择排序

在“选择排序”类别中,我们又有两个熟悉的算法。一个是“选择排序”本身,另一个是“堆排序”。

选择排序

它本质上是一种原地比较排序。它的复杂度为 O(n^2),在处理大型列表时效率低下,并且通常比类似的插入排序表现更差。选择排序以其简单性而闻名,并且在某些情况下比更复杂的算法具有性能优势。它几乎在所有情况下都比冒泡排序表现更好。

  • 最佳情况 - O(n^2)
  • 平均情况 - O(n^2)
  • 最坏情况 - O(n^2)
  • 稳定性 - 取决于实现
private void SelectionSort(long[] inputArray)
{
    long index_of_min = 0;
    for (int iterator = 0; iterator < inputArray.Length - 1; iterator++)
    {
        index_of_min = iterator;
        for (int index = iterator+1; index < inputArray.Length; index++)
        {
            if (inputArray[index] < inputArray[index_of_min])
                index_of_min = index;
        }
        Swap(ref inputArray[iterator], ref inputArray[index_of_min]);
    }
}

堆排序

这是一种基于比较的排序算法,属于选择排序家族。尽管在大多数机器上,其在实践中的速度比精心实现的快速排序要慢一些,但它具有更有利的 O(n log n) 的最坏情况运行时间优势。堆排序是一种原地算法,但它不是稳定的排序。堆排序的工作原理正如其名称所示。它首先将数据集构建成一个堆,然后提取最大项并将其放置在已排序数组的末尾。在移除最大项后,它会重建堆,移除剩余的最大项,并将其放置在已排序数组末尾的下一个可用位置。重复此过程,直到堆中没有剩余项且已排序数组已满。基本实现需要两个数组——一个用于存放堆,另一个用于存放已排序元素。

堆排序将输入列表的元素插入到堆数据结构中。直到没有剩余项为止,提取最大值(在最大堆中),提取的值按排序顺序排列。每次提取后,堆的不变量都会得到保持,因此唯一的成本就是提取的成本。

  • 最佳情况 - O(n log n)
  • 平均情况 - O(n log n)
  • 最坏情况 - O(n log n)
  • 稳定性 - 不稳定
private void HeapSort(long[] inputArray)
{
    for (int index = (inputArray.Length / 2) - 1; index >= 0; index--)
        Heapify(inputArray, index, inputArray.Length);
    for (int index = inputArray.Length - 1; index >= 1; index--)
    {
        SwapWithTemp(ref inputArray[0], ref inputArray[index]);
        Heapify(inputArray, 0, index - 1);
    }
}

此函数内部调用上面显示的 `Heapify()` 函数,该函数使用堆的特殊属性之一来构建数组内容的堆数据结构,该属性规定如果 B 是 A 的子节点,则 key(A) ≥ key(B)。这意味着具有最大键的元素始终是根节点(最大堆)。

private void Heapify(long[] inputArray, int root, int bottom)
{
    bool completed = false;
    int maxChild;

    while((root*2 <= bottom) && (!completed))
    {
        if (root * 2 == bottom)
            maxChild = root * 2;
        else if (inputArray[root * 2] > inputArray[root * 2 + 1])
            maxChild = root * 2;
        else
            maxChild = root * 2 + 1;
        if (inputArray[root] < inputArray[maxChild])
        {
            SwapWithTemp(ref inputArray[root], ref inputArray[maxChild]);
            root = maxChild;
        }
        else
        {
            completed = true;
        }
    }
}

插入排序

我们的下一类别是插入排序,我们将介绍两种算法:“插入排序”本身和“希尔排序”。我们来看一下。

插入排序

这是一种简单的排序算法,对于小型列表和大部分已排序的列表相对有效,并且通常用作更复杂算法的一部分。它通过逐个从列表中取出元素,并将它们插入到新的已排序列表中正确的位置来工作。希尔排序是插入排序的变体,对于大型列表更有效,因为在数组中,插入成本很高,需要将所有元素向右移动一位。

  • 最佳情况 - O(n)
  • 平均情况 - O(n^2)
  • 最坏情况 - O(n^2)
  • 稳定性 - 稳定
private void InsertionSort(long[] inputArray)
{
    long j = 0 ;
    long temp = 0 ;
    for (int index = 1; index < inputArray.Length; index++)
    {
        j = index;
        temp = inputArray[index];
        while ((j > 0) && (inputArray[j - 1] > temp))
        {
            inputArray[j] = inputArray[j - 1];
            j = j - 1;
        }
        inputArray[j] = temp;
    }
}

希尔排序

它通过一次将错位元素移动多个位置来改进冒泡排序和插入排序。它比较相隔若干位置的元素。这使得元素可以朝着其预期位置“迈出更大的步伐”。通过越来越小的间隔大小多次遍历数据。希尔排序的最后一步是简单的插入排序,但到那时,数据数组已保证几乎有序。

  • 最佳情况 - O(n)
  • 平均情况 - 取决于间隔序列
  • 最坏情况 - O(n^2) 或 O(nlog^2 n) 取决于间隔序列
  • 稳定性 - 不稳定
private void ShellSort(long[] inputArray)
{
    long j, temp = 0;
    int increment = (inputArray.Length) / 2;
    while (increment > 0)
    {
        for (int index = 0; index < inputArray.Length; index++)
        {
            j = index;
            temp = inputArray[index];
            while ((j >= increment) && inputArray[j - increment] > temp)
            {
                inputArray[j] = inputArray[j - increment];
                j = j - increment;
            }
            inputArray[j] = temp;
        }
        if (increment / 2 != 0)
            increment = increment / 2;
        else if (increment == 1)
            increment = 0;
        else
            increment = 1;
    }
}

归并排序

在此类别中,我只介绍了一种常见的排序,那就是归并排序。

归并排序

它利用了将已排序列表合并到新排序列表中的便利性。它首先比较每两个元素(例如,1 与 2,然后是 3 与 4...),如果第一个元素应该排在第二个之后,则交换它们。然后它将每对两个元素的列表合并成四个元素的列表,然后合并这些四个元素的列表,依此类推;直到最后将两个列表合并成最终的已排序列表。在大多数实现中,它是稳定的,这意味着它在已排序的输出中保持相等键元素的输入顺序。它是分治算法范例的一个例子。

  • 最佳情况 - O(n) 或 O(n log n)
  • 平均情况 - O(n log n)
  • 最坏情况 - O(n log n)
  • 稳定性 - 取决于实现(如果原地合并可以稳定,那么这个也将是稳定的)
private void MergeSort(long[] inputArray)
{
    int left = 0;
    int right = inputArray.Length - 1;
    InternalMergeSort(inputArray, left, right);
}

InternalMergeSort 是一个递归函数,它递归地排序左侧和右侧的内容,其代码非常直观,如下所示。

private void InternalMergeSort(long[] inputArray, int left, int right)
{
    int mid = 0;
    if (left < right)
    {
        mid = (left + right) / 2;
        InternalMergeSort(inputArray, left, mid);
        InternalMergeSort(inputArray,(mid + 1), right);
        MergeSortedArray(inputArray, left, mid, right);
    }
}

归并排序的最后一步是将两个已排序的数组合并(在上一步中)。MergeSortedArray 函数每次都执行完全相同的操作,并且是递归的。

private void MergeSortedArray(long[] inputArray, int left, int mid, int right)
{
    int index = 0;
    int total_elements = right-left+1; //BODMAS rule
    int right_start = mid + 1;
    int temp_location = left;
    long[] tempArray = new long[total_elements];

    while ((left <= mid) && right_start <= right)
    {
        if (inputArray[left] <= inputArray[right_start])
        {
            tempArray[index++] = inputArray[left++];
        }
        else
        {
            tempArray[index++] = inputArray[right_start++];
        }
    }
    if (left > mid)
    {
        for(int j = right_start; j <= right; j++)
            tempArray[index++] = inputArray[right_start++];
    }
    else
    {
        for(int j = left; j <= mid; j++)
            tempArray[index++] = inputArray[left++];
    }
    //Array.Copy(tempArray, 0, inputArray, temp_location, total_elements);
    // just another way of accomplishing things (in-built copy)
    for (int i = 0, j = temp_location; i < total_elements; i++, j++)
    {
        inputArray[j] = tempArray[i];
    }
}

排序算法的稳定性

如果排序算法保持具有相等键的记录的相对顺序,则该排序算法是稳定的。也就是说,如果存在相等的键(假设键是 K),并且存在与该相同键关联的两个不同记录(K->A)和(K->B),并且在原始未排序列表中,A 出现在 B 之前,那么在应用排序算法后,如果它们的顺序得到保留,即 A 仅出现在 B 之前,则该算法是稳定的。如果所有键都不同,则不需要区分。此外,在整数等无法区分的类型,或整个元素都是键的情况下,稳定性不适用。

例如,假设我们有集合 {6,8}, {2,4}, {6,5}, {4,7},并且我们想按第一个分量进行排序,那么可以得到两种不同的结果,如下所示:

  1. {2,4}, {4,7}, {6,8}, {6,5} 和
  2. {2,4}, {4,7}, {6,5}, {6,8}

在这种情况下,a 指的是排序算法的稳定性,其中保留了相等键元素的原始顺序,而 b 指的是不稳定的算法,其中顺序“可能”会改变。

然而,请注意,不稳定的排序算法可以特别实现为稳定的。一种方法是人为地扩展键比较,以便对具有相同键的两个对象之间的比较,使用原始数据顺序中的条目顺序作为平局的决定因素。然而,记住这个顺序通常需要额外的计算成本。

在上述排序算法中,冒泡排序、插入排序和归并排序是稳定的,而堆排序和希尔排序是不稳定的。对于选择排序、归并排序和快速排序,这些算法的稳定性通常取决于它们的实现方式。

结论

附带的代码已简化,包含一个硬编码的数据集和多个打印语句,用于遍历每个传递和每次迭代。通过本文,我只触及了非常常见的基于比较的排序,但请放心,一旦发布完成,我将深入介绍基数排序和桶排序。

© . All rights reserved.