经典排序算法
算法的性质:
稳定:如果a原本在b前面,且a == b,排序后a仍然在b前面
不稳定:如果a原本在b前面,且a == b,排序后a可能在b后面
内排序:所有排序操作都在内存中进行
外排序:由于数据太大,因此把数据放在磁盘中,排序通过磁盘和内存的数据传输才能进行
判断算法优劣的两个重要标准:
时间复杂度
空间复杂度:我的理解就是暂用内存与问题规模的关系,与问题规模没关系的,就是1。以下是摘抄来的一段说明,姑且贴在这里:
算法的空间复杂度并不是计算实际占用的空间,而是计算整个算法的辅助空间单元的个数,与问题的规模没有关系。算法的空间复杂度S(n)定义为该算法所耗费空间的数量级。
S(n)=O(f(n)) 若算法执行时所需要的辅助空间相对于输入数据量n而言是一个常数,则称这个算法的辅助空间为O(1);
递归算法的空间复杂度:递归深度N*每次递归所要的辅助空间, 如果每次递归所需的辅助空间是常数,则递归的空间复杂度是 O(N).
进一步分析:
两段算法串联在一起的复杂度 T 1 ( n ) + T 2 ( n ) = m a x ( O ( f 1 ( n ) ) + O ( f 2 ( n ) ) ) T_1(n) + T_2(n) = max( O(f_1(n)) + O(f_2(n)) ) T1(n)+T2(n)=max(O(f1(n))+O(f2(n)))
即比较慢的那个算法决定了串联后的效率。两段算法嵌套在一起的复杂度 T 1 ( n ) ∗ T 2 ( n ) = O ( f 1 ( n ) ∗ f 2 ( n ) ) T_1(n) * T_2(n) = O( f_1(n) * f_2(n) ) T1(n)∗T2(n)=O(f1(n)∗f2(n))
if - else 结构的复杂度取决于if条就爱你判断复杂度和两个分枝部分的复杂度,总体复杂度取三者中最大。
一、 冒泡排序(Bubble Sort)
public void BubbleSort(int[] arr){
for(int i=0;i<arr.length-1;i++){
for(int j=0;j<arr.length-1-i;j++){
if(arr[j]>arr[j+1]){
//相邻两个元素作比较,如果前面元素大于后面,进行交换
int temp = arr[j+1];
arr[j+1] = arr[j];
arr[j] = temp;
}
}
}
}
我的理解:
冒泡排序的核心,是 “泡”。也就是它会尝试把每一个值都当作大的气泡,要将他冒到数组尾部。
可以想象最差的情况,一个严格单调递减的数组,比如
9876543210
9 8 7 6 5 4 3 2 1 0
9876543210 ,先冒9,9跟8比较,交换,9跟7比较,交换。。。一直到9跟0比较,交换、结束9的冒泡。 此刻数组为
8765432109
8 7 6 5 4 3 2 1 0 9
8765432109。再冒8,8跟7比较,交换,跟6比较,交换。。。跟0比较,交换,注意,最后一位是9,不用比较了,因为9已经和8比较过了,这也就是为什么内层循环里,
j
<
a
r
r
a
y
.
l
e
n
g
t
h
–
1
−
i
j < array.length – 1 -i
j<array.length–1−i的原因。这样每次都只是把问题的规模减小1,效率较低。可以看到,这里的实现比较相邻两个值用的是小于,因此是稳定的,它不会把相等的值也交换位置。
二、 选择排序(Selection Sort)
public static int[] selectionSort(int[] array) {
if (array.length == 0)
return array;
for (int i = 0; i < array.length; i++) {
int minIndex = i;
for (int j = i; j < array.length; j++) {
if (array[j] < array[minIndex])
minIndex = j; //将最小数的索引保存,下一次for循环就是跟新的最小值比较了
}
//交换无序区中最小值和i,这样[0,i)就是有序的了,外层for循环把i不断向右推
int temp = array[minIndex];
array[minIndex] = array[i];
array[i] = temp;
}
return array;
}
我的理解:
选择排序的原理很简单,把数组看成左右两部分,即有序区、无序区,每次都从无序区选择最小的一个值,将它加入到有序区右边,也就是无序区最左侧的值和无序区的最小值交换。代码里的双层循环,第一层是把有序区向右扩展,第二层则是在无序区中选出最值。有序区和无序区的分解线为i,即 [0 , i)和[I , length – 1],i从零开始。
三、 插入排序(Insertion Sort)
public static int[] insertionSort(int[] array) {
if (array.length == 0)
return array;
int current;
for (int i = 0; i < array.length - 1; i++) {
current = array[i + 1];
int preIndex = i;
//注意,为了插入,需要把比current大的值后移。不用担心current的值,他已经被保留了
while (preIndex >= 0 && current < array[preIndex]) {
array[preIndex + 1] = array[preIndex];
preIndex--;
}
//将current的值放在preIndex后面,也就是放在那个比current小的值后面
array[preIndex + 1] = current;
}
return array;
}
我的理解:
插入排序其实和选择排序很像,即将整个数组分为两个部分,有序区、无序区。将无序区第一个元素i+1插入到有序区内,其插入方式是倒序遍历有序区。倒序终止的条件是下标越界或者已经找到合适位置(元素 i + 1大于等于,这个等于值得注意,它前面的值) 。当有序区的长度为length – 1,而无序区仅仅一个元素时,可以看出,最后一个无序元素会倒着冒泡到合适的位置去。
插入排序中的KaTeX parse error: Expected 'EOF', got '&' at position 15: preIndex >= 0 &̲& current < arr…这一句,这么写是因为i是从0开始的,有序区一定是有序的,这句话是废话。
以上三种排序算法,时间复杂度都是n^2 ,他们每一次比较都需要遍历无序的元素来比较,每次把无序的元素数量减一,效率较低
四、 希尔排序
public static int[] ShellSort(int[] array) {
int len = array.length;
int temp, gap = len / 2;
while (gap > 0) {
for (int i = gap; i < len; i++) {
temp = array[i];
int preIndex = i - gap;
//这里我一开始比较疑惑,因为觉得可能回出现元素值为3 1 2的情况,“有序区”不是有序的,无法一次插入得到有序序列.实际上,当gap=1
//的时候,就是有“序区会”一定有序的插入排序,最终也可以得到有序序列。此外,我觉得希尔排序的优势,就在于gap较大时,可能避免gap
//较小时的交换操作
while (preIndex >= 0 && temp < array[preIndex] ) {
array[preIndex + gap] = array[preIndex];
preIndex -= gap;
}
array[preIndex + gap] = temp;
}
gap /= 2;
}
return array;
}
另一种较好理解的写法
public void Shellsort(int[] arr){
int N = arr.length;
//进行分组,最开始时的增量(gap)为数组长度的一半
for(int gap = N/2 ; gap > 0 ; gap/=2){
//将各个分组进行插入排序
for(int i = gap ; i<N ;i++){
//将arr[i]插入到所在分组的正确位置上
insert(arr,gap,i);
}
}
}
private void insert(int[] arr, int gap, int i) {
int inserted = arr[i];
int j ;
//插入的时候按组进行插入(组内元素两两相隔gap)
for(j = i-gap ; j>=0 && inserted <arr[j] ; j-=gap){
arr[j+gap] = arr[j];
}
arr[j+gap] = inserted;
}
我的理解:
希尔排序我更愿意称之为逐步精确比较排序算法。它先对间隔较大的值两两比较,然后再缩小间隔继续两两比较,不断缩小到间隔为0,被比较的值紧挨着,也就是gap为1的时候。
主要是两个操作,一是依靠gap分组,而是把每个组进行插入排序。注意,对于某一次调用插入排序,这里的插入排序很可能是不彻底的,甚至一次都没有进行插入。 我们回顾一下 三、插入排序,很明显是从有序区仅仅只有一个元素开始,for循环不断的把有序区扩大,
希尔排序最重要的是这个gap。gap在较大时,将原数组分割成的数组的规模都比较小,这个时候插入,针对某一个下标而言,只是相当于此下标和间隔较大的另一个下标上的元素有序,而当gap小的时候,看似比较的范围可能会缩小,但是对原数组而言,较大范围的数据已经比较过了,因此它们之间不必,也不会再去比较交换了。当gap等于1,其实就是正常的插入排序,只不过这个时候,我们得到的数组已经是极其有序的了
i
n
s
e
r
t
e
d
<
a
r
r
[
j
]
inserted <arr[j]
inserted<arr[j]会减少大量的比较操作。
五、归并排序(Merge Sort)
经典的递归写法:
public static int[] mergeSort(int[] arr, int left, int right) {
// 如果 left == right,表示数组只有一个元素,则不用递归排序
if (left < right) {
// 把大的数组分隔成两个数组
int mid = (left + right) / 2;
// 对左半部分进行排序
arr = mergeSort(arr, left, mid);
// 对右半部分进行排序
arr = mergeSort(arr, mid + 1, right);
//进行合并
merge(arr, left, mid, right);
}
return arr;
}
//其实merge的过程也是比较的过程
// 合并函数,把两个有序的数组合并起来
// arr[left..mid]表示一个数组,arr[mid+1 .. right]表示一个数组
private static void merge(int[] arr, int left, int mid, int right) {
//先用一个临时数组把他们合并汇总起来
int[] a = new int[right - left + 1];
int i = left;
int j = mid + 1;
int k = 0;
//i++的值是i,但是i随后自加1,即int i = 0; temp = i++; 此时temp = 0, i = 1;
while (i <= mid && j <= right) {
if (arr[i] < arr[j]) {
//下表达式式相当于
//a[k] = arr[i];
// k++;
//i++;
a[k++] = arr[i++];
} else {
a[k++] = arr[j++];
}
}
while(i <= mid) a[k++] = arr[i++];
while(j <= right) a[k++] = arr[j++];
// 把临时数组复制到原数组
for (i = 0; i < k; i++) {
arr[left++] = a[i];
}
}
我的理解:
归并排序最重要的使使用了递归,当然也有非递归的写法,但是其思想是递归。 递归的话注意要有终止条件。合并函数里临时数组的复制也没什么好说的。
递归式写法的空间复杂度是多少呢?元素为n个,则对半分k次递归结束的话,则
2
k
=
n
2^k = n
2k=n, 即可得到
k
=
log
2
n
k = \log_2^{n}
k=log2n,而每次需要的辅助空间,也就是JVM内存模型中的虚拟机栈,都是一个常数,与问题规模n无关,再加上为临时数组开辟的空间,因此空间复杂度就是
n
+
log
2
n
n + \log_2^{n}
n+log2n,也就是n。
时间复杂度为
n
log
2
n
n \log_2^{n}
nlog2n
附一个非递归的写法,仍然利用的原来的合并函数
// 非递归式的归并排序
public static int[] mergeSort(int[] arr) {
int n = arr.length;
// 子数组的大小分别为1,2,4,8...
// 刚开始合并的数组大小是1,接着是2,接着4....
for (int i = 1; i < n; i += i) {
//进行数组进行划分
int left = 0;
int mid = left + i - 1;
int right = mid + i;
//进行合并,对数组大小为 i 的数组进行两两合并
while (right < n) {
// 合并函数和递归式的合并函数一样
merge(arr, left, mid, right);
left = right + 1;
mid = left + i - 1;
right = mid + i;
}
// 还有一些被遗漏的数组没合并,千万别忘了
// 因为不可能每个字数组的大小都刚好为 i
if (left < n && mid < n) {
merge(arr, left, mid, n - 1);
}
}
return arr;
}
六、快速排序(Quick Sort)
public static int[] quickSort(int[] arr, int left, int right) {
if (left < right) {
//获取中轴元素所处的位置
int mid = partition(arr, left, right);
//进行分割
arr = quickSort(arr, left, mid - 1);
arr = quickSort(arr, mid + 1, right);
}
return arr;
}
//这个函数咋一看看不明白,其实目的就是选出一个元素,将数组以此元素为界分为两半,返回此元素下标
private static int partition(int[] arr, int left, int right) {
//选取中轴元素
//选取第一个元素,即arr[left]为中轴元素,此刻的i、j是为了给找到的小于和大于中轴元素大小的元素确定下标
int pivot = arr[left];
int i = left + 1;
int j = right;
while (true) {
// 当while循环结束,必然不再满足条件,后面又用i >= j 来,可以看出这里其实是要找arr[i] > pivot的i,根据这个i来交换
//只要第一次不满足条件,while循环就结束,所以每次都是遇到一个大于pivot的值就记录其下标i
while (i <= j && arr[i] <= pivot) i++;
// 同上,找到arr[j] < pivot,记录元素位置j
while(i <= j && arr[j] >= pivot ) j--;
//当break的时候,i、j已经相逢过了,也就是遍历了所有元素了
if(i >= j)
break;
//交换前,arr[j] < pivot < arr[i],交换后,arr[i] < pivot < arr[j]
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
//通过while循环,可以知道,数组其实已经是分成了三个部分,即pivot(也就是arr[left])、 {小于pivot的元素}、 {大于pivot的元素},
//我们只要把中轴元素放到合适的位置就可以了,我们的j初始值为right,每次找到一个小于pivot的元素,j都自减一次,while循环遍历所
//有元素结束时, i >= j,也就是j为 {小于pivot的元素}的最右边的下标,交换arr[j]和arr[left]即可
//交换后,数组元素的三个部分为{小于pivot的元素}、pivot 、{大于pivot的元素}
arr[left] = arr[j];
// 使中轴元素处于有序的位置
arr[j] = pivot;
return j;
}
通常,快速排序被认为是,所有同数量级(nlogn)的排序方法中,平均性能最好的一个。但是,如果原始数据比较有序,快速排序将快速蜕化为冒泡排序。
我的理解:快速排序最重要的就是分区操作,也就是把基准放到合适的位置。这一部分的写法可以根据数组元素的特性,选择不同的元素作为基准,比如,如果说一个数组的基准更可能出现在中间位置,那可以选择(left + right)/ 2 为pivot,而不是left。
七、堆排序(Heap Sort)
大顶堆:首先,堆是顺序存储的完全二叉树,大顶堆,顾名思义,其根节点的关键字大于其子节点。
//堆排序
public static int[] heapSort(int[] arr, int length) {
//构建二叉堆
for (int i = (length - 2) / 2; i >= 0; i--) {
arr = downAdjust(arr, i, length);
}
//进行堆排序
for (int i = length - 1; i >= 1; i--) {
//把堆顶的元素与最后一个元素交换
int temp = arr[i];
arr[i] = arr[0];
arr[0] = temp;
//下沉调整
arr = downAdjust(arr, 0, i);
}
return arr;
}
/**
* 下沉操作,执行删除操作相当于把最后
* * 一个元素赋给根元素之后,然后对根元素执行下沉操作
* @param arr
* @param parent 要下沉元素的下标
* @param length 数组长度
*/
public static int[] downAdjust(int[] arr, int parent, int length) {
//临时保证要下沉的元素
int temp = arr[parent];
//定位左孩子节点位置
int child = 2 * parent + 1;
//开始下沉
while (child < length) {
//左与右孰大?
if (child + 1 < length && arr[child] > arr[child + 1]) {
child++;
}
//如果父节点比孩子节点小或等于,则下沉结束
if (temp <= arr[child])
//break而不是continue,是因为我们构造堆时是从最后一个非叶子节点开始倒序处理的
break;
//单向赋值
arr[parent] = arr[child];
parent = child;
child = 2 * parent + 1;
}
arr[parent] = temp;
return arr;
}
我的理解:堆排序是目前学习的排序里面最复杂的一个。我觉得它体现了数据结构和算法之间的紧密联系。
如何从数组构造二叉堆呢?其实这是一个约定的惯例,即从root开始,逐层列出所有元素。例如数组为{0,1,2,3,4,5,6,7,8,9},那么堆就是
代码中有
i
=
(
l
e
n
g
t
h
−
2
)
/
2
i = (length - 2) / 2
i=(length−2)/2,其实写成
l
e
n
g
t
h
/
2
−
1
length / 2 - 1
length/2−1看起来更简单一点,这里我们算一下很明显是4,恰好是最后一个非叶子节点,从这个下标开始,每次减一,得到的都是另一个非叶子节点。对非叶子节点进行下沉操作,也就是比较其与孩子节点的大小,必要时交换以确保非叶子节点的值是最大的(或最小的)。如此就可以得到一个二叉堆。我们看一下这个二叉树每一层的第一个节点,0,1,3,7,很明显是
2
n
−
1
2^n - 1
2n−1,n从0开始,知道这一点很重要。
如果我们排序的目的是要得到升序序列,那么需要构造大顶堆,如果是要得到降序序列,需要构造小顶堆。得到合适的堆后,将堆顶元素和最后一个元素交换(其实就是把最值移到有序区)。
完成堆排序需要两个部分,即前面说的构造堆,以及将堆顶元素与堆(数组)末尾元素的交换。这两各部分的核心都是downAdjust函数。每一次将堆顶元素处理完后,堆的规模减一,在downAdjust内,在while循环内不断的调整下沉,确保堆的性质没有被破坏,因此堆顶元素仍然会是堆内的最值,将这个堆顶元素再与堆末尾元素交换,直到所有元素都被排序。
八、计数排序(Counting Sort)
public static int[] sort(int[] arr) {
if(arr == null || arr.length < 2) return arr;
int n = arr.length;
int min = arr[0];
int max = arr[0];
// 寻找数组的最大值与最小值
for (int i = 1; i < n; i++) {
if(max < arr[i])
max = arr[i];
if(min > arr[i])
min = arr[i];
}
int d = max - min + 1;
//创建大小为max的临时数组
int[] temp = new int[d];
//统计元素i出现的次数
for (int i = 0; i < n; i++) {
temp[arr[i] - min]++;
}
int k = 0;
//把临时数组统计好的数据汇总到原数组
for (int i = 0; i < d; i++) {
for (int j = temp[i]; j > 0; j--) {
arr[k++] = i + min;
}
}
return arr;
}
我的理解:计数排序,所谓计数,计的是各个大小的元素的数量,最后按顺序把这些值赋给原数组即可。计数排序不用比较,实际上也没有交换,但是需要数据在一定范围内
九、桶排序(Bucket Sort)
public static int[] BucketSort(int[] arr) {
if(arr == null || arr.length < 2) return arr;
int n = arr.length;
int max = arr[0];
int min = arr[0];
// 寻找数组的最大值与最小值
for (int i = 1; i < n; i++) {
if(min > arr[i])
min = arr[i];
if(max < arr[i])
max = arr[i];
}
//和优化版本的计数排序一样,弄一个大小为 min 的偏移值
int d = max - min;
//创建 d / 5 + 1 个桶,第 i 桶存放 5*i ~ 5*i+5-1范围的数
int bucketNum = d / 5 + 1;
ArrayList<LinkedList<Integer>> bucketList = new ArrayList<>(bucketNum);
//初始化桶
for (int i = 0; i < bucketNum; i++) {
bucketList.add(new LinkedList<Integer>());
}
//遍历原数组,将每个元素放入桶中
for (int i = 0; i < n; i++) {
bucketList.get((arr[i]-min)/d).add(arr[i] - min);
}
//对桶内的元素进行排序,我这里采用系统自带的排序工具
for (int i = 0; i < bucketNum; i++) {
Collections.sort(bucketList.get(i));
}
//把每个桶排序好的数据进行合并汇总放回原数组
int k = 0;
for (int i = 0; i < bucketNum; i++) {
for (Integer t : bucketList.get(i)) {
arr[k++] = t + min;
}
}
return arr;
}
我的理解:桶排序的核心在于桶。分桶的操作其实和计数排序类似,bucketList.get((arr[i]-min)/d).add(arr[i] - min),这个get操作,就是根据元素的大小来找到合适的桶。分桶这个操作可以避免排序操作,因为它本身就是利用数据的大小来分桶的。桶越多,每个桶里的元素就越少越容易排序,但是占据内存越多(其实这一点我还不理解,是不是因为有空桶?)。假设每个元素都有一个桶,那自然就完成了排序。
十、基数排序(Radix Sort)
public static int[] radioSort(int[] arr) {
if(arr == null || arr.length < 2) return arr;
int n = arr.length;
int max = arr[0];
// 找出最大值
for (int i = 1; i < n; i++) {
if(max < arr[i]) max = arr[i];
}
// 计算最大值是几位数
int num = 1;
while (max / 10 > 0) {
num++;
max = max / 10;
}
// 创建10个桶
ArrayList<LinkedList<Integer>> bucketList = new ArrayList<>(10);
//初始化桶
for (int i = 0; i < 10; i++) {
bucketList.add(new LinkedList<Integer>());
}
// 进行每一趟的排序,从个位数开始排
for (int i = 1; i <= num; i++) {
for (int j = 0; j < n; j++) {
// 获取每个数最后第 i 位是数组
int radio = (arr[j] / (int)Math.pow(10,i-1)) % 10;
//放进对应的桶里
bucketList.get(radio).add(arr[j]);
}
//合并放回原数组
int k = 0;
for (int j = 0; j < 10; j++) {
for (Integer t : bucketList.get(j)) {
arr[k++] = t;
}
//取出来合并了之后把桶清光数据
bucketList.get(j).clear();
}
}
return arr;
}
我的理解:基数排序,其实是逐级比较。
他山美玉:
堆排序的讲解
希尔排序-这个知乎答主的代码很容易理解