大資料演算法:對5億資料進行排序
0.前言:
在大資料研究的路上,我們總要對一些很大的資料進行各種各樣的操作。比如說對資料排序,比如說對資料統計,比如說對資料計算。而在大量的資料面前,我們總是束手無策,因為我們無法在限定時間的情況下,在效率上做到讓人滿意,也無法在限定空間的情況下,能夠快速解決問題。可能我們在一些日常的開發過程中,沒有遇到過這些問題。不過,現在是時候來考慮一下這樣的問題了。因為,現在正值大資料的時代。
在本文中我會用三種方法,從兩個方面來說明如何解決對5億資料進行排序工作。
1.版本說明
2.思路分析:
拿到這樣的一個問題,你的第一感覺是什麼?氣泡排序?選擇排序?插入排序?堆排?還是快排?可能你的想法是我的記憶體不夠。的確,這麼大的一個數據量,我們的記憶體的確不夠。因為單是5億的整數資料就有3.7個G(別說你是壕,記憶體大著呢)。既然記憶體不夠,那麼我們要怎麼來解決呢?
要知道我們一步做不了的事,兩步總能做到。那麼我們就來嘗試第一步做一些,剩下的一些就等會再來搞定吧。基於這樣的思路,就有下面的一個解題方法——分治!
1.分治——根據資料存在檔案中的位置分裂檔案到批量小檔案中
相對於樸素的排序,這是一種比較穩妥的解決方法。因為資料量太大了!我們不得不將大事化小,小事化了。
這裡我們的做法是每次讀取待排序檔案的10000個數據,把這10000個數據進行快速排序,再寫到一個小檔案bigdata.part.i.sorted中。這樣我們就得到了50000個已排序好的小檔案了。
在有已排序小檔案的基礎上,我只要每次拿到這些檔案中當前位置的最小值就OK了。再把這些值依次寫入bigdata.sorted中。
2.分治——根據資料自身大小分裂檔案到批量小檔案中
按照資料位置進行分裂大檔案也可以。不過這樣就導致了一個問題,在把小檔案合併成大檔案的時候並不那麼高效。那麼,這裡我們就有了另一種思路:我們先把檔案中的資料按照大小把到不同的檔案中。再對這些不同的檔案進行排序。這樣我們可以直接按檔案的字典序輸出即可。
3.字典樹
關於字典樹的基本使用,大家可以參見本人的另一篇部落格:《資料結構:字典樹的基本使用》
基於《資料結構:字典樹的基本使用》這篇部落格中對字典序的講解,我們知道我們要做就是對字典樹進行廣度優先搜尋。
3.結構設計圖:
4.程式碼分析:
0.分治
(0)分割大檔案
此步對大檔案的分割是按序進行的,這樣我們就可以確保資料的離散化,不會讓一個小檔案中的資料很多,一個小檔案的資料很少。
public static void splitBigFile2PartBySerial(String filePath, String partPath) throws IOException {
File file = new File(filePath);
FileInputStream inputStream = new FileInputStream(file);
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
StringBuffer buffer = new StringBuffer();
String readerLine = "";
int line = 0;
while ((readerLine = reader.readLine()) != null) {
buffer.append(readerLine + " ");
if (++line % Config.PART_NUMBER_COUNT == 0) {
sortStringBuffer(buffer);
int splitLine = line / Config.PART_NUMBER_COUNT;
write(partPath.replace("xxx", "" + splitLine), buffer.toString());
buffer.setLength(0);
System.out.println("SPLIT: " + splitLine);
}
}
reader.close();
}
(1)排序
即使是已經切割成小份的了,不過每個小檔案中的資料集仍然有50000個。因為50000個數據也不是一個小資料,在排序的過程中,也會有一些講究,所有這裡我們使用的是快排。如下:
public static void sortStringBuffer(StringBuffer buffer) {
String[] numberTexts = buffer.toString().split(" ");
buffer.setLength(0);
int[] numbers = new int[numberTexts.length];
for (int i = 0; i < numberTexts.length; i++) {
numbers[i] = Integer.parseInt(numberTexts[i]);
}
int[] sorted = QKSort.quickSort(numbers);
for (int i = 0; i < sorted.length; i++) {
buffer.append(sorted[i] + "\n");
}
}
(2)合併
在合併的時候,我們要明確一個問題。雖然我們的單個小檔案已經有序,不過我們還並不知道整體的順序。比如:
檔案1:1 2 4 6 9 34 288
檔案2:4 5 6 87 99 104 135
上面的兩個檔案雖然每個檔案內部已經有序,不過整體來說,是無序的。對於在單個檔案有序的基礎上,我們可以做一些事情。我們可以把每個檔案中的資料看成是一個佇列,我們總是從佇列的首部開始進行出隊(因為佇列的頭部總是最小的數)。這樣,我們就把問題轉化成從N個小檔案中依次比較,得到最小的結果並記入檔案(當然,我們不可以生成一個數就寫一次檔案,這樣太低效了,我們可以使用一個變數快取這此"最小值",在累計到一定數量之後再一次性寫入。再清空變數,迴圈反覆,直到檔案全部寫入完畢)。
public static void mergeSorted(String dirPath) throws NumberFormatException, IOException {
long t = System.currentTimeMillis();
File dirFile = new File(dirPath);
File[] partFiles = dirFile.listFiles();
FileInputStream[] inputStreams = new FileInputStream[partFiles.length];
BufferedReader[] readers = new BufferedReader[partFiles.length];
int[] minNumbers = new int[partFiles.length];
for (int i = 0; i < partFiles.length; i++) {
inputStreams[i] = new FileInputStream(partFiles[i]);
readers[i] = new BufferedReader(new InputStreamReader(inputStreams[i]));
minNumbers[i] = Integer.parseInt(readers[i].readLine());
}
int numberCount = Config.TOTAL_NUMBER_COUNT;
while (true) {
int index = Tools.minNumberIndex(minNumbers);
System.out.println(minNumbers[index]);
write(Config.BIGDATA_NUMBER_FILEPATH_SORTED, minNumbers[index] + "\n");
minNumbers[index] = Integer.parseInt(readers[index].readLine());
if (numberCount-- <= 0) {
break;
}
}
System.err.println("TIME: " + (System.currentTimeMillis() - t));
for (int i = 0; i < partFiles.length; i++) {
inputStreams[i].close();
readers[i].close();
}
}
注:這裡關於分治的演算法,我就只提供一種實現過程了。可能從上面的說明中,大家也意識到了一個問題,如果我們把大檔案中的資料按照數值大小化分到不同的小檔案中。這樣會有一個很致命的問題,那就是可能我們的小檔案會出現兩極分化的局面,即有一部分檔案中的資料很少,有一部分小檔案中的資料很多。所以,這裡我就不再提供實現過程,在上面有所說明,只是想說我們在解決問題的時候,可能會有很多不同的想法,這些想法都很好,只是有時我們需要一個最優的來提升逼格(^_^)。
1.字典樹
因為我們知道字典樹是可以壓縮資料量的一種資料結構,尤其是針對那麼使用的字串個數有限(比如:'0','1','2','3','4','5','6','7','8','9'),並整體數量很多的情況。因為我們可以可以讓同一個字元重複使用多次。比如:"123456789"和"123456780"其實只使用了'0'-'9'這10個字元而已。關於字典樹的實現,我想是很簡單的一種方法。如果你還是感覺有些朦朧和模糊的話,就請參見本人的另一篇部落格《資料結構:字典樹的基本使用》,在那一篇部落格中,我有很詳細地介紹對字典樹的各種基本操作及說明。
這裡我還是貼出一部分關鍵的程式碼,和大家一起學習吧。程式碼如下:
(0)將資料記入檔案
public static void sortTrie(String filePath) throws IOException {
File file = new File(filePath);
FileInputStream inputStream = new FileInputStream(file);
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
TrieTree tree = new TrieTree("sorting");
String readerLine = "";
int line = 0;
while ((readerLine = reader.readLine()) != null) {
tree.insert(readerLine);
if (++line % Config.PART_NUMBER_COUNT == 0) {
System.out.println("LINE: " + line);
}
}
System.out.println("檔案讀取完畢");
writeTrieTree2File(Config.BIGDATA_NUMBER_FILEPATH_SORTED, tree);
reader.close();
}
(1)對字典樹進行廣度優先搜尋
public static void sortNumberOrder(String filePath, Node node) throws IOException {
Queue<Node> queuing = new LinkedList<Node>();
queuing.offer(node);
while (!queuing.isEmpty()) {
Node currentNode = queuing.poll();
if (currentNode.isEnd()) {
buffer.append(getNodePath(currentNode) + "\n");
if (++index % 50000 == 0) {
write(filePath, buffer.toString());
}
}
Node[] children = currentNode.getChildren();
for (Node sonNode : children) {
if (sonNode != null) {
queuing.offer(sonNode);
}
}
}
}
/**
* 獲得某一節點的上層節點,即字首字串
* @param node
* @return
*/
public static String getNodePath(Node node) {
StringBuffer path = new StringBuffer();
Node currentNode = node;
while (currentNode.getParent() != null) {
path.append(currentNode.getName());
currentNode = currentNode.getParent();
}
return path.reverse().toString();
}
5.小結:
在大資料的探索還遠不止於此。還有很多東西等著我們去了解,去發現,以及創造。
而對於大量資料的問題,我們可以利用分治來化解它的大,從而可以更方便地去觀察全域性。也可以使用我們已經學習過的一些資料結構及演算法來求解問題。不過隨著我們不斷地學習,不斷地探索,我們可能會感覺到自己學習的一些固有的資料結構和演算法並不能完全地解決我們遇到的問題,那麼就需要我們的創造力了。