外部排序,是相對於內部排序而言的。之前我分享了很多種排序,這些排序都是將待排序的亂序數組全部放到內存裏面,然後執行相應的排序算法,完成排序並輸出結果的。整個排序的過程都是在內存裏一次性加載所有的待排序數字,然後在內存裏完成排序算法,這種叫內部排序。外部排序,就是需要排序的數字太多了,以至於內存一次加載不了所有的數字,然後就只能通過今天分享的外部排序來完成。
外部排序,亂序數字就不是在內存中加載爲數組了,而是存在文件上的。Java需要IO讀取文件內容,將文件中的數字經過處理,然後IO輸出中間文件及最後的排好序的文件,完成整個外部排序過程。本來我打算寫代碼來完成這個外部排序,但是我覺得寫出思路來就可以了,就暫時不寫代碼了。後面我會給出一些Java的API,用來輔助實現外部排序。
實現思路1、兩路合併:
- 先按最大內存,每次讀入內存允許的數字量,然後快速排序(不需要其他額外的數組空間),並將排序結果輸出爲文件,並編號,比如叫1-N號有序數字串文件
- 每次取兩個文件,每個文件一行一行地讀,然後一個個地吐數字。像類似歸併排序那樣,將兩個文件的有序數字串合併爲一個有序數字串,並同時寫入新文件,現在就有1-N/2個有序數字串文件了
- 再每次取兩個文件,重複第二步操作,每次會生成之前大約二倍大小的有序數字串文件,且文件數量每次減少一半
- 直到最後,合併爲唯一一個有序數字串文件,即完成外部排序
兩路合併舉例:
-
待排序文件:2 9 5 3 8 6 1 7 4 0
-
假設內存一次只能讀入4個數字(2 9 5 3 | 8 6 1 7 | 4 0),快速排序後以文件輸出,生成3個文件:
文件1:2 3 5 9
文件2:1 6 7 8
文件3:0 4 -
然後文件兩兩歸併(就是歸併排序的那種歸併操作方式,兩個文件一行行讀取內容,然後每次吐出一個數字),較小的輸出,然後小的文件接着吐下一個數字,最終兩個有序文件歸併爲一個有序文件:
1和2歸併後的有序文件:1 2 3 5 6 7 8 9
3歸併後的有序文件:0 4 -
如果歸併後還是1個以上的文件,就接着歸併,直到最後只有一個文件:
步驟3中的兩個文件歸併後的有序文件(外部排序結果):0 1 2 3 4 5 6 7 8 9 -
最後歸併輸出的最後一個文件,就是外部排序完成後的有序數字文件了,外部排序完成!
實現思路2、多路合併:
- 先按最大內存,每次讀入內存允許的數字量,然後快速排序(不需要其他額外的數組空間),並將排序結果輸出爲文件,並編號,比如叫1-N號有序數字串文件,這個是共同的操作
- 每次取M個文件,每個文件一行一行地讀,然後一個個地吐數字。也是像類似歸併排序那樣,將N個文件的有序數字串的當前數字放入優先隊列中,最小的數字具有最大的優先級,然後出隊最小的數字,哪個文件的數字出隊了,就再吐出一個數字入隊,然後接着出隊,吐數字入隊……最後M個文件合併爲一個有序數字串,並同時寫入新文件,現在就有1-N/M個有序數字串文件了
- 如果最後的文件數不爲1,就重複步驟2,文件每次會變大M倍,數量會變爲1/M
- 最後一次歸併可能會少於M個文件。最後一次M路歸併,可能就會變成L路歸併(L<=M),然後合併爲唯一一個有序數字串文件,即完成外部排序
多路合併舉例:
-
待排序文件:8 4 6 2 5 3 9 1 7
-
假設內存一次只能讀入2個數字(8 4 | 6 2 | 5 3 | 9 1 | 7),快速排序後以文件輸出,生成5個文件:
文件1:4 8
文件2:2 6
文件3:3 5
文件4:1 9
文件5:7 -
然後我們N=3,採取3路歸併,用優先隊列來決定下一個輸出的數字和找到哪個文件該繼續吐數字,此例的3路歸併後,將生成2個文件:
文件1-3歸併後的有序文件:2 3 4 5 6 8
文件4-5歸併後的有序文件:1 7 9 -
如果最後不剩下唯一一個文件,則繼續重複步驟3。此例中出現了比路數小的文件個數:3<2。此時就需要2路歸併了:
步驟3中兩個文件歸併後的有序文件(外部排序結果):1 2 3 4 5 6 7 8 9
分析:其實兩路歸併,也是多路歸併的一個特例。歸併路數可以是2-N,N就是每次讀入內存支持的最大數字個數,然後總共需要讀的次數。但無論哪種歸併,都先要將所有數字讀入內存(建議使用快速排序),然後排序,輸出N個有序數字文件,這個是跑不掉的,差別就在於歸併的套路:
-
兩路歸併的話,每次只需要判斷兩個有序數字文件吐出來的數字誰大誰小就可以了。而多路歸併,則需要藉助於優先隊列,而且還要找到優先隊列出隊的小數字是哪個文件吐出來的
-
兩路歸併的話,生成的中間文件會比較多。而多路歸併,生成的中間文件會比較少。比如N=12:
兩路歸併的中間文件數:6 + 3 + 1 =10
三路歸併的中間文件數:4 + 2 = 6
四路歸併的中間文件數:3
六路歸併的中間文件數:2 -
歸併路數少了,文件IO開銷多;歸併路數多了,優先隊列操作開銷多;我們需要根據實際情況,選擇合適的歸併路數,完成外部排序
附:外部排序可能用到的JavaAPI(假設文件分爲很多行,每行有多個數字,以空格分隔):
- 從文件裏面逐一讀取數字的迭代工具類,需要入參文件路徑和分隔符,即可迭代所有數字。可以運行FileNumberReader類的main方法(準備好自己的源數據文件,並更新相關參數)做測試:
import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
/**
* @author LiYang
* @ClassName FileNumberReader
* @Description 從多行文件中,讀取固定分隔的數字的工具類
* @date 2019/11/12 15:50
*/
public class FileNumberReader implements Iterator<Long> {
//數據源文件的路徑
private String filePath;
//讀取是否結束
private boolean isFinished;
//FileReader類,用於讀取文件
private FileReader fileReader;
//BufferedReader類,用於逐行讀取文件
private BufferedReader bufferedReader;
//讀入一行內容,轉化的數字字符串List
private List<String> numberList;
//當前讀入的數字文件行內容
private String currentLine;
//當前提取的數字的numberList的下標
private int currentIndex;
//數據源文件中數字的分隔符
private String delimiter;
/**
* FileNumberReader類的構造方法
* @param filePath 數據源文件路徑
* @param delimiter 文件中數字的分隔符
* @throws FileNotFoundException
*/
public FileNumberReader(String filePath, String delimiter) throws FileNotFoundException {
this.filePath = filePath;
this.isFinished = false;
this.fileReader = new FileReader(filePath);
this.bufferedReader = new BufferedReader(this.fileReader);
this.numberList = new ArrayList<>();
this.delimiter = delimiter;
}
/**
* 根據isFinished,判斷是否還有下一個數字
* @return 是否還有下一個數字
*/
@Override
public boolean hasNext() {
return !isFinished;
}
/**
* 返回文件中下一個數字
* @return 下一個數字
*/
@Override
public Long next() {
try {
//如果當前的numberList讀完了,則清空numberList
if (currentIndex > numberList.size() - 1){
numberList.clear();
}
//如果numberList被清空或是初始狀態
if (numberList.size() == 0) {
//讀入一行內容
currentLine = bufferedReader.readLine();
//如果讀入的內容爲null,證明文件讀到末尾了
if (currentLine == null){
//迭代結束
isFinished = true;
//關閉輸入流
bufferedReader.close();
fileReader.close();
//返回null,表示該文件已讀完
return null;
}
//如果文件沒有讀完,則將當前的數據行按分隔符弄成numberList
numberList = new ArrayList<>(Arrays.asList(currentLine.split(delimiter)));
//下標置爲0,從第一個開始
currentIndex = 0;
}
//返回當前numberList的currentIndex下標的數字
return Long.parseLong(numberList.get(currentIndex++));
} catch (IOException e) {
e.printStackTrace();
}
//出異常返回null
return null;
}
/**
* 測試文件數字讀取工具類
* @param args
* @throws FileNotFoundException
*/
public static void main(String[] args) throws FileNotFoundException {
/**
* 數字源文件路徑,該文件內容如下:
* 334 6544 255 6711
* 34655 3 512 343
* 6545 63774 34782 82098
* 77 394
*
* 大家可以改爲自己的文件路徑
*/
String filePath = "C:\\Users\\Administrator\\Desktop\\external.txt";
//該文件數字分隔符爲空格,大家可以改爲自己的分隔符,注意是正則表達式
String delimiter = " ";
//創建文件數字閱讀工具類實例
FileNumberReader fileNumberReader = new FileNumberReader(filePath, delimiter);
//迭代該類實例,通過該類實例的next()方法取到下一個數字
while (fileNumberReader.hasNext()) {
//最後一個輸出不是數字,是null,這就證明文件讀完了
//可以作爲判斷的依據
System.out.print(fileNumberReader.next() + " ");
}
}
}
運行FileNumberReader類的main方法,控制檯輸出如下,測試通過(外部排序可以拿去從文件中讀數字了):
334 6544 255 6711 34655 3 512 343 6545 63774 34782 82098 77 394 null
- 將數字寫入文件的工具類,需要輸入寫入文件的路徑(包含文件名,如果文件不存在,則創建)、分隔符和每行寫多少個數字。可以運行FileNumberWriter類的main方法(準備好自己的相關參數)做測試:
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Random;
/**
* @author LiYang
* @ClassName FileNumberWriter
* @Description 將外部排序的有序數字寫入文件的工具類
* @date 2019/11/12 16:16
*/
public class FileNumberWriter {
//寫入的文件的路徑(包含自定義的文件名)
private String filePath;
//數字分隔符
private String delimiter;
//每行寫的數字的個數
private int amountEachLine;
//當前行數字的個數
private int currentLineAmount;
//待寫入的文件類
private File file;
//用於寫文件的FileWriter類
private FileWriter fileWriter;
//用於寫文件的BufferedWriter類
private BufferedWriter bufferedWriter;
/**
* 寫數字的文件工具類
* @param filePath 寫入的文件的路徑(包含自定義的文件名)
* @param delimiter 數字分隔符
* @param amountEachLine 每行寫的數字的個數
* @throws IOException
*/
public FileNumberWriter(String filePath, String delimiter, int amountEachLine) throws IOException {
this.filePath = filePath;
this.delimiter = delimiter;
this.amountEachLine = amountEachLine;
this.fileWriter = new FileWriter(filePath);
this.bufferedWriter = new BufferedWriter(fileWriter);
//實例化該文件
this.file = new File(filePath);
//文件不存在,則創建
if (!file.exists()){
file.createNewFile();
}
}
/**
* 向指定文件寫入數字(注意,最後一行會多寫一個分隔符)
* @param number 準備要寫入的數字
* @throws IOException
*/
public void writeNumber(Long number) throws IOException {
//當前行的第幾個數字自增
currentLineAmount ++;
//如果已經超過了當前行的最大數量
if (currentLineAmount > amountEachLine){
//換一行
bufferedWriter.newLine();
//重置當前行的數字的第幾個
currentLineAmount = 1;
}
//如果已經是當前行的最後一個數字(寫了這個數字就要換行了)
if (currentLineAmount == amountEachLine){
//只寫入當前數字
bufferedWriter.write(String.valueOf(number));
//如果這個數字寫了還不需要換行
} else {
//寫入當前數字,以及分隔符
bufferedWriter.write(number + delimiter);
}
}
/**
* 當有序數字寫完後,調用該方法,關閉各種輸出流
* @throws IOException
*/
public void writeFinish() throws IOException {
bufferedWriter.close();
fileWriter.close();
}
/**
* 測試文件數字寫入工具類
* @param args
* @throws IOException
*/
public static void main(String[] args) throws IOException {
//寫入的文件的路徑(包含文件名)
String filePath = "C:\\Users\\Administrator\\Desktop\\write_number.txt";
//爲了美觀,分隔符爲英文逗號和空格
String delimiter = ", ";
//每行寫4個數字
int amountEachLine = 4;
//FileNumberWriter工具類實例
FileNumberWriter fileNumberWriter = new FileNumberWriter(filePath, delimiter, amountEachLine);
//隨機數類
Random random = new Random();
//寫入30個一千萬以內的隨機整數
for (int i = 1; i <= 30; i++) {
//調用方法,寫入數字(注意,最後一行有可能多一個分隔符)
fileNumberWriter.writeNumber((long)random.nextInt(10000000));
}
//寫完調用writeFinish()方法,關閉輸出流
fileNumberWriter.writeFinish();
}
}
運行FileNumberWriter類的main方法,寫入30個一千萬以內的隨機整數,然後相應路徑上有了一個文件,打開文件,內容如下:
6560567, 5469256, 7342852, 5126873
7296214, 469243, 9396550, 5661757
9696285, 3138184, 1499909, 4495839
9281861, 8126570, 9893790, 7408475
5508093, 5307213, 623569, 9570021
5766407, 9533322, 8139950, 4605782
174081, 1960382, 209324, 1638231
9927870, 6185734,
測試通過,可以拿這個去將歸併後的有序數字序列寫入文件了
- JavaAPI的優先隊列類,可以用Java的PriorityQueue<>類來做優先隊列,實現多路合併的最小值的判斷。PriorityQueue<>類的測試代碼及功能測試思路(在註釋上說明)如下,可以直接拿來使用:
import java.util.PriorityQueue;
/**
* @author LiYang
* @ClassName PriorityQueueTest
* @Description 優先隊列的JavaAPI應用測試
* @date 2019/11/12 17:03
*/
public class PriorityQueueTest {
/**
* 測試JavaAPI的優先隊列PriorityQueue<>類
* @param args
*/
public static void main(String[] args) {
//實例化一個優先隊列,保存Long型數據
PriorityQueue<Long> priorityQueue = new PriorityQueue<>();
//調用add()方法,將數字入隊
priorityQueue.add(7L);
priorityQueue.add(19L);
priorityQueue.add(13L);
//調用peek()方法,返回優先隊列中的最小元素
System.out.println(priorityQueue.peek());
//調用poll()方法,將優先隊列中的最小元素出隊,並返回
System.out.println(priorityQueue.poll());
//重複上述操作,共計查看和出隊三次
//上面的三個數字將從小到大依次出來
//注意,add(),peek(),poll()方法可以隨時調用
//如果優先隊列的元素全部被poll了,再poll和peek,返回null
System.out.println(priorityQueue.peek());
System.out.println(priorityQueue.poll());
System.out.println(priorityQueue.peek());
System.out.println(priorityQueue.poll());
//再往下,就是null了
System.out.println(priorityQueue.peek());
System.out.println(priorityQueue.poll());
}
}
運行PriorityQueueTest類的main方法,控制檯輸出如下,測試結果符合預期:
7
7
13
13
19
19
null
null
好了,有了上面的思路,以及功能性關鍵代碼,歡快地去搞外部排序吧……