目录
排序算法性能比较
一些小结论:
(1)除上述之外,折半插入排序算法:
最坏时间复杂度为O(n²),平均时间复杂度O(n²),最好时间复杂度O(nlogn),空间O(1),也是一种稳定的排序算法
(2)可以发现有用到二叉树思想的算法(快、堆、归),其三种情况下的时间复杂度基本都在(除了快速排序在最坏情况下会退化到O(n²))
(3)具有稳定性的算法:合插基冒(在这家店喝茶几毛钱一直是稳定的)
(4)若n较小,可采用直接插入排序或简单选择排序。又由于直接插入排序所需的记录移动次数较简单选择排序的多,因此当记录本身信息量较大时,用简单选择排序较好。
(5)若文件的初始状态已按关键字基本有序,则选用直接插入排序或冒泡排序
(6)若n较大,则应采用时间复杂度为的排序方法:快速排序、堆排序或合并排序。
(7)当待排序的关键字随机分布时,快速排序的平均时间最短,平均性能最优;但当排序的关键字基本有序或基本逆序时,会得到最坏的时间复杂度和最坏的空间复杂度
(8)堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况
(9)若要求排序稳定,并且时间复杂度为,则可选用合并排序
(10)当数据量较大时,可以将合并排序和直接插入排序结合使用,先利用直接插入排序求得较长的有序子文件,然后再使用合并排序两两合并,由于两种方法都是稳定的,因此结合之后也是稳定的。
(11)当n很大,记录的关键字位数较少且可以分解时,采用基数排序较好
(12)当记录本身信息量较大时,为避免耗费大量时间移动记录,可以使用链表作为存储结构(当然,有的排序方法不适用于链表)
插入排序
直接插入排序
代码如下
#define maxsize 100
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27, 49 };
int n = 8;
//第一个元素a[0]可以看成一个已经排序好的子序列,因此这里可以从第二个元素开始
for (int i = 1; i < n; i++)
{
int temp = a[i];
int j;
//遇到 <= 就退出,可以保持排序的稳定性
for (j = i - 1; j >= 0 && a[j] > temp; j--)
{
a[j + 1] = a[j];
}
//注意,在上面的循环退出之前,j会多减一次,例如当a[0] > temp时依旧会进入循环,最终j = -1,
//因此a[j + 1]才是应该插入的位置
a[j + 1] = temp;
}
for (int i = 0; i < n; i++)
{
cout << a[i] << " ";
}
return 0;
}
输出为:
效率分析:
若采用链表存储,插入排序时虽然元素的移动次数减少了,但是比较次数还是不变,因此平均时间复杂度依旧是(n²)
折半插入排序
在上述的直接插入排序中,总是边比较边移动元素,在折半插入中将比较和移动操作分离,即先用二分查找找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素,最后再插入。
#define maxsize 100
//二分查找,寻找tmp应该插入的位置
int bin_locate(int a[], int low, int high, int tmp)
{
while (low <= high)
{
int mid = (low + high) / 2;
//注意与一般的二分查找不同的是,为了保持算法的稳定性,这里当tmp == a[mid]时应该继续向右寻找
//例如对于[1, 3, 3, 4]这个数组,如果要插入tmp == 3,那么第一次找到的是第一个3,但是右边可能还有3,所以应该继续向右寻找
if (tmp >= a[mid]) low = mid + 1;
else if (tmp < a[mid]) high = mid - 1;
}
//自己在草稿纸上试了几个例子,最终插入的位置应该是high + 1
return high + 1;
}
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 49, 13, 27, 49 };
int n = 9;
//第一个元素a[0]可以看成一个已经排序好的子序列,因此这里可以从第二个元素开始
for (int i = 1; i < n; i++)
{
int tmp = a[i], j;
//使用二分查找,寻找tmp应该插入的位置k
int k = bin_locate(a, 0, i - 1, tmp);
//将k位置之后的元素都向后移动一位
for (j = i - 1; j >= k; j--)
{
a[j + 1] = a[j];
}
//注意,在上面的循环退出之前,j会多减一次,例如当a[k] > temp时依旧会进入循环,最终j = k - 1,
//因此a[j + 1]才是应该插入的位置
a[j + 1] = tmp;
}
for (int i = 0; i < n; i++)
{
cout << a[i] << " ";
};
return 0;
}
输出为:
效率分析:
最坏时间复杂度为O(n²),平均时间复杂度O(n²),最好时间复杂度O(nlogn),空间O(1),也是一种稳定的排序算法。
在最好情况下,也就是当元素的初始顺序已经排序好时,虽然不需要移动元素,但由于比较关键字是通过二分查找比较的,因此时间复杂度需要O(nlogn),就这种情况来说时间复杂度不如直接插入排序。
希尔排序
我这里的写法与王道和吉大教材上的都略有不同,我是严格按照希尔排序的定义来写的,先确定渐减增量,再确定分组,然后对每组分别使用直接插入排序算法,只要搞懂希尔排序的定义,这样写看上去会更好理解。
代码如下:
#define maxsize 100
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27, 49 };
int n = 8;
for (int d = n / 2; d >= 1; d /= 2) //步长变化,逐次减半至1,当d为1时即直接插入排序
{
//当步长为d时,最多可以分为d个子表,而a[0] ~ a[d - 1]分别是这d个子表的首元素
for (int k = 0; k <= d - 1; k++)
{
//每个子表的第一个元素a[k]可以看成一个已经排序好的子序列,因此这里可以从第二个元素a[k + d]开始
for (int i = k + d; i < n; i += d)
{
int temp = a[i], j;
//在访问子表中的相邻元素时要注意步长是d
for (j = i - d; j >= 0 && a[j] > temp; j -= d)
{
a[j + d] = a[j];
}
//在上面的循环退出之前,j会多减一次d,例如当子表的首元素a[k] > temp时依旧会进入循环,最终j = k-d,
//因此a[j + d]才是应该插入的位置
a[j + d] = temp;
}
}
}
for (int i = 0; i < n; i++)
{
cout << a[i] << " ";
}
return 0;
}
输出为:
效率分析及总结:
交换排序
冒泡排序
①以下是吉大程序设计基础教材上的写法,也是我以前用的最多的一种写法
#define maxsize 100
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27, 49 };
int n = 8;
bool flag = true;
while (flag)
{
flag = false;
//只有在a[j] > a[j+1]时才交换,可以保证算法的稳定性
for (int i = 0; i < n - 1; i++)
{
if (a[i] > a[i + 1])
{
flag = true;
int temp = a[i];
a[i] = a[i + 1];
a[i + 1] = temp;
}
}
}
for (int i = 0; i < n; i++)
{
printf("%d ", a[i]);
}
return 0;
}
②以下是吉大数据结构这本教材的写法,这种写法与王道书上的相反,每趟排序过程会有一个较大元素被放到数组的末尾。
#define maxsize 100
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27, 49 };
int n = 8;
//根据冒泡排序的特性,n - 1趟排序之后,一定能把相对较大的n - 1个元素都排序好并放到数组的后面,
//因此循环n - 1次即可完成排序
for (int i = n - 1; i >= 1; i--)
{
bool flag = false;
for (int j = 0; j < i; j++)
{
//只有在a[j] > a[j+1]时才交换,可以保证算法的稳定性
if (a[j] > a[j + 1])
{
int temp = a[j];
a[j] = a[j + 1];
a[j + 1] = temp;
flag = true;
}
}
if (!flag) break;
}
for (int i = 0; i < n; i++)
{
cout << a[i] << " ";
}
return 0;
}
输出为:
效率分析:
总结
吉大版本的改进的冒泡排序
例如对于数组a = { 07, 02, 09, 08, 05, 12, 13, 14, 16},在执行一趟冒泡之后变为:
{ 02, 07, 08, 05, 09, 12, 13, 14, 16}
最后一次记录交换发生在 a[3] 和 a[4] 之间,这说明从 a[4] 开始,其后面的所有元素都已经排序完毕了,因此下一趟冒泡只需循环到 a[3] 即可。依照此思想,可以用一个变量 BOUND 记录每趟冒泡之后,最后一次发生元素交换的位置,下一趟冒泡循环到这个位置即可。
普通版本的冒泡相当于是BOUND从n - 1逐渐递减到0或1,而这种改进的冒泡中BOUND能够提前减少到0,也就提高了效率,并且这个改进比普通冒泡中使用flag变量更快一些。
#define maxsize 100
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27, 49 };
int n = 8;
int BOUND = n - 1; //记录每趟冒泡的终止位置,由于书上的下标是从1开始,而这里是从0开始的,所以这里初值为n - 1
while (BOUND) //在终止位置不为0,即所有元素还未排序完毕之前循环
{
int t = 0; //t用来记录每次交换元素的位置
for (int j = 0; j < BOUND; j++)
{
//只有在a[j] > a[j+1]时才交换,可以保证算法的稳定性
if (a[j] > a[j + 1])
{
int temp = a[j];
a[j] = a[j + 1];
a[j + 1] = temp;
t = j; // t = j 表示a[j]和a[j+1]交换了位置
}
}
BOUND = t; //此时a[BOUND](不含自身)之后的元素都已经排序完毕
}
for (int i = 0; i < n; i++)
{
cout << a[i] << " ";
}
return 0;
输出如下:
吉大版本的交替冒泡排序
所谓的交替冒泡,就是把大元素上浮(移到后面)和小元素下沉(移到前面)两种操作交替进行,可以改善排序效率,一般情况下交替冒泡排序要优于单纯上浮或下沉的冒泡排序。
代码如下:
#define maxsize 100
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27, 49 };
int n = 8;
//i是排在末尾的大元素的指针,j是排在前面的小元素的指针,[i, j]区间内的元素为尚未排序的元素
int i = n - 1, j = 0;
int tag = -1; //tag为-1时进行大元素的上浮操作,tag为1时进行小元素的下沉操作
while (i > j)
{
int flag = 0;
if (tag == -1) //大元素的上浮操作
{
for (int k = j; k < i; k++)
{
if (a[k] > a[k + 1])
{
flag = 1;
int temp = a[k];
a[k] = a[k + 1];
a[k + 1] = temp;
}
}
i--; //每排完一个元素,记得移动指针
}
else if (tag == 1) //小元素的下沉操作
{
for (int k = i; k > j; k--)
{
if (a[k] < a[k - 1])
{
flag = 1;
int temp = a[k];
a[k] = a[k - 1];
a[k - 1] = temp;
}
}
j++; //每排完一个元素,记得移动指针
}
tag = -tag; //更换下次的操作
if (!flag) break; //如果未发生交换,说明已经排序完毕,可以提前退出
}
for (int i = 0; i < n; i++)
{
printf("%d ", a[i]);
}
return 0;
}
输出如下:
链表版本的冒泡排序
写代码题时自己总结的版本
#define maxsize 100
typedef struct node
{
int val;
struct node* next;
}node;
node* head = (node*)malloc(sizeof(node));
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27, 49 };
head->next = NULL;
//创建链表
for (int i = 0; i < 8; i++)
{
node* temp = (node*)malloc(sizeof(node));
temp->val = a[i];
temp->next = head->next;
head->next = temp;
}
//冒泡排序
int n = 0;
for (node* it = head->next; it != NULL; it = it->next)
{
n++; //先遍历计算节点的个数
}
//注意,外层循环不能用如下这条语句!因为发生节点交换后,it也不一定是原来的it了!
//for (node* it = head->next; it != NULL; it = it->next)
//i无实际意义,外层循环只需要用来计算遍历次数,因为每次冒泡确定一个数,那么至少需要n - 1次排序
for (int i = 0; i < n - 1; i++) //外层循环
{
int flag = 0;
//需要用到三个指针first->second->third,排序时交换的是second和third指针,first指针用于辅助交换节点
node* first = head, * second = first->next, * third = second->next;
while (third != NULL) //内层循环
{
//与普通的冒泡排序一样,只有在>时交换,可以保证稳定性
if (second->val > third->val)
{
flag = 1;
//下面三条语句发生节点交换,三个指针变为:first->third->second
first->next = third;
second->next = third->next;
third->next = second;
}
//指针移动,发生节点交换和不发生交换时三个指针的先后顺序是不同的,不能随便交换
//下面这种移动方式无论节点是否发生交换都适用
first = first->next; //由于first指针一直是在第一位,因此可以先移动first指针
second = first->next;//然后另外两个节点再依照first指针移动
third = second->next;
}
if (!flag) break; //与普通的冒泡排序相同,当不再发生交换时退出
}
for (node* it = head->next; it != NULL; it = it->next)
{
printf("%d ", it->val);
}
return 0;
}
注释中标注了自己所遇到的坑点,还有一个地方解释一下:
//需要用到三个指针first->second->third,排序时交换的是second和third指针,first指针用于辅助交换节点
node* first = head, * second = first->next, * third = second->next;
这里在赋值时不需要担心first和second为NULL会导致赋值出错,首先first初值赋为哨兵节点肯定不为NULL,其次如果second为NULL则说明这个链表只有一个哨兵节点,n = 0,因此根本不会进入外层循环。
快速排序
代码如下:
#define maxsize 100
//一趟划分
int partition(int a[], int low, int high)
{
//这里可以视为在逻辑上移走了low位置的元素,之后low指向的是一个空元素
int K = a[low]; //用第一个元素作为枢轴,将待排序序列划分为左右两个部分
//何时移动low指针和high指针是有讲究的
//我们每次让指向空元素的指针固定不动,然后移动另外一个指针,再将找到的元素放到空元素位置
//因此让指向空元素的指针不动,可以视为用这个指针来记录空元素的位置,然后可以将找到的元素放到这里来
//而第一次进入大循环前,a[low]位置是空的,因此先移动high指针,之后每次大循环同理
while (low < high)
{
//在low和high指针移动的过程中,也要随时判断两者的大小关系
while (low < high && a[high] >= K)
{
high--; //high指针不断左移直到找到一个比枢轴小的元素
}
//这里可以视为在逻辑上移走了high位置的元素,之后high指向的是一个空元素
a[low] = a[high]; //把比枢轴小的元素移动到左端的空元素位置
while (low < high && a[low] <= K)
{
low++; //low指针不断右移直到直到一个比枢轴大的元素
}
//这里可以视为在逻辑上移走了low位置的元素,之后low指向的是一个空元素
a[high] = a[low]; //把比枢轴大的元素移动到右端的空元素位置
}
//当最终low == high时,a[high]就是枢轴元素应该放置的地方
a[high] = K; //此时a[high]左边的元素都比K小,a[high]右边的元素都比K大,枢轴元素的最终位置已经确定
return high;
}
void quicksort(int a[], int low, int high)
{
if (low < high) //递归跳出的条件
{
int mid = partition(a, low, high); //划分
quicksort(a, low, mid - 1); //划分左子表
quicksort(a, mid + 1, high); //划分右子表
}
}
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27, 49 };
int n = 8;
quicksort(a, 0, n - 1);
for (int i = 0; i < n; i++)
{
cout << a[i] << " ";
}
return 0;
}
输出为:
效率分析:
总结
上图中空间复杂度的最好、最坏情况,以及时间复杂度的最好、最坏情况都写反了
吉大版本的快速排序
说实话,这种写法实在不好懂,我直接背下来吧,遇到代码题中要使用快排就用上面的方法,遇到手写题之类的必须要用吉大的版本的话再用这个方法。
#define maxsize 100
//一趟划分
int partition(int a[], int low, int high)
{
int m = low;
int K = a[low];
high++; //这里可以理解为在a[high + 1]处插入了一个无穷大的数
while (low < high)
{
//和上面的快速排序不一样的地方是,这种方法每趟排序时是先让low进行移动,再让high移动
low++;
while (a[low] <= K) low++; //这里很关键,一定要有等于,不然结果会出错,我也不知道为什么
high--;
while (a[high] > K) high--; //这里不需要等于
if (low < high)
{
int temp = a[low];
a[low] = a[high];
a[high] = temp;
}
}
int temp = a[high]; //最终low = high - 1,a[m]应该放在high的位置,而不是low,我也不知道为什么
a[high] = a[m];
a[m] = temp;
return high;
}
void quicksort(int a[], int low, int high)
{
if (low < high) //递归跳出的条件
{
int mid = partition(a, low, high); //划分
quicksort(a, low, mid - 1); //划分左子表
quicksort(a, mid + 1, high); //划分右子表
}
}
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27, 49 };
int n = 8;
quicksort(a, 0, n - 1);
for (int i = 0; i < n; i++)
{
cout << a[i] << " ";
}
return 0;
}
输出结果:
选择排序
简单选择排序
代码如下
#define maxsize 100
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27, 49 };
int n = 8;
//每次将后面最小的元素放到前面,所以每趟形成的子序列一定是有序的,只需n - 1次循环
for (int i = 0; i < n - 1; i++)
{
int t = i; //记录最小元素的下标
for (int j = i + 1; j < n; j++)
{
if (a[j] < a[t])
{
t = j;
}
}
if (t != i)
{
int temp = a[t];
a[t] = a[i];
a[i] = temp;
}
}
for (int i = 0; i < n; i++)
{
cout << a[i] << " ";
}
return 0;
}
输出为
与直接插入排序的区别:直接插入排序的前i个元素一定是有序子序列,但是不一定是最小的i个元素。而简单选择排序的前i个元素一定是有序子序列,并且也是最小的i个元素。
与冒泡排序的区别:
1.冒泡排序是比较相邻位置的两个数,而简单选择排序是按顺序比较,找最大值或者最小值;
2.冒泡排序每一轮比较后,位置不对都需要换位置,简单选择排序每一轮比较都只需要换一次位置;
3.冒泡排序是通过数去找位置,简单选择排序是给定位置去找数;
效率分析:
总结
堆排序
实现之前先了解一下堆的定义
大根堆的实现思路为:
要注意调整一次之后可能还需向下调整,例如下图中53与左右儿子中较大的87交换之后,53仍然小于两个新的左右儿子65和78,因此还需再进行一次调整
例如对于下图这个例子来说:
建立大根堆为:
堆排序之后为:
代码实现为:
#define maxsize 100
//对以元素k为根的子树进行调整,使其符合大根堆的定义:a[k] >= a[2k], a[k] >= a[2k+1]
void HeapAdjust(int a[], int k, int n)
{
//n是最后一个结点的编号,而⌊n/2⌋就是其父结点的编号
//所以k <= n/2是k为分支结点的条件,如果是叶结点就不用调整了
while (k <= n / 2)
{
//m记录关键字较大的儿子结点的下标,能进入这个循环说明k必有左儿子,先赋为左儿子下标
int m = 2 * k;
//2 * k + 1 <= n是k的右儿子存在的条件,如果右儿子关键字比左儿子的大,就修改m
if (2 * k + 1 <= n && a[2 * k] < a[2 * k + 1])
{
m = 2 * k + 1;
}
if (a[k] < a[m])
{
//将k结点和m结点关键字交换,并继续向下调整
int temp = a[m];
a[m] = a[k];
a[k] = temp;
k = m;
}
else break;
}
}
//堆排序
void HeapSort(int a[], int n)
{
//建立大根堆
//n是最后一个结点的编号,而⌊n/2⌋就是其父结点的编号
//i <= n/2是i为分支结点的条件,对所有以分支结点为根的子树进行调整
for (int i = n / 2; i >= 1; i--)
{
HeapAdjust(a, i, n);
}
//由于每趟排序会确定一个结点的最终位置,因此只要n - 1趟即可排好
for (int len = n; len > 1; len--)
{
//每趟排序将堆顶元素加入有序子序列,即与待排序序列的最后一个元素交换
int temp = a[1];
a[1] = a[len];
a[len] = temp;
//再将剩余len - 1个元素调整为大根堆,这里只需调用一次HeapAdjust函数,因为除了根节点外其他分支结点都满足大根堆的定义
HeapAdjust(a, 1, len - 1);
}
}
int main()
{
//注意二叉树的顺序存储,首元素下标必须从1开始
int a[maxsize] = { 0, 53, 17, 78, 9, 45, 65, 87, 32 };
int n = 8;
HeapSort(a, n);
for (int i = 1; i <= n; i++)
{
cout << a[i] << " ";
}
return 0;
}
输出为:
效率分析:
总结
堆的插入与删除
①以小根堆为例,插入新结点操作:
例如在下图中插入新结点13:
发现13小于32,让结点13上升,调整为:
我一开始对这种上升操作有点疑惑,结点不断上升,就是不断与其父结点交换,那能保证其他结点依然满足小根堆的定义吗?
其实是能的,例如在上图中,13作为17的儿子结点时,发现13要比他的父结点17小,又由于在插入13之前原堆是个小根堆,根据小根堆的定义,17肯定小于他的右儿子45,也小于13的两个儿子53和32,所以13也会比17的右儿子45小,因此将13和17交换完之后,13和17都能够满足比他们新的左右儿子小。
13再次上升,调整为:
此时13大于父节点9,不用再调整了
②删除结点操作:
删除结点的操作较好理解,就是堆排序中的下坠过程
例如下图中,删除第二个结点后让堆底元素46代替,然后调整以46位根节点的子树即可
最终调整为:
自定义优先队列
虽然stl中有priority_queue可用,但之前考研时在学会了堆排序后就一直想自己动手实现优先队列,那时到网上一搜,发现别人的代码写的老长又少有注释就懒得再看,这次趁着在力扣上写到一道有关优先队列的题目,自己动手实现一下。
自定义优先队列与堆排序的部分操作相同,都需要下沉操作,区别在于优先队列中还需用到上浮操作(有关上浮和下沉的操作过程参考上一小节“堆的插入与删除”)
代码如下:
//自定义优先队列类
//注意:优先队列的队首元素一定是最大/最小的元素,但其余元素不一定有序,所以如果打印类中的nums数组后发现数组无序,这是正常现象
class Priqueue
{
private:
vector<int> nums;
int size = 0;
bool ismax = true; //true表示大根堆,false表示小根堆
public:
Priqueue(bool ismax) //初始化优先队列,传入true选择大根堆,传入false选择小根堆
{
this->ismax = ismax;
nums.push_back(0); //堆的下标应从1开始,所以先压入一个0
}
int sizeofque()
{
return size;
}
bool cmp(int x, int y) //根据ismax选择比较方式
{
//如果是大根堆,则x > y时为真;如果是小根堆,则x < y时为真
return ismax ? x > y : x < y;
}
//压入操作
void push(int num)
{
//先在末尾插入新元素,然后再上浮
nums.push_back(num);
size++;
//上浮
int k = size;
//无论k所在的节点是父节点的左儿子还是右儿子,k/2必然是该节点的父节点编号
//如果子节点比父节点的关键字大/小,就上浮
while (k > 1 && cmp(nums[k], nums[k / 2]))
{
//将k结点和k/2结点关键字交换,并继续向上调整
int tmp = nums[k / 2];
nums[k / 2] = nums[k];
nums[k] = tmp;
k /= 2;
}
}
//弹出操作
int pop()
{
int res = nums[1];
//先将最后一个元素放到第一个位置,然后再下沉
nums[1] = nums[size];
nums.pop_back();
size--;
//下沉
//size是最后一个结点的编号,而⌊size/2⌋就是其父结点的编号
//所以k <= size/2是k为分支结点的条件,如果是叶结点就不用调整了
int k = 1;
while (k <= size / 2)
{
//m记录关键字较大/小的儿子结点的下标,能进入这个循环说明k必有左儿子,先赋为左儿子下标
int m = 2 * k;
//2 * k + 1 <= n是k的右儿子存在的条件,如果右儿子关键字比左儿子的大/小,就修改m
if (2 * k + 1 <= size && cmp(nums[2 * k + 1], nums[2 * k]))
{
m = 2 * k + 1;
}
if (cmp(nums[m], nums[k]))
{
//将k结点和m结点关键字交换,并继续向下调整
int tmp = nums[m];
nums[m] = nums[k];
nums[k] = tmp;
k = m;
}
else break;
}
return res;
}
//访问队首元素
int top()
{
return nums[1];
}
};
合并排序
合并排序
代码如下:
#define maxsize 100
int b[maxsize]; //辅助数组,可以重复使用多次
//将a的两个有序子表a[low..mid]和a[mid+1..high],合并为一个有序表
void merge(int a[], int low, int mid, int high)
{
for (int k = low; k <= high; k++)
{
b[k] = a[k];
}
int i = low, j = mid + 1, k = low;
while (i <= mid && j <= high)
{
//在两者等于的情况下,优先选取左侧子表的元素,可以保持排序的稳定性
if (b[i] <= b[j])
{
a[k++] = b[i++];
}
else
{
a[k++] = b[j++];
}
}
while (i <= mid) a[k++] = b[i++];
while (j <= high) a[k++] = b[j++];
}
//合并排序
void mergesort(int a[], int low, int high)
{
if (low < high)
{
int mid = (low + high) / 2;
mergesort(a, low, mid); //对左侧子序列进行递归排序,这个地方不是二分查找中的mid - 1,否则会漏掉一个数字
mergesort(a, mid + 1, high); //对右侧子序列进行递归排序
merge(a, low, mid, high); //合并
}
}
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27 };
int n = 7;
mergesort(a, 0, n - 1);
for (int i = 0; i < n; i++)
{
cout << a[i] << " ";
}
return 0;
}
输出为:
效率分析
总结
链表版本的合并排序
做真题时遇到的题目,自己试了一下还是不简单的,思想和普通的数组合并排序一样,但是在具体的细节上若照搬普通的合并排序将会遇到很大的困难。
以下这种实现方法的一个小特点就是每次划分链表时要将一条大链表划分为两条单独的、尾节点都指向空的链表,合并时也是将这样的两条链表合并。我原本是打算直接在原链表的基础上移动节点的,但是在实现时发现很麻烦,可操作性不高。
#define maxsize 100
typedef struct node
{
int val;
node* next;
}node;
//这里的left和right指的是两条尾节点都指向空的链表,与数组版的合并排序中的left和right的意思不同
node* merge(node* left, node* right)
{
node* curhead = (node*)malloc(sizeof(node));
node* curtail = curhead;
node* it1 = left, * it2 = right;
while (it1 != NULL && it2 != NULL)
{
if (it1->val <= it2->val)
{
curtail->next = it1;
curtail = it1;
it1 = it1->next;
}
else
{
curtail->next = it2;
curtail = it2;
it2 = it2->next;
}
}
while (it1 != NULL)
{
curtail->next = it1;
curtail = it1;
it1 = it1->next;
}
while (it2 != NULL)
{
curtail->next = it2;
curtail = it2;
it2 = it2->next;
}
curtail->next = NULL; //记得将合并后的新链表的尾节点指向空
return curhead->next;
}
node* mergesort(node* cur)
{
//注意,cur->next为空的时候说明链表中只有一个节点,已经可以返回了
//如果判断的是cur是否为空,那么由于这个写法的原因,cur必不可能为NULL,所以会出现无限递归的情况
if (cur->next == NULL) return cur;
node* slow = cur, * fast = cur; //快慢指针
node* lefttail = slow; //标志左边的链表的尾节点,方便待会将链表一分为二后,将左边的链表的尾节点指向空
//经典的一次遍历找中间结点的方法
while (fast != NULL && fast->next != NULL)
{
lefttail = slow;
slow = slow->next;
fast = fast->next->next;
}
lefttail->next = NULL; //将左边的链表的尾节点指向空
node* left = mergesort(cur); //递归排序左边的链表
node* right = mergesort(slow); //递归排序右边的链表
return merge(left, right); //合并左右两条链表
}
int main()
{
int a[maxsize] = { 49, 38, 65, 97, 76, 13, 27 };
int n = 7;
node* head = (node*)malloc(sizeof(node));
node* tail = head;
for (int i = 0; i < n; i++)
{
node* tmp = (node*)malloc(sizeof(node));
tmp->val = a[i];
tail->next = tmp;
tail = tmp;
}
tail->next = NULL;
printf("排序前:\n");
for (node* it = head->next; it != NULL; it = it->next)
{
printf("%d ", it->val);
}printf("\n\n");
head->next = mergesort(head->next);
printf("排序后:\n");
for (node* it = head->next; it != NULL; it = it->next)
{
printf("%d ", it->val);
}
return 0;
}
结果如下:
基数排序(分布排序)
基数排序的操作为:
效率分析(吉大教材上计算时间复杂度时不考虑基数r,只算O(nd),用数字来理解就是有n数字,每个数有d位):
基数排序不只是用于排序若干个数字,其思想也可以用于其他信息的排序
基数排序的适用情况:
总结