为什么在归并排序中阈值交叉后应该使用插入排序?

6
我已经在很多地方看到过这种分治排序算法,例如Merge-SortQuicksort,在只剩一个元素时进行递归的方法。但是当达到一定阈值(比如30个元素)时,转移到Insertion-Sort会更好。这个想法没问题,但为什么只使用Insertion-Sort呢?为什么不能使用Bubble-Sort或者Selection-Sort呢?它们两者的性能都是O(N^2)Insertion-Sort只有在许多元素被预先排序的情况下才有效(虽然这个优势也同样适用于Bubble-Sort),但除此之外,为什么它比另外两种方法更高效呢?
其次,在这个链接中,第二个答案和评论说,在某些N值上,O(N log N)O(N^2)表现差,这怎么解释呢?因为对于所有的N>=2,N > log N,所以N log N应该始终比N^2表现更好,对吗?

4
插入排序被认为是对于少量元素而言较快的算法,原因在于它具有良好的缓存效率,如果我没记错的话。 - amit
5
请注意,大O符号给出的是函数渐近行为的信息。并不是说一个O(n^2)算法总是比O(n log n)算法性能差。例如,如果f(x)=x^2,而g(x)=9999999n log n,那么对于小的n,具有复杂度f(x)的算法将比具有复杂度g(x)的算法更快。渐近符号只保证**存在一个数字n,使得对于所有的m > n,我们都有f(m) > g(m)**。 - Haile
1
在使用大O符号时要注意隐藏常数,比较f(n)=10^-6.n^2(属于O(n^2))和g(n)=10^10^10.n.log(n)(属于O(n log n))。 - Kwariz
抱歉有些偏离主题,但该线程是缓存失败的一个典型例子,可以很容易地进行修改。 - amit
2
@Cupidvogel:经过仔细思考后,我认为问题并不在缓存上。现代计算机拥有约32KB的缓存,而30个元素通常占用的空间远远小于它。因此,对于几乎任何30个元素的排序算法,所有元素都预计会从内存中读取到缓存中,并在整个排序过程中保留在那里(在排序这30个元素时不需要将任何元素抛出)。 - amit
显示剩余8条评论
6个回答

12
如果在快速排序的分治过程中,当它达到阈值时退出每个分支,则您的数据将如下所示:
[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 选择插入排序是有原因的:他不是提供一般性建议,而是为实际使用优化库。


你可以选择在循环结束时调用它,而不是在循环中调用它。我不明白。两者都需要使用嵌套循环进行排序,对吧? - SexyBeast
+1,这从未发生在我身上,我也从未见过这种实现方式,但显然是正确的。您知道有哪些 std::sort 实现采用了这种方法吗? - ltjax
2
选项1:如果(size_left_to_do < 30){在您的循环中调用插入排序(data_to_do); 继续;}。插入排序是“在您的循环中”调用的。选项2:如果(size_left_to_do <30),请继续;在循环中不调用插入排序,而是在最后调用insertion_sort(the_original_array)。 - Steve Jessop
不错的属性。但对于大型数据集,我怀疑单独为每个包含30个左右元素的块调用插入排序实际上会更快,因为这些元素已经在缓存中,而如果您在最后执行一个大的插入排序,则情况并非如此。 - j_random_hacker
1
@j_random_hacker:确实,我编辑了一下,想说同样的话,但我猜邮件中的信息交叉了。快速排序可能早于内存缓存的例行使用,并且肯定早于当前缓存是最大的性能问题的时代(好吧,在当前时代,它可能是两个最大的性能问题之一,另一个是向量化,但快速排序也早于当前时代之前的时代)。这些建议可能与快速排序的年代相似 :-) - Steve Jessop

8
  1. 在实践中,插入排序比冒泡排序要快。它们的渐进运行时间相同,但插入排序具有更好的常数(每次迭代较少/便宜的操作)。最值得注意的是,它仅需要线性数量的交换对元素进行排序,并且在每个内部循环中,它执行 n/2 个元素和一个可以存储在寄存器中的“固定”元素之间的比较(而冒泡排序必须从内存中读取值)。即插入排序在其内部循环中所做的工作比冒泡排序要少。
  2. 答案声称,对于“合理”的 n 值,10000 n lg n > 10 n² 是正确的。这在大约14000个元素之前是正确的。

2
需要对1进行引用或证明。 - IVlad
@IVlad:例如,http://www.algorithmist.com/index.php/Insertion_sort -- 在插入排序中执行的交换次数,即对内存的读写次数,是线性的,但在冒泡排序中是二次的。这些可能是这些算法中最昂贵的操作,因为大多数其他操作都可以在寄存器中完成。 - Fred Foo
3
首先,交换和写入不是同一件事:插入排序具有线性交换,但二次写入。其次,选择排序实际上是具有最少写入的排序方式。因此,这并不是一个很好的解释。 - IVlad
@IVlad:嗯,是的,我忽略了那个。似乎插入排序中的比较更快,因为其中一个操作数可以被缓存在寄存器中。 - Fred Foo

5

我很惊讶没有人提到插入排序在“几乎”排序好的数据上速度更快这一简单事实。这就是为什么它被使用的原因。


4

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

我运行了附加的代码,并发现冒泡排序平均花费6338.515纳秒,而插入排序只需要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);
    }
}

1
哦,拜托,优化一下那个冒泡排序吧,就算维基百科都已经优化了它 :)。此外,我认为选择排序会更有趣。 - IVlad
冒泡排序中有什么可以优化的吗? - SexyBeast
还要优化插入排序...在内部循环中不需要调用swap。@Cupidvogel - 参见其维基条目。 - IVlad
好的,冒泡排序的第一次遍历将最大元素放在最后一个位置,第二次遍历将第二大元素放在倒数第二个位置。所以,在第n次遍历之后,第n大的元素已经被放置,自然而然内部循环不应该覆盖1到n,而是应该覆盖n-i。这是标准做法,对吧? - SexyBeast
@Cupidvogel - 如果在内部循环迭代中没有执行交换,则可以提前结束算法。这对于随机测试数据非常有帮助。 - IVlad
显示剩余4条评论

4
首先,为什么选择插入排序而不是选择排序?因为对于最优输入序列(即已经排序的序列),插入排序的时间复杂度为O(n),而选择排序的时间复杂度始终为O(n^2)
其次,为什么选择插入排序而不是冒泡排序?两者都只需要一次遍历就可以完成已排序的输入序列,但插入排序的性能更好。具体而言,当存在少量逆序对时,插入排序通常比冒泡排序表现更好。来源 这是因为冒泡排序总是在第i轮迭代中遍历N-i个元素,而插入排序更像是“查找”,平均只需要遍历(N-i)/2个元素(在第N-i-1轮)就可以找到插入位置。因此,插入排序的平均速度预计比冒泡排序快两倍。

对于选择排序总是需要O(n^2)时间的观察,我给予+1。但第二段并不令人满意:维基百科页面只是重复了你所说的,没有真正解释为什么。 - j_random_hacker

2
编辑:正如IVlad在评论中指出的那样,对于任何数据集来说,选择排序只进行n次交换(因此只进行3n次写入),因此插入排序不太可能因为进行较少的交换而超越它 - 但是它可能会进行更少的比较。下面的推理更适用于与冒泡排序的比较,后者将进行类似数量的比较,但平均交换(因此许多写入)更多。

插入排序比其他O(n^2)算法(如冒泡排序和选择排序)更快的一个原因是:在后一类算法中,每个数据移动都需要进行一次交换,如果交换的另一端稍后也需要再次交换,则可能需要进行多达3倍的内存复制。

相反,对于插入排序,如果要插入的下一个元素不是已经是最大的元素,则可以将其保存到临时位置,并通过从右侧开始使用单个数据副本(即不进行交换)将所有较低的元素向前推动。这就打开了一个空隙来放置原始元素。

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;
    }
}

在插入排序中,每个元素都被其前面的元素替换,直到达到一个比要定位的值小的值。这样确实需要交换,对吧? - SexyBeast
1
关于交换操作本身并没有什么特别慢的地方,交换只是三次写操作而已。插入排序平均而言比选择排序有更多的写操作,选择排序的交换次数为O(n),所以我真的不认为这足够有说服力。插入排序在读取/比较的次数上胜出,因此我认为必须提出一个有说服力的论点来支持这一观点。 - IVlad
额,插入排序还有不止一种变体吗? - SexyBeast
@IVlad:关于读取次数的观点很好。但是冒泡排序和插入排序的读取次数不是差不多吗?因此,在IS与BS比较的情况下,IS会更快,因为它具有较少的交换(以及类似数量的读取),而在IS与SS比较的情况下,IS会更快,因为它平均具有比SS更少的读取/比较次数,即使它平均执行更多的写操作也必须胜出。 - j_random_hacker
是的,我怀疑可能是这样,也许 IS 更适合缓存,因为某些原因。此外,平均而言,IS 中的内部循环并不完全执行,而选择排序总是恰好 n(n-1)/2 次迭代。冒泡排序在实践中可以减少迭代次数,这可能使其与 IS 相当,尽管我认为 IS 写入更少,因此获胜。 - IVlad
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接