目录
一、前言
排序算法是一类很经典的算法,在面试中经常被问到,需要我们手撕某个排序算法,或者分析其时间、空间复杂度并与其他算法比较优劣。而在考研中,也是数据结构必考知识,还可能涉及稳定性、内外排序问题,可见排序算法是基础中的基础。以下将仅仅简单分析各类排序算法并贴出代码,更详细地可具体百度某类算法。
二、插入排序
2.1 直接插入排序
直接插入排序,可以把数组a[n]看做有序表和无序表组成两部分。开始时有序表只要一个元素a[0],之后每次遍历时都从无序表取出一个元素,插入有序表中,直到n-1次后无序表就没有元素了,也就排序完成了。元素插入有序表的操作,就是查找该元素在有序表中合适的位置,从后往前遍历,未找到则数组后移,给插入元素空出位置插入。
时间复杂度:O(n^2)。折半插入排序是稳定的排序。稳定:关键字相同的元素排序前后位置不变。
代码:
//直接插入排序,有序表里存放已经排序好的数组,每次取出当前元素,从后开始遍历,
// 小于则数组往后移动a[j+1] = a[j];否则大于,直接赋值。
void straight_insertion_sort(int a[], int start, int end)
{
int temp;
int i, j;
for (i = start + 1; i < end; i++)//从第二个元素开始:start+1
{
temp = a[i]; //保存当前元素
for (j = i - 1; temp < a[j] && j >= start; j--)
{
a[j + 1] = a[j];
}
a[j + 1] = temp; //空出的位置插入当前元素
}
}
2.2 折半插入排序
折半插入排序的基本思路和 2.1 直接插入排序 是一致的,只不过查找的方式采用了折半查找(二分法),比直接插入算法明显减少了关键字之间比较的次数,因此速度比直接插入排序算法快,但记录移动的次数没有变,所以折半插入排序算法的时间复杂度仍然为O(n^2),与直接插入排序算法相同。同样的,折半插入排序也是稳定的排序方式。
代码:
//折半插入排序,思路与直接插入排序一致,不过查询用折半查找
void binary_insertion_sort(int a[], int start, int end)
{
int temp;
int i, j, l, r, mid;
for (i = start + 1; i < end; i++)
{
temp = a[i];
/* 折半查找 */
l = start;
r = i - 1;
while (l <= r)
{
mid = (l + r) / 2;
if (temp < a[mid])
{
r = mid - 1;
}
else
{
l = mid + 1;
}
}
/* 折半查找 */
for (j = i - 1; j >= l; j--) //移动次数不变
{
a[j + 1] = a[j];
}
a[l] = temp;
}
}
2.3 希尔插入排序
直接插入排序的算法时间复杂度为O(n^2),但是,如果待排序的序列是正序的,或者说按关键字“基本有序”,那么时间复杂度可以接近O(n)。举个简单的例子,如果待排序序列已经是排序好的,那么直接插入排序内层循环每次只需执行一次,时间复杂度O(1),再加上外层循环就是O(n)的时间复杂度。虽然实际上待排序的序列不可能完完全全排序好,但是从另一方面来说,如果序列长度n足够小,是不是可以看成基本有序呢?所以希尔插入排序由此提出。
希尔排序的基本思路:假设带排序序列有n个元素,那么我们可以取一个整数gap = [n/3] + 1 作为间隔,将全部元素分为gap个子序列,间隔为gap的元素是同一个子序列,在每个子序列分别进行直接插入排序。然后缩小gap,gap = [gap / 3] + 1,再重复上述操作,直到gap=1时就完成了全部排序。
如下图:
希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;当元素基本有序了,步长很小,插入排序对于有序的序列效率很高。所以,希尔排序的时间复杂度会比o(n^2)好一些。
两个函数代码:
对于shell_insert,如果gap=1,就直接退化为直接插入排序。所以最坏情况下时间复杂度O(n^2),并且希尔排序不是稳定的。
//希尔插入排序的一部分,和直接插入排序类似,不过间隔为gap
void shell_insert(int a[], int start, int end, int gap)
{
int temp;
int i, j;
//与直接插入排序类似
for(i = start + gap; i < end; i++)
{
temp = a[i];
for (j = i - gap; j >= start && temp < a[j]; j -= gap)//原本1的位置改为gap
{
a[j + gap] = a[j];
}
a[j + gap] = temp;
}
}
//希尔插入排序
void shell_sort(int a[], int start, int end)
{
int gap = end - start;
while (gap > 1)
{
gap = gap / 3 + 1;
shell_insert(a, start, end, gap);//按gap直接插入排序
}
}
三重for循环:
d每次除以2,直到d=1。
void shell_sort(int a[], int start, int end)
{
int d = end - start;
for (d = 4; d >= 1; d /= 2)//希尔排序d
{
//以下是直接插入排序,注意d
for (int i = start + d; i < end; i++)
{
int tmp = a[i];
int j;
for (j = i - d; j >= start && tmp < a[j]; j -= d)
{
a[j + d] = a[j];
}
a[j + d] = tmp;
}
}
}
三、交换排序
3.1 冒泡排序
假设待排序序列的元素个数为n,那么冒泡排序的思路就是从后往前比较,如果发生逆序(如前一个比后一个大),就交换它们,直到结束。这称为一趟冒泡,结果就是最小的元素交换到第一个位置。下一趟冒泡,前面的最小元素不参与交换,第二个位置得到第二小的元素,如此n-1次冒泡就可以完成排序。
时间复杂度:O(n).
代码:
//冒泡排序
void bubble_sort(int a[], const int start, const int end)
{
int exchange;
int tmp;
int i, j;
for (i = start; i < end - 1; i++)
{
exchange = 0;
for (j = end - 1; j > i; j--)
{
if (a[j - 1] > a[j])
{
tmp = a[j - 1];
a[j - 1] = a[j];
a[j] = tmp;
exchange = 1;
}
}
if (exchange == 0)//未发生交换,说明已经排序好
return;
}
}
3.2 快速排序
快速排序的基本思想:任取待排序元素序列中的某个元素作为基准,根据该元素的关键字大小,将数组分为两个序列:左侧所有元素的关键字都小于该元素的关键字大小,右侧所有元素的关键字都大于该元素的关键字大小,基准元素在两者之间。然后重复上述操作,直到完成整个排序。
- 最好时间复杂度:O(nlogn),最坏情况达到:O(n^2),平均时间复杂度:O(n ^ 2)。时间复杂度的计算和递归深度有关,O(n * 递归深度),可以将排序的元素用二叉排序树列出,通过计算二叉树的深度即为递归深度。
- 最坏情况,当数组已经是有序或者逆序时,因为我们每次取第一个元素为基准元素,那么一轮快排后,左右两边极度不平衡,即一边无元素一边剩余的元素。所以退化为O(n ^ 2)。即坏情况出现在,选取的基准元素使得两边元素不平衡,效率极低。解决方案:随机选取,随机从概率选择到最小或最大值的元素可能性就降低了;取开头、中间、末尾元素中的中间值作为基准元素。
- 不过在实际情况下,因为数据基本上都是无序的,所以快排的效率还是很高。快排是不稳定的。
- 快排采用分治法的思想,且效率在几种排序中较高,常作为面试题。快排在数据量大的情况下效率较高,经常和堆排序、归并排序竞争。在快排达到最坏情况下可采取堆排序。
代码:
//快排之一次划分
int partition(int a[], const int start, const int end)
{
int i = start;
int j = end - 1;
const int pivot = a[i];//以pivot的值为界限分割
while (i < j)
{
//注意必须加=,因为如果等于的也加到左边,之后再出现一个小于pivot的就不是单边有序了
while (i < j && a[j] >= pivot) j--;
a[i] = a[j];
while (i < j && a[i] <= pivot) i++;
a[j] = a[i];
}
a[i] = pivot;
return i;
}
//快速排序-递归
void quick_sort(int a[], const int start, const int end)
{
if (start < end - 1)
{
const int pivot_pos = partition(a, start, end);//一次划分
quick_sort(a, start, pivot_pos);//递归左边
quick_sort(a, pivot_pos + 1, end);//递归右边
}
}
四、选择排序
4.1 简单选择排序
简单选择排序,也叫直接选择排序。其基本思路是:从前往后遍历,在 [i+1, n] 中找到最小值,如果最小值不是i,则最小值与i交换,i++。重复之前的操作直到交换结束。
时间复杂度:O(n^2)。而且不论数组是有序、逆序,都需要n-1趟排序,也就是说时间复杂度不会因为数组的排列而改变。同时,简单选择排序是不稳定的。看个例子,221,第一次将1和2交换,这样同为关键字的2顺序就改变了,所以不稳定。
代码:
//简单选择排序(直接选择排序)
void simple_select_sort(int a[], int start, int end)
{
for (int i = start; i < end; ++i)
{
int min = i; //最小值
for (int j = end - 1; j > i; --j)
{
if (a[j] < a[min])
{
min = j;
}
}
if (min != i) swap(a[min], a[i]);
}
}
4.2 堆排序
堆定义
首先,我们需要明白什么是堆。堆(heap)是计算机科学中一类特殊的数据结构的统称。堆需要满足两个性质:
- 堆是一棵完全二叉树。所以,节点a[i]的左子节点a[2i+1],右子节点a[2i+2]。
- 堆中某个节点的值总是不大于或不小于其父节点的值。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
如下图就是一个大根堆。
对这棵二叉树进行层序遍历,对应数组arr:
堆排序思想
堆排序的基本思想:首先将n个数组元素构建成一个大根堆,这时候,整个序列的最大值就是堆顶元素。然后,我们将堆顶元素和末尾元素交换,末尾就是最大值了。之后,将剩下的n-1个元素重新构建成一个堆,再延续之前的操作,就得到排序后的数组了。
其实堆排序的关键就是两步:
- 将待排序序列构建成大根堆(升序)。
- 将大根堆与队尾元素交换。
- 重复上述操作,直到整个序列有序。
分析
堆排序需要n-1趟,属于选择排序的一种。时间复杂度:O(nlogn),生成大根堆用到了树的思想,树的深度:[logn] + 1。
代码1:
//堆排序
#define left(x) (2 * x + 1) //左子节点的数组下标
#define right(x) (2 * (x + 1)) //右子节点的数组下标
//构建一个大根堆
void max_heap(int a[], int i, int low, int high)
{
int l = left(i);
int r = right(i);
int tmp;//交换
int max = i;//保存 l、r、i 三者的最大值
//找出最大值
if (l <= high && a[l] > a[i]) max = l;
if (r <= high && a[r] > a[max]) max = r;
//是否修改
if (max != i)
{
//修改大根堆中根为最大值
tmp = a[i];
a[i] = a[max];
a[max] = tmp;
max_heap(a, max, low, high);
}
}
//堆排序
void heap_sort(int a[], int length)
{
//1.建立最大堆
for (int i = length / 2 - 1; i >= 0; i--)
max_heap(a, i, 0, length - 1);
int tmp;
for (int i = length - 1; i >= 1; i--)
{
//因为最大堆根节点为最大值,所以交换根节点和i
tmp = a[i];
a[i] = a[0];
a[0] = tmp;
max_heap(a, 0, 0, i);
}
}
代码2:
void HeapAdjust(int a[], int k, int len)//将k为根的子树调整为大根堆
{
int tmp = a[k];
for (int i = 2 * k + 1; i < len; i = i * 2 + 1)//i=i*2+1的原因是:对交换后的节点进行左右子节点判断
{
if (i < len - 1 && a[i] < a[i + 1]) i++;//取左右结点中大的节点
if (tmp >= a[i]) break;
else
{
a[k] = a[i];//调整使得根最大
k = i;//修改k值,继续筛选
}
}
a[k] = tmp;
}
void BuildMaxHeap(int a[], int len)//建立大根堆
{
for (int i = len / 2 - 1; i >= 0; --i)//对于二叉树,只取len/2调整,因为之后的是叶子节点
{
HeapAdjust(a, i, len);
}
}
void heap_sort(int a[], int len)//堆排序,升序(大根堆)
{
BuildMaxHeap(a, len);
for (int i = len - 1; i > 0; --i)
{
swap(a[i], a[0]);//和堆顶元素交换
HeapAdjust(a, 0, i);
}
}
五、归并排序
所谓“归并”,就是将两个或两个以上的有序序列合并成一个有序序列。归并排序就是建立在归并操作上的一个有效的排序算法,是采用分治法的一个典型应用。ps:快速排序也是基于分治法,速度仅次于 快速排序,为稳定排序算法。不过,快排从整个序列入手根据关键字大小分割成两个部分,递归直到序列很小为止;而归并排序更像从很多个小元素的归并,不断递归直到最后一个大序列。
平均时间复杂度和最坏情况都是:O(nlogn)。归并排序是稳定的。
以下是一个二路归并的例子:
递归算法
以下两种代码,区别在于:第一种代码end等同于n,第二种代码end等同于n-1。
代码1:
//将两个有序表合并成一个新的有序表
//[start, mid-1]为一个有序表,[mid, end]为另一个有序表
void merge(int a[], int tmp[], const int start, const int mid, const int end)
{
int i, j, k;
for (i = 0; i < end; i++) //tmp[]辅助数组
tmp[i] = a[i];
for (i = start, j = mid, k = start; i < mid && j < end; k++)
{
if (tmp[i] < tmp[j]) //取两段中小的
a[k] = tmp[i++];
else
a[k] = tmp[j++];
}
//如果两个表中有未检测完的表。
while (i < mid) a[k++] = tmp[i++];
while (j < end) a[k++] = tmp[j++];
}
//归并排序
void merge_sort(int a[], int tmp[], const int start, const int end)
{
if (start < end - 1)//至少两个元素
{
const int mid = (start + end) / 2;
merge_sort(a, tmp, start, mid);
merge_sort(a, tmp, mid, end);//注意是mid
merge(a, tmp, start, mid, end);//最后一层开始往前merge
}
}
代码2:
int* B = (int*)malloc(8*sizeof(int));//辅助数组
void Merge(int a[], int start, int mid, int end)
{
for (int i = start; i <= end; i++)
{
B[i] = a[i];
}
int i = start, j = mid + 1, k = start;
while (i <= mid && j <= end && k <= end)
{
if (B[i] <= B[j])
{
a[k++] = B[i++];
}
else
{
a[k++] = B[j++];
}
}
while (i <= mid) a[k++] = B[i++];
while (j <= end) a[k++] = B[j++];
}
//start:0,end:n-1
void merge_sort(int a[], int start, int end)
{
if (start < end)
{
int mid = (start + end) / 2;
merge_sort(a, start, mid);
merge_sort(a, mid + 1, end);
Merge(a, start, mid, end);
}
}
非递归算法
代码1:
void Merge(vector<int>& arr, int start, int mid, int end)
{
vector<int> tmp = arr;
int i = start, j = mid + 1, k = start;
while (i <= mid && j <= end && k <= end)
{
if (tmp[i] < tmp[j])
{
arr[k++] = tmp[i++];
}
else
{
arr[k++] = tmp[j++];
}
}
while (i <= mid) arr[k++] = tmp[i++];
while (j <= end) arr[k++] = tmp[j++];
}
void merge_sort(vector<int>& arr, int n)
{
int k = 1, i = 0;
while (k < n)
{
//从前往后,将两个长度为k的子序列合并为1个
for (i = 0; i + 2 * k - 1 < n; i += 2 * k)
{
Merge(arr, i, i + k - 1, i + 2 * k - 1);
}
//合并有序的左半部分以及不及一个步长的右半部分
if (i < n - k)
{
Merge(arr, i, i + k - 1, n - 1);
}
k *= 2;
}
}
代码2,使用sort:
void merge_sort(vector<int>& arr, int n)
{
int k = 1;
while (k < n)
{
k *= 2;
for (int i = 0; i < n; i += k)
{
sort(arr.begin() + i, arr.begin() + min(i + k, n));
}
}
}
六、基数排序
基数排序(radix sort)属于“分配式排序”,又称“桶子法”,是一种利用多关键字实现对单关键字排序的算法。有两种顺序,分为最高位优先 MSD 和最低位优先 LSD。以下介绍LSD:
(1)首先我们用静态链表存储n个元素,并且定义第一个元素a[0]为头指针,通过静态链表我们只需要修改每个元素的link值即可,不必移动元素。
静态链表定义:
typedef struct static_list_node_t
{
int key; //关键词
int link; //下一个结点
}static_list_node_t;
(2)每个位(0~9)设置一个桶(原理可参考计数排序or桶排序),桶采用静态链表结构存储,并且设定两个数组front[R]和rear[R],记录每个桶的头指针和尾指针。
(3)从头开始遍历,修改每个桶对应的头指针和尾指针,从而将相同值的桶用头指针到尾指针连接起来。
(4)接着,从小到大遍历所有有元素的桶,将前一个桶的尾指针和当前桶的头指针连接起来,遍历所有后就形成一个有序的序列。
(4)循环以上n次,n取决于最大数的位数。并且每次从最低位开始,最后完成排序。
基数排序时间复杂度:O(d * (n + R)),d为最大数的位数,R为元素个数。
代码:
#define R 10
typedef struct static_list_node_t
{
int key; //关键词
int link; //下一个结点
}static_list_node_t;
//打印静态链表
static void static_list_print(const static_list_node_t a[])
{
int i = a[0].link;
while (i != 0) //最后一个link = 0
{
printf("%d ", a[i].key);
i = a[i].link;
}
}
//获得十进制整数的某一位数字
static int get_digit(int n, const int index)
{
int j;
for (j = 1; j < index; j++)
{
n /= 10;
}
return n % 10;
}
//LSD 链式基数排序
//a 静态链表,a[0]头指针 n 待排序的总数 d 最大整数的位数
void radix_sort(static_list_node_t a[], const int n, const int d)
{
int i, j, k, cur, last;
int rear[R], front[R]; //front为头指针,rear为尾指针,头和尾之间的元素值一致
//设置链表a各参数的link值
for (i = 0; i < n; i++)
a[i].link = i + 1;
a[n].link = 0;
for (i = 0; i < d; i++) //多少位遍历多少次
{
/* 分配 */
//按计数排序的方式,存储了各元素,相同值之间用头指针和尾指针
for (j = 0; j < R; j++) front[j] = 0;
for (j = 0; j < R; j++) rear[j] = 0;
for (cur = a[0].link; cur != 0; cur = a[cur].link)
{
k = get_digit(a[cur].key, i + 1);
if (front[k] == 0) //第一个值为k的元素
{
front[k] = cur;
rear[k] = cur;
}
else
{
a[rear[k]].link = cur; //前一个值为k的元素的link = cur
rear[k] = cur; //当前值作为下一个元素前驱
}
}
/* 搜集 */
j = 0;
while (front[j] == 0) j++; //不等于0的才有值
a[0].link = front[j];//下一次遍历的头
last = rear[j]; //尾指针
for (j = j + 1; j < R; j++)
{
if (front[j] != 0) //不等于0的才有值
{
a[last].link = front[j]; //上一个的尾指针的link等于下一个的头指针,连接起来
last = rear[j];
}
}
a[last].link = 0;
}
}
//基数排序测试
void radix_sort_test()
{
static_list_node_t a[] = { {0, 0}, {373, 0}, {173, 0}, {273, 0}, {73, 0},
{53, 0}, {184, 0}, {505, 0}, {269, 0}, {8, 0}, {83, 0}
};
radix_sort(a, R, 3);
static_list_print(a);
}
七、对比
排序方法 | 平均时间 | 最坏情况 | 辅助存储 | 是否稳定 |
---|---|---|---|---|
直接插入排序 | O(n^2) | O(n^2) | O(1) | 是 |
折半插入排序 | O(n^2) | O(n^2) | O(1) | 是 |
希尔插入排序 | N/A | N/A | O(1) | 否 |
冒泡排序 | O(n^2) | O(n^2) | O(1) | 是 |
快速排序 | O(nlogn) | O(n^2) | O(nlogn) | 否 |
简单选择排序 | O(n^2) | O(n^2) | O(1) | 否 |
堆排序 | O(nlogn) | O(nlogn) | O(1) | 否 |
归并排序 | O(nlogn) | O(nlogn) | O(n) | 是 |
基数排序 | O(d*(n+R)) | O(d*(n+R)) | O( R ) | 是 |