目录
目录
A 抽象数据类型(abstract data type,ADT)
D6有序向量:插值查找(Interpolation search)
1-A 抽象数据类型(abstract data type,ADT)
要了解什么是ADT,我们要明确什么是数据结构。
数据结构是数据项的结构化集合,其结构性表现为数据项之间的相互联系以及作用,也可以理解为定义域数据项之间的某种逻辑次序。而根据逻辑次序的不同,可以大致划分为线性结构、半线性结构与非线性结构三大类。本章所介绍的 向量 (vector)和后续要介绍的列表(list)他们是最基本的线性结构,也统称为序列(sequence)。
什么是抽象数据类型 ?
现代数据结构普遍遵从“信息隐藏”的理念,通过统一接口和内部封装,分层次从整体上加以设计、实现与使用。
个人的理解,不对请补充,我是初学者:
意义是十分重大的,意味着许多的从业者,不需要理解底层逻辑,写什么需求的时候不用从0开始,一步步写完,而是可以通过接口,使用被设计者封装好的数据结构,进行一个整体的设计、实现和使用。同时,这些封装好数据结构是面向对象的,也就是说数据集合及其对应的操作可超脱于具体的程序设计语言、具体的实现方式。也就构成了所谓的抽象数据类型(abstract data type,ADT)
抽象数据类型是有其组成要素的,当我们讨论的时候不能离开下面的三要素:
对于数据结构和ADT,邓老师讲了一个很有意思的例子:
对于封装好的数据结构它更像是产品,ADT 是使用说明书,使用者不需要去理解产品是如何被设计生产的,只需要按照说明书,直接用interface(接口)去使用、去完成自己的目的即可。设计者,则需要根据要实现的功能去提供一个个接口,供使用者使用。
下图来说的话,数据结构就是汽车 ,这个汽车是可以直接被使用的,有油门、方向盘等(接口),与此同时设计者会写一份说明书(ADT),告诉你怎么踩油门打方向盘(怎么利用接口)
本章重点要掌握的内容
第一点是,如何实现一个ADT,封装好一个数据结构并且提供接口。
第二点是,如何利用算法,包括搜索和排序等算法去优化我们数据结构并以此提高我们接口的效率,值得注意的是,本章学习的算法十分重要。
向量是怎么来的,是什么?
说向量,一定要说数组。
数组是程序设计语言C/C++/JAVA等,内置的一种数据类型,支持对一组相关的元素的存储组织与访问操作。所谓的数组只是一段连续内存空间,并被均匀的划分,被划分的区域与[ 0 , n) 一一对应。
而向量,就是线性数组的一种抽象和泛化:
各元素的rank 互异,且均为[ 0 , n)内的整数,通过rank可以唯一确定某一个元素,这被称作call by rank 循秩访问。
ADT接口(操作集)
作为一种抽象数据类型,向量对象应支持如下的操作接口。通过这些接口可以完成许多操作,同时也只能通过这些接口进行操作
1.disordered的原理是根据紧邻的逆序对有多少,对应输出其数量。
2.search 的原理:search(10)的时候,会返回不超过十的最大元素的rank,所以返回的是5;
search1(8)同理 , 返回4;
search(4),当有重复元素的时候,会返回靠后的元素的rank。
search(1)找一个比所有元素都小的时候,会返回-1 这个rank,值得注意的是我们认为-1这个秩上面的元素是负无穷。
vector模板类:
ps: 通常,类规定了可以使用哪些数据来表示对象以及可以对这些数据执行哪些操作(c++ primer plus)。那么对于下面的向量模板类,就是在说我们如何去描述一个向量、有哪些操作。对于一个向量,要描述它,我们要有 容量、规模、还有他的地址(数据区),每一个向量都能 构造、析构、扩容expand、遍历traverse 等等(接口与实现)。
下图是模板类的框架:
对于 ,,意思是:我们定义了一个叫vector1的模板类,里面的元素类型的名字是 “ T ”,紧接着我们还会有几个私有的变量:
这个封装好的vector是如何与应用者们交互的呢:
可以看到 vector里面会有 _elem / _size / _capcity / interface 这也对应了我们的模板类的框架。使用者只需要利用interface去实现想要的功能即可。
构造与析构
作为一种数据结构,与所有的类一样,vector需要解决的首要问题就是构造与析构。
构造:
有三种方式/情况:
第一种:只需指明初始的容量,如果不指定 会默认用 DEFAULT_CAPACITY ,这里默认是3.
会通过 new 申请一段长度为 c 的基本类型是T的一段连续空间,并将连续空间的起始地址交给_elem 保存下来。由于目前没有有效的元素 ,所以_size = 0 ;
第二种:如果已经有一以数组的形式存放的数据
第三种:如果被复制的元素不是来自数组而是来自被封装的向量
析构
是构造的逆过程,用于释放数据空间类似于malloc的free操作
从构造和析构可以发现,copyfrom这个接口用处很大,那么他如何实现?
copyfrom的实现
将数组中的区间复制到向量的数据空间_elem中,首先进行数据空间的初始化,分配一个大容量,是两倍的(hi-lo),这样可以在以后想要扩容的时候不打断计算过程,并且将数据空间的起点交给_elem,同时清零规模。最后用一迭代循环逐一复制。
1-B可扩充向量
向量是数据结构,可以看成是数据项的集合,也就是说它也要有可以自动扩容自己容量的能力。而从向量的构造析构来看,它不具备这种能力。它采用的是静态空间管理策略:
静态空间管理主要有两个不足:
那么我们现在的需求就是,将静态的空间管理改变成动态的空间管理,让数据空间可以根据需要进行自动扩容。那么这种动态空间管理策略,有点类似于以前学的可变数组,模仿蝉的脱壳。
不难有扩容算法expand()
这个比较关键,设oldelem,记录原数据空间的起点,以保存原数据空间。
好处在于封装之后会更安全。数组就是指针,指针变量的本质是值,不过是地址的值。这个扩容的思路可以直接用于数组的实现:在于删除原数组,更替为更大的数组,而重新分配的数组的地址是操作系统分配的和原数据区没有直接的关系,如果直接引用数组,会导致共同指向的原数组的其他指针失效,成为wild pointer野指针,而经封装为向量之后,可以避免这一情况。
PS:
可以看到,这个扩容算法的扩容策略是容量翻倍,这样可能就会导致空间利用率也就是填充因子不太高,如果我们递增式的扩容会如何呢?
将原扩容算法,做轻微修改,使得每次即将发生上溢的时候
让容量递增 INCREMENT 简单记成大I.
从上图可知递增式扩容每次追加 I 的容量,间隔相同,是算术级数的形式。那么时间复杂度为末项的平方: O(((m-1)I+1)^2) = O(n^2),分摊到每次扩容的时间复杂度则是O(n)。效率就很低下,而倍增式扩容表现如何?
两种策略对比,为什么效率会差别这么大呢?
上图可以看到,倍增式的扩容只需要做紫色部分即可,意味着它copyfrom的次数少,循环的次数少,而递增式扩容则每次发生上溢都要扩容,每次扩容都要copyfrom 大大消耗时间,所以它的效率极低。但是,就空间利用率来说,递增策略更胜一筹,只不过比起它在时间的劣势来说则不值一提了。
上述用到的分摊分析的方法看起来很像是平均分析。但是有本质区别需要注意,重点就在于有无根据概率分布将成本加权平均。
1-C无序向量
小结:
在之前的内容里面,我们学习了如何去定义一个数据结构,以向量为例子。
首先以template引导:
括号之间 要有 “ typename ”,紧接着括号加我们定义的数据结构的名字 ,这里是 vector;
当然还包括,vector的实现(接口啊、底层的实现逻辑等) 这里最重要的是,typename所定义的模板参数 “ T ” , 这个参数的作用是指定 vector 这个结构中所有元素的类型。这样的一个操作,与其说是定义了一个 vector ,其实是定义了一系列的vector;
这样定义好了vector这一模板类,在使用的时候,我们就可以灵活的指定 我们自己的vector的数据类型。
定义好后,我们这么去使用:
括号可以填上我们想要的类型,就可以表明“ myvector ” 是由一系列的整数/浮点数/字符组成的向量。也可以理解成 a vector of ints/floats/chars.
除了这样的基本操作之外,在后续,我们可以利用这个形式构造出更为复杂的数据结构。后续要学二叉树,比如将树状数据结构也可以作为类型放进方框内,意为一系列二叉树组成的一个线性序列sequence。
回到本节的主题:无序向量,这个是向量的最基本的形式。要明确的是,无序向量,不见得一定是其中的元素无序甚至可能其中的元素无法排序。在这个前提下,我们要学会如何定义并实现统一的操作接口。
循秩访问
虽然向量给出的操作集中,我们可以
但是,这种方式还是太不方便了,有没有一种办法可以像我们使用数组的那样只需要对应的下标就可以读写数组中对应的单元呢?如果有的话,类似的我们只需要知道 “ 秩 ” 就可以读写对应元素,那么这种方式就叫做 循秩访问
方式是有的,只需要重载运算符即可。
返回类型是 T & 关键字operator 加上要重载的符号[ ] 参数表中有相应参数(秩),返回对应秩的元素即可。返回类型是 T & 是为了能让v[ i] 作为左值参与赋值运算。
插入
插入的算法,关键在于,在某一rank 里即将插入的元素得给它一个位置,所以要对原来的元素们做一个后移,同时要插入的这个rank 的后方的元素 称为它的后继,注意原数据空间 是 may be full 所以需要一个 expand()扩容函数,在每次插入前先检查是否需要扩容。后移的次序是有讲究的,不是从前到后而是从后到前,这样可以有效避免数据的被覆盖。
区间删除
区间删除这个算法最重要就是理解区间开闭,思路在于:删除一个[lo , hi) 区间 ,然后用[ hi , n)区间覆盖,次序是从前到后。算法上图中其实也挺简单的,但是可以自己去模拟算法去理解这个,我自己用的是 0 1 2 3 4 5 。删除的区间选择的是[1,2) , 最终的结果是lo = 5 hi = 6,元素是:0 2 3 4 5
PS:退化情况我个人理解的是这样:如果传入的区间是[ x,x )这种情况就认为这是一种退化的情况,作为一个特殊的情况交给其他的接口去处理。所以lo == hi的时候直接返回0;
单元素删除
算法还是比较简单的,和我区间删除里面模拟算法的那个例子是一样的。删除某一秩对应的元素,那只需要区间删除[ r ,r+1)即可。
反过来想,如果重复单元素删除的过程,删除一整个区间是否可行?和前面扩容算法讨论的倍增容量和递增容量的问题一样,可行,但是时间复杂度会非常高,写出一个低效率的算法那和学数据结构这门课的初衷是背道而驰了。、
查找
要完成查找这个操作,我们首先要有一个基本假设。这个假设也很好理解,所谓查找,那就是要找到符合(相等)参数的一个元素。对于无序向量,它可能是由无法排序的元素组成,比如有int 型的元素 也有 char 型的元素,那么我们对无序向量要完成查找的操作,起码是要能够 “判等 ” 。
对于有序向量,要完成查找,除了可以判等还要可以比较彼此大小,为什么要可以比较呢?其实如果学过二分查找的方法就可以理解了, 主要是为了能够提高效率,可以比较大小的话,可以用二分法等方法去优化DSA。
那么对于无序向量的查找过程,如何考虑?
对于一个[lo ,hi)的区间(注意开闭),我们要寻找的元素是 e;我们会从hi开始从后到前的注意扫描,如果不等于e 则跳过,如果比较完了整个区间都没有找到e ,试图越过最左侧的边界lo的时候,我们就说这次scan是失败的,反之如果找到,则是scan成功。
算法如下:
时间复杂度和区间有几个元素要去比较,第几个元素命中息息相关,所以最坏复杂度是O(n)最好的复杂度是O(1)对于这种最好最坏复杂度差距大的算法我们称为输入敏感的算法(input-sensitive algorithm);
参数表中填入要寻找的元素e,和秩所对应的区间 [lo,hi);紧跟着的const是为了保护return的值不受改变。
while循环结束之后意味着e=elem[ hi ] 或者lo>hi (hi=lo-1)。无论成功与否都会返回hi的值,也是循环停止的位置,也正是这个特性,如果向量中有重复的元素,它会返回秩最大的那个。
去重(唯一化)
把无序向量中重复的元素都剔除掉。
首先要明确的是,我们可以将数据空间分成三个部分,要查找的元素x, 前驱prefix ,后驱suffix;
要完成去重的操作,我们可以通过遍历元素去完成。上一个小节讲的find()函数如果把hi作为要查找的元素 x ,扫描的区间是 [0,x) 那么find函数不就相当于是作用于前驱吗?find的返回值是 hi ,如果扫描成功, 我们只需要remove(hi) 不就完成了去重的操作?
我们要做的是遍历 我们的“ hi ”即可。
将思路转化为算法不难有:(从elem[ 1 ] 开始的原因在于要满足find() 函数)
我们要证明这个算法是正确的;那么按照我们在绪论学习的内容来说,我们需要证明它的不变性 和 单调性。
不变性:
初始的情况: i=1 的时候,遍历的区间是[ 0 , 1)只有一个元素,所以肯定满足互异;
数学归纳法:
当第 i 个的时候也是否满足互异呢?
无非两种情况:
查到第i个的时候前面没有重复的元素,i++ 查找下一元素;都说第i个前面没有重复元素了,所以[0,i ] 也就是[ 0, i+1) 这个区间中也不会有重复元素也就是满足互异;
查到第i个的时候,前面有重复元素,那么在前驱中会remove掉重复元素。也就是说第i 个元素的前驱还是保持互异,满足不变性。
证明完了不变性 ,还要证明单调性,也就是这个算法会不会有停止的时候。我们知道,这个DSA会遍历完数据空间的每一个元素,从i=1走到i=_size,不断递增的过程就是有效规模缩减至 0 的过程,满足单调性。
综上这个算法的正确性是可以保证的。
分析一下它的时间复杂度:
每次迭代的复杂度来源于find 和 remove ,可以看到的是find针对前驱 remove针对后驱,最坏的情况 也就是数组的长度 n 那么复杂度则为O(n),又因为遍历整个数组,那么又总体最坏复杂度是O(n^2)
遍历
遍历是指,对向量中的每一个元素都执行一个事先约定好的操作,这里称为 visit
要明确的问题是:
给出两个传进visit的操作,定义traverse接口:
方法一:
对于vector类定义一个traverse,参数表里要填入visit函数的指针,这个函数要处理的元素类型是T ,具体功能的实现则是遍历每一个秩,对每一个对应的元素都执行一次visit操作。
方法二:
指定的这个visit是一个函数对象,是用于模拟操作的。接口traverse的参数表填入一个函数对象或者可以理解为一个操作的名字(类似于声明)
基于方法二 看一个 递增实例:
函数对象:以类的方式实现,同时为了简便不做更多封装用了 struct。 有这么一个Increase 重载了( ),要处理的是T类的元素,把这些元素当作(引用)e,对于e进行++的操作。
有了这么一个类之后,利用向量的travese接口 写一个 increase 调用函数。
increase 参数表中是要进行加一的向量,功能的实现则是利用 . 运算符 调用向量V的traverse接口进行遍历加一操作。
本节有序向量的相关内容 主要是 无序向量中接口实现的推广。比如唯一化的接口在有序向量是如何实现的?查找的接口是如何实现的等。
D1有序向量-唯一化
有序性
有序向量是相比无序向量而言的,我们要求无序向量可以比对,也就是判等;而有序向量则要求可以比较,除了判等还能比较它的大小。当然除了能比较大小以外,有序向量还要求它的元素确实是顺序排列的。
那么我们就要能判断向量是否有序。
基于上图的理解,也将逆序对的数量,作为度量序列的逆序性。所以我们会有一个disordered() 接口 要求返回逆序对的数量。
算法如下:
思路也不难,主要是比较相邻的元素。
如果遇到无序向量,且可以进行比较操作。那么我们应该对它进行顺序操作,这样可以优化相关的算法,比如查找等。
有序向量的唯一化(低效)
此前我们对于无序向量有了deduplicate()算法,那么为了区别,有序向量的去重算法命名为uniquify()。
算法思路:
要明确如果是有序向量,必然满足以下特性。
那么我们只需要逐一进行比对操作不就可以了,如果相邻的元素相等,就把后一个的元素remove掉即可,不相等的元素保留。那么我们总会得到下图的结果
兑现为算法:
这个i 是严格小于_size - 1并从0开始遍历,保证它循环到结尾的时候 i = _size - 2 ,这样就可以遍历完整个数据空间。
分析一下时间复杂度。如果有这么一个最坏情况的有序向量:
数据空间全是相同的元素,这意味着我们要进行n-1次的remove(), 而且 remove()接口是跟后驱数量息息相关,那么综上 整个时间复杂度有 : n-2 n-3 n-4 ........1 如下图。
是一个算术级数,那么时间复杂度就是末项的平方,O(n^2)。显而易见,这个算法过于低效了, 那么我们有没有更高效的办法呢?
分析一下,这个算法之所以低效,是在最坏情况下,每次比对都会remove,而remove的操作次数和后驱数量挂钩,如果我们可以不一个一个元素删除 比对,把相同的元素都聚在一起一次性删除,那么就可以大大提高效率。依照这个思路我们有:
有序向量-唯一化 (高效)
我们会有一个i j 他们一开始是相邻的,随后j 会不断的递增直到elem[ j ] != elem[ i ];那么就会有一个不变性( i , j )区间中全部都是与i相同的元素。
为了更好的理解这个算法,我们用这个算法考察实例:
那么这个算法的时间复杂度是什么:
j 会遍历整个数组,所以遍历的时间复杂度是O(n),而每次迭代的时候如果发现不同的元素会进行copy操作,只需要O(1)的时间,所以整体来看时间复杂度 仅为O(n);
D2有序向量- 二分查找(A)
概述
在之前无序向量关于查找的内容,已经提及过。可以比较大小的有序向量在执行查找的操作可以被优化,不用遍历查找。那么本节主要讲的就是二分查找的内容,同样的为了和无序向量区分,我们命名为search(T const & e,rank lo,rank hi),可以看到接口的类型和无序向量的也是一样的。
那么这个search 算法我们希望他能返回什么样的值,需要从语义出发,给search函数提要求,并根据要求进行功能的实现。
对这个search函数 我们有一个基本的要求:
基于这些个要求,我们有一个默认的约定:
也就是用search函数的时候,我们希望它能返回 不大于e的最后一个元素,这样就可以在插入元素的时候 维护自身的有序性。
根据这么些个要求,我们有了二分查找(A)的思路了。
值得注意的是,这个A版的算法只是为了说明原理,它实际上还不能真正彻底的满足语义,比如在search 的 e 小于区间里面的所有数的时候,查找失败,会返回 - 1 这实际上不满足语义,若按照语义 应该返回 lo - 1;
二分法的思路
二分法的思路主要是 将区间[ lo , hi )分割成三部分, 以S [mi] 为 界限。
然后用e 和 x 作比较 并分情况处理即可。
当然, 上述的思路还不能解决有重复元素的有序向量 如何查找的问题,即如果有多个解的时候,该返回谁的秩。这是后面会解决的问题,我们现在只要求理解二分法的核心。
具体实现
这里要说的是 在 e 和 A[ mi ] 比较的时候,更推荐用小于号,因为这样可以更直观的去理解二分法而减少不必要的失误,这种表达方式是一目了然的,e < A[ mi ] 的时候 意味着我们要在 轴点的前半段[lo,mi)进行查找,A[ mi ] < e 很明显就是在 轴点的后半段(mi,hi)查找。而若要完成在前半段查找 直接让 hi = mi, 而要后半段则要lo = mi+1 。至于为什么是这样,观察前半段 后半段 开闭性就可以很明显理解了。
分析一下二分法的时间复杂度:
归纳一下算法,每次迭代总会进行比较,或者比较一次(e < A[ mi ]的时候)或者比较2次(A[ mi ] < e的时候),总之会是常数次O(1)。而每次迭代的时候问题规模也会减半所以总体有递归方程:
,应用在绪论中所学的大师定理得到:
那么时间复杂度就是O(logn)大大优于顺序查找的方法。
邓老师在举了几个实例来说明,主要是为了让大家更好理解紧接着的查找长度的内容。
查找长度
要知道有序向量的查找算法,是有多个版本的。所以除了要会从渐进的角度大体把握住不同算法的时间复杂度以外,我们还需要更细微的评定,具体而言就是要考察logn前的常系数,如我们的A版二分查找的查找长度为O(1.5logn),具体怎么来的可以看书上的推导。
可以从下图的这个实例大概把握一下如何算查找长度:
假设有七个元素(实际上元素数量是多少无所谓,只要是非降排列即可),从这个个例里面体会这n个元素的情况是如何。下面框内的数字意思是经过判断的次数。虚框表示查找失败的情况 实框是查找成功的情况,不理解可以体会一下上一节邓俊辉老师在分析复杂度的时候举的实例。
查找成功的情况:
会有七个情况:查的e 是 0/1/2/3/4/5/6 号元素,那么不难有次数集:(4,3,5,2,5,4,6)
那么查找长度 = 29/ 7 = 4.14 ≈
查找失败的情况:
同理,有八种情况,次数集:(3,4,4,5,4,5,5,6)
查找长度 = 36/8= 4.5 ≈
邓老师说随着向量的更大 ,习惯于将 7+1 写成log(2,8), 我倒是觉得这样可能没那么直观,毕竟复杂度就是logn,是7还是8 取决于元素的数量。
D3. 有序向量:Fib查找
Fibseach 思路
尽管binsearch()在渐进的意义上可以保持住O(logn)的时间复杂度,但是当我们分析查找长度的时候,注意到O(1.5logn) 其实仍有改进的空间,那么究其原因在于,binsearch的左右转向需要的次数(关键码比较次数)是不同的,往左转只需要一次,而往右转是两次,而左右两边要处理的元素(递归深度)却是一样。
改进的思路其实就呼之欲出了:
① 调整切分点的位置,或者说 mi的位置,使得前后区域的宽度改变,往左转的比较次数只有一,那么能者多劳,我们就让前面的区域更宽,让它多干点活。这就是待会要讲的fibsearch的实现思路。
②左右转向次数不同,那么统一其比较次数,均为一次。这个是后续binsearch (B version)的思路。
本节重点讲①的实现和思路。
上图中讲的区间在[0,fib(k)-1), mi的点 其实相当于位于rank : 0+fib(k-1)-1。 这个思路听过老师讲课的应该都比较清楚,binsearch 和 fibsearch 的区别在于 mi 这个切分点的选取,fibsearch其实就是把mi的点设在fib(k-1)-1 rank上面,第k-1个fib数 再减一.
Fibseach实现(详细)
重点讲如何确定mi的位置。引入Fib类之后创建一个fib对象,参数表里面是(hi-lo) 也就是向量中的元素数量n。如果我们的元素数量n 是 7 ,那么会创建一个数列(fib数列的前n-1项):1 ,1,2,3,5,8
fib.get() 会返回对象fib的最末项,fib.prev()会往前走,具体怎么实现我也不懂,不过意思是这样,我个人理解是会删除最后一项。那么当hi - lo =7 的时候, fib 会被处理成 1,1,2,3,4,5
那么此时 mi = 0+ 5-1 = 4 .也就是说当有七个元素的时候,且扫描的区间是[0,7) 的时候 切分点位于 rank :4的位置上。
举了这么一个例子之后,相信对于mi位置的确定有大概了解了。
通过实例,也能看出来,fibsearch确实是一个优化的算法,较之binseach的查找长度,均低于。
最后我们论证一下,fibsearch(A)确实在这类通用策略中是最优的方法。首先明确一下,这类通用策略是哪类。
简单来说就是找点的策略,选用的系数λ是多少作为细分的标准。binsearch λ是1/2 ,二分嘛。fib则是黄金分割数:0.618.
那么我们怎么就说fib是最优的呢?说白了最优的话意味着O(Clogn),即logn前的常系数C是最小的同时,常系数会受到切分系数λ的影响,如下图所示,将其表示为α(λ)。这里最优性,是通过数学推导出来的,右式是加权平均得来的,得出的等式进一步处理,用求极值的方法,算得当λ=0.618的时候 α(λ)= 1.44 ,也就是最小值了。这里算法,常系数到1.44就是到顶了,无法再优化了。
D4&5. 有序向量:二分查找(B&C)
B版本的二分查找,主要的改进思路就是使得关键码的转向左右均为一次,判断左边的情况没变,但是判断右边的时候直接将mi点也包含进去。
这个实现的思路关键(或者说理解循环的关键)就是 hi - lo = 1即不断切分至只有一个元素,实际上最终会切分为:[ lo , hi) 。
具体实现(B版)
只要切分的时候,元素数量大于1 就一直切分
然后因为切分的结果 其实是[ lo,hi) ,而且元素数量只有一个,那么这个命中的元素 其实就在rank lo 上面,所以return lo。
考虑语义,这也是引出binsearch C版的一个原因。
查找失败之后,我们均是返回 -1 ,没有兑现 接口的语义约定。这个约定 有助于我们后续其他的算法的实现,比如最基本的维护。
只有兑现约定,在用insert 的时候,才可以继续维护有序向量的顺序性。
主要要完成下面这两个。
具体实现(C版)
B版和C版最大的区别在于,循环的条件和 进入右边的区间。B版循环至只有一个元素,C版循环至0个元素;B版本进入右边区间的时候包含mi,区间是:[ mi , lo ),而C版 区间是(mi,lo) 。
接下来探讨一下,这个做法的安全性,是否是正确的?
正确性验证
同样的验证单调性和不变性,
下图的 (a)(b)(c) 很好的验证了不变性。a->b 的情况 和a->c的情况。两侧区间总能分别满足不变性。那么不断切分的最终结果其实就有这么一个情况,区间宽度变为0(lo=hi),是一条严格的分界线。那么此时对应的,lo-1 即可满足语义,要么小于e 要么=e。
D6有序向量:插值查找(Interpolation search)
思路
插值查找可以说也是一种二分查找,但不同的是,二分查找是问题规模n的折半,而插值查找是字宽折半(后续分析复杂度会说明)。
使用插值查找的时候先有以下的假设。
均匀和独立的随机分布如下图,具体意思是比如 秩是0 ,那么这个元素的取值 要在 1 到 50 之间(大概大概),同理 那么 秩是2 , 元素取值则在 50~100。 这就是我们判断有序向量中 元素的随机分布是不是均匀且独立。
那么对于这种均匀分布的元素 有:(注意这里的hi不是哨兵,指向的是末项元素,秩是n-1)
意味着我们可以以一种更先进的方式 不是粗暴的二分 or 斐波那契数来确定 我们 rank mi 的所在。
实例:进一步说明
先看一下,实例中的元素是否算是均匀分布。十九个元素, 最大元素数值是92 , 那么区间宽度大概大概可以认为是100 /20=5,那么总体来看 ,也算是满足均匀分布,具体怎么取值还是蛮简单的这里不赘述。
这里面的具体实现即代码应该是什么样子的呢?我自己写了一个,主要是将二分查找(A)版改了一下,循环条件 因为现在hi 不是哨兵了,写成 lo<=hi 。
然后 mi=lo+(hi-lo)*(e-A[lo])/(A[hi]-A[lo]),然后 if(A[mi]<e) hi=mi-1. 但是这个查找还是有它自己的问题的,我想这也是为什么邓老师没有写出C++版的算法的原因。比如在处理 [ 5 ] e=5;这个问题中 , 可以知道 A[ lo] = A[hi] = 5 ,他们的差值也就是0,那么就会导致 执行 mi = ..... 这一行的时候会报错 (无法除以0)这是因为C++ 太靠近底层了它不会处理这种除以0的情况。
插值查找的性能分析:
插值查找最好的情况是一下命中 或者 是稍下命中,最差的情况则是如同顺序查找一般为O(n);那么这里我们关注它平均的情况,来分析它的时间复杂度来了解它的性能。
从数学的角度分析时间复杂度:
首先要接受下图的说法,在书本的解析里面有详细证明。
那么在足够多次的情况下,问题规模会不断退化直至为一个平凡的情况,我们认为宽度会是 2 即[lo,hi]的长度为 2,此时再取一次mi就可以知道是否命中。
<2 意味着得出结论。
这个推导的过程应该都一眼能看明白,简单来说就是 两边都进行 log 以 2为底 的对数处理 有:
one more time :
log1-log2 = -1 有:
也就是说 复杂度为O(log logn)
从字宽折半的角度分析复杂度:
数学的足够精确,但是确实有点耗脑,我们希望可以从一个本质的情况 大概的去把握它的时间复杂度。
对于n 来说 按照二进制打印出来的位宽 是 logn (隐含2为底)
那么使用插值查找的方式会变为
也就是 它的位宽 折半了,同理有下图即插值查找实际上是位宽不断折半的过程,相当于是对位宽进行二分查找。
那么二分查找的时候我们知道它的复杂度是根据初始的问题规模决定的,若问题规模为n 则 复杂度为logn ,那么位宽初始是logn ,复杂度有:O(loglogn).
插值查找的意义
虽然数学来看插值查找的速度大大提升了,但是实际应用中不明显:
而且,此前说过,插值查找最差的情况时间复杂度为O(n),这是由于它
同时
意味着查找的成本提高了。所以插值查找算法,需要和此前我们所学的查找算法综合运用,那么我要认识到 插值查找虽然有这个那个的问题,但是它非常善于处理大区间、问题规模极大的问题毕竟它可以一下子将问题规模给开根号了。那么我们可以扬长避短:
E.起泡排序
之前学完了查找的算法,我们注意到,查找的前提是要有序,所以现在开始学习 如何将无序的元素变成有序。
起泡排序的实现可以看看第一章绪论中是怎么写的,这里简单说一下,for循环的条件是
sorted = !sorted,且初始sorted = false。那么每当做一次扫描交换,sorted = false,而且每轮排序必然有一个元素就位(最大的元素会在最大的秩上)。
那么这样的一个情况会导致什么问题?做完初版的起泡排序,会发现它的时间复杂度是O(n^2),可以理解为是扫描了n 次 并且做了 n次扫描交换才有O(n*n)。如同下图中,红色是每次排序的结果,红色部分中元素都是有序的,但是这并不意味着,前面绿色的部分都是无序的。那么这也引出了我们改进的思路。
改进的思路(第一次)
我们需要判断出,前面绿色的部分是有序的,从而尽早结束算法。那么只需要知道在扫描时,前面部分的时候有无进行过扫描交换即可。
代码如下:
很明显看出来可以完成提前结束的需求。
与初版算法比较一下时间复杂度:
原版是,一个不漏,逐个排序 ,复杂度就是三角形 是O(n^2)
经过改进的算法,就有可能会提前结束,是个梯形:
然而,虽然这个算法蛮不错的。但是仍然存在一个改进空间,为什么呢?
反例(初改进的天敌,二次改进的引子)
有这么一组元素,前面绿色部分无序,后面均为有序,可以预见由于前面部分的一直无序,会导致初改进算法不断运行,它会扫描n 个元素 只进行 r 次的扫描交换,这里的r指的是绿色元素的数量,那么此时时间复杂度是O(n*r)。
可以看到实质需要排序的部分集中在绿色部分,如果我们可以只对绿色部分排序 那么时间复杂度会是O(r^2) ,如果r= 根号n,那么复杂度便是O(n) 而已。改进的思路,便是将hi变成绿色部分的哨兵,即r+1。
代码如下:
它的时间复杂度会是一个精细的梯形,由若干个梯形组成。
综合评价
各个版本的算法的效率:
差异只存在于一般的情况,他们是如何处理的。
这里顺便延伸讨论一下,排序算法的稳定性,主要是针对由重复元素的情况。
是否stable看 a b c顺序是否改变。我们之前的排序算法都是稳定的,原因在于发生交换要相邻元素严格逆序( 是> )。
那么最坏情况,起泡排序的复杂度是O(n^2) 太高了,我们希望在最坏情况,排序算法仍能有个比较高的效率 这就是为什么要学习 归并排序(mergesort)
F归并排序
主算法的思路
对于CBA(comparision based algorithm) 比较式的算法,它们都有一个下界Ω(nlogn) ,我们用的起泡排序算法也是CBA,那么有没有一种排序算法可以让它的时间复杂度达到nlogn呢?
那么就是我们的主角归并排序了。
归并排序其实就是一种分治的策略,主要的思想如上图。
那么思路兑现为代码有:
首先先写出递归基,也就是递归的终止条件,即处理至只剩一个元素的时候停止(hi-lo<2)
取出中点,将序列一分为二。 并对前后部分分别进行mergesort
最后归并merge,即可。 那么我们也可以看到,主算法的思路是很明确的,而算法的重点则在于merge 归并算法的实现。
merge算法实现
让我们分部分来解析这个代码,以达到理解记忆:
上图这四行代码,是下图的具体实现,首先A向量将继续保存在它输入的空间里,并以lo作为它左侧的界桩。接下来是B向量,我们需要对他申请一个新的数据空间,数据的区间是[lo,mi) 注意区间开闭,所以这区间的元素数量即区间宽度为lb=mi - lo。申请之后做好复制向量。C向量则是原输入向量的后段,区间是[mi,hi) 以mi 作为 左侧的界桩,lc = hi-mi 。
上图的代码,就是实现比较取值,取较小值。也是归并算法 merge 时间复杂度的大头所在,i j k 依次对应向量A B C . 循环的条件是
或运算所连接左右,两边同时不满足(为0)的时候,循环终止 ,即j 和 k 均越界的时候,循环终止。对于循环体中两个紧凑的if 语句的记忆 重点在于:
第一个if 语句 是取 B向量元素较小,先取B向量,那么 j 不能越界,同时
C[ k ] 不小意为 C[k] >= B[ j ] 。注意一点,”C[ k ]已无“ k 要越界,要么等于哨兵lc 要么大于。
第二个语句同理,理解记忆即可。
复杂度分析
归并的时间复杂度
上图很清楚了 for 循环的 时间复杂度必然是O(n),即归并的复杂度是O(n)
那么把目光回到主算法中,不难有这么一个递推式:
运用大师定理 可解得 O(nlogn)
G. bitmap位图
这个是很有意思的数据结构,它的优势在于时间复杂度极低,做test (判断是否存在)、set(置1)等等只需要O(1)的复杂度。同时占用内存极低,统计二十亿的数据 如果直接保存用int ,会占用20*4 亿字节 需要 8g内存,而使用bitmap,把每个数据都以bit的形式保存,则是20亿bit = 20/8 亿byte 大概为 250兆,极大节省内存。
看完邓老师的视频之后,有个初步的了解后可以看这个:
手撕BITMAP和 C++ 数据结构BITMAP 的完整实现
会有助于这个学习的理解。
这里呢我以 手撕BITMAP 中的内容和邓老师的内容两者结合,以邓的算法为主,作为一个记录学习:
首先要明确,C++是不会以bit作为基本单元去操作的而是以byte 字节,这也是为什么邓的数据结构中,要以char 类型 新建数据空间, 一个字节是八位 ,意味着 可以判断0~7 是否存在,若有置1,说明存在 。
邓公的主算法写成:
补充说明的是:memset 是 初始化位图,使得全部置零;邓的k 指的就是 手撕算法中的 index
重点看看算法中 set clear test的操作如何实现。
这里先以手撕bitmap中的基础概念出发:了解一下k/8 k%8 的实际效果 。当然手撕算法的视频也说得很清楚了,M数据空间 其实就是 byte数组,主要如下:
不过注意的是,手撕算法的视频用的是1 来移动位置,实现左移,邓的算法用的0X80 即 二进制的1000000,进行右移,为什么呢?这其实是因为邓跟视频的作者的数组方向不同 , 上图中 视频博主是从右到左 byte [0] byte [1] ,而邓的如下图,从左到右 byte 0 byte 1。
解释完这些基础之后大家应该也知道怎么去利用手撕算法的内容去理解邓的算法了。对于应用的部分这里就不赘述了,主要是要明白set 的作用是置0/1,那么你就会懂为什么是戳戳戳戳戳。
第二章的学习终于结束了,因为本人在职,所以施工文章的速度会下降。。。。
2022.8.8
l