归并排序中为啥要在阈值交叉后使用插入排序

Posted

技术标签:

【中文标题】归并排序中为啥要在阈值交叉后使用插入排序【英文标题】:Why should Insertion Sort be used after threshold crossover in Merge Sort归并排序中为什么要在阈值交叉后使用插入排序 【发布时间】:2012-09-19 06:38:04 【问题描述】:

我到处读到,对于像 Merge-SortQuicksort 这样的分而治之的排序算法,与其递归直到只剩下一个元素,不如在某个阈值时转移到 Insertion-Sort,比如 30元素,达到。这很好,但为什么只有Insertion-Sort?为什么不是Bubble-SortSelection-Sort,两者都具有相似的O(N^2) 性能? Insertion-Sort 应该只有在许多元素被预排序时才会派上用场(尽管 Bubble-Sort 也应该有这种优势),但除此之外,为什么它应该比其他两个更有效?

其次,在this link,在第二个答案及其随附的 cmets 中,它说 O(N log N)O(N^2) 相比表现不佳,直到某个 N。怎么会? N^2 的性能应该总是比 N log N 差,因为 N > log N 对于所有 N >= 2,对吧?

【问题讨论】:

插入排序被认为是针对少数元素的快速算法,如果我没记错的话,原因是缓存效率。 你来了!每当我提出问题而你给出答案时,不知何故cache 总是潜入! :) 是的,我听说过类似的事情,但没有地方解释如何。你能解释一下为什么它的缓存效率很高吗? 另外,请注意大 O 表示法提供了有关函数渐近行为的信息。 O(n^2) 算法总是 的性能比 O(n log n) 算法差是不正确的。例如,如果f(x) = x^2g(x) = 9999999n log n,那么对于较小的n,复杂度为 f(x) 的算法将比复杂度为 g(x) 的算法快。渐近符号只保证存在一个数 n 使得对于所有 m > n 我们有 f(m) > g(m) 在使用 big-oh 表示法时注意隐藏常量,比较 O(n^2) 和 g(n)= 中的 f(n)=10^-6.n^2 10^10^10.n.log(n) 在 O(n log n) 中 @Cupidvogel:仔细思考后:我相信问题不是缓存。现代机器有大约 32KB 的缓存,而 30 个元素占用的缓存通常要少得多。因此 - 对于几乎任何 30 个元素的排序算法 - 所有这些都预计会从内存中读取一次并在整个排序期间停留在那里(在对这 30 个元素进行排序时,预计不会抛出任何元素)。 【参考方案1】:

如果您在分治法快速排序的每个分支达到阈值时退出,您的数据将如下所示:

[the least 30-ish elements, not in order] [the next 30-ish ] ... [last 30-ish]

插入排序有一个相当令人愉悦的属性,您可以在整个数组上只调用一次,它的执行基本上与对每个 30 块调用一次时相同。因此,不要在循环中调用它,您可以选择最后调用它。这可能不会更快,尤其是因为它通过缓存提取整个数据需要额外的时间,但取决于代码的结构,它可能会很方便。

冒泡排序和选择排序都没有这个属性,所以我认为答案可能很简单,就是“方便”。如果有人怀疑选择排序可能更好,那么举证责任就在于他们“证明”它更快。

请注意,这种插入排序的使用也有一个缺点——如果你这样做并且在你的分区代码中有一个错误,那么只要它不会丢失任何元素,只是不正确地分区它们,你会 永远不会注意到

编辑:显然这个修改是由 Sedgewick 完成的,他于 1975 年在 QuickSort 上写了博士学位。Musser(Introsort 的发明者)最近对其进行了分析。参考https://en.wikipedia.org/wiki/Introsort

Musser 还考虑了 Sedgewick 延迟缓存对缓存的影响 小排序,其中小范围在最后一次排序 通过插入排序。他报告说,它可以使人数翻倍 缓存未命中,但其双端队列的性能是 明显更好,应该保留在模板库中,在 部分是因为在其他情况下立即进行排序会有所收获 不是很好。

无论如何,我不认为一般建议是“无论您做什么,都不要使用选择排序”。建议是,“插入排序优于快速排序,因为输入的大小非常小”,当你实现快速排序时,这很容易证明给你自己。如果您想出另一种在相同的小数组上明显优于插入排序的排序,那么这些学术资料都不会告诉您不要使用它。我想令人惊讶的是,建议始终针对插入排序,而不是每个来源都选择自己喜欢的来源(坦率地说,入门教师对冒泡排序有一种惊人的喜爱——如果我从来没有再听一遍)。插入排序通常被认为是小数据的“正确答案”。问题不在于它是否“应该”快,而在于它是否真的快,而且我从来没有特别注意到有什么基准可以打消这个想法。

在 Timsort 的开发和采用中可以查找此类数据。我很确定 Tim Peters 选择插入是有原因的:他不是在提供一般性建议,而是在优化库以供实际使用。

【讨论】:

您可以选择最后调用它,而不是在循环中调用它。我不明白。两者都使用嵌套循环进行排序,不是吗? +1,这从来没有发生在我身上,我也从未见过这个实现——但这显然是真的。您是否知道任何这样做的 std::sort 实现? 选项 1:if (size_left_to do < 30) insertion_sort(data_to_do); continue; 。插入排序称为“在您的循环中”。选项 2:if (size_left_to_do < 30) continue;。循环中不调用插入排序,而是在最后调用insertion_sort(the_original_array) 整洁的属性。但是对于大型数据集,我怀疑为每个 30-ish 元素块单独调用插入排序实际上更快,因为这些元素已经在缓存中,如果你在最后执行一个大插入排序,情况就不会如此. @j_random_hacker:确实,我编辑说同样的话,但我猜邮件中的消息是交叉的。快速排序可能完全早于内存缓存的常规使用,并且肯定早于缓存是大性能问题的当前时代(好吧,也许在当前时代它是 两个,另一个是矢量化,但 Quicksort 也早于当前时代之前的时代)。该建议可能与 Quicksort 类似:-)【参考方案2】:
    在实践中插入排序至少比冒泡排序更快。它们的渐近运行时间是相同的,但插入排序具有更好的常数(每次迭代的操作更少/更便宜)。最值得注意的是,它只需要线性数量的元素对交换,并且在每个内部循环中,它执行每个 n/2 个元素和可以存储在一个“固定”元素之间的比较。寄存器(而冒泡排序必须从内存中读取值)。 IE。插入排序在其内部循环中的工作量比冒泡排序少。 答案声称“合理”n 为 10000 n lg n > 10 n²。多达 14000 个元素也是如此。

【讨论】:

1. 需要引用或证明。 @IVlad: 例如,algorithmist.com/index.php/Insertion_sort -- 执行的交换次数,即对内存的读/写次数,在插入排序中是线性的,但在冒泡排序中是二次的。这些可能是这些算法中最昂贵的操作,因为其余大部分操作都可以在寄存器中完成。 首先,交换和写入不是一回事:插入排序有线性交换,但是二次写入。其次,选择排序实际上是写入最少的排序。所以根本不是一个好的解释。 @IVlad:嗯,是的,忽略了这一点。插入排序中的比较似乎更快,因为其中一个操作数可以缓存在寄存器中。【参考方案3】:

我很惊讶没有人提到插入排序对于“几乎”排序的数据要快得多这一简单事实。这就是它被使用的原因。

【讨论】:

【参考方案4】:

第一个更容易:为什么插入排序优于选择排序?因为对于最佳输入序列,插入排序在 O(n) 中,即如果序列已经排序。选择排序总是在 O(n^2) 中。

为什么插入排序优于冒泡排序?对于已经排序的输入序列,两者都只需要一次通过,但插入排序会更好地降级。更具体地说,插入排序在反转次数较少的情况下通常比冒泡排序表现更好。 Source 这可以解释,因为冒泡排序总是在第 i 步中迭代 Ni 元素,而插入排序更像“查找”,并且只需要平均迭代 (Ni)/2 个元素(在第 Ni-1 步中)即可找到插入位置。因此,平均而言,插入排序预计比插入排序快两倍左右。

【讨论】:

+1 用于观察选择排序总是需要 O(n^2) 时间。但第 2 段令人不满意:***页面只是重复你所说的,没有真正解释原因。【参考方案5】:

这是一个经验证明,插入排序比冒泡排序更快(对于 30 个元素,在我的机器上,附加实现,使用 java...)。

我运行了附加的代码,发现冒泡排序平均运行了 6338.515 ns,而插入花费了 3601.0

我使用wilcoxon signed test 来检查这是错误的概率,它们实际上应该是相同的 - 但结果低于数值错误的范围(实际上是 P_VALUE ~= 0)

private static void swap(int[] arr, int i, int j)  
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;


public static void insertionSort(int[] arr)  
    for (int i = 1; i < arr.length; i++) 
        int j = i;
        while (j > 0 && arr[j-1] > arr[j])  
            swap(arr, j, j-1);
            j--;
        
    

public static void bubbleSort(int[] arr)  
    for (int i = 0 ; i < arr.length; i++)  
        boolean bool = false;
        for (int j = 0; j < arr.length - i ; j++)  
            if (j + 1 < arr.length && arr[j] > arr[j+1]) 
                bool = true;
                swap(arr,j,j+1);
            
        
        if (!bool) break;
    


public static void main(String... args) throws Exception 
    Random r = new Random(1);
    int SIZE = 30;
    int N = 1000;
    int[] arr = new int[SIZE];
    int[] millisBubble = new int[N];
    int[] millisInsertion = new int[N];
    System.out.println("start");
    //warm up:
    for (int t = 0; t < 100; t++)  
        insertionSort(arr);
    
    for (int t = 0; t < N; t++)  
        arr = generateRandom(r, SIZE);
        int[] tempArr = Arrays.copyOf(arr, arr.length);

        long start = System.nanoTime();
        insertionSort(tempArr);
        millisInsertion[t] = (int)(System.nanoTime()-start);

        tempArr = Arrays.copyOf(arr, arr.length);

        start = System.nanoTime();
        bubbleSort(tempArr);
        millisBubble[t] = (int)(System.nanoTime()-start);
    
    int sum1 = 0;
    for (int x : millisBubble) 
        System.out.println(x);
        sum1 += x;
    
    System.out.println("end of bubble. AVG = " + ((double)sum1)/millisBubble.length);
    int sum2 = 0;
    for (int x : millisInsertion) 
        System.out.println(x);
        sum2 += x;
    
    System.out.println("end of insertion. AVG = " + ((double)sum2)/millisInsertion.length);
    System.out.println("bubble took " + ((double)sum1)/millisBubble.length + " while insertion took " + ((double)sum2)/millisBubble.length);


private static int[] generateRandom(Random r, int size) 
    int[] arr = new int[size];
    for (int i = 0 ; i < size; i++) 
        arr[i] = r.nextInt(size);
    return arr;


编辑: (1) 优化冒泡排序(已在上面更新)将冒泡排序的总时间减少到:6043.806 不足以进行重大更改。 Wilcoxon 检验仍然是结论性的:插入排序更快。

(2) 我还添加了一个选择排序测试(附加代码)并将其与插入进行比较。结果是:选择花费了 4748.35,而插入花费了 3540.114。 wilcoxon 的 P_VALUE 仍低于数值误差范围(实际上是 ~=0)

使用的选择排序代码:

public static void selectionSort(int[] arr) 
    for (int i = 0; i < arr.length ; i++)  
        int min = arr[i];
        int minElm = i;
        for (int j = i+1; j < arr.length ; j++)  
            if (arr[j] < min)  
                min = arr[j];
                minElm = j;
            
        
        swap(arr,i,minElm);
    

【讨论】:

哦,来吧,优化冒泡排序,甚至***都对其进行了优化:)。此外,选择排序将是我认为的有趣排序。 冒泡排序有什么需要优化的地方? 并优化插入排序...你不需要在内循环中调用交换。 @Cupidvogel - 查看它的 wiki 条目。 嗯,冒泡排序的第一遍将最高元素放在最后一个位置,第二遍将第二高元素放在倒数第二个位置。所以在第n遍之后,当第n个最大的元素已经放好后,自然内循环不应该覆盖1到n,而是n-i。这是标准做法,对吧? @Cupidvogel - 如果在内部循环迭代中没有执行交换,您也可以提前结束算法。这对随机测试数据有很大帮助。【参考方案6】:

编辑: 正如 IVlad 在评论中指出的那样,选择排序对任何数据集只进行 n 次交换(因此只有 3n 次写入),因此插入排序不太可能因为这样做而击败它更少的交换——但它可能会进行更少的比较。下面的推理更适合与冒泡排序进行比较,冒泡排序的比较次数相似,但平均交换次数更多(因此写入次数更多)。


插入排序往往比冒泡排序和选择排序等其他 O(n^2) 算法更快的一个原因是,在后一种算法中,每一次数据移动都需要交换,如果交换的另一端需要稍后再次交换,则内存副本最多可以达到所需内存副本的 3 倍。

使用插入排序OTOH,如果要插入的下一个元素还不是最大元素,则可以将其保存到一个临时位置,并通过从右侧开始并使用单个数据副本将所有较低的元素向前分流(即没有掉期)。这为放置原始元素打开了一个间隙。

不使用交换对整数进行插入排序的 C 代码:

void insertion_sort(int *v, int n) 
    int i = 1;
    while (i < n) 
        int temp = v[i];         // Save the current element here
        int j = i;

        // Shunt everything forwards
        while (j > 0 && v[j - 1] > temp) 
            v[j] = v[j - 1];     // Look ma, no swaps!  :)
            --j;
        

        v[j] = temp;
        ++i;
    

【讨论】:

在插入排序中,每个元素都被它前面的元素替换,直到达到一个小于要定位的值的值。所以这确实需要交换,对吧? @Cupidvogel:这是实现它的一种方式,但不是最快的方式。我稍后会发布一个 C sn-p。 swap 本身并不慢,swap 只是三个写入。平均而言,插入排序的写入次数比选择排序要多,后者有O(n) 交换,所以我真的不认为这有足够的说服力。插入排序在读取/比较次数上胜出,因此我认为必须朝这个方向提出令人信服的论据。 嗯,插入排序的变体不止一种吗? int temp = v[i]; ... v[j] = temp; 虽然技术上不是交换,因为您不交换两个原始值,但复杂性是相同的。抱歉,这是一个不好的论点:从技术上讲,您可能没有任何交换,但您的写入次数比选择排序多。选择排序中唯一的写入来自外部循环的每次迭代中的单个交换。您在内部循环中有 + 写入。

以上是关于归并排序中为啥要在阈值交叉后使用插入排序的主要内容,如果未能解决你的问题,请参考以下文章

js实现,归并排序,快速排序;插入排序,选择排序,冒泡排序

链表排序(冒泡选择插入快排归并希尔堆排序)

算法稳定性

算法导论之插入排序和归并排序

算法之-归并排序算法,插入排序算法

常见排序算法的实现(归并排序快速排序堆排序选择排序插入排序希尔排序)