接下来让8和2比较,发现8比2要大,所以8和2交换位置。
接下来让8和1比较,发现8比1要大,所以8和1交换位置。
继续让8和7比较,发现8比7要大,所以8和7交换位置。
第二轮排序结束后,我们数列右侧的有序区有了两个元素,顺序如下:
③第三趟冒泡排序👇
按照以上步骤,第三轮过后的状态如下:
④第四趟冒泡排序👇
第四轮过后状态如下:
⑤第五趟冒泡排序👇
第五轮过后状态如下:
⑥第六趟冒泡排序👇
第六轮过后状态如下:
⑦第七趟冒泡排序👇
第七轮过后状态如下(已经是有序了,所以没有改变):
⑧第八趟冒泡排序👇
第八轮过后状态如下(同样没有改变):
到此为止,所有元素都是有序的了,这就是冒泡排序的整体思路。
🍑 动图演示
清楚了冒泡排序的过程,我们再来看一个动态图
📃 代码实现
代码过程如下:
void bubble\_sort(int\* arr, int sz)
{
int i = 0;
int j = 0;
for (i = 0; i < sz - 1; i++)
{
for (j = 0; j < sz - 1 - i; j++)
{
if (arr[j] > arr[j + 1])
{
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
}
int main()
{
int arr[8] = { 5, 8, 6, 3, 9, 2, 1, 7 };
int sz = sizeof(arr) / sizeof(arr[0]);
bubble\_sort(arr, sz);
int i = 0;
for (i = 0; i < sz; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
这个代码很简单,使用双循环的方式进行排序。
外部的循环控制所有回合,内部循环代表每一轮的冒泡处理,先进行元素比较,再进行元素交换。
来看下运行结果:
📝 代码优化
让我们回顾一下刚才描述的排序细节,仍然以
5,8,6,3,9,2,1,7
这个数列为例;当排序算法分别执行到第六、第七、第八轮的时候,数列状态如下:
很明显可以看出,自从经过第六轮排序,整个数列已然是有序的了。
可是我们的排序算法仍然 “兢兢业业” 地继续执行第七轮、第八轮。
这种情况下,如果我们能判断出数列已经有序,并且做出标记,剩下的几轮排序就可以不必执行,提早结束工作。
优化的思路是:如果能判断出数列已经是有序的了,并且做出标记,那么就不会执行多余的排序。
因此,我们进行一个优化的方法:就是设置一个flags
;
如果在本轮排序中有元素进行交换,则说明数列无序,如果已经排序了那么设置为0;
如果在本轮排序中,没有元素进行交换,则说明数列有序,那么设置为1。
优化后的代码👇
void bubble\_sort(int\* arr, int sz)
{
int flags = 0;
for (int i = 0; i < sz - 1; i++)
{
for (int j = 0; j < sz - 1 - i; j++)
{
if (arr[j] > arr[j + 1])
{
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
flags = 1;//不是有序的,flags设置为1
}
}
if (flags == 0)
return;
}
}
我们再来看一下运行结果:
🍑 特性总结
1、冒泡排序是一种非常容易理解的排序;
2、时间复杂度:
O
(
N
2
)
O(N^2)
O(N2)
3、空间复杂度:
O
(
1
)
O(1)
O(1)
4、稳定性:稳定
3. 快速排序
🍑 基本思想
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法;
其基本思想为:
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值;
然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
每次把数列分成两部分,究竟有什么好处呢?
假如给出⼀个8个元素的数列,⼀般情况下,使⽤冒泡排序需要⽐较7 轮,每⼀轮把1个元素移动到数列的⼀端。
快速排序的流程是什么样⼦呢?
原数列在每⼀轮都被拆分成两部分, 每⼀部分在下⼀轮⼜分别被拆分成两部分,直到不可再分为⽌。
基准元素的选择,以及元素的交换,都是快速排序的核⼼问题。
🍑 基准元素的选择
随机选择一个元素作为基准元素。并且让基准元素和数列⾸元素交换位置
这样⼀来,即使在数列完全逆序的情况下,也可以有效地将数列分成两部分。
即使是随机选择基准元素,也会有极⼩的⼏率选到数列的最⼤ 值或最⼩值,同样会影响分分割的效果。
选定了基准元素以后,我们要做的就是把其他元素中⼩于基准元素的都交换到基准元素⼀边,⼤于基准元素的都交换到基准元素另⼀边。
常见的方式有:
1、挖坑法
2、前后指针法
🍑 挖坑法
什么是挖坑法呢?我们来看一看详细过程👇
给定原始数列如下,要求从小到大排序:
首先,我们选定基准元素Pivot,并记住这个位置index,这个位置相当于一个“坑”。并且设置两个指针left和right,指向数列的最左和最右两个元素:
接下来,从right指针开始,把指针所指向的元素和基准元素做比较。
如果比pivot大,则right指针向左移动;如果比pivot小,则把right所指向的元素填入坑中。
在当前数列中,1 < 4,所以把1填入基准元素所在位置,也就是坑的位置。这时候,元素1本来所在的位置成为了新的坑。同时,left向右移动一位。
此时,left左边绿色的区域代表着小于基准元素的区域。
接下来,我们切换到left指针进行比较。如果left指向的元素小于pivot,则left指针向右移动;如果元素大于pivot,则把left指向的元素填入坑中。
在当前数列中,7>4,所以把7填入index的位置。这时候元素7本来的位置成为了新的坑。同时,right向左移动一位。
此时,right右边橙色的区域代表着大于基准元素的区域。
下面按照刚才的思路继续排序👇
8>4,元素位置不变,right左移
2<4,用2来填坑,left右移,切换到left。
6>4,用6来填坑,right左移,切换到right。
3<4,用3来填坑,left右移,切换到left。
5>4,用5来填坑,right右移。这时候left和right重合在了同一位置。
这时候,把之前的pivot元素,也就是4放到index的位置。此时数列左边的元素都小于4,数列右边的元素都大于4,这一轮交换终告结束。
📃 代码实现
//快速排序(挖坑法)
void QuickSort1(int\* a, int begin, int end)
{
if (begin >= end)//当只有一个数据或是序列不存在时,不需要进行操作
return;
int left = begin;//L
int right = end;//R
int key = a[left];//在最左边形成一个坑位
while (left < right)
{
//right向左,找小
while (left < right&&a[right] >= key)
{
right--;
}
//填坑
a[left] = a[right];
//left向右,找大
while (left < right&&a[left] <= key)
{
left++;
}
//填坑
a[right] = a[left];
}
int meeti = left;//L和R的相遇点
a[meeti] = key;//将key抛入坑位
QuickSort1(a, begin, meeti - 1);//key的左序列进行此操作
QuickSort1(a, meeti + 1, end);//key的右序列进行此操作
}
代码中,QuickSort方法通过递归的方式,实现了分割的思想。
时间复杂度:
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN)
🍑 前后指针法
什么是指针交换法?我们来看一看详细过程👇
给定原始数列如下,要求从小到大排序:
开局和挖坑法相似,我们首先选定基准元素Pivot,并且设置两个指针left和right,指向数列的最左和最右两个元素:
接下来是第一次循环,从right指针开始,把指针所指向的元素和基准元素做比较。
如果大于等于pivot,则指针向左移动;如果小于pivot,则right指针停止移动,切换到left指针。
在当前数列中,1<4,所以right直接停止移动,换到left指针,进行下一步行动。
轮到left指针行动,把指针所指向的元素和基准元素做比较。如果小于等于pivot,则指针向右移动;如果大于pivot,则left指针停止移动。
由于left一开始指向的是基准元素,判断肯定相等,所以left右移一位。
由于7 > 4,left指针在元素7的位置停下。这时候,我们让left和right指向的元素进行交换。
接下来,我们进入第二次循环,重新切换到right向左移动。right先移动到8,8>4,继续左移。由于2<4,停止在2的位置。
切换到left,6>4,停止在6的位置。
元素6和2交换。
进入第三次循环,right移动到元素3停止,left移动到元素5停止。
元素5和3交换。
进入第四次循环,right移动到元素3停止,这时候请注意,left和right指针已经重合在了一起。
当left和right指针重合之时,我们让pivot元素和left与right重合点的元素进行交换。此时数列左边的元素都小于4,数列右边的元素都大于4,这一轮交换终告结束。
🎥 动图演示
我们再来看一个动态图👇
📃 代码实现
//快速排序(前后指针法)
void QuickSort2(int\* a, int begin, int end)
{
if (begin >= end)//当只有一个数据或是序列不存在时,不需要进行操作
return;
//三数取中
int midIndex = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[midIndex]);
int prev = begin;
int cur = begin + 1;
int keyi = begin;
while (cur <= end)//当cur未越界时继续
{
if (a[cur] < a[keyi] && ++prev != cur)//cur指向的内容小于key
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
int meeti = prev;//cur越界时,prev的位置
Swap(&a[keyi], &a[meeti]);//交换key和prev指针指向的内容
QuickSort2(a, begin, meeti - 1);//key的左序列进行此操作
QuickSort2(a, meeti + 1, end);//key的右序列进行此操作
}
时间复杂度:
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN)
🍑 非递归实现
我们前面实现快排是采用递归的方式,但是有些场景递归解决不了的问题
1、当递归深度过大的时候,递归程序本身可能没用错误,但是编译之后会报错——栈溢出(stack overflow)。
2、性能问题。
于是就需要非递归登场了
当我们需要将一个用递归实现的算法改为非递归时,一般需要借用一个数据结构,那就是栈。
介绍空间复杂度时我们曾经提到过,代码中⼀层⼀层的⽅法调⽤,本⾝就使⽤了⼀个⽅法调⽤栈。每次进⼊⼀个新⽅法,就相当于⼊栈;每次有⽅法返回,就相当于出栈。
所以,可以把原本的递归实现转化成⼀个栈的实现,在栈中存储每⼀ 次⽅法调⽤的参数。
📃 挖坑法
//挖坑法(单趟排序)
int PartSort2(int\* a, int left, int right)
{
int key = a[left];//在最左边形成一个坑位
while (left < right)
{
//right向左,找小
while (left < right&&a[right] >= key)
{
right--;
}
//填坑
a[left] = a[right];
//left向右,找大
while (left < right&&a[left] <= key)
{
left++;
}
//填坑
a[right] = a[left];
}
int meeti = left;//L和R的相遇点
a[meeti] = key;//将key抛入坑位
return meeti;//返回key的当前位置
}