直接插入排序
--思路
将待排序的元素,逐一插入到有序序列中,直到所有元素插入完为止.
初始的有序序列只有第一个元素.
--代码实现
单次插入:
用一个变量end来标识每次插入前的有序序列,[ 0, end ]为有序序列.
将end + 1位置的值insertEle插入到前面,
把比insertEle大的元素往后挪,同时也能找到插入的位置.
多次插入:
多次改变end的初始值,也能理解为待插入的元素数目.
void insertSort(vector<int>& v)
{
for (int i = 0; i < v.size() - 1; ++i) // 表示要插入的元素数目
{
//单次插入
//[0,end]有序,将end+1位置的值插入到前面
int end = i;
int insertEle = v[end + 1];
//用end反向遍历有序序列(升序为例),挪动数据并找到插入位置
//比insertEle大的数都往后挪动
while (end >= 0)
{
if (insertEle < v[end])
{
v[end + 1] = v[end];
--end;
}
else
break;
}
//此时插入位置为end+1
v[end + 1] = insertEle;
}
}
--时间复杂度
最坏情况: 逆序
第一个数据挪0次,第二个数据挪1次,……第n个数据挪动n-1次.
因此是O(N^2)
最优情况: 有序/接近有序
挪动次数少,O(N)
希尔排序
希尔排序是对直接插入排序的优化,时间复杂度是O(N*logN).
--思路
直接插入排序在 有序/接近有序 时,效率很高.
希尔排序分2步:
(1) 预排序 —— 让数据接近有序
(2) 直接插入排序
注意:这里的预排序会进行多次
--预排序
步骤
(1) 将数据分为gap组,间距为gap的倍数的元素在同一组
例:假设gap = 3,vector< int > v如下
(2) 对这gap组数据分别进行直接插入排序
这次预排序结束后,数据已经接近有序,
接下来使用直接插入排序只需挪动3次数据.
预排序gap的设置
(1) gap越大,数据一次挪动gap步
大的数更快到后面,小的数更快到前面,但越不接近有序
(2) gap越小,数据挪动慢,但越接近有序,
当gap = 1时,就是对所有元素进行直接插入排序.
(3) gap大了,不够接近有序;
gap小了,和直接插入排序差不多.
结论
(1) 需要多次预排序,让数据逐步接近有序
(2) gap开始大(与v.size()相关),逐步递减,让序列逐步接近有序
(3) 每一次预排序都会让数据更接近有序,除第一次预排外,
每一次预排序都能享受之前的预排序带来的效果,
使得即使后面gap逐步递减,数据挪动的次数也会变少.
--代码实现
单次插入
用变量end来标识当前组有序序列的结尾,
将v[ end+group ]插入到[0,end]的该组元素序列中
多次插入
gap组数据交替进行直接插入排序
end指向哪一组有序序列的结尾,就对这组进行一次插入.
void shellSort(vector<int>& v)
{
int gap = v.size()/2;
//gap > 1时,都是预排序;
//gap = 1,直接插入排序,此时数据已经 接近有序/有序
while (gap >= 1)
{
for (int i = 0; i < v.size() - gap; ++i) //gap组数据交替进行直接插入排序
{
//当前组的[0,end]内的元素有序,将end + gap位置的值插入
int end = i;
int insertEle = v[end + gap];
//单次插入
//用end反向遍历当前组的有序序列
//比insertEle大的数据往后挪gap步,同时找到插入位置
while (end >= 0)
{
if (insertEle < v[end])
{
v[end + gap] = v[end];
end -= gap;
}
else
break;
}
v[end + gap] = insertEle;
}
//更新gap前,看当前gap的处理结果
//cout << "gap:" << gap << "-> ";
//printVector(v);
gap /= 2;
}
}
--时间复杂度
仅从理解方面分析,以gap = v.size()/2为例
(1) 最外层调整gap的循环,表示预排序的次数,O(logN).
(2) 当gap很大时:
此时2个数据为1组,里面的单次插入时间可以忽略不计,
所以gap组数据交替进行插入排序时,消耗O(N).
(3) 当gap很小时:
此时数据已经接近有序,里面的单次插入次数少,消耗O(N)
(4) 当gap为中间值时:
每一次预排序都会让数据更接近有序,除第一次预排外,
每一次预排序都能享受之前的预排序带来的效果,
使得即使后面gap逐步递减,数据挪动的次数也会变少.
因此也可以认为是O(N).
结论:
所以预排序的时间复杂度大概是O(N*logN),希尔排序中的直接插入排序是O(N)
希尔排序的时间复杂度大概是O(N*logN).
直接选择排序
--思路
(升序为例)每次遍历待排序的元素,选出最小值,
和刚遍历的起始位置值做交换.
--代码实现
void seleteSort(vector<int>& v)
{
//在[begin,v.size() - 1]区间选出最小的数,放到begin位置,接着++begin
//直到这个区间只剩下一个元素,即begin == v.size() - 1时停止
for (int begin = 0; begin < v.size() - 1; ++begin)
{
//先假设v[begin]最小
//最小值下标(用于最后交换数据)
int minIndex = begin;
//对应的最小值(用于比较)
int min = v[minIndex];
//遍历begin之后的数
//若比min小,记录它的下标和值
for (int j = begin + 1; j < v.size(); ++j)
{
if (v[j] < min)
{
minIndex = j;
min = v[j];
}
}
//此时minIndex为最小值下标,把 最小值位置 和 begin位置 的值做交换
swap(v[minIndex], v[begin]);
}
}
--时间复杂度
无论是有序还是无序,时间复杂度均为O(N^2).
begin = 0时,内部循环遍历 N - 1个数据
begin = 1时,内部循环遍历 N - 2个数据
……………………………………………
begin = N - 2, 内部循环遍历 1 个数据
等差数列 1 + 2 + …… + (N-1) = O(N^2)
堆排序
--思路
回顾
堆是一种数据结构,是完全二叉树的一种存储结构(顺序存储结构).
大堆:根结点比左孩子和右孩子要大.
小堆:根结点比左孩子和右孩子要小.
排升序建大堆.
重点
堆能选出最大 (大堆) 或 最小 (小堆) 的数,放到堆顶,通过该特征实现堆排序.
--代码实现
//adjust:要向下调整的位置,adjust位置结点的左孩子和右孩子必须都是大堆
//end:堆的元素下标最大值[左闭右开)
void adjustDown(vector<int>& v, int adjust, int end)
{
//待调整的结点有孩子,才可以向下调整
while (adjust*2+1 < end)
{
//假设最大的孩子是左孩子
int maxChild = adjust * 2 + 1;
//如果右孩子存在且大于左孩子
if (maxChild + 1 < end && v[maxChild + 1] > v[maxChild])
{
++maxChild;
}
//如果待调整结点小于最大的孩子,需要向下调整,否则直接结束
if (v[maxChild] > v[adjust])
{
swap(v[maxChild], v[adjust]);
adjust = maxChild;
}
else
break;
}
}
void heapSort(vector<int>& v)
{
//1 建堆(向下调整建堆) —— 从最后一个非叶子结点开始建堆
//即最后一个结点的父结点
for (int i = ((v.size() - 1) - 1) / 2; i >= 0; --i)
{
adjustDown(v, i, v.size());
}
// 2 交换堆顶元素选数 + 调堆
for (int i = v.size() - 1; i > 0; --i)
{
//把选出的最大值交换到末尾
swap(v[i], v[0]);
//将剩下的数调整成堆,
adjustDown(v, 0, i); //这里是左闭右开,不会包含i
}
}
--时间复杂度
回顾
一棵满二叉树,结点数N = 2^h-1(根结点高度为1时)
一棵完全二叉树,结点数一定 <= 2^h - 1,
同时一定 > 2^(h-1) - 1.
即高度为h的完全二叉树,结点数一定比高度为h-1的满二叉树要大.
所以:h = O(logN)
建堆
将h = O(logN)代入,可知建堆循环的消耗是O(N).
选数+调堆
对n - 1个数调堆,向下调整消耗log(n-1)
对n - 2个数调堆,向下调整消耗log(n-2)
…………………………
对 2 个数调堆,向下调整消耗log 2
总消耗为:log(n-1) + log(n-2)+……+log2 = O(NlogN).
冒泡排序
--思路
遍历待排序序列,以升序为例
相邻的两个数进行比较,大的数交换到右边,小的数交换的左边.
每进行一次冒泡,可以选出当前待排序序列的最大值放到右边,待排序的元素数目-1.
--代码实现
void bubbleSort(vector<int>& v)
{
//每一次冒泡都能让待排序序列的最大值放到末尾,待排序的元素数目-1
//要进行v.size() - 1次冒泡
for (int i = 0; i < v.size() - 1; ++i)//i表示完成的冒泡次数
{
//标识此次冒泡是否有发生交换
bool flag = false;
//其中一次冒泡
//begin和begin+1位置的值比较,大的交换到右边
for (int begin = 0; begin < v.size() - 1 - i; ++begin)//待排序序列的末尾会改变
{
if (v[begin] > v[begin + 1])
{
swap(v[begin], v[begin+1]);
flag = true;
}
}
//若此次冒泡没有发生交换,说明已经有序
if (flag == false)
return;
}
}
--时间复杂度
最坏情况
第1次冒泡:遍历N-1个元素
第2次冒泡:N - 2
………………
第N-1次冒泡:1
1+2 + …… + (N-1) = O(N^2)
最好情况
有序,遍历一遍没有发生交换,则直接结束函数.
快速排序
--思路
以升序为例:
( 1 ) 任取 待排序序列 的某个元素作为基准值,将待排序序列分割成两个子序列.
左子序列中所有元素小于 基准值,右子序列中所有元素大于 基准值
( 2 ) 此时该基准值已经排到了正确的位置.
( 3 ) 然后左右序列,重复该过程(通过递归),直到所有元素排列在正确位置.
( 4 ) 快速排序是一种基于二叉树结构的排序方法,
基准值 —— 根
左区间 —— 左子树
右区间 —— 右子树
--分割成左右序列的三种方式
选出一个基准值key,一般最左边或最右边的值.
一趟快排完成后,左边序列比key小,右边序列比key大.
默认选择最左边的值做key.
hoare版本
目标:
比key小的数放到左边,大的数放到右边
步骤:
( 1 ) 定义left和right双下标
( 2 ) left 从左遍历,在左边找大
( 3 ) right 从右遍历,在右边找小
( 4 ) 交换left和right对应的值
( 5 ) left 继续找大,right继续找小
( 6 ) 当left和right相遇时,交换key与这个位置的值
【 必须保证相遇位置的值,小于key 】
代码:
返回基准值的新下标keyi,分割成两个区间:
[begin, keyi - 1] keyi [keyi + 1, end]
左边比key小,右边比key大
//快速排序单趟:hoare版本
//[begin, end]是要进行单趟排序的区间
int quickPartSortHoare(vector<int>& v, int begin, int end)
{
//key位置
int keyi = begin;
//选区间第一个数为key
int key = v[begin];
while (begin < end)
{
//左边做key,右边先走(为了保证相遇位置的值,比key小)
//右边找小,v[end]大就一直往前找
while (begin < end && v[end] >= key)
--end;
//左边找大,v[begin]小就一直往后找
while (begin < end && v[begin] <= key)
++begin;
//情况1:begin = end;
//情况2:v[begin] > key且v[end] < key,即左边找到大,右边找到小
swap(v[begin], v[end]);
}
//交换 key位置 和 相遇位置 的值
swap(v[keyi], v[begin]);
//更新keyi,返回基准值新下标
keyi = begin;
return keyi;
}
为什么左边做key,右边先走?
(right找小,left找大)
根本原因
左边做基准值:
说明相遇位置的值必须小于key,因为最后要交换相遇位置和基准值位置
情况分析
情况1:right先走,right没有找到小于key的数,right 去遇到 left
此时left的位置要么在keyi,要么就是小于key的值
(right找到小,left找到大后,要交换对应位置的值,所以此时left指向的值一定比基准值小)
情况2:right先走,但right找到小于key的数,停下来了,left 去遇到 right.
综上所述:左边做key,右边先走,能保证相遇位置的值一定比基准值小
挖坑法
与hoare版本的思路类似
步骤
( 1 ) 把左边选的基准值key,存放在临时变量中,此时keyi形成一个坑位
( 2 ) 定义left 和 right,left用来找比基准值大的数,right找比基准值小的数
( 3 ) 右边找小,找到后把数填进坑位,right位置形成坑位
( 4 ) 左边找大,找到后把数填进坑位,left位置又形成坑位
( 5 ) 当它们相遇时,一定在坑位上,此时把key填进坑位
不需要考虑【为什么左边做key,右边先走】,
key的位置keyi天然形成了一个坑位,只需要专注填坑.
代码
//快速排序单趟:挖坑法
int quickPartSortHole(vector<int>& v, int begin, int end)
{
int key = v[begin];
//基准值的位置形成坑
int hole = begin;
while (begin < end)
{
//右边找小
while (begin < end&& v[end] >= key)
--end;
//找到以后,把值填进旧坑位,end位置形成新的坑位
v[hole] = v[end];
hole = end;
//左边找大
while (begin < end && v[begin] <= key)
++begin;
//找到以后,把值填进旧坑位,begin位置形成新的坑位
v[hole] = v[begin];
hole = begin;
}
//最后相遇在坑位
v[hole] = key;
return hole;
}
前后指针版本
步骤
( 1 ) 定义pre和cur前后下标
( 2 ) cur用于遍历
( 3 ) pre指向小于基准值的数,同时pre前面的数都是小于基准值的数(基准值本身除外)
( 4 ) 当cur遍历到的值小于基准值,++pre,然后把pre指向的值和当前cur指向的值做交换
( 5 ) 遍历完成后,pre位置的值和基准值位置的值做交换
代码
//快速排序单趟:前后指针版本
int quickPartSortPreCur(vector<int>& v, int begin, int end)
{
//选择基准值
int key = v[begin];
//初始化前后指针
int pre = begin;
int cur = begin + 1;
while (cur <= end)
{
if (v[cur] < key)
{
++pre;
swap(v[pre], v[cur]);
}
++cur;
}
//此时pre前面的数(包括pre指向的数)都是小于基准值(除基准值外)
swap(v[pre], v[begin]);
//交换后基准值的位置就是pre
return pre;
}
--递归代码实现
快排的递归实现,和二叉树的前序遍历类似.
void quickSortChild(vector<int>& v, int begin, int end)
{
if (begin >= end)
return;
//单趟排序任选一种
//int keyi = quickPartSortHoare(v, begin, end);
//int keyi = quickPartSortHole(v, begin, end);
//先处理 基准值,将基准值排到正确位置
int keyi = quickPartSortPreCur(v, begin, end);
//处理左区间和右区间
//左区间:[begin, keyi - 1]
//右区间:[keyi + 1, end]
quickSortChild(v, begin, keyi - 1);
quickSortChild(v, keyi + 1, end);
}
void quickSortR(vector<int>& v)
{
quickSortChild(v, 0, v.size() - 1);
}
缺陷和改进策略
缺陷
基准值key的选择会影响效率:
如果每次选的key都是中位数,那每次都是二分,递归深度为O(logN)
如果每次选的key都是max或min,递归深度高且时间复杂度达O(N^2)
递归深度高达O(N),容易发生栈溢出
每次选key遍历剩下的数消耗为O(N),
时间复杂度高达O(N^2)
改进
(1) 三数取中
每次选到最小或最大,可能性不高.
第一个元素 中间元素 最后一个元素,在这三个元素中选出不是最大也不是最小的元素.
//三数取中,返回中间值的下标
int getMidKeyi(const vector<int>& v, int begin, int end)
{
int mid = (begin + end) / 2;
if (v[begin] < v[mid])
{
if (v[mid] < v[end])
return mid;
//否则mid位置为最大值
else if (v[begin] < v[end])//次大值即为中间值
return end;
else
return begin;
}
else//v[begin] > v[mid]
{
if (v[end] > v[begin])
return begin;
//否则begin位置为最大值
else if (v[mid] < v[end])//找次大值
return end;
else
return mid;
}
}
单趟排序之前,将选出的中间数和第一个数互换位置,
仍然选第一个数作为key,例
int quickPartSortPreCur(vector<int>& v, int begin, int end)
{
//三数取中,选出中间数,和begin位置的值交换
int tmp = getMidKeyi(v, begin, end);
swap(v[begin], v[tmp]);
//选择基准值
int key = v[begin];
//初始化前后指针
int pre = begin;
int cur = begin + 1;
while (cur <= end)
{
if (v[cur] < key)
{
++pre;
swap(v[pre], v[cur]);
}
++cur;
}
//此时pre前面的数(包括pre指向的数)都是小于基准值(除基准值外)
swap(v[pre], v[begin]);
//交换后基准值的位置就是pre
return pre;
}
void testGetMidKeyi()
{
srand((unsigned)time(nullptr));
//创建数据
vector<int> v;
//数据量
int num = 10000;
for (int i = 0; i < num; ++i)
v.push_back(rand() % num);
//先把数据排好序
heapSort(v);
//加入三数取中后,可以算出,若不加入程序会崩溃
//clock()返回程序运行到调用位置所用的毫秒数
clock_t begin = clock();
quickSortR(v);
clock_t end = clock();
cout << "快排速度:" << end - begin << endl;
}
( 2 ) 小区间优化
当数据量大时,例对1000000个数据进行快排.
当其中一个子区间只有10个数据时,为了让这个子区间有序,仍然需要继续递归.
函数调用的消耗大.
为了让这些小区间有序,同时减少递归次数,
当区间比较小时,直接用其它排序(直接插入排序)处理小区间
void insertSort(vector<int>& v, int left, int right)
{
for (int i = left; i < right; ++i)
{
//单次插入
//[left, end]有序,将end + 1 的位置的值插入到前面
int end = i;
int insertEle = v[end + 1];
while (end >= left)
{
if (insertEle < v[end])
{
v[end + 1] = v[end];
--end;
}
else
break;
}
v[end + 1] = insertEle;
}
}
void quickSortChild(vector<int>& v, int begin, int end)
{
if (begin >= end)
return;
//如果这个区间只有10个数,使用插入排序
if (end - begin + 1 <= 10)
{
//写一个在[begin, end]区间进行插入排序的函数
insertSort(v, begin, end);
return;
}
int keyi = quickPartSortPreCur(v, begin, end);
quickSortChild(v, begin, keyi - 1);
quickSortChild(v, keyi + 1, end);
}
--非递归代码实现
用栈模拟递归过程.
递归的过程:先处理整个区间的 基准值,再去处理 左区间 和 右区间.
用一个栈(后进先出) 存储 要处理的区间
//非递归
void quickSortNotR(vector<int>& v)
{
//存储区间
stack<int> st;
//[0, v.size() - 1]
//栈后进先出,所以先入 区间右边
st.push(v.size() - 1);
st.push(0);
while (!st.empty())
{
//当前区间出栈,准备处理
int left = st.top();
st.pop();
int right = st.top();
st.pop();
//处理当前区间的基准值
int keyi = quickPartSortPreCur(v, left, right);
//将当前区间分为 左区间 和 右区间
//[left,keyi - 1] [keyi + 1, right]
//区间存在且大于一个数,区间入栈
if (right > keyi + 1)
{
st.push(right);
st.push(keyi + 1);
}
if (keyi - 1 > left)
{
st.push(keyi - 1);
st.push(left);
}
}
}
--时间复杂度
最坏情况
每次选的基准值都为max或min,O(N^2)
最好情况
每次选的基准值都是中位数,O(N*logN).
归并排序
--思路
将待排序序列不断二分,直到得到有序的子序列(单个元素),
然后将已有序的子序列两两合并,得到有序序列.
--二路归并
将两个有序序列合并成一个有序序列
//将两段有序区间[begin1, end1] [begin2, end2]合并成一段,放入tmp中
void singleMerge(vector<int>& v, int begin1, int end1, int begin2, int end2, int* tmp)
{
int i = 0;
//任一区间数据放完就结束
while (begin1 <= end1 && begin2 <= end2)
{
if (v[begin1] <= v[begin2])
tmp[i++] = v[begin1++];
else if (v[begin2] < v[begin1])
tmp[i++] = v[begin2++];
}
//然后把另一个区间剩下数据拷贝回去
while (begin1 <= end1)
tmp[i++] = v[begin1++];
while (begin2 <= end2)
tmp[i++] = v[begin2++];
}
--代码实现
无论是递归代码还是非递归代码,都需要一个临时空间tmp,
tmp的空间大小与待排序序列相同,tmp每个下标位置都能对应待排序序列.
子序列进行二路归并,合并到tmp对应位置中,再把归并完成的序列拷贝回原序列.
递归代码实现
tmp的位置与原序列的位置一一对应,所以归并到tmp中,数据的位置也必须在原来的范围.
例:[4, 5] 和 [6, 7] 位置的数据进行归并,放到tmp中
这些数据必须放在tmp的[4, 7]范围.
我通过手动传起始位置pos,保证数据归并到tmp的位置
//将两段有序区间[begin1, end1] [begin2, end2]合并成一段,放入tmp中,起始位置是pos
void singleMerge(vector<int>& v, int begin1, int end1, int begin2, int end2, int pos, int* tmp)
{
while (begin1 <= end1 && begin2 <= end2)
{
if (v[begin1] <= v[begin2])
tmp[pos++] = v[begin1++];
else if (v[begin2] < v[begin1])
tmp[pos++] = v[begin2++];
}
while (begin1 <= end1)
tmp[pos++] = v[begin1++];
while (begin2 <= end2)
tmp[pos++] = v[begin2++];
}
void mergeSortChild(vector<int>& v, int begin, int end, int* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
int begin1 = begin;
int end1 = mid;
int begin2 = mid + 1;
int end2 = end;
int pos = begin;
//[begin1, end1] [begin2, end2] 分治递归,让子区间有序
mergeSortChild(v, begin1, end1, tmp);
mergeSortChild(v, begin2, end2, tmp);
//将两个有序区间归并到tmp中,起始位置为pos
singleMerge(v, begin1, end1, begin2, end2, pos, tmp);
//将归并好的tmp中数据 拷贝回原序列
while (begin <= end)
{
v[begin] = tmp[begin];
++begin;
}
}
void mergeSort(vector<int>& v)
{
int* tmp = new int[v.size()];
//左闭右闭(要进行归并排序的区间)
mergeSortChild(v, 0, v.size() - 1, tmp);
delete[] tmp;
}
非递归代码实现
( 1 ) 递归过程中,每个子区间最终都会只剩下一个元素,再进行二路归并逐层返回.
那n个元素的待排序序列,可以直接看成n组有序序列.
( 2 ) 两组序列进行二路归并时,要判断是否存在越界行为.
相邻的两组序列:第一组 —— [begin1, end1] ;第二组 —— [begin2, end2]
以下三种情况需要修正:
A end1 >= v.size()
B begin2 >= v.size()
C end2 >= v.size()
void mergeSortNotR(vector<int>& v)
{
if (v.size() <= 1)
return;
int* tmp = new int[v.size()];
//标识几个元素为1组进行归并
int group = 1;
while (group < v.size())
{
for (int i = 0; i < v.size(); i += group * 2/*跳过两组数据*/)
{
//第一组
int begin1 = i;
int end1 = i + group - 1;
//第二组
int begin2 = i + group;
int end2 = i + 2 * group- 1;
int pos = i;
//边界修正
//如果end1越界,修正end1,同时说明第二组不可能存在
if (end1 >= v.size())
{
end1 = v.size() - 1;
begin2 = v.size();
end2 = v.size() - 1;
}
//begin2越界,第二组数据不存在
else if (begin2 >= v.size())
{
begin2 = v.size();
end2 = v.size() - 1;
}
//只有end2越界,修正第二组数据即可
else if (end2 >= v.size())
{
end2 = v.size() - 1;
}
//两组序列进行二路归并
singleMerge(v, begin1, end1, begin2, end2, pos, tmp);
}
//遍历完当前所有的组,所有相邻组 归并后的结果保存在tmp中,结果拷贝回原序列
for (int j = 0; j < v.size(); ++j)
v[j] = tmp[j];
//更新一组数据的个数
group *= 2;
}
delete[] tmp;
}
时间复杂度
归并排序是严格的二分,所以效率是O(NlogN)
但会消耗O(N)的空间复杂度.
排序的稳定性
稳定性
稳定性是指相同的数,经过排序之后,相对次序保持不变
应用:高考的排名,在总分相同的情况下,要比较语文/数学等成绩确定
例:假设考生的排名只跟总分和语文有关,此时可以先排语文,再排序总分,
各排序的稳定性
直接插入排序:稳定
希尔排序:不稳定(分组时,相同的数据可能会被分到不同的组里)
直接选择排序:不稳定
堆排序:不稳定
冒泡排序:稳定
快速排序:不稳定
归并排序:稳定