数据结构
线性表
线性表的顺序存储形式,是随机存储的。
所谓的随机存储,又叫做直接访问 这个叫法更符合我们的常规思维,但是考试肯定用有迷惑性的字眼,可以通过下标直接访问到元素的位置,与存储位置无关,时间复杂度永远为O(1),例如数组。存取第N个数据时,不需要访问前(N-1)个数据,直接就可以对第N个数据操作 (array)。
- 将顺序表L的所有元素逆置
- 将a的前一半元素与后一半元素交换。
- 对于下标从0开始的数组 a[i] <-> a[length-i-1]
void reverse(list *a)
{
for(int i=0;i<length/2;i++)
{
swap(a[i],a[length-1-i]);
}
}
- 将顺序表中所有值为x的元素删除
- 用一个变量j记录顺序表值不为x的数字的个数
- 遇到不为x的值将a[j]的值记为a[i],并更新j值
int deletex(list a,int x)
{
int j=0;
for(int i=0;i<length;i++)
{
if(a[i]!=x)
{
a[j++]=a[i];
}
}
return j;
}
- 用变量j记录表中值为x的元素的个数
- 将值不为x的元素向前移动i-j个单位
int deletex(list a,int x)
{
int j=0;
for(int i=0;i<length;i++)
{
if(a[i]==x)
{
j++;
}
else
{
a[j]=a[j-k]
}
}
return j;
}
- 将无序数组中的重复元素去除
- O(n^2)的复杂度
- 开辟一个新的数组存放无重复的元素,扫描一遍原数组,将第一次出现的元素放到新的数组中,判断它是不是“第一次出现”的
bool repeat(int *list,int num)
{
for(int i=0;i<9;i++)
{
if(list[i]==num)
{
return true;
}
}
return false;
}
int main()
{
int a[9]={1,3,3,2,1,4,1,3,8};
int new_a[9];
new_a[0]=a[0];
int j=1;
for(int i=1;i<9;i++)
{
if(!repeat(new_a,a[i]))
{
new_a[j]=a[i];
j++;
}
}
}
- 还有一种用hash表的,是O(n)复杂度的,但是顺序变了。暂时没有找到满意的其他算法。
- 将两个有序表合并成一个有序表(典中典)
- 设置两个指针i和j分辨来遍历两个数组,将较小的放入新的数组中。
- 时间复杂度为O(LA+LB),空间复杂度也是。
void merge(List A,List B,List &C)
{
int i=0,j=0;
int k=0;
while(i<A.length&&j<B.length)
{
if(A[i]<B[i])
{
C[k++]=A[i++];
}
else
C[k++]=B[j++];
}
while(i<A.length)
{
C[k++]=A[i++];
}
while(j<B.length)
{
C[k++]=B[j++];
}
}
发现自己对于有角标的计算一直不清楚
首先,要算到一半的那一种 就是 i<长度/2 也就是 1<=角标/2 (因为你的角标是从0开始的,所以少了个1)只要搞清楚这个就可以迎刃而解。
当然,最好自己全都从1开始角标,就不会有那么多破事要考虑。
- 将a1a2a3…anb1b2b3…bm的数组转换成b1b2b3…bma1a2…an
- 在不借助辅助数组的情况下,我们可以
- 将整个数组颠倒 得到bmb(m-1)…b2b1ana(n-1)…a2a1 reverse(0,n+m-1) (下标)
- 将前面m个元素颠倒得到b1b2b3…bm reverse(0,m-1) (下标)
- 将后面n个元素颠倒得到a1a2…an reversse(m,m+n-1) (下标)
void reverse(int *list,int left,int right)
{
int mid=(left+right)/2;
for(int i=0;i<=mid-left;i++)
{
int temp=list[left+i];
list[left+i]=list[right-i];
list[right-i]=temp;
}
}
- 现在有两个等长的升序序列A和B,要求找到A和B连接后的中位数
-
myidea:将两个序列拼接,找到合并序列的中位数 时间复杂度 O(n),空间复杂度 O(n+n)
-
更好的方法:
-
分别找到A和B的中位数,分三种情况讨论:
-
-
如果a==b,那么它就是整个序列的中位数,结束
-
如果a<b,说明A整体较小。将a前面部分的元素去掉,b后面部分的元素去掉(记得两序列中去掉的元素个数应当相同)如果两个序列的长度是偶数,那么A要去掉a以及它之前的元素,B去掉b之后的元素即可;如果两个序列的个数是奇数,那么就去掉a前面的数字,b后面的数字。
-
如果a>b,说明b整体较小,将a后面的元素去掉,b前面的元素去掉,要求两次去掉的元素个数相同。
-
重复1-3,直到两个序列中只有一个元素,较小者为中位数(最坏情况)一般来说,最坏情况就是你循环的条件
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fxVsoAYJ-1673163064407)(D:\QQ\wendangxiazai\MobileFile\IMG_20220706_111228.jpg)]
时间复杂度:O(logn) , 空间复杂度:O(1)
-
int mid(int a[],int b[],int size)
{
int l1=0,l2=0,h1=size-1,h2=size-1,m1,m2;
//确定循环条件:最坏情况就是下界==上界,此时只剩下两个元素,取其小值作为中位数
while(l1!=h1||l2!=h2)
{
m1=(l1+h1)/2;
m2=(l2+h2)/2;
//case 1:
if(a[m1]==b[m2])
return a[m1];
if(a[m1]<b[m2])
{
if((h1-l1)%2==0)//元素个数为奇数
{
l1=m1;
h2=m2;
}
else
{
l1=m1+1;
h2=m2;
}
}
else if(a[m1]>b[m2])
{
if((h1-l1)%2==0)
{
h1=m1;
l2=m2;
}
else
{
h1=m1;
l2=m2+1;
}
}
}
return (a[s1]<b[s2])? a[s1]:b[s2];
}
-
题目描述:
已知三个升序整数数组a[l], b[m]和c[n]。请在三个数组中各找一个元素,使得组成的三元组距离最小。
三元组的距离定义是:假设a[i]、b[j]和c[k]是一个三元组,那么距离为:Distance = |a[i]–b[j]|+|a[i]–c[k]|+|b[j]–c[k]|请设计一个求最小三元组距离的最优算法,并分析时间复杂度。
假设我们在A中的元素为a,B中的元素为b,C中的元素为c,不失一般性,假设a<b<c,如下面数轴所示的情况,
- 寻找a,b,c[k+1]的最小距离。 由于我们的数组时升序的,所以c[k+1]>c[k],那么最小距离保持不变。依旧是2(c-a);
- 寻找a,b[j+1],c的最小距离。 b[j+1]如果小于c,对最小距离没有影响,仍旧是2(c-a);b[j+1] 如果超过c,则这个情况下的最小距离肯定比刚才大,所以最小距离依然没有变
- 寻找a[i+1],b,c的最小距离。 当a[i+1]<c+(c-a)时,最小距离会发生改变。
综上所述,只有在移动三个点钟的最小点的时候,最小距离才有可能发生改变。
算法总体思想:
先得到三个数组中最小的三个元素的距离,然后移动三个数字中最小的那个,观察此时最小距离是否发生变化。
int min(int a,int b,int c)
{
if(a<=b&&a<=c) return a;
if(b<=a&&b<=c) return b;
if(c<=a&&c<=b) return c;
}
int mindis(int a[],int b[],int c[])
{
int i=0,j=0,k=0;
int min_d= 10000000;
int dis=0;
int a_l=sizeof(a);
int b_l=sizeof(b);
int c_l=sizeof(c);
while(i<a_l&&j<b_l&&k<c_l)
{
dis=abs(a[i]-b[j])+abs(b[j]-c[k])+abs(c[k]-a[i]);
if(dis<min_d) min_d=dis;
int mi=min(a[i],b[j],c[k]);
if(mi==a[i])
{
i++;
}
if(mi==b[j])
{
j++;
}
if(mi==c[k])
{
k++;
}
}
return min_d;
}
二分查找
- 明确 left right mid 说的都是待查找元素的下标!(也就是左闭右闭的版本 left =0 right =size-1 )
- 左闭右开的版本,left=0,right=length
- 明确循环条件:开个上帝视角,如果我们查找的元素是在这个数组中的,那么最后一次找到它的时候(极端情况)left==right 坚持循环不变量原则
- 如果我们查找的元素不在数组中,比如说是11,那么最后一个left=mid+1的时候,left就会超过right。所以我们的循环条件就是left<=right
- tips:如果没有查找到元素时,right=mid所指的元素就是第一个比查找元素k小的值,left=mid+1就是第一个比查找元素k大的值
- 如果我们要在数组中插入这个没有查找的元素并保持原数组的顺序的话,只要在下标为left的地方插入这个元素即可。
进阶版:寻找目标元素的边界
- 给你一个按照非递减顺序排列的整数数组
nums
,和一个目标值target
。请你找出给定目标值在数组中的开始位置和结束位置。 - 首先确定target值对我们上下界的影响。
- 如果target值小于最小的数字或者是大于最大的数字,那么就不会有所谓的左右边界,直接返回-1
- 如果target值在数组范围内,但是数组中没有target这个元素,那么依旧返回-1
- 如果target在数组中则正常1返回左右边界。
1. 寻找左边界
首先肯定是用二分查找来实现啦,关键就是在查找到target值的时候这个left和right如何移动可以找到边界。
首先来确定循环停止条件。和查找一个元素时一样,由于我们现在用的是左闭右闭,所以循环跳出的条件就是left>right
当我们第一次在数组中找到target的时候,肯定不能直接return,我们此时要更新边界。那么更新哪一个边界呢,这是一个问题。
那我们现在要找的是左边界嘛,左边界肯定我们要把寻找的范围再往左边收缩
所以!!当找到target的时候,我们要把right=mid-1,在新的区间里面再去寻找看看有没有target。此时顺便更新一下left board的值,令right=left board
注意哦,这个left board的值指的是不包含target的值,也就是比target小的第一个值。为啥呢??咱现在分两种情况讨论。
首先如果你找到的这个target不是位于边界状态的target,我们就在小于或等于target的数字里面再去找target嘛,那肯定是不包含我们刚才找到的target
如果你找到的是最后一个target(边界)那你left到right中的值肯定都比target小,根据二分法定义的规则,你的right不会再动了,就是left一直往右,直到超过right的值,跳出循环。
结束了上面的分析,下面,上代码!
int find_left(vector<int>& nums,int target)
{
int left=0,right=nums.size()-1,mid;
int leftboard=-2;
while(left<=right)
{
mid=(left+right)/2;
if(nums[mid]<target)
{
left=mid+1;
}
else if(nums[mid]>=target){
right=mid-1;
leftboard=right;
}
}
return leftboard;
}
2. 寻找右边界
不多分析了,和上面的情况类似
当遇到target=nums[mid]的时候,令left=mid+1 令right board=left.
int find_right(vector<int>& nums,int target)
{
int left=0,right=nums.size()-1,mid;
int rightboard=-2;
while(left<right)
{
mid=(left+right)/2;
if(nums[mid]>target)
{
right=mid-1;
}
else if(nums[mid]<=target)
{
left=mid+1;
rightboard=left;
}
}
return rightboard;
}
3. 综上所述
vector<int> find_range(vector<int>& nums,int target)
{
int left_board = find_left(nums,target);
int right_board = find_right(nums,target);
if(left_board==-2 || right_board==-2) return {-1,-1};
if(nums[left_board+1]!=target || nums[right_board-1]!=target) return {-1,-1};
else return {left_board+1,right_board-1};
}
双指针法在数组运算中的应用
删除元素
-
给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。
不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并原地修改输入数组。
元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。
-
暴力解法肯定是可以解决的,下面要来了解一种可以在O(n)时间复杂度内完成的解法:双指针法(快慢指针法)
-
首先,搞清楚快慢指针的定义
-
- 快指针:表示新的数组中需要的元素。在这一题中也就是值不为val的元素
- 慢指针:指向新的数组中需要更新的位置。
那么我们在用快指针扫描整个数组的时候,当遇到了满足条件的值,就更新slow指向的位置,然后slow指向下一个位置。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iprawgl9-1673163064408)(https://tva1.sinaimg.cn/large/008eGmZEly1gntrds6r59g30du09mnpd.gif)]
int removeElement(vector<int>& nums, int val) {
//双指针法O(n)复杂度
int fast,slow=0;
for(fast =0;fast<nums.size();fast++)
{
//确定更新新数组的时机
if(nums[fast] != val)
{
nums[slow] =nums[fast];
slow++;//更新后指向下一个位置。
}
}
return slow;
}
是的,就是这么的简单!但是记不住的原因就是,没有搞清楚快慢指针的含义!
有序数组的平方
- 题目描述:
- 给你一个按 非递减顺序 排序的整数数组 nums,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。
- 题目分析:
这个题目原来的数组就是有序的,问题在于,进行了平方的操作之后,原来的负数的值会变大。但是这样的变化并没有让我们的数组混乱不堪,我们数组中数字的状态是 大->小->大。 - 这就给我们使用双指针提供了条件:我们可以从数组的两端选取此时数组中最大的数字,把它放到新的数组中(逆向更新,这点就是我没有想到的,老想着怎么确定数组中的最小元素)
- 那么我们可以选用两个指针,指向数组的首段和尾端,比较两端的数字大小,将较大的元素摘下来,放到新的数组中。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v0Gmo7pI-1673163064409)(https://code-thinking.cdn.bcebos.com/gifs/977.%E6%9C%89%E5%BA%8F%E6%95%B0%E7%BB%84%E7%9A%84%E5%B9%B3%E6%96%B9.gif)]
一些小细节:
-
循环的进行条件:i<=j
为什么是<= ?
我们可以想象当i==j的时候,如果此时跳出了循环,那那个他们一起指向的元素不就没有被更新么,漏了一个元素,所以是<=
-
对于i和j的更新时机:
当选取了i所指向的元素,i++
选取指向了j所指向的元素,j–
-
对于新数组的更新:
从后往前更新,这样得到的最后结果才是从小到大。
vector<int> sortedSquares(vector<int>& nums) {
vector<int> new_nums(nums.size(),0);
int k=nums.size()-1;
int i=0,j=k;
while(i<=j)
{
if(nums[i]*nums[i]<nums[j]*nums[j])
{
new_nums[k--]=nums[j]*nums[j];
j--;
}
else
{
new_nums[k--]=nums[i]*nums[i];
i++;
}
}
return new_nums;
}
链表
- 从尾到头输出链表的值
- 抛弃传统地将链表逆置的方法,这次用递归法来实现。
- 当访问一个节点时,递归输出它后面的节点,再输出它本身
- 终止条件:p->next ==null
void print(LinkList *L)
{
if(L->next !=NULL)
{
print(L->next);
}
if(L!=NULL)
print("%d ",L->data);
}
- 对带有头结点的链表L,将其原地逆置
-
解法1:双指针头插法逆置。
-
将头结点L摘下来,将其下一项指向null,变成表尾,然后逐渐更新。
void inverse(LinkList *L)
{
Lnode *p,*q;
q=L->next;
p=L;
L->next=NULL;
while(q!=NULL)
{
p=q;
q=q->next;
p->next=L->next;
L->next=p;
}
}
- 解法二:三指针法
- 头插法只有一个工作指针p指向一个操作对象——被摘下来的结点,以及存储其后继的指针r。而三指针法有两个工作指针,即一对工作指针,以及存储其后继的指针r,共计3个指针。考虑如下一般情况,
- 指针pre和p分别指向两个结点,将其看作一对结点,它们是每次循环操作的对象。循环中,让N2的后继指向N1,即完成了(N1,N2)的逆置。之后三个指针进一,pre=p,p=r,r=r->next,重复上述逆置操作,链表变成了下图。
显而易见,如果指针r!=NULL
,则循环还要继续下去,若r==NULL
,循环结束,链表逆置完成,而指针p指向逆置后的一个元素。
void inverse(LinkList *L)
{
Lnode *pre,*p,*r;
pre=NULL;
p=L->next;
if(L->next==NULL)
{
return;
}
r=p->next;
while(r!=NULL)
{
pre=p;
p=r;
r=r->next;
p->next=pre;
}
L->next=p;
}
- 有一个带有头结点的链表L,请设计一个算法使其元素递增有序。
-
始于直接插入排序的方法完成任务
-
算法思想:
-
- 将数组分为两个部分:已排序部分和待排序部分。
- 一开始,已排序部分为NULL,未排序部分为整个LinkList
- 每次取出待排序部分的第一个元素A,与已排序部分逐个比较,找到第一个比A大的元素B
- 将A插入到B的前一个位置,在链表中需要注意,如果B元素是原链表的第一个元素,则要将链表头指向插入的元素。
Linklist * sortlist(Linklist *L) { Lnode *p=L->next,*pre; Lnode *r=p->next; //r是p的后继节点 p->next =NULL; p=r; //把链表中的第一个元素摘下来形成一个新的,待插入的链表 while(p !=NULL) { r=p->next; //保存p的后继节点 pre=L; while(pre->next != NULL && pre->next->data <p->data) //如果当前的节点的值小于待插入节点的值,就一直向后寻找,直到找到下一个数字比p—>data大的结点(方便后插) { pre = pre->next; } //将p节点插入到找到的节点后面 p->next=pre->next; pre->next=p; p=r;//p转向下一个结点 } }
- 在线性时间内找到两个链表的公共结点
- 我们知道,在链表中如果有公共结点的话,那么这两个链表一定是形如 "Y"型的,因为每个结点的next只能指向一个节点,所以不可能出现 ”X“型的两个链表。
- 可以这样想。如果两个链表的尾结点是同一个的话,那么这两个链表一定是有公共结点的(不管这个公共结点是在哪里) 所以我们只要遍历两个链表一遍,看其尾结点是否为同一个就好了
- 当然这也延伸出来了一个问题,就是如果两个链表不一样长的话,他们两个是不能同步到达最后一个节点的,所以我们要先计算一下两个链表的长度,比如说A链表比B链表多K个节点,就让A链表的循环指针先走K步,让两个链表的循环指针处在同一个“起跑线上”然后再一同遍历。
Lnode find_common_node(Linklist *L1,Linklist *L2) { int len1=length(L1),len2=length(L2); int dis=0; if(len1>=len2) { Linklist *longlist =L1,*shortlist=L2; dis =len1-len2; } else { Linklist *longlist =L2,*shortlist=L1; dis =len2-len1; } while(dis--) { longlist=longlist->next; } while(longlist !=NULL) { if(longlist==shortlist) return shortlist; else { longlist=longlist->next; shortlist=shortlist->next; } } return NULL; }
-
用链表比较元素的值并删除相应的节点时,可以比较当前元素的下一个元素,这样方便我们的删除,因为在删除元素的时候,需要用到当前节点的前驱节点。
-
单链表去重:对于一个按照严格升序排列的链表,去掉其重复的元素。
- 对于一个按照严格升序的链表,想要出现重复的元素,那么一定是凑在一起的啦。根据5中给自己的小提示,我们当然是要保存前驱结点and比较后续结点的啦
- 对于一个节点,我们用pre来固定住它,用p指向它的下一个节点。因为是按顺序排列的嘛,那你肯定如果有重复的元素,肯定排在它后一个咯,如果没有那就是没有咯。
- 所以如果后一个等于前一个,我们就把p删除,然后p=p->next,如果不等于的话,pre=p;p=p->next
void delete_repeat(Linklist *L)
{
Lnode *pre,*p,*q;
pre=L->next;
if(pre==NULL) return ;
p=pre->next;
while(p!=NULL)
{
if(p->data==pre->data)
{
q=p;
pre->next=p->next;
p=p->next;
free(q);
}
else
{
pre=p;
p=p->next;
}
}
}
双指针法在链表中的应用
- 找出量表中的环
-
当链表的尾结点不指向null而指向链表中的另一节点时,我们称此链表中有环。
-
请你设计一个算法找到链表中的环。
-
思路:设置两个指针,slow和fast。fast指针一次走两格,slow指针一次一格。
-
-
如果链表中有环,fast指针先进入环,并在环中循环,肯定会和慢来的slow指针相遇在环中的某一点。
-
假设环的起始点距离链表头的距离为a,fast和slow的相遇点与环起始点距离为x,设环的长度为r,在相遇时fast指针已经在环里走了n圈,我们可以得到以下式子
a + x = 1 2 ∗ ( a + x + n ∗ r ) a+x=\frac {1} {2}*(a+x+n*r) a+x=21∗(a+x+n∗r)
左边是slow走的距离,右边是fast走的距离(fast一次两格) -
现在要求a,可以得到a=n*r-x
-
此时我们可以;让一个指针在head,另一个指针在相遇点,连个指针同时往前走,当两格指针相遇时,新的相遇点就是环的起点。
Lnode* findring(Linklist *L) { Lnode *fast=L,*slow=L; while(fast!=NULL && fast->next!=NULL) { fast=fast->next->next; slow=slow->next; if(fast==slow) { break; } } if(slow==NULL ||fast->next==NULL) { retun NULL; } Lnode *head=L,meet=fast; while(head!=meet) { head=head->next; meet=meet->next; } return meet; }
-
- 一次循环找出链表倒数第k个元素
-
没看答案前:先遍历一遍链表,知道链表的长度,然后让另一个指针移动n-k个单位!
-
答案巧妙地使用了双指针的思想,先让快指针走k个单位,那么它到链表结尾的距离就是n-k!倒数第k个不就是第n-k个嘛,所以这时候我们再让慢指针开始走,这俩指针一起走,当快指针走到队尾的时候,我们的慢指针也指向了倒数第k个位置,我们只用了一遍扫描就成功完成了任务!
int fink_k(Linklist *L,int k)//寻找倒数第k个元素 { int fast=L,slow=L; while(k--) { fast=fast->next; } while(fast!=NULL) { fast=fast->next; slow=slow->next; } return slow->data; }栈与队列
栈与队列
一些经典应用
- 括号匹配
这是栈的经典应用,自己实现。
bool blacket(char str[])
{
stack s;
init(s);
char x;
for(int i=0;str[i]!='\0';i++)
{
if(str[i]=='('||str[i]=='{'||str[i]=='[')
{
push(s,str[i]);
}
if(str[i]==')')
{
if(!pop(s,x)) return false;
if(x!='(') return false;
continue;
}
if(str[i]=='}')
{
if(!pop(s,x)) return false;
if(x!='{') return false;
continue;
}
if(str[i]==']')
{
if(!pop(s,x)) return false;
if(x!='[') return false;
continue;
}
}
if(s.top!=-1)
return false;
return true;
}
- 栈的表达式求值
首先我们要将习惯的中缀表达式转换成后缀表达式。
后缀表达式的基本形式:左操作数 右操作数 符号
对于这个算法,理论上讲是这样的:
-
初始化两个栈,一个是数据栈,一个是操作符栈。
-
从左到右扫描字符串,下面分四种情况讨论
-
-
如果扫描到数字,将其加入数据栈(这里有一个判断数据是否结束的步骤,number=number*10+number)
-
如果扫描到符号,分两种情况讨论:如果栈非空,栈顶的操作符如果优先级比扫描到的符号高,则要把这个符号先出栈,然后在数字栈中弹出两个数字进行运算。(注意:先弹出的是右操作数),将运算后的结果在放入数字栈中,然后继续比较符号栈中栈顶符号以及扫描到的符号的优先级。直到遇到比它优先级小的符号或者是左括号或者栈空,才可以把这个符号入栈。
如果栈空或者是栈顶元素为左括号,或者是栈顶元素优先级小,则直接入栈。
如果扫描到左括号,直接入栈
如果扫描到右括号,将符号栈中符号弹出并进行操作,直到遇到左括号。(右括号不入栈)
-
当扫描结束之后,依次将符号栈中的符号弹出,并进行运算,直到符号栈空,最后数据栈中得到的结果就是答案。
int calculate (char input[]) { stack data_stack; stack opt_stack; int status = 0;//0接收左操作数,1接收操作符,2接收右操作数 int ldata=0; int rdata=0; int temp; for(int i=0;input[i]!='\0';i++) { if(isspace(input[i])) { continue; } switch(status) { case 0: if(isdigit(input[i])) { ldata*=10; ldata +=input[i]-'0'; } else { push(data_stack,ldata); i--; status =1; } break; case 1: if(input[i]=='(') { push(opt_stack,input[i]); status=0; //括号后的肯定是左操作数 } if(input[i]==')') { while(gettop(opt_stack)!='(') { int right=0,left=0,opt=0; pop(data_stack,right); pop(data_stack,left); pop(opt_stack,opt); int result=operate(left,right,opt); push(data_stack,result); } pop(opt_stack,temp); status =1; } if(input[i] == '+' || input[i] == '-' || input[i] == '*' || input[i] == '/') { if(isempty(opt_stack) || gettop(opt_stack)=='(') { push(opt_stack,input[i]); status=2; //运算符后的一定是右操作数 } else { if(prior(gettop(opt_stack),input[i])) { push(opt_stack,input[i]); status =2; rdata=0; } else { do { int right=0,left=0,opt=0; pop(data_stack,right); pop(data_stack,left); pop(opt_stack,opt); int result=operate(left,right,opt); push(data_stack,result); }while((!isempty(opt_stack)||gettop(opt_stack)!='(')&& !prior(gettop(opt_stack),input[i])); push(opt_stack,input[i]); status =2; rdata =0; } } } else if(input[i]=='=') { int opt=0; int result =0; do { pop(data_stack,rdata); pop(data_stack,ldata); pop(opt_stack,opt); result = operate(ldata,rdata,opt); push(data_stack,result); }while(!isempty(opt_stack)); return result; break; } case 2: if(isdigit(input[i])) { ldata*=10; ldata +=input[i]-'0'; } else { push(data_stack,rdata); i--; status =1; } break; } } return -1; }
-
汽车轮渡
某一汽车轮渡口,过江轮渡每次能载10辆车过江。过江的车分为客车类和货车类。轮渡规定:1)同类车先到先上船,客车先于货车上船
2)每上4辆客车,才允许上一辆货车
3)若等待客车不足4辆,则以货车代替
4)若无货车等待,允许使用客车上船
请设计一个算法模拟渡口管理
解题思路:
因为题目中有四种不一样的情况,我们需要把每种不同的情况不遗漏且不重复地依次处理。
首先我们考虑来一个循环,表示轮渡还没装满的情况,在这个循环中处理下面的四种情况。
void control() { queue q; //轮渡 queue q1; //客车 queue q2;//货车 int i=0,j=0; while(j<10) { //case1 有多于四辆客车,有等待的货车 while(!stackempty(q1)&&i<4) { dequeue(q1,x) enqueue(q,x); i++; j++; } if(i==4&&!stackempty(q2)) { dequeue(q2,x); enqueue(q,x); j++; i=0; } //case2 客车不满四辆,以货车代替 while(i<4&&!stackempty(q2)&&j<10) { dequeue(q2,x); enqueue(q,x); j++; i++; } i=0; //case 3 没有货车等待,使用客车上船 if(stackempty(q2)&&!stackempty(q1)) { dequeue(q1,x); enqueue(q,x); j++; } //case 4 两种车总量小于10 if(stackempty(q1)&&stackempty(q2)) { break; } } }
-
字符串
KMP算法
- kmp算法的主要思想
- 解决问题:文本串中是否存在模式串
- 主要思想:当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,利用这些信息,避免从头匹配
- 时间复杂度:O(n+m)
- 相关概念
-
前缀:不包含最后一个字符的所有以第一个字符开头的连续子串
-
后缀:不包含第一个字符的所有以最后一个字符结尾的连续子串 后缀也是从左往右读的!
-
前缀表:记录当模式串与文本串不匹配时,模式串应该从哪里开始重新匹配
tip:前缀表为何能用来帮助我们找到回退位置?
前缀表中记录的是 下标 i 之前(包括 i ) 的字符串中,有多大长度的相同前缀和后缀
看一下上面这张图,当扫描到5这个位置的时候,我们发现模式串与文本串不匹配。
此时我们需要比较不匹配位置的前一个位置上前缀表中记录的数值,它是2 (因为最长的相同前后缀子串是aa),那么我们下一个匹配的位置就是2 。当我们找到最长的相等前后缀子串时,那说明前缀的下一个字符就是我们要重新匹配的字符(看它与刚才不匹配的那个字符是不是匹配)。由于我们的数组下标是从0开始的,长度计算是从1开始的,所以,长度的数字刚好就只想了最长匹配前缀的下一个位置的数组下标。前缀表就是这么用的!但是这个前缀表有点不方便的就是 当前字符不匹配时,要去找前一个下标对应的前缀表,所以,在实际应用中,我们使用的是next数组
如何实现next数组?
一般来说有两种主流的方式:
- 前缀表统一-1,next[0]初始值为-1
-
构造next数组->计算模式串前缀表的过程
-
-
初始化
i:后缀开始(后缀是从左往右读的)
j:前缀末尾,同时也代表着前缀与后缀相等的最大子串长度
next[i] :i以及i之前的最长相等的前后缀长度
-
处理前后缀不同的情况
(这里的next数组中的元素是将所有元素-1)
当遇到前后缀不相同的情况,那我们就要向前回退到上一个相同的地方。
由于next[j]记录的是j以及j之前的最长相等的前后缀长度,那么现在s[i]!=s[j+1],我们要找到j+1的前一个元素在next数组中的值。(也就是next[j])
-
处理前后缀相同的情况
如果模式串s的s[i]==s[j+1],说明找到了相同的前后缀,我们应该先把j移动到下一位,同时,将j的值赋给next[i],再将i向后移动一位,继续判断是否相同。
-
void getnext(int *next,const string &s)
{
int i,j;
j=-1;
next[0]=j;
for(i=1;i<s.size();i++)
{
if(s[i]==s[j+1])
{
j++; //长度增加
}
while(s[i]!=s[j+1] &&j>=0)
{
j=next[j]; //回退
}
next[i]=j; //更新next
}
}
- 直接使用前缀表
此时我们对比的是s[i]和s[j],当我们回退时,需要让j=next[j-1]; 其余思想与第一种类似
void getnext(int *next,const string &s)
{
int i,j;
j=0;
next[0]=0;
for(i=1;i<s.size();i++)
{
if(s[i]==s[j])
{
j++;
}
while(s[i]!=s[j]&&j>0)
{
j=next[j-1];
}
next[i]=j;
}
}
如何用next数组匹配?
定义两个指针i和j
i指向文本串起始位置,j指向模式串的起始位置
j的初值为-1(因为在next数组中第一个值是-1),
i从0开始即可
int strstr(string s,string t)
{
int i,j=-1;
for(i=0;i<s.size();i++)
{
while(j>=0&&s[i]!=t[j+1])
{
j=next[j];
}
if(s[i]==t[j+1])
{
j++;
}
}
if(j+1==t.size())
{
return (i-j);
}
return -1;
}
至此,我们的kmp算法就完成了,在实际编写代码时,我发现我们需要先处理 ”比较元素不同“的情况,然后再处理”比较元素相同“的情况,代码才能正常运行(具体原因未知),自己在编写代码时需要注意这一点
二叉树
二叉树的遍历-非递归方法
我们的教材中,着重介绍的就是递归方法,因为递归方法的代码量少,逻辑上便于理解,但是递归算法需要占用大量的空间,所以我们也要注重对非递归遍历方法的理解掌握。
上一章栈的学习中,我们可以知道,递归算法转换为非递归方法,需要用到栈的方法这里也不例外。下面依次介绍三种遍历用非递归方式实现的算法
前序遍历
前序遍历的顺序是 根左右 每次先处理的是中间节点,然后处理左、右节点。
在前序遍历中,由于待处理的节点与待访问的节点是同一个,所以前序遍历是三种遍历中最简单的。
那么在入栈的时候,我们需要先将待处理节点入栈,再出栈,然后将它的右孩子入栈,然后是左孩子,因为这样子在出栈的时候才是 根左右 的顺序
void preOrder(treenode *root)
{
stack<treenode*> st;
if(root==NULL) return;
st.push(root);
while(!st.empty())
{
treenode *node = st.top();
st.pop();
cout<<node->val<<endl;
if(node->right) st.push(node->right);
if(node->left) st.push(node->left);
}
}
法二:
为了解决处理节点和遍历到的节点不是同一个的问题,我们引入了一个标志机制,即如果这此访问是第一次来,就先将此节点出栈,然后根据想要的队列相反的顺序入栈,这样输出时就可以得到我们想要的顺序当刚才访问的节点入栈时(本次的根节点),我们在其后再入栈一个空指针,这样当我们出栈遇到空指针时就知道这次不是处理函数。而是要输出了,我们将空节点出栈后此时的栈顶元素就是我们想要的元素。
vector<int> preorder(treenode *root)
{
vector<int> result;
stack<treenode *> st;
if(root !=NULL)
st.push(root);
treenode *node;
while(!st.empty())
{
node=st.top();
if(node!=NULL)
{
st.pop();
if(node->right) st.push(node->right);//右
if(node->left) st.push(node->left);//左
st.push(node); //中
st.push(NULL);
}
else
{
st.pop();
node =st.top();
result.push_back(node);
st.pop();
}
}
}
//注意入栈的顺序要和想要的顺序相反!
中序遍历
中序遍历时,我们的顺序是 左中右 对于一个要中序遍历的二叉树来说,我们的待处理节点与待访问节点不是同一个,所以处理逻与前序遍历有一些不同,需要有一个辅助指针帮我们指向最左边的孩子,将这“一路上”的节点全都入栈,然后在出栈时再处理根节点
void inOrder(treenode *root)
{
if(root ==NULL) return;
stack<treenode *> st;
treenode *cur;
while(cur !=NULL || !st.empty())
{
if(cur!=NULL)
{
st.push(cur);
cur=cur->left;//左
}
else
{
cur=st.top();
st.pop();
//visit(cur);//中
cur=cur->right; //右
}
}
}
后序遍历
后序遍历的顺序是 左右中 那么我们只需要调整一下先序遍历的顺序,先处理根节点,然后再访问根节点。
如果我们只是单纯地调整更改入栈的顺序,即先将左子树入栈,再将右子树入栈,在出栈时,我们就会得到与后序队列相反的顺序,即根右左 此时我们需要设置一个辅助数组,先存储出栈的元素,然后将此数组中的元素翻转即可。
void posOrder(treenode *root)
{
if(root ==NULL) return ;
stack<treenode *> st;
std::vector<int> result;
st.push(root);
while(!st.empty())
{
treenode *node=st.top();
result.push_back(node->val); // 1.中
if(node->left) st.push(node->left); //3.左
if(node->right) st.push(node->right);//2.右
}
reverse(result.begin(), result.end());//翻转之后的顺序就变成了左右中,变成了后序遍历的顺序
}
二叉树的应用-并查集
并查集指的是只有合并和查找两个功能的集合。并查集的存储结构可以看做的森林,查找某一节点是否在某个集合中,也就是查找某个节点的根节点。而判断两个节点是否在同一集合中,本质上就是判断两节点所在树的根节点是否相同。如果两个节点不在一个集合中,则将两个节点所在的集合合并
-
查找某节点是否在某个节点
-
首先需要知道并查集的存储结构,并查集中的一个集合是一个数组,数组下标代表节点顺序,数组中存放每个节点的双亲。根节点存放的是负值。
-
简单的查找就是根据数组中的元素,将next=arry[i],直到查找到元素为负数的元素,其下标即为根,这样查询的时间复杂度为O(n)
-
如果我们使用路径压缩的方法,在查询的过程中,将沿途上每一个查找的节点的父节点都设为根节点即可,我们可以使用递归的方法实现,也可以利用循环实现。
-
递归写法
int find(int x,int fa[]) { if(fa[x]==-1) { return x; } else { fa[x]=find(fa[x]); return fa[x]; } }
-
非递归写法
int find(int x,int fa[]) { int root=x; while(fa[root]>=0) root=fa[root]; //先循环找到根 while(x!=root)//从要查找的元素开始,一个一个压缩路径,让其指向根 { int temp=fa[x]; fa[x]=root; x=temp; } }
-
-
查找两个节点是否属于同一集合
-
利用查找函数,判断两集合的根是否相同
bool union(int x,int y) { if(find(x)==find(y)) { return true; } return false; }
- 合并两个集合
- 为了加快两集合合并后的速度,我们需要需要将小的树合并到更大的树上。
- 如果把大树合并到小树上,则会延长查找的路径,降低了我们查找的速率
- 如何表示树的高度:树的根节点是负数,其绝对值表示树的高度。
- 简单合并
void union(int root1,int root2,int fa[])
{
if(root1!=root2)
fa[root2]=root1;
return;
}
-
合并优化
void union(int root1,int root2,int fa[]) { if(root1==root2) return; if(abs(root1)>abs(root2))//绝对值越大,树的结点越多 { root1+=root2; //将小树合并到大树上来 fa[root2]=root1; } else { root2+=root1; fa[root1]=root2; } }
线索二叉树的构造与遍历
1.构造线索二叉树
线索二叉树是一种利用二叉树链表中的空指针域指向其前驱或后继的一种数据结构。
对于一棵有n个结点的普通二叉树,其有n+1个空指针域(, n2没有空指针域,n1有一个空指针域,n0有两个空指针域,所以空格指针域一共有2n0+n1个 又因为n0=n2+1,所以2n0+n1=n0+n2+1+n1=n+1)
为了利用这些空指针域,我们构造了线索二叉树,为了更方便地找到树中结点的前驱和后继
结点结构
typedef struct threadnode
{
int data;//数据域
struct threadnode *left,*right;//左右孩子指针
int tagl,tagr; //标记左右指针指向孩子还是前驱和后驱
}threadnode, *threadtree;
中序线索化
线索化的实质:遍历一遍二叉树
在中序线索化的过程中,需要两个运动的指针,一个指向当前遍历到的指针p另一个指向p的前驱指针pre;
当p的left指针域为空时,p->left = pre, p->ltag =1;
当pre的右指针域为空时,将其指向p,pre->rtag =1;
void Inthread(threadtree &p,threadtree &pre)
{
if(p!=NULL)
{
Inthread(p->lchild,pre);
if(p->lchild==NULL)
{
p->lchild=pre;
p->tagl =1;
}
if(pre!=NULL &&pre->rchild==NULL)
{
pre->rchild = p;
pre->tagr =1;
}
pre=p;
Inthread(p->rchild,pre);
}
}
void CreateInthreadtree(threadtree T)
{
threadtree pre = NULL;
if(T!=NULL)
{
Inthread(T,pre);
pre->rchild=NULL;
pre->tagr = 1;
}
}
中序线索树的遍历
//2. 中序线索树的遍历
//2.1 求中序线索树的第一个和最后一个节点
threadnode *FirstNode(threadtree T)
{
threadtree p=T;
while(p->tagl==0) p=p->lchild;
return p;
}
threadnode *LastNode(threadtree T)
{
threadtree p=T;
while(p->tagr==0) p=p-rchild;
return p;
}
//2.2 求某个节点的前驱/后继结点
threadnode *prenode(threadtree p)
{
if(p->tagl==1) return p->lchild;
else return LastNode(p->lchild);
}
threadnode *postnode(threadtree p)
{
if(p->tagr==1) return p->rchild;
else return FirstNode(p->rchild);
}
树、森林与二叉树的转换
利用“孩子兄弟表示法”可以将树转换为二叉树。
对于每个节点,有两个指针域,一个指针域指向其第一个孩子节点,另一个指针域指向其第一个兄弟节点。
typedef struct btree{
int data;
struct btree *firstchild,*nextsibling;
}btree,bnode;
树与森林的遍历
树的遍历包括两种
- 先根遍历:遍历时先访问根节点,在依次访问其子树,其遍历序列与这棵树转换成的二叉树的先序遍历序列一致
- 后根遍历,对于一个节点,先遍历其子树,在遍历这个结点本身,其遍历序列与这棵树转换成的二叉树的中序序遍历序列一致(左子树是子孙,右子树是兄弟,所以对应的是中序遍历)
森林的遍历也包括两种
- 先序遍历森林:先根遍历森林中第一棵树,再遍历第二棵、第三棵…
- 中序遍历森林:后艮遍历森林中第一棵树,再遍历第二棵、第三棵…
哈夫曼树
带权路径长度
从树的根节点到任意节点的路径长度*该节点上权值,称为该节点的带权路径长度,树的带权路径长度则是将树中每一个节点的带权路径长度相加。
对于一颗带权二叉树,其带权路径长度最短的二叉树称为哈夫曼树。
哈夫曼树的构建
(1)首先创建一个空节点 z,将最小频率的字符分配给 z 的左侧,并将频率排在第二位的分配给 z 的右侧,然后将 z 赋值为两个字符频率的和;然后从队列 Q 中删除 B 和 D,并将它们的和添加到队列中,上图中 * 表示的位置。
(2)紧接着,重新创建一个空的节点 z,并将 4 作为左侧的节点,频率为 5 的 A 作为右侧的节点,4 与 5 的和作为父节点,并把这个和按序加入到队列中,再根据频率从小到大构建树结构(小的在左)。
(3)继续按照之前的思路构建树,直到所有的字符都出现在树的节点中,哈弗曼树构建完成。
哈夫曼编码
首先,哈夫曼编码是一种可变长度编码,出现频率高的字符使用较短的编码,出现频率低的字符则使用较长的编码。同时,它是一种前缀码,即表示某些特定符号的位串永远不是代表任何七天符号的位串的前缀。
对字符进行编码:
哈夫曼树构建完成,下面我们要对各个字母进行编码,编码原则是:
对于每个非叶子节点,将 0 分配给连接线的左侧,1 分配给连接线的右侧,最终得到字符的编码就是从根节点开始,到该节点的路径上的 0 1 序列组合,路径长度等于编码位数。
如何从01串变成字符串?
设置一个指针从左往右扫描01串,同时从根节点开始遍历哈夫曼树。遇到0则向左走,遇到1则向右走,直到遇到一个叶节点,此叶节点中保存的字符即为扫描码串所对应的字符。扫描完一个字符后,再按同样的规则重新从根开始遍历二叉树,直到将整个码串都扫描完成。
如何判断一个字符集的不等长编码是否具有前缀性?
判断不等长编码是否具有前缀性,也正是二叉树的构建过程。
刚开始时,树中只有一个根节点。对于每一个给定的编码C,从左往右扫描01串,同时从根节点开始遍历二叉树。遇到0则往左走,遇到1则往右走。
在遍历过程中会遇到以下几种情况:
- 遍历时遇到空指针:直接创建新节点,继续遍历;
- 遍历过程中遇到了叶节点:表明该码串不具有前缀特性,返回
- 遍历过程中没有创建任何新节点:表明该码串不具有前缀特性,返回
- 在处理最后一位时创建了新节点,表明该码字具有前缀特性,继续验证下一个码字。
如果所有码字都通过了检验,则表明该字符集编码具有前缀性。
图
图的存储
下述图默认有N个顶点,M条边
1.无向图的存储
邻接矩阵:用于存储稠密图 空间复杂度:O(N*N) M [i] [j]表示顶点i和j之间有一条边
邻接表:用于存储稀疏图。图的每一个顶点由数据域和指向其第一条边的指针域。每一条边会出现两次,所以其空间复杂度为O(N+2M)
邻接多重表:每一条边由这样的结构域构成:空间复杂度为O(N+M)
mark | ivet | ilink | jvet | jlink | data |
---|---|---|---|---|---|
用于记录是否遍历 | 顶点i | 下一条依附于i的边 | 顶点j | 下一条依附于j的边 | 信息域 |
2.有向图的存储
邻接矩阵:用于存储稠密图 空间复杂度:O(N*N) M [i] [j]表示顶点i指向顶点j的一条边,M [j] [i] 表示顶点j指向顶点i的一条边
邻接表:用于存储稀疏图。空间复杂度为O(N+2M),图的每一个顶点由数据域和指向其发出第一条边的指针域。在一个顶点后连接的边的条数表示该点的出度。计算入度必须遍历整个邻接表。
十字链表:与无向图的邻接多重表类似。
图的遍历
1.广度优先遍历BFS
广度优先遍历是指从一个顶点开始,访问其所有邻接的结点,然后再访问这些结点的邻接结点,知道整张图连接的结点都被访问。类似于树的层次遍历。
如果是在无向图中进行BFS,则需要注意的是,i和j邻接,j和i也邻接,所以需要一个辅助数组visited[ ]来记录图中已经访问过的结点。同时还需要用到队列来进行辅助遍历。
void BFStravel(Graph G)
{
for(int i=0;i<G.vertexnumber;i++)
{
visited[i]= false;
}
initqueue(Q);
for(int i=0;i<G.vertexnumber;i++)
{
if(visited[i]==false)
{
BFS(G,i);
}
}
}
void BFS(Graph G,int v)
{
if(visited[i]==true)
{
return;
}
visit(v);
visited[v]=true;
Enqueue(G,v);
while(!emptyqueue(Q))
{
Dequeue(Q,v);//v是队列中第一个元素
for(w=firstneighbor(G,v);w>=0;w=nextneighbor(G,v,w))
{
if(visited[w]==false)
{
visit(w);
visited[w]=true;
Enqueue(Q,w);
}
}
}
}
BFS的应用——单源最短路径
由于BFS遍历的特点是“从近到远”,所以利用BFS可以得到从给定点都所有与该点连通的点的最短距离.
我们用distance[i]记录从给定点到i点的距离长度,用path[i]数组记录到达i点的上一个点(你是通过哪个点到达i的)
void BFS_min_dis(Graph G,int v)
{
for(int i=0;i<G.vertexnumber;i++)
{
distance[i]=-1;
path[i]=-1;
}
visit(v);
visited[v]=true;
distance[v]=0;
path[v]=v;
Enqueue(Q,v);
while(!emptyqueue(Q))
{
Dequeue(Q,v);
for(w=firstneighbor(G,v);w>=0;w=nextneighbor(G,v,w))
{
if(visited[w]==false)
{
distance[w]=distance[v]+1;
path[w]=v;
visited[w]=true;
Enqueue(Q,w);
}
}
}
}
2. 深度优先搜索——DFS
深度优先搜索与树的先序遍历类似,对于一个节点,尽可能“深’'地访问此节点,直到无法再访问。
算法思想:从给定点v开始,任意访问一个与其邻接的节点,然后再访问与此顶点邻接的未访问过的节点,以此类推。
利用递归算法和栈来实现此过程。
void DFStravel(Graph G)
{
for(int i=0;i<G.vertexnumber;i++)
{
visited[i]=false;
}
for(int i=0;i<G.vertexnumber;i++)
{
DFS(G,v);
}
}
void DFS(Graph G,int v)
{
if(visited[v]==true)
{
return;
}
visit(v);
visited[v]=true;
for(w=firstneighbor(G,v);w>=0;w=nextneighbor(G,v,w))
{
if(visited[w]==false)
{
DFS(G,w);
}
}
}
3. 时空复杂度分析
(1)BFS:遍历时使用了一个辅助队列,O(v)
遍历时需要访问每一个顶点,至少访问一次边,时间复杂度O(V+E)
如果使用邻接矩阵,则时间复杂度为O(V^2)
(2)DFS:遍历时递归调用栈,O(V)
遍历时需要访问每一个顶点,至少访问一次边,时间复杂度O(V+E)
如果使用邻接矩阵,则时间复杂度为O(V^2)
对于一个给定的图,基于邻接表生成的BFS/DFS生成树是不一样的,因为连接表连接在每个顶点之后的顺序是随意的,而基于邻接矩阵生成的生成树是一样的,因为邻接表存储的图,表示方法是唯一的。
图的应用
最小生成树
1.最小生成树的特点
(1) 最小生成树不是唯一的:当图中包含多个权值相等的路径时,会有多个最小生成树
(2)最小生成树的边的权值之和相同:由其定义规定
(3)最小生成树边数为n-1
2.构造最小生成树
构造最小生成树的算法都是基于贪心算法的。
2.1 基于顶点的最小生成树构造算法—Prim算法
- 设置一个顶点集S和一个边集E ,S和E的初始状态都设为空
- 将给定的根节点K开始,构造最小生成树,将K加入顶点集S
- 重复以下步骤,直到所有结点加入顶点集S
挑选权值最小的一条边(X,Y)加入边集E,要求X是S中的结点,Y不是S中的结点。将Y也加入顶点集。
- 得到最小生成树。
由于Prim算法基于顶点,与边的多少无关,因此Prim算法适合在稠密图中使用
时间复杂度O(V^2)
2.2 基于边的最小生成树构造算法—kruskal算法
-
设最小生成树T(S,E)S中包括树的n个顶点,E初始为空集
-
将图的所有边按照权值大小排序。
-
循环以下步骤直到T成为一颗树(E中有n-1条边):
选取权值最小的一条边将其加入边集,如果加入这条边后,T中没有回路,则保留这条边,否则舍弃这条边再在剩下的边中选择权值最小的边尝试。
通常kruskal算法使用堆来对边进行排序存储,这里的时间复杂度为O(logE);在选择边的过程中最坏情况需要将所有边遍历一遍,所以此时的时间复杂度为O(E);总时间复杂度为O(ElogE)由于其复杂度与顶点数量无关,所以本算法适用于图中顶点较而边较少的图
最短路径
最短路径算法基于最短路径这样的特点:两点之间的最短路径也包括了路径上其他点之间的最短路径。
因此我们可以使用贪心算法对这个问题进行求解。
1.Dijkstra算法:求单源带权图的最短路径
首先要构建两个辅助数组:
dis[ ]数组:用来存放单源结点i到其他各节点之间的距离
path[ ]数组:用来存放到达节点i的直接前驱
然后将顶点集V分为两个集合,一个是最短路径顶点集S,另一个是剩余顶点集V-S
算法思想:
(1)初始化:将最短路径顶点集S初始化为{u}(给定顶点),则dis[i] =arc[u] [i]
(2)在V-S集合中找到使得目前dis[j]最小的顶点vj,将其加入S
(3) 修改dis[i] 中i属于V-S的元素。修改规则是,如果原点通过新加入的点到达i比原来的路径更短,则修改成更小值,否则不需要修改。 即dis[j]+arc[j] [k] <dis[k] 则将dis[k]更新为dis[j]+arc[j] [k]
(4)重复(2)(3)直到所有的顶点都包含在S中。
时间复杂度分析:由于需要遍历所有的边,在挑选边的同时还要扫描一次dis数组,所以改算法的时间复杂度为O(V^2)
注:Dijkstra算法无法求解权值中带有负值的路径,因为如果权值带有负值,在新的顶点加入后,很有可能原点到之前加入的顶点的距离会缩短,然而该算法不会修改这些顶点的路径。
再注:Dijkstra算法采用贪心策略进行决策,但是它每次选择的是离源点最近的一条边,prim算法选择的是与确定顶点集合中任意顶点距离最近的一条边,因此Dijkstra算法生成的生成树不一定是最小生成树。
如果想用Dijkstra算法求每个顶点到其他顶点的最短距离,则对每一个顶点都使用一次该算法即可,其时间复杂度为O(V^3)
2. Floyd算法:求每个顶点之间的最短路径问题
Floyd算法基于动态规划的思想。
辅助数组:
distance[][]
:用来储存每个点到其他点的最短距离path[][]
:用来储存每个点到其他点最短距离的路径
算法思想:
将问题分解为两个子问题:
1》找出最短的距离
利用动态规划的思想,对于任何两个点i和j而言,其距离无外乎两种可能:经过k点和不经过k点。令k=1~n则我们可以判断i与j是经过哪个点时得到距离最短,便将其保留下来。即 对于每一个k,distance(k)[i] [j]=min{distance(k-1)[i] [j],distanan(k-1)[i] [k]+distance(k-1)[k] [j]}
2》考虑如何找出对应的行走路线
与其他的算法一样,path[i] [j] 表示从i到j所走的最短路径中最后一个经过的城市。对path进行初始化时,path[i] [ ]=i,当path[i] [j] 对应的distance[i] [j] 发生更改时,同步将此时的k填入path[i] [j]即可
每次的循环迭代就是更新二维数组,所以时间复杂度为O(V^3)
值得注意的是,该算法可以实现带有负数权值的最短路径计算。
有向无环图表述表达式
看书
拓扑排序
VOA网:用DAG图表示一个工程,其顶点表示活动,用有向边<vi,vj>表示活动vi必须在活动vj之前完成,则将这种有向图叫做 顶点表示活动的网络
1. 拓扑排序的定义
在 有向无环图 中,如果
- 每个顶点只出现一次
- 若A在B的前面,则该图中没有从B到A的路径
每一个AOV网都有一个或多个拓扑排序序列。
2. 拓扑排序算法
- 在AOV网中选择一个没有前驱的顶点并输出
- 从网中删除该顶点以及所有以它为起点的有向边
- 重复1.2 直到当前AOV网为空或当前网中不存在无前驱的顶点为止。
根据上述我们可以得知,拓扑排序算法的关键就是要在循环中不断处理入度为0的顶点。
void topusort(Graph G)
{
initstack(s);
for(int i=0;i<G.vertexnumber;i++)
{
if(indrgee[i]==0)
{
push(s,i);
}
}
while(!isempty(s))
{
sort[i++]=pop(s);
for(w=G.vertices[i].firstneighbor;w>=0;w=G.vertices[i].nextneighbor)
{
indrgee[w]--;
if(indrgee[w]==0)
{
push(s,w);
}
}
}
if(i!=G.vertexnumber) return false;
return true;
}
这里使用了队列来暂存入度为0的点。事实上,用栈也可以。我们知道,每次处理的入度为0的结点,不存在祖先或子孙关系,这些变量在拓扑排序中的顺序是随意的。
方法2:基于DFS的拓扑排序实现
DFS在遍历结点时,采用的是递归的方式,如果当前顶点还存在着未遍历的指向其他顶点的边,就会递归调用DFS算法,而不会退出。当我们要退出时,说明该节点没有指向其他顶点的边了,即它已经是当前情况下该路径上最后一个结点。
所以我们在visit方法要退出之时,将该结点加入到另一数组中,在输出时倒序输出存储数组中的数字即可。
由于每个输出的顶点还要删去以它为起点的边,所以改算法的时间复杂度为O(V+E) (邻接表)或O(V^2) (邻接矩阵)
考虑对于任意的一条边v->w 当调用dfs(v)时,有三种情况:
- dfs(w)尚未被访问,则调用dfs(v)后,还会继续调用dfs(w),直到dfs(w)返回后,dfs(v)才会返回。
- dfs(w)已经返回,此时w已经被访问过
dfs(w)已经被调用,但是在此时调用~~dfs(v)~~的时候还未返回- 注:以上第三种情况在拓扑排序的场景下是不可能发生的,因为如果情况3是合法的话,就表示存在一条由w到v的路径。而现在我们的前提条件是由v到w有一条边,这就导致我们的图中存在环路,从而该图就不是一个有向无环图**(DAG)**,而我们已经知道,非有向无环图是不能被拓扑排序的。*
同时如果我们每次在处理时都选择没有后继的顶点,得到的序列为逆拓扑排序。
void DFStupo(Graph G)
{
for(int i=0;i<G.vertexnumber;i++)
{
visited[i]=false;
}
for(int i=0;i<G.vertexnumber;i++)
{
DFS(G,v);
}
tupo[i]=v;//在要退出时才将顶点加入结果数组中
}
对于一般的图来说,若其邻接矩阵为三角矩阵,则存在拓扑序列,反之则不一定成立(因为AOV网中的编号的随机的)
关于拓扑排序的一些性质
一个图的拓扑排序唯一,也不能确定一个唯一的图
关键路径
在带权有向图中,用顶点代表事件,用边代表活动,边的权值代表完成该活动需要的代价/时间,这样的图叫做AOE网。
如何定义这个所谓的“事件”?
也就是说,这个顶点代表“我前面的事情都已经完成啦,可以开始干接下来的事情了”,这是一种中介状态。
区分AOE网和AOV网:AOV网用顶点表示事件,边只是表示一种“前后关系”,而AOE网的边代表活动。
AOE网的两个特性:
- 只有在顶定点所代表的事件发生之后,从该顶点出发的各有向边所代表的活动才能开始。
- 只有在进入某顶点所代表的各有向边所代表的活动都已经结束时,该顶点所代表的事件才能发生。
AOE网中的最长路径,被称为关键路径,
而吧关键路径上的活动称为关键活动,显然关键活动会影响整个工程的进度。由于在时间上,有一些活动是可以并行的,而决定什么时候可以到达下一个“事件”的正是最长路径。
因此,我们说关键路径的长度,是工程完成的最短时间。
1. 关键概念
事件最早发生时间ve(k)
指的是从源点v1到顶点vk的最长路径长度。事件vk的最早发生时间决定了所有从vk开始的活动能够开工的最早时间。
ve[0] = 0
ve[1] = ve[0] + a0 = 0 + 4 = 4
ve[2] = max( ve[0] + a1, ve[1] + a2 ) = max(0 + 3, 4 + 2 = 6
ve[3] = max(ve[1] + a4, ve[2] + a3) = max(4 + 6, 3 + 4) = 10
计算ve( )的值时,按从前往后的顺序进行,可以在拓扑排序的基础上进行
- 将ve[1…n]初始化为0
- 每当输出一个入度为0的顶点vj时,计算其所有直接后继的ve值,若ve[j] +weight(vj,vk) > ve[k] 则更新ve[k] 以此类推,直到所有顶点都输出
事件最晚发生时间vl
指顶点vk的最晚发生时间,也就是每个顶点对应的时间最晚需要开始的时间,如果超出此时间见鬼延误整个工期。
计算vl时,从后往前计算,可以在逆拓扑排序的基础上计算
- 将vl[1…n]初始化为n
- 栈顶顶点vj出栈,计算其所有直接前驱vk的最迟发生时间vl,若vl[j]-weight(vj,vk)<vl[k],则更新vl[k] 以此类推,直到所有顶点都输出
活动的最早开工时间e[k]
指弧ak最早的发生时间
若<vk,vj>表示活动ai,则有e(i)=ve(k)
活动最晚开工时间l[k]
指弧ak最晚的发生时间,即在不推迟工程进度的前提下,活动ak可以推迟的时间。
若<vk,vj>表示活动ai,则有l(i)=vl(j)-weight(vk,vj)
活动的最早开始时间 和 最晚开始时间相等,则说明该活动时属于关键路径上的活动,即关键活动
算法设计:
关键路径算法是一种典型的动态规划法,设图G=(V, E)是个AOE网,结点编号为1,2,…,n,其中结点1与n 分别为始点和终点,ak=<i, j>∈E是G的一个活动。算法关键是确定活动的最早发生时间ve[k]和最晚发生时间vl[k],进而获取顶点的最早开始时间e[k]和最晚开始时间l[k]。
tips:
对于有多条关键路径的网,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,值域加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。
缩短关键活动的时间并不一定能缩短整个工程的工期,因为如果缩短的时间过多,关键活动可能变为非关键活动。
查找
顺序查找
在顺序表/链表中顺序访问数组书上是从后往前
每次与第i个位置上的数字比较时,比较次数为n-i+1
设每个元素的查找概率相同,查找成功时,其平均查找长度为(n+1)/2
查找失败时,平均查找长度为(n+1)
缺点:顺序表长度较长时,平均查找长度较大,效率低
优点:对数据元素存储没有要求,对表中顺序也无要求
线性链表只能使用顺序查找
对于有序的线性表,在进行顺序查找时,如果查找失败,我们的指针会停在第一个大于查找元素的元素 的位置。
此时的平均查找长度为(1+2+3+…+n+n) =n/2+n/(n+1)
折半查找
二分查找,详情见第一章
int binarysearch(SeqList L,int ket)
{
int left = 0;
int right = L.length;
while(left<=right)
{
mid=(left+right)/2;
if(L.num[mid]<key)
{
left=mid+1;
}
else if(L.num[mid]>key)
{
right=mid-1;
}
return mid;
}
return -1;
}
平均查找长度:log2(n+1)-1
对于二分查找,查找成功与查找不成功,最坏的情况下,都需要比较[log2n]+1(向下取整),或者[log2(n+1)] (向上取整) ,其实就是树的深度公式
要求:查找表有序,可以随机存储,故只适用于顺序表,不适用于链表
折半查找的过程可以用二叉树表示,称为判定树
查找成功时,查找长度为从根到目标结点的路径长度。
查找失败时,查找长度为从根节点到失败结点的父节点的路径长度。
每个节点都大于其左子树的值,小于其右子树的值:判定树的中序序列是有序的
同时,判定树应该是一颗平衡二叉树
分块查找
算法思想:将待查找序列分块,块间有序,块内无序。即前一块的数据都比后一块大/小,但块内元素可以无序。然后再对于每一个块,建立索引。
要查找元素时,先确定元素所在的块(用顺序/二分),然后在块中用顺序查找。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XbHIWuBm-1673163064411)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20221109203512899.png)]
注意,当我们使用二分查找时,最后left指针停留在第一个比目标元素大的位置,由于我们选择的关键值是某一快中最大的,所以我们需要的查找的块应该是left指针所停留的目标值。
假设索引表长度为n,均匀分成b块,每块有s条记录,则
平均查找长度为:log2(n+1) +(s+1)/2 或 (n+1)/2 +(s+1)/2
最短查找长度
记录长度
S
=
n
时,有最小查找长度
n
+
1
记录长度S=\sqrt{n} 时,有最小查找长度\sqrt{n}+1
记录长度S=n时,有最小查找长度n+1
!!树形查找
二叉排序树
二叉排序树要么是一个空树,要么满足:
- 左子树的数值比结点小
- 右子树的数值都比结点大
- 左子树和右子树也是二叉排序树
二叉排序树是一种“动态树表”,其特点为:树的结构通常不是一次生成的,而是在查找的过程中查找失败时插入的。
如果插入值比结点值小,则成为左孩子,否则成为右孩子。新插入的结点一定是叶子节点。
int BST_Insert(BiTree &T,int key)
{
BiTree *p=T;
if(p==NULL)
{
T=(BiTree)malloc(sizeof(BiTree));
T->data=key;
T->left=NULL;
T->right=NULL;
}
while(p!=NULL &&p->data!=key)
{
if(p->data>key)
p=p->left;
else if(p->data<key)
p=p->right;
else return 0;
}
BiTree s;
s.data=key;
s.left=NULL;
s.right=NULL;
p=s;
return 1;
}
二叉排序树的删除
在删除二叉排序树上的结点时,必须满足该结点删除后,该树依旧保持排序树的性质。主要有以下三种情况:
-
删除的是叶节点:直接删除
-
删除节点有一个子树:将其孩子顶替它的位置
-
删除节点有两个子树:将其中序的直接前驱or直接后继与要删除节点的值进行交换,删除该直接前驱/后继,该前驱/后继一定是前面的两种情况之一(由中序排序的性质确定)
查找性能分析:
最好情况下,这个二叉排序树是一个平衡二叉树,那么其平均查找长度为log2(n) ,如果该二叉排序树为单支树,其最长查找长度为O(n),即该输入序列是有序的,导致了二叉排序树的不平衡。
tip:二叉排序树与二分查找的判定树在最好情况下平均查找长度一样
因此对于静态的有序表,适合使用顺序表存储,有二分查找法进行查找,而对于动态查找表,适合使用二叉排序树作为其逻辑结构
平衡二叉树
1.定义
平衡二叉树AVL要么是一颗空树,要么是一颗左右子树高度差绝对值不超过1的二叉排序树
2.平衡二叉树的插入
2.1 两种旋转方式:
左旋:原根节点成为新根节点的左子树;新根节点若有左子树则成为原根节点的右子树
右旋:原根节点成为新根节点的右子树;新根节点若有右子树则成为原根节点的左子树
2.2 四种调整方式:
- LL 型:插入左孩子的左子树,右旋
- RR 型:插入右孩子的右子树,左旋
- LR 型:插入左孩子的右子树,先左旋,再右旋
- RL 型:插入右孩子的左子树,先右旋,再左旋
1)LL型失衡:
将最小不平衡子树进行一次右旋
如果2原来有右子树,将其变为3的左子树
2)RR型失衡:
将最小不平衡子树进行一次左旋
如果2原来有左子树,将其变为1的右子树
3)LR型失衡:
在原根节点的左孩子L的右子树R上进行插入操作时,造成了不平衡
先左旋,再右旋
1. 将其左孩子{1,2} 左旋为{2,1}(原来是1是左子树的根,现在2是左子树的根,1成为2的左孩子。如果2有左子树,则变为1的右子树
1. 将{3,2,1} 进行右旋,即将2作为新的根,3作为2的右子树,如果2有右子树,将其作为3的左子树
3)RL型失衡
当要在跟节点的右子树R插入左子树L时,发生失衡。
1. 将右孩子{3,2} 右旋为{2,3} 如果2有右子树,则成为3的左子树,3成为2的右孩子
1. 将{1,2,3}左旋,将2作为原树的根,1成为2的左孩子,若2有左子树,则成为1的右子树。
3.二叉平衡树的删除
二叉平衡树的删除与二叉排序树一样有四种情况
- 删除节点为叶节点。这种情况最简单,直接将其置空,释放,然后返回给父节点即可。
- 删除节点有左子树没有右子树。 先保存该节点的地址(方便释放),将该节点直接等于左子树地址, 相当于该节点存的就是左子树的地址,将原来节点地址覆盖了。然后释放。
- 删除节点有右子树没有左子树 。与2处理相同,只是将左子树换为右子树。
既有左子树又有右子树
可以有两种解决办法:- 找到左子树中的最大值,将值赋给给节点,然后将左子树最大值这个节点删除(删除可以用递归实现)
- 找到左子树中的最小值,将值赋给给节点,然后将右子树最小值这个节点删除
当然这样会有个弊端:当一直删除时,会导致树高度失衡,导致一边高,一边低,解决这样的办法可以删除左右子树最大最小节点交替实行。或者记录一高度,主要删除左子树或者右子树高的那一边。
基于二叉排序树的删除,我们再对删去节点的树进行与插入一样的旋转调整即可,先从最小的不平衡树开始调整,直到所有结点对应的树都平衡。
4. 平衡二叉树的查找
查找过程与二叉排序树类似。
设nh为深度为h的二叉平衡树中含有的最少的结点个数,则n0=0,n1=1,n2=2;
n
h
=
n
h
−
1
+
n
h
−
2
+
1
n_{h}=n_{h-1}+n_{h-2}+1
nh=nh−1+nh−2+1
含有n个节点的平衡二叉树的最大深度为O(log2n),即平衡二叉树平均查找长度为O(log2n)
B树及其操作
1. B树的定义
B树:多路平衡查找树 一个m阶B树或者为一颗空树,或者满足下列性质:
- 树中每个节点至多有m棵子树,(即至多有m-1个关键字)
- 若根节点不是终端节点,则至少有两棵子树
- 除了根节点以外的所有非叶子节点至少有m/2(向上取整)棵子树,m/2-1个关键字
- 所有叶节点都出现在同一层次上,并且不带信息(可以看做是外部结点或查找失败的结点,实际上这些结点不存在,指向这些结点的指针为空)。
B树的出现是为了减少磁盘读取的次数,因为之前介绍的二叉系列树,只能保证树的高度为log2n,但是在B树中,由于我们可以搞出“多叉树”,所以树的高度相较于二叉树会矮很多,这样也就可以使得我们访问磁盘(查找数据)的次数少很多。
对于任意一颗有n个关键字,高度为h,阶数为m的B树:
-
B树的最小高度
当每一层的每一个节点都包含m-1个关键字(有m棵子树)时,B树的高度达到最小,即
结点数 = 1 + m + m 2 + m 3 . . . . + m h − 1 = ( m h − 1 ) / ( m − 1 ) 结点数=1+m+m^2+m^3....+m^{h-1}=(m^h−1)/(m−1) 结点数=1+m+m2+m3....+mh−1=(mh−1)/(m−1)
关键字数 n = 节点数 ∗ 每个节点最多包含的关键字个数 关键字数n=节点数*每个节点最多包含的关键字个数 关键字数n=节点数∗每个节点最多包含的关键字个数
n < = m h − 1 , 即: l o g m ( n + 1 ) < = h n<=m ^{h−1} ,即:log _m(n+1)<=h n<=mh−1,即:logm(n+1)<=h
- B树的最大高度
由B树的定义可知,根节点最少可以有两个子树,叶子节点最少有m/2棵子树,因此,对于第h+1层(也就是存放失败节点的那一层)来说,其至少有
2
∗
(
m
/
2
)
h
−
1
2*(m/2)^{h-1}
2∗(m/2)h−1
个节点,对于有n个关键字的B树来说,叶节点一共有n+1个,所以
n
+
1
>
=
2
∗
(
m
/
2
)
h
−
1
n+1>=2*(m/2)^{h-1}
n+1>=2∗(m/2)h−1
2. B树的查找
有点像分块查找,先在B树上找结点,在在结点内找关键字。
3. B树的插入
- 定位:找到要插入该关键字的最低层中某个非叶结点
- 插入:如果插入该节点后依旧满足每个节点中可以容纳的关键字个数的区间,则可以直接插入,否则若插入该关键字后关键字个数大于m-1,就需要将原节点分裂
- 分裂:取该节点的第m/2个元素,插入到父节点中,前面的关键字归为一个新的节点,后面关键字也归为一个新的节点
4. B树的删除
- 删除非底层关键字:用其下一层的前驱来代替它的位置
- 删除底层关键字:三种情况
- 直接删
- 借兄弟节点的关键字填补
- 兄弟节点也不够,则选一个兄弟与其合并,在加上那个落下的双亲节点中的关键字
B+树
1.特点:节点的子树个数与关键字个数相等
所有非叶节点仅其索引作用,该索引想只含有对应子树的最大关键字和指向该孩子的指针,不包含该关键字对应记录的存储地址
只有查找到叶节点才能找到记录的具体内容
叶子节点存有所有的记录,他们也用链表连起来。
对于一个B+树,有两种查找方式,一种是按照树的方式进行查找,另一种是直接在“记录”中进行顺序查找。
数据库中通常使用这种存储方式。
有n个关键字的节点,有n棵子树。
散列查找
-
散列函数
散列函数用来建立查找的关键字与其存储地址之间的映射关系。
常用的散列函数有以下几种:
-
直接定址法
H ( k e y ) = a ∗ k e y + b H(key)=a*key+b H(key)=a∗key+b
适用于key分布连续的情况。若分布不连续,空位较多,会产生存储空间的浪费。 -
除留余数法
取一个最大的,不大于散列表长m的质数p
H ( k e y ) = k e y ( m o d p ) H(key)=key (mod p) H(key)=key(modp)
p值的选取很重要,要让冲突发生的概率尽可能小。 -
数字分析法
用于r进制数
-
平方取中法
取关键字平方值的中间几位作为散列地址
-
-
处理冲突的方法
- 开放定址法:可存放新表项的空闲地址既向其同义词开放,也向其非同义词开放。
H i = ( H ( k e y ) + d i ) m o d m H_i=(H(key)+d_i) modm Hi=(H(key)+di)modm
其中,m是序列长度,di是增量序列。
di的选取方法有四种,但是都有可能产生“堆积问题”:即由于开放定址的特点,导致计算结果本来不是这个数字的被迫占领的别的元素本该在的地方,原来计算结果是在这个地方的又要去占下一个人的。。。
- 拉链法:
有点像邻接表,把属于一个位置的元素连接在一起
-
散列查找性能分析
平均查找长度根据散列函数的设计有很大的不同。设计得不好的散列函数,冲突的次数很多,导致较长的查找长度。
散列表的查找效率取决于三个因素:
散列函数处理冲突的方法
装填因子 = 表中记录数/散列表长度
成功的查找长度÷的是散列表中元素个数,失败的查找的查找长度÷的是散列表长度
散列表平均查找长度依赖于散列表的装填因子,不直接依赖于记录数or散列表长度
-
散列表元素的删除
在散列表中删除一个记录时,
如果使用的是拉链法,可以直接将其物理删除。
如果使用开放定址法,则只能做一个删除标记,不能直接将其删除。
因为该地址可能是该记录的同义词查找路径上的一个地址,物理删除则中断了其查找路径,应为散列查找在遇到空地址时便认为查找失败。
排序(重点)
基本概念
1. 算法稳定性
若排序前ai在aj之前,排序后ai仍在aj之前,则说该算法具有稳定性
算法是否具有稳定性不能衡量一个算法的优劣
2. 算法分类
根据在排序过程中数据元素是否完全在内存中,可将算法分为内部排序和外部排序
插入排序
基本思想:每次将一个待排序记录按其关键字大小插入前面已经排好序的子序列,直到所有元素插入完成。
1. 直接插入排序
void insertsort(int A[],int n)
{
for(int i=2;i<=n;i++)
{
if(A[i]<A[i-1])
{
A[0]=A[i];
for(int j=i-1;A[j]>=A[0];j--)
{
A[j+1]=A[j];
}
A[j+1]=A[0];
}
}
}
算法分析
空间复杂度:O(1)
最好时间复杂度:O(n):待排序列已有序
最坏时间复杂度:当待排序列逆序存放时:
∑
2
n
i
\sum{_2^n}i
∑2ni
平均时间复杂度:O(n^2)
稳定性:先比较在移动,所以稳定。
适用于顺序存储和链式存储的线性表。
2. 折半插入排序
一种“半吊子”的算法,就是在查找插入位置的时候,使用二分查找法。但是在移动元素的时候,人就是一个一个向后移动,所以并没有改变总的时间复杂度。但对于数据量不是很大的排序表,往往有很好的性能。
void binsertsort(int A[],int n)
{
int low,high;
for(int i=2;i<=n;i++)
{
low=1;
high=i-1;
A[0]=A[i];
while(low<=high)
{
int mid =(low+high)/2;
if(A[mid]<A[0])
{
low=mid+1;
}
if(A[mid]>A[0])
{
high=mid-1;
}
}
for(int j=i;j>=low;j--)
{
A[j+1]=A[j];
}
A[j]=A[0];
}
}
3. 希尔排序/缩小增量排序
算法思想:分组+插入
先将待排序列按一定步长分为若干个子序列,因为子序列中元素较少,可直接用插入排序;然后缩小步长,再对子序列进行插入排序,直到步长为1,再进行一次插入排序,这时要排序的序列就比较有序了,移动元素数量大大减少。
void shellsort(int A[],int n)
{
for(int dk=n/2;dk>1;dk=dk/2)
{
for(int i=dk+1;i<=n;i++)
{
if(A[i]<A[i-dk])
{
int A[0]=A[i];
for(int j=A[i-dk];A[0]<A[j];j-=dk)
{
A[j+dk]=A[j];
}
A[j+dk]=A[0];
}
}
}
}
算法分析
空间复杂度:O(1)
时间复杂度:O(n1.3),最坏情况下会达到O(n2)
稳定性:不稳定(子表划分不确定)
适用性:仅适用于顺序存储的线性表
交换排序
1. 冒泡排序
将待排序列从前往后/从后往前两两比较。如果后一个元素比前一个小则将两个元素交换,直到最小的元素被交换到序列的第一个(或最大的元素被交换到最后一个
void bobblesort(int A[],int n)
{
for(int i=0;i<n-1;i++)
{
bool flag=false;
for(int i=n;i>0;i--)
{
if(A[i]<A[i-1])
{
int temp=A[i-1];
A[i-1]=A[i];
A[i]=temp;
flag=true;
}
}
if(flag==false)//这里用了一个flag,目的是标记本趟是否进行了交换。如果本趟遍历没有发生元素交换,说明该序列已经有序,不用再进行接下来的排序
{
return;
}
}
}
算法分析
空间复杂度:O(1)
最好时间复杂度:遍历一次,得到有序O(n)
最坏时间复杂度:当待排序列逆序存放时:
∑
2
n
i
\sum{_2^n}i
∑2ni
平均时间复杂度:O(n^2)
稳定性:先比较在移动,所以稳定。
适用于顺序存储和链式存储的线性表。
tips:
不同于插入排序,冒泡排序产生的有序子序列一定是全局有序的,即每一趟排序都至少会把一个元素放到其最终位置上。
2. 快速排序
算法思想:选择一个枢纽元素pivot 确定它的位置k,在一趟排序后,确定A{1k-1}都小于A[k],A{k+1n}都大于它。接着递归排序A{1k-1}和A{k+1n}
int partition(int A,int low,int high)
{
int povit=A[low];
while(low<high)
{
while(low<high&&A[low]<povit)
low++;
A[high]=A[low];
while(low<high&&A[high]>povit)
high--;
A[low]=A[high];
}
A[low]=povit;
return low;
}
void quiksort(int A[],int low,int high)
{
if(low<high)
{
int povitsite=partition(A,low,high);
quiksort(A,low,povitsite-1);
quiksort(A,povitsite+1,high);
}
}
算法分析
空间复杂度:由于快速排序算法是基于递归的,所以其所需空间与递归深度一致,在最好情况下需要O(log2n);在最坏情况下需要O(n)。平均情况下,需要的栈深度为O(log2n)
时间复杂度:快速排序的时间复杂度与数轴元素选择的好坏有直接关系。
如果枢轴元素可以较为平均地将待排序列分为两个长度相近的子序列,则可以达到最好的时间复杂度,为O(nlog2n)
但是,如果我们选择的枢轴元素没有将元素分为两个长度相近的子序列,比如每次枢轴元素都选择到了第一个元素,(即待排序列基本有序的情况下)得到最坏情况时间复杂度:O(n^2)
所以我们在选择枢轴元素时要注意,选择可以将排序表尽量均分的元素,或是随机选择枢轴元素,将发生最坏情况的可能性讲到最低。
同时,快速排序算法是不稳定的算法。例如右端有两个数字都小于枢轴,在进行交换时,在后面的年糕元素会被交换到左端较前的位置(因为右边是倒着处理的)
快排的过程中,不产生有序子序列,只按照枢轴分成两个“集合”
选择排序
1. 简单选择排序
基本思想:每一趟(如第i趟)在后面n-i+1个待排序列中选取关键字最小的元素,作为有序子序列的第i个元素(与L[i]交换)直到第n-1趟完成,待排元素就只有一个,不用再选了。
void slectsort(int A[],int n)
{
for(int i=1;i<=n-1;i++)
{
int min=i;
for(j=i+1;j<=n;j++)
{
if(A[j]<A[min]);
min=j;
}
if(i!=min)
{
int temp=A[i];
A[i]=A[min];
A[min]=temp;
}
}
}
算法分析
- 空间复杂度:O(1)
- 时间复杂度:主要由比较次数决定。由于无论待排序列的初始状态如何,其都要进行n(n-1)/2次比较,使用时间复杂度无论好坏,均为O(n^2).
- 选择排序是一种不稳定的排序算法
2. 堆排序(重点)
什么是堆?
满足以下条件的序列:
L
(
i
)
>
=
L
(
2
i
)
且
L
(
i
)
>
=
L
(
2
i
+
1
)
L(i)>=L(2i) 且L(i)>=L(2i+1)
L(i)>=L(2i)且L(i)>=L(2i+1)
其中1<=i<=n/2向下取整,这样的序列叫大根堆
如果把大于号换成小于号,就叫小根堆
排序思路
首先将序列初始化为一个大/小根堆,由于堆的性质,根元素即为最大/小值,将该元素输出,将堆底元素作为根,重新调整剩余序列,使其保持堆的特征。
这样就引出了两个问题:1. 如何构造初始堆?2. 如何调整剩余元素成堆?
初始堆的构造:对于一个完全二叉树,其最后一个节点是第n/2向下取整 个节点的孩子。如果这个子树不满足所在堆的规则(以大根为例),如果L(n/2)<L(n),则L(n/2)=max{L(2n),L(2n+1)},这样做有可能引起上层的根节点不满足堆的规则,于是我们就逐个递减,调整序列为堆。
调整:当最大元素被输出后,用最后一个元素填补根的位置,再进行上述调整。
void BuildMaxHeap(int A[],int len)
{
for(int i=len/2;i>=1;i--)//从最后一个非叶子节点开始,向前调整二叉树
{
HeadAdjust(A,i,len);
}
}
void HeadAdjust(int A[],int K) //调整以K为根的树,使其为大根堆
{
A[0]=A[k];
for(int i=k*2;i<=len;i=i*2)
{
if(i+1<=len&&A[i]<A[i+1])
{
i++;
}
if(A[0]<A[i])
{
A[K]=A[i];
K=i;
}
else break;
}
A[k]=A[0];
}
void HeapSort(int A[],int len)
{
for(int i=len;i>=1;i--)
{
swap(A[i],A[1]);
HeadAdjust(A,1,i-1);
}
}
算法分析
- 空间复杂度:O(1)
- 建堆:O(n) 每次调整O(log2n) 与序列初始排序无关。所以最好,最坏,平均复杂度均为O(nlog2n)
- 不稳定
归并排序
算法思想
假定待排序表由n个记录,则可将其视为n个有序子表,每个子表长度为1,两两归并,得到n/2(向上取整)个长度为2或1的有序子表,继续两两归并,得到更长的子表…
如此重复,知道得到长度为n的有序表。这种排序方法称为2路归并排序。
void merge(int A[],int low,int mid,int high) //将两个有序的子表归并形成一个长的有序表
{
for(int i=low;i<=high;i++)
{
B[i]=A[i];
}
int i,j,k;
for(i=low,j=mid+1,k=low ; i<=mid&&j<=high ;)
{
if(A[i]<B[i]) B[k++]=A[i++];
else B[k++]=A[j++];
}
while(i<=mid) B[k++]=A[i++];
while(j<=high) B[k++]=A[j++];
}
void mergesort(int A[],int low,int high)
{
while(low<high)
{
int mid=(low+high)/2;
mergesort(A,low,mid);
mergesort(A,mid+1,high);
merge(A,low,mid,high);
}
}
优化:
-
原地归并排序:不需要辅助数组,空间复杂度为O(1). 但是会增加时间复杂度
算法思想
- i 往后移动,找到第一个arr[i]>arr[j]的索引。如图找到30
- j往后一栋,找到第一个arr[j]>arr[i]的索引。如图找到55
- 交换i到index-1和index到j的部分,采用局部数组循环移动的方法,通过三次数组逆序实现。交换后数组前半部分局部有序,之后重复进行此步骤即可实现两个有序数组的合并。
这里ID交换局部序列,可以用2010年考研试题中的算法思想,将[i,index)先逆置,再将[index,j)逆置,最后将[i,j)逆置。
void reverse(int *arr,int n) //逆序操作 { int i=0,j=n-1; while(i<j) { std::swap(arr[i],arr[j]); i++; j--; } } void exchange(int *arr,int n,int i) //将含有n个元素的数组循环左移i个位置 { reverse(arr,i); reverse(arr+i,n-i); reverse(arr,n); } void merge(int *arr,int begin,int mid,int end) { int i=begin,j=mid+1,k=end; while(i<j && j<=k) { int step=0; while(i<j && arr[i]<=arr[j]) i++; while(i<j && arr[j]<=arr[i]) { j++; step++; } exchange(arr+i,j-i,j-i-step); i+=step; } }
算法分析
-
空间复杂度:O(n)
-
时间复杂度:每趟归并的时间复杂度为O(n),共需要执行log2n(向上取整)次归并,所以算法时间复杂度为O(Nlog2N) 与初始序列顺序无关
-
具有稳定性。
k路归并共需执行logkn(向上取整)次
基数排序
算法思想
基于关键字各位大小进行排序
可以从最低位开始排,也可以从最高位开始排。
算法分析
- 空间复杂度:假设需要r个队列作为辅助队列,则空间复杂度为O®
- 时间复杂度:假设需要d趟分配和收集,一次分配需要O(n),一次收集需要O®,则一共需要O(d(r+n))
- 具有稳定性
排序算法选择
- 若n较小:选择简单选择排序或直接插入排序,简单选择排序的移动次数较少,更佳。
- 若原序列已基本有序,可选优直接插入或冒泡(这俩最好能到O(n))
- 若n较大,可选用O(nlog2n)级别的那几个算法
- 快速排序:如果序列中的数字随机分布时使用较好
- 堆排序:不会出现快速排序的最差情况,
- 归并排序:是稳定的时间复杂度好的算法,可以与直接插入算法一起使用,先用直接插入算法将较小的序列进行排序,再利用归并排序合并各个子序列。
- 若n较大,且记录的关键字位数较少且可以分解是,可采用基数排序。
外部排序
使用外部排序的情况
对大文件进行排序,由于文件记录多,信息量庞大,无法将整个文件复制进内存中进行排序,所以需要即将待排记录存储在外存中,排序时再经数据一部分一部分调入内存中。
外部排序的方法
外部排序主要考虑磁盘访问次数(I/O次数)
外部排序通常使用归并排序,包括两个独立的阶段:
- 根据内存缓存区大小,将外存中的文件分为若干长度为l的子文件,依次读入内存中使用内部排序的方法进行排序,并将排序后得到的有序子文件重新写回外存。
- 对写回外存的归并段进行归并,使得归并段有序。
外部排序的时间
外部排序总时间=内部哦艾许所需时间+外存信息读写时间+内部归并所需时间
一般对r个初始归并段,做k路平衡归并,可利用严格K叉树来表示。
第一趟可归并r/k向上取整个归并段,以后每趟可将m个归并段归并成m/k向上取整个归并段。
归并趟数S=logkr=树的高度-1
多路平衡归并与败者树
由于在k个关键字中选出最小关键字需要k-1次比较,而每趟归并n个元素需要(n-1)(k-1)次比较,S趟归并需要S(n-1)(k-1)=logkr(n-1)(k-1)=log2r/log2k(n-1)(k-1)次比较
所以内部归并的时间会随k的增长而增长,这就会抵消由于k增大而减少访问外存次数所节省的时间。
要使内部归并不受k增大的影响,需要使用到败者树。
k个叶节点用于存放k个归并段在归并过程中当前惨叫比较的记录。内部结点用于记忆左右子树中的“失败者”,让获胜者继续向上比较,直到根节点。
在败者树中,在k个关键字中选出最小关键字需要log2k次比较,所以S趟归并所需要比较的次数变为(n-1)log2r次,与k无关。
但是k依旧不是越大越好,因为k越大,就要相应的增加缓冲区的个数。若内存大小一定,势必要减少缓冲区内存,依旧会增加磁盘读写次数。
最佳归并树
构造k叉哈夫曼树,得到归并代价最小的归并树,也就是最佳归并树。
若初始归并段不足以构成一棵严格k叉树,则需要添加长度为0的虚段。
若
$$
(n_0-1)%(k-1)=u
$$
则需要添加k-u-1个虚段。
int mid=(low+high)/2;
mergesort(A,low,mid);
mergesort(A,mid+1,high);
merge(A,low,mid,high);
}
}
优化:
1. 原地归并排序:不需要辅助数组,空间复杂度为O(1). **但是会增加时间复杂度**
算法思想
1. i 往后移动,找到第一个arr[i]>arr[j]的索引。如图找到30
2. j往后一栋,找到第一个arr[j]>arr[i]的索引。如图找到55
3. 交换i到index-1和index到j的部分,采用局部数组循环移动的方法,通过三次数组逆序实现。交换后数组前半部分局部有序,之后重复进行此步骤即可实现两个有序数组的合并。
<img src="https://i-blog.csdnimg.cn/blog_migrate/388675b611d9c44f0743dd6dd856e292.png" alt="这里写图片描述" style="zoom:50%;" />
这里ID交换局部序列,可以用2010年考研试题中的算法思想,将[i,index)先逆置,再将[index,j)逆置,最后将[i,j)逆置。
```c++
void reverse(int *arr,int n) //逆序操作
{
int i=0,j=n-1;
while(i<j)
{
std::swap(arr[i],arr[j]);
i++;
j--;
}
}
void exchange(int *arr,int n,int i) //将含有n个元素的数组循环左移i个位置
{
reverse(arr,i);
reverse(arr+i,n-i);
reverse(arr,n);
}
void merge(int *arr,int begin,int mid,int end)
{
int i=begin,j=mid+1,k=end;
while(i<j && j<=k)
{
int step=0;
while(i<j && arr[i]<=arr[j])
i++;
while(i<j && arr[j]<=arr[i])
{
j++;
step++;
}
exchange(arr+i,j-i,j-i-step);
i+=step;
}
}
算法分析
-
空间复杂度:O(n)
-
时间复杂度:每趟归并的时间复杂度为O(n),共需要执行log2n(向上取整)次归并,所以算法时间复杂度为O(Nlog2N) 与初始序列顺序无关
-
具有稳定性。
k路归并共需执行logkn(向上取整)次
基数排序
算法思想
基于关键字各位大小进行排序
可以从最低位开始排,也可以从最高位开始排。
算法分析
- 空间复杂度:假设需要r个队列作为辅助队列,则空间复杂度为O®
- 时间复杂度:假设需要d趟分配和收集,一次分配需要O(n),一次收集需要O®,则一共需要O(d(r+n))
- 具有稳定性
排序算法选择
- 若n较小:选择简单选择排序或直接插入排序,简单选择排序的移动次数较少,更佳。
- 若原序列已基本有序,可选优直接插入或冒泡(这俩最好能到O(n))
- 若n较大,可选用O(nlog2n)级别的那几个算法
- 快速排序:如果序列中的数字随机分布时使用较好
- 堆排序:不会出现快速排序的最差情况,
- 归并排序:是稳定的时间复杂度好的算法,可以与直接插入算法一起使用,先用直接插入算法将较小的序列进行排序,再利用归并排序合并各个子序列。
- 若n较大,且记录的关键字位数较少且可以分解是,可采用基数排序。
外部排序
使用外部排序的情况
对大文件进行排序,由于文件记录多,信息量庞大,无法将整个文件复制进内存中进行排序,所以需要即将待排记录存储在外存中,排序时再经数据一部分一部分调入内存中。
外部排序的方法
外部排序主要考虑磁盘访问次数(I/O次数)
外部排序通常使用归并排序,包括两个独立的阶段:
- 根据内存缓存区大小,将外存中的文件分为若干长度为l的子文件,依次读入内存中使用内部排序的方法进行排序,并将排序后得到的有序子文件重新写回外存。
- 对写回外存的归并段进行归并,使得归并段有序。
外部排序的时间
外部排序总时间=内部哦艾许所需时间+外存信息读写时间+内部归并所需时间
一般对r个初始归并段,做k路平衡归并,可利用严格K叉树来表示。
第一趟可归并r/k向上取整个归并段,以后每趟可将m个归并段归并成m/k向上取整个归并段。
归并趟数S=logkr=树的高度-1
多路平衡归并与败者树
由于在k个关键字中选出最小关键字需要k-1次比较,而每趟归并n个元素需要(n-1)(k-1)次比较,S趟归并需要S(n-1)(k-1)=logkr(n-1)(k-1)=log2r/log2k(n-1)(k-1)次比较
所以内部归并的时间会随k的增长而增长,这就会抵消由于k增大而减少访问外存次数所节省的时间。
要使内部归并不受k增大的影响,需要使用到败者树。
k个叶节点用于存放k个归并段在归并过程中当前惨叫比较的记录。内部结点用于记忆左右子树中的“失败者”,让获胜者继续向上比较,直到根节点。
在败者树中,在k个关键字中选出最小关键字需要log2k次比较,所以S趟归并所需要比较的次数变为(n-1)log2r次,与k无关。
但是k依旧不是越大越好,因为k越大,就要相应的增加缓冲区的个数。若内存大小一定,势必要减少缓冲区内存,依旧会增加磁盘读写次数。
最佳归并树
构造k叉哈夫曼树,得到归并代价最小的归并树,也就是最佳归并树。
若初始归并段不足以构成一棵严格k叉树,则需要添加长度为0的虚段。
若
$$
(n_0-1)%(k-1)=u
$$
则需要添加k-u-1个虚段。