算法:用Java實現外部排序(ExternalSort)

外部排序,是相對於內部排序而言的。之前我分享了很多種排序,這些排序都是將待排序的亂序數組全部放到內存裏面,然後執行相應的排序算法,完成排序並輸出結果的。整個排序的過程都是在內存裏一次性加載所有的待排序數字,然後在內存裏完成排序算法,這種叫內部排序。外部排序,就是需要排序的數字太多了,以至於內存一次加載不了所有的數字,然後就只能通過今天分享的外部排序來完成。

外部排序,亂序數字就不是在內存中加載爲數組了,而是存在文件上的。Java需要IO讀取文件內容,將文件中的數字經過處理,然後IO輸出中間文件及最後的排好序的文件,完成整個外部排序過程。本來我打算寫代碼來完成這個外部排序,但是我覺得寫出思路來就可以了,就暫時不寫代碼了。後面我會給出一些Java的API,用來輔助實現外部排序。

實現思路1、兩路合併:

  1. 先按最大內存,每次讀入內存允許的數字量,然後快速排序(不需要其他額外的數組空間),並將排序結果輸出爲文件,並編號,比如叫1-N號有序數字串文件
  2. 每次取兩個文件,每個文件一行一行地讀,然後一個個地吐數字。像類似歸併排序那樣,將兩個文件的有序數字串合併爲一個有序數字串,並同時寫入新文件,現在就有1-N/2個有序數字串文件了
  3. 再每次取兩個文件,重複第二步操作,每次會生成之前大約二倍大小的有序數字串文件,且文件數量每次減少一半
  4. 直到最後,合併爲唯一一個有序數字串文件,即完成外部排序

兩路合併舉例:

  1. 待排序文件:2 9 5 3 8 6 1 7 4 0

  2. 假設內存一次只能讀入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

  3. 然後文件兩兩歸併(就是歸併排序的那種歸併操作方式,兩個文件一行行讀取內容,然後每次吐出一個數字),較小的輸出,然後小的文件接着吐下一個數字,最終兩個有序文件歸併爲一個有序文件:
    1和2歸併後的有序文件:1 2 3 5 6 7 8 9
    3歸併後的有序文件:0 4

  4. 如果歸併後還是1個以上的文件,就接着歸併,直到最後只有一個文件:
    步驟3中的兩個文件歸併後的有序文件(外部排序結果):0 1 2 3 4 5 6 7 8 9

  5. 最後歸併輸出的最後一個文件,就是外部排序完成後的有序數字文件了,外部排序完成!

實現思路2、多路合併:

  1. 先按最大內存,每次讀入內存允許的數字量,然後快速排序(不需要其他額外的數組空間),並將排序結果輸出爲文件,並編號,比如叫1-N號有序數字串文件,這個是共同的操作
  2. 每次取M個文件,每個文件一行一行地讀,然後一個個地吐數字。也是像類似歸併排序那樣,將N個文件的有序數字串的當前數字放入優先隊列中,最小的數字具有最大的優先級,然後出隊最小的數字,哪個文件的數字出隊了,就再吐出一個數字入隊,然後接着出隊,吐數字入隊……最後M個文件合併爲一個有序數字串,並同時寫入新文件,現在就有1-N/M個有序數字串文件了
  3. 如果最後的文件數不爲1,就重複步驟2,文件每次會變大M倍,數量會變爲1/M
  4. 最後一次歸併可能會少於M個文件。最後一次M路歸併,可能就會變成L路歸併(L<=M),然後合併爲唯一一個有序數字串文件,即完成外部排序

多路合併舉例:

  1. 待排序文件:8 4 6 2 5 3 9 1 7

  2. 假設內存一次只能讀入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

  3. 然後我們N=3,採取3路歸併,用優先隊列來決定下一個輸出的數字和找到哪個文件該繼續吐數字,此例的3路歸併後,將生成2個文件:
    文件1-3歸併後的有序文件:2 3 4 5 6 8
    文件4-5歸併後的有序文件:1 7 9

  4. 如果最後不剩下唯一一個文件,則繼續重複步驟3。此例中出現了比路數小的文件個數:3<2。此時就需要2路歸併了:
    步驟3中兩個文件歸併後的有序文件(外部排序結果):1 2 3 4 5 6 7 8 9

分析:其實兩路歸併,也是多路歸併的一個特例。歸併路數可以是2-N,N就是每次讀入內存支持的最大數字個數,然後總共需要讀的次數。但無論哪種歸併,都先要將所有數字讀入內存(建議使用快速排序),然後排序,輸出N個有序數字文件,這個是跑不掉的,差別就在於歸併的套路:

  1. 兩路歸併的話,每次只需要判斷兩個有序數字文件吐出來的數字誰大誰小就可以了。而多路歸併,則需要藉助於優先隊列,而且還要找到優先隊列出隊的小數字是哪個文件吐出來的

  2. 兩路歸併的話,生成的中間文件會比較多。而多路歸併,生成的中間文件會比較少。比如N=12:
    兩路歸併的中間文件數:6 + 3 + 1 =10
    三路歸併的中間文件數:4 + 2 = 6
    四路歸併的中間文件數:3
    六路歸併的中間文件數:2

  3. 歸併路數少了,文件IO開銷多;歸併路數多了,優先隊列操作開銷多;我們需要根據實際情況,選擇合適的歸併路數,完成外部排序

附:外部排序可能用到的JavaAPI(假設文件分爲很多行,每行有多個數字,以空格分隔):

  1. 從文件裏面逐一讀取數字的迭代工具類,需要入參文件路徑和分隔符,即可迭代所有數字。可以運行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  
  1. 將數字寫入文件的工具類,需要輸入寫入文件的路徑(包含文件名,如果文件不存在,則創建)、分隔符和每行寫多少個數字。可以運行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, 

測試通過,可以拿這個去將歸併後的有序數字序列寫入文件了

  1. 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

好了,有了上面的思路,以及功能性關鍵代碼,歡快地去搞外部排序吧……

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章