Java并行编程--并行归并排序_java并行排序_wancong3的博客-程序员秘密

技术标签: Java  并行归并排序  并行编程  

一、归并排序回顾

归并排序,想必大家都不陌生,它是我们学习排序算法和分治法的极好例子。它是稳定排序,且有稳定的 O ( n l o g n ) O(nlogn) O(nlogn)时间复杂度,不受数据混乱度影响。唯一的不足是需要 O ( n ) O(n) O(n)的辅助空间。因此,归并排序被认为是综合性能最优的排序算法。

我们先来回顾一下归并排序的基本思想:首先,把数组平分成两半。然后,对这两半分别递归地进行归并排序。最后,把这两半排好序的数组有序合并起来。所以,大致可以写成这样:(用Java)

    public void mergeSort(int[] arr, int l, int r) {
    
        final int len = r - l;
        if (len <= 0) {
    
            return;
        } else if (len == 1) {
    
            if (arr[l] > arr[r]) {
    
                int tmp = arr[l];
                arr[l] = arr[r];
                arr[r] = tmp;
            }
            return;
        } // 这两个是边界条件
        final int mid = l + (len >> 1);
        mergeSort(arr, l, mid);
        mergeSort(arr, mid + 1, r);
        merge(arr, l, mid, mid + 1, r);
    }

其中merge是合并函数,把两个有序数组(这里表示成一个数组中互不交叠的两段)合并成一个有序数组。代码如下:

    private void merge(int[] arr, int l1, int r1, int l2, int r2) {
    
        int i = l1, j = l2, k = left;
        while (i <= r1 && j <= r2) {
    
            if (arr[i] <= arr[j]) {
    
                tmp[k++] = arr[i++];
            } else {
    
                tmp[k++] = arr[j++];
            }
        }
        while (j <= r2) {
    
            tmp[k++] = arr[j++];
        }
        while (i <= r1) {
    
            tmp[k++] = arr[i++];
        }
        for (i = 0; i < k - left; i++) {
    
            arr[i + l1] = tmp[i + left];
        }
    }

我们看到,归并排序有两个递归调用,这两个递归调用没有“重叠子问题”,也就是说完全可以互不干扰地进行。所以,我们很自然地想到用并行框架,把两个子问题并行解决,这样就可以进一步提升速度。

二、Java并行编程框架

Java中并行编程有两种方法,一是借助线程和线程池,二是借助并行框架ForkJoinTask。我们分别思考一下。

如果借助线程,则要处理的是两个子数组排序的并行,以及两个子数组排序后再合并的问题。前者并不难,重点在于后者。如果某一个子数组没有排序完毕就开始merge操作,则得不到正确结果。因为merge是建立在两个子数组都有序的大前提下的。所以必须考虑同步问题。这就需要借助信号量等操作,比较繁琐。

而对于归并排序这类问题,Java提供了两个非常合适的框架,分别是ForkJoinTaskForkJoinPool。前者用于定义需要并行的任务,后者提供并行所需的线程池。ForkJoinTask是一个处理并行问题的接口,有两个抽象类实现了它,一类是RecursiveTask<T>,一类是RecursiveAction,分别处理有返回结果(返回结果类型为T)和无返回结果的并行问题。我们的归并排序不需要返回结果,是原地排序,所以用RecursiveAction

三、RecursiveAction详解

要使用RecursiveAction,则必须用一个类来继承它,这个类就定义了一个具体的并行任务。我们的并行任务叫做ParallelMergeSort。注意RecursiveAction是一个抽象类,它有一个抽象方法compute,继承它的类必须实现这个抽象方法。这个compute方法,就是并行任务的执行代码。

我们先看调用并行任务的方法:调用时,不能直接用start或fork等方法,因为并行任务的执行并不在主线程中,而是在线程池提供的线程中,无法得知任务是否执行完毕。

不要担心,Java提供了另外一种阻塞式的启动方法,叫做invoke。它是ForkJoinPool的一个成员方法,表示启动一个并行任务,并阻塞主线程,在所有任务都执行完毕后再唤醒主线程。因此,我们可以这样写:

ParallelMergeSort task = new ParallelMergeSort(...); // 分配一个任务
ForkJoinPool pool = new ForkJoinPool(); // 分配一个并行线程池
pool.invoke(task); // 向线程池提交任务

然后再看并行任务ParallelMergeSort的定义。首先,有序数组的合并函数是完全一样的。重点在于如何将两个子数组的排序并行。所以,我们需要提供数组的排序区间。

import java.util.concurrent.RecursiveAction;

public class ParallelMergeSort extends RecursiveAction {
    

    private final int[] arr; // 待排序数组
    private final int left; // 排序区间左端点
    private final int right; // 排序区间右端点。注意这里是闭区间。
    private final int min; // min 和 max将在下文解释
    private final int max;
    private final int[] tmp; // tmp就是归并时的辅助数组,和普通归并排序中一样

    public ParallelMergeSort(int[] arr, int left, int right,
                             int min, int max, int[] tmp) {
    
        this.min = min;
        this.max = max;
        this.tmp = tmp;
        if (arr == null) {
    
            throw new NullPointerException();
        } else if (left > right) {
    
            throw new IndexOutOfBoundsException();
        }
        this.arr = arr;
        this.left = left;
        this.right = right;
    }

    private void merge(int[] arr, int l1, int r1, int l2, int r2) {
    
        int i = l1, j = l2, k = left; // 疑问1:为什么k初始化为left而不是0?
        while (i <= r1 && j <= r2) {
    
            if (arr[i] <= arr[j]) {
    
                tmp[k++] = arr[i++];
            } else {
    
                tmp[k++] = arr[j++];
            }
        }
        while (j <= r2) {
    
            tmp[k++] = arr[j++];
        }
        while (i <= r1) {
    
            tmp[k++] = arr[i++];
        }
        for (i = 0; i < k - left; i++) {
    
            arr[i + l1] = tmp[i + left];
        }
    }

    public void mergeSort(int[] arr, int l, int r) {
    
    // 疑问2:为什么要保留这个函数
        final int len = r - l;
        if (len <= 0) {
    
            return;
        } else if (len == 1) {
    
            if (arr[l] > arr[r]) {
    
                int tmp = arr[l];
                arr[l] = arr[r];
                arr[r] = tmp;
            }
            return;
        }
        final int mid = l + (len >> 1);
        mergeSort(arr, l, mid);
        mergeSort(arr, mid + 1, r);
        merge(arr, l, mid, mid + 1, r);
    }

    @Override
    protected void compute() {
     // 核心代码
        final int len = right - left;
        if (len < 50 || (len + 1) << 4 <= max - min + 1) {
    
            mergeSort(arr, left, right);
            return;
        } // 当数组规模很小或线程开得太多,就转为普通归并排序
        final int mid = left + (len >> 1);
        ParallelMergeSort leftTask =
                new ParallelMergeSort(arr, left, mid, min, max, tmp);
        ParallelMergeSort rightTask =
                new ParallelMergeSort(arr, mid + 1, right, min, max, tmp);
        invokeAll(leftTask, rightTask); // 和归并排序思路完全相同,只不过这里是并行的
        merge(arr, left, mid, mid + 1, right);
    }
}

这段代码并不难理解,但如代码中注释所示,有两个疑惑的地方,一个是为什么要保留普通归并排序函数,一个是merge函数中tmp数组为什么要从left开始存储。

其实归根结底,就是因为并行。有人说这不废话吗,诶,还真不废话。

我在compute函数的注释中已经写明白,如果数组规模太小,或者线程开得太多,就改用普通归并排序,因此需要保留普通归并排序函数。

然后就是merge函数中从left开始的问题。我们这里为了节省空间,始终用同一个tmp数组,它的真正内容由构造方法传入。任务中,每个子任务都需要使用tmp数组。如果归并操作统一从0开始,则由于子任务的并行,导致tmp同位置的元素可能被不同线程共享,造成归并操作混乱(线程安全问题)。因此,我们需要使得不同子任务使用的tmp数组区间互不重叠。用left可保证tmp和原数组位置一一对应,这样就避免了线程安全问题。

这两点是最难理解的,如果理解了这两点,剩下的工作就是水到渠成的。

最后考虑一下并行程度的问题。是不是开的线程越多,运行速度越快呢?并不是。因为线程分为用户线程和硬件线程两种。前者只是逻辑上的线程,而后者才是CPU中真正支持并行的东西。我们的CPU经常说“四核八线程”就是指支持四个独立计算单元以及8个真正并行的任务。其它逻辑线程仅仅是并发而非并行。而且,一般操作系统需要长期占用两个线程,其余6个线程才是真正供程序使用。无论你在程序中表面上开了多少个线程,都只有最多6个线程是真正并行的。

另外,开线程需要占用大量的操作系统资源,如果线程开得过多,则时间空间消耗是非常可观的,如果开线程本身的负面影响已经抵消甚至超过并行带来的好处,则使用并行就没有意义。

而根据我们上面的代码,并行任务数量大致和递归层数是指数关系(递归到第k层,则共有约 2 k 2^k 2k个并行任务)。因此我们应该在递归到第3-5层,就转为普通归并排序。所以,compute函数中有这样的特判:

        if (len < 50 || (len + 1) << 4 <= max - min + 1) {
    
            mergeSort(arr, left, right);
            return;
        } 

根据测试,保留8-32个并行任务时运行最快。

四、测试和效率分析

时间单位均为毫秒。CPU corei5-7200,四核八线程,操作系统win10 64bit,jdk1.8

排序规模 MergeSort时间 ParallelMergeSort时间 加速比
1000 2 2 1.000
5000 2 10 0.200
10000 4 15 0.267
50000 18 18 1.000
100000 32 16 2.000
500000 108 50 2.160
1000000 218 90 2.422
5000000 960 550 1.745
10000000 2100 950 2.211
30000000 6200 2520 2.460

可见,当规模较小时,由于并行与否的影响不大,而且开线程需要消耗时间空间,所以比普通归并排序慢一些。但规模较大时就体现出来差距了,并行版本的速度是普通版本的2-3倍。

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/zhangwancongcsdn/article/details/100572772

智能推荐

日常报错记录_illegalargumentexception: parse attempt failed for_Blithe_JJ的博客-程序员秘密

报错java.lang.NullPointerException: nulljava.lang.NullPointerException: nulljava.lang.NullPointerException: null at com.bootdo.hrms.controller.DepartmentManagementController.list(DepartmentManagementController.java:66) ~[classes/:na] at com.bootdo.hrms.c

[Autosar] Arrival Rates 与 Inter Arrival Times 区别_inter arrival time和arrival time有什么区别吗_「已注销」的博客-程序员秘密

Autosar OS 中的 Timing Protection 中有两个词:Arrival Rates 和 Inter Arrival Times.Arrival Rate:指在规定时间到达的数量。– Arrival rate = 1 / inter arrival timeInter Arrival Time: 指每个到达系统及下一个的时间。– inter arrival time = 1 / arrival rate举个例子:在一个咖啡店,每一个小时按序进来10个客人,那个客人之间的时间

0基础开始微服务(持续更新中......)_微服务资料百度网盘_小卢在路上的博客-程序员秘密

文章目录架构演进一.开发环境&amp;生成环境1.1 开发环境1.2 生产环境二.WEB1.0&amp;WEB2.02.1 WEB1.02.2 WEB2.02.3 搭建集群的问题三.垂直架构Linux一.引言1.1 开发环境1.2 生产环境1.3 测试环境1.4 操作系统的选择二.介绍2.1 Linux的版本2.2 Linux和Windows区别三.Linux目录结构四.Linux基本命令五.Linux目录命令5.1 查看目录5.2 切换目录5.3 创建目录5.4 删除目录5.5 复制目录5.6 移动/重命

理解Object.defineProperty的作用_JEECG低代码平台的博客-程序员秘密

对象是由多个名/值对组成的无序的集合。对象中每个属性对应任意类型的值。定义对象可以使用构造函数或字面量的形式:var obj = new Object; //obj = {}obj.name = "张三"; //添加描述obj.say = function(){}; //添加行为除了以上添加属性的方式,还可以使用Object.defineProperty定义新属性或修改原有的...

zcmu—1980_小w是云南中医学院的同学,有一天他看到了学校的百度百科介绍: 截止到2014年5月,云_鸡冠花12138的博客-程序员秘密

1980: 不存在的泳池Time Limit: 1 Sec  Memory Limit: 128 MBSubmit: 89  Solved: 28[Submit][Status][Web Board]Description小w是云南中医学院的同学,有一天他看到了学校的百度百科介绍:截止到2014年5月,云南中医学院图书馆纸本藏书74.8457万册,纸质期刊388种,

CDatabase::Open() 和 CDatabase::OpenEx()_极杰子的博客-程序员秘密

CDatabase::Openvirtual BOOL Open(LPCTSTR lpszDSN,                  BOOL bExclusive = FALSE,                  BOOL bReadOnly = FALSE,

随便推点

数据结构---栈_数据结构初始化栈_买代码的小猪猪的博客-程序员秘密

栈:是一种只能先进先出的数据结构,通过栈顶和栈底指针控制元素的输入输出。结构体定义:typedef int ElemType;typedef struct Stack { ElemType* top; ElemType* base;}Stack;初始化:先分配内存空间再使栈顶和栈底指向相同,表示空栈void init(Stack&amp; S) { S.top = new ElemType[100]; if (!S.top) return; S.base = S.top;}

python学习笔记——Windowns下Python3之安装jupyter_ailun8259的博客-程序员秘密

Windowns下Python3之安装jupyter Jupyter notebook: 一个交互式笔记本,支持运行40多种编程语言。 利用它来写Python,代码和运行结果都可以保存下载,十分方便。本文主要以自身的安装过程为例,结合遇到的问题,以及解决办法进行整理的一篇关于jupyter notebook安装的总结,自己是个python小白,入门走了很多弯路,希...

C++ - 子类与父类的同名成员变量_父类子类同名变量_学习&笔记的博客-程序员秘密

1.思考子类中是否可以定义父类中的同名成员?如果可以,如何区分?如果不可以,为什么?代码示例:#include &lt;iostream&gt;#include &lt;string&gt; using namespace std; class Parent{public: int mi;}; class Child : public Parent{public: int mi;}; int main(){ Child c;

Win10桌面背景(壁纸)导出工具_hongweigg的博客-程序员秘密

    Win10桌面背景会自动更新,有些图片看起来不错,很喜欢,希望保留,怎么做到呢?这个工具可帮助你。导出Windows10的桌面背景(壁纸),一键操作。基本思路:1、找到存放桌面动态壁纸的目录:C:\Users\[用户名]\AppData\Local\Packages\Microsoft.Windows.ContentDeliveryManager_cw5n1h2txyewy\L...

Linux下使用OTL操作数据库_liuguangzhou123的博客-程序员秘密

unixODBC1.下载unixODBC-2.3.2.tar.gz  地址:ftp://ftp.unixodbc.org/pub/unixODBC/unixODBC-2.3.2.tar.gz2.开启权限  sudo chmod 777 /usr/local3.拷贝  将unixODBC-2.3.2.tar.gz拷贝至/usr/local下  cp -i /mnt/hgf

第六周 项目五-友元类_'' Only .的博客-程序员秘密

/* *Copyright (c) 2015,烟台大学计算机学院 *All rights reserved. *文件名称:time.cpp *作者:刘天恩 *完成时间:2015年4月17号 *版本号:v1.0 *问题描述:给赋初值的日期增加1秒,1秒后可能会到了下一天,乃到下一月、下一年,输出时间,格式:月/日/年 时:分:秒 *输入