数据结构与算法
排序Sort

其中:
一轮结束后能够确定一个元素的最终位置的排序方法有是 简单选择排序、快速排序、冒泡排序、堆排序。
1. 冒泡排序BubbleSort
冒泡排序思路:
每次遍历数组,都会比较相邻元素的大小,如果满足条件,就把两个元素交换位置。
这样,每遍历一遍数组,都会把最大或最小的元素放在数组尾部,就像气泡上浮一样。
显然,如果有
m个数,则最多要遍历m-1次。
优化:
在一轮遍历中,我们无需再比较最后一个数,所以每轮遍历要比较
m-1次而在一轮遍历中,我们还无需再比较前几轮遍历产生的“尾泡”
- 如果遍历
n轮,就会产生n个“尾泡”,所以,在此轮遍历中只需要比较m-1-n次
- 如果遍历
如果在某轮遍历中,发现遍历的数组依然有序,这时可以不再进行下轮遍历
- 我们只需要加一个,标志数组是否发生过交换的变量,根据变量判断数组是否已经有序
实现很简单,如:
1 | package Sort; |
2. 快速排序QuickSort
快速排序采用了分而治之的思想:
- 通过一个基准数
pivot来把整个数组分成两部分,一部分全大于pivot,一部分全小于pivot,然后把pivot放在这两部分中间,这样就找到了pivot的正确位置。 - 然后再对每个部分再次进行同样的分割,通常使用递归来实现,一直到不能再分割为止。
- 这样分割完成后,就得到一个有序的数列。
这里,我们通过两个指针来对数组array进行分割:
- 取数组最后一个数为基准数
pivot,其下标为pivotIndex - 设一个快指针
quickP,它会遍历数组从左端到pivot之前的所有数,不包括pivot - 设一个慢指针
slowP,它只在快指针发现当前数小于基准数时移动1次,慢指针从数组左端-1开始。 - 慢指针
slowP移动后,会把array[slowP]与array[quickP]交换 - 当快指针
quickP遍历完成后,交换array[slowP+1]与array[pivotIndex],这样就把pivot放在了正确的位置。 - 可以看这个视频,能够方便理解:图解快速排序,也可以直接看代码,我注释很多
java实现
1 | package Sort; |
3. 选择排序SelectionSort
选择排序每轮遍历数组,会把数组中的最值放在数组头部或尾部,即每轮都会找到一个最小的数或最大的数。
显然,选择排序与冒泡排序思路类似,都是每轮遍历找到一个最值,只是交换的方式有区别。
我们只需关注这一点即可:
- 冒泡排序是依次比较,把符合条件的两个相邻元素交换位置,这样把最值交换到末尾
- 选择排序,是假定一个数是最值,然后依次与数组中的数据比较,让满足条件的数替换这个假定的数,一轮遍历完成后,再让它与当前数组尾或头部的数交换
很简单,不需要细说。
java实现
如:
1 | package Sort; |
4. 插入排序InsertionSort
插入排序把数组分为两个部分,一部分视为有序,初始只有一个元素;另一部分是无序,包含其他的数组元素
在每轮遍历时,取出无序部分的数,经过比较后插入到有序部分中,使有序部分仍然有序。
随着每轮的遍历,无序部分的数被插入到有序部分中,最终,只剩下一个有序的数组。
主要的难点在于如何找到有序部分的插入位置,并将有序的一部分数后移,先看第一种实现:
java实现①
1 | public int[] insertSort(int[] array) { |
这里使用3个for循环嵌套,显然,是非常耗费性能的。我们对有序数后移这部分,进行优化:
- 我们可以在比较大小时,就把有序数后移
java实现②
1 | //优化 |
5. 希尔排序ShellSort
希尔排序是插入排序的一个更高效的改进版本,又称缩小增量排序
排序流程示意图:

它事先以一个可变增量对数据分组,然后在对每组进行插入排序。
第一次分组:增量为
arrary.length/2第二次分组:增量为
arrary.length/4第n次分组:增量为
arrary.length/2n
这里增量可以看作两个数下标的差值,直到增量为1时,此时只有一组数据,不再分组。
tips:
增量可以自定,只要保证增量能递减,并且最后的值是1就可以。
所以为了避免分组后的数据仍然有序的最坏情况,我们可以使用:
Hibbard增量序列:第n个增量值=2的n次方-1
或者Sedgewick增量序列,可以处理上万的数据。
这是比较简单的算法,我们只需要在分组后使用插入排序即可:
图解,对照代码来理解,ABC为3个分组:

java实现
1 | //排序1 |
另外一种思路:
- 这是增量
gap的for循环:
1 | for (int gap = array.length / 2; gap >= 1; gap = gap / 2) {} |
- 会得到
gap个分组
1 | //比如array[1]和array[1+gap]、array[1+2*gap]是一组的 |
- 遍历每个分组
1 | for (int j = i + gap; j < array.length; j = j + gap) {} |
完整代码
1 | public void shellSort(int[] array) { |
6. 归并排序Mergesort
归并排序的核心思想是:
- 把两个有序数组(实际上,这两个有序数组其实是整个数组的一部分),通过第三个
temp数组,合并为新的有序数组。
而如何获得这两个有序数组呢?
- 将待排的无序数组不停拆分,直到不可再拆,这样就得到一个个只包含1个元素的数组,我们可以把它视为有序数组。
显然,归并排序也使用了分而治之的思想。
归并的图解:

具体实现:
- 先看两个有序数组的合并
①设两个指针i与j来遍历各自的数组部分,下图用绿色和红色来表示两个部分,它们的初始位置都在各自部分的头部。依次比较i与j对应的值,把较小的一方放入一个temp临时数组,当指针指向的值已经被放入temp后,对应指针后移,temp的指针也后移,以此类推:

②当绿色或者红色部分有一方的数已经全放入了temp,那么把另一部分的数一次性全放入temp中,如此,这两部分的数就已经全放入到temp中了,最后,把temp中的数再复制回去,覆盖掉,这样就结束了本次合并,得到了一个有序数组。

- 拆分阶段较为简单,只需要直到每次要拆分的数组的左端点
left和右端点right,就可以得到中间点pivot,这样会得到[left,pivot]和[pivot+1,right]这两部分数组,然后把对应数组再次拆分即可
代码实现1
1 | package Sort; |
上个代码中,使用了temp作为临时数组,最后每次归并又把temp写回了array,我们可以调整一下思路,能不能把要合并的两部分作为新数组,然后直接与array进行交换呢?
- 这样似乎并不能优化什么,不过我觉得这样好理解一些。
代码实现2
1 | public void merge2(int[] array, int left, int pivot, int right) { |
其中,合并部分还可以这样写:
1 | public void merge2_1(int[] array, int left, int pivot, int right) { |
7. 基数排序RadixSort
基数排序是分配式的,它额外使用多个数组来存放对象,当数据量较大时,就会占用较高的内存。
核心思想:
- 借助10个数组(也可称为桶),分别把待排数据的个位、十位、百位….排成有序,直到最高位排完,最终便得到1个有序数组
流程:
- 第一轮按照对象的个位数值,分配给对应数组,比如12分配给2号数组,200分配给0号数组,分配完毕后,将数组里的数据再依次覆盖进原数组中。
- 第二轮和上一轮类似,这次是按照十位数值
- 第三轮按照百位数值
- 直到最高位数排完为止。
我们可以使用一个二维数组表示这10个数组,不过要知道,实际上java是没有二维数组的,java的二维数组是由一维数组组成的。
- 设待排数组为
array,使用int[10][array.length]表示10个数组(桶)
那么如何知道我们需要进行多少轮呢,这与原数组的最大数的位数有关
- 先对原数组进行一轮遍历,找到最大的数,假设是123,
(123+"").length这样就可以巧妙的拿到一个数字的位数 - 当然,你也可以像我代码中那样,设一个布尔值的标识,使它在原数组的数据全被分入一个桶中时变化,从而停止循环。
如何拿到当前数的当前位的值
- 可以这样:
int cur=(value / (n * 10)) % 10;从而拿到value个位十位…的值,n初始为1,每轮分配后变为原来的10倍。
如何表示表示这10个数组(桶)、及其指针
- 桶是按照数据的个位、十位、百位…的值(设为
cur)来分配数据的,这正是设计10个桶的原因,所以cur对应为桶的下标 - 我们用一个二维数组来表示这10个桶:
int[][] buckets = new int[10][]; - 使用一个一维数组表示这10个桶的指针:
int[] bucketPs = {-1, -1, -1, -1, -1, -1, -1, -1, -1, -1};,下标刚好对应这10个桶,10个指针的初始值是-1,表示桶中没有数据 - 那么,
bucketPs[cur]是第cur号桶的指针,buckets[cur][bucketPs[cur]]就能表示当前数据应该存入的桶及其指针位置
✭如何保证上次排序后的结果在下轮分配排序时仍然有序?
- 在把桶内数据写回原数组时,我们应该从头部开始,按照当时放入桶的顺序,对桶遍历,如果从尾部倒序遍历,就会逆序上次的排序结果。
- 可以将桶的指针的值通过
temp临时保存,然后让桶指针从头开始,遍历到temp,结束一轮写回后,记得把桶指针置为初始状态。 - 也可以使用栈,利用栈把桶内数据再次逆序输出。
- 你也可以直接做个for循环,从0遍历到桶指针。
java实现
1 | package Sort; |
输出结果
1 | [1, 89, 131, 312, 525, 732, 2342, 4987, 6453] |
8.堆排序Heapsort
堆排序需要堆这种数据结构,
堆的概念
由于我之前的文章并没有讲到树和堆的数据结构,所以在这里说一下主要的概念:
二叉树大家应该都知道,而堆和树关系密切,堆需要满足以下两个条件:
- 满足完全二叉树
- 每个父结点都大于或都小于子结点
什么是完全二叉树
看下几个图就明白了:
这个是完全二叉树:

这个也是完全二叉树:

这个不是完全二叉树:

堆的两种分类
注意父结点与子结点值之间的关系:
大顶堆(大根堆)

小顶堆(小根堆)

构建堆
在这之前,要说明一下堆的数组存储
堆的数组存储
堆的数组存储就是树的数组存储,从父结点的位置0开始,依次为结点编号,从左到右,从上到下,然后将树用数组的形式表示即可,每个数组元素对应该位置编号的节点,如上图所示,array[8]==45,array[0]==10。
设index为从0开始的位置编号,对应数组下标,则一个index为i的节点,它与父结点、子结点的关系,将满足以下条件:
- 父结点:
parentNode=(i-1)/2 - 左子结点:
childNodeLeft=2i+1 - 右子结点:
childNodeRight=2i+2
堆化逻辑
将一个无序二叉树,转为大顶堆或小顶堆的过程叫做堆化(heapify)
依次将两个子结点和父结点进行比较,每次比较,都选出一个较大的结点,与父结点交换位置,便可以找出最大的结点作为新的父结点。对每个子树都做这种处理后,就可以形成一个大顶堆;反之选较小的节点,便会形成小顶堆
对一个节点所在的子树做heapify的时候,必须保证此节点的所有子树都已经是堆,一般从二叉树的倒数第二层开始做heapify,从最后一个非叶子节点开始,具体为:
1 | //数组形式存储的树 |
堆化的代码实现
仔细看注释
java
1 | package Sort; |
排序
在弄明白了堆化之后,堆排序就会变得非常之简单。
堆排序的做法是:
将堆的根节点与最后一个结点交换位置,这样最后一个节点就是堆的最值,随后把最后一个结点剔除堆,然后再对被打乱的堆进行一次堆化,如此循环,依次被剔除堆的结点就会排成一个有序的队列。
如何表示结点被剔除了堆呢?
我们可以每轮循环时移出堆中要被剔除的元素,但有个更好的方法:
使用一个常数n表示堆中结点的总数,而n恰好决定了每轮堆化最后一个非叶子节点,我们只需要每轮让n-1,就能做出剔除最后一个节点的效果,这样做完后,不需要额外的空间,就能完成堆排序。
由于第一次堆化后已经是一个堆了,所以把第一次堆化单独进行,然后循环对每轮的二叉树根结点,调用heapify方法即可,该方法会堆化传入index的结点的子树,并且递归堆化被上一次堆化影响的子树。
堆排序代码实现
注意,增加了参数n来表示节点数量,变动了原来的一些堆化代码
java完整代码
1 | package Sort; |
输出
1 | D:\java\bin\java.exe "-javaagent:D:\idea\IntelliJ IDEA |
写在最后
堆排序到此便总结完成~!
可喜可贺,断断续续的写了很长时间。虽然现在回过头来看,当时代码写得很不成熟,不过也能看到一些进步,回顾完这些数据结构和常见算法后,便能开始leetCode无尽地域刷题之旅了。到时候也会更新leetCode的内容。
反思
正好在这时,看到一个比较引发思考的文章,这是我在学习Go语言时看到的,原文:https://www.imooc.com/article/19963
二分查找,这是一个看似简单实则很难的问题,因为需要考虑的事情很多,一段二分查找的代码大概率的就会出现层出不穷的bug:
D.Knuth:
Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky即使二分查找的思想相对来说很直接简单,但具体实现起来有特别多的注意点。
比如,据说Java库里面的二分查找,曾经有一个埋藏了10年才被修复的bug:
- 当时java库二分查找的源码
1 | public static int binarySearch(int[] a, int key) { |
int mid = (low + high) / 2;中,low + high 是会溢出的
修复后的写法:
int mid = (low + high) >>> 1;三个>表示无符号位移的意思。
这样或许也可行
mid = low + (high - low) / 2
写二分算法时,应该多考虑以下几点:
ccmouse:
差1错误。我们的左端点应该是当前可能区间的最小范围,那么右端点是最大范围呢,还是最大范围+1呢。我们取了中间值之后,在缩小区间时,有没有保持左右端点的这个假设的一致性呢?
死循环。我们做的是整数运算,整除2了之后,对于奇数和偶数的行为还不一样,很有可能有些情况下我们并没有减小取值范围,而形成死循环。
退出条件。到底什么时候我们才觉得我们找不到呢?
请让自己的代码出现尽量少的bug吧