引言
在大数据时代,处理海量数据成为许多应用的常见需求。然而,当数据量超过内存容量时,传统的内存排序算法将不再适用。本文将介绍如何使用Java编程语言,在有限的内存条件下(例如2GB内存)对一个10GB的文本文件进行排序。
这也是一个最近几年比较流行的实际面试题:10GB的文本文件,全是介于[10000000, 88888888]之间的随机整数,每行一个数字。要求使用2GB内存,编程对该文件中的数字排序并按降序重新输出到一个新的文件中。
我们将使用外部排序思想来解决这个问题。
外部排序算法简介
外部排序算法是一种用于处理无法完全加载到内存中的数据集的排序方法。最常用的外部排序算法是多路归并排序,它分为两个主要步骤:分割 和 合并。
- 分割:将大文件分割成多个小文件,每个小文件的大小应小于可用内存,以便可以对其进行内部排序。
- 合并:将所有已排序的小文件合并成一个大的已排序文件。
基于外部排序思想,我们把文件分成79个,分别存储10、11、12、……、87、88开头的数字,平均每个文件大小约为130MB(10GB/79)。由于数字在文本中以字符串形式存储,再加上换行符,所以单行大小9bytes(8bytes+1byte),单个文件的数字个数约为1510_0000个。
把单个文件中的全部数字加载到内存中,每个数字是一个int类型的,占用4字节,总共占用57.6MB。
关于数字排序,这里使用位数组+HashMap计数的方式进行,提升排序效率。
实现步骤
步骤 1: 分割文件
首先,我们将大文件分割成多个小文件,每个小文件的大小不超过可用内存。
顺序读取10GB文件中的每一个数字n,则该数字应该写入的文件序号i = ( n / 100_0000 ) - 10
;遍历完成后所有数字按照前两位分别存储到序号0-78的小文件中。
步骤 2: 合并已排序的文件
接下来,我们需要将所有小文件数字进行排序并合并成一个大的已排序文件。
- 这里我们初始化一个长度为100_0000的位数组,分别用于表示当前文件中是否存在某个数字。同时还要初始化一个HashMap,用于存储每一个数字出现的次数。
- 例如,现在处理序号0的文件,其数字范围是[1000_0000, 2000_0000),则位数组索引为0的位置表示文件0中是否存在数字1000_0000。
- 逐个遍历该文件中的每一个数字,最后得到一个记录[1000_0000, 2000_0000)范围内每一个数字是否存在的数组和一个记录每一个数字出现次数的HashMap。
- 从前向后遍历该位数组,并结合HashMap即可输出当前文件中所有数字的排序结果。
- 从文件78到文件0逐个处理,并将结果输出到最终的结果文件中,排序完成。
示例代码
package org.hbin.sort;
import java.io.*;
import java.util.*;
/**
* @author Haley
* @version 1.0
* 2024/10/25
*/
public class SplitSort {
/** 最小数字 */
private static int min = 1000_0000;
/** 最大数字 */
private static int max = 8888_0000;
/** 单个文件的数字个数 */
private static int fileCapacity = 100_0000;
/** 小文件数量 */
private static int fileCount = max / fileCapacity - min / fileCapacity + 1;
private static int bound = max - min + 1;
/** 位数组 */
private static boolean[] bits = new boolean[bound];
/** 统计集合 */
private static Map<Integer, Integer> map = new HashMap<>(bound);
private static String fileName = "1MB.txt";
private static String prefixBatchPath = "prefix_batch//";
private static String prefixOutFileName = "prefix_merge_sort_";
private static String outFileName;
public static List<File> split() throws IOException {
System.out.println("clean ...");
File batchPath = new File(prefixBatchPath);
if(batchPath.exists()) {
batchPath.delete();
}
batchPath.mkdir();
System.out.println("create batch files ...");
List<File> files = new ArrayList<>();
int offset = min / fileCapacity;
BufferedWriter[] writers = new BufferedWriter[fileCount];
for (int i = 0; i < fileCount; i++) {
files.add(new File(prefixBatchPath + (i + offset)));
writers[i] = new BufferedWriter(new FileWriter(prefixBatchPath + (i + offset)));
}
System.out.println("split file ...");
long s = System.currentTimeMillis();
long printCapacity = fileCapacity;
try (BufferedReader reader = new BufferedReader(new FileReader(fileName))) {
String line;
int count = 0;
while((line = reader.readLine()) != null) {
int slot = Integer.parseInt(line) / fileCapacity;
writers[slot - offset].write(line + System.lineSeparator());
count ++;
if(count % printCapacity == 0) {
System.out.println("deal " + count);
if(count / printCapacity >= 10) {
printCapacity *= 10;
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
for (int i = 0; i < fileCount; i++) {
writers[i].flush();
writers[i].close();
}
System.out.println("split file. " + (System.currentTimeMillis() - s) / 1000 + " s.");
return files;
}
public static void bitSort() throws IOException {
// 清空
outFileName = prefixOutFileName + fileName;
File outFile = new File(outFileName);
if(outFile.exists()) {
outFile.delete();
}
outFile.createNewFile();
long start = System.currentTimeMillis();
int end = min / fileCapacity;
for (int i = max / fileCapacity; i >= end; i--) {
// 重置位数组
Arrays.fill(bits, false);
map.clear();
int total = 0;
int batch = 500_0000;
File f = new File(prefixBatchPath + i);
try (BufferedReader reader = new BufferedReader(new FileReader(f))) {
String line;
while((line = reader.readLine())!=null) {
int num = Integer.parseInt(line);
int index = num - min;
if(!bits[index]) {
bits[index] = true;
map.put(num, map.getOrDefault(num, 0) + 1);
}
total ++;
if(total % batch == 0) {
System.out.println("deal ... " + total/10000 + "w, map's size: " + map.size());
if(total > 2500_0000) {
if(total % 100 == 0) {
System.out.println("\tdeal ... " + total + ", map's size: " + map.size());
}
}
}
}
}
System.out.println(i + " sort " + (System.currentTimeMillis() - start) / 1000 + " s.");
print();
}
}
public static void print() {
long start = System.currentTimeMillis();
try (BufferedWriter writer = new BufferedWriter(new FileWriter(outFileName, true))) {
for (int i = bits.length - 1; i >= 0; i--) {
if(bits[i]) {
int num = i + min;
for (int j = 0; j < map.get(num); j++) {
writer.write(num + System.lineSeparator());
}
}
}
writer.flush();
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("print " + (System.currentTimeMillis() - start) / 1000 + " s.");
}
public static void main(String[] args) throws IOException {
// fileName = "200MB.txt"; // split-46s - bit 46s
// fileName = "1GB.txt"; // 30+320=360s - 25+85=110s/25+65=93s
fileName = "10GB.txt"; // - 300+200=550s/310+270=590s
long start = System.currentTimeMillis();
split();
bitSort();
System.out.println("total: " + (System.currentTimeMillis() - start) / 1000 + " s.");
}
}
关键点解析
内存管理:通过拆分成不同的文件个数,确保单个文件大小不超过内存限制,最后排序加载单个文件时即可完成单个文件的数字排序。
文件操作:使用 BufferedReader 和 BufferedWriter 进行高效的文件读写操作。
排序:使用 位数组和Map 对每个小文件中的数据进行排序。
合并:按顺序处理每一个小文件,并最终合并输出到一个结果文件中。
结论
通过上述步骤,我们成功地在有限的内存条件下对一个10GB的文本文件进行了排序。这种方法不仅适用于本文中的场景,还可以扩展到其他需要处理大规模数据的应用中。希望本文能为您提供有价值的参考,帮助您在实际工作中解决类似的问题。