一,真题试练
一个序列有正有负,编写算法使负数排在非负数前面
思路1,直接插入
我的思路是按直接插入排序来执行,有一个小问题是如果在循环中定义了j,那么循环结束后就无法使用j了。因此要在循环开始前定义j。
同时如果在循环开始前定义j,那么最好就用while循环,因为for循环第一个条件空着比较丑。
void sort(int A[], int n) {
for (int i = 1; i < n; ++i) {
if (A[i] < 0) {
int temp = A[i];
int j = i - 1;
while(j>=0){
if (A[j] >= 0)
A[j + 1] = A[j];
else
break;
--j;
}
A[j + 1] = temp;//在循环外使用j的话,之前就不能在循环中定义j,否则就是局部变量。
}
}
}
时间复杂度为:O( n 2 n^{2} n2)
思路2,类似快排
low和high分别从左右向中间遍历,若low遇到正数停下来,high遇到负数停下来。然后交换low和high的值。这样只需遍历n次即可,时间复杂度为O(n)
void sort(int A[], int n) {
int low = 0;
int high = n - 1;
int flag, temp;
while (low < high) {
while (low < high && A[high] >= 0)
--high;
while (low < high && A[low] < 0)
++low;
if (A[high] < 0 && A[low] >= 0) {//当序列有序时,high指向了前面的负数,而low遍历到high前面结束指向的也是负数,无需再交换。
temp = A[low];
A[low] = A[high];
A[high] = temp;
}
}
}
长度为0-n-1的数组A中所有数的值不同,且取值范围也是0-n-1。编写算法将A的元素排序到B中
值和序号都是唯一的,那么每个元素的值就可以当做序号。直接遍历一次将B[A[i]]=A[i];注意此题空间复杂度为O(n)。
二,选择题
1,TQ5,对有序序列进行排序,各个算法的时间复杂度。
最快:
1,直接插入排序,for循环遍历每个元素,且每个当前遍历的元素>=前一元素,已经有序故不操作。实际上只进行了n-1次的循环遍历,为O(n)。
2,冒泡排序,若一趟排序中,没有进行任何交换则直接中止遍历。对于有序序列,则是在遍历完一次序列后退出,时间复杂度为O(n)。
最慢:
快速排序,序列有序或逆序时,每层递归都会被分为n-1长度的子表和0长度子表,相当于每次low指向的元素都比后面的元素小,就要从最右边遍历到low。对应的时间复杂度为 O ( n 2 ) O(n^{2}) O(n2)。
与初始序列无关:
1,堆排序,要先构建大根堆,使得堆顶元素最大,然后将堆顶元素与堆底元素互换,再对除堆底元素的序列重新调整堆,循环排序。序列有序也要先构建大根堆然后依次循环放置堆顶元素到已排序列,因此在最坏、最好和平均状况下都为 O ( n l o g n ) O(nlog^{n}) O(nlogn)。
2,归并排序,实际是借助一个辅助数组,将两个有序序列对比然后将元素从小到大排入原始序列。其对序列的分割排序与原始序列无关,不管序列是否有序都要一一归并,时间复杂度始终为 O ( n l o g n ) O(nlog^{n}) O(nlogn)。
3,简单选择排序,有序时,无需移动元素,但是元素之间比较的次数与初始序列状态无关,始终要从头到尾比较,因此时间复杂度始终为 O ( n 2 ) O(n^{2}) O(n2)
4,基数排序,d为位数,r为基数,n为序列长度;需要d趟分配和收集,每趟分配对序列遍历O(n),收集元素时对队列遍历O®,则时间复杂度为O(d(n+r)),与初始序列无关。就算是有序序列,也要按位分别进行元素的分配与收集。
与初始序列有关
1,折半插入排序,与直接插入排序不同,需要对已排序列进行折半查找,找到当前遍历元素的插入位置,而查找时间与原始序列初始状态无关(因为始终是在已排序列中查找),为 O ( n l o g n ) O(nlog^{n}) O(nlogn)。然后找到位置后因为原本就是有序序列,只需插入无需移动元素。最终时间复杂度为 O ( n l o g n ) O(nlog^{n}) O(nlogn)
2,希尔排序,只需要调整增量,然后遍历子表,但是因为已经有序了,因此无需再对子表调整。调整增量时间复杂度为 O ( l o g n ) O(log^{n}) O(logn),子表每次遍历不超过n次,则时间复杂度为 O ( n l o g n ) O(nlog^{n}) O(nlogn)
2,TQ6,n个关键字序列,只要前k个最小关键字。使用哪种算法?
每一趟都可以确定一个元素最终位置的算法有:冒泡排序、简单选择排序、堆排序和快速排序。
①对于快速排序,每次只是将枢轴放入最终位置,不能快速找出最小的。
②对于冒泡排序和简单选择排序,每趟找出一个最小的,只需k趟即可。每一趟都需要对未排序列进行遍历,则需要kn次。
③对于堆排序,也是需要k趟,
k
l
o
g
n
klog^{n}
klogn次,但还多了个建立初始堆的时间<=4n,则时间复杂度为
4
n
+
k
l
o
g
n
4n+klog^{n}
4n+klogn。
则易知,k>=5时堆排序更好,k<5时,可以选择冒泡和简单选择排序。
3,TQ9 10,原始序列与算法趟数、比较次数。
趟数与原始序列
1,有关:
交换类排序,快速排序,冒泡排序,当序列有序时,快速排序趟数大大提升要从第一个一直分划到最后一个。而冒泡排序只需一趟即可结束。
2,无关:
插入类,无论原始序列如何,都要遍历固定的趟数,每趟插入一个元素,或者一组子表调整。
简单选择排序,每趟都要选择一个元素,因此也是固定趟数。
堆排序,固定要从堆底向堆顶遍历处理,堆顶与堆底元素交换,存入有序序列最终位置。
k路归并排序,将其想象为完全k叉树,序列个数N=最后一层元素数;趟数m=层数h-1;则 k m = k h − 1 = N k^{m}=k^{h-1}=N km=kh−1=N。 m = l o g k N m=log_{k}^{N} m=logkN
基数排序,趟数固定为d次,每次处理一个位的数据。
比较数与原始序列
1,有关
直接插入排序,希尔排序,在执行元素移动时,还需向前遍历有序序列,与当前元素比较。如果序列有序,则无需进行元素移动的比较。
快速排序,若序列有序,那么每次都要总high到low执行比较。
冒泡排序,若序列有序,只比较一趟就中止了。
2,无关
简单选择排序,无论序列是否有序,每趟都要遍历序列依次与当前最小值对比。
折半插入排序,在关键字对比方面,由于采取了折半查找法,固定找出待排位置,然后直接移动无需比较元素大小。每次比较的次数都固定,和初始序列无关,都是在low>high时结束。
4,TQ22,简单选择排序比较、交换次数。
比较,对n个元素代表n个待排位置,每个位置都要遍历待排序列,因此为 O ( n 2 ) O(n^{2}) O(n2)
交换,对于n个元素,如果都不在待排序列第一个的位置上,则都需要依次交换,因此为 O ( n ) O(n) O(n)
5,TQ33,归并排序的两个基本阶段?
1,生成初始归并段
2,对初始归并的进行多次归并。
三,综合题
1,TQ2,快速排序算法改错
题目给的代码,要求找出错误
int partition(int A[], int low, int high)
{
int pivot = low;
while (low < high) {
while (low < high && A[high] > pivot)
--high;
A[low] = A[high];
while (low < high && A[low] < pivot)
++low;
A[high] = A[low];
}
A[low]= A[pivot];
return low;
}
void QuickSort(int A[], int low, int high) {
if (low < high) {
int pivotpos = partition(A, low, high);
QuickSort(A, low, pivotpos - 1);
QuickSort(A, pivotpos + 1, high);
}
}
易错点
1,int pivot = low;
快速排序中枢轴接收的应该是元素值,而不是下标。如果是下标,那么在快速排序时元素会不断移动,那么原本是枢轴的位置,排序后可能被其他元素替代了,导致错误。
因此接收的是元素值,这样排序后直接将值放入最终的位置即可。
2,左右遍历时要不要移动和枢轴相等的元素。
首先移动相等元素的目的是保证稳定性。
while (low < high && A[high] > pivot)
while (low < high && A[low] < pivot)
但是快速排序是一个不稳定的算法。算法中有两个值相同的元素,原本在后面的元素,可能会和前面大于该元素的枢轴遍历时被交换的前面,导致不稳定性。因此这里加不加等于号都一样。(上机测试过)
2,TQ3,双向冒泡排序
即左边将max冒到右边,–high;然后右边将min冒到左边,++low。反复交替。直到没有冒泡,序列有序便停止。
//冒泡排序
void DoubleBubbleSort(int A[], int n) {
int low = 0;
int high = n - 1;
int flag = 1;
while (flag == 1) {
flag = 0;
for (int i = low; i < high; ++i) {
if (A[i + 1] < A[i]) {
swap(A[i], A[i + 1]);
flag = 1;
}
}
--high;
for (int j = high; j > low; --j) {
if (A[j - 1] > A[j])
{
swap(A[j - 1], A[j]);
flag = 1;
}
}
++low;
}
}
改进点
算法以while (flag == 0)为条件循环;一开始我想着是,如果从左到右的循环没有调整元素,那么第二次从右到左是不是就多对比了一次。 也就是最多多对比了n次。(但其实也没有太大的改进,因为总体还是O(
n
2
n^{2}
n2))
可以添加一个条件,第一次从左到右遍历后,如果没有调整元素,说明已经有序,后面一次就没必要继续遍历。
void DoubleBubbleSort2(int A[], int n) {
int low = 0;
int high = n - 1;
int flag = 1;
while (flag == 1) {
flag = 0;
for (int i = low; i < high; ++i) {
if (A[i + 1] < A[i]) {
swap(A[i], A[i + 1]);
flag = 1;
}
}
--high;
for (int j = high; j > low && flag == 0; --j) {
if (A[j - 1] > A[j])
{
swap(A[j - 1], A[j]);
flag = 1;
}
}
++low;
}
printf("num:%d\n", num);
}
3,TQ4,复杂性分析
快排空间复杂性
快排的空间复杂度为O(
l
o
g
n
log^{n}
logn),每次递归只存储一些常量(常数级),则实际上的空间复杂性取决于递归层数。
快排的递归过程可以看做一个节点数为n的二叉树,每次选择处一个枢轴,然后分为左右子表,直到只剩下一个元素。则递归数就是层数-1.
最坏的情况,每层只有一个节点,则递归n层。
平均情况,每次都分为左右两个,为完全二叉树,h=⌊
l
o
g
n
log^{n}
logn⌋+1;则递归层数为⌊
l
o
g
n
log^{n}
logn⌋。
则平均情况下,为O(
l
o
g
n
log^{n}
logn)。
平均排序最快的算法
快排的时间复杂度为O(递归层数*每层遍历数)=O(
n
l
o
g
n
nlog^{n}
nlogn)
尽管有多个算法的时间复杂度都在O(
n
l
o
g
n
nlog^{n}
nlogn)这个数量级,但所有算法中的最高次数项X
n
l
o
g
n
nlog^{n}
nlogn中,快速排序的常数项最小,因此在同类中表现最优。
归并排序时间复杂度O( n l o g n nlog^{n} nlogn)
1,归并趟数,
n
l
o
g
n
nlog^{n}
nlogn
一开始以单一元素为有序序列,进行合并。合并后的再继续两两合并,直到只剩下一个有序序列。
可以看做一个二叉树,与快排不同的是,这个二叉树固定为完全二叉树(也就是不管初始序列如何,归并趟数固定),最下面为n个叶子节点,然后不断两两合并。然后树高-1就是归并趟数
n
l
o
g
n
nlog^{n}
nlogn。
2,每趟对比元素次数
在两个有序序列确定每个元素的最终位置,因为序列已经有序了,只需要自左向右对比第一个元素的大小即可,最多也不会超过n次。
则平均,最好,最坏的时间复杂度都为O(趟数*每趟对比数)=O( n l o g n nlog^{n} nlogn)
4,TQ6,基数排序第一步
第一步要补0,如题目给了序列{1,22,100,5};第一步要将序列改为{001,022,100,055}这样。方便后续对各个为进行排序。
5,TQ8,链表实现排序
本题,直接插入排序
首先将L断开,L只保留一个节点。其余节点由p指向为待排序列。
遍历L找到第一个大于等于待排元素的节点,将待排元素插在前面,或者遍历完已排序列,则插在已排序列末尾。
注意:是对已排序列遍历。
依次处理未排序列的第一个元素,对已排序列遍历找到合适的位置将未排序列插入已排序列
其他链表排序思路总结
其他可以用链表实现的有:冒泡排序和简单选择排序
冒泡:数组是从后往前冒泡,而链表特别是单链表不方便从后向前查找,因此可以从前向后把较大的值冒到后面去。
//不管p交换与否,pre位置不变,因此通过pre指针指后移p
简单选择:和直接插入类似,也是先将L置空,p指向剩余元素,然后遍历p每次都找max头插法插入L。
直接插入每一趟是对已排序列遍历找到未排序列位置。简单选择每一趟是对未排序列处理找到MAX,直接插入已排序列后面。
题目提到创建带头结点的链表
那么答题时就应该写一个建立头结点的函数。
void CreateLink(LNode *&L,int A[],int n){
int i;
LNode *s,*t;
L=(LNode*)malloc(sizeof(LNode));
L->next=NULL;
t=L;//头结点不能直接参与遍历
for(i=0;i<n;++i){
s=(LNode*)malloc(sizeof(LNode));
s->data=A[i];
t->next=s;
t=s;//t每次后移,实现尾插法。
}
t->next=NULL;
}
四,真题
1,TQ3,快排处理左右子表顺序
快排不管是先处理左子表还是右子表,都不影响结果。
2,TQ6,大根堆插入新元素
首先将新元素插入堆底。
然后新元素只需要和父节点进行比较,若大于父节点就上浮。
循环直到比父节点小,或者到堆顶。
注意,不需要和兄弟节点对比,因为其兄弟节点原本就是大根堆内的已经小于父节点了。
3,TQ11,如何判定一个序列是不是快速排序产生的
快速排序每排一趟就有一个元素放在最终位置,使得左边所有元素小于该元素,右边所有元素大于该元素。
如果进行了n趟快速排序,则在该序列中至少能找到n个这样放在最终位置的元素。
4,TQ1,哈夫曼树与外部排序
原理
对5个初始归并段进行归并排序,要求在最坏的情况下比较的总次数最少。
可以使用哈夫曼树,以表的长度为节点的值,构建哈夫曼树。也就是依次选择最短的两个表进行归并,哈夫曼树有最小带权路径长度,这样长度短的表参与合并次数多,长度长的表合并次数少,这样总的I/O次数最小。
计算最多的比较次数
如果两个表合起来有n个元素,则最多比较n-1次,因为最后一个元素不需要进行对比,直接输出。
这样计算最多的对比次数,每次计算归并次数时要-1。
第一次归并 10+35-1=44
第二次归并 45+40-1=84
…
合计:825次