目录
前言
这篇博客要讲的是排序,那么我们就先来聊聊什么是排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
排序也分为两种:
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
在日常生活中,排序的使用范围也非常广泛:比如网易云音乐的热歌榜,以及我们买基金时会看的基金排行榜等等。
但是排序实现的结果是一样的,但是方法各种各样,实现的原理也是各不相同,在实际运用的不同场景中我们该如何取舍呢?
这就是这篇博客的意义,手把手教大家实现各种排序,让看完的同学可以从底层逻辑上,把经典的排序原理了解的清清楚楚,在使用的过程中可以得心应手。
一般选择排序时,主要从时间复杂度和空间复杂度来考虑,但在这两者相差不大的情况下,稳定性也是一个不可忽略的因素。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
让我们来看看一般的排序有哪些:
选择排序
首先来个开胃小菜,选择排序,这个排序没什么优点,唯一的优点可能就是比较好理解,这个排序就是来搞笑的,平时几乎用不上,不过作为排序的引入是再合适不过了。
实现逻辑
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
通俗来说是通过比较前N个数,选出一个最大的数与第N个位置的数交换,然后又比较前N-1个数,选出一个最大数与第N-1个数交换位置,以此类推,最大的数,次大的数不断向后填充,最终使得排列有序。
代码实现
void SelectSort(int* a, int n)
{
int maxi = 0;
for (int i = n - 1; i > 0; i--)
{
//选出最大值
maxi = 0;
for (int j = 1; j <= i; j++)
{
if (a[j] > a[maxi])
{
maxi = j;
}
}
//将最大值放在待排序列尾端
int temp = a[maxi];
a[maxi] = a[i];
a[i] = temp;
}
}
堆排序
讲到堆排序就要了解什么是堆,如何建立一个堆。堆和二叉树之间又有紧密的联系,这就又要求我们掌握二叉树的相关知识。
接下来让我们先来学习一下二叉树,如果对这部分知识了解的很透彻的同学可以跳过。
二叉树
树的概念及结构
树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
树有一个特殊的结点,称为根结点,根节点没有前驱结点。除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i<= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继节点。
因此,树是递归定义的。(这一点暂且不用管,看完之后就理解了)
可以从图中看出。红色的节点就是根节点,没有前驱节点,12有一个后继节点,11就没有后继节点。
注意:树形结构中,子树之间不能有交集,否则就不是树形结构
上面两张图就不是树。
我们先来了解一下树的一些名词:
节点的度:一个节点含有的子树的个数称为该节点的度; 如上图:A的为6
叶节点或终端节点:度为0的节点称为叶节点; 如上图:B、C、H、I...等节点为叶节点
非终端节点或分支节点:度不为0的节点; 如上图:D、E、F、G...等节点为分支节点
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A是B的父节点
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点
兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点
树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为6
节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推;
树的高度或深度:树中节点的最大层次; 如上图:树的高度为4
堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为兄弟节点
节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先
子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙
森林:由m(m>0)棵互不相交的树的集合称为森林
树的表示
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。我们这里就简单的了解其中最常用的孩子兄弟表示法。
孩子兄弟表示法的节点结构定义如下:
typedef int DataType;
struct Node
{
struct Node* _firstChild1; // 第一个孩子结点
struct Node* _pNextBrother; // 指向其下一个兄弟结点
DataType _data; // 结点中的数据域
};
如图所示,一个节点指向他的兄弟节点和他的第一个子节点,比如说A后面没有兄弟了,A的_pNextBrother指针就指向空,B和C都是A的子节点,A的_firstChild1指向B,因为B是A的第一个子节点,虽然C也是A的子节点,但是A并没有直接指向C,而是通过B的_pNextBrother找兄弟来找到C。由此可知,孩子兄弟表示法的精髓就是——任何一个节点都记录了他的孩子和下一个兄弟的信息。
接下来我们再简单讲讲双亲表示法:这里要用到两个顺序表,一个用来记录树的内容,一个用来记录节点的双亲节点的下标。
这个方法的好处是可以快速找到任何一个节点的所有祖先节点。
二叉树的概念及结构
一棵二叉树是结点的一个有限集合,该集合:
1. 或者为空
2. 由一个根节点加上两棵别称为左子树和右子树的二叉树组成
1. 二叉树不存在度大于2的结点
2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
而且任何二叉树都是以下情况复合而成:
特殊的二叉树
1. 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是 ,则它就是满二叉树。
2. 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
二叉树的性质
1. 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有个结点.
2. 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是.
3. 对任何一棵二叉树, 如果度为0其叶结点个数为n0, 度为2的分支结点个数为n2 ,则有n0=n2+1
4. 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h= . (ps: 是log以2
为底,n+1为对数)
5. 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有:
1. 若i>0,i位置节点的双亲序号:(i-1)/2;i=0,i为根节点编号,无双亲节点
2. 若2i+1<n,左孩子序号:2i+1,2i+1>=n否则无左孩子
3. 若2i+2<n,右孩子序号:2i+2,2i+2>=n否则无右孩子
二叉树的存储结构
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
顺序结构:
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储,关于堆我们后面会专门讲解。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
而非完全二叉树则需要先补成完全二叉树再储存:
链式存储
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链。
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pLeft; // 指向当前节点左孩子
struct BinTreeNode* _pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
};
// 三叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pParent; // 指向当前节点的双亲
struct BinTreeNode* _pLeft; // 指向当前节点左孩子
struct BinTreeNode* _pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
};
其实三叉链相比于二叉链就多了一个指向双亲节点的指针,方便寻找祖先。
二叉树进阶
其实对于堆排序来说以上的二叉树知识已经完全够用了,不想看的同学可以跳过,但学习是一个注重积累的连续的过程,还是希望大佬和同学们帮我看看疏漏。
二叉树结构的实现
前置说明
在学习二叉树的基本操作前,需先要创建一棵二叉树,然后才能学习其相关的基本操作。由于现在大家对二叉树结构掌握还不够深入,为了降低大家学习成本,此处手动快速创建一棵简单的二叉树,快速进入二叉树操作学习,等二叉树结构了解的差不多时,我们反过头再来研究二叉树真正的创建方式。
typedef int BTDataType;
typedef struct BinaryTreeNode
{
BTDataType _data;
struct BinaryTreeNode* _left;
struct BinaryTreeNode* _right;
}BTNode;
BTNode* CreatBinaryTree()
{
BTNode* node1 = BuyNode(1);
BTNode* node2 = BuyNode(2);
BTNode* node3 = BuyNode(3);
BTNode* node4 = BuyNode(4);
BTNode* node5 = BuyNode(5);
BTNode* node6 = BuyNode(6);
node1->_left = node2;
node1->_right = node4;
node2->_left = node3;
node4->_left = node5;
node4->_right = node6;
return node1;
}
如图为构建出来的树。
二叉树的遍历
学习二叉树结构,最简单的方式就是遍历。所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉树中的节点进行相应的操作,并且每个节点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。
按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:
1. 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。
2. 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。
3. 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。
由于被访问的结点必是某子树的根,所以N(Node)、L(Left subtree)和R(Right subtree)又可解释为根、根的左子树和根的右子树。NLR、LNR和LRN分别又称为先根遍历、中根遍历和后根遍历。
前序,后序和中序的递归图类似:
void BinaryTreePrevOrder(BTNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
printf("%c ", root->_data);
BinaryTreeInOrder(root->_left);
BinaryTreeInOrder(root->_right);
}
让我们先来看看前序遍历的程序,比较简单,采用了分而治之的递归思想。
中序和后序的实现和前序大同小异,可以看最后的代码好好感悟。
我们还要讲一个非递归的二叉树遍历,层序遍历:
除了先序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。设二叉树的根节点所在
层数为1,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第2层上的节点,接着是第三层的节点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。这里我们要用到队列,实现层序遍历。
接下来我把构建二叉树的代码附上:
BinaryTree.h
#pragma once
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include"queue.h"
//二叉树的建立
BTNode* BinaryTreeCreate(BTDataType* a, int n, int* pi);
//二叉树的销毁
void BinaryTreeDestory(BTNode* root);
// 二叉树中序遍历
void BinaryTreeInOrder(BTNode* root);
// 二叉树节点个数
int BinaryTreeSize(BTNode* root);
// 二叉树叶子节点个数
int BinaryTreeLeafSize(BTNode* root);
// 二叉树第k层节点个数
int BinaryTreeLevelKSize(BTNode* root, int k);
// 二叉树查找值为x的节点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x);
// 二叉树前序遍历
void BinaryTreePrevOrder(BTNode* root);
// 二叉树后序遍历
void BinaryTreePostOrder(BTNode* root);
// 层序遍历
void BinaryTreeLevelOrder(BTNode* root);
// 判断二叉树是否是完全二叉树
int BinaryTreeComplete(BTNode* root);
BinaryTree.c
#define _CRT_SECURE_NO_WARNINGS 1
#include"binarytree.h"
BTNode* BuyBinaryTreeNode(BTDataType x)
{
BTNode* newnode = (BTNode*)malloc(sizeof(BTNode));
if (newnode != NULL)
{
newnode->_data = x;
newnode->_left = NULL;
newnode->_right = NULL;
}
return newnode;
}
BTNode* BinaryTreeCreate(BTDataType* a, int n, int* pi)
{
if (a[*pi] == '#')
{
(*pi)++;
return NULL;
}
else
{
BTNode* newnode = BuyBinaryTreeNode(a[*pi]);
(*pi)++;
newnode->_left = BinaryTreeCreate(a, n, pi);
newnode->_right = BinaryTreeCreate(a, n, pi);
return newnode;
}
}
void BinaryTreeDestory(BTNode* root)
{
if (root == NULL)
{
return;
}
BinaryTreeDestory(root->_left);
BinaryTreeDestory(root->_right);
free(root);
}
void BinaryTreeInOrder(BTNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
BinaryTreeInOrder(root->_left);
printf("%c ", root->_data);
BinaryTreeInOrder(root->_right);
}
int BinaryTreeSize(BTNode* root)
{
if (root == NULL)
{
return 0;
}
return 1 + BinaryTreeSize(root->_left) + BinaryTreeSize(root->_right);
}
int BinaryTreeLeafSize(BTNode* root)
{
if (root == NULL)
{
return 0;
}
if (root->_left == NULL && root->_right == NULL)
{
return 1;
}
int leftTreeLeafSize = BinaryTreeLeafSize(root->_left);
int rightTreeLeafSize = BinaryTreeLeafSize(root->_right);
return leftTreeLeafSize + rightTreeLeafSize;
}
int BinaryTreeLevelKSize(BTNode* root, int k)
{
if (root == NULL)
{
return 0;
}
if (k == 1)
{
return 1;
}
return BinaryTreeLevelKSize(root->_left, k - 1)
+ BinaryTreeLevelKSize(root->_right, k - 1);
}
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
{
return NULL;
}
if (root->_data == x)
{
return root;
}
BTNode* leftret = BinaryTreeFind(root->_left, x);
if (leftret != NULL)
{
return leftret;
}
BTNode* rightret = BinaryTreeFind(root->_right, x);
return rightret;
}
void BinaryTreePrevOrder(BTNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
printf("%c ", root->_data);
BinaryTreeInOrder(root->_left);
BinaryTreeInOrder(root->_right);
}
void BinaryTreePostOrder(BTNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
BinaryTreeInOrder(root->_left);
BinaryTreeInOrder(root->_right);
printf("%c ", root->_data);
}
void BinaryTreeLevelOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
Queue* pq = (Queue*)malloc(sizeof(Queue));
QueueInit(pq);
QueuePush(pq, root);
while (!QueueEmpty(pq))
{
root = QueueFront(pq);
printf("%c ", root->_data);
QueuePop(pq);
if (root->_left != NULL)
{
QueuePush(pq, root->_left);
}
if (root->_right != NULL)
{
QueuePush(pq, root->_right);
}
}
printf("\n");
}
int BinaryTreeComplete(BTNode* root)
{
if (root == NULL)
{
return 1;
}
Queue* pq = (Queue*)malloc(sizeof(Queue));
QueueInit(pq);
QueuePush(pq, root);
while (!QueueEmpty(pq))
{
root = QueueFront(pq);
QueuePop(pq);
if (root == NULL)
{
while (!QueueEmpty(pq))
{
if (QueueFront(pq) != NULL)
{
return 0;
}
QueuePop(pq);
}
return 1;
}
QueuePush(pq, root->_left);
QueuePush(pq, root->_right);
}
}
堆的建立
讲完了二叉树的知识,让我们来看看堆排序的堆是如何建立的,并且,其与二叉树之间有什么关系。
堆的概念及结构
如果有一个关键码的集合K = { , , ,…, },把它的所有元素按完全二叉树的顺序存储方式存储
在一个一维数组中,并满足: <= 且 <= ( >= 且 >= ) i = 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
简而言之,堆就是特殊的完全二叉树结构,特殊在有以下几个特点:
堆中某个节点的值总是不大于或不小于其父节点的值;
堆总是一棵完全二叉树。
堆就只有两种,大根堆和小根堆:
堆的实现
在堆的实现过程中有两个算法至关重要:
一个是向上调整,一个是向下调整。
堆的结构如下:
typedef int HPDataType;
typedef struct Heap
{
HPDataType* _a;
int _size;
int _capacity;
}Heap;
我们先来讲解一下两种调整算法的实现,首先是是向上调整:
向上调整用于堆的插入,在一个堆的后面插入一个数据,向上调整,就生成了一个新的堆。
向上调整代码实现:
void AdjustUp(Heap* hp, int newnode)
{
int parent = (newnode - 1) / 2;
int child = newnode;
while (child != 0)
{
if (hp->_a[child] < hp->_a[parent])
{
swap(&hp->_a[child], &hp->_a[parent]);
child = parent;
parent = (parent - 1) / 2;
}
else
{
break;
}
}
}
向下调整:
现在我们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从根节点开始的向下调整算法可以把它调整成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整。
向下调整代码实现:
void AdjustDown(int* a, int size)
{
int parent = 0;
int greaterchild = parent * 2 + 1;
while (greaterchild < size)
{
if (parent * 2 + 2 < size)
{
if (a[parent * 2 + 1] > a[parent * 2 + 2])
greaterchild = parent * 2 + 2;
}
if (a[parent] > a[greaterchild])
{
swap(&a[parent], &a[greaterchild]);
}
else
{
break;
}
parent = greaterchild;
greaterchild = parent * 2 + 1;
}
}
下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。根节点左右子树不是堆,我们怎么调整呢?
让我们来看看第一个,向上调整:
通过不断插入新的节点,再向上调整,从而创建一个堆:
void HeapCreate(Heap* hp, HPDataType* a, int n)
{
hp->_a = (HPDataType*)malloc(sizeof(HPDataType) * 4);
hp->_capacity = 4;
hp->_size = 0;
for (int i = 0; i < n; i++)
{
HeapPush(hp, a[i]);
}
}
void HeapPush(Heap* hp, HPDataType x)
{
if (hp->_capacity == hp->_size)
{
int new_capacity = hp->_capacity == 0 ? 4 : 2 * hp->_capacity;
HPDataType* temp = (HPDataType*)realloc(hp->_a, sizeof(HPDataType) * new_capacity);
assert(temp);
hp->_a = temp;
hp->_capacity = new_capacity;
}
hp->_a[hp->_size] = x;
AdjustUp(hp, hp->_size); //向上调整
hp->_size++;
}
我们来看看这种向上调整的方法的时间复杂度和空间复杂度是多少。
空间复杂度很明显是O(N),而时间复杂度的计算就有一些繁琐了。
我们先要搞清楚一次向上调整的时间复杂度是多少,这个还是好算的,在插入第K个值,进行向上调整的时候,时间复杂度是O(logK)。
我们可以看到,使用向上调整的方法来构建堆时,时间复杂度主要看最后一层,调整的次数占所有调整次数的一半还多。因为2^(h-1) <= N <= 2^h通过最后一层的调整次数,可以比较容易地计算出时间复杂度为O(N * log(N))。
其实在实际构建堆的过程中,主要使用向下调整法,等一下我们从时间复杂度的角度来看就知道了。
向下调整构建堆的实现代码如下:
void CreateHeap(int* a, int size)
{
assert(a);
int k = (size - 1) / 2;
while (k >= 0)
{
AdjustDown(a, k, size);
k--;
}
}
我们再来看看向下调整的次数是多少:
总共需要调整的次数为:
T(n) = 2^0 * (h-1) + 2^1 * (h-2) + 2^2 * (h-3) + ............+2^(h-3) *2 + 2^(h-2) * 1;
乍一看,还挺难算的,这就要用到高中的知识,错位相减法了。
2 * T(n) = 2^1 * (h-1) + 2^2 * (h-2) + 2^3 * (h-3) + .........+2^(h-2) *2 + 2^(h-1) * 1;
T(n) = 2^0 * (h-1) + 2^1 * (h-2) + 2^2 * (h-3) + ............+2^(h-3) *2 + 2^(h-2) * 1;
上下相减:
T(n) = 2^0 * (h-1) + 2^1 + 2^2 + ............+2^(h-3) + 2^(h-2) + 2^(h-1);
这是类似于一个等差数列T(n) = h - 3 + 2^h.
因为2^(h-1) <= N <= 2^h
时间复杂度为O(N).
明显向下调整构建堆要远远强于向上调整。
堆的代码实现
头文件:
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* _a;
int _size;
int _capacity;
}Heap;
// 堆的构建
void HeapCreate(Heap* hp, HPDataType* a, int n);
// 堆的销毁
void HeapDestory(Heap* hp);
// 堆的插入
void HeapPush(Heap* hp, HPDataType x);
// 堆的删除
void HeapPop(Heap* hp);
// 取堆顶的数据
HPDataType HeapTop(Heap* hp);
// 堆的数据个数
int HeapSize(Heap* hp);
// 堆的判空
int HeapEmpty(Heap* hp);
源文件:
#include"heap.h"
void swap(HPDataType* px1, HPDataType* px2)
{
HPDataType temp = *px1;
*px1 = *px2;
*px2 = temp;
}
void HeapCreate(Heap* hp, HPDataType* a, int n)
{
hp->_a = (HPDataType*)malloc(sizeof(HPDataType) * 4);
hp->_capacity = 4;
hp->_size = 0;
for (int i = 0; i < n; i++)
{
HeapPush(hp, a[i]);
}
}
void HeapDestory(Heap* hp)
{
assert(hp);
free(hp->_a);
hp->_capacity = 0;
hp->_size = 0;
}
void AdjustUp(Heap* hp, int newnode)
{
int parent = (newnode - 1) / 2;
int child = newnode;
while (child != 0)
{
if (hp->_a[child] < hp->_a[parent])
{
swap(&hp->_a[child], &hp->_a[parent]);
child = parent;
parent = (parent - 1) / 2;
}
else
{
break;
}
}
}
void HeapPush(Heap* hp, HPDataType x)
{
if (hp->_capacity == hp->_size)
{
int new_capacity = hp->_capacity == 0 ? 4 : 2 * hp->_capacity;
HPDataType* temp = (HPDataType*)realloc(hp->_a, sizeof(HPDataType) * new_capacity);
assert(temp);
hp->_a = temp;
hp->_capacity = new_capacity;
}
hp->_a[hp->_size] = x;
AdjustUp(hp, hp->_size);
hp->_size++;
}
int HeapSize(Heap* hp)
{
return hp->_size;
}
int HeapEmpty(Heap* hp)
{
if (HeapSize(hp) == 0)
return 1;
else
return 0;
}
void AdjustDown(Heap* hp)
{
int parent = 0;
int greaterchild = parent * 2 + 1;
while (greaterchild < hp->_size)
{
if (parent * 2 + 2 < hp->_size)
{
if (hp->_a[parent * 2 + 1] > hp->_a[parent * 2 + 2])
greaterchild = parent * 2 + 2;
}
if (hp->_a[parent] > hp->_a[greaterchild])
{
swap(&hp->_a[parent], &hp->_a[greaterchild]);
}
else
{
break;
}
parent = greaterchild;
greaterchild = parent * 2 + 1;
}
}
void HeapPop(Heap* hp)
{
assert(!HeapEmpty(hp));
hp->_a[0] = hp->_a[hp->_size - 1];
hp->_size--;
AdjustDown(hp);
}
HPDataType HeapTop(Heap* hp)
{
assert(!HeapEmpty(hp));
return hp->_a[0];
}
堆排的实现
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
1. 建堆
升序:建大堆
降序:建小堆
2. 利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。
我们来看看堆排的具体实现,以降序为例:
把第一个和最后一个交换,不把最后一个数据看作堆内数据,向下调整,以此类推,完成堆排序:
void AdjustDown(int* a, int k,int size)
{
int parent = k;
int greaterchild = parent * 2 + 1;
while (greaterchild < size)
{
if (parent * 2 + 2 < size)
{
if (a[parent * 2 + 1] > a[parent * 2 + 2])
greaterchild = parent * 2 + 2;
}
if (a[parent] > a[greaterchild])
{
swap(&a[parent], &a[greaterchild]);
}
else
{
break;
}
parent = greaterchild;
greaterchild = parent * 2 + 1;
}
}
void HeapSort(int* a, int size)
{
CreateHeap(a, size);
int k = size - 1;
while (k > 0)
{
swap(&a[0], &a[k]);
AdjustDown(a, 0, k);
--k;
}
}
我们可以看到,经过前面的铺垫,堆排序的代码还是十分简洁清晰的。
直接插入排序
直接插入排序是一种简单的插入排序法,其基本思想是:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
直接插入排序的特性总结:
1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1),它是一种稳定的排序算法
4. 稳定性:稳定
插入排序的代码实现:
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int temp = a[i + 1];
for (int j = i; j >= 0; j--)
{
if (a[j] > temp)
{
a[j + 1] = a[j];
if (j == 0)
{
a[0] = temp;
}
}
else
{
a[j + 1] = temp;
break;
}
}
}
}
希尔排序
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个gap组,所有距离为gap的记录分在同一组内,并对每一组内的记录进行排序。然后,取gap/3 + 1,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。
希尔排序的特性总结:
1. 希尔排序是对直接插入排序的优化。
2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就
会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定
只记一个结论希尔排序的时间复杂度为O(n ^ 1.3),或者O(N ^ 1.25).
希尔排序的代码如下:
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int temp = a[i + gap];
for (int j = i; j >= 0; j = j - gap)
{
if (temp < a[j])
{
a[j + gap] = a[j];
if (j < gap)
{
a[j] = temp;
}
}
else
{
a[j + gap] = temp;
break;
}
}
}
}
}
冒泡排序
冒泡排序(Bubble Sort),是一种计算机科学领域的较简单的排序算法。
它重复地走访过要排序的元素列,依次比较两个相邻的元素,如果顺序(如从大到小、首字母从Z到A)错误就把他们交换过来。走访元素的工作是重复地进行,直到没有相邻元素需要交换,也就是说该元素列已经排序完成。
这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端(升序或降序排列),就如同碳酸饮料中二氧化碳的气泡最终会上浮到顶端一样,故名“冒泡排序”。
冒泡排序代码实现:
void BubbleSort(int* a, int n)
{
for (int i = n - 1; i > 0; i--)
{
for (int j = 0; j < i; j++)
{
if (a[j] > a[j + 1])
{
swap(&a[j], &a[j + 1]);
}
}
}
}
冒泡排序的时间复杂度为O(N ^ 2);
快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
将区间按照基准值划分为左右两半部分的常见方式有:
1. hoare版本
代码实现:
int PartSort1(int* a, int left, int right)
{
int keyi = left;
while (left < right)
{
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
swap(&a[left], &a[right]);
}
swap(&a[keyi], &a[left]);
return left;
}
2. 挖坑法
代码实现:
int PartSort2(int* a, int left, int right)
{
int hole = left;
int temp = a[hole];
while (left < right)
{
while (left < right && a[right] >= temp)
{
right--;
}
a[hole] = a[right];
hole = right;
while (left < right && a[left] <= temp)
{
left++;
}
a[hole] = a[left];
hole = left;
}
a[hole] = temp;
return hole;
}
3. 前后指针版本
代码实现:
int PartSort3(int* a, int left, int right)
{
int prev = left;
int back = left + 1;
while (back <= right)
{
if (a[back] < a[left])
{
swap(&a[prev + 1], &a[back]);
back++;
prev++;
}
else
{
back++;
}
}
swap(&a[left], &a[prev]);
return prev;
}
快排简略版:
void QuickSort(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int keyi = PartSort3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
三数取中
简略版的快排已经可以工作了,并且在排列一般数组时也很快,但是遇到特殊情况就出现问题了。快组排序我们希望基准值越靠近序列的中间值越好,开辟的栈帧也越少,计算的次数也越少,但是简略版本的快排,如果遇上了一个已经有序的数列,就会出现一边没有元素的情况,这种情况下开辟栈帧的深度是很深的很有可能栈溢出,并且处理的效率也很低,时间复杂度达到O(N ^ 2)。
所以我们用一些方法,来解决这些问题,一个比较好的方法就是三数取中。
取头,尾和中间,三个位置数的中间值,与首元素交换,代码实现如下:
void ThreeGetM(int* a, int left, int right)
{
int mid = (left + right) / 2;
if (right - left < 3)
{
return;
}
if (a[left] > a[right])
{
if (a[left] <= a[mid])
{
return;
}
else
{
if (a[mid] >= a[right])
{
swap(&a[left], &a[mid]);
}
else
{
swap(&a[left], &a[right]);
}
return;
}
}
else if (a[left] < a[right])
{
if (a[left] >= a[mid])
{
return;
}
else
{
if (a[mid] <= a[right])
{
swap(&a[left], &a[right]);
}
else
{
swap(&a[left], &a[mid]);
}
return;
}
}
else
{
return;
}
}
小区间优化
关于快速排序还有一个就是小区间优化,我们可以了解到,绝大多数的栈帧的开辟都是在小区间排列的时候,这时候使用快速排序,对于效率的提升也不多,所以递归到小的子区间时,可以考虑使用插入排序。
完整递归版快速排序
int PartSort3(int* a, int left, int right)
{
ThreeGetM(a, left, right);//三数取中
int prev = left;
int back = left + 1;
while (back <= right)
{
if (a[back] < a[left])
{
swap(&a[prev + 1], &a[back]);
back++;
prev++;
}
else
{
back++;
}
}
swap(&a[left], &a[prev]);
return prev;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
{
return;
}
if(right - left < 13)
{
InsertSort(&a[left], right - left + 1);
}
int keyi = PartSort3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
非递归的快速排序
非递归的快速排序,需要使用栈或者队列,接下来我们简单讲讲栈和队列的实现。
栈的实现
栈:一种特殊的线性表,其只允许在固定的一端进行插入和删除元素操作。进行数据插入和删除操作的一端
称为栈顶,另一端称为栈底。栈中的数据元素遵守后进先出LIFO(Last In First Out)的原则。
压栈:栈的插入操作叫做进栈/压栈/入栈,入数据在栈顶。
出栈:栈的删除操作叫做出栈。出数据也在栈顶。
栈的实现一般可以使用数组或者链表实现,相对而言数组的结构实现更优一些。因为数组在尾上插入数据的代价比较小。
栈的头文件
#pragma once
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
// 支持动态增长的栈
typedef int STDataType;
typedef struct Stack
{
STDataType* _a;
int _top; // 栈顶
int _capacity; // 容量
}Stack;
// 初始化栈
void StackInit(Stack* ps);
// 入栈
void StackPush(Stack* ps, STDataType data);
// 出栈
void StackPop(Stack* ps);
// 获取栈顶元素
STDataType StackTop(Stack* ps);
// 获取栈中有效元素个数
int StackSize(Stack* ps);
// 检测栈是否为空,如果为空返回非零结果,如果不为空返回0
int StackEmpty(Stack* ps);
// 销毁栈
void StackDestroy(Stack* ps);
栈的源文件
#include"stack.h"
void StackInit(Stack* ps)
{
assert(ps);
ps->_a = (STDataType*)malloc(4 * sizeof(STDataType));
ps->_capacity = 4;
ps->_top = 0;
}
void StackIsFull(Stack* ps)
{
if (ps->_top == ps->_capacity)
{
STDataType* temp = (STDataType*)realloc(ps->_a, 2 * ps->_capacity * sizeof(STDataType));
assert(temp);
ps->_a = temp;
ps->_capacity = 2 * ps->_capacity;
}
}
void StackPush(Stack* ps, STDataType data)
{
assert(ps);
StackIsFull(ps);
ps->_a[ps->_top] = data;
ps->_top++;
}
int StackEmpty(Stack* ps)
{
if (ps->_top == 0)
return 1;
else
return 0;
}
void StackPop(Stack* ps)
{
assert(!StackEmpty(ps));
assert(ps);
(ps->_top)--;
}
STDataType StackTop(Stack* ps)
{
assert(!StackEmpty(ps));
assert(ps);
return ps->_a[ps->_top - 1];
}
int StackSize(Stack* ps)
{
assert(ps);
return ps->_top;
}
void StackDestroy(Stack* ps)
{
free(ps->_a);
ps->_a = NULL;
ps->_capacity = 0;
ps->_top = 0;
}
队列的实现
队列:只允许在一端进行插入数据操作,在另一端进行删除数据操作的特殊线性表,队列具有先进先出FIFO(First In First Out)
入队列:进行插入操作的一端称为队尾 出队列:进行删除操作的一端称为队头。
队列也可以数组和链表的结构实现,使用链表的结构实现更优一些,因为如果使用数组的结构,出队列在数组头上出数据,效率会比较低。
队列的头文件
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
typedef int QDataType;
// 链式结构:表示队列
typedef struct QListNode
{
struct QListNode* _next;
QDataType _data;
}QNode;
// 队列的结构
typedef struct Queue
{
QNode* _front;
QNode* _rear;
}Queue;
// 初始化队列
void QueueInit(Queue* q);
// 队尾入队列
void QueuePush(Queue* q, QDataType data);
// 队头出队列
void QueuePop(Queue* q);
// 获取队列头部元素
QDataType QueueFront(Queue* q);
// 获取队列队尾元素
QDataType QueueBack(Queue* q);
// 获取队列中有效元素个数
int QueueSize(Queue* q);
// 检测队列是否为空,如果为空返回非零结果,如果非空返回0
int QueueEmpty(Queue* q);
// 销毁队列
void QueueDestroy(Queue* q);
队列的源文件
#define _CRT_SECURE_NO_WARNINGS 1
#include"queue.h"
void QueueInit(Queue* q)
{
q->_front = NULL;
q->_rear = NULL;
}
int QueueEmpty(Queue* q)
{
if (q->_rear == NULL)
return 1;
else
return 0;
}
void QueuePush(Queue* q, QDataType data)
{
QNode* newnode = (QNode*)malloc(sizeof(QNode));
newnode->_data = data;
newnode->_next = NULL;
if (QueueEmpty(q))
{
q->_front = newnode;
q->_rear = newnode;
}
else
{
q->_rear->_next = newnode;
q->_rear = newnode;
}
}
void QueuePop(Queue* q)
{
assert(q->_front);
QNode* newfront = q->_front->_next;
free(q->_front);
q->_front = newfront;
if (newfront == NULL)
{
q->_rear = NULL;
}
}
QDataType QueueFront(Queue* q)
{
assert(q->_front);
return q->_front->_data;
}
QDataType QueueBack(Queue* q)
{
assert(q->_rear);
return q->_rear->_data;
}
int QueueSize(Queue* q)
{
if (QueueEmpty(q))
{
return 0;
}
else
{
QNode* cur = q->_front;
int size = 1;
while (cur != q->_rear)
{
cur = cur->_next;
size++;
}
return size;
}
}
void QueueDestroy(Queue* q)
{
if (!QueueEmpty(q))
{
QueuePop(q);
}
}
非递归快速排序的实现
非递归快速排序的实现,和递归的过程几乎没有区别,只是使用了栈或者队列来模拟实现递归的过程。
void QuickSortNonR(int* a, int left, int right)
{
Stack* ps = (Stack*)malloc(sizeof(Stack));
StackInit(ps);
StackPush(ps, left);
StackPush(ps, right);
while (!StackEmpty(ps))
{
int end = StackTop(ps);
StackPop(ps);
int begin = StackTop(ps);
StackPop(ps);
int keyi = PartSort3(a, begin, end);
if (keyi + 1 < end)
{
StackPush(ps, keyi + 1);
StackPush(ps, end);
}
if (keyi - 1 > begin)
{
StackPush(ps, begin);
StackPush(ps, keyi - 1);
}
}
StackDestroy(ps);
}
非递归版本还有一个特点,不会像递归一样开辟栈帧,就不需要小区间优化了。
归并排序
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
归并排序逻辑:
1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(N)
4. 稳定性:稳定
归并排序递归实现:
//归并排序子函数
void _MergeSort(int* a, int begin, int end, int* temp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
_MergeSort(a, begin, mid, temp);
_MergeSort(a, mid + 1, end, temp);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] > a[begin2])
{
temp[begin1 + begin2 - mid -1] = a[begin2];
begin2++;
}
else
{
temp[begin1 + begin2 - mid - 1] = a[begin1];
begin1++;
}
}
while (begin1 <= end1)
{
temp[begin1 + begin2 - mid - 1] = a[begin1];
begin1++;
}
while (begin2 <= end2)
{
temp[begin1 + begin2 - mid - 1] = a[begin2];
begin2++;
}
memcpy(a + begin, temp + begin, (end - begin + 1) * sizeof(int));
}
//归并排序
void MergeSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
int* temp = (int*)malloc(n * sizeof(int));
if (temp)
{
memcpy(temp, a, n * sizeof(int));
_MergeSort(a, begin, end, temp);
}
free(temp);
}
我们从代码中可以看出,归并排序和快速排序有异曲同工之妙,递归的过程不是二叉树,胜似二叉树,只不过快排是前序遍历,而归并是后序遍历。
归并排序非递归实现:
快速排序的非递归是通过栈和队列来模拟递归的过程,对于前序遍历来说,这种方法比较便捷,但是对于归并这种后续遍历来说,这种实现方式就比较费劲了。
所以我们另辟蹊径,通过将数组人为划分为为不同的组,每一组均有序,再将两组合并为一组,最后只剩一组,完成排序。
值得注意的是最后一组或者两组的边界需要仔细处理。
void MergeSortNonR(int* a, int n)
{
int gap = 1;
int begin = 0;
int end = n - 1;
int* temp = (int*)malloc(n * sizeof(int));
if (temp)
{
memcpy(temp, a, n * sizeof(int));
}
while (gap < n)
{
for (int i = 0; i < n; i += gap * 2)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + gap * 2 - 1;
if (end2 > end)
{
if (begin2 > end)
{
break;
}
else
{
end2 = end;
}
}
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] > a[begin2])
{
temp[begin1 + begin2 - (i + gap)] = a[begin2];
begin2++;
}
else
{
temp[begin1 + begin2 - (i + gap)] = a[begin1];
begin1++;
}
}
while (begin1 <= end1)
{
temp[begin1 + begin2 - (i + gap)] = a[begin1];
begin1++;
}
while (begin2 <= end2)
{
temp[begin1 + begin2 - (i + gap)] = a[begin2];
begin2++;
}
}
gap *= 2;
memcpy(a, temp, n * sizeof(int));
}
free(temp);
}
非比较排序
我们上面讲到的都是比较排序,既然有比较排序就有非比较排序,比较经典的非比较排序有计数排序,桶排序,鸡尾酒排序等等。
我们就以计数排序为例,简单讲解一下:
思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。 操作步骤:
1. 统计相同元素出现次数
2. 根据统计的结果将序列回收到原来的序列中
简单来说,就是将元素的内容与储存的位置相关联起来。
计数排序的特性总结:
1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
2. 时间复杂度:O(MAX(N,范围))
3. 空间复杂度:O(范围)
4. 稳定性:稳定
计数排序代码实现
void CountSort(int* a, int n)
{
int max = a[0];
int min = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] > max)
{
max = a[i];
}
if (a[i] < min)
{
min = a[i];
}
}
int* temp = (int*)malloc((max - min + 1) * sizeof(int));
if (temp)
{
memset(temp, 0, (max - min + 1) * sizeof(int));
for (int i = 0; i < n; i++)
{
++(temp[a[i] - min]);
}
int cur = 0;
int pos = 0;
while (cur < n && pos < max - min + 1)
{
while (temp[pos] != 0)
{
a[cur] = min + pos;
cur++;
--(temp[pos]);
}
pos++;
}
}
free(temp);
}
排序总结
通过排列10000000个随机数,查看以下各个排序所用的时间,可以看出快排还是非常不错的,希尔排序和归并排序虽然效果略高一些,但是他们都有自己的短板。
void TestOP()
{
srand(time(0));
const int N = 10000000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
int* a7 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
}
int begin1 = clock();
//InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
//SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a5, 0, N - 1);
int end5 = clock();
int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();
int begin7 = clock();
//BubbleSort(a7, N);
int end7 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("BubbleSort:%d\n", end7 - begin7);
printf("QuickSort:%d\n", end5 - begin5);
printf("MergeSort:%d\n", end6 - begin6);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
}
值得注意的是,希尔排序和选择排序,前面的操作会影响后面,所以做不到稳定。
如果有错漏之处,还请大佬指正。