【算法笔记】2.数据结构

链表

结构体指针

链表的第一种表达方式是直接使用指针指向下一节点,节点内容使用结构体包裹。这一方式可读性强,但 newNode() 构造非常慢,一般在面试中为了易读而使用,在笔试时为了性能而一般不用。

单链表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
struct ListNode {
    int val;
    ListNode *next;
    ListNode(int x) : val(x), next(NULL) {}
};

// 添加元素 - 尾插法
void LinkPushBack(LinkNode* head, int x) {
    LinkNode* newNode = LinkNode(x);
    if (head == NULL) {
        head = newNode;
        return;
    }
    LinkNode* pcur = head;
    while (pcur->next) {
        pcur = pcur -> next;
    }
    pcur->next = newNode;
}

数组模拟

链表的另一种表达方式是使用数组模拟链表,不使用结构体,各节点内容单独使用数组。常用于表示树、图的邻接表,代码书写简洁,运行速度快

单链表 例题

 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
int e[N];     // node's value
int ne[N];    // node's next pointer
int head = -1, idx = 0;

// 向链表头插入一个数 x
void addHead(int x) {
    e[idx] = x;
    ne[idx] = head;
    head = idx;
    idx++;
}

// 在第 k 个插入的数后面插入一个数 x(k > 0)
void insert(int k, int x) {
    e[idx] = x;
    ne[idx] = ne[k];
    ne[k] = idx;
    idx++;
}

// 删除第 k 个插入的数后面的数(k > 0)
void remove(int k) {
    ne[k] = ne[ne[k]];
}

// 遍历输出
for (int i = head; i != -1; i = ne[i]) {
    cout << e[i] << " ";
}

双向链表(用于问题优化)例题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int e[N], l[N], r[N], idx;

void init() {
    // 两个特殊节点 0: head  1: tail
    r[0] = 1;
    l[1] = 0;
    idx = 2;
}

// add(k, x)    在第 k 个插入的数 右侧 插入一个数 x
// add(l[k], x) 在第 k 个插入的数 左侧 插入一个数 x
void add(int k, int x) {
    e[idx] = x, l[idx] = k, r[idx] = r[k];
    l[r[k]] = idx;
    r[k] = idx;
    idx++;
}

// 删除第 k 个插入的数
void removeK(int k) {
    r[l[k]] = r[k];
    l[r[k]] = l[k];
}

栈 & 队列

数组模拟栈:先进后出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const int N = 1e5+10;
int stk[N], tt = -1;

void push(int x) {
    stk[++tt] = x;
}

void pop() {
   --tt;
}

bool empty() {
    return tt < 0;
}

int query() {
    return stk[tt];
}

数组模拟队列(顺序队列):先进先出,在队尾入,在队头出

当数组有空闲空间,但无法入队时,需要做一次数据搬移操作(链式队列或循环队列没有此问题)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const int N = 1e5+10;
int q[N], hh = 0, tt = -1;    // [head, tail]

void push(int x) {
    q[++tt] = x;
}
 
void pop() {
    hh++;
}

int query() {
    return q[hh];
}

int queryAndPop {
	return q[hh++];
}

bool empty() {
    return tt < hh;         // tt >= hh is not empty
}

单调栈 & 单调队列

单调栈和单调队列的问题:

先想暴力怎么做,再考虑把没有用的元素删掉,再看有没有单调性,有单调性的话再看怎么优化

单调栈 例题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const int N = 1e5 + 10;
int stk[N], tt = -1;

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        while (tt >= 0 && x <= stk[tt]) tt--;
        if (tt < 0) cout << -1 << " ";
        else cout << stk[tt] << " ";
        stk[++tt] = x;
    }
    return 0;
}

单调队列 例题

 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
// 求滑动窗口中的最小值
const int N = 1e6+10;
int a[N];

int main() {
    int n, k;
    cin >> n >> k;
    for (int i = 0; i < n; i++) cin >> a[i];
    
    // MIN
    deque<int> deq;   // 双向队列存下标
    for (int i = 0; i < n; i++) {
        // 判断队头是否滑出窗口
        if (!deq.empty() && i - k + 1 > deq.front()) deq.pop_front();
        
        while(!deq.empty() && a[deq.back()] >= a[i]) deq.pop_back();
        
        deq.push_back(i);
        
        if (i >= k - 1) cout << a[deq.front()] << ' ';
    }
    cout << '\n';
    
    return 0;   
}

堆是完全二叉树,小根堆中每个节点的值小于两个子节点,建堆时间复杂度O(n)。堆常用于求 TopK,优先队列,堆排序。

基础 down 与 up 操作实现(数组下标从 1 开始):

 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
void down(int u) {
    int l = u * 2, r = u * 2 + 1;   // 下标从 1 开始对应的左右儿子
    int t = u;  // t 用于记录值最小的节点
    if (l <= hsize && h[l] < h[t]) t = l;
    if (r <= hsize && h[r] < h[t]) t = r;
    if (t != u) {
        swap(h[u], h[t]);
        down(t);
    }
}

// down 的迭代写法
void down_iter(int u) {
	int i = u;
	while (i < hsize) {
		int t = i;
		int l = 2 * i + 1, r = 2 * i + 2;
		if (l < hsize && h[l] > h[t]) t = l;
		if (r < hsize && h[r] > h[t]) t = r;
		if (t == i) break;
		swap(h[i], h[t]);
		i = t;
	}
}

void up(int u) {
    while (u / 2 && h[u / 2] > h[u]) {
        swap(u / 2, u);
        u /= 2;
    }
}

建堆的两种方式

  • HeapInsert (up) $O(nlogn)$:假定我们事先不知道有多少个元素,通过不断往堆里面插入元素进行调整来构建堆
  • Heapify (down) $O(n)$:从最后一个非叶子节点一直到根结点进行堆化的调整
1
2
3
4
5
6
7
8
9
// 从第一个节点开始遍历,直到最后一个节点,向上调整
for (int i = 1; i <= n; i++) {
	up(i);
}

// 从最后一个非叶节点开始,不断向下调整
for (int i = n / 2; i; i--) {
	down(i);
}

堆排序 例题 | LeetCode 912. 排序数组

1)使用小根堆直接输出顺序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const int N = 1e5+10;
int h[N], hsize;    // h 数组下标从 1 开始

// 建堆,下标从 1 开始
for (int i = 1; i <= n; i++) cin >> h[i];
hsize = n;
for (int i = n / 2; i; i--) down(i);

// 依次输出前 M 小的数
while (m--) {
    // 输出当前最小值
    cout << h[1] << ' ';
    
    // 删去当前最小值(把最后一个数放在第一的位置 hsize--)再 down(1) 后得到新的最小值
    h[1] = h[hsize];
    hsize--;
    down(1);
}

2)使用大根堆对原数组排序

 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
class Solution {
public:
    vector<int> sortArray(vector<int>& nums) {
        heapSort(nums);
        return nums;
    }
    void heapSort(vector<int>& nums) {
        int n = nums.size();
        // 建大根堆
        for (int i = n / 2 - 1; i >= 0; i--) {
            down(nums, i, n);
        }
        // 堆排序
        for (int i = n - 1; i > 0; i--) {
            // 将堆顶元素弹出,和堆的末尾元素交换,此时 [i, n-1] 为排序后的区间,[0, i-1] 为堆的元素范围
            swap(nums[0], nums[i]);
            down(nums, 0, i);    // 维护堆
        }
    }
    void down(vector<int>& nums, int u, int hsize) {
        int n = nums.size();
        int t = u, l = u * 2 + 1, r = u * 2 + 2;
        if (l < hsize && nums[l] > nums[t]) t = l;
        if (r < hsize && nums[r] > nums[t]) t = r;
        if (t != u) {
            swap(nums[t], nums[u]);
            down(nums, t, hsize);
        }
    }
};

堆的完全模拟(维护堆的坐标映射-不常用) 例题

 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
52
53
54
55
56
const int N = 1e5+10;
int h[N], hsize;    // h 数组下标从 1 开始
int ph[N]; // ph[k] = j; 第 k 个插入的数的下标 j,直接使用
int hp[N]; // hp[j] = k; 下标为 j 的数是第 k 个插入的,维护 ph[] 需要使用

void heap_swap(int a, int b) {
    swap(ph[hp[a]], ph[hp[b]]);
    swap(hp[a], hp[b]);
    swap(h[a], h[b]);
}

// down 递归(堆的初始排序使用,顺序打印对根节点使用 down(1),删除更新可能使用)
void down(int u) {
    int l = u * 2, r = u * 2 + 1;   // 下标从 1 开始对应的左右儿子
    int t = u;  // t 用于记录值最小的节点
    if (l <= hsize && h[l] < h[t]) t = l;
    if (r <= hsize && h[r] < h[t]) t = r;
    if (t != u) {
        heap_swap(u, t);
        down(t);
    }
}

// up 非递归(建堆完成后,添加一个新元素使用 up(++hsize),删除更新可能使用)
void up(int u) {
    while (u / 2 && h[u / 2] > h[u]) {
        heap_swap(u / 2, u);
        u /= 2;
    }
}

if (op == "I") {            // insert
    cin >> x;
    idx++, hsize++;
    ph[idx] = hsize;
    hp[hsize] = idx;
    h[hsize] = x;
    up(hsize);
} else if (op == "PM") {    // print minus
    cout << h[1] << '\n';
} else if (op == "DM") {    // delete minus
    heap_swap(1, hsize);
    hsize--;
    down(1);
} else if (op == "D") {     // delete k_th
    cin >> k;
    k = ph[k];
    heap_swap(k, hsize);
    hsize--;
    down(k), up(k); // 最多执行一个
} else {                    // assign k_th = x
    cin >> k >> x;
    k = ph[k];
    h[k] = x;
    down(k), up(k); // 最多执行一个
}

KMP

KMP 用于在文本串中快速查找一个模式串的出现位置 例题

 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
#include <iostream>
using namespace std;

const int M = 1e6+10, N = 1e5+10;
char s[M], p[N];    // 主串 模式串,下标从 1 开始,可简化代码
int ne[N];          // 针对模式串 P 而言,记录前缀和后缀相同的最长长度

int main() {
    int n, m;
    cin >> n >> p + 1 >> m >> s + 1;
    
    // get next, ne[1~n]
    // ne[j] = [1~j] 最长 相同 前-后缀
    // ne[1] = 0
    for (int i = 2, j = 0; i <= n; i++) {
        while (j && p[i] != p[j + 1]) j = ne[j];
        if (p[i] == p[j + 1]) j++;
        ne[i] = j;
    }
    
    for (int i = 1; i <= n; i++) cout << ne[i] << " ";
    cout << "\n";
    
    // kmp
    for (int i = 1, j = 0; i <= m; i++) {       // i->主串 j->pattern
        while (j && s[i] != p[j + 1]) j = ne[j];
        if(s[i] == p[j + 1]) j++;   // s[i] vs. p[j + 1] 
        // 完全匹配 pattern
        if (j == n) {
            cout << i - n << " ";
            j = ne[j];  // 继续匹配下一个
        }
    }
    return 0;
}

哈希表

哈希表的操作一般只是插入和查询,很少删除,实际删除也是打标签 flag = false。 Mod 选尽可能大的质数,且离 2 的整次幂尽可能远,保证冲突最小。

哈希冲突

解决哈希冲突的算法有两种:拉链法、开放寻址法 例题

  • 拉链法简单易于实现,每个槽可存储多个元素,可以处理任意数量的哈希冲突,但维护额外的链表结构导致内存消耗较高。

  • 开放寻址法节省了额外的链表结构所需的内存,由于元素存储在连续的内存位置上,有助于提高缓存命中率,从而提高访问效率。当哈希表的装载因子较高时(元素数量接近槽的数量),会导致冲突概率增加,进而影响性能。

拉链法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const int N = 100003;           // 大于 1e5 的第一个质数
int h[N];                       // 存储节点的下标,拉链槽,h[k] 指向 hash=k 的链表头
int e[N], ne[N], idx = 0;       // 链表

// 初始化链表头为 -1
memset(h, -1, sizeof h);

void insert(int x) {
    int k = (x % N + N) % N;    // 保证 k 为正数
    e[idx] = x;                 // 新分配一个节点
    ne[idx] = h[k];             // 新节点插在链表头前
    h[k] = idx++;               // 更新头结点
}

bool query(int x) {
    int k = (x % N + N) % N;
    for (int i = h[k]; i != -1; i = ne[i]) {
        if (e[i] == x) return true;
    }
    return false;
}

开放寻址法(线性探测):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const int N = 200003;           // Mod 一般是 N 的 2~3 倍
const int null = 0x3f3f3f3f;
int h[N];

// 初始化链表头为 null (> 1e9)
memset(h, 0x3f, sizeof h);

// 返回找到的 or 没找到可插入的地址
int search(int x) {
    int k = (x % N + N) % N;
    while (h[k] != null && h[k] != x) {
        k++;
        if (k == N) k = 0;  // 循环
    }
    return k;
}

int t = search(x);
// 插入
h[t] = x;
// 查询
if (h[t] != null) puts("Yes");
else puts("No");

字符串前缀哈希

字符串前缀哈希用于快速求解任意的子串的哈希值,可以快速判断两个字符串是否相等。 例题

模式匹配需要至少 $O(n)$,而字符串哈希只需要 $O(1)$)

字符串哈希是把字符串变成一个 p 进制数字(哈希值),实现不同的字符串映射到不同的数字的算法。 即对字符串 $x_1x_2x_3⋯x_{n−1}x_n$,采用字符的 ascii 码乘上 P 的次方来计算哈希值,映射公式如下:

$(x_1×P^{n−1}+x_2×P^{n−2}+⋯+x_{n−1}×P^1+x_n×P^0) \bmod Q$

  1. 字符不能映射为 0,否则哈希值无法区分字符串,比如 A,AA,AAA 皆为0
  2. 设置以下参数,则默认不存在冲突
    • P = 131 or 13331
    • Q = $2^{64}$,直接用 ULL 存哈希结果,达到取模 $2^{64}$ 的效果

朴素的哈希方法每次需要重新计算,当子串查询太多则会导致超时,这里可以预处理哈希值前缀和数组,空间换时间:

  • 前缀和公式 $h[i+1]=h[i]×P+s[i] i∈[0,n−1]$,h为前缀和数组,s为字符串数组

  • 区间和公式 $h[l,r]=h[r]−h[l−1]×P[r−l+1]$

区间和公式举例,求 ABCDE 中 $DE[4,5]$ 的哈希值 $h[5] - h[3] * P^2$,含义如下:

$ABC$ 经过 $* P^2$ 操作变为 $ABC00$,$ABCDE - ABC00$ 就得到 DE 的哈希值。

 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
#include <iostream>
using namespace std;
typedef unsigned long long ULL;

const int N = 1e5+10, P = 131;
ULL h[N];   // 字符串前缀哈希
ULL p[N];   // P 的幂

// [3, 6] -> h[6]-h[2]*p[4]
ULL get(int l, int r) {
    return h[r] - h[l - 1] * p[r - l + 1];
}

int main() {
    int n, m;
    char str[N];
    cin >> n >> m >> str + 1;
    
    p[0] = 1;   // P^0 = 1
    for (int i = 1; i <= n; i++) {
        // 预处理 P^n
        p[i] = p[i - 1] * P;
        // str[i] 可为任意非0值
        h[i] = h[i - 1] * P + str[i];
    }
    
    int l1, l2, r1, r2;
    while (m--) {
        cin >> l1 >> r1 >> l2 >> r2;
        if (get(l1, r1) == get(l2, r2)) puts("Yes");
        else puts("No");
    }
    
    return 0;
}

Trie 树

Trie树 用于高效存储和查找字符串集合

树节点为字母 例题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const int N = 1e5 + 10;          // 所有输入的字符串总长度不超过 10^5
int son[N][26], cnt[N], idx;     // 根节点和空节点下标都是 0

// 插入字符串
void insert(string s) {
    int p = 0;          // 树的节点下标
    for (int i = 0; s[i]; i++) {
        int u = s[i] - 'a';
        if (son[p][u] == 0) son[p][u] = ++idx;
        p = son[p][u];  // 迭代向下
    }
    cnt[p]++;
}

// 查询字符串出现次数
int query(string s) {
    int p = 0;
    for (int i = 0; s[i]; i++) {
        int u = s[i] - 'a';
        if (son[p][u] == 0) return 0;
        p = son[p][u];
    }
    return cnt[p];
}

树节点为数字 例题

 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 = 1e5+10, M = N * 31;   // M 节点个数 1e5 * 31 
int son[M][2], idx;                 // 根节点和空节点都是 0
int a[N];

// 按位插入数字,其中 x 有效31位
void insert(int x) {
    int p = 0;
    for (int i = 30; i >= 0; i--) {
        int t = (x >> i) & 1;
        if (son[p][t] == 0) son[p][t] = ++idx;
        p = son[p][t];
    }
}

// 求与 x 异或最大的值(贪心)
int query(int x) {
    int p = 0, res = 0;
    for (int i = 30; i >= 0; i--) {
        int t = (x >> i) & 1;
        if (son[p][!t] != 0) {
            res = res * 2 + 1;
            p = son[p][!t];
        } else {
            res = res * 2;
            p = son[p][t];
        }
    }
    return res;
}

Trie 的结构体写法,速度上会慢一些,注意 query() 中的 res 更新使用了新的方法。

 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
const int N = 1e5+10, M = N * 31;
int a[N];

struct Node {
    Node *next[2];
    // bool tag;
    Node () {
        memset(next, NULL, sizeof next);
        // tag = false;
    }
};

void insert(int x, Node *root) {
    Node *p = root;
    for (int i = 30; i >= 0; i--) {
        int t = (x >> i) & 1;
        if (p->next[t] == NULL) p->next[t] = new Node();
        p = p->next[t];
    }
}

// 求与 x 异或最大的值(贪心)
int query(int x, Node *root) {
    Node *p = root;
    int res = 0;
    for (int i = 30; i >= 0; i--) {
        int t = (x >> i) & 1;
        if (p->next[!t] != NULL) {
            res = res + (1 << i);
            p = p->next[!t];
        } else {
            p = p->next[t];
        }
    }
    return res;
}

// main
Node* root = new Node();
insert(a[i], root);
query(a[i], root);

并查集

并查集解决的问题:

  1. 合并集合
  2. 判断两元素是否在同一个集合

代码实现上注意两个细节:

  1. 路径压缩 p[x] = p[p[x]] => find() 中实现,常用
  2. 重量平衡,小树挂在大树下 => union() 中实现,一般不用

最 NB 的模板如下:

 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
const int N = 100010;
int p[N];   // 父指针数组
int cnt[N]; // 只保证根节点的 cnt 有意义

// 找根节点 同时 压缩路径(递归)
int Find(int x){   
    if (p[x] != x) p[x] = Find(p[x]);
    return p[x];
}

// 合并集合(不考虑 cnt)
void Union1(int a, int b) {
    p[Find(a)] = Find(b);
}

// 合并集合(考虑 cnt)
void Union2(int a, int b) {
    int x = Find(a), y = Find(b);
    if (x == y) return;   // 已经在一个集合内,需要特判,否则 cnt 出错
    p[x] = y;
    cnt[y] += cnt[x];
}

// 初始化
for (int i = 0; i < N; i++)  {
    p[i] = i;
    cnt[i] = 1;
}

Union() 考虑树的大小

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void doUnion(int p, int q) {
    int x = doFind(p), y = doFind(q);
    if (x == y) return;
    if (cnt[x] > cnt[y]) {  // x 为大树,y 加入 x
        p[y] = x;
        cnt[x] += cnt[y];
    } else {
        p[x] = y;
        cnt[y] += cnt[x];
    }
}

Find() 其他写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
int doFind1(int x) {
    while (p[x] != x) {
        p[x] = p[p[x]];      //路径压缩
        x = p[x];
    }
    return x;
}

int doFind2(int x){
    int a = x;
    while(father[x] != x){
        x = father[x];
    }
    while(father[a] != a){
        int z = father[a];
        father[a] = x;
        a = z;
    }
    return x;
}

树状数组

前缀和数组可以快速 $O(1)$ 求出区间和,但当元素更新时需要以 $O(n)$ 的效率重新维护前缀和数组,此时可以使用树状数组优化,同时实现前缀和查询单点更新这两个操作。LeetCode-307. 区域和检索 - 数组可修改

树状数组,也称作“二叉索引树”(Binary Indexed Tree)或 Fenwick 树

树状数组原理笔记 image.png

从已知子结点的索引 i,则结点 i 的父结点的索引 parent 的计算公式为:

$$parent(i)=i+lowbit(i)$$

代码模板如下(注意数组下标从 1 开始):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
int lowbit(int x) {
	return x & -x;
}

// 单点更新
void update(int x, int delta) {
	for (int i = x; i <= len; i += lowbit(i)) {
		tree[i] += delta;
	}
}

// 求前缀和
int query_sum(int x) {
	int sum = 0;
	for (int i = x; i > 0; i -= lowbit(i)) {
		sum += tree[i];
	}
	return sum;
}

初始化:树状数组的初始化可以通过单点更新来实现,因为最开始数组每个元素的值都为 0,每个都对应地加上原始数组的值,就完成了预处理数组 C 的创建

1
2
3
4
5
6
7
FenwickTree(vector<int>& nums) {
	int n = nums.size();
	vector<int> tree(n + 1);
	for (int i = 0; i < n; i++) {
		update(i + 1, nums[i]);
	}
}
0%