@richey
2020-09-29T08:04:34.000000Z
字数 13311
阅读 1091
未分类
在计算机的发展历史上,众多“大牛”孜孜不倦地发明创造了这么多的数据结构,为什么呢?因为没有一种数据结构是万能的、可以应用于任何场景。毕竟,不同的数据结构存储数据的形式不一样,效率也就不一样。有的是连续存放,有的是分散存放,有的存储效率高,有的查找效率高,我们必须要依据具体的应用场合来进行取舍。
线性表和非线性表
线性表(Linear List)
顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。
除了数组,链表、队列、栈等也是线性表结构。

非线性表
比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。




int main(int argc, char* argv[]){int i = 0;int arr[3] = {0};for(; i<=3; i++){arr[i] = 0;printf("hello world\n");}return 0;}
因为,数组大小为 3,a[0],a[1],a[2],而我们的代码因为书写错误,导致 for 循环的结束条件错写为了 i<=3 而非 i<3,所以当 i=3 时,数组 a[3]访问越界。
在 C 语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的。根据我们前面讲的数组寻址公式,a[3]也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么a[3]=0就相当于i=0,所以就会导致代码无限循环。
容器能否完全替代数组?


与数组一样,链表也支持数据的查找、插入和删除操作。

针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O(1)。
链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。
循环链表是一种特殊的单链表。实际上,循环链表也很简单。它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。从我画的循环链表图中,你应该可以看出来,它像一个环一样首尾相连,所以叫作“循环”链表。



#include <stdio.h>#include <stdbool.h>struct single_list {struct single_list *next;int val;};struct single_list_head {struct single_list *head;};bool is_empty(struct single_list_head *head){return head->head == NULL;}void dump(struct single_list_head *head){struct single_list *tmp = head->head;int idx = 0;while (tmp) {printf("[%02d]: %08d\n", idx++, tmp->val);tmp = tmp->next;}}void insert(struct single_list **prev, struct single_list *elem){if (!prev)return;elem->next = *prev;*prev = elem;}void insert_head(struct single_list_head *head, struct single_list *elem){insert(&head->head, elem);}struct single_list* del(struct single_list **prev){struct single_list *tmp;if (!prev)return NULL;if (*prev == NULL)return NULL;tmp = *prev;*prev = (*prev)->next;tmp->next = NULL;return tmp;};struct single_list* delete_head(struct single_list_head* head){return del(&head->head);};struct single_list** search(struct single_list_head* head, int target){struct single_list **prev, *tmp;for (prev = &head->head, tmp = *prev;tmp && (tmp->val < target);prev = &tmp->next, tmp = *prev);return prev;};void reverse(struct single_list_head* head){struct single_list_head tmp = {NULL};struct single_list *elem;while (!is_empty(head)) {elem = delete_head(head);insert_head(&tmp, elem);}head->head = tmp.head;}bool is_cyclic(struct single_list_head* head){struct single_list *s1, *s2;s1 = s2 = head->head;while(s1 && s2) {s1 = s1->next;s2 = s2->next ? s2->next->next:s2->next;if (s1 == s2)return true;}return false;}struct single_list* middle(struct single_list_head* head){struct single_list *s1, *s2;struct single_list pseudo_head;pseudo_head.next = head->head;s1 = s2 = &pseudo_head;while (true) {if (!s2 || !s2->next)return s1;s1 = s1->next;s2 = s2->next->next;}return NULL;};int main(){struct single_list_head head = {NULL};struct single_list lists[10];struct single_list **prev;int idx;for (idx = 0; idx < 10; idx++) {lists[idx].val = idx;lists[idx].next = NULL;}insert_head(&head, &lists[6]);insert_head(&head, &lists[5]);insert_head(&head, &lists[4]);insert_head(&head, &lists[1]);insert_head(&head, &lists[0]);printf("=== insert 0, 1, 4, 5, 6\n");dump(&head);prev = search(&head, 2);insert(prev, &lists[2]);printf("=== insert 2\n");dump(&head);printf("middle elem is %d\n", middle(&head)->val);prev = search(&head, 2);if ((*prev) && ((*prev)->val == 2))printf("The list contains 2\n");elseprintf("The list not contains 2\n");del(prev);prev = search(&head, 2);printf("After remove 2\n");if ((*prev) && ((*prev)->val == 2))printf("The list contains 2\n");elseprintf("The list not contains 2\n");dump(&head);printf("After reverse \n");reverse(&head);dump(&head);printf("middle elem is %d\n", middle(&head)->val);lists[0].next = &lists[6];printf("list is%s cyclic\n", is_cyclic(&head)?"":" not");return 0;}

// 基于数组实现的顺序栈public class ArrayStack {private String[] items; // 数组private int count; // 栈中元素个数private int n; //栈的大小// 初始化数组,申请一个大小为n的数组空间public ArrayStack(int n) {this.items = new String[n];this.n = n;this.count = 0;}// 入栈操作public boolean push(String item) {// 数组空间不够了,直接返回false,入栈失败。if (count == n) return false;// 将item放到下标为count的位置,并且count加一items[count] = item;++count;return true;}// 出栈操作public String pop() {// 栈为空,则直接返回nullif (count == 0) return null;// 返回下标为count-1的数组元素,并且栈中元素个数count减一String tmp = items[count-1];--count;return tmp;}}
int main() {int a = 1;int ret = 0;int res = 0;ret = add(3, 5);res = a + ret;printf("%d", res);reuturn 0;}int add(int x, int y) {int sum = 0;sum = x + y;return sum;}

表达式求值
实际上,编译器就是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。

栈在括号匹配中的应用
我们假设表达式中只包含三种括号,圆括号 ()、方括号[]和花括号{},并且它们可以任意嵌套。比如,{[] ()[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式。
那我现在给你一个包含三种括号的表达式字符串,如何检查它是否合法呢?这里也可以用栈来解决。我们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
当需要申请资源的时候,可能没有空闲资源了,这时候就需要排队,这就是队列的应用场景。
队列这个概念非常好理解。你可以把它想象成排队买票,先来的先买,后来的人只能站末尾,不允许插队。先进者先出,这就是典型的“队列”。
我们知道,栈只支持两个基本操作:入栈 push()和出栈 pop()。
队列跟栈非常相似,支持的操作也很有限,最基本的操作也是两个:入队 enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素。
所以,队列跟栈一样,也是一种操作受限的线性表数据结构。
队列的应用也非常广泛,特别是一些具有某些额外特性的队列,比如循环队列、阻塞队列、并发队列。它们在很多偏底层系统、框架、中间件的开发中,起着关键性的作用。比如高性能队列 Disruptor、Linux 环形缓存,都用到了循环并发队列。
跟栈一样,队列可以用数组来实现,也可以用链表来实现。用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。同样,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。
// 用数组实现的队列public class ArrayQueue {// 数组:items,数组大小:nprivate String[] items;private int n = 0;// head表示队头下标,tail表示队尾下标private int head = 0;private int tail = 0;// 申请一个大小为capacity的数组public ArrayQueue(int capacity) {items = new String[capacity];n = capacity;}// 入队public boolean enqueue(String item) {// 如果tail == n 表示队列已经满了if (tail == n) return false;items[tail] = item;++tail;return true;}// 出队public String dequeue() {// 如果head == tail 表示队列为空if (head == tail) return null;// 为了让其他语言的同学看的更加明确,把--操作放到单独一行来写了String ret = items[head];++head;return ret;}}
对于栈来说,我们只需要一个栈顶指针就可以了。但是队列需要两个指针:一个是 head 指针,指向队头;一个是 tail 指针,指向队尾。你可以结合下面这幅图来理解。当 a、b、c、d 依次入队之后,队列中的 head 指针指向下标为 0 的位置,tail 指针指向下标为 4 的位置。

当我们调用两次出队操作之后,队列中 head 指针指向下标为 2 的位置,tail 指针仍然指向下标为 4 的位置。

随着不停地进行入队、出队操作,head 和 tail 都会持续往后移动。当 tail 移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了。这个问题该如何解决呢?

接下来,我们再来看下基于链表的队列实现方法。基于链表的实现,我们同样需要两个指针:head 指针和 tail 指针。它们分别指向链表的第一个结点和最后一个结点。如图所示,入队时,tail->next= new_node, tail = tail->next;出队时,head = head->next。

我们刚才用数组来实现队列的时候,在 tail==n 时,会有数据搬移操作,这样入队操作性能就会受到影响。那有没有办法能够避免数据搬移呢?我们来看看循环队列的解决思路。循环队列,顾名思义,它长得像一个环。原本数组是有头有尾的,是一条直线。现在我们把首尾相连,扳成了一个环。

图中这个队列的大小为 8,当前 head=4,tail=7。当有一个新的元素 a 入队时,我们放入下标为 7 的位置。但这个时候,我们并不把 tail 更新为 8,而是将其在环中后移一位,到下标为 0 的位置。当再有一个元素 b 入队时,我们将 b 放入下标为 0 的位置,然后 tail 加 1 更新为 1。所以,在 a,b 依次入队之后,循环队列中的元素就变成了下面的样子:

通过这样的方法,我们成功避免了数据搬移操作。
看起来不难理解,但是循环队列的代码实现难度要比前面讲的非循环队列难多了。要想写出没有 bug 的循环队列的实现代,最关键的是,确定好队空和队满的判定条件。在用数组实现的非循环队列中,队满的判断条件是 tail == n,队空的判断条件是 head == tail。
那针对循环队列,如何判断队空和队满呢?

就像我图中画的队满的情况,tail=3,head=4,n=8,所以总结一下规律就是:(3+1)%8=4。多画几张队满的图,你就会发现,当队满时,(tail+1)%n=head。
public class CircularQueue {// 数组:items,数组大小:nprivate String[] items;private int n = 0;// head表示队头下标,tail表示队尾下标private int head = 0;private int tail = 0;// 申请一个大小为capacity的数组public CircularQueue(int capacity) {items = new String[capacity];n = capacity;}// 入队public boolean enqueue(String item) {// 队列满了if ((tail + 1) % n == head) return false;items[tail] = item;tail = (tail + 1) % n;return true;}// 出队public String dequeue() {// 如果head == tail 表示队列为空if (head == tail) return null;String ret = items[head];head = (head + 1) % n;return ret;}}


在 C++ 里,容器就是这个公式里面的“数据结构”。
容器,其实就是 C++ 对数据结构的抽象和封装。
array、vector、deque、list、forward_list。
array<int, 2> arr; // 初始一个array,长度是2assert(arr.size() == 2); // 静态数组的长度总是2vector<int> v(2); // 初始一个vector,长度是2for(int i = 0; i < 10; i++) {v.emplace_back(i); // 追加多个元素}assert(v.size() == 12); // 长度动态增长到12
deque 也是一种可以动态增长的数组,它和 vector
的区别是,它可以在两端高效地插入删除元素,这也是它的名字 double-end queue 的来历,而 vector 则只能用 push_back 在末端追加元素。
deque<int> d; // 初始化一个deque,长度是0d.emplace_back(9); // 末端添加一个元素d.emplace_front(1); // 前端添加一个元素assert(d.size() == 2); // 长度动态增长到2

set/multiset 和 map/multimap。set 是集合,map 是关联数组(在其他语言里也叫“字典”)。有 multi 前缀的容器表示可以容纳重复的key,内部结构与无前缀的相同,所以也可以认为只有两种有序容器。
template<class T // 模板参数只有一个元素类型> class vector; // vectortemplate<class Key, // 模板参数是key类型,即元素类型class Compare = std::less<Key> // 比较函数> class set; // 集合template<class Key, // 第一个模板参数是key类型class T, // 第二个模板参数是元素类型class Compare = std::less<Key> // 比较函数> class map; // 关联数组
C++ 里的 int、string 等基本类型都支持比较排序,放进有序容器里毫无问题。但很多自定义类型没有默认的比较函数,要作为容器的 key 就有点麻烦。虽然这种情况不多见,但有的时候还真是个“刚性需求”。
除了比较函数这点,有序容器其实没有什么太多好说的,因为就这两个,选择起来很简单:集合关系就用 set,关联数组就用 map。
因为有序容器在插入的时候会自动排序,所以就有隐含的插入排序成本,当数据量很大的时候,内部的位置查找、树旋转成本可能会比较高。还有,如果你需要实时插入排序,那么选择 set/map 是没问题的。如果是非实时,那么最好还是用 vector,全部数据插入完成后再一次性排序,效果肯定会更好。
无序容器也有四种,名字里也有 set 和 map,只是加上了 unordered(无序)前缀,分别是 unordered_set/unordered_multiset、unordered_map/unordered_multimap。
无序容器同样也是集合和关联数组,用法上与有序容器几乎是一样的,区别在于内部数据结构:它不是红黑树,而是散列表(也叫哈希表,hash table)。
因为它采用散列表存储数据,元素的位置取决于计算的散列值,没有规律可言,所以就是“无序”的,你也可以把它理解为“乱序”容器。下面的代码简单示范了无序容器的操作,虽然接口与有序容器一样,但输出元素的顺序是不确定的乱序:
using map_type = // 类型别名unordered_map<int, string>; // 使用无序关联数组map_type dict; // 定义一个无序关联数组dict[1] = "one"; // 添加三个元素dict.emplace(2, "two");dict[10] = "ten";for(auto& x : dict) { // 遍历输出cout << x.first << "=>" // 顺序不确定<< x.second << ","; // 既不是插入顺序,也不是大小序}
无序容器虽然不要求顺序,但是对 key 的要求反而比有序容器更“苛刻”一些,拿 unordered_map 的声明来看一下:
template<class Key, // 第一个模板参数是key类型class T, // 第二个模板参数是元素类型class Hash = std::hash<Key>, // 计算散列值的函数对象class KeyEqual = std::equal_to<Key> // 相等比较函数> class unordered_map;
它要求 key 具备两个条件,一是可以计算 hash 值,二是能够执行相等比较操作。第一个是因为散列表的要求,只有计算 hash 值才能放入散列表,第二个则是因为 hash 值可能会冲突,所以当 hash 值相同时,就要比较真正的 key 值。
有序容器和无序容器的接口基本一样,这两者该如何选择呢?其实看数据结构就清楚了,如果只想要单纯的集合、字典,没有排序需求,就应该用无序容器,没有比较排序的成本,它的速度就会非常快。
