几种常用排序算法总结

几种常用排序算法总结

  • 插入排序
  • 希尔排序
  • 堆排序
  • 归并排序
  • 快速排序
  • 基数排序

所有方法所属类的泛型:<E extends Comparable<? super E>>


插入排序

基本思路:将元素插入到已经排过序的序列中来完成排序。使用插入排序就需要一个有顺序的序列,所以将输入序列的第一个元素和第二个元素先进行比较,产生一个大小为2的顺序序列,然后对后面的元素进行遍历,将元素依次按照大小插入进顺序序列,最终产生一个排好序的序列。

public void sort(E[] elements) {//elements为输入序列
    int j;//①
    for (int i = 1; i < elements.length; i++) {
        E e = elements[i];//②
        for (j = i; j - 1 >= 0 && elements[j - 1].compareTo(e) > 0; j--) {
            elements[j] = elements[j - 1];//交换元素
        }
        elements[j] = e;//③
    }
}

这里在交换元素的时候,并没有使用定义一个变量然后进行三次赋值的方法来交换位置。而是先通过元素移动,将属于当前元素合适的位子空出。在内循环结束时,把当前元素放进空出的位置。
这里写图片描述

这里写图片描述
因为是在循环外面将元素放入,所以需要一个变量(①)来储存最后空出位置的索引;当前元素会被前一个元素覆盖,所以还需要一个变量(②)来储存当前元素。最后在③处将元素放入之前保存的索引的位置。


希尔排序

基本思路:使用一个增量序列,按照增量序列中的最大增量对输入序列进行分组,然后对分好组的元素进行插入排序;再使用第二大的增量对之前已经进行过分组插入的序列进行分组,然后再插入排序……直到增量为1,分组排序后,完成排序。

我对希尔排序的理解就是插入排序的升级。它的升级体现在它先对输入序列进行了分组,然后再分组后的序列进行插入排序。而分组的依据是一个增量序列。一个增量序列中有多个增量h1,h2,h3…只要最小的增量等于1就行,而增量就是分组后,两个相邻元素索引的差。
这里写图片描述
以这个序列为例,如果从第一个元素开始,增量为2的话,那么分组后的元素就是[2,5,7,4];使用相同增量,从第二个元素开始,那么分组后的元素就是[1,3,8]。而要是从第三个元素开始,那么分组后的序列是属于第一个元素开始的序列。所以这个输入序列以增量2进行分组的结果就是[2,5,7,4]和[1,3,8]。接下来的步骤就是对这两个序列进行插入排序,结果为[2,4,5,7]和[1,3,8]。
这里写图片描述
接下来在进行增量为1的分组,而增量为1的分组就是原来的序列,之后进行的排序也就是普通的插入排序。在增量1的分组排序完成后,整个希尔排序也就完成了。

public void sort(E[] elements) {
    int j;
    for (int gap = elements.length / 2; gap >= 1; gap /= 2) {
    //这里使用的增量序列是 n/2(希尔增量) ,n的初始值为输入序列的长度

        //分组完成后,进行插入排序。
        for (int i = gap; i < elements.length; i++) {
            //使用减少赋值次数的技巧
            E e = elements[i];
            for (j = i; j - gap >= 0 
               && elements[j - gap].compareTo(e) > 0; j -= gap) {
                    elements[j] = elements[j - gap];
            }
            elements[j] = e;
        }
    }
}

希尔排序之所以会比插入排序快,是因为它使用了增量进行分组的结果。在插如排序中,每次的元素交换,只会减少一个逆序(在一个排列中,如果一对数的前后位置与大小顺序相反,即前面的数大于后面的数,那么它们就称为一个逆序——百度百科。例如上面的例子中的 [2,1]就是一个逆序。)而在希尔排序中,由于增量的存在,使它每次元素交换可能会减少多个逆序。例如上面例子中的,从第一个元素开始,增量为2的序列是[2,5,7,4],4移动到正确的位置需要交换两次,而在第一个与7交换位子时,除了消除了[7,4]逆序,也消除了原序列中的[8,4]逆序。但与此同时,也产生了新的逆序[8,7]。如果使用希尔增量,它的最坏情况依然是二次时间。


堆排序

基本思路:在将输入序列构建(max)堆完成后,每次进行deleteMax操作,将返回的元素保存在原序列的末尾。堆中只剩下一个元素时,排序完成。

一开始最初的想法是将构建好的堆(用数组实现)每次deleteMin,将返回的元素保存到一个新的数组中,堆中没有元素后,新数组中的序列就是原数组排好序的样子,再将新数组拷贝进原数组。然而这个方法需要一个与原数组相同大小的新数组,空间占用会很大,不是很好。
在每次deleteMin后,堆的大小会减少1,于是自然想到将deleteMin返回的值保存到原来的数组中。然而保存的时候需要从小到大,也就是说,如果从数组开头开始保存,又想从小到大排列,就必须移动整个堆。因此这个方法也不好。
如果可以每次删除的都是最大的元素,那么就可以将返回的元素保存在数组的末尾,这样既利用了空间,也不需要堆进行移动。所以需要对堆进行改动,使每一个父亲都大于它的儿子,称之为(max)堆。原来的deleteMin操作,所对应的就是deleteMax操作。

public void sort(E[] elements) {
//堆排序的驱动函数
    for (int i = elements.length / 2 - 1; i >= 0; i--) {//① 构建堆
        percolateDown(elements,i,elements.length);
    }
    for (int i = elements.length - 1; i > 0; i--) {//② deleteMax
        E e = elements[0];
        elements[0] = elements[i];
        elements[i] = e;

        percolateDown(elements,0,i);
    }
}

第一个循环(①)是通过下滤构建一个max堆。循环的开始是elements.length / 2 - 1,这是倒数第二行的有儿子的最后一个节点的索引。输入序列以[34,24,65,12,56]例
这里写图片描述24就是循环的开始节点,而不是从65开始,因为65没有儿子,所以它不需要进行下滤操作。

void percolateDown(E[] elements, int index, int length) {//下滤函数
    int child,i;
    E e = elements[index];
    for (i = index;leftChild(i)<length;i=child) {
        child = leftChild(i);
        if (child + 1 < length && elements[child + 1].compareTo(elements[child]) > 0) {//①
            child++;
        }
        if (child < length && elements[child].compareTo(e) > 0) {
            elements[i] = elements[child];
        } else {
            break;
        }
    }
    elements[i] = e;
}


int leftChild(int index) {//用来计算左儿子索引的函数
    return 2 * index + 1;
}

下滤函数使用了和插入排序一样的方法来减少元素交换的次数。第一个判断(①)用来判断是左儿子还是右儿子大,选取大的儿子来与当前节点交换。
构建完成的堆 这里写图片描述数组中的样子
在堆构建完成之后,就要进行多次deleteMax操作来实现排序(驱动程序中的②)。这里的deleteMax使用了一个技巧,将数组中第一个元素与最后一个元素交换,然后对第一个元素进行下滤操作。调用下滤方法的参数—-堆的长度(length)必须减少1
这里写图片描述 这里写图片描述数组中的样子
一次deleteMax操作就让最大的元素位于数组的最后一位,循环的次数为输入序列的长度减一,因为最后一个在堆中的元素就是最小的元素。


归并排序

基本思路:以将两个排序好的序列合并成一个排序好的序列的算法为基础。通过递归,从两个元素开始,合并成一个长度为2的有序序列……最后将序列的排好序的前半部分和排好序的后半部分合并,完成排序。

先从合并两个有序序列开始

void merge(E[] elements,int pos1,int pos2,int end2) {
//将两个已经排序的序列合并到一个序列中
//pos1,第一个序列开始的索引;pos2,第二个序列开始的索引,end2,第二个序列结束的索引

    int end1 = pos2 - 1;                                   //第一个序列的结束索引
    int num = end2 - pos1 + 1;                             //纪录合并的元素个数

    E[] newArr = (E[]) new Comparable[num];                //合并后的数组
    int k = 0;                                             //新数组的开始索引

    while (pos1 <= end1 && pos2 <= end2) {                 //①当两个序列都没有遍历完时候
        if (elements[pos1].compareTo(elements[pos2]) < 0) {
            newArr[k++] = elements[pos1++];
        } else {
            newArr[k++] = elements[pos2++];
        }
    }
    while (pos1 <= end1 ){                                 //②只有第一个序列没有遍历完
        newArr[k++] = elements[pos1++];
    }
    while (pos2 <= end2) {                                 //③只有第二个序列没有遍历完
        newArr[k++] = elements[pos2++];
    }

    for (int i = num - 1; i >= 0; i--, end2--) {           //④拷贝回原数组
        elements[end2] = newArr[i];
    }
}

合并两个有序序列需要考虑几种情况,最先想到的就是从头开始比较两个序列的元素大小,小的元素放进新的数组,并且当前索引+1(对应①)。然而总有一个序列中的元素会先遍历完,这个时候就可以将另一个序列中的所有元素都放进新数组中(对应②,③)。当两个序列都遍历完的时候,说明合并已经完成,这个时候就可以将新数组中的元素全部拷贝进原来序列中的原本的位置(④)。由于pos1发生了改变,所以使用end2作为遍历的起始。

public void sort(E[] elements) {
//递归的驱动方法
//elements 输入序列

    sort(elements, 0, elements.length - 1);
}

void sort(E[] elements,int left,int right) {
//递归方法
//left为序列的开始索引,right为序列的结束索引

    if (right > left) {
        int center = (left + right) / 2;
        sort(elements, left, center);
        sort(elements,center+1,right);
        merge(elements, left, center + 1, right);
    }
}

只有当序列的结束索引大于序列的开始索引时,也就是序列中的元素个数大于等于2时,才会继续递归。以输入序列[3,1,7]为例,探索递归的过程。
这里写图片描述


快速排序

基本思路:选取一个枢纽元,将序列中比枢纽元小的元素放在枢纽元的左边,比枢纽元大的元素放在枢纽元的右边。再对枢纽元的左边和右边采取同样的操作,进行递归,来完成排序。

每次随机选取枢纽元,对一个序列进行排序 ,以[37,68,12,67,13]为例
这里写图片描述
然而每次都是随机选取元素并不是一个好的选择,如果随机的情况不好,可能造成分成的两个序列的大小严重失衡,从而影响效率。还以刚才的序列为例,选取枢纽元的次数为三次,而要是每次都随机选到了最小元素,那么就需要选取四次枢纽元。如果元素的个数为n,每次都选到了最小的元素,那么将会花费二次时间;而如果每次都恰好选到中间大小的元素,那么就是O(NlogN)。因此枢纽元的选择十分重要。

这里介绍一种选取策略:三数中值分割法。选取序列的头,尾和中间位置的元素,对这三个元素按升序排列,选取中间元素作为枢纽元。将枢纽元与倒数第二个元素交换位置,然后创建两个变量i,j分别从头索引+1,尾索引-2开始遍历。i在大于等于枢纽元的地方停下,j在小于等于枢纽元的地方停下。如果i在j的左边,那么交换i和j位置上的元素。如果i不在j的左边了,那么就跳出循环,将枢纽元与i位置上的元素交换位置。
这里写图片描述

E findCenter(E[] elements, int left, int right) {//获得枢纽元的方法
    int center = (left + right) / 2;

    if (elements[left].compareTo(elements[center]) > 0) {
        swap(elements,left,center);
    }
    if (elements[left].compareTo(elements[right]) > 0) {
        swap(elements, left, right);
    }
    if (elements[center].compareTo(elements[right]) > 0) {
        swap(elements, center, right);
    }                                                     //三个if来进行头尾中的排序

    swap(elements, center, right - 1);                    //将枢纽元换出需要进行交换的序列
    return elements[right - 1];
}

void swap(E[] elements, int i, int j) {//用于元素交换的方法
    E e = elements[i];
    elements[i] = elements[j];
    elements[j] = e;
}

有一种情况需要考虑,那就是当元素个数比较少的情况下,快速排序的效率不如插入排序,并且快速排序是递归的,所以总会出现排序个数比较少的情况。所以当排序元素的个数小于等于10的情况下使用插入排序。使用插入排序也可以避免出现计算三数中值只有两个元素的情况。

void sort(E[] elements, int left, int right) {  //递归函数
    if (right - left > 10) {

        E center = findCenter(elements, left, right); //获得枢纽元

        int i = left, j = right - 1;                  //②头,尾开始索引

        while (true) {
            while (elements[++i].compareTo(center) < 0) {}
            while (elements[--j].compareTo(center) > 0) {} //①
            if (i < j) {
                swap(elements, i, j);
            } else {
                break;                              //如果i,j交错就跳出循环
            }
        }

        swap(elements, i, right - 1);               //将i位置上的元素与枢纽元交换位置

        sort(elements, left, i - 1);                
        sort(elements, i + 1, right);               //递归处理枢纽元左边与右边的元素

        } else {   
        //元素个数小于等于10时使用插入排序,这里插入排序的程序就不再给出
            insertionSort(elements,left,right);
        }
    }

有一点需要特殊说明,那就是两个while循环(①)。这两个循环的目的是为了让i在大于等于枢纽元,j在小于等于枢纽元的元素上停下。这里使用++i而不是i++的原因是,如果用i++,那么当i和j都停在和枢纽元相等元素的位置上的时候,它们就会不停地交换,出现死循环。正因为使用了++i,所以开始索引的也要提前一位,原来i应该是left+1,j是right-2(②)。由于之前进行了头尾中三个元素的排序,所以就不需要限定i和j的范围了。


基数排序

基本思路:根据输入序列中的最大值来创建数组。每遍历到一个元素,就把这个元素放到数组中索引与该元素相等的位置。输入序列遍历完成后,按照顺序遍历数组,出来的就是排好序的序列。

举一个简单的例子来说明。输入序列[7,6,8,4,2,3],这个序列中最大的元素为8,那么就创建大小为9的数组,每个元素都放到与自己相同值的索引上。例如7放到索引为7的位置上,6放到索引为6的位置上…最后数组中元素的排列顺序就是完成排序后的顺序。

从上面的例子来看,排序使用的是线性时间,但是必须先知道输入序列中的最大元素。如果真的按照最大元素的值来创建数组就会带来一个很严重的问题,空间占用可能会非常大。有一个巧妙的方法可以解决这个问题,那就是分位来进行排序。从最低位开始排序,最高位排序完成后,所有的排序也就完成了。但是这样排序的速度就与序列中最长元素的长度有关。以[548,697,214,658,396]输入序列为例,探索基数排序的过程。
这里写图片描述
在每个位上会有相同的数字,这个时候就要求数组中每个单元都可以存放多个元素,将数组中的单元称为桶。从个位开始,按照个位上的数字大小放入数组中相应的桶中,元素都放进数组中后,再将数组中的元素全部放回序列,这个时候序列中的元素就是按照个位排序的;再按照十位上的数字大小放入数组中相应的桶中,在倒回序列的时候,序列中的元素就是按照十位排序,并且当十位上的数字相同时按个位排序。百位上也是相同,最后的序列就是正确排序的序列。

现在想要实现的是,不同长度字符串的排序。首先要了解字符串的排序规则,查看String类中的compareTo()方法:

public int compareTo(String anotherString) {
    int len1 = value.length;
    int len2 = anotherString.value.length;
    int lim = Math.min(len1, len2);
    char v1[] = value;
    char v2[] = anotherString.value;

    int k = 0;
    while (k < lim) { //①
        char c1 = v1[k];
        char c2 = v2[k];
        if (c1 != c2) {
            return c1 - c2;
        }
        k++;
    }
    return len1 - len2;
}

从这个循环(①)中可以发现,第一位上的元素大的就直接返回,要是相对的短的字符串上所有位上的元素都与相比较的字符串相同,才会去比较长度。也就是说在[“f”,”abc”,”abcc”]这个序列中,排序结果应该是[“abc”,”abcc”,”f”]。

public void sort(String[] elements, int maxLength) {
//elements为输入序列,maxLength为序列中最长元素的长度

    //用ArrayList数组来实现桶数组
    ArrayList<String>[] wordByLength = new ArrayList[maxLength + 1];//按照长度分的桶
    ArrayList<String>[] buckets = new ArrayList[256];             //按照字符串位上字符ASCII码值来分的桶

    for (int i = 0; i < wordByLength.length; i++) {
        wordByLength[i] = new ArrayList<>();
    }

    for (int i = 0; i < buckets.length; i++) {
        buckets[i] = new ArrayList<>();
    }                                             //将两个数组都初始化

    for (String s : elements) {
        wordByLength[s.length()].add(s);
    }                                             //将输入序列中的元素按照长度放到相应的桶中

    int index = 0;
    for (ArrayList<String> list : wordByLength) {
        for (String s : list) {
            elements[index++] = s;
        }
    }                                             //将所有桶中的元素倒回序列中,此时序列就是按照长度排序的

    int startIndex = elements.length;
    for (int pos = maxLength - 1; pos >= 0; pos--) {  //①
        startIndex -= wordByLength[pos + 1].size();

        for (int i = startIndex; i < elements.length; i++) {
            buckets[elements[i].charAt(pos)].add(elements[i]);
        }                                        //将元素放入桶中

        index = startIndex;
        for (ArrayList<String> list : buckets) { //将元素倒回序列
            for (String s : list) {
                elements[index++] = s;
            }
            list.clear();
        }
    }
}

循环(①)需要仔细考虑。他的目的是将序列中的元素按照位上的字符的ASCII码来放进相应的桶。从之前了解到的字符串排序规则中确定:循环的开始索引是最后一位,也就是maxLength-1。然而这会带来一个问题,并不是所有的字符串都有maxLength那么长,要是强行使用charAt取出字符就会抛出异常。所以循环内部有定义了一个变量:startIndex,这个变量确保了当前pos肯定在字符串的内部,也就是说不会抛出异常。startIndex -= wordByLength[pos + 1].size();这句代码就是关键,先通过按长度分的桶数组,取出当前长度(pos+1)元素的个数,然后修改startIndex的值,使从startIndex到element.length-1的元素的长度都大于等于pos。将元素按照位上的字符ASCII码放入桶中后,在倒回序列,中间有一个步骤不能遗漏,那就是必须将桶清空。当第一个索引遍历也完成后,基数排序也就完成了。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值