数据结构二分法算法的步骤_数据结构与算法 - 算法详解

本文介绍了数据结构中的查找算法,包括顺序表查找、二分法查找及其优化。二分法查找因其高效的时间复杂度O(logn)而优于顺序表查找的O(n)。此外,文章还探讨了插值查找和二叉排序树等高级查找技术,强调了在不同场景下选择合适查找算法的重要性。
摘要由CSDN通过智能技术生成

最近重新温习了一下数据结构与算法,所以稍作整理,做了个总结,用通俗易懂的语言,满足更多的初学者阅读和学习这方面的知识,希望对大家有所帮助。

算法

算法的时间复杂度和空间复杂度

我们所讲的算法主要分为查找算法和排序算法,这里也经常会提到算法的时间复杂度和空间复杂度,它可以体现算法的性能,那么什么是算法的时间复杂度和空间复杂度呢?时间复杂度:执行算法所需要的计算工作量。它是一个函数,描述了该算法的运行时间,通常用大“O”来表示

空间复杂度:执行算法所需要的内存空间

查找算法

顺序表查找

顺序表查找又叫线性表查找,是最基本的查找技术。

过程:从表中第一个(或最后一个)记录开始,逐个进行比较,如果某个记录的关键字和给定值相等,则查找城成功。

废话不多说,先上代码:

//array为数组,length为要查找的数组长度,key为要查找的关键字int SequentialSearch(int* array,int length,int key)

{

|for (int i = 1; i <= length; i++)

|{

||if(array[i] == key)

||{

|||return i;

||}

|}

|return 0;

}

这就是最基本的顺序表查找,是不是非常的简单。当然这里并非足够完美,因为每次循环都要对 i 是否越界(即是否<= n)做判断,那么是否还可以优化呢?当然! 我们可以通过设置一个哨兵,可以不需要每次让 i 与 n 作比较。

顺序表查找优化

int SequentialSearch(int* array,int length,int key)

{

|int i = length; //循环从尾部开始|a[0] = key; //设置a[0]为关键字值,即 “哨兵”|while (array[i] != key)

|{

||i--;

|}

|return i; //如果返回0,则说明查找失败}

看上去好像没什么变化,但当总数据较多时,效率提高很大。当然哨兵可以在数组的最开始,也可以在末端。对于这种顺序表查找算法来说,查找成功最好的情况就是在第一个位置就找到了,时间复杂度为O(1);

最差的情况是最后一个位置才找到,需要n次比较,所以时间复杂度为O(n)。

顺序表查找的缺点:当 n 很大时,查找效率极为低下。

二分法查找

二分法查找又叫折半查找。二分法查找的前提是: 要查找的数据集必须是顺序存储的有序线性表。

二分法查找的思想:在有序表中,取一个中间值,如果要查找的值小于这个中间值,就就在中间值左半边继续查找,如果要查找的值大于这个中间值,那就在中间值右半边继续查找,不断重复上面的过程,直到查找成功 (所以大家知道为什么要查找的数据集必须是顺序存储的有序线性表了吧)。

如果觉得我解释的不到位,没关系,直接上代码:

int BinarySearch(int* array,int length,int key)

{

|int low = 1; //定义最低下标为记录首位|int high = length; //定义最高下标为记录末位|int mid = 0; //中间值|while(low < high)

|{

||mid = (low + high) /2; //折半,取中间值||if (key < array[mid]) //如果查找的值比中间值小||high = mid -1; //最高下标向左移动一位||else if (key > array[mid]) //如果查找的值比中间值大||low = mid + 1; //最低下标向右移动一位||else

||return mid; //如果相等则说明mid即为查找到的位置|}

|return 0;

}

二分法查找的时间复杂度为O(logn),显然远远好过顺序表查找的O(n)时间复杂度。

不过由于二分法查找需要有序表顺序存储,所以对于需要频繁插入和删除操作的数据集来说并不适用。

那么我们新的问题又来啦,既然二分法是折二分之一,那么为什么不是折四分之一、八分之一甚至更多呢?

所以我们的二分法查找,是有改进空间的。因此,又引出我们接下来的算法。

插值查找

其实插值查找和二分法查找只有一句代码的差别。插值查找就是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找算法。

在上面二分法查找中:

mid = (low + high) /2; //折半,取中间值

这行代码,我们略微等式变换后可以得到:

mid = (low + high) /2 = low + (high -low) /2

也就是说mid等于最低下标low加上最高下标high与low的差的一半,算法科学家们考虑的就是将这个1/2进行进一步的改进,得到如下的方案:

mid = low + (high - low) * (key - array[low])/(array[high] - array[low]) //插值

因此,插值查找的核心就在于将这个1/2换成了我们的插值函数:

(key -array[low])/(array[high] - array[low])

下面贴上我们的代码:

int BinarySearch(int* array,int length,int key)

{

|int low = 1; //定义最低下标为记录首位|int high = length; //定义最高下标为记录末位|int mid = 0; //中间值|while(low < high)

|{

||mid = low + (high - low) * (key - array[low])/(array[high] - array[low]); //插值||if (key < array[mid]) //如果查找的值比中间值小||high = mid -1; //最高下标向左移动一位||else if (key > array[mid]) //如果查找的值比中间值大||low = mid + 1; //最低下标向右移动一位||else

||return mid; //如果相等则说明mid即为查找到的位置|}

|return 0;

}

显然,跟上面的二分法确实只有一句代码的差别。从时间复杂度上看虽然插值查找的时间复杂度同样为O(long),但对与数据集较大,并且关键字分布又比较均匀的查找表来说,性能要比二分法查找好很多。

讲到这里我要插一句,如果查找的数据集是顺序存储的有序线性表,我们可以用上面的二分法、插值等算法来实现,但是正因为有序,所以在插入和删除操作上就要消耗大量的时间,那么有没有一种不仅插入和删除效率还不错,同时又可以高效的实现查找的算法呢,嘿嘿,还真有。

二叉排序树

讲到树,我们先来温习下它的基本定义。

树是n(n>=0)个结点的有限集,结点拥有的子树数称为结点的度。度为0的结点称为叶子结点,度不为0的结点称为分支结点(又叫非终端结点),除根节点外,分支结点又叫内部结点,数的度为树内各结点的最大值。比如下面这颗树结点度的最大值是D的度,为3。

结点的层次从根开始定义,根为第一层,根的孩子为第二层。树中结点的最大层次称为树的深度(高度)。

OK,下面来介绍我们的二叉排序树。

二叉排序树又叫二叉查找树,它具有以下性质:如果它的左子树不为空,则左子树上所有结点的数值均小于它根结点的值

如果它的右子树不为空,则右子树上所有结点的数值均大于它根结点的值

它的左右子树也分别为二叉排序树

二叉排序树查找最重要的一点就是用到递归的思想,下面来看代码:

//首先我们定义一个二叉树的结构typedef struct TreeNode

{

|struct TreeNode* lchild; //左孩子指针|struct TreeNode* rchild; //右孩子指针|int data; //结点数据}TreeNode,*pTreeNode;

bool SearchTree(pTreeNode T,int key,pTreeNode end,pTreeNode *p)

{

|if (!T) //如果查找不成功,指针p指向查找路径上访问的最后一个结点并返回false|{

||*p = end;

|| return false;

|}

|else if (key == T->data) //如果查找成功,则指针p指向该数据元素结点并返回true|{

||*p = T;

||return true;

|}

|else if (key < T->data)

|return SearchTree(T->lchild,key,T,p); //用递归,在左子树中继续查找|else if (key > T->data)

|return SearchTree(T->rchild,key,T,p); //用递归,在右子树中继续查找}

二叉排序树的性能取决于二叉排序树的形状,可问题在于,二叉排序树的形状是不固定的,所以我们希望二叉排序树是比较平衡的,那么查找的时间复杂度为O(long)。不平衡最坏的情况为斜树,查找的时间复杂度为O(n),等同于顺序查找。

因此我们使用二叉排序树查找,最好是把它构建成一棵平衡二叉树。那么我们来简单了解下什么是平衡二叉树。

平衡二叉树简介

平衡二叉树首先是一种二叉排序树,它满足二叉排序树的性质,并且其中每一个结点的左子树和右子树的高度差至多等于1。我们将二叉树上左子树的深度减去右子树的深度的值称为平衡因子 BF(Balance Factor)。平衡因子只可能是0,-1,1,如果大于1,则说明该二叉树是不平衡的。平衡因子的绝对值大于1的结点为根的子树,称为最小不平衡因子。

至于平衡二叉树的实现,这里不再赘述,除了上面介绍的外,还有多路查找树(B树)、B-树、B+树等等,大家有兴趣可以再去深究。

不知道大家有没有发现,为了查找结果,之前的方法不可避免的是要进行“比较”,但是这是否真的有必要?好了,带着疑问,我们来认识下面这种算法。

哈希表查找

哈希表又叫散列表,就是一块连续的存储空间。散列技术是在记录的存储位置和它的键值key之间建立一个确定的对应关系 f ,每个关键字key对应一个存储位置 f(key)。它是一种直接通过关键字key得到要查找的记录内存存储的位置,避免了繁琐的比较。我们把这种对应关系 f 称为散列函数(哈希函数)。

散列表整个散列过程主要分为两步:在存储时,通过散列函数f(key)计算记录的散列地址(哈希表),按此散列地址存储该记录

在查找记录时,通过同样的散列函数f(key)计算记录的散列地址(哈希表),按此散列地址访问该记录

所以说,哈希表既是一种存储方法也是一种查找方法。散列技术的记录之间不存在什么逻辑关系,只与关键字有关联,所以散列表不具备很多常规数据结构的能力,同样也不适合范围查找。 它适合查找与给定值相等的记录,简化了比较过程。还有一个问题就是散列表冲突问题,我们通常会遇到 key1 != key2,但却有 f(key1) == f(key2),这种现象我们称为冲突。并把key1和key2称为该散列表的同义词。出现冲突会造成数据查找错误,是非常糟糕的。如果没有冲突,哈希表查找使我们介绍的所有查找中效率最高的,时间复杂度为O(1),然而没有冲突的哈希表只是一种理想,实际应用中冲突时不可能完全避免的。对于散列函数的构造方法以及处理散列表冲突的方法大家可以简单了解下,这里不再赘述。

OK,我们的查找算法就先介绍到这里,接下来我们来学习排序算法。

排序算法

在介绍我们的排序算法之前先把我们后面排序需要用的顺序表结构及交换函数写上:

#define MAXSIZE 10;//用于排序数组的最大值,也可以根据需要修改typedef struct

{

|int array[MAXSIZE + 1];//用于存储要排序的数组,array[0]用作哨兵或临时变量|int length; //记录数组的长度}SqList;

void swap(SqList* L,int i ,int j)

{

|int temp = L->array[i]

|L->array[i] = L->array[j]

|L->array[j] = temp;

}

OK,下面来看看我们的排序算法:

冒泡排序

首先,最简单也是最容易理解的排序。冒泡排序是一种交换排序,它的基本思想就是两两比较相邻记录的关键字,如果反序就交换直到没有反序为止。

void Bubble_sort(SqList* L)

{

|for (int i = 1; i < L->length; i++)

|{

||for (int j = L->length-1; j >= i ; j--) //注意 j是从后往前循环||{

||| if (L->array[j] > L->array[j+1]) //如果前者大于后者|||{

||| |swap(L,j,j+1); //交换array[j] 与 array[j+1]|||}

||}

|}

}

咱们可以想一下,这样的冒泡是否还可以优化?如果我们需要排序的数组为{2,1,3,4,5,6,7,8,9},除了前两个关键字需要交换外,其余数据已经是正常的顺序,但是算法仍然每个循环都执行一遍,尽管并没有交换数据。所以如果序列已经有序就不需要继续后面的循环操作了。

冒泡排序的优化

为了实现上面的想法,我们需要改进一下代码,增加一个bool值变量flag作为标记

void Bubble_sort(SqList* L)

{

|bool flag = true;

|for (int i = 1; i < L->length && flag; i++) //如果 flag为 false则退出循环|{

||flag = false; //初始为 false;||for (int j = L->length-1; j >= i; j--)

||{

|||if (L->array[j] > L->array[j+1])

|||{

||||swap(L,j,j+1);

||||flag = true; //如果有数据交换,则 flag为 true|||}

||}

|}

}

经过这样的改进,冒泡排序在性能上有老一些提升。冒泡的思想就是不断地交换,通过交换完成最终的排序,其时间复杂度为O(n2)。但是问题来了,不断的交换是否真的有必要呢?如果我们在排序时只有找到了最合适关键字之后再做交换,减少了交换的次数从而优化我们算法的性能呢。答案是有的,这就是我们接下来所介绍算法的初步思想。

简单的选择排序算法

上面已经介绍了简单选择排序的初步思想,那么我们来看看代码:

void Selection_sort(SqList* L)

{

|int min = 0; //先定义一个最小值变量|for (int i = 1; i < L->length; i++)

|{

||min = i; //将当前的下标定义为最小值的下标||for (int j = i+1; j <= L->length; j++)

||{

|||if (L->array[j] < L->array[min]) //如果有比当前最小值min的关键字更小|||{

||||min = j; //就将此关键字的下标赋给min,保持min是最小的那个|||}

||}

||if (i != min) //如果min与i不相等,则找到最小值,此时再做交换||{

|||swap(L,i,min);

||}

|}

}

可以看出简单选择排序最大的特点就是交换移动数据的次数非常少,也就相应了减少了算法的开销,无论最好和最坏的情况,该算法比较的次数都是一样的。虽然简单选择排序的时间复杂度跟冒泡一样同为O(n2),但是性能上还是略优于冒泡的。

直接插入排序

直接插入排序就是将一个记录插到已经排好序的有序表中,从而得到一个新的记录数增1的有序表。它属于插入排序的一种。

void Insert_sort(SqList* L)

{

|for (int i = 2; i < L->length; i++)

|{ //i从2开始的原因就是我们假设array[1]已经放好位置,后面的数据就是插入到它前面还是后面的问题||if (L->array[i] < L->array[i-1]) //此时我们需要将array[i]插入有序表中||{

|||L->array[0] = L->array[i]; //设置哨兵,为下行循环退出的条件,同时也起到备份array[i]的作用|||for (int j = i-1; L->array[j] > array[0]; j--)

|||{

||||L->array[j+1] = L->array[j]; //将array[j]向后移一位|||}

|||L->array[j+1] = L->array[0]; //第一次循环j=1经过j--后为0加1,即array[1]的位置||}

|}

}

同样的,直接插入算法的时间复杂度依旧为O(n2),但直接插入算法比冒泡和简单选择排序算法的性能要好一些。

希尔排序

希尔排序是直接插入排序的改进,它和直接插入排序同属于插入排序的一种。希尔排序就是将原本大量记录的数据进行分组,分成若干的子序列,然后在这些子序列内分别进行直接插入排序,当整个序列基本有序时,再对全体进行一次直接插入排序。

其中所谓的基本有序,就是最小的关键字基本在前面,最大的关键字基本在后面,不大不小的基本在中间。

比如说,{2,1,3,6,4,7,5,8,9}就属于基本有序,但是像{1,5,9,3,7,8,2,4,6}这样,9在第三位,2在倒数第三位就谈不上基本有序了。

问题是,往往我们分完组后各自排序的方法达不到我们的要求,所以我们还需要一种叫作跳跃分割的策略,将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。

下面来看代码:

void Shell_sort(SqList* L)

{

|int increment = L->length;

|do

|{

||increment = increment/3+1; //增量序列||for (int i = increment+1; i <= L->length; i++)

||{

|||if (L->array[i] < L->array[i-increment]) //此时需要将arry[i]插入有序增量子表|||{

||||L->array[0] = L->array[i]; //先将array[i]暂时存在array[0]中,为下行循环退出的条件||||for (int j = i-increment; j > 0 && L->array[0] < L->array[j]; j-= increment)

||||{

|||||L->array[j+increment] = L->array[j];//将array[j]后移,查找插入位置||||}

||||L->array[j+increment] = L->array[0]; //插入|||}

||}

||

|}while(increment >1);

}

可以看出,希尔排序并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动(即跳跃分割策略)。当然,这个增量的选取就很关键了,在代码中,我们是用 increment =increment/3+1 的方式选取增量的,但是究竟选取什么样的增量才是最好,目前还是个数学难题,至今还没有人找到一种最好的增量序列。希尔排序的时间复杂度为0(n3/2),显然要好过直接插入排序的O(n2)。最后由于是跳跃式的移动,希尔排序并不是一种稳定的排序算法。

有很多年,算法科学家们认为所有的算法都很难突破O(n2),而希尔排序算法的出现,使我们终于突破了慢速排序的时代,超越了时间复杂度O(n2),从此以后,相应的更加高效的排序算法也相继的出现了。

说了这么多,我们排序的大Boos终于要出场了!

快速排序算法

快速排序(quick sort),被列为20世纪十大算法之一,公认的排序算法中的王者。快速排序的的基本思想就是通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可以分别对这两部分记录继续进行排序,以达到整个序列有序。

光从字面意思无法看出它的精妙,下面来看看代码:

void quick_sort(SqList* L, int low, int higth)

{ //low和higth分别为数组的最低和最高下标|int pivot; //枢轴值|if (low < higth)

|{

||pivot = Partition(L,low,higth); //将array[low...higth]一分为二,计算出枢轴值pivot||quick_sort(L,low,pivot-1); //通过递归,对底子表进行排序||quick_sort(L,pivot+1,higth); //通过递归,对高子表进行排序|}

}

这段代码的核心其实就是计算枢轴值pivot的函数Partition(),它所要做的就是在需要排序的数据表中选取一个关键字,然后想尽办法将它放到一个位置,使它左边的数值都比它小,右边的数值都比它大,我们将这样的关键字称为枢轴。

举个例子,比方说我们现在要对数组{50,10,90,30,70,40,80,60,20}进行快速排序,经过Partition(L,1,9)后,数组变成了{20,10,40,30,50,70,80,60,90};并将返回值5给pivot,返回值5表明50放在了数组下标为5的位置,此时我们的数组以50为界分成了{20,10,40,30}和{70,80,60,90}两个部分,而递归调用quick_sort(L,1,5-1)和quick_sort(L,5+1,9)其实就是在对{20,10,40,30}和{70,80,60,90}两个部分分别进行同样的Partition()的操作,直到顺序全部正确为止。

这么说应该不难理解了吧,所以接下来我们来看看这个Partition()函数的代码实现:

int Partition(SqList* L, int low, int higth)

{

|int pivotkey = L->array[low]; //定义一个枢轴值,先用子表的第一个元素赋值给它|while(low < higth)

|{ //从表的两端依次向中间扫描:||while(low < higth && L->array[higth] >= pivotkey)

||{

|||higth--;

||}

||swap(L,low,higth); //如果比枢轴值小,就交换到低端||while(low < higth && L->array[low] <= pivotkey)

||{

|||low++;

||}

||swap(L,low,higth); //如果比枢轴值大,就交换到高端|}

|return low; //返回枢轴所在的下标}

通过代码可以看出,Partition()函数其实就是将选取的枢轴值pivotkey不断地交换,将比它小的换到它的左边,比它大的换到它的右边,枢轴值也在交换中不断的更改自己的位置,直到满足条件为止。

快速排序的时间复杂度为O(long),上面只是最基本的快速排序,其实还是有不少优化空间的。

快速排序的优化

上面所说的快速排序算法的复杂程度取决于选取枢轴的那行代码

int pivotkey = L->array[low];//定义一个枢轴值,先用子表的第一个元素赋值给它

即快速排序算法的快慢取决于选取的枢轴值在整个序列中的位置。当pivotkey大小处于序列的中间位置的时候算法的性能是最高的,如果pivotkey太大或者太小都会影响快排的性能。显然直接选择序列的第一个值作为枢轴值不是最高效的。所以快排的第一种优化方案就是优化我们所选取的枢轴值。

1.优化选取枢轴

刚刚我们说了当我们所选取的pivotkey大小处于序列的中间位置的时候算法的性能是最高的,为了保证我们的枢轴值处于序列偏中间的位置,我们通常会使用三数取中或九数取中的方式。三数取中就是分别取出序列中第一元素,中间的元素,最后一个元素取出来进行比较(也可以随机取出三个比较),取出中间的那个数作为我们的枢轴值,显然比直接用第一个数要好的多,而九数取中也是同样的道理。

2.优化不必要的交换

上面代码中的交换操作,我们也是可以进行优化的,如果将它们都变成替换,通过减少不必要的交换从而提高它的性能。我们来看看它们和之前代码的区别:

int Partition(SqList* L, int low, int higth)

{

| //我们将先上面的代码优化为三数取中:| int m = low + (higth - low) /2; //拿到数组中间元素的下标| if (L->array[low] > L->array[higth])

| swap(L,low,higth); //左端值和右端值比较,保证左端最小| if (L->array[m] > L->array[higth])

|swap(L,m,higth);//中间值与右端值比较,保证中间比右端的小| if (L->array[m] > L->array[low])

|swap(L,m,low); //中间和左端的作比较,保证左端最小| int pivotkey = L->array[low]; //更之前代码不同的是,此时low的值已经是经过三数取中后的值| L->array[0] = pivotkey;//然后需要注意的是,优化交换的前提需要将我们选出的枢轴值先备份一下;|while(low < higth)

|{ //从表的两端依次向中间扫描:||while(low < higth && L->array[higth] >= pivotkey)

||{

|||higth--;

||}

||L->array[low] = L->array[higth];//将之前该行代码的交换改成替换,如果比枢轴值小,就替换到低端||while(low < higth && L->array[low] <= pivotkey)

||{

|||low++;

||}

||L->array[higth] = L->array[low]; //将之前该行的代码改成替换,如果比枢轴值大,就替换到高端|}

| L->array[low] = L->array[0];//当low和higth会合即找到了枢轴的正确位置,此时再将之前备份的pivotkey赋值给它|return low; //返回枢轴所在的下标}

参考文献:

《算法导论》 潘金贵等译 机械工业出版社 2006

《大话数据结构》 程杰 清华大学出版社 2011

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值