@richey
2020-09-29T08:04:34.000000Z
字数 13311
阅读 926
未分类
在计算机的发展历史上,众多“大牛”孜孜不倦地发明创造了这么多的数据结构,为什么呢?因为没有一种数据结构是万能的、可以应用于任何场景。毕竟,不同的数据结构存储数据的形式不一样,效率也就不一样。有的是连续存放,有的是分散存放,有的存储效率高,有的查找效率高,我们必须要依据具体的应用场合来进行取舍。
线性表和非线性表
线性表(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");
else
printf("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");
else
printf("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() {
// 栈为空,则直接返回null
if (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,数组大小:n
private 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,数组大小:n
private 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,长度是2
assert(arr.size() == 2); // 静态数组的长度总是2
vector<int> v(2); // 初始一个vector,长度是2
for(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,长度是0
d.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; // vector
template<
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 值。
有序容器和无序容器的接口基本一样,这两者该如何选择呢?其实看数据结构就清楚了,如果只想要单纯的集合、字典,没有排序需求,就应该用无序容器,没有比较排序的成本,它的速度就会非常快。