算法:用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

好了,有了上面的思路,以及功能性关键代码,欢快地去搞外部排序吧……

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