《王道》第15章 排序--PART1
目录
0 名词
排序
算法稳定性
内部和外部排序
1 插入排序
插入排序基本思想:每次将一个待排序的记录,按其关键字大小插入到前面已经排好序的子序列中,直到全部记录插入完成。
插入排序引申出:直接插入排序和希尔排序。
1.1 直接插入排序
基本思想
假设待排序的记录存放在数组R[1..n]中。初始时,R[1]自成1个有序区,无序区为R[2..n]。从i = 2起直至i = n为止,依次将R[i]插入当前的有序区R[1..i - 1]中,生成含n个记录的有序区。
插入排序与打扑克时整理手上的牌非常类似。摸来的第1张牌无须整理,此后每次从桌上的牌(无序区)中摸最上面的1张并插入左手的牌(有序区)中正确的位置上。为了找到这个正确的位置,须自左向右(或自右向左)将摸来的牌与左手中已有的牌逐一比较。
算法实现
void InsertSort(SqList *L) //输入形参为链表头指针
{
int i, j;
for (i = 2; i <= L->length; i++) //依次将L->data[2]-L->data[n]插入到前面已排序序列
{
if (L->data[i] < L->data[i - 1]) //若L->data[i]小于其前驱,需将L->data[i]插入到有序表
{
L->data[0] = L->data[i]; //复制为哨兵
for (j = i - 1; L->data[j]>L->data[0]; j--) //从后往前查找待插入位置
L->data[j + 1] = L->data[j]; //向后挪位
L->data[j + 1] = L->data[0]; //复制到插入位置
}
}
}
void InsertSort(int a[], int n)
{
int i, j, temp;
for (i = 1; i < n; i++)
{
if(a[i]<a[i-1])
{
int key = a[i]; //待排序第一个元素
//从后向前逐个比较已经排序过数组,如果比它小,则把后者用前者代替,
//其实说白了就是数组逐个后移动一位,为找到合适的位置时候便于Key的插入
for (j=i-1; (j>=0) && (key < a[j]); j--)
a[j + 1] = a[j];
}
a[j + 1] = key;//找到合适的位置了,赋值,在i索引的后面设置key值。
}
}
算法分析
1)时间复杂度
最坏时间复杂度:O(n^2),待排序表逆序。比较次数2+3+······+n=(n+2)(n-1)/2,记录的移动次数1+2+······+(n-1)=n(n-1)/2。
最好时间复杂度:O(n),待排序表本身有序。比较次数n-1,无移动记录,时间复杂度O(n)。
平均时间复杂度:O(n^2)
2)空间复杂度:O(1)
3)稳定排序方法
1.2 希尔排序
基本思想
先将待排序表分割成若干形如L[i,i+d,i+2d,...,i+kd]的特殊子表,分别进行直接插入排序,当整个表中元素已呈“基本有序”时,再对全体记录进行一次直接插入排序。
到目前为止,尚未求得一个最好的增量序列,希尔提出的方法是d1=n/2,di+1=向下取整[di/2],并且最后一个增量等于1。
算法实现
void ShellSort(SqList *L) //输入形参为链表头指针
{
//对顺序表做希尔插入排序,本算法和直接排序算法相比,做了以下修改:
//1.前后记录位置的增量是dk,不是1
//2.L->data[0]只是暂存单元,不是哨兵,因此需判断j是否大于0
int i, j, dk;
for (dk = L->length / 2; dk >= 1; dk = dk / 2) { //步长变化
for (i = dk + 1; i <= L->length; i++)
{
if (L->data[i] < L->data[i - dk]) //需将L->data[i]插入到有序表
{
L->data[0] = L->data[i]; //暂存
for (j = i - dk; j > 0 && L->data[j] > L->data[0]; j -= dk) //从后往前查找待插入位置
L->data[j + dk] = L->data[j]; //向后挪位
L->data[j + dk] = L->data[0]; //复制到插入位置
}
}
}
}
void ShellSort(int array[], int n)
{
int i, j, dk, temp;
for (dk = n / 2; dk >= 1; dk = dk / 2)
{
for (i = dk; i < n; i++)
{
if(array[i] < array[i-dk])
{
temp = array[i];
for(j = i-dk; j>=0 && array[j] > temp; j -= dk)
{
array[j + dk] = array[j];
}
array[j + dk] = temp;
}
}
}
}
算法分析
1)时间复杂度(与所选增量序列有关)
最坏时间复杂度 O(n^2)
最好时间复杂度 当n在某个特定范围时,时间复杂度约为O(n^1.3)
2)空间复杂度 O(1)
3)不稳定排序方法
2 交换排序
交换排序基本思想:根据序列中两个元素关键字的比较结果来对换两个记录在序列中的位置。
交换排序引申出:冒泡排序和快速排序。
2.1 冒泡排序
基本思想
冒泡排序基本思想:设排序表长为n,从后向前(或者从前向后)两两比较相邻元素的值,如果两者的相对次序不对(A[i - 1] > A[i]),则交换它们,其结果是将最小的元素交换到待排序序列的第一个位置(或者将最大的元素交换到待排序序列的最后一个位置),我们称它为一趟冒泡。下一趟冒泡时,前一趟确定的最小(或者最大)元素不再参与比较,待排序序列减少一个元素,每趟冒泡的结果把序列中最小的元素放到了序列的“最前面”(或者最大的元素放到了序列的“最后面”)。
算法实现
void BubbleSort(int *pdata, int n)
{
bool Flag = true;
int i, j;
for (i = 0; i<n-1 && Flag; i++) //若本趟冒泡未发生交换,说明表已经有序,不再循环
{
Flag = false; //表示本趟冒泡是否发生交换的标志
for (j = n - 2; j >= i; j--) //一趟冒泡过程
{
if (pdata[j] > pdata[j + 1]) //若为逆序,交换
{
swap(pdata[j],pdata[j+1]);
Flag = true;
}
}
}
}
//从前往后比较
void BubbleSort(int *pdata, int n)
{
bool Flag = true;
int i, j;
for (i = n-1; i>0 && Flag; i--) //若本趟冒泡未发生交换,说明表已经有序,不再循环
{
Flag = false; //表示本趟冒泡是否发生交换的标志
for (j = 0; j < i; j++) //一趟冒泡过程
{
if (pdata[j+1]<pdata[j]) //若为逆序,交换
{
swap(pdata[j+1], pdata[j]);
}
}
}
}
算法分析
1)时间复杂度
最坏时间复杂度:O(n^2),待排序表为逆序,需比较次,并作等数量级的记录移动,因此,总的时间复杂度为O(n^2)
最好时间复杂度:O(n),要排序的表本身就是有序的,经过n-1次比较,无数据交换,时间复杂度O(n);
平均时间复杂度:O(n^2)
2)空间复杂度 O(1)
3)稳定排序方法
2.2 快速排序
基本思想
快速排序是对冒泡排序的一种改进,其基本思想是基于分治法的。在待排序的序列中选取一个值作为一个基准值,按照这个基准值的大小将这个序列划分成两个子序列,基准值会在这两个子序列的中间,一边是比基准小的,另一边就是比基准大的。这样快速排序第一次排完,我们选取的这个基准值就会出现在它该出现的位置上。这就是快速排序的单趟算法,也就是完成了一次快速排序。然后再对这两个子序列按照同样的方法进行排序,直到只剩下一个元素或者没有元素的时候就停止,这时候所有的元素都出现在了该出现的位置上。
算法实现
void QuickSort(int *a, int left, int right)
{
if (left < right){ //边界条件,即递归跳出的条件
//PartSort就是划分操作,将表a[left...high]划分为满足上述条件的两个子表
int div = PartSort(a, left, right);
QuickSort(a, left, div - 1); //依次对两个子表进行递归排序
QuickSort(a, div + 1, right);
}
}
快速排序的单趟算法Partsort
1)左右指针法
实现思路:在一段区间内我们有一个值key,从左边区间进行遍历,直到找到一个大于key的值就停下,然后再从右边找小于key的值,找到一个也停下来。我们将左右的值进行交换,这样左边那个大于key的值就被换到了右边,而右边那个比key小的值就被换到了左边。当左右两个指针相遇的时候就说明所有元素都与key做过了比较。然后再将左指针所在的元素和key交换并返回key所在位置div。此时按照上述方法进行递归实现[left, div-1]和[div + 1, right]。
代码:
using namespace std;
int PartSort1(int* a, int left, int right) //左右指针法
{
int mid = GetMidIndex(a, left, right); //此处是对快排的优化,再后面会提到
swap(a[mid], a[right]);
int key = a[right]; //利用key作为基准值的下标
while (left < right)
{
//左指针向右找第一个比key大的数
while (left < right && a[left] <= key)
{
++left;
}
//右指针向左扎找第一个比key小的数
while (left < right && a[right] >= key)
{
--right;
}
//交换左右指针所指的值
if (a[left] != a[right])
{
swap(a[left], a[right]);
}
}
//将key值放到正确位置上
swap(a[left], key);
return left; //返回存放key值的最终位置
}
2)挖坑法(两个下标分别从首、尾向中间扫描的方法)
实现思路:类似于左右指针法,先将最右边的值保存下来,作为key值。这时候最右边的值被取出去,最右边就相当于有了一个坑,我们从左向右进行遍历,找到一个比key大的数就把它填到这个坑里,这时候就相当于坑在左边,我们有从右向左进行遍历找比key小的数,找到后再次填到坑里,依次类推。
代码:
int PartSort2(int*a, int left, int right) //挖坑法
{
int mid = GetMidIndex(a, left, right);
swap(a[mid], a[right]);
int key = a[right]; //将区间最右侧数据基准值
while (left < right)
{
//左指针向右找比key大的数据
while (left < right && a[left] <= key)
{
++left;
}
a[right] = a[left]; //用找到的数据填坑
//右指针想左找比key小的数据
while (left < right && a[right] >= key)
{
--right;
}
a[left] = a[right]; //用找到的数据填坑
}
a[left] = key; //最后用key值填坑
return left;
}
3)前后指针法(两个指针索引一前一后逐步向后扫描的方法)
实现思路:有两个指针,一个为cur,另一个为prev。开始的时候让cur指向left,让prev指向left的前一个位置。让cur向后找比key小的值,找到之后就让prev++,如果此时prev与cur不相等就让prev与cur进行交换。如果找不到比key小的值就一直让cur向后走,直到走到区间的最右边就停止,当cur走到边界的时候就让cur与prev+1进行交换。不断缩小边界,相同的方法进行遍历子区间。
代码:
int PartSort3(int* a, int left, int right) //前后指针法
{
int mid = GetMidIndex(a, left, right);
swap(a[mid], a[right]);
int key = a[right]; //key保存基准值的下标
int cur = left;
int prev = cur - 1;
while (cur != right)
{
if (a[cur] < key && a[++prev] != a[cur])
{
swap(a[cur], a[prev]);
}
++cur;
}
swap(a[++prev], a[cur]);
return prev;
}
注意:上述算法有一个特点,即一次划分后,枢纽左边的相对位置不变。比如,原始序列:[3, 8, 7, 1, 2, 5, 6, 4]->[3, 1, 2, 4, 7, 5, 6, 8]。枢纽4左边的相对顺序不变,元素3, 1, 2保持在初始序列中的相对顺序(原序列中为3, ..., 1, 2, ...),某些应用要求序列的一部分保持相对顺序,这时可以考虑此种划分。
算法分析
快速排序是所有内部排序算法中平均性能最优的排序算法。
1)时间复杂度
快速排序的运行时间与划分是否对称有关,而后者又与具体使用的划分算法有关。
最坏情况发生在两个区域分别包含n-1个元素和0个元素时,这种最大程度的不对称性若发生在每一层递归上,即对应于初始排序表基本有序或基本逆序时,最坏时间复杂度为O(n^2);
在最理想的状态下,即PartSort可能做到最平衡的划分中,得到的两个子问题的大小都不可能大于n/2,在这种情况下,快速排序的运行速度将大大提升,此时,时间复杂度为O(nlog2n)。
快速排序平均情况下运行时间与其最佳情况下运行时间很接近,即为O(nlog2n)。
2)空间复杂度
由于快速排序是递归的,需要借助一个递归工作栈来保存每一层递归调用的必要信息,其容量应与递归调用的最大深度一致。最好情况下为向上取整[log2(n+1)];最坏情况下,因为要进行n-1次递归调用,所以栈的深度为O(n);最好情况下,栈的深度为O(log2n)。因而空间复杂度在最坏情况下为O(n),最好情况下为O(log2n)。
3)不稳定排序方法
快速排序算法优化
1)三数取中法
三数取中法就是我们取三个数中间的那个数,这样我们就能在给定的一段区间中找到那个每次出现在中间的那个数。代码如下:
int GetMidIndex(int* a, int left, int right) //三数取中法
{
int mid = left + ((right - left) >> 1); //中间下标
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return right;
}
else
{
return left;
}
}
else //a[left]>=a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return right;
}
else
{
return left;
}
}
}
2)小区间优化
当我们划分的子区间很小的时候(一般情况下13为判断的标准),我们使用快速排序对于这些小区间进行排序的时候,如果我们还使用快速排序的话就会得不偿失。因为快速排序对子区间的划分就像二叉树一样,越到下面递归越深,那么还不如我们把这剩下的数取出来用其他的排序,这样的话也就提高快速排序的效率。具体代码如下:
void QuickSort(int *a, int left, int right) //小区间优化
{
assert(a);
if (left < right)
{
if (right - left > 13)
{
int div = PartSort1(a, left, right);
QuickSort(a, left, div - 1);
QuickSort(a, div + 1, right);
}
else
{
InsertSort(a + left, right - left + 1); //这里的InsertSort用的是直接插入排序
}
}
}
快速排序应用
题目1:一个数组中存储有且仅有大写和小写字母,编写一个函数对数组内的字母重新排列,让小写字母在所有大写字母之前。
#include<iostream>
using namespace std;
bool isUpper(char ch) //判断ch是否为大写字符
{
if (ch >= 'A' && ch <= 'Z')
return true;
else
return false;
}
bool isLower(char ch) //判断ch是否为小写字符
{
if (ch >= 'a' && ch <= 'z')
return true;
else
return false;
}
void Patition(char r[], int left, int right)
{
while (left<right)
{
while (left<right && isUpper(r[right]))
--right;
while (left<right && isLower(r[left]))
++left;
/*char temp = r[left];
r[left] = r[right];
r[right] = temp;*/
swap(r[left], r[right]);
}
}
int main(int argc,char* argv[])
{
char r[] = { 'a', 'A', 'b', 'B', 'c', 'C', 'D', 'E', 'e' };
Patition(r, 0, 8);
int i = 0;
while (i != 8)
cout << r[i++] << ",";
cout << endl;
system("pause");
return 0;
}
题目2:给定含有n个元素的整形数组a, 其中包括0元素和非0元素,对数组进行排序,要求:
1)排序后所有0元素在前,所有非零元素在后,且非零元素排序前后相对位置不变。
2)不能使用额外存储空间。、
例如:
输入 0、3、0、2、1、0、0
输出 0、0、0、0、3、2、1
解答:此处要求非零元素排序前后相对位置不变,可以利用快排一次排序的前后指针法。
void Partition(int A[], int p, int r) {
int i = r + 1;
for (int j = r; j >= p; --j) { //从后往前遍历,当然也可以写成从前往后遍历
if (A[j] != 0) {
--i;
int temp = A[i];
A[i] = A[j];
A[j] = temp;
}
}
}
题目3:荷兰国旗问题
将乱序的红白蓝三色小球排列成同颜色在一起的小球组(按照红白蓝排序),这个问题称为荷兰国旗问题。这是因为我们可以将红白蓝小球想象成为条状物,有序排列后正好组成荷兰国旗,用0表示红球,2为篮球,1为白球。
解答:这个问题,类似于快排中partition过程。不过,要用三个指针,一个begin,一中current,一后end,begin与current都初始化指向数组首部,end初始化指向数组尾部。
1. current遍历整个数组序列,current指1时,不交换,current++;
2. current指0时,与begin交换,而后current++,begin++;
3. current指2时,与end交换,而后,current不动,end--。
while (current <= end) {
if (array[current] == 0) {
swap(array[current], array[begin]);
current++;
begin++;
}
else if (array[current] == 1) {
current++;
}
else {
swap(array[current], array[end]);
end--;
}
}
题目4:最小的k个数
输入n个整数,输出其中最小的k个。
例如输入1, 2, 3, 4, 5, 6, 7, 8这8个数字,则最小的4个数字为1, 2, 3, 4
解答:分析:这到底最简单的思路莫过于把输入的n个整数排序,这样排在最前面的k个数就是最小的k个数。只是这种思路的时间复杂度为O(nlgn)。我们试着寻找更快的解题思路。
我们设最小的k个数中最大的数为A。在快速排序算法中,我们现在数组中随机选择一个数字,然后调整数组中数字的顺序,使得比选中的数字小的数字都排在他的左边,比选中的数字大的数字都排在它的右边(即快排一次排序)。如果这个选中的数字的下标刚好是k - 1(下标从0)开始,那么这个数字(就是A)加上左侧的k - 1个数字就是最小的k个数。
如果它的下标大于k - 1,那么A应该位于它的左边,我们可以接着在它的右边部分的数组中寻找。可见这是一个递归问题,但是注意我们找到的k个数不一定是有序的。
int Partition(int a[], int p, int r) {
int pivot = a[r];
int i = p - 1;
for (int j = p; j <= r - 1; ++j) {
if (a[j] <= pivot) {
++i;
swap(a[i], a[j]);
}
}
swap(a[i + 1], a[r]);
return i + 1;
}
void GetLeastKNum(int *input, int n, int k) {
if (input == NULL || n <= 0 || k > n || k <= 0)
return;
int start = 0;
int end = n - 1;
int index = Partition(input, start, end);
while (index != k - 1) {
if (index < k - 1) {
start = index + 1;
index = Partition(input, start, end);
}
else {
end = index - 1;
index = Partition(input, start, end);
}
}
for (int i = 0; i <= k - 1; ++i) {
cout << input[i];
}
cout << endl;
}
上述方法的时间复杂度是O(n)。