一、实验目的
根据文档,要求我们编写插入排序算法、归并排序算法和用插入排序改进的归并排序算法代码。
改进的归并排序算法在处理元素较少的子数组时,使用插入排序算法来提高效率。k值表示当子数组元素个数少于等于k时,采用插入排序。并通过实验和测试不同的k值,找到合适的k值获得最佳的性能表现。
二、实验步骤
实验硬件:Apple MacBook Pro
CPU:M1 Pro
系统:MacOS Ventura 13.5.1
软件:IntelliJ IDEA 2023.1.3 (Ultimate Edition)
Java运行时版本: 17.0.7+10-b829.16 aarch64
VM: OpenJDK 64-Bit Server VM by JetBrains s.r.o.
GC: G1 Young Generation, G1 Old Generation
Java虚拟机内存: 4096MB
核心数: 8核
-
生成测试数据
/** * 创建一个包含随机整数的测试文件 */ public static void createTestFile() { String filename = "test.txt"; // 文件名 int count = 10000000; // 随机整数的数量 try (BufferedWriter writer = new BufferedWriter(new FileWriter(filename))) { Random random = new Random(); for (int i = 0; i < count; i++) { int randomNumber = random.nextInt(count + 1); // 生成随机整数 writer.write(Integer.toString(randomNumber)); // 将随机整数写入文件 writer.newLine(); // 写入换行符 } System.out.println("Random numbers have been written to " + filename); } catch (IOException e) { System.out.println("An error occurred while writing to the file: " + e.getMessage()); } }
向一个名为 “test.txt” 的文本文件写入向其中写入一千万个随机数,以测试排序性能。重复使用相同的测试样例,避免因每次生成数据不一致导致的误差。
-
编写排序算法代码
插入排序
/** * 使用插入排序对给定数组进行排序 * * @param array 要排序的数组 * @param <T> 实现了 Comparable 接口的泛型类型 */ public static <T extends Comparable<? super T>> void insertionSort(T[] array) { // 从第二个元素开始遍历数组 for (int i = 1; i < array.length; i++) { // 将当前元素存储在临时变量 key 中 T key = array[i]; // 向前比较并移动比 key 大的元素 int j = i - 1; while (j >= 0 && array[j].compareTo(key) > 0) { array[j + 1] = array[j]; j--; } // 将 key 插入到合适的位置 array[j + 1] = key; } }
归并排序
/** * 使用归并排序对给定数组进行排序 * * @param array 要排序的数组 * @param <T> 实现了 Comparable 接口的泛型类型 */ public static <T extends Comparable<? super T>> void mergeSort(T[] array) { // 如果数组长度小于等于1,则已经有序,直接返回 if (array.length <= 1) return; // 求中间位置的索引 int mid = array.length / 2; // 将原数组分割为左右两个子数组 T[] leftArray = Arrays.copyOfRange(array, 0, mid); T[] rightArray = Arrays.copyOfRange(array, mid, array.length); // 递归调用归并排序对左右子数组进行排序 mergeSort(leftArray); mergeSort(rightArray); // 将排好序的左右子数组合并到原数组中 merge(leftArray, rightArray, array); } /** * 将两个有序数组合并到目标数组中 * * @param leftArray 左子数组 * @param rightArray 右子数组 * @param resultArray 目标数组 * @param <T> 实现了 Comparable 接口的泛型类型 */ public static <T extends Comparable<? super T>> void merge(T[] leftArray, T[] rightArray, T[] resultArray) { int i = 0, j = 0, k = 0; // 比较左右子数组的元素,将较小的元素放入目标数组中 while (i < leftArray.length && j < rightArray.length) { if (leftArray[i].compareTo(rightArray[j]) <= 0) { resultArray[k++] = leftArray[i++]; } else { resultArray[k++] = rightArray[j++]; } } // 将剩余的元素复制到目标数组中 while (i < leftArray.length) { resultArray[k++] = leftArray[i++]; } while (j < rightArray.length) { resultArray[k++] = rightArray[j++]; } }
用插入排序改进的归并排序(部分代码直接调用了插入排序和归并排序中已定义好的方法)
/** * 使用归并排序对给定数组进行排序 * * @param array 要排序的数组 * @param <T> 实现了 Comparable 接口的泛型类型 * @param k 数组大小小于k时开始使用插入排序 */ public static <T extends Comparable<? super T>> void combineSort(T[] array, int k) { // 如果数组长度小于等于 k,使用插入排序 if (array.length <= k) { insertionSort(array); return; } // 将数组分为两半 int mid = array.length / 2; T[] leftArray = Arrays.copyOfRange(array, 0, mid); T[] rightArray = Arrays.copyOfRange(array, mid, array.length); // 递归地对左右两部分数组进行排序 combineSort(leftArray, k); combineSort(rightArray, k); // 合并左右两个有序数组 merge(leftArray, rightArray, array); }
三、数据收集
编写测试代码,获得实验结果
经过预测试发现,当数据规模小于十万数量级时,运行速度太快,耗时均为0ms或1ms,无法得到运行速度变化,所以我选取的数据规模n从100000开始。
当数据规模达到一亿数量级规模时,运行速度太慢,进行一次排序需要1.5min,很难测试不同k下的运行速度变化,所以我选取的数据规模n最大到10000000。
对于每个k值,进行十次排序取平均值,减小由于某次排序可能存在的异常情况或错误对结果的影响,提高数据的稳定性和可靠性。
public static void main(String[] args) {
// createTestFile(); // 创建测试数据文件(已注释)
try {
PrintWriter writer = new PrintWriter(new FileWriter("result.txt"));
//测试MergeSort效率,获得基准运行时间
for (int n = 100000; n <= 10000000; n *= 10) {
Integer[] nums = TestFile.readTestFile(n); // 读取测试数据
long runtime = 0;
for(int i=0;i<10;i++){ // 进行十次排序并取平均运行时间
runtime += testMergeSort(nums);
}
writer.println("MergeSort: n=" + n + " runtime:" + runtime/10 + "ms"); // 将结果写入文件
System.out.println("MergeSort: n=" + n + " runtime:" + runtime/10 + "ms"); // 在控制台输出结果
}
//测试不同数据规模和不同k取值下CombineSort效率
for (int n = 100000; n <= 10000000; n *= 10) {
Integer[] nums = TestFile.readTestFile(n); // 读取测试数据
for (int k = 1; k <= 100; k++) { // 遍历不同的k值
long runtime = 0;
for(int i=0;i<10;i++){ // 进行十次排序并取平均运行时间
runtime += testCombineSort(nums,k);
}
writer.println("CombineSort: n=" + n + " k=" + k + " runtime:" + runtime/10 + "ms"); // 将结果写入文件
System.out.println("CombineSort: n=" + n + " k=" + k + " runtime:" + runtime/10 + "ms"); // 在控制台输出结果
}
}
writer.close(); // 关闭文件写入流
} catch (IOException e) { // 异常处理
System.out.println("Failed to write results to file. Error message: " + e.getMessage());
}
System.out.println("TEST FINISH"); // 输出测试结束信息
}
public static <T extends Comparable<? super T>> long testCombineSort(T[] arr, int k) {
long startTime = System.currentTimeMillis();
combineSort(arr,k); // 合并排序
long endTime = System.currentTimeMillis();
return endTime - startTime; // 返回运行时间
}
public static <T extends Comparable<? super T>> long testMergeSort(T[] arr) {
long startTime = System.currentTimeMillis();
mergeSort(arr); // 归并排序
long endTime = System.currentTimeMillis();
return endTime - startTime; // 返回运行时间
}
/**
* 从测试文件中读取指定数量的整数
*
* @param n 要读取的整数数量
* @return 包含读取整数的数组
*/
public static Integer[] readTestFile(int n){
String filename = "test.txt"; // 文件名
Integer[] arr = new Integer[n]; // 存放读取整数的数组
try (BufferedReader reader = new BufferedReader(new FileReader(filename))) {
List<Integer> numbers = new ArrayList<>();
String line;
while ((line = reader.readLine()) != null && numbers.size() < n) {
int number = Integer.parseInt(line); // 将字符串转换为整数
numbers.add(number); // 将整数添加到列表中
}
numbers.toArray(arr); // 将列表转换为数组
} catch (IOException e) {
System.out.println("An error occurred while reading the file: " + e.getMessage());
}
return arr;
}
结果导出到result.txt中,将其转换到result.xlsx中,方便后续分析数据。
四、结果与讨论
-
理论分析
归并排序的时间复杂度为 O ( n log 2 n ) O(n\log_{2}n) O(nlog2n)
插入排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2)
当待排数组的规模足够大时,归并排序比插入排序更快。但归并排序为递归算法,存在堆栈调用的额外开销,而插入排序为原地排序,所以当数组长度比较小时,插入排序的常量因子比较小,排序速度比归并排序更快。
因此,我使用插入排序对归并排序进行优化,自顶向下,指定需要使用插入排序的数组大小k,当递归排序的数组大小小于等于k时,不再递归调用归并排序,而是直接调用插入排序。
自此,该问题形成一个递归树,若是完全的归并排序,递归树的高度为 O ( log 2 n + 1 ) O(\log_{2}n+1) O(log2n+1)
而在我的算法中当子问题长度小于等于指定长度k时,不再递归,此时递归树的高度为 O ( log 2 ( n / k ) + 1 ) O(\log_{2}(n/k)+1) O(log2(n/k)+1)
归并算法的总时间为 O ( n log 2 ( n / k ) O(n\log_{2}(n/k) O(nlog2(n/k)需要执行插入排序的子数组有 n / k n/k n/k个,每个子数组长度为 k k k,所以插入排序的总时间为 n / k ∗ O ( k 2 ) = O ( n k ) n/k*O(k^2)=O(nk) n/k∗O(k2)=O(nk)
总的时间复杂度为 O ( n k + n log 2 ( n / k ) ) O(nk+n\log_{2}(n/k)) O(nk+nlog2(n/k))接下来讨论 k k k的理论最大值,当 k k k趋近1时,时间复杂度趋近归并排序的复杂度 O ( n log 2 n ) O(n\log_{2}n) O(nlog2n),当 k k k趋近于 n n n时,时间复杂度趋近于插入排序的时间复杂度 O ( n 2 ) O(n^2) O(n2) 。把 k k k作为 n n n的函数,观察到当 k k k等 log 2 n \log_{2}n log2n时,代入 O ( n k + n log 2 ( n / k ) ) O(nk+n\log_{2}(n/k)) O(nk+nlog2(n/k))得:
O ( n log 2 n + n log 2 ( n / log 2 n ) ) O(n\log_{2}n + n\log_{2}(n/\log_{2}n)) O(nlog2n+nlog2(n/log2n)) 化简后得: O ( 2 n log 2 n + n log 2 ( l o g 2 n ) ) O(2n\log_{2}n + n\log_{2}(log_{2}n)) O(2nlog2n+nlog2(log2n))
其中 O ( n log 2 ( l o g 2 n ) ) O(n\log_{2}(log_{2}n)) O(nlog2(log2n))部分的时间复杂度相对较小,通常不会影响整体的时间复杂度。因此可以近似表示为 O ( n log 2 n ) O(n\log_{2}n) O(nlog2n)
即归并排序复杂度,所以可以得出当 k k k 等于 log 2 n \log_{2}n log2n 时整个算法的复杂度趋近于归并排序复杂度,因此 k k k 理论最大值为 log 2 n \log_{2}n log2n。
当n=100000时k的理论最大值为16.6
当n=1000000时k的理论最大值为19.9
当n=10000000时k的理论最大值为23.3 -
实验结果
在Matlab中使用result.xlsx的数据画图
% n=100000 % 读取 Excel 文件 [num, txt, raw] = xlsread('result.xlsx'); % 提取数据 k = num(1:100, 1); % 提取 "k" 列数据 runtime = num(1:100, 2); % 提取 "runtime" 列数据 % 绘制散点图 scatter(k, runtime, 'filled'); % 添加未优化时的参考线 hold on; y_ref = 8; ref_line = line([min(k), max(k)], [y_ref, y_ref], 'Color', 'red'); hold off; % 添加标题、轴标签和网格线 title('CombineSort Efficiency (n=100000)'); xlabel('k'); ylabel('Runtime(ms)'); grid on;
n=1000000和n=10000000时同理,得到以下图像
由图,其中红色线为单独使用归并排序的运行耗时,可以发现,使用插入排序对归并排序进行优化确实可以使算法的运行效率变快。分析散点图,n=100000时,k=67~74时算法运行效率较高;n=1000000时,k=76的算法效率最高;n=10000000时,k=63的算法效率最高。
但是实验获得的k大于理论k值,分析是因为归并排序存在堆栈调用的额外开销,而现代处理器在执行原地排序的算法时效率更快,所以最终实验得到的k值大于理论值。在图中也会发现在每次增大数据规模时,前几次排序耗时总是高于平均耗时,推测是因为CPU对于一定规模的数据有实时的对应优化,导致增大数据规模时前几次排序效率较低。另外,观察硬件设备活动监视器发现在排序算法运行过程中CPU负载普遍在30%左右,且由于系统资源管理的特性,CPU频率并不稳定。同时排序算法的执行过程中可能会对内存进行读操作,内存的读写速度出现波动或延迟也会影响最终的排序耗时,最终表现为排序算法的耗时并不稳定,没有一个十分明显的根据k的变化而变化的趋势。
五、结论
理论分析和实验结果表明,通过对归并排序进行优化,使用插入排序来处理较小的子数组,可以进一步提高排序算法的效率。优化后的算法的时间复杂度为
O
(
n
k
+
n
log
2
(
n
/
k
)
)
O(nk+n\log_{2}(n/k))
O(nk+nlog2(n/k))其中
k
k
k为指定的阈值。理论上,
k
k
k的最大值为
log
2
n
\log_{2}n
log2n,即当
k
k
k等于
log
2
n
\log_{2}n
log2n时,算法的复杂度趋近于归并排序的复杂度。
实验结果显示,在不同数据规模下,选择适当的阈值
k
k
k可以使排序算法的运行效率最高。然而,排序耗时受到多种因素的影响,包括CPU优化、内存读写速度等,因此排序耗时可能会出现波动和不稳定的情况。
综上所述,通过合理选择阈值k,优化后的归并排序算法可以在一定程度上提高排序效率。