常见排序算法
快排
基本实现: 两侧向中间靠拢的 partition 版本:
int partition(int a[], int left, int right)
{
int pivot = a[left]; //设置a[left]为主键值
while(left<right)
{
while(left<right && a[right]>=pivot) right--; //从右边开始找到比主键值小的值,移到左边
a[left]=a[right];
while(left<right && a[left]<=pivot) left++; //从左边开始找到比主键值大的值,移到右边
a[right]=a[left];
}
a[left] = pivot; //跳出while循环后的left==right,此时,区间已经一分为二了,将a[left]的值还原
return left;
}
/**使用递归快速排序**/
void QuickSort(int a[], int left, int right)
{
if(left<right) //快排区间要大于1
{
int mid = partition(a,left,right); //进行一次划分,以a[left]划分区间为左右两个区间
QuickSort(a,left,mid-1); //对左区间进行进一步划分
QuickSort(a,mid+1,right); //对左区间进行进一步划分
}
}
/**使用栈的非递归快速排序**/
void qSortNonRecusive(int a[], int left, int right){
//用栈保存每一个待排序子串的首尾元素下标,while循环时取出这个范围,进行partition操作
Stack<Integer> st = new Stack<>();
if(left < right){
int mid=partition(a,left,right);
if(left < mid-1){
st.push(left);
st.push(mid-1);
}
if(mid+1 < right){
st.push(mid+1);
st.push(right);
}
while(!st.isEmpty()){
right = st.peek(); st.pop();
left = st.peek(); st.pop();
mid=partition(vec,left,right);
if(left < mid-1){
st.push(left);
st.push(mid-1);
}
if(mid+1 < right){
st.push(mid+1);
st.push(right);
}
}
}
}
针对数字重复出现的改进: 3-way partition 使用三个指针(两个初始化为开头,一个在结尾), 左中右分别为小于、等于、大于pivot的元素。
另外,可以对pivot的选取随机化:
random_partition(int *a, int left, int right) {
i = random(left, right);
swap(a, i, left); // 随机选一个元素当作pivot主元, 放置在开头或末尾的位置.
return partition(a,left,right);
}
快排空间复杂度
快速排序的原地排序时的空间复杂度是O(log2n)~O(n), 是递归调用的栈空间大小; 最坏情况下递归树的深度是O(n).
最好情况下的时间复杂度
时间复杂度递推关系式:
由主定理得 \(f(n)\in\Theta(n\log n)\).
随机化快速排序的期望时间
假设排序后的数组 A 中的每个元素定义为 z1,z2,z3...zn。这里的 zi 表示为 A 中 第 i 小的元素。定义zi与zj两个元素在排序过程中的比较次数时 \(X_{ij}\), 那么排序的总比较次数为:
因此总比较次数的期望为:
这里我们需要计算zi与zj会发生比较的概率. 由于排序过程中的比较仅出现在pivot与其它元素之间, 因此如果zi与zj发生比较, 那么其中之一必然曾被当作过pivot. 因此 \(P(z_i 比较 z_j)=P(z_i \text{是 pivot}) + P(z_j \text{是 pivot})=2\times {1\over j-i+1}={2\over j-i+1}\)
这里zi与zj离得越远, 两者被随机选择为pivot的概率越小.
因此,
其中用到了调和级数:
第n个调和级数是: \(H_n=1+\frac{1}{2}+\frac{1}{3}+...+\frac{1}{n}=\sum_{k=1}^n {1\over k}=\ln n+O(1)\)
参考
算法导论. 快排平均时间复杂度.
堆排
基于数组的实现:
// 将最大值移到末尾
void heapSort(int a[],int size)
{
int heapSize = size; //heapSize was init to be the length of the array
buildMaxHeap(a,heapSize); //build a heap on a[]
for(int i=heapSize;i>0;i--)
{
swap(a[0],a[i]); //swap the root of tree with the final element ,get out of the heap
heapSize --;
maxHeapify(a,0,heapSize); //重新整队,保持最大堆的性质
}
}
// 从第一个非叶子节点开始遍历到根节点
void buildMaxHeap(int a[],int size)
{
for(int k=(size-1)/2;k>=0;k--) //从第一个非叶子节点开始,遍历到根节点
{
maxHeapify(a,k,size); //进行建堆,就是让所有节点都符合堆的定义 ——根节点都要比子节点大
}
}
// 从指定节点开始向下
void maxHeapify(int a[],int i,int heapSize)
{
int left = (i<<1) + 1; //left child
int right = (i<<1) + 2; //right child
int largest =i; //mark the position of the largest element in array
if(left <=heapSize && a[left] > a[i])
largest = left;
else
largest = i;
if(right <= heapSize && a[right] > a[largest])
largest = right;
if(largest != i) //说明有节点比根节点大,根节点需要下移
{
swap(a[i],a[largest]);//swap 之后需要调整堆
maxHeapify(a,largest,heapSize); //递归调用,防止子节点违反规则
}
}
优先级队列
基于大顶堆可实现优先级队列. 主要有以下操作:
- peek: 返回最大元素, 返回堆顶元素即可
- poll/extract-max: 移除并返回最大元素, 将堆顶元素与末尾元素交换, 重新maxHeapify
- set/increase-key: 更改某一元素的值, 如果值增大,那么依次与父节点比较,向上移动到合适位置.
- offer/add/insert: 添加元素, 在队列末尾新增空元素, 然后调用set/increase-key.
- PriorityQueue的实现除了这种原始形式的堆, 也可以替换成斐波那契堆FibonacciHeap.
归并排序
/**
tmp_array[]:辅助数组。
left_pos:数组左半部分的游标
left_end:左边数组的右界限
*/
void Merge(int array[], int tmp_array[], int left_pos, int right_pos, int right_end) {
int i, left_end, num_elements, tmp_pos;
left_end = right_pos - 1;
tmp_pos = left_pos;
num_elements = right_end - left_pos + 1;
while (left_pos <= left_end && right_pos <= right_end)
if (array[left_pos] <= array[right_pos])
tmp_array[tmp_pos++] = array[left_pos++];
else
tmp_array[tmp_pos++] = array[right_pos++];
while (left_pos <= left_end)
tmp_array[tmp_pos++] = array[left_pos++];
while (right_pos <= right_end)
tmp_array[tmp_pos++] = array[right_pos++];
for (i = 0; i < num_elements; i++, right_end--)
array[right_end] = tmp_array[right_end];
}
void MSort(int array[], int tmp_array[], int left, int right) {
int center;
if (left < right) {
center = (left + right) / 2;
MSort(array, tmp_array, left, center);
MSort(array, tmp_array, center + 1, right);
Merge(array, tmp_array, left, center + 1, right);
}
}
void MergeSort(int array[], int n) {
int *tmp_array;
// 建立临时数组tmp_array
tmp_array = (int *)malloc(n * sizeof(int));
if (tmp_array != NULL) {
MSort(array, tmp_array, 0, n - 1);
free(tmp_array);
}
else
cout << "malloc failed" << endl;
}
归并空间复杂度
归并排序的空间复杂度是O(n). 因为归并排序每次递归需要用到一个辅助表,长度与待排序的表相等O(n),递归调用中重复使用这个辅助表。当然, 在一些人的实现当中是动态分配并释放最小量临时空间. 虽然递归次数是O(log2n),但每次递归都会释放掉所占的辅助空间,所以下次递归的栈空间和辅助空间与这部分释放的空间就不相关了,因而空间复杂度还是O(n)。但是动态分配并释放最小量临时空间,那么由malloc/free占用的时间会很多。
空间复杂度为O(1)的归并算法参考: 合并两个有序的子序,要求空间复杂度为O(1)
外存排序-多路归并
有4g大小文件,可用内存1g,如何排序?
1:将大文件分成4个等分小文件分别在内存排序后存入文件。
2:采用四路归并排序。需要注意每路输入和综合输出的内存缓冲区大小,可以等分,即每个200m.输入缓冲区空了之后从文件继续读,输出缓冲满后向最终文件写入。
多路归并: 实现方式-优先级队列
如n路归并, 可以采用固定大小为n的优先级队列. 首先将每路有序数组的首元素加入队列. 然后循环取出队列的最值(从小到大排序则是取最小值, 优先级队列是个小顶堆), 将该最值所在数组中的后一位加入队列中(如果该路数组已经到了末尾则不添加元素了).
多路归并例题 - 序列和的前 k 小元素
给出两个有序表A, B, 求这两个序列中分别任意的元素的和组成的数列中的最小的k个值.
可以转换成多路归并的思想. 元素的和可以组成下列的 n 个有序表, n是A的长度, m 是B的长度:
A[1]+B[1] <= A[1]+B[2] <= ... <= A[1]+B[m]
A[2]+B[1] <= A[2]+B[2] <= ... <= A[2]+B[m]
...
A[n]+B[1] <= A[n]+B[2] <= ... <= A[n]+B[m]
然后我们就可以对这 n 个有序表合并成一个有序表. 并取前k个.
那么怎么样有效的进行前k个元素的多路归并呢?
思路是用一个大小为n的优先级队列, 每次输出一个最小值, 并将其在多路归并的有序表中的位置的后一个元素加入优先级队列.
struct node
{
int a,b;
int sum;
friend bool operator < (node n1 ,node n2) //priority_queue需要的cmp函数
{
return n2.sum < n1.sum;
}
};
void topKsum(int a[], int n, int b[], int m, int K) //n路归并求两个序列和过程
{
node t;
priority_queue < node , vector< node > > Q;
for (int i=0;i<n;i++)
{
t.a=i; //t.a表示这个最值来自哪个表:a[i]+b[0],a[i]+b[1],...,a[i]+b[n]
t.b=0; //t.b表示当前表已经选到了a[i]+b[t.b](刚开始第一个是0)
t.sum=a[i]+b[0];
Q.push(t);
}
int result[2005];
int k=0;
while(k!=K)
{
t=Q.top();
Q.pop();
result[k++]=t.sum; //最小值放入结果序列中
if(t.b < m-1){ //多路归并有序表后边还有元素时
t.b++; //此表向后推一个
t.sum=a[t.a]+b[t.b];
Q.push(t); //把当前表的新元素加入到堆中
}
}
}
参考 https://www.cnblogs.com/AbandonZHANG/archive/2012/08/06/2625893.html
https://blog.csdn.net/yl1415/article/details/44874487
poj 2442题目 m行n列的组合.
冒泡排序及其改进
在原始冒泡排序的基础上思考改进方案:
- 提前有序: 如果某一轮冒泡没有发生交换元素操作说明已有序, 算法停止
- 有序区的边界: 原始的冒泡排序是每次冒泡, 最终的有序区的元素数量加一, 但是对于基本有序的情况每次只向有序区增加一个元素还是比较慢. 我们可以在每一轮排序的最后,记录下最后一次元素交换的位置,那个位置也就是无序数列的边界,再往后就是有序区了。
- 如果待排数组的某一侧已经基本有序, 采用一个方向上的冒泡会产生很多次不会发生交换的比较, 如果从两个方向上交替进行冒泡, 比较次数将会进一步减少. 这就是鸡尾酒排序CockTailSort. 实现也比较简单, 不做介绍.
void bubbleSort(int a[]) {
//记录最后一次交换的位置
int lastExchangeIndex = 0;
//无序数列的边界,每次比较只需要比到这里为止
int sortBorder = array.length - 1;
for(int i=0; i< a.length; i++) {
//有序标记,每一轮的初始是true
boolean isSorted = true;
for(int j=0; j < sortBorder; j++) {
if(a[j] > a[j+1]) {
swap(a, j, j+1);
//有元素交换,所以不是有序,标记变为false
isSorted = false;
//把无序数列的边界更新为最后一次交换元素的位置
lastExchangeIndex = j;
}
}
sortBorder = lastExchangeIndex;
if(isSorted) break;
}
}
常用功能
swap 不借助变量交换两个数
为swap函数增加判断语句:当a和b相等的时候不交换。否则下面两种方法会导致a的结果为0.
//A方法, 容易溢出
void swap(int &a, int &b) {
if (a != b) {
a = a + b;
b = a - b;
a = a - b;
}
}
//B方法
void swap(int &a, int &b) {
if (a != b) {
a ^= b;
b ^= a;
a ^= b;
}
}
二分查找/上下界
Java中有二分的实现,叫做java.util.Arrays.binarySearch()
。使用二分的前提是数组必须有序(从小到大)。如果没有排序,那么方法无法确定返回哪个值。对于有序的数组,如果数组中包含多个相同的目标值,方法也无法保证找到的是哪一个。若找到了目标值,方法会返回目标值所在的下标;如果没有找到目标值,则方法会返回一个可以插入该值的位置,以负数表示 。
int binarySearch(int[] nums, int target){
int s = 0;
int e = nums.length - 1;
while(s <= e){
int mid= s + ((e-s)>>1);
if(nums[mid]==target) return mid; //return true;
else if(nums[mid]>target){
e=mid-1;
}else s=mid+1;
}
return -(s+1); //return false;
}
实现lower_bound
和 upper_bound
// 返回第一个大于等于value的下标, 注意数组范围是[begin, end-1]
int lower_bound(int[] nums, int begin, int end, int value) {
while (begin < end) {
int mid = begin + (end - begin) / 2;
if (nums[mid] < value) {
begin = mid + 1;
} else {
end = mid;
}
}
return begin;
}
// 返回第一个大于value的下标.
int upper_bound(int[] nums, int begin, int end, int value) {
while (begin < end) {
int mid = begin + (end - begin) / 2;
if (nums[mid] <= value) {
begin = mid + 1;
} else {
end = mid;
}
}
return begin;
}
另可参考C++ STL实现 https://sumygg.com/2017/09/08/upper-bound-and-lower-bound-in-java/