提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
上节课补充
快排空间复杂度O(logN),最差情况是O(N),需要调用系统栈记录划分值所在的位置,如果每次选取的划分值都在两端,则栈有N层,空间复杂度就是O(N);如果每次选取的划分值都在中间,则栈有logN层,空间复杂度为O(logN)。通过随机选取划分值可以使得空间复杂度为O(logN),类似于时间复杂度的分析。
1.堆–>堆排序
逻辑上是完全二叉树(二叉树从左往右依次变满)。将完全二叉树的节点与数组中从下标0开始的元素依次对应:数组中下标i对应的左孩子在数组中的下标是2i+1、右孩子是2i+2,对应的父节点在数组中的下标是(i-1)/2(0的父节点还是自己)。
大根堆:完全二叉树中如果每棵子树的最大值都在顶部就是大根堆
小根堆:完全二叉树中如果每棵子树的最小值都在顶部就是小根堆
heapInsert:新插入的节点与其父节点比较,如果比父节点大,则交换,然后再与交换后的父节点(i-1)/2比较,直到不比父节点大或者当前节点已经是整棵树的根节点为止。时间复杂度为O(logN),向上走一个高度。
代码实现:
void heapInsert(vector<int>& arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) {//包含了当前节点是0根节点的判断
swap(arr[index], arr[(index - 1) / 2]);
index = (index - 1) / 2;
}
}
heapify:用一个变量存储根节点(最大值),然后将最后一个节点的值移到根节点,heapSize–(等同于将最后一个节点剔除出堆)。这时整个完全二叉树可能不是堆,需要从头节点开始,选择左孩子和右孩子中的最大值,如果比最大值小,则与这个孩子交换,直到比当前位置的左孩子右孩子的值都大,或者当前位置没有左孩子(当然也不可能有右孩子)。时间复杂度为O(logN),向上走一个高度。
heapify是从上往下、heapInsert是从下往上。如果更改了堆中的某个节点的值,如果比原来值变大了,则向上heapInsert;如果比原来值变小了,则向下heapify;如果不告诉是变大还是变小,则可以都试一下,两个只能中一个。
代码实现:
void heapify(vector<int>& arr, int index, int heapSize) {//从index位置开始做heapify
int left = 2 * index + 1;//左孩子下标
while (left < heapSize) {//左孩子下标存在的条件
//将两个孩子值大的下标赋给largest(要判断是否存在右孩子)
int largest = (left + 1 < heapSize) && (arr[left + 1] > arr[left]) ? left + 1 : left;
//当前节点与较大孩子节点比较
largest = arr[index] < arr[largest] ? largest : index;
if (largest == index) {
break;
}
swap(arr[index], arr[largest]);
index = largest;
left = 2 * index + 1;
}
}
堆排序:heapSize=0开始,每次将堆的范围扩大1,相当于每次heapInsert一个元素,最后将整个数组变成一个堆;然后将堆中的最后一个元素与第一个元素(最大值)交换,并将heapSize–(也就是将最后一个元素与堆断开联系,该值已经来到排好序的位置);从根节点做heapify,再次调整成大根堆;继续上述过程,直到heapSize==1
代码实现:
void heapSort(vector<int>& arr) {
if (arr.size() < 2) {
return;
}
// for (int i = 0; i < arr.size(); i++) {//O(N)
// heapInsert(arr, i);//O(logN)
// }
for (int i = arr.size() - 1; i >= 0; i--) {
heapify(arr, i, arr.size());
}
int heapSize = arr.size();
swap(arr[0], arr[--heapSize]);
while (heapSize > 0) {//O(N)
heapify(arr, 0, heapSize);//O(logN)
swap(arr[0], arr[--heapSize]);
}
}
时间复杂度:O(NlogN),空间复杂度:O(1)
给定数组所有元素的情况下也可以有更好的方法进行堆化:依次从叶子节点从右向左分别做heapify。时间复杂度:
例:已知一个几乎有序的数组,几乎有序是指,如果把数组排好顺序的话,每个元素移动的距离可以不超过k,并且k相对于数组来说比较小。请选择一个合适的排序算法针对这个数据进行排序。
解:将数组的前k+1个元素调整成小根堆,则堆中的第一个元素所在位置就是排好序的位置;然后将堆顶弹出,数组中的后一个元素添加近堆中,再调整成小根堆;重复上述过程,直到将最后一个数组元素也加入到堆中;最后依次将堆顶元素弹出放到数组中,整个数组有序。
时间复杂度:O(N*logk)。
代码实现:
void sortedArrDistanceLseeK(vector<int>& arr, int k) {
priority_queue<int, vector<int>, greater<int>>heap;
int index = 0;
for (; index <= k; index++) {
heap.push(arr[index]);
}
int i = 0;
for (; index < arr.size(); i++, index++) {
arr[i] = heap.top();
heap.pop();
heap.push(arr[index]);
}
while (!heap.empty()) {
arr[i++] = heap.top();
heap.pop();
}
}
C++提供priority_queue容器来实现堆(但有些时候要手写堆才能做到高效),priority_queue用法如下:
1)包含头文件:#include
2)定义:priority_queue<Type, Container, Functional>
a)Type:数据类型
b)Container:容器类型(Container必须是用数组实现的容器,比如vector,deque等等,但不能用list。STL里面默认用的是vector)
c)Functional:比较的方式,当需要用自定义的数据类型时才需要传入这三个参数,使用基本数据类型时,只需要传入数据类型,默认是大顶堆
d)小根堆:priority_queue <int,vector,greater > q;
大根堆:priority_queue <int,vector,less >q;
3)基本操作:和队列相同
e)top:访问队头元素
f)empty:队列是否为空
g)size:返回队列内元素个数
h)push:插入元素到队尾 (并排序)
i)emplace:原地构造一个元素并插入队列
j)pop:弹出队头元素
k)swap:交换内容
2.priority_queue多种自定义类型排序
(1)在自定义类型中重载<、>运算符,重载函数一定要声明const类型
//1.自定义类中重载比较运算符:< >
class Node01 {
public:
int size;
int price;
//重载<运算符
bool operator<(const Node01& b) const {//注意此处的const是必须的
if (this->size == b.size) {
return this->price < b.price;
}
return this->size < b.size;
}
//重载>运算符
bool operator>(const Node01& b) const {
if (this->size == b.size) {
return this->price > b.price;
}
return this->size > b.size;
}
};
void test01() {
//priority_queue<Node01> pq;//默认是大根堆
priority_queue<Node01, vector<Node01>, greater<Node01>> pq;//小根堆
pq.push(Node01{ 1, 5 });
pq.push(Node01{ 5, 1 });
pq.push(Node01{ 1, 3 });
pq.push(Node01{ 3, 5 });
while (!pq.empty()) {
cout << pq.top().size << " " << pq.top().price << endl;
pq.pop();
}
}
(2)使用函数对象(仿函数),直接将函数对象类当作priority_queue的第三个参数传给它
//2.使用函数对象(仿函数)
class Node02 {
public:
int size;
int price;
};
class Cmp {
public:
bool operator()(const Node02& a, const Node02& b) {
return a.size == b.size ? a.price > b.price:a.size > b.size;//小根堆
}
};
void test02() {
priority_queue<Node02, vector<Node02>, Cmp> pq;//小根堆
pq.push(Node02{ 1, 5 });
pq.push(Node02{ 5, 1 });
pq.push(Node02{ 1, 3 });
pq.push(Node02{ 3, 5 });
while (!pq.empty()) {
cout << pq.top().size << " " << pq.top().price << endl;
pq.pop();
}
}
(3)使用lambda表达式,需要使用decltype,同时将lambda表达式对象传入优先队列实例中
//3.使用lambda表达式
class Node03 {
public:
int size;
int price;
};
void test03() {
auto cmp = [](const Node03& a, const Node03& b) {
return a.size == b.size ? a.price > b.price:a.size > b.size;
};//lambda表达式是一个表达式,这里必须要有";"
priority_queue<Node03, vector<Node03>, decltype(cmp)> pq(cmp);//小根堆
pq.push(Node03{ 1, 5 });
pq.push(Node03{ 5, 1 });
pq.push(Node03{ 1, 3 });
pq.push(Node03{ 3, 5 });
while (!pq.empty()) {
cout << pq.top().size << " " << pq.top().price << endl;
pq.pop();
}
}
(4)使用函数指针:将一个bool类型的函数当作参数传给priority_queue,与lambda表达式类似
//4.使用函数指针
class Node04 {
public:
int size;
int price;
};
bool cmpFun(const Node04& a, const Node04& b) {
return a.size == b.size ? a.price > b.price:a.size > b.size;
}
void test04() {
priority_queue<Node04, vector<Node04>, decltype(&cmpFun)> pq(&cmpFun);//小根堆
pq.push(Node04{ 1, 5 });
pq.push(Node04{ 5, 1 });
pq.push(Node04{ 1, 3 });
pq.push(Node04{ 3, 5 });
while (!pq.empty()) {
cout << pq.top().size << " " << pq.top().price << endl;
pq.pop();
}
}
3.桶排序
之前的所有排序都是基于比较的排序,桶排序是基于数据状况的排序,应用范围没有基于比较的排序广。
桶排序中的计数排序参见《剑指offer专项突破》P208。
基数排序:以十进制为例,准备10个容器(桶),编号分别为0…9。首先按照数组中数据的个位从左到右放在对应编号的桶中,然后将数据从左往右依次从桶中取出(先进先出原则);然后按照数据的十位重复上述过程,直到数据的最大位,此时再取出的数据即为排好序的数据。
代码分析:用一个函数maxbits求数组中元素的最大十进制位;准备一个与原数组等规模的空间bucket;一共要出桶入桶的次数等于最大十进制位(外层循环);外层循环内部分析:
用一个长度为10的数组count记录每个数的个位数字出现的频次:
然后将count变成前缀和数组:
现在count每个元素的值的含义是个位数字小于等于下标的数字的个数;然后将原数组从右往左遍历,每遍历到一个数字num求其对应位在count的值,该值减一得到的数就是num应该放到bucket中的位置(注意还要将count在此位置的值减一),最后再将bucket的值赋值给原数组。至此完成一次入桶处桶的操作。
代码实现:
int maxbits(vector<int>& arr) {
int maxNum = INT_MIN;
for (int num : arr) {
maxNum = max(num, maxNum);
}
int res = 0;
while (maxNum != 0) {
maxNum /= 10;
res++;
}
return res;
}
int getDigit(int num, int d) {//求num第d位上的数字,d=1对应个位
for (int i = 1; i < d; i++) {
num /= 10;
}
return num % 10;
}
void radixSort(vector<int>& arr, int l, int r, int digit) {//对数组的l…r进行排序,digit是数组中最大位数
int radix = 10;//10进制数
vector<int>bucket(r - l + 1);//辅助空间
for (int d = 1; d <= digit; d++) {//有多少位就进出多少次
vector<int>count(radix);
for (int i = l; i <= r; i++) {
int j = getDigit(arr[i], d);
count[j]++;
}
for (int i = 1; i < count.size(); i++) {
count[i] = count[i] + count[i - 1];
}
for (int i = r; i >= l; i--) {
int j = getDigit(arr[i], d);
bucket[count[j] - 1] = arr[i];
count[j]--;
}
for (int i = l, j = 0; i <= r; i++, j++) {
arr[i] = bucket[j];
}
}
}
void radixSort(vector<int>& arr) {
if (arr.size() < 2) {
return;
}
radixSort(arr, 0, arr.size() - 1, maxbits(arr));
}
4.排序算法的稳定性
稳定性:相同的元素排序之后是否保持相对次序不变。基础类型没用,非基础类型有用(自定义数据类型)
选择排序:不稳定
冒泡排序:稳定(相等的时候不能交换)
插入排序:稳定(相等的时候不能交换)
归并排序:稳定(双指针指向的数字相等时,应先copy左边的;小和问题是先copy右边的,因此得到的有序数组是不稳定的)
快排:不稳定
堆排:不稳定
计数排序和基数排序:稳定(因为和比较无关)
5.总结
一般选择快排(最快),用空间限制选堆排,需要稳定时选归并。目前没有找到时间复杂度O(NlogN),额外空间复杂度O(1),又稳定的排序。
常见的坑:
(1)归并排序的额外空间复杂度可以变成O(1),“归并排序内部缓存法”,但是就不稳定了,不如选择堆排
(2)“原地归并排序”的帖子都是垃圾,会让归并排序的时间复杂度变成O(N^2),不如用插入
(3)快速排序可以做到稳定,但是非常难,不需要掌握,可以搜“01 stable sort”,空间复杂的变成O(N),不如用归并
(4)所有的改进都不重要,因为目前没有找到时间复杂度O(NlogN),额外空间复杂度O(1),又稳定的排序。
(5)有一道题目,是奇数放在数组左边,偶数放在数组右边,还要求原始的相对次序不变,碰到这个问题,可以怼面试官。经典快排partition的思想可以做到奇数放左边,偶数放右边,但是快排不稳定,原始的相对次序改变了
工程上对排序的改进:
(1)充分利用O(N*logN)和O(N^2)排序各自的优势
(2)稳定性的考虑:基础类型用快排,自定义类型用归并