本文是本人读过《算法导论》之后所写,C代码实现是我尽量参照伪代码所写,如有错误,敬请指正。
*:所有排序算法默认从小到大排序,伪代码数组的首元素为A[1], 数组长度为n
一、冒泡排序
冒泡排序应该是最简单的比较排序了,排序原理就是重复遍历数组,每次比较相邻的两个元素,如果前一个元素大于后一个元素,则交换数组两个元素的位置。这样每遍历一次,最大的元素就会下沉到数组最底部,重复遍历n-1次,所有元素就都已排好序了。
伪代码:
1. for i = 1 to n-1
2. for j = 1 to n-i
3. if A[j] > A[j+1]
4. exchange A[j] with A[j+1]
伪代码讲解:
第一行控制遍历轮数;
第二行控制需要比较的数组元素下标范围;
第三四行,当相邻的两个元素不满足比较条件时,交换两个元素的位置
C代码:
/*Author:Terry Zhang*/View Code
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
size_t n = 0;
scanf_s("%d", &n);
int *p = (int *)calloc(n, sizeof(int));
for (size_t i = 0; i < n; i++)
{
scanf_s("%d", p + i);
}
int * p0 = p;
for (size_t i = 0; i < n-1; i++)
{
for (size_t j = 0; j < n-i-1; j++)
{
int t = 0;
if (p[j] > p[j + 1])
{
t = p[j];
p[j] = p[j + 1];
p[j + 1] = t;
}
}
}
p0 = p;
for (size_t i = 0; i < n; i++)
{
printf("%d ", *(p0++));
}
printf("\n");
free(p);
return 0;
}
二、选择排序
选择排序是一种简单直观的排序算法,排序思路是,第一次通过比较选出数组中最小的元素放在数组的起始位置,接着比较剩下的元素选出最小的元素放在已经排好序的序列后面,以此类推,直到所有元素排序完毕。
伪代码:
1. for i=1 to n-1
2. min = i;
3. for j=i+1 to n
4. if A[j] < A[min]
5. min = j;
6. if min != i
7. exchange A[i] with A[min]
伪代码讲解:
第一行控制遍历轮数;
第二行将最小值下标设置为当前未排序数组下标的第一个;
第四五行,如果发现比当前最小值小的元素,则将min更新;
第六七行,如果最小值下标和当前未排序数组下标的第一个(j)不等,则交换A[i]和A[min],即将最小值移动到已排序数组末尾
C代码:
#include <stdio.h>View Code
#include <stdlib.h>
int main(void)
{
size_t n = 0;
scanf_s("%d", &n);
int *p = (int *)calloc(n, sizeof(int));
for (size_t i = 0; i < n; i++)
{
scanf_s("%d", p + i);
}
for (size_t j = 0; j < n - 1; j++)
{
int min = j;
for (size_t k = j + 1; k < n; k++)
{
if (p[k] <= p[min])
min = k;
}
if (min != j)
{
int t = p[j];
p[j] = p[min];
p[min] = t;
}
}
int * p0 = p;
for (size_t i = 0; i < n; i++)
{
printf("%d ", *(p0++));
}
printf("\n");
free(p);
return 0;
}
三、插入排序
插入排序的排序思想是,通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
伪代码:
1. for i = 2 to n
2. key = A[i];
3. j = i - 1;
4. while j >= 1 and A[j] > key
5. A[j+1] = A[j];
6. j = j - 1;
7. A[j + 1] = key;
伪代码讲解:
第一行控制循环轮数;
第二行key为当前需要插入的元素;
第四到六行,将比key大的元素依次后移,直到遇到第一个不大于key的元素跳出循环;
第七行将需要插入的元素key插入到在已排序好的序列中应有的位置
C代码:
/*Author:Terry Zhang*/View Code
#include<stdio.h>
#include<stdlib.h>
int main(void)
{
//输入n个数
size_t n = 0;
scanf_s("%d",&n);
int *p;
p = (int *)calloc(n, sizeof(int));
for (size_t i = 0; i < n; i++)
{
scanf_s("%d", p + i);
}
//排序
int key = 0, i = 0;
for (size_t j = 1; j < n; j++)
{
key = p[j];
i = j - 1;
while (i >= 0 && p[i] > key)
{
p[i + 1] = p[i];
i--;
}
p[i + 1] = key;
}
//格式化输出
int *p0 = p;
for (size_t i = 0; i < n; i++)
{
printf("%d ",*p0++);
}
printf("\n");
free(p);
return 0;
}
四、堆排序
堆排序像插入排序而不像归并排序(见第五种排序),它是一种原址排序算法(元素的相对位置排序前后不发生变化);像归并排序而不像插入排序,堆排序运行时间为O(nlgn). (二叉)堆数据结构是一种数据对象,它可以被视为一棵近似的完全二叉树,树中每一个元素分别对应数组中的一个元素,且从左到右依次排列。这样给定一个节点的下标i,我们很容易计算得到它的父节点、左孩子和右孩子的下标。在排序算法中我们使用最大堆(满足A[Parent(i)] >= A[i]),最小堆用于构造优先队列。
Parent(i)
return
Left(i)
return 2i
Right
return 2i+1
下面我们介绍排序算法中需要用到的三个函数
1. ManHeapify过程:时间复杂度为O(lgn), 它是维护最大堆性质的关键
2. BuildMaxHeap过程:它具有线性时间复杂度,功能是从无序的数组中构造一个最大堆
3.HeapSort排序过程:时间复杂度为O(nlgn),功能是对一个数组进行原址排序
维护堆的性质:
我们通过MaxHeapify函数来维护堆的性质,我们假定根节点为Left(i)和Right(i)的二叉树都是最大堆,此时A[i]可能小于它的孩子,此函数的目的就是让A[i]在最大堆中“逐级下降”,从而使得以下标i为根节点的子树重新遵循最大堆的性质。
伪代码:
MaxHeapify(A,i)
1. l = Left(i)
2. r = Right(i)
3. if l <= n and A[l] > A[i]
4. largest = l
5. else largest = i
6. if r <= n and A[r] > A[largest]
7. largest = r
8. if largest != i
9. exchange A[i] with A[largest]
10 MaxHeapify(A,largest)
伪代码解释:
第一二行获取根节点i的左右孩子的下标;
第三行到七行比较根节点与左右孩子的大小,更新largest为三者中最大元素的下标;
第八九行,如果根节点largest!= i,即根节点小于某个孩子,则交换根节点与A[largest]的位置;
第十行,重复1~9
建堆:
我们可以利用自底向上的方法利用过程Maxheapify把一个大小为n的数组转换为最大堆,通过计算我们知道,A[n/2 + 1] to A[n]都是叶结点,而每个叶节点可以看成是只有一个元素的堆。该过程对树中其他结点都调用一次MaxHeapify从范围完成建堆过程
伪代码:
BuildMaxHeap(A)
1. for i = n/2 downto 1
2. MaxHeapify(A,i)
堆排序算法:
我们已经直到最大堆的一个最重要的性质就是根节点永远大于等于子结点,也就是说最大的元素永远在根节点A[1]处,我们可以利用这一性质对数组进行排序。排序的方法是交换A[1]和A[n]的位置,之后去掉结点n,维持堆的性质,之后重复此过程,最终完成排序。
伪代码:
HeapSort(A)
1. BuildMaxHeap(A)
2. for i = A.length downto 2
3. exchange A[1] with A[i]
4. A.heap_size = A.heap_size - 1
5. MaxHeapify(A,1)
完整的C代码如下:
/*Authority:Terry Zhang*/View Cod
#include <stdio.h>
#include <stdlib.h>
int main()
{
size_t n = 0;
scanf_s("%d", &n);
int *p = (int *)calloc(n, sizeof(int));
for (size_t i = 0; i < n; i++)
{
scanf_s("%d", p + i);
}
void BuildMaxHeap(int *A, int n);
void HeapSort(int *A, int n);
HeapSort(p, n);
int *p0 = p;
for (size_t i = 0; i < n; i++)
{
printf("%d ", *p0++);
}
free(p);
return 0;
}
//maintain max heap function
void MaxHeapify(int *A, int n, int i)
{
int Left(int i);
int Right(int i);
void Swap(int *A, int i, int j);
int l = Left(i); //get the subscript of its left child
int r = Right(i); //get the subscript of its right child
int largest;
if (l < n && A[l] > A[i]) //n = A.heap_size
largest = l;
else
largest = i;
if (r < n && A[r] > A[largest]) //n = A.heap_size
largest = r;
if (largest != i)
{
Swap(A, i, largest);
MaxHeapify(A, n, largest);
}
}
//build max heap
void BuildMaxHeap(int *A, int n)
{
for (int i = n / 2 - 1; i >= 0; i--)
{
MaxHeapify(A, n, i);
}
}
//heap sort
void HeapSort(int *A,int n)
{
BuildMaxHeap(A, n);
void Swap(int *A, int i, int j);
for (int i = n - 1; i >= 1; i--) //n -1 to 1
{
Swap(A, 0, i);
MaxHeapify(A, i, 0);
}
}
//return the subscript of i's left child
int Left(int i)
{
return (i <<= 1) + 1;
}
//return the subscipts of i's right child
int Right(int i)
{
return (i <<= 1) + 2;
}
void Swap(int *A, int i, int j)
{
int temp = A[i];
A[i] = A[j];
A[j] = temp;
}
五、归并排序
归并排序是一种使用分治法(divide and conquer)的排序算法,分治法的思想是将原有的一个规模比较大的问题,分解成若干个规模较小但又类似于原问题的子问题,然后求解子问题,再合并子问题的解来建立原问题的解。
分治模式在每层递归中都有三个步骤:
1. 分解原问题为若干子问题,这些子问题都是原问题规模较小的实例
2. 解决这些子问题。递归地求解子问题,当子问题规模足够小时则直接求解
3. 合并这些子问题的解形成原问题的解
归并排序完全符合分治模式
1. 分解:待排序的n个元素的序列成各具n/2个元素的两个子序列
2. 解决:使用归并排序递归的排序两个子序列
3. 合并:合并两个已排好的子序列产生已排序的整个序列
当待排序的序列长度为1时,递归“开始回升”,因为长度为1的序列可以被认为是已经排好序的了。
归并排序中最关键的步骤是合并,我们通过一个辅助过程Merge(A,p,q,r)完成,p,q,r为数组下标,满足p<=q<r。我们假定A[p,q]和A[q+1,r]都是已经排好序的,这样通过Merge过程合并两个子数组形成一个排好序的新数组A[p,r]来取代原有的数组。我们可以以玩扑克牌为例,假设桌子上有两堆牌面朝上的牌,每堆都是已经排好序的,最小的牌在最上面。合并操作的过程为,每次比较最上面的两张牌,将较小的牌牌面朝下放在桌子上,当一堆牌为空时,将另一堆牌直接放到输出堆中则完成了整个合并过程。为了避免每次都要检查两堆牌是否为空,我们在每堆牌的底部放置一个“哨兵牌”,我们以《无穷大》为哨兵值,哨兵牌不小于任何牌。下面的伪代码实现了这一思想:
伪代码:
Merge(A,p,q,r)
1. n1 = q - p + 1
2. n2 = r - q
3. Let L[1..n1+1] and R[1..n2+1] be new arrays
4. for i = 1 to n1
5. L[i] = A[p+i-1]
6. for j = 1 to n2
7. R[j] = A[q+j]
8. L[n1+1] = 无穷大
9. R[n2+1] = 无穷大
10. i = 1
11. j = 1
12. for k = p to r
13. if L[i] <= R[j]
14. A[k] = L[i]
15. i = i + 1
16. else A[k] = R[j]
17. j = j + 1
伪代码解释:
第一二行计算两个子数组的长度;
第三行创建两个新数组L,R,并且分别创建一个额外位置来存取哨兵;
第四行到第七行将两个子数组拷贝到两个新数组L,R中;
第八九行设置哨兵值;
第十二到十七行完成合并过程
完成这个辅助过程,我们就会很容易写出归并排序算法的伪代码:
MergeSort(A,p,r)
1. if p < r
2. q = (p+r)/2
3. MergeSort(A,p,q)
4. MergeSort(A,q+1,r)
5. Merge(A,p,q,r)
完整C代码如下:
/*Author:Terry Zhang*/View Code
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#include <math.h>
void Merge(int *A, size_t p, size_t q, size_t r);
void MergeSort(int *A, size_t p, size_t r);
int main(void)
{
size_t n;
scanf_s("%d", &n);
int *p = (int*)calloc(n, sizeof(int));
for (size_t i = 0; i < n; i++)
{
scanf_s("%d",p + i);
}
MergeSort(p, 0, n - 1);
//output
int *p0 = p;
for (size_t i = 0; i < n; i++)
{
printf("%d ", *p0++);
}
printf("\n");
//free
free(p);
return 0;
}
//Merge Sort
void MergeSort(int *A, size_t p, size_t r)
{
if (p < r)
{
size_t q = (p + r) / 2;
MergeSort(A, p, q);
MergeSort(A, q + 1, r);
Merge(A, p, q, r);
}
}
//auxiliary procedure
void Merge(int *A, size_t p, size_t q, size_t r)
{
size_t n1 = q - p + 1;
size_t n2 = r - q;
size_t i = 0;
size_t j = 0;
int *L = (int *)calloc(n1 + 1, sizeof(int));
int *R = (int *)calloc(n2 + 1, sizeof(int));
for (i = 0; i < n1; i++)
{
L[i] = A[p+i];
}
for (j = 0; j < n2; j++)
{
R[j] = A[q+1+j];
}
L[i] = INT_MAX; //not L[i+1]
R[j] = INT_MAX;
i = 0;
j = 0;
for (size_t k = p; k <= r; k++)
{
if (L[i] <= R[j])
A[k] = L[i++];
else
A[k] = R[j++];
}
free(L);
free(R);
}
六、快速排序
快速排序,名副其实,它是最广泛使用的一种快速排序算法。快速排序最坏情况下的时间复杂度和插入排序一样,但是它的平均时间复杂度却和归并排序一样。具体的原因可以参见《算法导论》,书中有详细的数学证明。
快速排序同归并排序一样,也是采用了分治思想。同样,我们以分治思想的三个必要步骤来解释这个算法:
1. 分解:数组A[p...r] 被分解成两个子数组A[p...q-1]和A[q+1...r],使得前一个数组中的每一个元素都小于等于A[q],后一个数组中的每一个元素都大于等于A[q],其中计算下标q也是划分过程的一部分
2. 解决:通过递归调用快速排序,对两个子数组进行排序
3. 合并: 因为子数组都是排好序的,所以不需要合并操作。
实质上,快速排序在划分的过程中也是在排序的过程中,这一点是它不同于归并排序的地方,也是不需要合并的原因。
快速排序的伪代码:
QuikSort(A,p,r)
1. if p < r
2. q = Partition(A,p,r)
3. QuickSort(A,p,q-1)
4. QuickSort(A,q+1,r)
由此可将,数组划分是快速排序算法最关键的部分
数组划分的伪代码:
Partition(A,p,r)
1. key = A[r]
2. i = p-1;
3. for j = p to r-1
4. if A[j] <= key
5. i = i + 1
6. exchange A[i] with A[j]
7. exchange A[i+1] with A[r]
8. return i + 1
伪代码讲解:
第一行我们设置数组最后一个元素为划分的主元key;
第二行初始化变量i,i在划分过程中用来表示比key小的元素应该放置的位置下标,即数组前面的部分;
第三到六行,j用来遍历数组中的元素,当发现比key小的元素A[j]时就将其依次放到数组前面部分,即与A[i] 交换,这样完成后数组前面部分都是小于等于key的元素;
第七行,将A[r]即key交换到所有小于等于key的元素的后面,这样整个数组就被key划分了一次,key也就恰好被放置到了它最终排序后应该在的位置;
第八行,返回划分位置下标
完整C代码如下:
#include <stdio.h>View Code
#include <stdlib.h>
#include <time.h>
int main(void)
{
size_t n;
scanf_s("%d", &n);
int *p = (int *)calloc(n, sizeof(int));
for (size_t i = 0; i < n; i++)
{
scanf_s("%d", p + i);
}
void QuickSort(int A[], int p, int r);
QuickSort(p, 0, n-1);
//output
int *p0 = p;
for (size_t i = 0; i < n; i++)
{
printf("%d ", *p0++);
}
printf("\n");
free(p);
return 0;
}
void QuickSort(int A[], int p, int r)
{
int Partition(int A[], int p, int r);
if (p < r)
{
int q = Partition(A, p, r);
QuickSort(A, p, q - 1); //q-1 not q
QuickSort(A, q + 1, r);
}
}
void swap(int v[], int i, int j)
{
int temp;
temp = v[j];
v[j] = v[i];
v[i] = temp;
}
int Partition(int A[], int p, int r)
{
int x = A[r];
int i = p - 1;
for (size_t j = p; j <= r-1; j++)
{
if (A[j] <= x)
{
i++;
swap(A, i, j);
}
}
swap(A, i + 1, r);
return i + 1;
}
以上我们的前提假设是输入数据的所有排列都是等概率的,所以我们每次选择数组或者子数组的最后一个元素为主元key,但实际情况这一假设并不总是成立,所以我们要考虑随机选取主元key,随机性的引入使得快读排序对所有的可能输入都会得到一个较好的期望性能。这就是快速排序的随机化版本。
伪代码很简单:
RandomPartition(A,p,r)
1. i= Random(p,r)
2. exchange A[r] with A[r]
3. return Partition(A,p,r)
随机划分的C代码如下:
int Randomized_Partition(int A[], int p, int r)
{
srand((unsigned)time(NULL));
int t = rand() % (r - p) + p;
swap(A, t, r);
return Partition(A, p, r);
}