1. 前言
插入排序,一般也被称为直接插入排序。对于少量元素的排序,它是一个有效的算法。插入排序是一种最简单的排序方法,它的基本思想是将一个记录插入到已经排好序的有序表中,从而一个新的、记录数增1的有序表。在其实现过程使用双层循环,外层循环对除了第一个元素之外的所有元素,内层循环对当前元素前面有序表进行待插入位置查找,并进行移动。
在本文中,你将重点了解:
- 描述插入排序的工作原理。
- 展示了 Java 中的实现。
- 解释如何导出时间复杂度。
- 并检查 Java 实现的性能是否与预期的运行时行为相匹配。
2. 示例:对扑克牌进行排序
让我们从一张扑克牌的例子开始。
想象一下,你一次拿到一张牌。你拿第一张牌。然后把第二张牌放在它的左边或右边。第三张牌根据其大小放在左边、中间或右边。此外,所有后面的牌都放在正确的位置。
您以前曾用这种方法对卡片进行分类吗?
如果是这样,那么您就直观地使用了“插入排序”。
3. 插入排序算法原理
让我们从卡片示例转到计算机算法。假设我们有一个包含元素 [6, 2, 4, 9, 3, 7] 的数组。此数组应按插入排序按升序排序。
3.1 第1步
首先,我们将数组分为左侧已排序部分和右侧未排序部分。已排序部分在开头已经包含第一个元素,因为只有一个元素的数组始终可以视为已排序。
3.2 第 2 步
然后,我们查看未排序区域的第一个元素,并通过将其与左邻居进行比较来检查需要将其插入到排序区域中的哪个位置。
在示例中,2 小于 6,因此它属于 6 的左侧。为了腾出空间,我们将 6 向右移动一位,然后将 2 放在空白区域上。然后,我们将已排序区域和未排序区域之间的边界向右移动一步:
3.3 第3步
我们再次查看未排序区域的第一个元素 4。它小于 6,但不小于 2,因此位于 2 和 6 之间。所以我们再次将 6 向右移动一个位置,并将 4 放在空白处:

3.4 第4步

3.5 第5步
下一个元素是 3,它小于 9、6 和 4,但大于 2。因此,我们将 9、6 和 4 向右移动一位,然后将 3 放在之前 4 的位置:
3.6 第6步
剩下的是 7 - 它小于 9,但大于 6,因此我们将 9 向右移动一个字段,并将 7 放在空置位置上:
数组现在已完全排序。
4. 插入排序 Java 源代码
以下 Java 源代码展示了实现插入排序是多么简单。
外循环从第二个元素开始迭代,因为第一个元素已经排序,遍历要排序的元素。因此,循环变量 i 始终指向右侧未排序部分的第一个元素。
在内层 while 循环中,将插入位置的查找和元素的移动结合起来:
- 循环条件查找:直到查找位置j左边的元素小于待排序元素,
- 并在循环体中移动已排序的元素。
public class InsertionSort {
public static void sort(int[] elements) {
for (int i = 1; i < elements.length; i++) {
int elementToSort = elements[i];
// Move element to the left until it's at the right position
int j = i;
while (j > 0 && elementToSort < elements[j - 1]) {
elements[j] = elements[j - 1];
j--;
}
elements[j] = elementToSort;
}
}
}
它允许指定起始和结束索引,以便子数组也可以排序。这将使我们能够通过对小于特定大小的子数组使用插入排序而不是进一步划分它们来优化快速排序。
5. 插入排序时间复杂度
我们用n表示要排序的元素的数量;在上面的例子中,n = 6。
两个嵌套循环表明我们正在处理二次工作量,即时间复杂度为O(n²) *。如果外循环和内循环的计数值都随元素数量线性增加,则情况就是如此。
对于外循环,这一点很明显,因为它计数到n。
那么内循环呢?我们将在接下来的三节中对其进行分析。
*在本文中,我使用示例和图表解释“时间复杂度”和“大 O 符号”这两个术语。
5.1 平均时间复杂度
让我们再看一下上面的例子,其中我们对数组 [6, 2, 4, 9, 3, 7] 进行了排序。
在示例的第一步中,我们将第一个元素定义为已排序;在源代码中,它被简单地跳过。
在第二步中,我们从排序后的数组中移动了一个元素。如果要排序的元素已经在正确的位置,我们就不需要移动任何东西了。这意味着我们在第二步中平均有 0.5 次移动操作。
在第三步中,我们也移动了一个元素。但这里也可能没有移动或移动了两个。平均而言,这一步移动了一次。
在第四步中,我们不需要移动任何元素。但是,可能需要移动一个、两个或三个元素;此处的平均值是 1.5。
在第五步中,我们平均有两次移位操作:
第六步,2.5:
因此总的来说我们平均有 0.5 + 1 + 1.5 + 2 + 2.5 = 7.5 次移位操作。
因此总的来说我们平均有 0.5 + 1 + 1.5 + 2 + 2.5 = 7.5 次移位操作。
我们也可以按如下方式计算:
六个元素乘以五次移位操作;除以二,因为在所有步骤中平均而言,一半的卡片已经排序;再次除以二,因为平均而言,要排序的元素必须移动到已排序元素的中间:
6 × 5 × 1/2 × 1/2 = 30 × 1/4 = 7.5
下图再次显示了所有步骤:
如果我们用n代替6,我们得到
n × (n - 1) × ¼
相乘后即为:
¼ n²-¼ n
该项中n的最高幂为n²;因此,移位的时间复杂度为O(n²)。这也称为“二次时间”。
到目前为止,我们只研究了如何移动已排序的元素 —— 但是如何比较元素并将要排序的元素放置在空闲的字段上呢?
对于比较操作,我们比移位操作多一个(如果将元素移到最左边,则数量相同)。因此,比较操作的时间复杂度也是O(n²)。
待排序元素必须放置在正确位置的次数等于元素总数减去已经处于正确位置的元素总数——因此最多n-1次。由于这里没有n²,只有n,我们称之为“线性时间”,记为O(n)。
在考虑整体复杂性时,只有最高级别的复杂性才算数。因此如下:
插入排序的平均时间复杂度为:O(n²)
有平均情况,就有最坏情况和最好情况。
5.2 最坏情况的时间复杂度
在最坏的情况下,元素一开始就完全按降序排序。因此,在每一步中,已排序子数组的所有元素都必须向右移动,以便将待排序元素(小于每一步中已排序的所有元素)放在最开始。
在下图中,箭头始终指向最左边,证明了这一点:
因此,平均情况中的项发生了变化,因为省略了第二个除以二的步骤:
6 × 5 × ½
或者:
n × (n - 1) × ½
当我们将其乘以时,我们得到:
½ n²-½ n
即使我们的操作数只有平均情况的一半,时间复杂度方面也不会发生任何变化——该项仍然包含n²,因此:
插入排序的最坏时间复杂度为:O(n²)
5.3 最佳时间复杂度
最好的案例变得有趣!
如果元素已经按排序顺序出现,则内层循环中只会进行 一次比较,并且根本不需要交换操作。
有了n 个元素,也就是n-1步(因为我们从第二个元素开始),因此我们进行n-1 次比较操作。因此:
插入排序的最佳时间复杂度为:O(n)
5.4 插入排序和二分查找?
我们能否通过使用二分搜索来加快算法的插入点速度?这比顺序搜索快得多——它的时间复杂度为O(log n)。
是的,我们可以。但是,我们不会从中获得任何好处,因为我们仍然必须将每个元素从插入位置向右移动一个位置,这在数组中只能一步一步地实现。因此,尽管进行了二分搜索,但内循环仍将保持线性复杂度。整个算法将保持二次复杂度,即O(n²)。
5.5 使用链接列表进行插入排序?
如果元素在链表中,我们能否在常数时间内插入一个元素,O(1)?
是的,我们可以。但是,链表不允许二分搜索。这意味着我们仍然必须在内循环中遍历所有已排序的元素来找到插入位置。这反过来会导致内循环的复杂度为线性,整个算法的复杂度为二次。
5.6 Java 插入排序示例的运行时
了解了所有这些理论之后,是时候根据上面介绍的 Java 实现来检查它了。
如下所示:
- 对于不同的数组大小,从 1,024 开始,然后在每次迭代中翻倍,直到 536,870,912(尝试创建一个包含 1,073,741,824 个元素的数组会导致“本机内存分配”错误) - 或者直到测试花费超过 20 秒;
- 包含未排序、升序和降序排序元素;
- 进行两轮预热,让 HotSpot 编译器优化代码;
- 然后重复,直到程序中止。
我们将用以下代码做测试:
public class UltimateTest {
static final SortAlgorithm[] ALGORITHMS = {
new InsertionSort(),
new SelectionSort(),
new BubbleSortOpt1(),
new CountingSort(),
new JavaArraysSort(),
// Quicksort
new QuicksortVariant1(PivotStrategy.RIGHT),
new QuicksortVariant1(PivotStrategy.MIDDLE),
new QuicksortVariant1(PivotStrategy.MEDIAN3),
new QuicksortImproved(48, new QuicksortVariant1(PivotStrategy.MIDDLE)),
new DualPivotQuicksort(DualPivotQuicksort.PivotStrategy.THIRDS),
new DualPivotQuicksortImproved(64, DualPivotQuicksort.PivotStrategy.THIRDS),
new MergeSort(),
// Heapsort
new Heapsort(),
new BottomUpHeapsort(),
new HeapsortSlowComparisons(),
new BottomUpHeapsortSlowComparisons(),
// Radix Sort
new RadixSortWithDynamicListsAndCustomBase(10),
new RadixSortWithDynamicListsAndCustomBase(100),
new RadixSortWithArraysAndCustomBase(10),
new RadixSortWithArraysAndCustomBase(100),
new RadixSortWithCountingSortAndCustomBase(10),
new RadixSortWithCountingSortAndCustomBase(100),
new RecursiveMsdRadixSortWithArraysAndCustomBase(10),
new RecursiveMsdRadixSortWithArraysAndCustomBase(100),
new ParallelRadixSortWithArrays(),
new ParallelRecursiveMsdRadixSortWithArrays()
};
private static final int WARM_UPS = 2;
private static final int MIN_SORTING_SIZE = 1 << 10;
private static final int MAX_SORTING_SIZE = 1 << 29;
// Stop when sorting takes longer than 20 seconds
private static final int MAX_SORTING_TIME_SECS = 20;
private static final boolean TEST_SORTED_INPUT = true;
@SuppressWarnings("PMD.UseConcurrentHashMap") // Not accessed concurrently
private final Map<String, Scorecard> scorecards = new HashMap<>();
public static void main(String[] args) {
new UltimateTest().run();
}
private void run() {
for (int i = 1; i <= WARM_UPS; i++) {
System.out.printf("%n===== Warm up %d of %d =====%n", i, WARM_UPS);
for (SortAlgorithm algorithm : ALGORITHMS) {
test(algorithm, true);
}
}
for (int i = 1; ; i++) {
System.out.printf("%n===== Iteration %d =====%n", i);
for (SortAlgorithm algorithm : ALGORITHMS) {
test(algorithm, false);
}
System.out.printf("%n===== Results for iteration %d =====%n", i);
for (SortAlgorithm algorithm : ALGORITHMS) {
printResults(i, algorithm, "random");
if (TEST_SORTED_INPUT) {
printResults(i, algorithm, "ascending");
printResults(i, algorithm, "descending");
}
}
}
}
private void test(SortAlgorithm algorithm, boolean warmingUp) {
// Test with a random, a sorted, and a reversed (= sorted descending) array
test(algorithm, InputOrder.RANDOM, ArrayUtils::createRandomArray, warmingUp);
if (TEST_SORTED_INPUT) {
test(algorithm, InputOrder.ASCENDING, ArrayUtils::createSortedArray, warmingUp);
test(algorithm, InputOrder.DESCENDING, ArrayUtils::createReversedArray, warmingUp);
}
}
private void test(
SortAlgorithm algorithm,
InputOrder inputOrder,
IntFunction<int[]> arraySupplier,
boolean warmingUp) {
System.out.printf("%n--- %s (order: %s) ---%n", algorithm.getName(), inputOrder);
// Sort until sorting takes more than MAX_SORTING_TIME_SECS
// Upper limit used by insertion sort on already sorted data
for (int size = MIN_SORTING_SIZE;
size <= MAX_SORTING_SIZE
&& algorithm.isSuitableForInputSize(size)
&& (!inputOrder.isSorted() || algorithm.isSuitableForSortedInput(size));
size <<= 1) {
long time = measureTime(algorithm, arraySupplier.apply(size));
boolean newRecord =
!warmingUp && scorecard(algorithm, inputOrder.toString(), size, true).add(time);
System.out.printf(
Locale.US,
"%s (order: %s): size = %,11d --> time = %,10.3f ms %s%n",
algorithm.getName(),
inputOrder,
size,
time / 1_000_000.0,
newRecord ? "<<< NEW RECORD :-)" : "");
// Stop after specified time
if (time > MAX_SORTING_TIME_SECS * 1_000_000_000L) {
break;
}
}
}
// In production code, we should never try to be smarter than the JVM; here we do try ;-)
@SuppressWarnings({"PMD.DoNotCallGarbageCollectionExplicitly", "java:S1215"})
private long measureTime(SortAlgorithm algorithm, int[] elements) {
System.gc();
long time = System.nanoTime();
algorithm.sort(elements);
return System.nanoTime() - time;
}
private Scorecard scorecard(
SortAlgorithm algorithm, String inputOrder, int size, boolean create) {
String key = algorithm.getName() + "/" + inputOrder + "/" + size;
return create ? scorecards.computeIfAbsent(key, Scorecard::new) : scorecards.get(key);
}
private void printResults(int iteration, SortAlgorithm algorithm, String inputOrder) {
System.out.printf(
"%n--- Results for iteration %d for: %s (order: %s) ---%n",
iteration, algorithm.getName(), inputOrder);
int longestNameLength = 0;
for (int size = MIN_SORTING_SIZE;
size <= MAX_SORTING_SIZE && algorithm.isSuitableForInputSize(size);
size <<= 1) {
Scorecard scorecard = scorecard(algorithm, inputOrder, size, false);
if (scorecard != null) {
int nameLength = scorecard.getName().length();
if (nameLength > longestNameLength) {
longestNameLength = nameLength;
}
}
}
for (int size = MIN_SORTING_SIZE;
size <= MAX_SORTING_SIZE && algorithm.isSuitableForInputSize(size);
size <<= 1) {
Scorecard scorecard = scorecard(algorithm, inputOrder, size, false);
if (scorecard != null) {
scorecard.printResult(longestNameLength, "");
}
}
}
private enum InputOrder {
RANDOM(false),
ASCENDING(true),
DESCENDING(true);
private final boolean sorted;
InputOrder(boolean sorted) {
this.sorted = sorted;
}
boolean isSorted() {
return sorted;
}
@Override
public String toString() {
return name().toLowerCase(Locale.US);
}
}
}
每次迭代后,测试程序都会打印出前一次测量结果的中值。
以下是插入排序经过 50 次迭代后的结果:
n | 未分类 | 降序 | 上升 |
---|---|---|---|
... | ... | ... | ... |
32,768 | 87.86 毫秒 | 175.80 毫秒 | 0.042 毫秒 |
65,536 | 350.43 毫秒 | 697.59 毫秒 | 0.084 毫秒 |
131,072 | 1,398.92 毫秒 | 2,840.00 毫秒 | 0.168 毫秒 |
262,144 | 5,706.82 毫秒 | 11,517.36 毫秒 | 0.351 毫秒 |
524,288 | 23,009.68 毫秒 | 46,309.27 毫秒 | 0.710 毫秒 |
1,048,576 | – | – | 1.419 毫秒 |
... | ... | ... | ... |
536,870,912 | – | – | 693.310 毫秒 |
很容易看出:
- 当未排序和降序排序元素的输入量增加一倍时,运行时间大约会增加四倍,
- 最坏情况下的运行时间是平均情况下的两倍,
- 预排序元素的运行时间如何线性增长并且明显更小。
这对应于预期的时间复杂度O(n²)和O(n)。
此处以图表形式显示测量值:
使用预排序元素,插入排序非常快,几乎看不到线。因此,这里分别给出了最好的情况:
6. 插入排序的其他特性
插入排序的空间复杂度是恒定的,因为除了循环变量 i 和 j 以及辅助变量 elementToSort 之外,我们不需要任何额外的内存。这意味着 - 无论我们对 10 个元素还是 100 万个元素进行排序 - 我们始终只需要这三个额外的变量。恒定复杂度记为O(1)。
该排序方法是稳定的,因为我们只移动大于要排序的元素(而不是“大于或等于”)的元素,这意味着两个相同元素的相对位置永远不会改变。
插入排序不能直接并行化。*但是,插入排序有一个并行变体:希尔排序。
7. 总结
插入排序是一种易于实现、稳定的排序算法,平均和最坏情况下的时间复杂度为O(n²),最好情况下的时间复杂度为O(n)。
对于非常小的n,插入排序比快速排序或归并排序等更高效的算法更快。因此,这些算法使用插入排序解决较小的子问题(例如,JDK 中的双枢轴快速排序实现,Arrays.sort()
元素少于 44 个)。