数据结构是组织数据的结构。能够使得我们更加方便的操作管理数据。
列表
- 是一种数据项构成的有限序列,即按照一定的线性顺序,排列而成的数据项的集合。
- 列表最常见的表现形式有数组和链表,而栈和队列则是两种特殊类型的列表。
链表
链表是一系列节点相互连接而形成的一种数据结构。通常会有一个头节点,其它节点的顺序在头节点的后面,最后一个节点称为尾节点。用指针可以很容易实现节点与节点之间的连接。动态按需分配每个节点。
链表有好几种类型,最简单的是单链表,一个节点到下一个节点只有一个连接,连接从头节点开始,到尾节点结束。循环链表没有尾节点,链表的最后一个节点又指向头节点。双链表用了两个链表,一个向前连接,一个向后连接,我们可以在两个方向上查找节点,这类链表更灵活,但是也更难实现。
我们可以通过以下方式来创建和使用单链表:
typedef struct _node{
void *data;
struct _node *next;
} Node;
typedef struct _linkedList{
Node *head;
Node *tail;
Node *current;
}LinkedList;
Node
结构体定义一个节点,它有两个指针,第一个是void
指针,持有任意类型的数据,第二个指针是指向下一个节点的指针。LinkedList
结构体表示链表,持有指向头节点和尾节点的指针,当前指针用来辅助遍历链表。
- 初始化链表:
使用链表之前要先初始化,如下所示的initializeList
函数执行这个任务,将LinkedList
对象的指针传递给函数,函数把结构体里的指针置为NULL
:
void initializeList(LinkedList *List){
list->head = NULL;
list->tail = NULL;
list->current = NULL;
}
- 向链表的头部和尾部添加数据:
在添加节点之前,需要先给节点分配内存,然后把传递给函数的数据,赋给结构体的data
字段。之后把data
以指针的参数形式传递,链表就能够持有任何类型的data
数据了。
之后我们需要检查链表是否为空,如果为空,就把尾指针指向节点,然后把结点的next
字段赋值为NULL
;如果不为空,那么将结点的next
指针指向链表头部。无论哪种情况,链表头都指向节点:
void addHead(LinkedList *list, void data){
Node *node = (Node*)malloc(sizeof(Node));
node->data = data;
if(list->head == NULL){
list->tail = node;
node->next = NULL;
}else{
node->next = list->head;
}
list->head = node;
}
链表问题与注意事项
链表问题算法难度不高,主要考察代码实现能力。链表和数组一样,也是一种线性结构,通常认为数组是物理地址上一段连续的地址空间,所以可以通过数组下标直接就得到这个元素。链表与数组最大的区别在于:链表的存储空间是零时分配的,我们只能通过一个节点的next
指针找到下一个节点的位置,而无法直接找到第i
个节点的位置。
链表的分类:1. 按照链表的链接方向可以分为单链表和双链表;2. 按照有无环可以分为普通链表和循环链表,对于循环双链表来说,最后一个节点的next
指针需要指向第一个元素,第一个元素的prior
指针需要指向最后一个节点;
链表问题代码实现的关键点
- 链表调整函数的返回值类型,根据要求往往是节点类型,因为链表在调整的过程中很有可能会更换头部,自然我们就需要返回一个新的头部;
- 处理链表的过程中,先采用画图的方式理清逻辑;
- 链表问题对于边界条件讨论要求严格,比如头节点、尾节点、空节点等都是特殊值,需要时刻注意节点是否为空;
链表插入和删除的注意事项
-
特殊处理链表为空,或者链表长度为
1
的情况; -
如果在单链表中想插入一个节点,需要做以下几步:同时找到插入位置之前的节点和之后的节点;然后把前一个节点的指针指向新插入的节点;再把新插入的节点的
next
指针指向后一个节点; -
单链表中删除一个节点,需要以下几步:找到删除节点的前一个节点;前一个节点的
next
指针指向删除这个节点的后一个节点;
注意:如果在头部、尾部,或者为空节点插入或者删除节点需要特殊考虑。双链表中插入和删除过程和单链表的插入和删除是大同小异的,但需要额外考虑previous
指针的指向;
链表翻转
-
当链表为空或者长度为
1
时,需要特殊考虑; -
翻转链表可以分为以下几步(假设前一个已经翻转好的节点是
head
,当前节点为now
):a. 用一个节点记录now
节点的下一个节点是什么;b.now
节点的next
指针指向head
;c. 更新头节点head
到当前节点;d.now
节点指向之前记录的now
节点的下一个节点。
经典案例
- 案例一
题目:给定一个整数num
,如何在节点有序的环形链表中插入一个节点值为num
的节点,并保证这个环形链表依然有序。
思路:首先生成节点值为num
的新节点,记为node
。1. 如果链表为空,node
自己形成环形链表返回;2. 如果链表不为空,令previous
等于头节点,变量current
等于第二个节点,然后令previous
和current
同步移动下去,如果遇到previous
的值小于等于num
,并且current
值大于等于num
的话,说明node
应该插入在两个节点之间。3. 如果previous
和current
转了一圈都没有发现应该插入的位置,此时node
应该插入到头节点的前面。
- 案例二
题目:给定一个链表中的节点node
,但不给定整个链表的头节点。如何在链表中删除node
?请实现这个函数,要求时间复杂度为
O
(
1
)
O(1)
O(1)。
思路:如果是删除双向链表这个题目就很容易,可以通过previous
指针找到前一个节点,和next
指针找到后一个节点,删掉这个节点只需要前后两个指针重新连接即可。对于单链表的话,我们只需要将下一个节点的值赋值给当前节点,然后把下一个节点删除即可。但是这种删除方式是存在问题的,它没办法删除掉最后一个节点。因为没办法将下一个节点的值赋值给当前节点,并且无法获取上一个节点。遇到这种情况能否将当前节点在内存中的这块区域变成空呢?这样就相当于将当前节点的上一个节点的的next
指针指向空了嘛,实际上是无法做到这一点的,因为null
在系统中是一个特定的区域,如果想让上一个节点的next
指针指向null
,必须找到上一个节点。
这种方式并不是删除了该节点,而是进行了值的拷贝。在实际问题中可能会带来很大的问题,比如工程上的一个节点可能代表一个很复杂的结构,节点值的拷贝操作代价可能会比较高,或者改变节点值这个操作本身都可能在工程上被禁止。还有如果删除掉的是下一个节点,而下一个节点有可能提供某些服务,直接这样删除掉下一个节点可能会产生一些问题。
- 案例三
题目:给定一个链表的头节点head
,再给定一个数num
,请把链表调整成节点值小于num
的节点都放在链表的左边,值等于num
的节点都放在链表的中间,值大于num
的节点都放在链表的右边。
思路:1. 将链表的所有节点放入到数组中,然后将数组进行快排划分的调整过程,然后将数组中的节点依次重新串连;2. 最优解并不需要额外的空间,我们只需要在遍历链表的过程中,把所有链表分成三个小链表,分别是值小于num
组成的链表,等于num
组成的链表,和值大于num
组成的链表,最后再把这三条链表整体连接起来即可;
- 案例四
题目:给定两个有序链表的头节点head1
和head2
,打印两个有序链表的公共部分。
思路:首先判断两个链表是否有存在为空的情况,只要有一个链表为空,则说明不可能会有公共部分,直接返回即可。如果两个链表都不为空,从两个链表的头节点开始遍历:1. 如果list1
当前节点的值小于list2
当前节点的值,那么就继续遍历list1
的下一个节点;2. 如果list2
的当前节点值小于list2
的当前节点值,就遍历list2
的下一个节点;3. 如果两个链表的当前节点值相等,就打印当前的节点值,两个都向下一个节点移动。
- 案例五
题目:给定一个单链表的头节点head
,实现一个调整单链表的函数,使得每K
个节点之间逆序,如果最后不够K
个节点一组,则不调整最后几个节点。例如链表
1->2->3->4->5->6->7->8->null, K=3
调整后为:
3->2->1->6->5->4->7->8->null
思路:如果链表为空,或长度为1
,或
k
<
2
k<2
k<2,链表不用进行调整。
方法一:时间复杂度为
O
(
n
)
O(n)
O(n),额外空间复杂度为
O
(
k
)
O(k)
O(k):利用栈来进行处理,元素依次进栈,在栈中凑齐了
k
k
k个元素之后就依次从栈中弹出,因为栈中弹出的顺序是原来顺序的逆序,所以这
k
k
k个元素之间就实现了逆序,然后下一组元素继续按照这种方法处理。在实现的时候要注意最后一组元素不足K
个如何处理,每次从栈中弹出,需要处理next
指针重连的操作,每组节点之间需要连接,并且这里第一组要特殊处理,需要返回的头节点会被改变。
方法二:时间复杂度为 O ( n ) O(n) O(n),额外空间复杂度为 O ( 1 ) O(1) O(1):方法二与方法一类似,依然是收集 k k k个元素就做逆序调整,同样需要考虑每一组逆序之后的头节点和上一组调节好的尾节点相连,比第一种方法需要考虑更多代码实现上的细节。
- 案例六
题目:给定一个单链表的头节点head
,链表中每个节点保存一个整数,再给定一个值val
,把所有等于val
的节点删掉。
思路:把整个过程看作是构造链表的过程,假设之前构造的节点的头是head
,尾部是tail
,如果当前节点的值是val
,我们就直接抛弃它,否者就将这个节点接到需要构造的新链表节点的末尾,接到末尾需要改变末尾的next
指针,让它指向null
节点,并且把null
节点设置为新的尾节点。最开始构造的节点head
和tail
是相同的,并且tail
没有下一个指针,这种种边界条件需要考虑清楚。
- 案例七
题目:判断一个链表是否为回文结构。
思路:
方法一:时间复杂度 O ( n ) O(n) O(n),使用了 n n n个额外空间;申请一个栈结构,然后把节点依次压入栈中,之后把栈中的节点弹出,弹出的时候与链表的头部节点开始对比判断其值。
方法二:时间复杂度 O ( n ) O(n) O(n),使用了 n / 2 n/2 n/2个额外空间;申请一个栈,之后依然去遍历链表,但是用快慢两个指针同时遍历,快指针一次走两步,慢指针一次走一步,慢指针遍历时,将遍历过的节点压入到栈中,当快指针走完的时候,慢指针会来到链表中间的位置,同时从栈顶到栈低的位置是链表左半部分的逆序。此时如果链表长度为基数,不把中间的节点压入栈中,接下来慢指针继续遍历栈也开始同步弹出节点,对比其值。整个过程相当于是将链表的左部分折过来与链表的右半部分比较。
方法三:时间复杂度 O ( n ) O(n) O(n),额外空间复杂度为 O ( 1 ) O(1) O(1);前面的过程与方法二一样,都是找到链表的中间位置,然后把链表右半部分做逆序的调整,接下来从链表的两头开始一次对比节点值是否一样,如果对比到中间的位置,都一样,说明整个链表是回文结构,否者就不是回文结构。不管是否是回文结构,在返回之前,我们都需要将链表右半部分调整成原来链表的样子,然后才能返回最终的结果。可以看出这个过程是不用申请任何额外数据结构的。
- 案例八
题目:一个链表结构中,每个节点不仅含有一条指向下一个节点的next
指针,同时含有一条rand
指针,rand
指针可能指向任何一个链表中的节点,请复制这种含有rand
指针节点的链表。
思路:首先,如果链表的长度为0
或者为空,我们就直接返回空;
核心思路是从链表的头节点开始,根据next
指针,往下遍历,在遍历的过程中,我们拷贝当前的节点,并且把拷贝的这个节点放在当前节点和下一个节点之间,比如1->2->3
遍历完之后变成1->1'->2->2'->3->3'
,然后在这个链表中再遍历一次,所有节点,此时遍历的时候我们同时拿到两个节点,比如先拿到1
和1'
,然后通过1
的rand
指针找到3
,3
的下一个节点就是拷贝节点3'
,所以可以成功地将1'
的rand
指针指向3'
。然后依次做。之后把这个大的链表分流成1->2->3
和1'->2'->3'
。
- 案例九
题目:如何判断一个链表是否有环?有环的话返回进入环的第一个节点,无环的话返回进入环的返回为空。如果链表的长度为 N N N,请做到时间复杂度 O ( N ) O(N) O(N),额外空间复杂度 O ( 1 ) O(1) O(1)。
方法一:如果本题没有额外空间复杂度的限制,我们可以用哈希表来做,也就是从链表的头节点开始遍历,每遍历一次就用哈希表记录下这个节点。1. 如果一个链表无环,那走到结尾处也不会出现重复的节点,此时直接返回空;2. 如果有环,根据哈希表则肯定能够发现遍历到同一个节点的情况,第一个重复出现的节点肯定就是第一个进入环的节点直接返回即可。
方法二:最优解额外空间复杂度 O ( 1 ) O(1) O(1),从头节点开始,用快慢两个指针遍历,快指针一次走两步,慢指针一次走一步:1. 如果一个链表无环,那么快指针将快速发现一个链表为空;2. 如果一个链表有环,那么快指针和慢指针将在环中的某个位置相遇,在它们相遇的时刻,让快指针从头节点开始重新遍历,此时快指针一次走一步,慢指针从相遇位置继续往下走,一次也走一步,当快指针与慢指针再次相遇时,相遇到的那个节点就是进入环的第一个节点。
- 案例十
题目:如何判断两个无环单链表是否相交,相交的话返回第一个相交点,不相交的话返回空。如果两个链表长度分别为 N N N和 M M M,请做到时间复杂度 O ( N + M ) O(N+M) O(N+M),额外空间复杂度 O ( 1 ) O(1) O(1)。
方法一:没有空间复杂度的限制可以用哈希表来做,先遍历第一个链表,将第一个链表的所有节点都加入到哈希表中,然后开始遍历第二个链表,一旦遇到某个节点在哈希表中有记录,则说明这个节点在第一个链表中也存在,并且是它们第一个相交的节点。
方法二:遍历两个链表,统计两个链表的长度,比如一个链表长度为100
,另外一个链表的长度为50
,然后让长度100
的链表先走50
步,接下来两个链表再一起走,如果两个链表相交的话,那么它们在同步走的过程中一定会共同到达第一个相交的节点,如果一直走到最后都没有相交,说明两个链表就不相交,返回空即可。
- 案例十一
题目:如何判断两个有环单链表是否相交?相交的话返回第一个相交的节点,不相交的话返回空。如果两个链表长度分别为 N N N和 M M M,请做到时间复杂度 O ( N + M ) O(N+M) O(N+M),额外空间复杂度 O ( 1 ) O(1) O(1)。
思路:根据之前介绍的找到环形单链表第一个入环的题目,找到两个链表各自的入环节点。1. 如果入环节点是同一个节点,那么可以肯定两个链表是相交的,此时想要找到第一个相交节点,我们可以以公共入环点作为节点的终止位置,这样就与找两个无环单链表相交的情况类似。2. 如果入环节点不是同一个节点的话,只能是两种情况,第一种情况:一个是各自有一个环,互不相交;第二种情况:另外一个是同一个环,两个相交点。想要区分这两种情况,只要判断在某一个环中能否找到另一个链表的入环节点即可。第一种情况直接返回空即可,因为两个链表不相交;第二种情况返回任何一个入环节点即可。
基础习题
1. 链表的必备知识要点(包括基础知识、刷题中使用的STL等知识)
2. 链表逆序(LeetCode 92,206. Reverse Linked List 1,2)
3. 求两个链表的交点(LeetCode 160. Intersection of Two Linked Lists)
4. 链表的节点交换(LeetCode 24. Swap Nodes in Pairs)
5. 链表求环(LeetCode 141,142. Linked List Cycle 1,2)
6. 链表重新构造(LeetCode 86. Partition List)
7. 复杂的链表复制(LeetCode 138. Copy List with Random Pointer)
8. 排序链表合并(2个与多个) (LeetCode 21,23 Merge Two(k) Sorted ListsLeetCode)
数组
数组是一种简单的数据结构(顺序存储结构的线性表)。数组与列表的区别在于索引。索引是数组中的一个重要概念,列表中没有索引这个概念。在大多数编程语言中,索引是从0
算起的。数组中元素在内存中也是连续存储的。
如果我们想要改变数组的元素,就需要复制两个元素,而数组中元素可能消耗的内存很大。此外,对于往数组中添加或删除元素,无论是为新元素腾出空间或是删除已有元素,都可能需要移动数组的一大部分。
- 合并区间
给出一个区间的集合,请合并所有重叠的区间。
示例 1:
输入: [[1,3],[2,6],[8,10],[15,18]]
输出: [[1,6],[8,10],[15,18]]
解释: 区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].
示例 2:
输入: [[1,4],[4,5]]
输出: [[1,5]]
解释: 区间 [1,4] 和 [4,5] 可被视为重叠区间。
思路也就很简单,首先按照二维数组里面的一维数组的第一个元素排序,这个时候比较上一个一维数组的末尾元素(比如[1,3]
中的3
)与下一个一维数组的第一个元素(比如[2,6]
中的2
)大小,然后判断是否要合并。
class Solution {
public:
static bool cmp(vector<int> num1, vector<int> num2){
return num1[0]<num2[0];
}
vector<vector<int>> merge(vector<vector<int>>& intervals) {
if(intervals.size()<2) return intervals;
sort(intervals.begin(), intervals.end(),cmp);
int n = intervals.size();
vector<vector<int>> ans;
ans.push_back(intervals[0]);
for(int i=1; i<n; ++i){
if(ans.back()[1] >= intervals[i][0]){
ans.back()[1] = max(ans.back()[1], intervals[i][1]);
} else {
ans.push_back(intervals[i]);
}
}
return ans;
}
};
这里要注意:
static bool cmp(vector<int> num1, vector<int> num2){
return num1[0]<num2[0];
}
之所以要加static
的原因是:使用了函数指针,而函数指针所指函数须得是静态才行。如果不加的话,报错如下:
fatal error: reference to non-static member function must be called
- 旋转图像
给定一个
n
×
n
n \times n
n×n 的二维矩阵表示一个图像。将图像顺时针旋转90
度。
说明:你必须在原地旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要使用另一个矩阵来旋转图像。
示例 1:
给定 matrix =
[
[1,2,3],
[4,5,6],
[7,8,9]
],
原地旋转输入矩阵,使其变为:
[
[7,4,1],
[8,5,2],
[9,6,3]
]
示例 2:
给定 matrix =
[
[ 5, 1, 9,11],
[ 2, 4, 8,10],
[13, 3, 6, 7],
[15,14,12,16]
],
原地旋转输入矩阵,使其变为:
[
[15,13, 2, 5],
[14, 3, 4, 1],
[12, 6, 8, 9],
[16, 7,10,11]
]
我们首先需要对其最外层循环,将每个数字旋转90
度,然后进入内层循环,所以我们要循环的外层数就是
N
/
2
N/2
N/2(for(int r=0; r<n/2;++r)
);当确定了循环的层数之后,我们需要进入内层循环,内层循环的起始位置s=r
,内层循环的终止位置从右边往左边看就是N-1-r
。
接下来就是需要确定各个元素的位置了,刚开始起始位置的元素坐标是(r,i)
。因为是按照顺时针旋转90
度,所以逆时针旋转的坐标为(end-(i-s), r)
,(end, end-(i-s))
, (i, end)
。
class Solution {
public:
void rotate(vector<vector<int>>& matrix) {
int n = matrix.size();
for(int r=0; r<n/2;++r){
int s = r;
int end = n-1-r;
for(int i=s; i<end; i++){
int temp = matrix[r][i];
matrix[r][i] = matrix[end-(i-s)][r];
matrix[end-(i-s)][r] = matrix[end][end-(i-s)];
matrix[end][end-(i-s)] = matrix[i][end];
matrix[i][end] = temp;
}
}
}
};
- 对角线遍历
给定一个含有
M
×
N
M \times N
M×N个元素的矩阵(
M
M
M 行,
N
N
N列),请以对角线遍历的顺序返回这个矩阵中的所有元素,对角线遍历如下图所示。(给定矩阵中的元素总数不会超过100000
。)
示例:
输入:
[
[ 1, 2, 3 ],
[ 4, 5, 6 ],
[ 7, 8, 9 ]
]
输出: [1,2,4,7,5,3,6,8,9]
我们可以知道每一条斜线上的坐标之和为一个固定的值,从0
到m+n
。如果我们以这个和作为大循环遍历的话(for(int s=0; s<=m+n;s++)
),我们只需要去确定行或者列中的一个,我们就可以通过总和s减去行或者列得到另一个。以行为例,如果我们知道行坐标r
的范围,那么s-r
就是纵坐标的值。
那现在的问题就是如何确定行坐标的值。
- 当
s
小于行坐标的总数时,行坐标的最大值为s
,否者为m = matrix.size()-1
。程序里面表示为int max_r = min(s, m);
,(对应第0列边界)。 - 当
s
很大时,大到被最后一列限制的时候,此时行坐标的最小值为s-n
,否者为0
。程序里面表示为int min_r = max(0, s-n)
,(对应第最后一列边界)。
class Solution {
public:
vector<int> findDiagonalOrder(vector<vector<int>>& matrix) {
vector<int> ans;
if (matrix.empty()) return ans;
int m = matrix.size()-1;
int n = matrix[0].size()-1;
for(int s=0; s<=m+n;s++){
int min_r = max(0, s-n);
int max_r = min(s, m);
if(s%2==1){
for(int r=min_r; r<=max_r; ++r){
ans.push_back(matrix[r][s-r]);
}
} else {
for(int r=max_r; r>=min_r; --r){
ans.push_back(matrix[r][s-r]);
}
}
}
return ans;
}
};
栈
栈是一种后进先出数据结构。它所支持的操作有:
- 查询栈顶
- 弹出栈顶
- 向栈顶部压入元素
栈的实现比队列容易。动态数组足以实现堆栈结构。其原理实现如下所示:
#include <iostream>
class MyStack {
private:
vector<int> data; // store elements
public:
/** Insert an element into the stack. */
void push(int x) {
data.push_back(x);
}
/** Checks whether the queue is empty or not. */
bool isEmpty() {
return data.empty();
}
/** Get the top item from the queue. */
int top() {
return data.back();
}
/** Delete an element from the queue. Return true if the operation is successful. */
bool pop() {
if (isEmpty()) {
return false;
}
data.pop_back();
return true;
}
};
int main() {
MyStack s;
s.push(1);
s.push(2);
s.push(3);
for (int i = 0; i < 4; ++i) {
if (!s.isEmpty()) {
cout << s.top() << endl;
}
cout << (s.pop() ? "true" : "false") << endl;
}
}
在c++
中是有栈这样一种数据结构的,使用方法如下:
#include<bits/stdc++.h>
using namespace std;
void calc(){
stack<int> s;
for(int i=0; i<=5; i++){
s.push(i);
}
while(!s.empty()){
cout << s.top() << "\n";
s.pop();
}
}
STL
的栈支持其他各种数据类型、支持用户自己的struct
。
栈和队列的基本性质
- 栈是先进后出的。2. 队列是先进先出的。3. 栈和队列在实现结构上可以有数组和链表两种形式。
数组结构实现较容易;用链表结构较复杂,因为牵扯很多指针操作。
栈结构的基本操作
pop
操作:从栈顶弹出一个元素。top
或peek
操作:只访问栈顶元素而不弹出。push
操作:从栈顶压入一个元素。size
操作:返回当前栈中的元素个数。
队列的基本操作
与栈操作不同的是,push
操作为在队头加入元素。而pop
操作是从队列尾部弹出一个元素。栈和队列的基本操作,都是时间复杂度为
O
(
1
)
O(1)
O(1)的操作。除此之外还有一些结构:
- 双端队列结构:在首尾都可以压入和弹出元素。
- 优先级队列为根据元素的优先级,决定元素的弹出顺序。优先级队列准确来说并不是线性结构,而是一种堆结构。
关于栈和队列,实际上还必须掌握与这两种结构有关的两种图的遍历方式。深度优先遍历(DFS
)和宽度优先遍历(BFS
)。
-
深度优先遍历:深度优先遍历可以用栈来实现。
-
宽度优先遍历:宽度优先遍历可以用队列实现。
这里需要注意,平时使用的递归函数实际上用到了系统提供的函数栈。递归函数的处理过程可以看作一个函数进栈、出栈的一个过程。所以,所有递归函数可以做的过程都一定可以用非递归的方式实现。在搜索的过程中,减支是通过提早排除一些已经不可能的方案,使搜索不在错误的道路上越走越远,从而降低复杂度。
-
可行性剪枝:所谓可行性剪枝,顾名思义,就是当前状态不可行,并且可以推出此状态扩展出的所有状态均不可行,那么就可以进行剪枝,直接返回,不再搜索后续的状态。即:不可行,就返回。
-
排除等效冗余:所谓排除等效冗余,就是当几个枝桠具有完全相同的效果的时候,只选择其中一个走就可以了。即:都可以,选一个。
-
最优性剪枝:所谓最优性剪枝,是在我们用搜索方法解决最优化问题的一种常用剪枝。就是当你搜到一半的时候,已经比已经搜到的最优解要不优了,那么这个方案肯定是不行的,即刻停止搜索,进行回溯。即:有比较,选最优。
-
顺序剪枝:普遍来讲,搜索的顺序是不固定的,对一个问题来讲,算法可以进入搜索树的任意的一个子节点。但假如我们要搜索一个最小值,而非要从最大值存在的那个节点开搜,就可能存在搜索到最后才出解。而我们从最小的节点开搜很可能马上就出解。这就是顺序剪枝的一个应用。一般来讲,有单调性存在的搜索问题可以和贪心思想结合,进行顺序剪枝。即:有顺序,按题意。
-
记忆化:记忆化搜索其实是搜索的另外一个分支。在这里简单介绍一下记忆化的原理:就是记录搜索的每一个状态,当重复搜索到相同的状态的时候直接返回。即:搜重了,直接跳。
经典案例
- 案例一:实现一个特殊的栈,在实现栈的基本功能的基础上,再实现返回栈中最小元素的操作
getmin
。
要求:1. pop
、push
、getmin
操作的时间复杂度都是
O
(
1
)
O(1)
O(1)。2. 设计的栈类型可以使用现成的栈结构。
思路:设计两个栈,一个栈与正常的栈没有任何区别,另一个栈用来保存每一步的最小值。
- 案例二:编写一个类,只能用两个栈结构实现队列,支持队列的基本操作(
add
、pop
、peek
)。
栈的特点是先进后出,而队列的特点是先进先出。用两个栈正好能把顺序反过来,从而实现队列的操作。
-
案例三:实现一个栈的逆序,但是只能用递归函数和这个栈本身的操作来实现,而不能自己申请另外的数据结构。思路:递归实现。
-
案例四:一个栈中元素类型为整型,现在想将该栈从顶到底按从大到小排序,只许申请一个栈,除此之外还可以申请新的变量,但不能申请额外的数据结构。如何完成排序?
将想要排序的栈记为stack
,申请的辅助栈记为help
,在stack
上执行pop
操作,弹出的元素记为current
:1. 如果current
小于或等于当前help
当前的栈顶元素,则将current
直接压入到help
栈中。2. 如果current
大于help
的栈顶元素,则将help
中的元素逐渐弹出,并且重新压回到stack
中,直到current
小于等于help
的栈顶元素,将current
压入到help
当中。3. 重复执行上述操作,直到stack
中全部的元素全部压入到help
中,最后只用将help
中的元素再重新压回到stack
中。
- 案例五:给定一个整型数组
arr
和一个大小为w
的窗口,从数组的最左边滑到最右边,窗口每次向右边滑一个位置。返回一个长度为n-w+1
的数组res,res[i]表示每一种窗口状态下的最大值。
以数组为[4,3,5,4,3,3,6,7]
,w=3
为例。因为第一个窗口[4,3,5]
的最大值为5
,第二个窗口[3,5,4]
的最大值为5
,第三个窗口[5,4,3]
的最大值为5
,第四个窗口[4,3,3]
的最大值为4
,第五个窗口[3,3,6]
的最大值为6
,第六个窗口[3,6,7]
的最大值为7
,最终返回[5,5,5,4,6,7]
。
如果数组长度为 N N N,窗口大小为 w w w,普通解法的时间复杂度为 O ( N × w ) O(N \times w) O(N×w),也就是每次对每一个窗口遍历其中的 w w w个数,选出最大值。本题的最优解可以做到时间复杂度 O ( N ) O(N) O(N)。关键之处在于利用双端队列来实现窗口最大值的更新:
首先生成双端队列
q
m
a
x
=
{
}
qmax = \{\}
qmax={},双端队列存放着数组中的下标值,假设当前数为arr[i]
,
放入规则如下:
- 如果
qmax
为空,直接把下标i
放入qmax
中; - 如果
qmax
不为空,取出当前qmax
队尾存放的下标j
。如果arr[j]>arr[i]
,直接把下标i
放进qmax
的队尾; - 如果
arr[j] <= arr[i]
,则一直从qmax
的队尾弹出下标,直到某个下标在qmax
中对应的值大于arr[i]
,把i
放入qmax
的队尾。
弹出规则为:
如果qmax
队头的下标等于i-w
,说明当前的队头下标已经过期,弹出qmax
当前队头下标即可。
根据如上的放入和弹出规则,qmax
就成为了一个维护窗口为w
的数组最大值更新结构。
-
案例六:给定一个没有重复元素的数组
arr
,写出生成这个数组的MaxTree
的函数。要求如果数组长度为N
,则时间复杂度为 O ( N ) O(N) O(N),额外空间复杂度为 O ( N ) O(N) O(N)、额外空间复杂度为 O ( N ) O(N) O(N)。MaxTree
的概念如下: -
MaxTree
是一颗二叉树,数组的每一个值对应一个二叉树节点。 -
包括
MaxTree
树在内且在其中的每一颗子树上,值最大的节点都是树的头。
基础练习
1. 栈、队列知识要点与实现(数组、链表)
2. 使用队列实现栈(LeetCode 232. Implement Queue using Stacks)
3. 使用栈实现队列(LeetCode 225. Implement Stack using Queues)
4. 包含min函数的栈(LeetCode 155. Min Stack)
5. 简单的计算器(栈的应用)( LeetCode 224. Basic Calculator)
6. 堆(优先级队列)知识要点与实现
7. 数组中第K大的数(堆的应用) (LeetCode 215. Kth Largest Element in an Array)
8. 寻找中位数(堆的应用)( LeetCode 295 Find Median from Data Stream)
队列
队列是先近先出的数据结构(FIFO
)。队列通常支持两种主要操作:插入(insert
)操作也称作入队(enqueue
),新元素始终被添加在队列的末尾。 删除(delete
)操作也被称为出队(dequeue
)。 你只能移除第一个元素。实现队列经常用到链表。入队操作就是将节点添加到链表头,出队操作就是从链表尾删除节点,当然也可以用数组实现。
- 队列提供的操作有:
- 查询队首;
- 弹出队首;
- 向尾部压入元素。
#include <iostream>
class MyQueue {
private:
// store elements
vector<int> data;
// a pointer to indicate the start position
int p_start;
public:
MyQueue() {p_start = 0;}
/** Insert an element into the queue. Return true if the operation is successful. */
bool enQueue(int x) {
data.push_back(x);
return true;
}
/** Delete an element from the queue. Return true if the operation is successful. */
bool deQueue() {
if (isEmpty()) {
return false;
}
p_start++;
return true;
};
/** Get the front item from the queue. */
int Front() {
return data[p_start];
};
/** Checks whether the queue is empty or not. */
bool isEmpty() {
return p_start >= data.size();
}
};
int main() {
MyQueue q;
q.enQueue(5);
q.enQueue(3);
if (!q.isEmpty()) {
cout << q.Front() << endl;
}
q.deQueue();
if (!q.isEmpty()) {
cout << q.Front() << endl;
}
q.deQueue();
if (!q.isEmpty()) {
cout << q.Front() << endl;
}
}
上述实现方法效率低下。 随着起始指针的移动,浪费了越来越多的空间。 当我们有空间限制时,这将是难以接受的。因此考虑设计循环队列。
循环队列
更有效的方法是循环队列的方式。具体来说,我们可以使用固定大小的数组和两个指针来指示起始位置和结束位置。 目的是重用我们之前提到的被浪费的存储。
class MyCircularQueue {
private:
vector<int> data;
int size;
int head;
int tail;
int capacity;
public:
/** Initialize your data structure here. Set the size of the queue to be k. */
MyCircularQueue(int k) {
data.resize(k);
size=k;
head=0;
tail=0;
capacity=0;
}
/** Insert an element into the circular queue. Return true if the operation is successful. */
bool enQueue(int value) {
if(capacity<size){
data[tail]=value;
tail = (tail+1)%size;
++capacity;
return true;
} else return false;
}
/** Delete an element from the circular queue. Return true if the operation is successful. */
bool deQueue() {
if(capacity>0){
head=(head+1)%size;
--capacity;
return true;
} else return false;
}
/** Get the front item from the queue. */
int Front() {
if(!isEmpty()){
return data[head];
} else return -1;
}
/** Get the last item from the queue. */
int Rear() {
if(!isEmpty()){
return tail==0?data[size-1]:data[tail-1];
} else return -1;
}
/** Checks whether the circular queue is empty or not. */
bool isEmpty() {
return capacity==0;
}
/** Checks whether the circular queue is full or not. */
bool isFull() {
return capacity==size;
}
};
/**
* Your MyCircularQueue object will be instantiated and called as such:
* MyCircularQueue* obj = new MyCircularQueue(k);
* bool param_1 = obj->enQueue(value);
* bool param_2 = obj->deQueue();
* int param_3 = obj->Front();
* int param_4 = obj->Rear();
* bool param_5 = obj->isEmpty();
* bool param_6 = obj->isFull();
*/
这里的程序要注意用Rear()
函数取最后一个元素的时候,由于tail
需要减1
,因此当tail
等于0
的时候减1
是会越界的,因此需要处理一下为0
的特殊情况。
大多数的编程语言都有内置的队列库,我们无需重新造轮子。队列最基本的两个操作就是入队(enqueue
)和出队(dequeue
)。其用法如下:
#include <iostream>
int main() {
// 1. Initialize a queue.
queue<int> q;
// 2. Push new element.
q.push(5);
q.push(13);
q.push(8);
q.push(6);
// 3. Check if queue is empty.
if (q.empty()) {
cout << "Queue is empty!" << endl;
return 0;
}
// 4. Pop an element.
q.pop();
// 5. Get the first element.
cout << "The first element is: " << q.front() << endl;
// 6. Get the last element.
cout << "The last element is: " << q.back() << endl;
// 7. Get the size of the queue.
cout << "The size is: " << q.size() << endl;
}
堆
我的微信公众号名称:小小何先生
公众号介绍:主要研究分享深度学习、机器博弈、强化学习等相关内容!期待您的关注,欢迎一起学习交流进步!