详解 剑指 Offer 40. 最小的k个数,各类排序算法实现及性能比较

185 阅读4分钟

首先看题

leetcode-cn.com/problems/zu…

输入整数数组 arr ,找出其中最小的 k 个数。例如,输入4、5、1、6、2、7、3、8这8个数字,则最小的4个数字是1、2、3、4。

示例 1:

输入:arr = [3,2,1], k = 2 输出:[1,2] 或者 [2,1]

示例 2:

输入:arr = [0,1,2,1], k = 1 输出:[0]

看到排序,我啪的一下就站起来了,很快啊,上来就是sort

class Solution {
public:
    vector<int> getLeastNumbers(vector<int>& arr, int k) {
        sort(arr.begin(), arr.end());
        return vector<int>(arr.begin(), arr.begin() + k);
    }
};

效率很快,不愧是sort函数,然而面试官可能觉得你是来砸场子的,那么这题应该用什么排序算法呢?

一. 平均时间复杂度O(n2)的排序算法

1.冒泡排序

void BubbleSort(vector<int>& arr, int k) {
	int temp;
	bool flag;
	for (int i = 0; i < k; i ++) {
		flag = true;
		for (int j = arr.size() - 1; j > i; j --) {
			if (arr[j] < arr[j - 1]) {
				temp = arr[j];
				arr[j] = arr[j - 1];
				arr[j - 1] = temp;
				flag = false;
			}
		}
		if (flag) return;
	}
}

冒泡排序,此题只需交换k趟,所以时间复杂度为O(kn),空间复杂度O(1),然而...

2.选择排序

void SelectSort(vector<int>& arr, int k) {
	int temp, minIndex;
	for (int i = 0; i < k; i++) {
		minIndex = i;
		for (int j = arr.size() - 1; j > i; j--) {
			if (arr[j] < arr[minIndex]) {
				minIndex = j;
			}
		}
		temp = arr[i];
		arr[i] = arr[minIndex];
		arr[minIndex] = temp;
	}
}

选择排序,也只需交换k趟,所以时间复杂度为O(kn),空间复杂度O(1),结果还是一样

值得注意的是,此处

对比冒泡排序的

要多三个通过,所以这题选择排序比冒泡排序要略快一点,大概赋值操作较少,因为冒泡每次循环都会两两交换,进行三次赋值,而选择排序只需将下标赋值一次,然而这还是不足以通过此题。

3.插入排序

void InsertSort(vector<int>& arr, int k) {
	int insertNum, i, j;
	for (i = 1; i < arr.size(); i++) {
		insertNum = arr[i];
		for (j = i - 1; j >= 0; j--) {
			if (arr[j] > insertNum) {
				arr[j + 1] = arr[j];
			}
			else {
				break;
			}
		}
		arr[j + 1] = insertNum;
	}
}

插入排序需要全部排序,时间复杂度为O(n^2),空间复杂度O(1),交换次数为在已有序序列中比自身大的值向后移位次数,在特定条件下,比如全列有序,此时不需移位,时间复杂度为O(n),那么来看一下这题的提交结果

插入排序居然AC了,不过时间炒鸡久,在超时的边缘徘徊。。

4.希尔排序

void ShellSort(vector<int>& arr) {
	int n = arr.size(), insertNum, d, i, j;
	for (d = n/2; d >= 1; d /= 2) {
		for (i = d; i < n; i++) {
			insertNum = arr[i];
			for (j = i - d; j >= 0; j-=d) {
				if (j >= 0 && arr[j] > insertNum) {
					arr[j + d] = arr[j];
				}
				else {
					break;
				}
			}
			arr[j + d] = insertNum;
		}
	}
}

希尔排序时间复杂度,取决于每次的步长,一般来说为每次为长度/2,在每个子序列中进行插入排序,因为越有序插入排序所需比较和移位的次数越少,在每个子序列中都是相对有序的,所以希尔排序最好情况下时间复杂度为O(n^1.3),最坏情况下为O(n^2),而此题需要全排列后才能得到前k个,所以此题的时间复杂度不变,空间复杂度为O(1)

希尔排序AC~,希尔牛逼!(破音)

那么那些时间复杂度为O(nlogn)的算法要怎么实现又能怎么优化呢?

二. 平均时间复杂度O(nlogn)的排序算法

1.堆排序

其实堆排序有两种实现算法,我们都来实现一下:

①.对整个数组建立小根堆,每次取最小数,总共取k次,所以时间复杂度为O(klogn)

vector<int> HeapSort1(vector<int>& arr, int k) {
	int length = arr.size(), temp;
	BuildMinHeap(arr, length);
	temp = arr[0];
	arr[0] = arr[length - 1];
	arr[length - 1] = temp;
	for (int i = 1; i < k; i++) {
		AdjustMinHeap(arr, 0, length - i);
		temp = arr[0];
		arr[0] = arr[length - i - 1];
		arr[length - i - 1] = temp;
	}
    return vector<int>(arr.rbegin(), arr.rbegin() + k);
}
void BuildMinHeap(vector<int>& arr, int length) {
	for (int i = (length - 1) / 2; i >= 0; i--) {
		AdjustMinHeap(arr, i, length);
	}
}
void AdjustMinHeap(vector<int>& arr, int i, int length) {
	int index, nextIndex, temp = arr[i];
	for (index = i; index * 2 + 1 < length;) {
		if (index * 2 + 2 < length && arr[index * 2 + 1] > arr[index * 2 + 2]) {
			nextIndex = index * 2 + 2;
		}
		else {
			nextIndex = index * 2 + 1;
		}
		if (temp < arr[nextIndex]) {
			break;
		}
		else {
			arr[index] = arr[nextIndex];
			index = nextIndex;
		}
	}
	arr[index] = temp;
}

②.对前k个数建立大根堆,k后面的数每次与最大值比较,如果比最大值小则交换,重新构造大顶堆,到最后前k个数就是最小的k个数,所以时间复杂度为O(nlogk)

vector<int> HeapSort2(vector<int>& arr, int k) {
    int length = arr.size(), temp;
    BuildMaxHeap(arr, k);
    for (int i = k; i < length; i++) {
        if (arr[0] > arr[i]) {
            temp = arr[0];
            arr[0] = arr[i];
            arr[i] = temp;
        }
        AdjustMaxHeap(arr, 0, k);
    }
    return vector<int>(arr.begin(), arr.begin() + k);
}
void BuildMaxHeap(vector<int>& arr, int length) {
    for (int i = (length - 1) / 2; i >= 0; i--) {
        AdjustMaxHeap(arr, i, length);
    }
}
void AdjustMaxHeap(vector<int>& arr, int i, int length) {
    int index, nextIndex, temp = arr[i];
    for (index = i; index * 2 + 1 < length;) {
        if (index * 2 + 2 < length && arr[index * 2 + 1] < arr[index * 2 + 2]) {
            nextIndex = index * 2 + 2;
        }
        else {
            nextIndex = index * 2 + 1;
        }
        if (temp > arr[nextIndex]) {
            break;
        }
        else {
            arr[index] = arr[nextIndex];
            index = nextIndex;
        }
    }
    arr[index] = temp;
}

可见两种实现方法的耗时差不多,所以两种方法皆可,由于①为klogn、②为nlogk,通过数学推导我们可以得到,当k<n^(k/n)时,使用第一种方法,当k>n^(k/n)时,使用第二种方法。

2.快速排序

void quickSort(vector<int>& arr, int left, int right, int k) {
	if (left < right) {
		int leftNum = left;
		int rightNum = right;
		int midNum = arr[left];
		while (left < right) {
			while (left < right && arr[right] >= midNum) right--;
			arr[left] = arr[right];
			while (left < right && arr[left] <= midNum) left++;
			arr[right] = arr[left];
		}
		arr[left] = midNum;
		if (left == k) {
			return;
		}
		else if (left < k) {
			quickSort(arr, left + 1, rightNum, k);
		}
		else {
			quickSort(arr, leftNum, left - 1, k);
		}
	}
	return;
}

快排不需要全排列,只需判断中值是否为k,如果小于k需要再判断右边,如果大于k需要判断左侧,直到取到k为止,这样左边就是最小的k个数,时间复杂度为O(n),空间复杂度由于没有额外申请了常数级参数,所以为O(1), 但如果算递归调用栈的大小,空间复杂度为递归的深度,一般来说为O(logn)。

快排果然快,根据内存消耗看,判题机制应该没有考虑递归栈的大小。

3.归并排序

归并排序是外排序,唯一使用了额外空间的排序,归并排序时间复杂度为O(nlogn),是一种稳定排序方法,空间复杂度为O(n)

vector<int> arr2;	// 额外缓存数组
void MergeSort(vector<int>& arr, int left, int right) {
	if (left < right) {
		int mid = left + (right - left) / 2, i, j, k;
		MergeSort(arr, left, mid);
		MergeSort(arr, mid + 1, right);
		for (i = left; i <= right; i++) {
			arr2[i] = arr[i];
		}
		i = left; j = mid + 1; k = i;
		while (i <= mid && j <= right) {
			if (arr2[i] <= arr2[j]) {
				arr[k++] = arr2[i++];
			}
			else {
				arr[k++] = arr2[j++];
			}
		}
		while(i <= mid) arr[k++] = arr2[i++];
		while(j <= right) arr[k++] = arr2[j++];
	}
	return;
}

看来本题归并排序相对前面几个算法,O(nlogn)的时间还是偏慢的,且需要额外空间,此题不推荐,不过归并排序可以用作数据量较大的排序,因为当数据量巨大无法一次读取到内存中,需要分段排序,最后再汇总在一起。

三. 本题算法选择

那么本题推荐的算法为堆排序,快排,这两种排序在本题目时间复杂度都小于O(nlogn)。