1:排序算法常用时间复杂度:
快速排序
快速排序的核心操作是"哨兵划分",其目标是:选择数组中的某个元素作为"基准数",将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程如图所示。
- 选取数组最左端元素作为基准数,初始化两个指针 i 和 j 分别指向数组的两端。
- 设置一个循环,在每轮中使用 i(j)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。
- 循环执行步骤 2. ,直到 i 和 j 相遇时停止,最后将基准数交换至两个子数组的分界线。
最好时间复杂度:O(NlogN)
最坏时间复杂度:O(N2)
最坏时间复杂度:O(NlgN)
空间复杂度:O(logN)
稳定排序:否
原地排序:是
原数组被划分成三部分:左子数组、基准数、右子数组,且满足"左子数组任意元素 基准数 右子数组任意元素"。因此,我们接下来只需对这两个子数组进行排序。
程序如下;
python
def partition(self, nums: list[int], left: int, right: int) -> int:
"""哨兵划分"""
# 以 nums[left] 为基准数
i, j = left, right
while i < j:
while i < j and nums[j] >= nums[left]:
j -= 1 # 从右向左找首个小于基准数的元素
while i < j and nums[i] <= nums[left]:
i += 1 # 从左向右找首个大于基准数的元素
# 元素交换
nums[i], nums[j] = nums[j], nums[i]
# 将基准数交换至两子数组的分界线
nums[i], nums[left] = nums[left], nums[i]
return i # 返回基准数的索引
快速排序的整体流程如图 11-9 所示。
- 首先,对原数组执行一次"哨兵划分",得到未排序的左子数组和右子数组。
- 然后,对左子数组和右子数组分别递归执行"哨兵划分"。
- 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序。
python
def quick_sort(self, nums: list[int], left: int, right: int):
"""快速排序"""
# 子数组长度为 1 时终止递归
if left >= right:
return
# 哨兵划分
pivot = self.partition(nums, left, right)
# 递归左子数组、右子数组
self.quick_sort(nums, left, pivot - 1)
self.quick_sort(nums, pivot + 1, right)
冒泡排序
冒泡排序(bubble sort)通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样,因此得名冒泡排序。如图 11-4 所示,冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果"左元素 > 右元素"就交换二者。遍历完成后,最大的元素会被移动到数组的最右端。
设数组的长度为 n ,冒泡排序的步骤如图 11-5 所示。
- 首先,对 n 个元素执行"冒泡",将数组的最大元素交换至正确位置。
- 接下来,对剩余 n -1个元素执行"冒泡",将第二大元素交换至正确位置。
- 以此类推,经过 n -1轮"冒泡"后,前 n-1大的元素都被交换至正确位置。仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成。
python
def bubble_sort(nums: list[int]):
"""冒泡排序"""
n = len(nums)
# 外循环:未排序区间为 [0, i]
for i in range(n - 1, 0, -1):
# 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for j in range(i):
if nums[j] > nums[j + 1]:
# 交换 nums[j] 与 nums[j + 1]
nums[j], nums[j + 1] = nums[j + 1], nums[j]
最好时间复杂度:O(N)
最坏时间复杂度:O(N2)
平均时间复杂度:O(N2)
空间复杂度:O(1)
稳定排序:是
原地排序:是
如果某轮"冒泡"中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位 flag 来监测这种情况,一旦出现就立即返回。
堆排序
输入数组并建立小顶堆,此时最小元素位于堆顶。不断执行出堆操作,依次记录出堆元素,即可得到从小到大排序的序列。以上方法虽然可行,但需要借助一个额外数组来保存弹出的元素,比较浪费空间。在实际中,我们通常使用一种更加优雅的实现方式。
最好时间复杂度:O(NlogN)
最坏时间复杂度:O(NlogN)
平均时间复杂度:O(NlogN)
空间复杂度:O(1)
稳定排序:否
原地排序:是
python
def sift_down(nums: list[int], n: int, i: int):
"""堆的长度为 n ,从节点 i 开始,从顶至底堆化"""
while True:
# 判断节点 i, l, r 中值最大的节点,记为 ma
l = 2 * i + 1
r = 2 * i + 2
ma = i
if l < n and nums[l] > nums[ma]:
ma = l
if r < n and nums[r] > nums[ma]:
ma = r
# 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
if ma == i:
break
# 交换两节点
nums[i], nums[ma] = nums[ma], nums[i]
# 循环向下堆化
i = ma
def heap_sort(nums: list[int]):
"""堆排序"""
# 建堆操作:堆化除叶节点以外的其他所有节点
for i in range(len(nums) // 2 - 1, -1, -1):
sift_down(nums, len(nums), i)
# 从堆中提取最大元素,循环 n-1 轮
for i in range(len(nums) - 1, 0, -1):
# 交换根节点与最右叶节点(交换首元素与尾元素)
nums[0], nums[i] = nums[i], nums[0]
# 以根节点为起点,从顶至底进行堆化
sift_down(nums, i, 0)
插入排序
具体步骤可以看hello算法里面:
链接如下:
最好时间复杂度:O(N)
最坏时间复杂度:O(N2)
平均时间复杂度:O(N2)
空间复杂度:O(1)
稳定排序:是
原地排序:是
python
def insertion_sort(nums: list[int]):
"""插入排序"""
# 外循环:已排序区间为 [0, i-1]
for i in range(1, len(nums)):
base = nums[i]
j = i - 1
# 内循环:将 base 插入到已排序区间 [0, i-1] 中的正确位置
while j >= 0 and nums[j] > base:
nums[j + 1] = nums[j] # 将 nums[j] 向右移动一位
j -= 1
nums[j + 1] = base # 将 base 赋值到正确位置
选择排序
选择排序(selection sort)的工作原理非常简单:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。
python
def selection_sort(nums: list[int]):
"""选择排序"""
n = len(nums)
# 外循环:未排序区间为 [i, n-1]
for i in range(n - 1):
# 内循环:找到未排序区间内的最小元素
k = i
for j in range(i + 1, n):
if nums[j] < nums[k]:
k = j # 记录最小元素的索引
# 将该最小元素与未排序区间的首个元素交换
nums[i], nums[k] = nums[k], nums[i]
最好时间复杂度:O(N2)
最坏时间复杂度:O(N2)
平均时间复杂度:O(N2)
空间复杂度:O(1)
稳定排序:否
原地排序:是
希尔排序
基本思想
希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算
法。希尔排序是基于插入排序的以下两点性质而提出改进方法的:插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整
个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。
判断大小端
大端模式:低位字节存在高地址上,高位字节存在低地址上。
小端模式:高位字节存在高地址上,
釆用小端模式的CPU对操作数的存放方式是从低字节到高字节,而大端模式对操作数的存放方式是从高
字节到低字节。例如,16位宽的数0x1234在小端模式CPU内存中的存放方式(假设从地址0x4000开始
存放)见表1,而在大端模式CPU内存中的存放方式见表2。
c
#include <stdio.h>
struct mybitfields
{
unsigned short a:4;
unsigned short b:5;
unsigned short c:7;
}test;
int main()
{
int i;
test.a = 2;
test.b = 3;
test.c = 0;
i =*((short*)&test);
printf("%d\n",i);
return 0;
}
上例中, sizeof( test)=2,上例的声明方式是把一个 short(也就是一块16位内存)分成3部分,各部
分的大小分别是4位、5位、7位,赋值语句 i*( short*)&test) 就是把上面的16位内存转换成 short
类型进行解释。
变量a的二进制表示为0000000000000010,取其低四位是0010.变量b的二进制表示为
0000000000000011,取其低五位是00011。变量c的二进制表示为0000000000000000,取其低七位
是0000000。
80x86机是小端(修改分区表时要注意)模式,单片机一般为大端模式。小端一般是低位字节在高位字
节的前面,也就是低位在内存地址低的一端,可以这样记(小端→低位→在前→与正常逻辑顺序相
反),所以合成后得到0000000000110010,即十进制的50。
python
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main()
{
unsigned int uiVal_1 = 0x12345678;
unsigned int uiVal_2 = 0;
unsigned char aucVal[4] = {0x12,0x34,0x56,0x78};
unsigned short usVal_1 = 0;
unsigned short usVal_2 = 0;
memcpy(&uiVal_2,aucVal,sizeof(uiVal_2));
usVal_1 = (unsigned short)uiVal_1;//在这里截断,都取得的是低位
usVal_2 = (unsigned short)uiVal_2;//在这里截断
printf("usVal_1:%x\n",usVal_1);//在这里又转化回来
printf("usVal_2:%x\n",usVal_2);//在这里又转化回来
return 0;
}
这个C程序主要演示了如何使用 memcpy
函数来复制数据,并展示了数据类型转换和截断的效果。下面是程序的逐行解释和预测的运行结果:
程序解释
-
定义并初始化变量:
unsigned int uiVal_1 = 0x12345678;
定义一个无符号整型变量uiVal_1
并初始化为16进制值12345678
。unsigned int uiVal_2 = 0;
定义另一个无符号整型变量uiVal_2
并初始化为0。unsigned char aucVal[4] = {0x12, 0x34, 0x56, 0x78};
定义一个无符号字符数组aucVal
并初始化。
-
使用
memcpy
复制数据:memcpy(&uiVal_2, aucVal, sizeof(uiVal_2));
将aucVal
数组的内容复制到uiVal_2
。复制的大小是uiVal_2
的大小,即4字节。这里的内存布局取决于系统的字节序(大端或小端)。
-
数据截断:
usVal_1 = (unsigned short)uiVal_1;
将uiVal_1
的值转换为unsigned short
,只保留低16位,即5678
。usVal_2 = (unsigned short)uiVal_2;
将uiVal_2
的值转换为unsigned short
,根据memcpy
的结果和系统的字节序,也只保留低16位。
-
打印输出:
printf("usVal_1:%x\n", usVal_1);
打印usVal_1
的值,预期为5678
。printf("usVal_2:%x\n", usVal_2);
打印usVal_2
的值,其具体值取决于系统的字节序。
字节序依赖
- 在小端字节序 系统中(低位字节存储在低地址),
memcpy
将导致uiVal_2
成为78563412
(即aucVal
数组直接映射到整数),因此usVal_2
将截取为3412
。 - 在大端字节序 系统中(高位字节存储在低地址),
uiVal_2
的值将直接是12345678
,因此usVal_2
将截取为5678
。
预测输出
假设系统使用小端字节序,输出将是:
usVal_1:5678
usVal_2:3412
如果系统使用大端字节序,输出将是:
usVal_1:5678
usVal_2:5678
这个程序很好地展示了如何操作和转换不同的数据类型,以及如何通过 memcpy
处理字节序问题。
如何判断计算机处理器是大端,还是小端?
c
#include <stdio.h>
int checkCPU()
{
{
union w
{
int a;
char b;
}c;
c.a =1;
return(c.b == 1);
}
}
int main()
{
if(checkCPU())
printf("小端\n");
else
printf("大端\n");
return 0;
}
因为 Intel处理器一般都是小端模式,所以此时程序的输出结果为:小端上述代码中,如果处理器是大端,则返回0;如果处理器是小端,则返回1.联合体 union的存放顺序是所有成员都从低地址开始存放,如果能够通过改代码知道CPU对内存是采用小端模式读写,还是采用大端模式读写,一定会令面试官刮目相看。
要理解为什么在 union
中的 char b
也会是 1
,我们首先需要了解 union
的内存共享特性以及如何处理字节序。
union
的内存共享
在 C 语言中,union
是一种特殊的数据类型,允许在相同的内存位置存储不同的数据类型。这意味着 union
中所有成员都占用同一块内存地址。在你的程序中,union w
包含两个成员:
int a;
------ 一个整型,通常占用4个字节(这取决于系统和编译器,但在大多数现代系统上是这样)。char b;
------ 一个字符型,占用1个字节。
当你对 union
的 int
成员 a
赋值为 1
时,这个值被存储在整型 a
的内存中。由于 union
的特性,这个内存同时也是 char
成员 b
的内存。
如何存储的
整数 1
在内存中的表示取决于计算机的字节序:
- 小端字节序 (Little Endian):较低的字节(即值较小的字节)存储在较低的地址。因此,整数
1
被存储为01 00 00 00
(从低地址到高地址)。在这种情况下,char b
(占据最低的地址,即这里的第一个字节)将是01
,即1
。 - 大端字节序 (Big Endian):较高的字节(即值较大的字节)存储在较低的地址。整数
1
被存储为00 00 00 01
(从低地址到高地址)。在这种情况下,char b
(仍然占据最低的地址,但这里是最后一个字节)将不是1
,而是0
。
通过指针判断大小端。
c
#include <stdio.h>
int checkCPU()
{
unsigned short usData = 0x1122;
unsigned char*pucData = (unsigned char*)&usData;
return (*pucData == 0x22);
}
int main()
{
if(checkCPU())
printf("小端\n");
else
printf("大端\n");
return 0;
}