目录
(一)算法的时间复杂度及空间复杂度
时间复杂度
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间度量,记作:T(n)= O(f(n))。他表示随问题规模n的增大,算法执行时间增长率f(n)的增长率相同,称作算法的渐进时间复杂度,简称为时间复杂度。其中f(n)是问题规模n的某个函数。
时间复杂度是在一个算法流程中,常熟操作数量的指标。通常我们用O()来刻画时间复杂度,称为大O表示法。简单来说在常数操作数量的表达式中,只要高阶项,不要低阶项,也不要高阶项的系数,剩下的部分我们记作f(n),则时间复杂度就为O(f(n))。如下面几个例子:
int i = 0;
while(i < 10)
{
i++;
}
在这里i++
为算法语句,其被执行的次数为10次,f(n) = 10。
int i;
for(i = 0; i < n; i++)
{
printf("hello world");
}
在这里printf()
为算法语句,其被执行的次数为n次,f(n) = n.
int i, j;
for(int i = 0;i < n; i++)
{
for(int j = 0; j < n; j++)
{
printf("#");
}
}
在这里printf()
为算法语句,被执行n * n次,其f(n) = n * n。
通过上面所总结出来的时间复杂度表示在常数操作数量的表达式中,只要高阶项,不要低阶项,也不要高阶项的系数我们可知上面三个例子中的时间复杂度分别为:
通过这样推导方法我们可得出常见的算法时间复杂度及所耗时间排序如下:
最坏情况与平均情况
因为实际情况的不同,算法应用情况也不同,算法的最坏情况运行时间是一种保证,即现实的运行状况不会比这个最坏时间更糟,通常我们所指的运行时间都是最坏情况的运行时间。算法的平均运行时间是所有情况中最有意义的,因为他是期望的运行时间。
算法的空间复杂度
算法的空间复杂度通过计算算法的存储空间来衡量,空间复杂度用S(n)来刻画,S(n) = O(f(n)),其中n为问题的规模,f(n)为语句中关于n所占存储空间的函数。
排序稳定性
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。例如:
冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。所以,如果两个元素相等,我想你是不会再无聊地把他们俩交换一下的;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换,所以相同元素的前后顺序并没有改 变,所以冒泡排序是一种稳定排序算法。
选择排序是给每个位置选择当前元素最小的,比如给第一个位置选择最小的,在剩余元素里面给第二个元素选择第二小的,依次类推,直到第n-1个元素,第n个 元素不用选择了,因为只剩下它一个最大的元素了。那么,在一趟选择,如果当前元素比一个元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么 交换后稳定性就被破坏了。比较拗口,举个例子,序列5 8 5 2 9, 我们知道第一遍选择第1个元素5会和2交换,那么原序列中2个5的相对前后顺序就被破坏了,所以选择排序不是一个稳定的排序算法。
(二)冒泡排序
冒泡排序(Bubble Sort)是一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,知道没有反序记录为止。
动画演示:
示例代码:
void BubbleSort(int arr[], int n) //从小到大冒泡排序
{
for (int i = 0; i < n - 1; i++)
{
for (int j = 0; j < n - i - 1; j++)
{
if (arr[j] > arr[j + 1])
swap(arr[j], arr[j + 1]);
}
}
}
void InverseBubbleSort(int arr[], int n) //从大到小冒泡排序
{
for (int i = 0; i < n - 1; i++)
{
for (int j = n - 1; j > 0; j--)
{
if (arr[j] > arr[j - 1])
{
swap(arr[j], arr[j - 1]);
}
}
}
}
性能
冒泡排序的时间复杂度为O(n^2),空间复杂度为O(1),冒泡排序是稳定的,比较次数与初始序列无关,但交换次数与初始序列有关。
优化–加入标记位
考虑一种特殊情况:如果是这样一个序列{2,1,3,4,5,6,7,8,9}即除了第一个和第二个元素外该序列是有序的那么我们在第一次循环的过程中已经将序列遍历了一遍如果后面的元素没有发生交换则我们可以退出循环不再继续后面的循环判断工作。
我们可以增加一个标记变量flag来标记,增加到第一层循环的成立条件,如果后面数据发生了交换则flag = true
使第一层循环退出。
void BubbleSort2(int arr[], int n)
{
bool flag = true;
for(int i = 0; i < n - 1 && flag; i++)
{
flag = false;
for(int j = 0; j < n - i - 1; j++)
{
if(arr[j] > arr[j+1])
{
swap(arr[j],arr[j+1]);
flag = true;
}
}
}
}
(三)简单选择排序
简单选择排序(Simple Selection Sort)是通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第I(1 <= i <= n)个记录交换。
动画演示:
示例代码:
void selectSort(int r[], int n) {
int i, index, j;
for (i = 0; i < n; i++) //执行n次扫描
{
index = i;
for (j = i + 1; j < n; j++)
{
if (r[index] > r[j]) //记录序列中最小值的位置
{
index = j;
}
}
if (index != i) //如果无序序列中第一个记录不是最小值,则进行交换
{
int temp = r[index];
r[index] = r[i];
r[i] = temp;
}
}
}
性能
简单选择排序的特点就是交换移动数据次数少,提高效率,节省时间。通过分析他的时间复杂度,无论待排序的序列情况好坏,比较次数都是一样的。对于交换次数来说,与待排序的序列情况好坏有关,最好的时候交换0次,最坏的时候交换n-1次。最终排序时间取决于比较和交换的次数之和,因此简单选择排序时间复杂度为:O(n^2),空间复杂度为O(1),排序不稳定,尽管和冒泡排序的时间复杂度相同,但性能上还是略好于冒泡排序。
(三)插入排序
插入排序可能是最容易记住的因为贴近生活,一个可以让你很容易记住他的例子就是扑克牌,当你依次抓到了5,3,4,6,2这几张牌,你稍微整理一下,就会从左到右先将3移到5的前面,然后将4放到3,5之间,最后将2放到3的前面,这样你手里的牌就变得从小到大有序了。
直接插入排序(Straigth Insertion Sort)的基本操作是将一个记录插入到已经排好的有序表中,从而得到一个新的记录数加一的有序表。
动画演示:
示例代码:
看了网上好多代码用的大多是swap两两交换,但是好像有点像冒泡了,下面的代码应该可以体现出插入的思想:
void InsertSort(int array[], int n)
{
for (int i = 0; i < n-1; i++)
{
int j = i + 1, k = 0; //k用来存放待插入的数据
if (array[j] < array[j - 1])
{
k = array[j];
for (j -= 1; k < array[j] && j >= 0; j--) //如果被插入的比前面的大则这个位置就是合适位置
{
array[j+1] = array[j]; //将比k大的数向后移
}
array[j+1] = k;
}
}
}
性能
直接插入排序的时间复杂度我为O(n^2),空间复杂度为O(1),插入排序是稳定的,初始序列会影响比较和交换次数。
我们可以看出,直接插入排序会比冒泡和简单排序好一些。
优化–折半插入
在直接插入排序中,我们找这个属于代插元素的合适位置的时候是按顺序依次向前查找,如果数据量较大的话可以对此进行优化,将顺序查找改为二分查找,即折半插入。折半插入相对于直接插入而言平均性能快,时间复杂度为O(NlogN),空间复杂度为O(1),排序是稳定的,排序的比较次数与原序列无关,需要log(i)+1次比较。
void InsertSort2(int array[], int n)
{
for (int i = 0; i < n-1; i++)
{
int j = i + 1, k = 0;
if (array[j] < array[j - 1])
{
k = array[j];
int low = 0, high = j;
while (low <= high) //折半查找找到待插入元素合适位置
{
int mid = (low + high) / 2;
if (array[mid] < k)
low = mid + 1;
if (array[mid] > k)
high = mid - 1;
}
for (j -= 1; j > high; j--) //high右侧都是比待插元素大的数因此要依次向后移
{
array[j+1] = array[j];
}
array[high+1] = k;
}
}
}
(四)堆排序
堆排序(Heap Sort)就是利用堆进行排序的方法。它的基本思想是将待排序的序列构造成一个大根堆。此时,整个序列的最大值就是堆顶的根节点。将他移走(将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,会得到n个元素中的次小值,反复执行,就会得到一个有序序列。
堆:是一种特殊的树形数据结构,即完全二叉树。堆分为大根堆和小根堆,大根堆为根结点的值大于两个子节点的值,小根堆为根结点的值小于两个子节点的值,同时根节点的两个子树也分别是一个堆。
动画演示:
示例代码:
void heapSort(int arr[], int size)
{
// 构建大根堆(从最后一个非叶子节点向上)
for(int i=size/2 - 1; i >= 0; i--)
{
adjust(arr, size, i);
}
// 调整大根堆
for(int i = size - 1; i >= 1; i--)
{
swap(arr[0], arr[i]); // 将当前最大的放置到数组末尾
adjust(arr, i, 0); // 将未完成排序的部分继续进行堆排序
}
}
void adjust(int arr[], int len, int index)
{
int left = 2*index + 1; // index的左子节点
int right = 2*index + 2; // index的右子节点
int maxIdx = index;
if(left<len && arr[left] > arr[maxIdx])
maxIdx = left;
if(right<len && arr[right] > arr[maxIdx])
maxIdx = right;
if(maxIdx != index)
{
swap(arr[maxIdx], arr[index]);
adjust(arr, len, maxIdx); //调整后递归使调整后的该节点下面的子树也满足大根堆
}
}
该代码运用递归构造大根堆,并且调整后要求子树也满足大根堆,如果理解困难就自己带程序走一遍会好很多。
性能
堆排的运行时间主要消耗在初始构建和重建堆的反复筛选,堆排序的时间复杂度为O(nlongn)。由于堆排对原始数据排序状态不敏感,因此无论情况最好、最坏还是平均时间复杂度均为O(nlongn),这在性能上已经明显好于前面介绍过的冒泡,选择和直接插入的O(n^2)了。不太适合待排序列个数较少的情况。(构建时所需比较次数多)堆排也是一种不稳定的排序方法,其空间复杂度为O(1)。
参考书籍:大话数据结构