链表&栈

单链表

双链表

关于链表, 我们采用数组模拟的方式, 而不是以创建 struct 的方式, 因为new 一个 struct 太慢了, 数据量达到百万级的时候性能低

单链表

用途: 创建邻接表. (邻接表的用途是用来存储树或图)

模拟方式如下:

链表中每个点存两个值: ①自己的 val ②下一个节点的指针

采用两个数组, e数组和 ne数组, e数组用来存储当前节点的值, ne数组用来存储当前节点的下一节点的指针(也就是当前节点的下一个节点在什么位置==> 下一个节点的下标是什么)

e[i]: 位置为 i 的节点的 value

ne[i]: 位置为 i 的节点指向的下一个节点的位置, 还是一个下标

e数组和 ne数组关联的方式是下标来关联

那么对于链表的每个点, 他有两个值, 一个是 e, 一个是 ne, 他们使用下标关联起来的

数组模拟单链表

构造一个单链表并给基本的操作方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const int N = 100010
int e[N], ne[N], head, idx;
void init(){
  head = -1;
  idx = 0;
}
void addToHead(int x) {
  e[idx] = x;
  ne[idx] = head;
  head = idx;
  idx++;
}
void add(int k, int x) {
  e[idx] = x;
  ne[idx] = ne[k];
  ne[k] = idx;
  idx++;
}
void remove(int k) {
  ne[k] = ne[ne[k]];
}

注释版

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//构建静态链表的大小----数组模拟链表就是构建了一个静态链表
const int N = 100010;
//head始终表示头结点的下标, idx 表示的是当前操作节点的下标
int e[N], ne[N], head, idx;

//初始化链表
void init(){
    //head=-1说明当前为空集
    head = -1;
    //idx=0表示要操作的节点的下标为 0
    idx = 0;
}

//向链表头插入一个数;
void insertHead(int x) {
    /*
    1. 把这个数存到节点里
    2. 让这个节点指向当前的头结点
    3. 让 head 指向当前节点
    4. idx++, 让他指向下一个要操作的节点
    */
    e[idx] = x;
    ne[idx] = head;
    head = idx;
    idx++;
}

// 删除下标为 k 的后一个节点
void remove(int k) {
    /*
    1. 找到下标为 k 的节点的下一个节点的下一个节点
    2. 更新下标为 k 的节点的下一个节点
    */
    //ne[k]表示下一个节点的位置, 由于ne[i]表示节点i的下一个节点位置, 所以 ne[ne[k]]表示下标为k的节点的下一个节点的下一个节点
    ne[k] = ne[ne[k]];
}

//在下标为k的节点后插入一个节点
void insert(int k, int x) {
    /*
    1. 把这个数存到节点里
    2. 让当前操作节点指向下标 k 的节点的下一个节点
    3. 让下标为k 的节点指向当前节点
    4. 更新 idx 到下一个要操作的节点, idx++
    */
    e[idx] = x;
    ne[idx] = ne[k];
    ne[k] = idx;
    idx++;
}
 

题目:

单链表

单链表可能只能建立一个数组模拟链表的初步印象, 在"寻找节点"这一点上, 双链表可以加深理解

双链表

用途: 优化某些问题

单链表的每个节点只有一个指针, 这个指针指向后一个节点

双链表每个节点有两个指针, 一个指针指向前, 另一个指针指向后

对于双链表, 我们直接用数组的 0 位表示 head节点, 1 位表示 tail节点, $l[i]$数组表示下标为 i 的节点的左指针, $r[i]$表示下标为 i 的节点的右指针

初始化

让 head节点右指针指向1(tail节点下标), tail节点左指针指向 0(head 节点下标). 表示空链表. 两个点不表示实质内容, 只表示边界节点

添加节点操作

对于在下标为 k 的节点后面添加节点, 需要① 把新的值存进去 ②让新的节点右指针指向 k 节点的右节点 ③让新节点的左指针指向k 节点的右节点的左节点 ④让 k 节点的右节点的左指针指向当前节点 ⑤让 k 节点的右指针指向当前节点

其中④⑤不能调换顺序, 因为我们只能通过 k 先找到右节点, 然后再把k节点的原右节点的左指针指向当前节点. 如果调换顺序我们就无法通过下标和 r[i]找不到 k 节点的原右节点了

对于在下标为 k 的节点前面添加节点, 那么我们就直接找到下标为 k 的节点的左节点, 然后再调用添加节点到右边的方法就可以了

找到下标为 k 的节点的左节点的方式就是通过 k 来找到, 即 $l[k]$

删除节点操作

也就是让下标为 k 的节点的左节点右指针指向下标为 k 的节点的右节点, 让下标为 k 的节点的右节点的左指针指向下标为 k 的节点的左节点.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const int N = 100010;
int e[N], l[N], r[N], idx;
void init(){
 	r[0] = 1;
  l[1] = 0; 
  idx = 2;
}

void add(int k, int x){
	e[idx] = x;
	r[idx] = r[k];
  l[idx] = k;
  l[r[k]] = idx;
  r[k] = idx;
  idx++;
}

void remove(int k) {
  r[l[k]] = r[k];
  l[r[k]] = l[k];
}

为什么没有插入头结点的操作?

因为我们没有空指针, 所以直接使用 add 方法来找到对应的位置即可插入头结点

注释

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//声明静态链表长度, 一般由输入的数据量决定
const int N = 100010;
//e[i]表示下标为i的节点的值, l[i]表示下标为i的节点的左指针(左节点下标), r[i]表示下标为i的节点的右指针(右节点下标), idx 表示正在处理的节点的下标
int e[N], l[N], r[N], idx;
//双链表使用两个节点来初始化, 分别是 head 节点和 tail 节点, 互相指向表示空链表, 同时这两个节点可以定义出链表的边界
void init(){
    //让 head 节点指向 tail 节点
	r[0] = 1;
    //让 tail 节点指向 head 节点
 	l[1] = 0; 
    //由于 0 1 位置被占用了, 所以 idx 从 2 开始
	idx = 2;
}

//add操作表示在 idx 为 k 的节点右边插入节点
void add(int k, int x){
    //存储节点值
	e[idx] = x;
    //让当前操作节点右指针指向下标为k的节点的右节点
	r[idx] = r[k];
    //让当前操作节点左指针指向下标为k的节点
    l[idx] = k;
    //让下标为k的节点的右节点的左指针指向当前节点
    l[r[k]] = idx;
    //让下标为k的节点的右指针指向当前节点 --- 这一步不能和上一步调换
    r[k] = idx;
    //操作下一个节点
    idx++;
}
//注意: 这里的 k 指的值要执行的节点下标而不是说删除第 k 个节点的右一个节点 --- 这是和单链表不同的地方
void remove(int k) {
    //让待删除节点的 左节点的右指针 指向 待删除节点的右节点
    r[l[k]] = r[k];
    //让待删除节点的 右节点的左指针 指向 待删除节点的左节点
    l[r[k]] = l[k];
}

单链表和双链表的区别

我们都是用数组来模拟链表, 但是这里的模拟方式有区别

① 单链表有空指针, 所以有head 可以为 -1

​ 双链表不表示空指针, 而是直接用固定的两个点0 1 表示 head 节点和 tail 节点, 但是这两个节点不存储任何数据, 仅仅用来限制整个链表的边界

② 初始化方法不同

​ 单链表由于存在空指针, 所以一开始直接让 head 为空指针-1

​ 双链表不存在空指针, 所以用两个端点互相指向来完成初始化

③ 单链表的 head 是表示下标(指针的含义); 双链表的 head, tail 表示的是不存储数值的节点

④ 删除节点操作在单链表中指的是删除 idx 为k 的下一个节点, 在双链表中指的是 idx 为 k 的这个节点本身. 添加节点则和单链表意思一致

题目

双链表

数组模拟栈

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const int N = 1000010;
//tt 表示栈顶指针, 这里我们以 0 表示栈中没有元素
int stk[N], tt;
//向栈顶插入元素
stk[ ++ tt ] = x;
//弹出元素
tt--;
//获取栈顶元素
stk[tt];
//判空
if(tt > 0) not empty

队列

数组 q[N] , 使用 head 指向队头, tail 指向队尾.

初始化: head=0, tail=-1;

插入: q[++tail] = val

删除: head++

判空: head<=tail 不为空

读取队头元素: q[head]

单调栈

应用场景: 给定一个序列, 求出这个序列中 每一个数 左边最近的比他小的数

1
2
序列: 	3	4	2	7	5
ans: 	 -1	 3	 -1	 2	 2

思考过程: 先暴力再优化(类似双指针)

暴力: 要找每一个数左边最近的比他小的数, 那么就可以直接遍历每一个数, 然后对于遍历到的数, 遍历其左边的数

1
2
3
4
5
6
7
8
9
for(int i = 0; i< n; i++){
	for(int j = i - 1; j >= 0; j--){
    	if(arr[i] > arr[j]) {
        	cout << arr[j] << endl;
            break;
        }
    }
    cout << "-1" << endl;
}

优化: 对于遍历到的数x, 我们把他左边的数放到栈中, 如果当前栈顶元素大于等于x, 那么就说明栈顶元素肯定不是我们需要的, 所以把栈顶元素弹出, 一直找到小于 x 的栈中元素然后停止弹出, 把这个元素输出来, 接下来把 x 加入栈中.

这样做的好处是我们能保证数 x 加入栈之后, 他的左边一定不会有比他大的数, 那么当我们开始遍历 x 右边的数时, 已经被弹出去的栈元素(大于或等于 x)的元素一定不会成为x右边数的答案, 因为 x 是最近的. 这样, 我们就在栈中构成了一个单调增的序列, 也就是单调栈.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const int N = 100010;
int a[N];
int stk[N], tt = 0;
void pop(){
	tt--;
}
void push(int x) {
	stk[++ tt] = x;
}
bool empty() {
	return !(tt > 0);
}
int query() {
	return stk[tt];
}

void find(int n){
	for(int i = 0; i < n; i++) {
    	while(!empty() && query() >= a[i]) {
        	pop();
        }
        if(empty()) {
    		cout << "-1" << " ";
        }else {
        	cout << query() << " ";
        }
        push(a[i]);
    }
}

单调队列

应用场景: 滑动窗口中的最大值和最小值

单调队列优化过程: 先考虑暴力怎么做? 然后把其中没有用的元素删掉, 得到单调性, 有单调性的话当我们去求极值, 就可以直接从队列中拿第一个点或最后一个点了

单调队列: 队头出队, 队尾出队入队(注意: 单调队列的队尾是可以出队的)

关于单调栈和单调队列的使用方法

我们先考虑使用栈或者队列时暴力做法是什么? 然后把栈或队列中没有用的元素删除, 观察剩下的元素是否有单调性, 有单调性的话再看看怎么优化这个问题