数组操作及经典排序算法总结
文章目录
一、排序算法概念
- 排序:假设含有n个记录的序列为{R1,R2,…,Rn},其相应的关键字序列为{K1,K2,…,Kn}。将这些记录重新排序为{Ri1,Ri2,…,Rin},使得相应的关键字值满足条Ki1<=Ki2<=…<=Kin,这样的一种操作称为排序。
- 通常来说,排序的目的是快速查找。
- 用到分治思想的排序算法有:快速排序、归并排序
1.1 排序算法分类
- 内部排序:整个排序过程不需要借助于外部存储器(如磁盘等),所有排序操作都在内存中完成。
- 比较类:交换(冒泡、快速)、插入(直接插入、希尔)、选择(简单选择、堆)、归并
- 非比较类:基数、桶、计
- 外部排序:参与排序的数据非常多,数据量非常大,计算机无法把整个排序过程放在内存中完成,必须借助于外部存储器(如磁盘)。可以理解为外部排序是由多次内部排序组成。
- 多路归并排序
2.2 排序算法的特性
排序算法 | 时间复杂度最好 | 最坏 | 平均 | 空间复杂度 | 排序方式(?占用内存) | 稳定性 |
---|---|---|---|---|---|---|
冒泡排序 | O(n) | O( n 2 n^{2} n2) | O( n 2 n^{2} n2) | O(1) | In-place | 稳定 |
选择排序 | O( n 2 n^{2} n2) | O( n 2 n^{2} n2) | O( n 2 n^{2} n2) | O(1) | In-place | 不稳定 |
插入排序 | O(n) | O( n 2 n^{2} n2) | O( n 2 n^{2} n2) | O(1) | In-place | 稳定 |
归并排序 | O(n logn) | O(n logn)) | O(n logn) | O(n) | Out-place | 稳定 |
快速排序 | O(n logn) | O( n 2 n^{2} n2) | O(n logn) | O(logn) | In-place | 不稳定 |
堆排序 | O(n logn) | O(n logn)) | O(n logn) | O(1) | In-place | 不稳定 |
计数排序 | O(n+k) | O(n+k)) | O(n+k) | O(n+k) | Out-place | 稳定 |
桶排序 | O(n) | O( n 2 n^{2} n2) | O(n+k) | O(n+k) | Out-place | 稳定 |
基数排序 | O(n×k) | O(n×k)) | O(n×k) | O(n+k) | Out-place | 稳定 |
希尔排序 | O(n) | O( n 2 n^{2} n2) | O( n 1.3 n^{1.3} n1.3) | O(1) | In-place | 不稳定 |
特性总结:
- 稳定性:泡插归计稳,其他都不稳
- 平均时间复杂度:选泡插 n 2 n^{2} n2,快归堆n logn,希O( n 1.3 n^{1.3} n1.3),桶计O(n+k),基O(n×k)
- 最坏时间复杂度:“希‘望’桶快坏了”:O( n 2 n^{2} n2),其余与平均时间复杂度一致
- 最好时间复杂度:“希‘望’桶”、“好泡插”:O(n)
- 希尔排序由最好→最坏→平均时间复杂度的变化情况:O(n logn)→O( n 2 n^{2} n2)→O( ( n l o g n ) 2 (n logn)^{2} (nlogn)2)
- 时间复杂度由小到大依次为:Ο(1)<Ο(log2n)<Ο(n)<Ο(nlog2n)<Ο(n2)<Ο(n3)<…<Ο(2n)<Ο(n!)<O(nn)
说明:
- 稳定/不稳定:若两个记录A和B的关键字值相等,但排序后A、B的先后次序保持不变,则称这种排序算法是稳定的。
- 内/外排序:是否所有排序操作都在内存中完成
- 时间复杂度:分析关键字的比较次数和记录的移动次数
- 空间复杂度:分析排序算法中需要多少辅助内存
二、数组的操作
2.1 数组赋值&复制
注意点:数组arr1赋值给arr2,这两个数组指向同一个地址,而如果是arr1复制给arr2(一个一个元素的给),两个数组指向的是不同的地址!
赋值赋的是地址值,复制复的是元素值
int[] arr1 = new int[]{12,43,65,3,-8,64,2};
//赋值
int arr2 = arr1;//指向同一个地址
for(int i = 0; i < arr2.length; i++){
if(i % 2 == 0){
arr2[i] = i;
}
}
//遍历arr1,得到的内容与arr2修改后的一致!
for(int i = 0; i < arr1.length; i++){
System.out.print(arr[i] + " ");
}
//复制, 如果对arr3修改不影响arr1!
int[] arr3 = new int[arr1.length];
for(int i = 0; i < arr1.length; i++){
arr3[i] = arr1[i];
}
2.2 数组元素的反转
实现思想:数组对称位置的元素互换。
法一:奇/偶数组都是一样的,遍历数组至一半,通过中间变量temp实现对应首尾位置的交换
int[] arr = new int[]{1,2,3,2,7,45,34,32};
//反转之前
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i]+" ");
}
System.out.println();
int mid;
//交换次数 = 数组.length / 2
for (int i = 0; i <= arr.length/2; i++) {
temp = arr[i];
arr[i] = arr[arr.length - i - 1];
arr[arr.length - i - 1] = temp;
}
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i]+" ");
}
法二:定义两个循环变量(类似于c++中的指针)
一开始left=0, right=length-1,然后left++, right–,只要left<right就交换 !
int[] arr = new int[]{1,2,3,2,7,45,34,32};
int mid;
//同时定义左右两个遍历
for (int left = 0, right = arr.length - 1; left < right; left++, right--) {
int temp = arr[left];
arr[left] = arr[right];
arr[right] = temp;
}
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i]+" ");
}
扩展:判断对称数组
2.3 数组的扩容与缩容
数组一旦确定了,那么它的长度就是不可改变的!所谓扩容new一个新数组存放它的数据,让原数组指向一个该新的地址值达到扩容的目的
缩容也可以new新数组,也可以将多余的值设置为默认值!
注意:新数组地址值赋值给原数组,达到原数组扩容/缩容的目的!
扩容:现有数组 int[] arr = new int[]{1,2,3,4,5}; ,现将数组长度扩容1倍,并将10,20,30三个数据添加到arr数组中,如何操作?
int[] arr = new int[]{1,2,3,4,5};
//new新的数组
int[] newArr = new int[arr.length << 1];//左移运算符:去高位补低位,结果是原来的2倍
for(int i = 0;i < arr.length;i++){
newArr[i] = arr[i];
}
//赋值:让原数组指向一个新的地址值
arr = newArr;
//遍历arr
for (int i = 0; i < arr.length; i++) {
System.out.println(arr[i]);
}
缩容:现有数组 int[] arr={1,2,3,4,5,6,7}。现需删除数组中索引为4的元素。
//方案1:创建新数组缩容→赋值给原数组
int[] arr = {1, 2, 3, 4, 5, 6, 7};
//删除数组中索引为4的元素
int delIndex = 4;
//创建新数组
int[] newArr = new int[arr.length - 1];
for (int i = 0; i < delIndex; i++) {
newArr[i] = arr[i];
}
for (int i = delIndex + 1; i < arr.length; i++) {
newArr[i - 1] = arr[i];
}
arr = newArr;
//方案2:将原数组多余的部分设置为默认值
int[] arr = {1, 2, 3, 4, 5, 6, 7};
int delIndex = 4;
for (int i = delIndex; i < arr.length - 1; i++) {
arr[i] = arr[i + 1];
}
arr[arr.length - 1] = 0;
for (int i = 0; i < arr.length; i++) {
System.out.println(arr[i]);
}
三、数组的元素查找
3.1 线性查找
顺序查找:挨个查看,对数组元素的顺序没要求
查找value第一次在数组中出现的index
int index = -1;
int value = 2;
int[] arr = new int[]{1,5,32,67,3,9,1,76,2,4,6,1};
for (int i = 0; i < arr.length; i++) {
if(arr[i] == value){
index = i;
}
}
if(index==-1){
System.out.println(value + "不存在");
}else{
System.out.println(value + "的下标是" + index);
}
3.2 二分查找
数组有二分查找方法:static int binarySearch(int[] a, int key) 、static int binarySearch(Object[] a, Object key) :要求数组有序,在数组中查找key是否存在,如果存在返回第一次找到的下标,不存在返回负数。
要求:要求此数组必须是有序的。
描述:设置首位索引位置,确定那个中间位置,如果目标值=中间值则查找成功,如果目标值>中间值说明目标值在该数组的右半部分,此时重新修改首索引=mid+1,如果目标值<中间值则修改尾索引
一个有序数组,利用二分查找法查找与目标值相等的索引
直到首尾相等时没找到就结束!
int[] arr = new int[]{1,2,3,24,43,45,67,100,101};
int value = 2;
int head = 0;
int end = arr.length - 1;
boolean isFlag = true;
while(head <= end){
int mid = (head + end) / 2;
if(value == arr[mid]){
System.out.println(mid);
isFlag = false;
break;
}else if(value > arr[mid]){
head = mid + 1;
}else{//(value < arr[mid])
end = mid - 1;
}
}
if(isFlag){
System.out.println("没找到");
}
四、排序算法
排序算法回顾:
- 比较类:交换(冒泡、快速)、插入(直接插入、希尔)、选择(简单选择、堆)、归并
- 非比较类:基数、桶、计
排序算法动画演示网址:https://visualgo.net/en/sorting?slide=1
数组有排序方法
- static void sort(int[] a) :将a数组按照从小到大进行排序
- static void sort(int[] a, int fromIndex, int toIndex) :将a数组的[fromIndex, toIndex)部分按照升序排列
- static void sort(Object[] a) :根据元素的自然顺序对指定对象数组按升序进行排序。
- static void sort(T[] a, Comparator<? super T> c) :根据指定比较器产生的顺序对指定对象数组进行排序。
4.1 交换排序
4.1.1 冒泡排序:每次确定一个后面的位置元素
1、定义:第一遍依次比较两个元素,如果它们的顺序错误就把它们交换过来,一直比较到最后一个,遍历结束后就会确定最大/小的已经确定在最后一个位置了。第二遍再从头开始交换能确定倒数第二个位置的数,以此类推。即越小/大的元素会经由交换慢慢 “浮” 到数列的顶端。
思想:每一次比较“相邻(位置相邻)”元素,如果它们不符合目标顺序(例如:从小到大),就交换它们,经过多轮比较,最终实现排序。
2、算法步骤:以升序为例
- (1) 比较相邻的元素:如果第一个比第二个大,就交换它们两个;
- (2) 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- (3) 针对所有的元素重复以上的步骤,除了最后一个;
- (4) 重复步骤 1~3,直到排序完成(在某次遍历中没有发生交换行为)。图解算法
- 结束标志:没有发生交换行为、排到最后一次遍历
- 结束标志:没有发生交换行为、排到最后一次遍历
3、算法分析
- 稳定性:稳定
- 时间复杂度:最佳:O(n), 最差:O(n2),平均:O(n2)
- 空间复杂度:O(1)
- 排序方式:In-place
冒泡排序:升序
- 设置flag的目的:每次遍历中两两比较完后,如果发生了交换(顺序不一致),还需要进行下一轮遍历交换,但是如果本次遍历中从头到尾没有执行交换操作,那么就可以得到现在的数组顺序已经达到要求了,就没必要再执行后面的遍历(多此一举)!目的是将算法的最佳时间复杂度优化为 O(n),即当原输入序列就是排序好的情况下,该算法的时间复杂度就是 O(n)。
- 外循环控制 轮数,内循环控制每一轮的比较次数和过程
public int[] bubbleSort(int[] arr){
int temp;
for (int i = 1; i < arr.length; i++) {
boolean flag = true;
for (int j = 0; j < arr.length - i; j++) {
//两两比较,发生交换需要再执行下一次遍历,如果没发生则说明排序完成
if(arr[j] > arr[j + 1]){
temp = arr[j+1];
arr[j+1] = arr[j];
arr[j] = temp;
flag = false;//如果元素发生了交换,那么说明数组还没有排好序
}
}
if(flag){
break;//本次遍历中没有发生交换行为,说明数组顺序已经达到要求,没必要再往后执行了
}
}
return arr;
}
4.1.2 快速排序:每次确定基准元素的位置
参考网址理解:数据结构与算法快速排序的三种实现方法_数据结构快速排序算法-优快云博客
关键词:基准元素
1、定义:所有内排序算法中速度最快的一种,比同为O(nlogn)的其他算法更快。是以一个数作为基准值,实现将数组中比基准数小的数放在基准值的左侧,比基准值大的数放在基准值的右侧。
基本思想:采用“分治”的思想,任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
快速排序vs归并排序:
- 相同点:都用到了分治思想,将问题变小,先排序子串,最后合并。
- 不同点:快速排序在划分子问题的时候经过多一步处理,将划分的两组数据划分为一大一小,这样在最后合并的时候就不必像归并排序那样再进行比较。但是划分的不定性使得快速排序的时间复杂度并不稳定。
2、算法步骤:分区+递归
-
从数列中挑出一个元素,称为"基准"(pivot),
-
重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
-
递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
-
递归的最底部情形,是数列的大小是零或一,也就是永远都已经被排序好了。虽然一直递归下去,但是这个算法总会结束,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。
3、算法分析
- 稳定性:不稳定
- 时间复杂度:最佳:O(n logn), 最差:O(n logn),平均:O(n logn)
- 空间复杂度:O(logn)
4、快速排序的三种实现方法
无论采用哪种方式,每轮排序后结果都是:(左边小于基准值)基准值(右边大于基准值)。
方法一:霍尔法
:霍尔是最初发现快速排序的人,所以,它使用的单趟排序算法被称为霍尔法。
用key标记基准值的下标(数组下标0的元素),使用两个指针left和right分别指向待排数组的最左侧和最右侧,right指针找比key基准值小的数,left指针找比key基准值大的数,找到后将两个数交换位置,同时实现大数右移和小数左移,直到left与right相遇则排序完成,最后将key基准值的下标返回,就完成了单趟排序。
霍尔法实现快速排序
- 有数组的区间是[begin, end]
- 假设选取最左边为keyi,则right先走,找比arr[keyi]小的,left后走,找比arr[keyi]大的,然后right与left交换;
- 当left和right相遇时,结束循环,最后交换arr[keyi]和arr[left];
- 再让keyi=left,接着递归它的左子列和右子列;其中左子列的区间[begin,keyi-1],右子列的区间[keyi+1,end]
void QuickSort(int* arr, int left,int right){
if (left >= right)
return;
int begain = left;
int end = right;
int keyi= left;
while (left < right){
//这里要先判断left<right,防止越界,下同
while (left<right && arr[right]>=arr[keyi]){//找小
right--;
}
while (left < right && arr[left] <= arr[keyi]){//找大
left++;
}
Swap(&arr[left], &arr[right]); //交换
}
Swap(&arr[keyi], &arr[left]);
keyi = left;
QuickSort(arr, begain, keyi-1); //递归左子列
QuickSort(arr, keyi+1, end); //递归右子列
}
方法二:挖坑法
挖坑法是将key基准值用变量单独保存,然后将key的位置空出来形成一个坑,left和right指针分别从左右两端向中心遍历,此时left先指向这个坑,从右边先开始,right找比key小的数,找到后将该数直接放进坑里,并将自己空出来的位置设置为坑,left找比key大的数,找到后将该数放进坑里,并将现在空出来的位置设置为坑,一直遍历,直到left与right相遇,相遇位置一定为坑(left和right必定有一个指向坑),此时将key基准值放进坑内,并返回基准值下标完成单趟排序。
上面的Hoare法有很多坑,一不注意就容易写错,而挖坑法就没那么多坑了。
这个方法需要定义一个坑变量(hole),前面的Hoare法是交换两个元素,挖坑法是把值赋给坑位,然后更新一下坑位 。
void QuickSort(int* arr, int left, int right){
if (left >= right)
return;
int begain = left;
int end = right;
int key = arr[left];
int hole = left; //坑变量,初始是基准元素
while (left < right){
//防止越界
while (left < right && arr[right] >= arr[leyi]){//找小
right--;
}
arr[hole] = arr[right]; //赋值给坑位
hole = right; //更新坑位
while (left < right && arr[left] <= arr[keyi]){//找大
left++;
}
arr[hole] = arr[left];
hole = left;
}
arr[hole] = key;
keyi = left;
//递归左右子列
QuickSort(arr, begain, hole - 1);
QuickSort(arr, hole + 1, end);
}
方法三:前后指针法
用key保存数组第一个元素作为基准值,定义前指针prev指向第一个数,后指针cur指向前指针的后一个位置。
由cur挨个遍历数组中的数据,如果cur找到比key小的值,则prev++后,cur和prev位置互换;如果cur找到比key大的值,cur++;
当cur>right时结束循环。
最后将prev处与key位置的元素交换,将基准值下标返回(此时基准值下标已经交换到prev位置)。则完成单趟排序。
注意:
- prev要么紧跟着cur,即prev的下一个就是cur;
- prev之前的值一定小于key基准值,而prev与cur之间的一定大于基准值,cur后面的都是还没排序的
前后指针法实现快速排序:
void QuickSort(int[] arr, int left, int right){
if (left >= right)
return;
int begin = left, end = right;
int prev = left, cur = left + 1;
int keyi = left;
while (cur <= right){
if (arr[cur] < arr[keyi]){
prev++;
Swap(&arr[cur], &arr[prev]);
cur++;
}
while (arr[cur] > arr[keyi]){
cur++;
}
}
Swap(&arr[keyi], &arr[prev]);
keyi = prev;
QuickSort(arr, begin, keyi - 1);
QuickSort(arr, keyi + 1, end);
}
方式四:非递归法利用栈
:这里就不说了
5、快速排序的优化
在面对有序或是接近有序的情况下,快速排序的效率不高,是O(N^2),那要怎么解决这个问题呢?
优化法一:随机下标交换法
int randi = left + rand() % (right - left); //随机key
Swap(&arr[keyi], &arr[randi]);
优化法二:三路取中法
int GetMid(Sdatatype* arr, int left, int right)
{
int mid = (left + right) / 2;
if (arr[left] < arr[mid])
{
if (arr[mid] < arr[right])
return mid;
else if (arr[right] < arr[left])
return left;
else
return right;
}
else //arr[left]>arr[mid]
{
if (arr[right] < arr[mid])
return mid;
else if (arr[right] > arr[left])
return left;
else
return right;
}
}
if (left != midi)
Swap(&arr[left], &arr[midi]);
4.2 插入排序
4.2.1 直接插入
将数组分为三个部分:(已排序部分)当前排序元素(未排序部分)。从第一个元素开始,该元素可以被认为已经被排序
将当前排序元素在已排序序列中从后向前扫描,找到相应位置并插入。
直接插入排序:
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤 3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤 2~5。图解算法
public static int[] insertionSort(int[] arr) {
for (int i = 0; i < arr.length; i++) {//i表示当前要排序的元素位置索引
int preindex = i - 1;
//记录当前要插入的元素值,因为前面的元素会后移
int current = arr[i];
if(arr[preindex] > arr[i]){
arr[preindex + 1] = arr[preindex];//将前一个位置后移
preindex -= 1;
}
arr[preindex] = current;
}
return arr;//返回排序后的数组
}
4.2.2 希尔排序
基本思想:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录 “基本有序” 时,再对全体记录进行依次直接插入排序。
4.3 选择排序
4.3.1 简单选择
4.3.2 堆排序
4.4 归并排序
4.5 桶排序
4.6 基数排序
4.7 计数排序
五、内部排序性能比较与选择
1、性能比较
- 从平均时间而言:快速排序最佳。但在最坏情况下时间性能不如堆排序和归并排序。
- 从算法简单性看:由于直接选择排序、直接插入排序和冒泡排序的算法比较简单,将其认为是简单算法。对于Shell排序、堆排序、快速排序和归并排序算法,其算法比较复杂,认为是复杂排序。
- 从稳定性看:直接插入排序、冒泡排序和归并排序时稳定的;而直接选择排序、快速排序、 Shell排序和堆排序是不稳定排序
- 从待排序的记录数n的大小看,n较小时,宜采用简单排序;而n较大时宜采用改进排序。
2、选择
- 若n较小(如n≤50),可采用直接插入或直接选择排序。
当记录规模较小时,直接插入排序较好;否则因为直接选择移动的记录数少于直接插入,应选直接选择排序为宜。 - 若文件初始状态基本有序(指正序),则应选用直接插入、冒泡或随机的快速排序为宜。
- 若n较大,则应采用时间复杂度为O(nlgn)的排序方法:快速排序、堆排序或归并排序。