[关闭]
@richey 2020-09-29T08:04:34.000000Z 字数 13311 阅读 926

物联网综合应用讲义(研究生)-03 嵌入式软件设计专题之数据结构

未分类


4 数据结构基础

在计算机的发展历史上,众多“大牛”孜孜不倦地发明创造了这么多的数据结构,为什么呢?因为没有一种数据结构是万能的、可以应用于任何场景。毕竟,不同的数据结构存储数据的形式不一样,效率也就不一样。有的是连续存放,有的是分散存放,有的存储效率高,有的查找效率高,我们必须要依据具体的应用场合来进行取舍。

4.1 数组(Array)

4.1.1 几个基本概念:数组、线性表、非线性表

4.1.2 数组元素的操作:随机访问和插入删除

4.1.3 警惕数组元素的越界访问问题!

  1. int main(int argc, char* argv[]){
  2. int i = 0;
  3. int arr[3] = {0};
  4. for(; i<=3; i++){
  5. arr[i] = 0;
  6. printf("hello world\n");
  7. }
  8. return 0;
  9. }

因为,数组大小为 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,所以就会导致代码无限循环。

4.1.4 容器和数组

容器能否完全替代数组?

4.2 链表(Linked list)

4.2.1 单链表

4.2.2 循环链表

循环链表是一种特殊的单链表。实际上,循环链表也很简单。它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。从我画的循环链表图中,你应该可以看出来,它像一个环一样首尾相连,所以叫作“循环”链表。
image.png-136.8kB

4.2.3 双向链表

4.2.4 双向循环链表

image.png-205kB

4.2.5 链表的代码实现

  1. #include <stdio.h>
  2. #include <stdbool.h>
  3. struct single_list {
  4. struct single_list *next;
  5. int val;
  6. };
  7. struct single_list_head {
  8. struct single_list *head;
  9. };
  10. bool is_empty(struct single_list_head *head)
  11. {
  12. return head->head == NULL;
  13. }
  14. void dump(struct single_list_head *head)
  15. {
  16. struct single_list *tmp = head->head;
  17. int idx = 0;
  18. while (tmp) {
  19. printf("[%02d]: %08d\n", idx++, tmp->val);
  20. tmp = tmp->next;
  21. }
  22. }
  23. void insert(struct single_list **prev, struct single_list *elem)
  24. {
  25. if (!prev)
  26. return;
  27. elem->next = *prev;
  28. *prev = elem;
  29. }
  30. void insert_head(struct single_list_head *head, struct single_list *elem)
  31. {
  32. insert(&head->head, elem);
  33. }
  34. struct single_list* del(struct single_list **prev)
  35. {
  36. struct single_list *tmp;
  37. if (!prev)
  38. return NULL;
  39. if (*prev == NULL)
  40. return NULL;
  41. tmp = *prev;
  42. *prev = (*prev)->next;
  43. tmp->next = NULL;
  44. return tmp;
  45. };
  46. struct single_list* delete_head(struct single_list_head* head)
  47. {
  48. return del(&head->head);
  49. };
  50. struct single_list** search(struct single_list_head* head, int target)
  51. {
  52. struct single_list **prev, *tmp;
  53. for (prev = &head->head, tmp = *prev;
  54. tmp && (tmp->val < target);
  55. prev = &tmp->next, tmp = *prev)
  56. ;
  57. return prev;
  58. };
  59. void reverse(struct single_list_head* head)
  60. {
  61. struct single_list_head tmp = {NULL};
  62. struct single_list *elem;
  63. while (!is_empty(head)) {
  64. elem = delete_head(head);
  65. insert_head(&tmp, elem);
  66. }
  67. head->head = tmp.head;
  68. }
  69. bool is_cyclic(struct single_list_head* head)
  70. {
  71. struct single_list *s1, *s2;
  72. s1 = s2 = head->head;
  73. while(s1 && s2) {
  74. s1 = s1->next;
  75. s2 = s2->next ? s2->next->next:s2->next;
  76. if (s1 == s2)
  77. return true;
  78. }
  79. return false;
  80. }
  81. struct single_list* middle(struct single_list_head* head)
  82. {
  83. struct single_list *s1, *s2;
  84. struct single_list pseudo_head;
  85. pseudo_head.next = head->head;
  86. s1 = s2 = &pseudo_head;
  87. while (true) {
  88. if (!s2 || !s2->next)
  89. return s1;
  90. s1 = s1->next;
  91. s2 = s2->next->next;
  92. }
  93. return NULL;
  94. };
  95. int main()
  96. {
  97. struct single_list_head head = {NULL};
  98. struct single_list lists[10];
  99. struct single_list **prev;
  100. int idx;
  101. for (idx = 0; idx < 10; idx++) {
  102. lists[idx].val = idx;
  103. lists[idx].next = NULL;
  104. }
  105. insert_head(&head, &lists[6]);
  106. insert_head(&head, &lists[5]);
  107. insert_head(&head, &lists[4]);
  108. insert_head(&head, &lists[1]);
  109. insert_head(&head, &lists[0]);
  110. printf("=== insert 0, 1, 4, 5, 6\n");
  111. dump(&head);
  112. prev = search(&head, 2);
  113. insert(prev, &lists[2]);
  114. printf("=== insert 2\n");
  115. dump(&head);
  116. printf("middle elem is %d\n", middle(&head)->val);
  117. prev = search(&head, 2);
  118. if ((*prev) && ((*prev)->val == 2))
  119. printf("The list contains 2\n");
  120. else
  121. printf("The list not contains 2\n");
  122. del(prev);
  123. prev = search(&head, 2);
  124. printf("After remove 2\n");
  125. if ((*prev) && ((*prev)->val == 2))
  126. printf("The list contains 2\n");
  127. else
  128. printf("The list not contains 2\n");
  129. dump(&head);
  130. printf("After reverse \n");
  131. reverse(&head);
  132. dump(&head);
  133. printf("middle elem is %d\n", middle(&head)->val);
  134. lists[0].next = &lists[6];
  135. printf("list is%s cyclic\n", is_cyclic(&head)?"":" not");
  136. return 0;
  137. }

4.3 栈(stack)

4.3.1 栈的基本概念

  1. // 基于数组实现的顺序栈
  2. public class ArrayStack {
  3. private String[] items; // 数组
  4. private int count; // 栈中元素个数
  5. private int n; //栈的大小
  6. // 初始化数组,申请一个大小为n的数组空间
  7. public ArrayStack(int n) {
  8. this.items = new String[n];
  9. this.n = n;
  10. this.count = 0;
  11. }
  12. // 入栈操作
  13. public boolean push(String item) {
  14. // 数组空间不够了,直接返回false,入栈失败。
  15. if (count == n) return false;
  16. // 将item放到下标为count的位置,并且count加一
  17. items[count] = item;
  18. ++count;
  19. return true;
  20. }
  21. // 出栈操作
  22. public String pop() {
  23. // 栈为空,则直接返回null
  24. if (count == 0) return null;
  25. // 返回下标为count-1的数组元素,并且栈中元素个数count减一
  26. String tmp = items[count-1];
  27. --count;
  28. return tmp;
  29. }
  30. }

4.3.2 栈的应用场景

  1. int main() {
  2. int a = 1;
  3. int ret = 0;
  4. int res = 0;
  5. ret = add(3, 5);
  6. res = a + ret;
  7. printf("%d", res);
  8. reuturn 0;
  9. }
  10. int add(int x, int y) {
  11. int sum = 0;
  12. sum = x + y;
  13. return sum;
  14. }

image.png-200.7kB

我们假设表达式中只包含三种括号,圆括号 ()、方括号[]和花括号{},并且它们可以任意嵌套。比如,{[] ()[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式。
那我现在给你一个包含三种括号的表达式字符串,如何检查它是否合法呢?这里也可以用栈来解决。我们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。

4.4 队列(queue)

当需要申请资源的时候,可能没有空闲资源了,这时候就需要排队,这就是队列的应用场景。

4.4.1 如何理解队列?

队列这个概念非常好理解。你可以把它想象成排队买票,先来的先买,后来的人只能站末尾,不允许插队。先进者先出,这就是典型的“队列”。

我们知道,栈只支持两个基本操作:入栈 push()和出栈 pop()
队列跟栈非常相似,支持的操作也很有限,最基本的操作也是两个:入队 enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素。
image.png-185.5kB
所以,队列跟栈一样,也是一种操作受限的线性表数据结构
队列的应用也非常广泛,特别是一些具有某些额外特性的队列,比如循环队列、阻塞队列、并发队列。它们在很多偏底层系统、框架、中间件的开发中,起着关键性的作用。比如高性能队列 Disruptor、Linux 环形缓存,都用到了循环并发队列。

4.4.2 顺序队列和链式队列

跟栈一样,队列可以用数组来实现,也可以用链表来实现。用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。同样,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列

  1. // 用数组实现的队列
  2. public class ArrayQueue {
  3. // 数组:items,数组大小:n
  4. private String[] items;
  5. private int n = 0;
  6. // head表示队头下标,tail表示队尾下标
  7. private int head = 0;
  8. private int tail = 0;
  9. // 申请一个大小为capacity的数组
  10. public ArrayQueue(int capacity) {
  11. items = new String[capacity];
  12. n = capacity;
  13. }
  14. // 入队
  15. public boolean enqueue(String item) {
  16. // 如果tail == n 表示队列已经满了
  17. if (tail == n) return false;
  18. items[tail] = item;
  19. ++tail;
  20. return true;
  21. }
  22. // 出队
  23. public String dequeue() {
  24. // 如果head == tail 表示队列为空
  25. if (head == tail) return null;
  26. // 为了让其他语言的同学看的更加明确,把--操作放到单独一行来写了
  27. String ret = items[head];
  28. ++head;
  29. return ret;
  30. }
  31. }

对于栈来说,我们只需要一个栈顶指针就可以了。但是队列需要两个指针:一个是 head 指针,指向队头;一个是 tail 指针,指向队尾。你可以结合下面这幅图来理解。当 a、b、c、d 依次入队之后,队列中的 head 指针指向下标为 0 的位置,tail 指针指向下标为 4 的位置。
image.png-98.5kB

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

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

image.png-196.8kB

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

image.png-239.5kB

4.4.3 循环队列

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

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

image.png-163.8kB

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

就像我图中画的队满的情况,tail=3,head=4,n=8,所以总结一下规律就是:(3+1)%8=4。多画几张队满的图,你就会发现,当队满时,(tail+1)%n=head。

  1. public class CircularQueue {
  2. // 数组:items,数组大小:n
  3. private String[] items;
  4. private int n = 0;
  5. // head表示队头下标,tail表示队尾下标
  6. private int head = 0;
  7. private int tail = 0;
  8. // 申请一个大小为capacity的数组
  9. public CircularQueue(int capacity) {
  10. items = new String[capacity];
  11. n = capacity;
  12. }
  13. // 入队
  14. public boolean enqueue(String item) {
  15. // 队列满了
  16. if ((tail + 1) % n == head) return false;
  17. items[tail] = item;
  18. tail = (tail + 1) % n;
  19. return true;
  20. }
  21. // 出队
  22. public String dequeue() {
  23. // 如果head == tail 表示队列为空
  24. if (head == tail) return null;
  25. String ret = items[head];
  26. head = (head + 1) % n;
  27. return ret;
  28. }
  29. }

4.4.4 阻塞队列和并发队列

4.4.5 小结

4.5 容器

在 C++ 里,容器就是这个公式里面的“数据结构”。
容器,其实就是 C++ 对数据结构的抽象和封装。

4.5.1 顺序容器

  1. array<int, 2> arr; // 初始一个array,长度是2
  2. assert(arr.size() == 2); // 静态数组的长度总是2
  3. vector<int> v(2); // 初始一个vector,长度是2
  4. for(int i = 0; i < 10; i++) {
  5. v.emplace_back(i); // 追加多个元素
  6. }
  7. assert(v.size() == 12); // 长度动态增长到12

deque 也是一种可以动态增长的数组,它和 vector
的区别是,它可以在两端高效地插入删除元素,这也是它的名字 double-end queue 的来历,而 vector 则只能用 push_back 在末端追加元素。

  1. deque<int> d; // 初始化一个deque,长度是0
  2. d.emplace_back(9); // 末端添加一个元素
  3. d.emplace_front(1); // 前端添加一个元素
  4. assert(d.size() == 2); // 长度动态增长到2

4.5.2 有序容器

  1. template<
  2. class T // 模板参数只有一个元素类型
  3. > class vector; // vector
  4. template<
  5. class Key, // 模板参数是key类型,即元素类型
  6. class Compare = std::less<Key> // 比较函数
  7. > class set; // 集合
  8. template<
  9. class Key, // 第一个模板参数是key类型
  10. class T, // 第二个模板参数是元素类型
  11. class Compare = std::less<Key> // 比较函数
  12. > class map; // 关联数组

C++ 里的 int、string 等基本类型都支持比较排序,放进有序容器里毫无问题。但很多自定义类型没有默认的比较函数,要作为容器的 key 就有点麻烦。虽然这种情况不多见,但有的时候还真是个“刚性需求”。

除了比较函数这点,有序容器其实没有什么太多好说的,因为就这两个,选择起来很简单:集合关系就用 set,关联数组就用 map。

因为有序容器在插入的时候会自动排序,所以就有隐含的插入排序成本,当数据量很大的时候,内部的位置查找、树旋转成本可能会比较高。还有,如果你需要实时插入排序,那么选择 set/map 是没问题的。如果是非实时,那么最好还是用 vector,全部数据插入完成后再一次性排序,效果肯定会更好。

4.5.3 无序容器

无序容器也有四种,名字里也有 setmap,只是加上了 unordered(无序)前缀,分别是 unordered_set/unordered_multiset、unordered_map/unordered_multimap

无序容器同样也是集合和关联数组,用法上与有序容器几乎是一样的,区别在于内部数据结构:它不是红黑树,而是散列表(也叫哈希表,hash table)。

因为它采用散列表存储数据,元素的位置取决于计算的散列值,没有规律可言,所以就是“无序”的,你也可以把它理解为“乱序”容器。下面的代码简单示范了无序容器的操作,虽然接口与有序容器一样,但输出元素的顺序是不确定的乱序:

  1. using map_type = // 类型别名
  2. unordered_map<int, string>; // 使用无序关联数组
  3. map_type dict; // 定义一个无序关联数组
  4. dict[1] = "one"; // 添加三个元素
  5. dict.emplace(2, "two");
  6. dict[10] = "ten";
  7. for(auto& x : dict) { // 遍历输出
  8. cout << x.first << "=>" // 顺序不确定
  9. << x.second << ","; // 既不是插入顺序,也不是大小序
  10. }

无序容器虽然不要求顺序,但是对 key 的要求反而比有序容器更“苛刻”一些,拿 unordered_map 的声明来看一下:

  1. template<
  2. class Key, // 第一个模板参数是key类型
  3. class T, // 第二个模板参数是元素类型
  4. class Hash = std::hash<Key>, // 计算散列值的函数对象
  5. class KeyEqual = std::equal_to<Key> // 相等比较函数
  6. > class unordered_map;

它要求 key 具备两个条件,一是可以计算 hash 值,二是能够执行相等比较操作。第一个是因为散列表的要求,只有计算 hash 值才能放入散列表,第二个则是因为 hash 值可能会冲突,所以当 hash 值相同时,就要比较真正的 key 值。

有序容器和无序容器的接口基本一样,这两者该如何选择呢?其实看数据结构就清楚了,如果只想要单纯的集合、字典,没有排序需求,就应该用无序容器,没有比较排序的成本,它的速度就会非常快。

4.6.4 容器小结

image.png-313.4kB

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注