01数据结构-交换排序
01数据结构-交换排序
- 1.冒泡排序
- 1.1基础冒泡排序
- 1.1.1基础冒泡排序代码实现
- 1.2冒泡排序的一次优化
- 1.2.1冒泡排序的第一次优化代码实现
- 1.3冒泡排序的二次优化
- 1.3.1 冒泡排序的二次优化代码实现
- 2.快速排序
- 2.1双边循环法
- 2.1.1双边循环法的代码实现
- 2.2单边循环法
- 2.2.1单边循环法代码实现
1.冒泡排序
1.1基础冒泡排序
算法思想
冒泡排序是最简单的排序算法了。冒泡排序通过不断地比较两个相邻元素,将较大的元素交换到右边(升序),从而实现排序。那我们直接看例子。
我们对数组 [5,1,4,2,8,4] ,采用冒泡排序进行排序,注意这里的两个 4 的颜色是不同
的,主要是为了区分两个不同的 4 ,进而解释冒泡排序算法的稳定性问题。
第一轮冒泡排序:第一步:比较 5 和 1 ,5 > 1,则交换 5 和 1 的位置:
第二步,比较 5 和 4,5 > 4,交换 5 和 4 的位置:
第三步:比较 5 和 2 ,5 > 2,交换 5 和 2 的位置:
第四步:比较 5 和 8 ,5 < 8 ,不交换
第五步:比较 8 和 4 , 8 > 4,交换 8 和 4 :
此刻我们获得数组当中最大的元素 8 ,使用橘⻩色进行标记:
第一轮冒泡结束,最大的元素8到了最后,然后对于前面5个元素,进行第二轮冒泡将第二大的数据放在右边。
最终结果:
事实上第二阶段结束,整个数组已经有序了,但是对于冒泡排序而言并不知道,她还需要通过第三阶段的比较操作进行判断。
对于冒泡排序算法而言,她是通过判断整个第三阶段的比较过程中是否发生了交换来确定数组是否有序的,显然上面的过程中没有交换操作,冒泡排序也就知道了数组有序,整个算法执行结束。
1.1.1基础冒泡排序代码实现
void bubbleSortV1(SortTable *table) {for (int i = 0; i < table->length-1; ++i) {for (int j = 0; j < table->length-1-i; ++j) {if (table->data[j].key > table->data[j+1].key) {swapElement(&table->data[j+1],&table->data[j]);}}}
}
这里面的swapElement
是我测试框架里的代码,作用是交换两个元素的位置,外层循环用于把最大的元素冒到右边去,内层循环从0开始length-1-i,才能完整扫描未排好区间,实现“冒泡”效果。注意第二层循环的上界,最右边 i 个元素已经是有序区,不需要再碰,所以上界写成 table->length-1-i。
来测一下:
#include"bubbleSort.h"
void test01() {int n=100000;SortTable *table1=generateRandomArray(n,0,1+5000);testSort("bubbleSortV1",bubbleSortV1,table1);releaseSortTable(table1);
}int main() {test01();return 0;
}
结果:
D:\work\DataStruct\cmake-build-debug\04_Sort\SwapSort.exe
bubbleSortV1 cost time: 20.063000s.进程已结束,退出代码为 0
可以看到同样是十万个元素上一节课中的插入排序几秒钟就能完成,但是这里的冒泡排序需要20几秒,原因在于无论数据是否已经有序,都要跑完全部 n-1 趟且交换次数往往也很高,我们就在想能不能优化一下呢?
1.2冒泡排序的一次优化
这里我们增加了一个标识数组是否有序 ,当冒泡排序过程中没有交换操作时,swapped = false ,也意味着数组有序;否则数组无序继续进行冒泡排序。不要小看这个变量奥,因为这个变量,当数组有序的时候,冒泡排序的时间复杂度将降至 O(n)(因为其只需要执行一遍内层的 for 循环就可以结束冒泡排序),没有这个变量,数组有序也需要O(n2)的时间复杂度。讲直白点因为冒泡排序始终把大的元素冒到右边,可以当作右边始终是有序的,当发现某一轮不需要交换,那么就说明已经有序,退出循环。
1.2.1冒泡排序的第一次优化代码实现
void bubbleSortV2(SortTable* table) {for (int i = 0; i < table->length - 1; ++i) {int isSorted = 1;for (int j = 0; j < table->length - 1 - i; ++j) {if (table->data[j].key > table->data[j + 1].key) {swapElement(&table->data[j + 1], &table->data[j]);isSorted = 0;}}if (isSorted) {break;}}
}
来测一下:
#include"bubbleSort.h"
void test01() {int n=10000;SortTable *table1=generateRandomArray(n,0,1+5000);SortTable *table2=copySortTable(table1);testSort("bubbleSortV1",bubbleSortV1,table1);testSort("bubbleSortV2",bubbleSortV2,table2);releaseSortTable(table1);releaseSortTable(table2);
}int main() {test01();return 0;
}
结果:
D:\work\DataStruct\cmake-build-debug\04_Sort\SwapSort.exe
bubbleSortV1 cost time: 0.130000s.
bubbleSortV2 cost time: 0.130000s.进程已结束,退出代码为 0
这里看不出来精度差是因为我们产生的数据太随机了,意义不太大,但是这种思想要学会。
1.3冒泡排序的二次优化
一次优化是为了避免数组有序的情况下,继续进行判断操作的。那么二次优化又为了什么呢 ?
我们看下面的例子。
经过一次冒泡后,我们会注意到一个问题,但是我们注意到,数组数组中的 [5,6,8] 本身已经有序,而对于有序的部分进行比较是没有意义的,相当于在白白浪费资源,有没有什么办法减少这样的比较次数呢?
换句话说,是否能够确定出已经有序部分和无序部分的边界呢?
答案当然是肯定的,这个边界就是第一趟冒泡排序的过程中最后一次发生交换的位置 j :也就是 1 和 4 发生交换之后,4 和 5 没有发生交换,此时 1 之后的元素为有序。
第一步:4 和 2比较,4 > 2 ,交换 4 和 2 ,将 LastSwappedIndex = 0;
第二步:4 和 1 比较,4 > 1,交换 4 和 1, LastSwappedIndex = 1 ;
第三步:比较 4 和 5 , 4 < 5,不交换, lastSwappedIndex 也不更新;
第四步:比较 5 和 6 ,不交换, lastSwappedIndex 也不更新;
第五步:比较 6 和 8 ,不交换, lastSwappedIndex 也不更新;
第一趟冒泡排序结束了,我们把 LastSwappedIndex放在了4这里,相当于是一个挡板
来看第二趟冒泡排序,此时 j 的 取值将从 j = 0 到 j = lastSwappedIndex ,第一步:比较 2 和 1 ,2 > 1,交换,lastSwappedIndex = 0 ,并且第二趟冒泡也就结束了,也就说我们节省了 从 2 到 6的比较操作;
最后再来一趟冒泡排序,发现没有任何交换,所以冒泡排序结束。
相比于一次优化的实现方式,二次优化的实现方式进一步减少了不必要的执行次数,两种优化后的实现方式需要冒泡排序的趟数是一样的,本质上没有什么区别。所以即使对于一个有序的数组,两种方式的时间复杂度都是O(n)
1.3.1 冒泡排序的二次优化代码实现
/* 引入newIndex标记交换的索引位置,下次冒泡的时候结束位置就是newIndex */
void bubbleSortV3(SortTable* table) {int newIndex;int n = table->length;do {newIndex = 0;for (int i = 0; i < n - 1; ++i) {if (table->data[i].key > table->data[i + 1].key) {swapElement(&table->data[i + 1], &table->data[i]);newIndex = i + 1;}}//更新挡板位置n = newIndex;} while (newIndex > 0);
}
注意要把i+1赋给newIndex,如果赋的是i由于我们for循环中循环条件是n-1就会少一个数的排序,如图假设newIndex在6,本来该是2,1,4,5冒泡排序,但是由于newIndex-1赋值给n,n-1为循环条件就会导致5没有参与冒泡排序。
来测一下:
#include"bubbleSort.h"
void test01() {int n=10000;SortTable *table1=generateRandomArray(n,0,1+5000);SortTable *table2=copySortTable(table1);SortTable *table3=copySortTable(table1);testSort("bubbleSortV1",bubbleSortV1,table1);testSort("bubbleSortV2",bubbleSortV2,table2);testSort("bubbleSortV3",bubbleSortV3,table3);releaseSortTable(table1);releaseSortTable(table2);
}int main() {test01();return 0;
}
结果:
D:\work\DataStruct\cmake-build-debug\04_Sort\SwapSort.exe
bubbleSortV1 cost time: 0.129000s.
bubbleSortV2 cost time: 0.131000s.
bubbleSortV3 cost time: 0.125000s.进程已结束,退出代码为 0
看的出来第二次优化会比前两次是要好一点的
2.快速排序
找pos犄点,pos是索引号,pos对应的值的左边都是比pos值小的,pos对应的值的右边都是比pos值大的,这样咱们就把要排序的序列分成两部分,我们拿排序的最差时间复杂度来说,假设左边部分有x个元素,右边部分为y个,那么分别排序这两个序列的时间复杂度为x2+y2而没有拆分前的时间复杂度是x2+y2+2xy,同理在左边部分的序列又可以找一个犄点,右边部分的序列也可以找一个犄点。如图所示:
如果我们每次的犄点都是均分,一直分下去最后的时间复杂度为nlogn。找犄点的方法有两种:双边循环法,单边循环法。
2.1双边循环法
所谓双边就是两个指针在左右,用这两个指针从数组两端向中间“夹击”,随机初始化一个犄点把小于犄点的元素换到左边、大于犄点的元素换到右边,直到两指针相遇。初始时的犄点是随机取得,这里我就把它放在数组得最左边,如图:
来看右边right,看右边的指针是否比初始时犄点值大,发现是49没问题,right往左边走一步来到27,发现27比38小,说明应该在犄点的左边;left最初指向38,可以认为大于等于初始时犄点的值,left往右走一步来到49,49比38大,说明应该在犄点的右边,我们应该把两个值交换位置,注意一定要先操作右边,找到第一个比我们最初设定的犄点值小的数,如果暂时没有,就先一直操作右边right–,保证右边先找到,再去操作左边,找到第一个比我们最初设定的犄点值大的数,如果暂时没有,就再一直操作右移,两者都找到了就交换位置如图
right往右一直走到13发现比38小了,left往左边走,相碰了,我们找到了犄点所在位置,我们就交换38和13的位置。如图,犄点左边的值确实比犄点的值小,犄点右边的值确实比犄点的值大,同理在新分出来的左,右边依然可以采取这种方法直到left和right相碰,很明显这是一种递归的是方法,下面我们来看怎么实现代码
2.1.1双边循环法的代码实现
static int partitionDouble(SortTable *table, int startIndex, int endIndex) {int pivot = startIndex;int left = startIndex;int right = endIndex;// 随机将startIndex和后续的一个随机索引指向的元素进行交换while (left != right) {while (left < right && table->data[right].key > table->data[pivot].key) { right--; }while (left < right && table->data[left].key <= table->data[pivot].key) { left++; }if (left < right) {swapElement(&table->data[right], &table->data[left]);}}swapElement(&table->data[pivot], &table->data[left]);return left;
}// 用递归思想实现[start, end]区间的排序
static void quickSort1(SortTable *table, int startIndex, int endIndex) {if (startIndex >= endIndex) {return;}// 找到犄点int pivot = partitionDouble(table, startIndex, endIndex);quickSort1(table, startIndex, pivot - 1);quickSort1(table, pivot + 1, endIndex);
}void quickSortV1(SortTable* table) {quickSort1(table, 0, table->length - 1);
}
我们先来看大框架:static void quickSort1(SortTable *table, int startIndex, int endIndex);我们需要通过找犄点把整个序列按左边小右边大的思路一直分下去,所以我们需要写一个找犄点的函数,找到后开始递归,把新的左右两边的序列再次通过犄点分成左右两份,但是我们不能无限递归下去,所以我们需要写一个递归终止条件,只要子区间长度 ≤ 1,即 startIndex >= endIndex,就已经有序,无需再排,然后我们在
void quickSortV1(SortTable *table)调用这个函数,并赋值starIndex和endIndex。
接下来看找犄点的代码,我们把最初的基准pivot放到startIndex上,当左边left小于等于右边right的时候,开始先对右边处理逻辑,再对左边处理逻辑。有人可能会问,为什么外层循环已经有left<=right,内层循环为什么还要加呢?当出现如下图所示情况时如果在内层while循环中没有left<right的话,right就会一直减。同理处理左边的时候也需要加上left<right这个条件。外层循环只是控制分区过程是否继续,内层循环防止单指针移动时越界/交叉,确保每次移动后仍满足指针有效性。前面这两是必须的,if中的left<right避免指针重合时的无效交换,推荐使用
来测试一下:
#include"bubbleSort.h"
#include"quickSort.h"
void test02() {int n = 10000;SortTable *table1 = generateRandomArray(n, 0, n + 5000);SortTable *table2 = copySortTable(table1);testSort("bubbleSortV3", bubbleSortV3, table1);testSort("quick SortV1", quickSortV1, table2);releaseSortTable(table1);}int main() {test02();return 0;
}
结果:
D:\work\DataStruct\cmake-build-debug\04_Sort\SwapSort.exe
bubbleSortV3 cost time: 0.131000s.
quick SortV1 cost time: 0.000000s.进程已结束,退出代码为 0
能够看出快速排序的时间比冒泡排序的时间快的多,接下来看单边循环法
2.2单边循环法
我们依旧需要一个犄点,犄点的左值小于犄点,犄点的右值大于犄点,只不过我们用一个指针来处理结构,我们把这个犄点重新起个名字叫mark,帮忙维护整个序列的指针我们先称为i,依旧先把序列的最左边当作我们的犄点。如下图初始时
我们的i不断地往右边走,当走到i对应的值大于基础值38的时候我们就不管,当发现我们的i对应的值小于了38时,我们mark往右边走一位,然后交换mark指向的值和i指向的值,为什么mark要先往右边走一位呢,因为最终我们38会和我们最终确定的mark交换位置,我们需要保证最后交换后的mark的左边全部小于mark对应的值。反过来说,我们找到了对应i的值小于了基础值38后,mark要往后面走一位给我们找到的数据空一格位置出来,我们走到13的时候发现13比38小,mark往右边移动一位,和i指向的值交换
下一次找到比38小的数是27,我们依旧采用这样的思路如图所示:
i继续往后面走,发现mark的右边已经全部比38大了,这时我们交换38和mark指向的27的位置即可。
2.2.1单边循环法代码实现
static int partitionSingle(SortTable *table, int startIndex, int endIndex) {keyType tmpValue = table->data[startIndex].key;//备份一下第一个基准值int mark = startIndex;//假设第一个是犄点for (int i = startIndex + 1; i <= endIndex; i++) {if (table->data[i].key < tmpValue) {mark++;swapElement(&table->data[i], &table->data[mark]);}}swapElement(&table->data[startIndex], &table->data[mark]);return mark;
}static void quickSort2(SortTable *table, int startIndex, int endIndex) {if (startIndex >= endIndex) {return;}// 找到犄点int pivot = partitionSingle(table, startIndex, endIndex);quickSort2(table, startIndex, pivot - 1);quickSort2(table, pivot + 1, endIndex);
}void quickSortV2(SortTable* table) {quickSort2(table, 0, table->length - 1);
}
这里的逻辑就是我上述说的逻辑。
#include"bubbleSort.h"
#include"quickSort.h"void test02() {int n = 10000;SortTable *table1 = generateRandomArray(n, 0, n + 5000);SortTable *table2 = copySortTable(table1);SortTable *table3 = copySortTable(table1);testSort("bubbleSortV3", bubbleSortV3, table1);testSort("quick SortV1", quickSortV1, table2);testSort("quick SortV2", quickSortV2, table3);releaseSortTable(table1);releaseSortTable(table2);releaseSortTable(table3);
}int main() {test02();return 0;
}
结果:
D:\work\DataStruct\cmake-build-debug\04_Sort\SwapSort.exe
bubbleSortV3 cost time: 0.123000s.
quick SortV1 cost time: 0.001000s.
quick SortV2 cost time: 0.001000s.进程已结束,退出代码为 0
大概先写这些吧,今天的博客就先写到这,谢谢您的观看。