排序是出镜率最高的面试题,基本上没有之一
一、就排序说排序
这一环节的目的,一方面是完全熟悉常用常考的排序,另一方面是对排序所涉及的相关的知识,如时间复杂度的感觉到理解、不同的数据结构、不同的算法思路、排序解决的问题、排序在实际问题中的用途,建立一个更全面的体系,毕竟不论是面试还是实际工作,所面对的问题远远不会是仅是会写排序。
1.0、排序本身都有哪些排序:
1、原地比较排序:和线性排序实质区别是,原地比较排序的时间复杂度不是O(N),在理论上最高只能做到O(N*logN),而线性排序达到O(N)线性复杂度
按核心思路分为:
1.1、基于选择的:不断选出特大/小值。
冒泡排序:稳定、接近O(N^2),从头到尾共N趟,发现有更大/小就交换
快速排序:不稳定(因为会劈开)、平均/可认为O(N*logN)、原始数据越有序则越慢(因为导致大量数据交换)最慢O(N^2)、最好情况也是O(N*logN),典型的分治思想的运用,选一个标杆值,然后把其他数据按标杆值分为两部分,就这样不断细分下去,最终实现全部有序。
快排的分治思路非常重要,不仅排序用到,诸如找第K大的数这样的使用分治解决的问题同样会用到
冒泡排序:
/*
* buddle sort
* */
void buddle_sort (int *data, int size) {
for (int i = 0; i < size; i++) {
for (int j = i; j < size; j++) {
if (data[j] < data[i]) {
int tmp = data[j];
data[j] = data[i];
data[i] = tmp;
}
}
}
}
快速排序:
/*
* quick sort
* */
int partition (int *data, int start, int end) {
if (start < end) {
int flagval = data[end];
int i = start, j = start;
for (; i < end; ++i) {
if (data[i] < flagval) {
int tmp = data[i];
data[i] = data[j];
data[j++] = tmp;
}
}
int tmp = data[j];
data[j] = flagval;
data[end] = tmp;
return j;
} else {
return -1;
}
}
void qsort_unrecursion (int *data, int size) {
int part = partition(data, 0, size - 1);
std::stack<int> stk;
stk.push(0);
stk.push(part - 1);
stk.push(part + 1);
stk.push(size - 1);
while (!stk.empty()) {
int end = stk.top();
stk.pop();
int start = stk.top();
stk.pop();
if (start < end) {
int part = partition(data, start, end);
if (part > 0) {
stk.push(start);
stk.push(part - 1);
stk.push(part + 1);
stk.push(end);
}
}
}
}
选择排序:稳定、接近于O(N^2),从头到尾共N趟,每一趟找出第i个索引到尾部的最大/小值
堆排序:不稳定、接近于O(N*logN)(完全二叉树,无最好最坏平均之分),每趟均二叉的找出剩余数据的最大/小者作为堆顶,直到全部处理完毕
堆在海量数据面试题中常常被用到,典型为topk、中位数、第K大。实际应用中也经常用于维护满足"可能定长的、且动态可变"性质的业务结构数据。
选择排序:
/*
* select sort
* */
void select_sort (int *data, int size) {
for (int i = 0; i < size; i++) {
int min = i;
for (int j = i; j < size; j++) {
if (data[min] > data[j]) {
min = j;
}
}
int tmp = data[i];
data[i] = data[min];
data[min] = tmp;
}
}
堆排序:
template<class T> class Heap {
T *data;
int size;
public:
Heap (T *_data, int _size):size(_size) {
data = new T[_size + 1];
for (int i = 1; i <= size; i++) {
data[i] = _data[i - 1];
}
for (int i = size/2; i >= 1; --i) {
adjust(i, size);
}
}
~Heap () {
if (data) {
delete []data;
}
size = 0;
}
void adjust (int idx, int sz) {
if (idx <= sz/2) {
int l = idx * 2, r = idx * 2 + 1;
int minidx = idx;
if (l < sz && data[minidx] < data[l]) {
minidx = l;
}
if (r < sz && data[minidx] < data[r]) {
minidx = r;
}
if (idx != minidx) {
T tmp = data[idx];
data[idx] = data[minidx];
data[minidx] = tmp;
adjust(minidx, sz);
}
}
}
T gettop () {
return data[1];
}
void settop (T val) {
data[1] = val;
for (int i = size; i >= 1; i--) {
adjust(i, size);
}
}
void sort () {
for (int i = size; i >= 1; i--) {
T min = data[1];
data[1] = data[i];
data[i] = min;
adjust(1, i - 1);
}
}
void show () {
for (int i = 1; i <= size; i++) {
std::cout << data[i] << ",\t";
}
std::cout << std::endl;
}
};
void heap_sort (int *data, int size) {
Heap<int> hp(data, size);
hp.sort();
hp.show();
}
1.3、基于插入的:
插入排序:稳定、时间复杂度O(N^2),相比冒泡和选择,相对难一些:
排序方式为,每个数据在其前面的数据找比他大/小的,即所谓的插入位置,在找到之前将做数据整体移位,
如原数据为1、2、3、100、101、102、103、4
则对于4的插入排序趟,找到合适的插入位置3之后的100过程中,它前面的103到100会在遍历过程中依次向前移位,直到找到3,将4插入在原先100的位置。
明显可见如果原始数据已经比较有序,那么插入排序进行的越快。如果原始数据已经完全有序,插入排序的时间复杂度为O(N),反之则越慢,而且有大量的数据比较和交换的工作。
针对插入排序大量的数据比较工作的问题,一个常见的改进是,利用插入排序的"待排序数据之前的数据已经有序"的特点,不通过挨个比较,而是通过二分查找的方式找到待排序数据应该插入的位置,如上面的4,通过对1到103的数据进行二分查找找到应该插入的位置100;这样可大幅度减少数据比较,但仍然需要进行交换,依然是非常低效的。
所以插入排序往往用于链表,这也是链表排序面试题的解决方式之一(链表的原地排序,主要适合基于插入的和归并排序式的),链表插入排序的核心思想是分治,初始将一个元素(往往是第一个元素)作为"已排序链表",其他的元素作为"未排序链表",然后把"未排序链表"的元素依次在"已排序链表"里寻找按顺序的插入点,直到"未排序链表"的元素都插入到了"已排序链表"。
对于插入排序,链表形式之所以合适就是因为不再大量的进行数据交换,而是仅修改指针的指向。
希尔排序:不稳定、平均时间复杂度被认为接近O(N^1.3);希尔排序是"渐进式插入排序"的方式,针对插入排序适合数据越有序越快的特性,希尔排序不断用较少的排序次数,实现整个数据的较大程度有序;
希尔排序重在理解其其发明目的,是处于什么原因,对原始插入排序做的什么改进;
方式是,按由大逐渐到小的"步长",把数据按照步长的对应数据达到有序,比如总数为10,步长初始为5,那么首先保证0和5、1和6、.......4和9的有序,然后步长减少为2,保证0和2、0和2和4、0和2和4和8的有序,然后步长为1,变成插入排序,由于前面弄的数据已经相对比较有序了,所以现在做插入排序就比较快了;
二分查找优化的插入排序、链表式插入排序、希尔排序都是堆插入排序的优化改进。
二分查找优化的插入排序:
/*
* insert sort
* */
int binary_search (int *data, int cur, int start, int end) {
while (start <= end) {
int mid = (start + end)/2;
if (data[mid] < cur) {
start = mid + 1;
} else if (data[mid] > cur) {
end = mid - 1;
} else {
return mid + 1;
}
}
return start;
}
void insert_sort (int *data, int size) {
for (int i = 1; i < size; ++i) {
int cur = data[i], j = binary_search(data, cur, 0, i - 1);
if (j >= 0) {
for (int k = i; k > j; --k) {
data[k] = data[k - 1];
}
data[j] = cur;
}
}
}
template<class T> struct Node {
T val;
Node<T> *next;
Node (T _val):val(_val), next(nullptr) {}
};
template<class T> class List {
Node<T> *root;
public:
List (T *data, int size):root(nullptr) {
for (int i = 0; i < size; i++) {
if (!root) {
root = new Node<T>(data[i]);
} else {
Node<T> *newnode = new Node<T>(data[i]);
newnode->next = root->next;
root->next = newnode;
}
}
}
~List () {
if (root) {
while (root) {
Node<T> *next = root->next;
delete root;
root = next;
}
root = nullptr;
}
}
void sort () {
Node<T> *sorted = root, *unsorted = root->next;
sorted->next = nullptr;
while (unsorted) {
Node<T> *p = sorted, *prev = sorted, *q = unsorted;
for (; p && q->val >= p->val; p = p->next) {
prev = p;
}
Node<T> *next = q->next;
unsorted = next;
if (p == sorted) {
q->next = sorted;
sorted = q;
} else {
q->next = p;
prev->next = q;
}
}
root = sorted;
}
void show () {
Node<T> *cur = root;
while (cur) {
std::cout << cur->val << ",\t";
cur = cur->next;
}
std::cout << std::endl;
}
};
void insert_list_sort (int *data, int size) {
List<int> list(data, size);
list.show();
list.sort();
list.show();
}
希尔排序:
void shell_sort (int *data, int size) {
for (int i = size/2; i > 0; i /= 2) {
for (int j = i; j < size; j++) {
int cur = data[j];
int k = j;
while (k >= 0 && cur <= data[k - i]) {
data[k] = data[k - i];
k -= i;
}
if (k < 0) {
data[k + i] = cur;
} else {
data[k] = cur;
}
}
}
}
1.4、归并排序:
归并排序从排序手段上,本身并没有什么新内容,重点在于思路:将原始数据分为不断的K分(K由2-M,M < N),当数据被分割为小于K时,做每K路的合并,并对合并后的继续做K路的合并,直到都合并完成。K路归并排序则为O(N * logN / logK)。常用的是2路归并排序,即O(N*logN)。
如数据总量为10,首先分为两个大小分别为5的部分,然后再分为两个大小为2、两个大小为3的部分,然后分成8个大小为1、2个为2的部分,这时进行merge,merge的实现自定义。
归并排序是对数复杂度排序的终极排序方法。它是稳定的、时间复杂度也稳定为O(N*logN)的。
为了实现O(N*logN)的时间复杂度,归并排序事实上需要少量(<= N)多次的空间复杂度,即merge时用线性比较的方式,将结果写入临时额外空间,再更新原数据对应部分。这个很多文章没有明说,或者用隐晦的方式实现,表面看起来空间复杂度是O(1)。
归并排序重在思想,它是更复杂的问题,如海量数据中的各种问题中的一种重要解决思路,包括外部排序本身就是归并排序的思想出发点。
归并排序:
void sort (int *data, int st1, int ed1, int st2, int ed2) {
int *tmp = new int[ed2 - st1 + 1];
int start = st1;
int i = 0;
while (st1 <= ed1 && st2 <= ed2) {
if (data[st1] < data[st2]) {
tmp[i++] = data[st1++];
} else {
tmp[i++] = data[st2++];
}
}
while (st1 <= ed1) {
tmp[i++] = data[st1++];
}
while (st2 <= ed2) {
tmp[i++] = data[st2++];
}
for (int idx = 0; idx < i; idx++) {
data[start + idx] = tmp[idx];
}
delete []tmp;
}
void merge_sort (int *data, int start, int end) {
if (start < end) {
int mid = (start + end)/2;
merge_sort(data, start, mid);
merge_sort(data, mid + 1, end);
sort(data, start, mid, mid + 1, end);
}
}
1.5、总结:
1、日常:不要求稳定,快排,因为速度最快;要求稳定时建议用归并;注意堆排序的使用;
2、堆排序的妙用、快排的分治的思想、分块再归并的思路,是排序的最重要需要理解和收获的内容;要作为解决更复杂问题的常规武器;
3、要注意链表的排序,链表排序除插入方式外,最适合的就是归并方式,典型题就是"合并两个有序链表",如果可以使用额外空间,则归并排序是最佳方式;
2、线性排序:
数据总量为N,能保证也用O(N)的时间复杂度完成的排序。但往往都是有限制条件或适用场景。
1、计数排序:仅适用整数,必须明确最大值,最好从0开始。需要O(2 * N)的额外空间。
计数排序思路是:在明确所排序数字范围为0-N后,创建个数为N的数组并累计每个数字出现次数,进而得到每个数字出现的座次,如0出现2次,1出现3次,则0的座次为0-1,1的座次为2-4,以此类推得到全部数字的座次,然后是一个技巧,从后向前逆序遍历,将原始数据根据其座次,塞入结果数组;
缺点:当范围很大时,也需要一个巨大的O(range)的额外空间
计数排序:
/*
* counter sort, range is 0-99
* */
void countersort (int *data, int size, int max) {
int range[100] = {0}, result[100];
for (int i = 0; i < size; i++) {
range[data[i]] += 1;
}
for (int i = 1; i < max; i++) {
range[i] += range[i - 1];
}
for (int i = size - 1; i >= 0; i--) {
if (range[data[i]]) {
result[range[data[i]] - 1] = data[i];
--range[data[i]];
}
}
for (int i = 0; i < size; i++) {
data[i] = result[i];
}
}
桶排序思路:同样需要明确最大值,然后根据数值范围,再创建一个个的小范围,如数值范围为0-100,再创建0-9、10-19、......90-99的10个小范围,每个小范围称之为一个桶,原始数据根据大小落在对应的桶中,这样首先实现了数据的按范围有序,然后每个桶内数据再排序,最终将全部桶按顺序输出就是排序结果。
另类的归并排序,在海量数据问题中很有效,比如找第K大、中位数、topk等。
缺点:和计数排序一样,当范围很大时,需要一个巨大的O(range)的额外空间
桶排序:
class Bucket {
std::vector<int> *buckets;
public:
Bucket (int *data, int size, int max, int base) {
buckets = new std::vector<int>[max/base];
for (int i = 0; i < size; i++) {
int cur = data[i];
int idx = cur/base;
buckets[idx].push_back(cur);
}
sort(max/base);
}
~Bucket () {
delete []buckets;
}
void sort (int num) {
for (int i = 0; i < num; i++) {
std::sort(buckets[i].begin(), buckets[i].end());
}
for (int i = 0; i < num; i++) {
for (auto j: buckets[i]) {
std::cout << j << "\t";
}
std::cout << std::endl;
}
}
};
3、基数排序:基数排序是一个广义维度思路的排序方式。基数的意思是,以什么指标作为排序依据。
在用于正整数排序时,排序指标是:每一个十进制整数的末位数大小,如:
1、100、105、5000、309
第1次比较末位数:排序结果为100、5000、1、105、309
第2次比较末位数:排序结果为100、5000、1、105、309
第3次比较末位数:排序结果为1、100、105、309、5000
第4次比较末位数:排序结果为1、100、105、309、5000
每次末位数的比较的排序方式随意,图简单直接用计数排序,0-9的数字范围只需要10个int的额外空间,时间复杂度O(N)
基数排序的空间复杂度为O(N + 10)相当于O(N),时间复杂度大致为O(最大数字位数 * N * 2),相当于O(N)O()
不同于计数排序和桶排序必须知道最大值确切范围,整数的基数排序只需知道最大值的位数
举一反三的思考,基数排序更大的意义在于通过不同的基数定义,进行不同的排序,如:
上亿个int32整数中找第K大/中位数。
原地比较排序中,
可以由快排的分治思想做,但快排会破坏原始数据;
可以由K大小的大顶堆实现,时间复杂度N*logN空间复杂度O(K)相当于没有;
线性排序中,计数排序也可以实现,比较有意思的解决方式是:
1、桶排序:分为若干桶,O(N)的插入不同的桶,插入后可发现每个桶内数据大小,则可定位第K大、中位数落在哪个桶的哪个位置,
然后对那个桶单独进行排序,取到那个位置的数据,使用额外空间可认为O(N),时间复杂度可认为O(N)
2、基数排序:非上面的直接基数排序,而是同时借鉴直接基数排序和通排序,改排序指标即基数为每个数除以65535的余数,
这样对于int32的整数,会有最多65536个结果,即0-65535、65536-65536*2、...这样,
创建数组个数为65536,遍历一次数据,每个成员统计落到本区间数字的个数
这样,遍历一次后,同样可以根据每个区间的个数,判断出第K大/中位数在哪个区间及其位置,
然后再次遍历原始数据,专门找出落在对应区间的数据,进而找到该数据。
空间复杂度大幅度降低为O(65535),因为每个区间仅仅计数,这点区别于桶排序方式,时间复杂度O(N * 2)相当于O(N)
当K比较大,比如上亿数找中位数时,基数排序的优势就体现出来了,相比其他任何方式,空间相对小速度相对快
基数排序:
int get_max_bit_num (int max) {
int num = 0;
while (max) {
++num;
max /= 10;
}
return num;
}
void radixsort (int *data, int size, int max) {
int maxbit = get_max_bit_num(max), base = 1;
int range[10] = {0};
int result[size];
for (int i = 0; i < maxbit; i++) {
for (int j = 0; j < 10; j++) {
range[j] = 0;
}
for (int j = 0; j < size; j++) {
int lastbit = (data[j]/(base)) % 10;
++range[lastbit];
}
for (int j = 1; j < 10; j++) {
range[j] += range[j - 1];
}
for (int j = size - 1; j >= 0; j--) {
result[range[(data[j]/(base)) % 10] - 1] = data[j];
--range[(data[j]/(base)) % 10];
}
for (int j = 0; j < size; j++) {
data[j] = result[j];
}
base *= 10;
}
}
二、更广义的排序:
1、hash族的bitmap,也是一个排序利器,需要的额外空间还更小,同样需要预先知道最大值,不过最好非负整数,并且最好不要重复;
限制条件较多,所以只在某些条件下适用。
时间复杂度O(N * 2)相当于O(N),O(N/32 + 1)的额外空间,亮点是空间复杂度降低,对于不是特别巨大的海量数据有效。
bitmap排序:
class Bitmap {
int *data;
public:
Bitmap (int max) {
data = new int[max/32 + 1];
}
~Bitmap () {
delete []data;
}
void Set (int d) {
data[d / 32] |= (1 << (d % 32));
}
void Traverse (int max) {
for (int i = 0; i < max/32 + 1; i++) {
for (int j = 0; j < 32; j++) {
if (data[i] & (1 << j)) {
std::cout << i * 32 + j << " ";
}
}
}
std::cout << std::endl;
}
};
2、可排序的数据结构
对于实际的有序数据存储,肯定不是一个已知数组,而是一个数据结构,能够动态的增加/更新、删除成员,同时保持有序性,
排序的用途,最终归结在"可随时快速的增删查改"
对于可排序的数据结构,分为两大类:
1、树族:无翻转规则的二叉排序树、按翻转规则的二叉排序树、按分裂规则的多叉排序树
1.1、无翻转规则的二叉排序树:无实际用途
1.2、按翻转规则的二叉排序树:典型为AVL树、红黑树,其中红黑树用于标准库的map
对于这一类,明确为什么要翻转规则即可。不翻转放任自流,就像第一种,那么增删查改时间复杂度无法控制,极端情况下退化为有序链表
AVL的翻转较简单,任何一个节点底下的左右子树高度差不可超过1,超过1就翻转。所以AVL树的增删查改的时间复杂度肯定是O(logN),
但缺点是总会引起翻转;而红黑树则一定程度上放宽了高度差要求,减少了翻转次数,增删查改的时间复杂度可认为也是O(logN)。
1.3、按分裂规则的多叉排序树:B族树,典型的被用于mysql
2、链表族:目前所了解的只有跳表
见跳表