线性数据结构 Linear
Linear
数组概述
数组有四种操作:添加、删除、更改和搜索。
读取是数组中最容易、最快的操作,因为数组以顺序的方式存储在内存中。
数组最突出的能力叫做随机读取,即通过一个下标读取值。
int a[] = {3, 1, 2, 5, 4, 9, 2, 7};
printf("%d", a[3]);
读取和更新的时间复杂度为\(O(1)\)
插入元素存在三种情况
-
尾部插入
只需将元素放在数组的末尾即可,等同于更新元素 -
中间插入
需要将插入位置和之后的元素向后移动for(int i = size - 1; i >= index; ++i) array[i + 1] = array[i]; array[index] = element; ++size;
-
超范围插入
if (index > size || index < 0) throw new Error();
如果数组容量不够可以扩容,一般来说采用\(\times 2\)的方法
int[] arr = new int[16];
int newArr = new int[arr.Length * 2];
Array.Copy(arr, 0, newArr, 0, arr.Length);
arr = newArr;
删除元素和插入元素操作过程相反,删除的元素其后的元素都向前挪动一位,因为不用考虑扩容,其方法比插入更简单
for(int i = index; i != size -1; ++i)
array[i] = array[i+1];
--size;
// array[size] = default(int);
扩容、插入、删除的时间复杂度都为\(O(n)\)
数组的优劣
- 高效的随机访问能力,通过下标可以在常数时间内找到对应元素,二分查找算法就利用了这一优势
- 元素紧密的排列在内存中,扩容、插入和删除导致的大量移动都会影响效率
链表概述
链表是一种在物理上非连续的数据结构,有若干个节点组成,每个节点一级一级传递,分单向节点和双向节点,下面以单向节点为例
template<typename T>
struct Node { // single linked node
T val;
Node* next;
}
template<typename T>
struct Binode { // double linked node
T val;
Binode* next;
Binode* prev;
}
查找
Node *p = head; // 使用不存储数据的头节点
while (p.next != nullptr)
if (p->next->val == search_value)
return p; // 返回前一个节点
else
p = p->next;
return nullptr;
插入
如果是不存储数据的头节点,在中部和头部插入方法相同;
如果使用头指针,头节点存储数据,则不同
// insert to last
Node *p = head;
while (p.next) p = p->next;
p->next = new Node(val, nullptr);
// insert to position i
// assert(i <= size);
Node *p = head;
for (int i = 0; i != index; ++i) p = p->next;
p->next = new Node(val, p->next);
删除
Node *current;
Node *prev = head;
while ((current = prev->next) != nullptr)
if (current->val == delete_value) break;
else prev = current;
if (current) {
prev->next = current->next;
delete current;
}
查找的时间复杂度为\(O(n)\),如果不考虑插入和删除操作之前的查找,两个操作点时间复杂度都为\(O(n)\)
头指针和头节点
使用头指针则可在第一个节点存储数据,使用头节点可以免去对第一个节点的判断和对头指针的修改,在单向链表中会使用头节点使元素的插入和删除变简单
小结
查找 | 更新 | 插入 | 删除 | |
---|---|---|---|---|
数组 | \(O(1)\) | \(O(1)\) | \(O(n)\) | \(O(n)\) |
链表 | \(O(n)\) | \(O(1)\) | \(O(1)\) | \(O(1)\) |
栈和队列概述
栈(Stack)和队列(Queue)是计算机中常用的两种数据结构,是操作受限的线性表。
- 栈的插入和删除等操作都在栈顶进行,它是先进后出(First In Last Out, FILO)的线性表。
- 队列的删除操作在队头进行,而插入、查找等操作在队尾进行,它是先进先出(First In First Out, FIFO)的线性表。
栈的操作
-
入栈(push)
-
出栈(pop)
由于代码实现比较简单,不再展示代码
由于入栈和出栈只涉及最后一个元素,不涉及其他元素的整体移动,所以无论是以数组实现还是以链表实现,它们的时间复杂度都为O(1)
队列的操作
用数组实现时,为了入队操作方便,将最后入队元素的下一个位置规定为队尾(rear)
- 入队
将新元素放入队列中,只允许在队尾(rear)放入元素,新元素的下一个位置成为新的队尾 - 出队
把元素移出队列,只允许在队头(front)移除元素,出队元素后的下一个元素会成为新的队头
对于链表实现方式,队列的入队和出队与栈是大同小异的,而数组实现方式则采用循环队列的方式。即利用已出队元素留下的空间的,让队尾指针重新回到数组的首位
此时判断队满的条件为front == (rear + 1) % array.Length
,判断队空的条件是rear == front
,注意此时队尾指向的位置永远空出一位,否则空和满的条件就相同了,所以队列的最大容量比数组长度小1
入队和出队的时间复杂度同样为O(1)
栈和队列的应用
栈和队列都建立在数组和链表的基础上,但却限制了几种操作。正是由于它们的实用性
栈的输入顺序和输出顺序相反,所以栈的本质就是“对历史的回溯”
-
递归就是建立在栈上的,先被调用的函数要等后被调用的函数结束才能结束。由此,我们总能用栈重写递归为迭代
-
倒序字符串
-
括号匹配
Stack<char> stack = new Stack<char>(); stack.Push('?'); foreach (char c in s) { switch (c) { case '(': stack.Push('('); break; case '{': stack.Push('{'); break; case '[': stack.Push('['); break; case ')': if (stack.Pop() != '(') return false; break; case '}': if (stack.Pop() != '{') return false; break; case ']': if (stack.Pop() != '[') return false; break; } } return stack.Count == 1;
-
表达式求值
队列的输入顺序和输出顺序相同,所以队列的本质就是“对历史的重播”
- 多线程中,竞争锁的队列就是按照队列的顺序来决定线程的顺序的
双端队列
将栈和队列结合起来的结构叫做双端队列(deque),既可以在队头出队和入队,也可以在队尾出队和入队,虽然它的灵活性提高了,但在实际运用中不如栈和队列常见。