第8章 线性数据结构

第8章 线性数据结构

线性数据结构是 CSP-S 复赛与初赛的共同核心:数组、链表、栈、队列、哈希表、字符串。复赛中栈(递归/表达式/DFS)、队列(BFS)、哈希表(判重/计数)、字符串(模式/前缀)几乎每场必考;初赛则偏好考"概念辨析 + 计算"(哈希冲突、子串数、出栈序列、复杂度)。

本章把每个知识点都写成 C++17 示例,并把 15 道经典初赛选择题整理为随堂检测(每题附可展开解析)。


8.1 数组与顺序表

数组在内存中连续存放,支持随机访问(按下标 O(1) 取元素),但插入/删除需移动元素(最坏 O(n))。

操作数组/顺序表说明
按下标访问O(1)连续存储,可随机访问
头部插入/删除O(n)需整体后移/前移
尾部插入(均摊)O(1)vector 倍增均摊
查找指定值O(n)无序时只能顺序查找

8.1.1 顺序表插入(教学示例)

#include <iostream>
#include <vector>
using namespace std;

// 在顺序表 a 的下标 p 处插入 x(p 从 0 计),元素后移 O(n)
void insertAt(vector<int>& a, int p, int x) {
a.insert(a.begin() + p, x);
}

int main() {
vector<int> a = {1, 2, 3, 4};
insertAt(a, 2, 99); // 在下标 2 插 99
for (int v : a) cout << v << " "; // 1 2 99 3 4
cout << "\n";
return 0;
}

8.1.2 多维数组的存储(教学示例)

多维数组按行优先(C/C++)或列优先(Fortran)线性存放。行优先下一维 a[i][j](共 cols 列)相对首地址偏移为 i * cols + j。

#include <iostream>
using namespace std;

int main() {
const int rows = 3, cols = 4;
int a[rows][cols] = {{1,2,3,4},{5,6,7,8},{9,10,11,12}};
// 行优先:a[i][j] 地址偏移 = i*cols + j
int i = 2, j = 1;
cout << "a[" << i << "][" << j << "] = " << a[i][j] << " 偏移=" << (i*cols + j) << "\n"; // 10, 偏移 9
return 0;
}

8.1.3 顺序表按位置删除(教学示例)

在顺序表下标 p 处删除元素,后继元素前移(最坏 O(n)),与 8.1.1 的插入对称。

#include <iostream>
#include <vector>
using namespace std;

// 删除顺序表 a 下标 p 处的元素(p 从 0 计),后继前移 O(n)
void eraseAt(vector<int>& a, int p) {
a.erase(a.begin() + p);
}

int main() {
vector<int> a = {1, 2, 99, 3, 4};
eraseAt(a, 2); // 删除下标 2 的 99
for (int v : a) cout << v << " "; // 1 2 3 4
cout << "\n";
return 0;
}

8.2 链表

链表结点通过指针串接,内存不必连续。按结构分为四类,核心区别在"指针方向与数量":

类型指针结构能否向前遍历是否成环典型用途
单链表每结点 1 个 next否否通用、栈/队列底层
双链表每结点 prev+next是否LRU、邻接表
单循环链表next 且尾指向头否是约瑟夫环、轮询
双循环链表双向且成环(常带哨兵)是是高效插入删除

共同点:插入/删除(已知前驱)O(1),不能随机访问(查第 k 个需 O(k) 遍历)。内存地址连续不连续均可。

图 8-1 四种链表结构对比

图 8-1:单/双/单循环/双循环链表的指针结构差异。蓝色箭头为 next,紫色箭头为 prev,绿色箭头为 head。

8.2.1 单链表(插入与删除)

单链表每结点一个 next,头结点为空表示表空。插入/删除已知前驱时 O(1),但定位前驱需先遍历。

(1)头插建表 + 按值删除(用哨兵简化边界)

#include <iostream>
using namespace std;

struct Node { int v; Node* next; Node(int x): v(x), next(nullptr) {} };

// 头插法建表(结果逆序),O(1)
Node* pushFront(Node* head, int x) {
Node* p = new Node(x); p->next = head; return p;
}

// 删除第一个值为 x 的结点,返回新头(用哨兵简化边界)
Node* removeVal(Node* head, int x) {
Node* dummy = new Node(0); dummy->next = head;
Node* pre = dummy;
while (pre->next) {
if (pre->next->v == x) { Node* t = pre->next; pre->next = t->next; delete t; break; }
pre = pre->next;
}
Node* nh = dummy->next; delete dummy; return nh;
}

int main() {
Node* h = nullptr;
for (int x : {3, 2, 1}) h = pushFront(h, x); // 1 -> 2 -> 3
h = removeVal(h, 2); // 1 -> 3
for (Node* p = h; p; p = p->next) cout << p->v << " ";
cout << "\n";
return 0;
}

(2)按位置插入 + 按位置删除(先遍历到目标前一个结点,再改指针)

#include <iostream>
using namespace std;

struct Node { int v; Node* next; Node(int x): v(x), next(nullptr) {} };

// 在第 pos 个位置(0-based)插入 x,返回新头
Node* insertAt(Node* head, int pos, int x) {
Node* dummy = new Node(0); dummy->next = head;
Node* pre = dummy;
for (int i = 0; i < pos; ++i) pre = pre->next; // 走到 pos-1
Node* p = new Node(x); p->next = pre->next; pre->next = p;
Node* nh = dummy->next; delete dummy; return nh;
}

// 删除第 pos 个位置(0-based)结点,返回新头
Node* deleteAt(Node* head, int pos) {
Node* dummy = new Node(0); dummy->next = head;
Node* pre = dummy;
for (int i = 0; i < pos; ++i) pre = pre->next;
Node* t = pre->next; pre->next = t->next; delete t;
Node* nh = dummy->next; delete dummy; return nh;
}

int main() {
Node* h = new Node(1); h->next = new Node(3); // 1 -> 3
h = insertAt(h, 1, 2); // 下标 1 插入 2:1 -> 2 -> 3
h = deleteAt(h, 0); // 删下标 0 的 1:2 -> 3
for (Node* p = h; p; p = p->next) cout << p->v << " ";
cout << "\n";
return 0;
}

8.2.2 双链表(在 p 后插入 + 删除)

双链表每结点多一个 prev,可向前遍历,插入/删除已知结点时 O(1) 且不需找前驱。

#include <iostream>
using namespace std;

struct DNode { int v; DNode *prev, *next; DNode(int x): v(x), prev(nullptr), next(nullptr) {} };

// 在 p 之后插入新结点,注意维护两端指针
void insertAfter(DNode* p, int x) {
DNode* q = new DNode(x);
q->next = p->next; q->prev = p;
if (p->next) p->next->prev = q;
p->next = q;
}

// 删除结点 p(返回 p->next 便于继续遍历)
DNode* eraseNode(DNode* p) {
DNode* nxt = p->next;
if (p->prev) p->prev->next = p->next;
if (p->next) p->next->prev = p->prev;
delete p; return nxt;
}

int main() {
DNode* a = new DNode(1); DNode* b = new DNode(2);
a->next = b; b->prev = a; // 1 <-> 2
insertAfter(a, 99); // 1 <-> 99 <-> 2
for (DNode* p = a; p; p = p->next) cout << p->v << " ";
cout << "\n";
return 0;
}

头插与头删(在表头增删,均 O(1)),是对上方"在 p 后插入 / 删除任意结点"的补充:

#include <iostream>
using namespace std;

struct DNode { int v; DNode *prev, *next; DNode(int x): v(x), prev(nullptr), next(nullptr) {} };

// 在表头插入 x,返回新头
DNode* insertHead(DNode* head, int x) {
DNode* p = new DNode(x);
p->next = head;
if (head) head->prev = p;
return p;
}
// 删除表头结点,返回新头
DNode* deleteHead(DNode* head) {
if (!head) return nullptr;
DNode* nh = head->next;
if (nh) nh->prev = nullptr;
delete head; return nh;
}

int main() {
DNode* h = nullptr;
h = insertHead(h, 1); h = insertHead(h, 2); // 2 <-> 1
h = deleteHead(h); // 删表头 -> 1
cout << "头=" << h->v << "\n"; // 1
return 0;
}

8.2.3 单循环链表(约瑟夫环)

尾结点的 next 指向头,形成环。约瑟夫问题是经典应用:n 人围成圈,每次数到 m 出列,求最后幸存者。

#include <iostream>
using namespace std;

struct Node { int v; Node* next; Node(int x): v(x), next(nullptr) {} };

// 约瑟夫问题:n 人编号 1..n,每次数到 m 出列,返回幸存者编号
int josephus(int n, int m) {
Node* head = new Node(1); Node* cur = head;
for (int i = 2; i <= n; ++i) { cur->next = new Node(i); cur = cur->next; }
cur->next = head; // 成环(cur 停在最后一个结点)
while (cur->next != cur) {
for (int i = 1; i < m; ++i) cur = cur->next; // 数 m-1 步
Node* dead = cur->next; // 第 m 个出列
cur->next = dead->next; delete dead;
}
int ans = cur->v; delete cur; return ans;
}

int main() {
cout << "n=5,m=2 幸存者 = " << josephus(5, 2) << "\n"; // 3
return 0;
}

普通单循环链表的插入/删除与单链表类似,区别在于遍历终止条件为"回到头结点"。下面给出"在 p 后插入"与"删除 p 的后继"两个基本操作(建环见约瑟夫环示例):

#include <iostream>
using namespace std;

struct Node { int v; Node* next; Node(int x): v(x), next(nullptr) {} };

// 在单循环链表结点 p 之后插入 x(不破坏环)
void insertAfter(Node* p, int x) {
Node* q = new Node(x);
q->next = p->next; p->next = q;
}

// 删除 p 的后继结点(保持环不断),返回被删结点值
int removeNext(Node* p) {
Node* t = p->next;
p->next = t->next;
int v = t->v; delete t; return v;
}

int main() {
Node* a = new Node(1); a->next = a; // 自环:1 -> 1
insertAfter(a, 2); // 1 -> 2 -> 1(成环)
insertAfter(a, 3); // 1 -> 3 -> 2 -> 1
cout << "删掉 a 的后继(=1 后面是 3):"; // 输出 3
cout << removeNext(a) << "\n";
for (Node* p = a->next; p != a; p = p->next) cout << p->v << " "; // 2
cout << "\n";
return 0;
}

8.2.4 双循环链表(带哨兵头结点)

用**哨兵(sentinel)**结点自身成环,可使插入/删除在表头、表尾、中间都走同一套代码,无需判空。

#include <iostream>
using namespace std;

struct DNode { int v; DNode *prev, *next; DNode(int x = 0): v(x), prev(this), next(this) {} };

// 哨兵 s 的双循环链表:在哨兵后(即表头)插入
void insertHead(DNode* s, int x) {
DNode* p = new DNode(x);
p->next = s->next; p->prev = s;
s->next->prev = p; s->next = p;
}
void removeNode(DNode* p) {
p->prev->next = p->next; p->next->prev = p->prev; delete p;
}

int main() {
DNode* s = new DNode(); // 哨兵自身成环
insertHead(s, 1); insertHead(s, 2); insertHead(s, 3); // s -> 3 -> 2 -> 1 -> s
for (DNode* p = s->next; p != s; p = p->next) cout << p->v << " ";
cout << "\n";
return 0;
}

8.3 栈(后进先出 LIFO)

栈只在栈顶压入(push)和弹出(pop),遵循后进先出。常见用途:函数调用(递归)、表达式求值、DFS、括号匹配、单调栈。

图 8-2 栈与队列的基本形态

图 8-2:栈是 LIFO,push/pop 在同一端;队列是 FIFO,入队从队尾、出队从队头;循环队列把数组逻辑上首尾相接,避免假溢出。

8.3.1 顺序栈(数组实现)

用数组 + top 指针实现,空栈 top == 0,满栈 top == 容量。

#include <iostream>
using namespace std;

const int N = 100;
int st[N], top = 0; // top = 已存元素个数
void push(int x) { st[top++] = x; }
void pop() { --top; }
int peek() { return st[top - 1]; }
bool empty() { return top == 0; }

int main() {
push(10); push(20);
cout << peek() << "\n"; // 20
pop();
cout << peek() << "\n"; // 10
return 0;
}

8.3.2 链栈

用单链表实现栈,栈顶即表头,无需预设容量(不会"上溢"),push/pop 均为 O(1)。

#include <iostream>
using namespace std;

struct Node { int v; Node* next; Node(int x): v(x), next(nullptr) {} };
Node* top = nullptr;
void push(int x) { Node* p = new Node(x); p->next = top; top = p; }
void pop() { if (top) { Node* t = top; top = top->next; delete t; } }
int peek() { return top->v; }
bool empty() { return top == nullptr; }

int main() {
push(1); push(2); push(3);
cout << "栈顶=" << peek() << "\n"; // 3
return 0;
}

8.3.3 应用:括号匹配(教学示例)

遇到左括号入栈,遇到右括号则必须与栈顶左括号同型且栈非空,最后栈须空。

#include <iostream>
#include <stack>
#include <string>
using namespace std;

bool match(const string& s) {
stack<char> st;
for (char c : s) {
if (c == '(' || c == '[' || c == '{') st.push(c);
else {
if (st.empty()) return false;
char t = st.top(); st.pop();
if ((c == ')' && t != '(') || (c == ']' && t != '[') || (c == '}' && t != '{')) return false;
}
}
return st.empty();
}

int main() {
cout << "({[]}) 匹配? " << (match("({[]})") ? "是" : "否") << "\n"; // 是
cout << "([)] 匹配? " << (match("([)]") ? "是" : "否") << "\n"; // 否
return 0;
}

8.3.4 合法出栈序列判定(教学示例)

给定入栈序列 0..n-1,判断某出栈序列是否合法:用栈模拟,栈顶等于下一个要弹出的元素就弹出,否则把入栈序列下一个数压栈。

#include <iostream>
#include <vector>
#include <stack>
using namespace std;

bool validPop(const vector<int>& a) {
int n = a.size();
stack<int> st; int j = 0;
for (int i = 0; i < n; ++i) {
st.push(i);
while (!st.empty() && st.top() == a[j]) { st.pop(); ++j; }
}
return st.empty();
}

int main() {
cout << "1 2 3 合法? " << (validPop({1,2,3}) ? "是" : "否") << "\n"; // 是
cout << "3 1 2 合法? " << (validPop({3,1,2}) ? "是" : "否") << "\n"; // 否
return 0;
}

8.3.5 栈容量计算(教学示例)

元素依次入栈 S、出栈即进队列 Q,给定出队顺序可反推入/出栈过程并求栈的最大深度(最小容量):

#include <iostream>
#include <vector>
#include <stack>
using namespace std;

// q: 出队顺序(=出栈顺序)。返回所需最小栈容量
int minStackCap(const vector<int>& q) {
int n = q.size();
vector<int> in(n); for (int i = 0; i < n; ++i) in[i] = i + 1;
stack<int> st; int idx = 0, cap = 0;
for (int k = 0; k < n; ++k) {
while (idx < n && (st.empty() || st.top() != q[k])) {
st.push(in[idx]); ++idx; cap = max(cap, (int)st.size());
}
st.pop();
}
return cap;
}

int main() {
// 出队 e2 e4 e3 e6 e5 e1(编号 1..6),最小容量 = 3
cout << "最小栈容量 = " << minStackCap({2,4,3,6,5,1}) << "\n"; // 3
return 0;
}

8.3.6 应用:后缀表达式求值(洛谷 P1449)

中缀表达式转后缀(逆波兰)后用栈求值,是栈的经典应用。

#include <iostream>
#include <stack>
#include <string>
#include <sstream>
using namespace std;

// 计算后缀(逆波兰)表达式,操作数/运算符空格分隔;'@' 结尾(P1449 风格)
int evalRPN(const string& expr) {
stack<int> st; stringstream ss(expr); string tok;
while (ss >> tok) {
if (tok == "@") break;
if (tok == "+" || tok == "-" || tok == "*" || tok == "/") {
int b = st.top(); st.pop();
int a = st.top(); st.pop();
if (tok == "+") st.push(a + b);
else if (tok == "-") st.push(a - b);
else if (tok == "*") st.push(a * b);
else st.push(a / b);
} else st.push(stoi(tok));
}
return st.top();
}

int main() {
cout << "3 4 + 2 * = " << evalRPN("3 4 + 2 * @") << "\n"; // (3+4)*2 = 14
return 0;
}

8.4 队列(先进先出 FIFO)

队列在队尾入、队头出,遵循先进先出,常见用途:BFS、缓冲区、任务调度、单调队列。

8.4.1 顺序队列的"假溢出"与循环队列

普通数组队列:出队后前面空位无法再用(假溢出)。解决办法是循环队列:front/rear 在到达末尾时回到 0,判满用 (rear+1) % M == front(牺牲一个单元区分空/满)。

#include <iostream>
using namespace std;

const int M = 5; // 循环队列容量(实际可用 M-1 个)
int q[M], front = 0, rear = 0; // 空:front==rear
bool full() { return (rear + 1) % M == front; }
void enqueue(int x) { q[rear] = x; rear = (rear + 1) % M; }
int dequeue() { int v = q[front]; front = (front + 1) % M; return v; }
int qsize() { return (rear - front + M) % M; }

int main() {
enqueue(1); enqueue(2); enqueue(3);
cout << "size=" << qsize() << " 出队=" << dequeue() << "\n"; // size=3 出队=1
return 0;
}

8.4.2 链队列

用链表实现队列,头删尾插,无需预设容量,入队/出队均 O(1)。

#include <iostream>
using namespace std;

struct Node { int v; Node* next; Node(int x): v(x), next(nullptr) {} };
Node *head = nullptr, *tail = nullptr;
void enqueue(int x) {
Node* p = new Node(x);
if (!tail) head = tail = p; else { tail->next = p; tail = p; }
}
int dequeue() { int v = head->v; Node* t = head; head = head->next; if (!head) tail = nullptr; delete t; return v; }

int main() {
enqueue(10); enqueue(20);
cout << "出队=" << dequeue() << " 再出队=" << dequeue() << "\n"; // 10 20
return 0;
}

8.4.3 STL 队列与双端队列(教学示例)

std::queue 是适配器(默认基于 deque);std::deque 支持两端高效插入删除,是单调队列的基础。

#include <iostream>
#include <queue>
#include <deque>
#include <vector>
using namespace std;

int main() {
queue<int> q; q.push(10); q.push(20); q.push(30);
cout << "队头=" << q.front() << "\n"; // 10(FIFO)
q.pop(); cout << "出队后队头=" << q.front() << "\n"; // 20

// deque 双端队列:两端都可 push/pop
deque<int> d; d.push_back(1); d.push_front(0);
cout << " deque 头=" << d.front() << " 尾=" << d.back() << "\n"; // 0 1
return 0;
}

8.4.4 应用:单调队列(滑动窗口最大值)

单调队列在 O(n) 内求滑动窗口最值(洛谷 P1886 滑动窗口),是队列的高级应用。

#include <iostream>
#include <deque>
#include <vector>
using namespace std;

// 滑动窗口(大小 k)最大值:维护递减 deque,存下标
vector<int> slideMax(const vector<int>& a, int k) {
deque<int> dq; vector<int> ans;
for (int i = 0; i < (int)a.size(); ++i) {
while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();
dq.push_back(i);
if (dq.front() == i - k) dq.pop_front(); // 移出窗口
if (i >= k - 1) ans.push_back(a[dq.front()]);
}
return ans;
}

int main() {
vector<int> a = {1, 3, -1, -3, 5, 3, 6, 7};
for (int v : slideMax(a, 3)) cout << v << " "; // 3 3 5 5 6 7
cout << "\n";
return 0;
}

8.5 哈希表

哈希表通过哈希函数 H(key) 把关键字映射到数组下标,理想查找 O(1)。冲突不可避免,需用冲突解决策略。

图 8-3 哈希冲突两种解决方式

图 8-3:线性探查把冲突元素顺次后移(可能产生“聚集”);链地址法把同桶元素挂在链表里,删除方便且不聚集。

8.5.1 哈希函数与线性探查(开放寻址)

H(key) = key % M。线性探查:冲突时向后找第一个空位(到末尾再从 0 循环)。

#include <iostream>
#include <vector>
using namespace std;

// 线性探查插入;返回 key 最终落点(表大小 M,初始全 -1 表示空)
int linearProbe(vector<int>& tbl, int key, int M) {
int h = key % M;
while (tbl[h] != -1 && tbl[h] != key) h = (h + 1) % M;
tbl[h] = key;
return h;
}

int main() {
const int M = 13;
vector<int> tbl(M, -1);
for (int k : {2, 8, 31, 20, 19, 18, 53, 27}) linearProbe(tbl, k, M);
// 18: H=18%13=5 被 31 占 -> 6(20) -> 7(19) -> 8(8) -> 9 空,落入 9
cout << "18 落在地址 " << linearProbe(tbl, 18, M) << "\n"; // 9
return 0;
}

8.5.2 链地址法(拉链法)

每个桶挂一条链表,冲突的元素都挂在同一桶下;插入/删除 O(1) 期望,且不会因冲突退化成聚集。

#include <iostream>
#include <vector>
#include <list>
using namespace std;

const int M = 13;
vector<list<int>> tbl(M);
void insert(int key) { tbl[key % M].push_back(key); } // 同桶挂链表
bool find(int key) {
for (int v : tbl[key % M]) if (v == key) return true;
return false;
}
// 链地址法删除:在对应桶链表中移除 key(期望 O(1))
void erase(int key) {
auto& bucket = tbl[key % M];
for (auto it = bucket.begin(); it != bucket.end(); ++it)
if (*it == key) { bucket.erase(it); break; }
}

int main() {
for (int k : {2, 8, 31, 20, 19, 18, 53, 27}) insert(k);
cout << "查找 18? " << (find(18) ? "命中" : "未命中") << "\n"; // 命中
erase(18);
cout << "删除后查找 18? " << (find(18) ? "命中" : "未命中") << "\n"; // 未命中
return 0;
}

8.5.3 无冲突哈希函数(教学示例)

判断给定哈希函数对一组 key 是否产生冲突:若所有 H(key) 互不相同则无冲突。

#include <iostream>
#include <vector>
#include <set>
using namespace std;

bool noConflict(const vector<int>& keys, int (*h)(int)) {
set<int> seen;
for (int x : keys) {
int v = h(x);
if (seen.count(v)) return false;
seen.insert(v);
}
return true;
}
int mod11(int x) { return x % 11; }
int pow2mod11(int x) { int r = 1; for (int i = 0; i < x; ++i) r = (r * 2) % 11; return r; }
int sqrtmod11(int x) { int r = 0; while (r * r <= x) ++r; return (r - 1) % 11; }

int main() {
vector<int> keys = {2, 6, 10, 17};
cout << "x%11 无冲突? " << (noConflict(keys, mod11) ? "是" : "否") << "\n"; // 否
cout << "2^x mod 11 无冲突? " << (noConflict(keys, pow2mod11) ? "是" : "否") << "\n"; // 是
cout << "floor(sqrt)mod11? " << (noConflict(keys, sqrtmod11) ? "是" : "否") << "\n"; // 是
return 0;
}

经典初赛【多选】:对 (2,6,10,17) 存入地址区间 0~10,h(x)=2ˣ mod 11 与 h(x)=⌊√x⌋ mod 11 不产生冲突,答案为 C、D。


8.6 字符串

字符串是特殊的线性表(元素为字符),长度可为零(空串 "");空格串不是空串。子串、相邻交换、模式匹配是初赛/复赛常考。

8.6.1 非空子串计数(教学示例)

长度为 n 的字符串,非空子串数 = n(n+1)/2(起点有 n 种、长度从 1 到 n)。

#include <iostream>
#include <string>
using namespace std;

long long subCount(const string& s) {
long long n = s.size();
return n * (n + 1) / 2;
}

int main() {
string s = "Olympic"; // 长度 7
cout << "\"" << s << "\" 非空子串数 = " << subCount(s) << "\n"; // 7*8/2 = 28
return 0;
}

8.6.2 相邻交换最少次数 = 逆序对(教学示例)

把串 DACFEB 变成 ABCDEF,每次交换相邻两字符。最少交换次数 = 目标排列相对原串的逆序对数。

#include <iostream>
#include <string>
#include <map>
using namespace std;

int minAdjSwap(const string& src, const string& tgt) {
map<char,int> pos;
for (int i = 0; i < (int)tgt.size(); ++i) pos[tgt[i]] = i;
int inv = 0;
for (int i = 0; i < (int)src.size(); ++i)
for (int j = i + 1; j < (int)src.size(); ++j)
if (pos[src[i]] > pos[src[j]]) ++inv;
return inv;
}

int main() {
cout << "DACFEB -> ABCDEF 最少交换 = " << minAdjSwap("DACFEB", "ABCDEF") << "\n"; // 7
return 0;
}

8.6.3 字符串哈希(BKDR,教学示例)

把字符串映射为大整数(模大质数),可在 O(1) 比较两子串是否相同,是 Rabin-Karp、字符串判重的基础。

#include <iostream>
#include <string>
using namespace std;

const unsigned long long BASE = 131, MOD = 1000000007;
unsigned long long strHash(const string& s) {
unsigned long long h = 0;
for (char c : s) h = (h * BASE + c) % MOD;
return h;
}

int main() {
cout << "abc 哈希 = " << strHash("abc") << "\n";
cout << "abc == abd? " << (strHash("abc") == strHash("abd") ? "同" : "不同") << "\n"; // 不同
return 0;
}

8.6.4 KMP 模式匹配(next 数组,教学示例)

KMP 用 next 数组(最长真前缀=真后缀长度)在失配时跳过不可能匹配的位置,主串不回退,复杂度 O(n+m)。

#include <iostream>
#include <vector>
#include <string>
using namespace std;

// 计算 KMP 的 next 数组:next[i] = 模式 p[0..i] 最长真前缀=真后缀的长度
vector<int> buildNext(const string& p) {
int m = p.size(); vector<int> nx(m, 0);
for (int i = 1, j = 0; i < m; ++i) {
while (j > 0 && p[i] != p[j]) j = nx[j - 1];
if (p[i] == p[j]) ++j;
nx[i] = j;
}
return nx;
}

int main() {
auto nx = buildNext("ABABC");
for (int v : nx) cout << v << " "; // 0 0 1 2 0
cout << "\n";
return 0;
}

8.7 综合:栈实现表达式求值(逆波兰)

中缀表达式转后缀(逆波兰)后用栈求值,是栈的经典综合应用(见 8.3.6 示例)。要点:

  • 中缀转后缀:用栈存运算符,按优先级(先 * / 后 + -)和括号决定输出顺序。
  • 后缀求值:遇数压栈,遇运算符取栈顶两操作数计算(注意被除数/减数顺序)。

8.8 本章小结

  • 数组/顺序表:连续存储、随机访问 O(1)、插入删除 O(n);多维数组行优先偏移 i*cols+j。
  • 链表四类:单链表(1 个 next)、双链表(prev+next,可前移)、单循环(尾指头,约瑟夫环)、双循环(常带哨兵,插入删除统一)。均不能随机访问(最坏查询 O(n))。
  • 栈 LIFO:顺序栈(数组+top)、链栈(无容量限制);应用:括号匹配、表达式求值、DFS、单调栈;合法出栈序列用模拟判定。
  • 队列 FIFO:循环队列((rear+1)%M==front 判满)解决假溢出;链队列无容量限制;deque 双端队列支撑单调队列(滑动窗口最值 O(n));应用:BFS。
  • 哈希表:H(key)=key%M;冲突用线性探查(开放寻址,会聚集)或链地址法(拉链,不退化);理想查找 O(1)。
  • 字符串:特殊线性表,长度可为 0;非空子串数 n(n+1)/2;相邻交换最少次数 = 逆序对;字符串哈希用于快速比较;KMP O(n+m) 模式匹配。

随堂检测(CSP-S 初赛风格)

  1. (单选)哈希表地址 0~12,H(key)=key%13,线性探查,序列 (2,8,31,20,19,18,53,27) 中 18 应放在( )

    • A. 5
    • B. 9
    • C. 4
    • D. 0
    参考答案

    B. 9。

    解析(考点):18%13=5,但 5 已被 31%13=5 占用;线性探查依次后移:6(=20)、7(=19)、8(=8) 均占,9 空,故落入 9。

  2. (单选)哈希表初始空,地址 0~9,h(x)=x%10,线性探查(到 9 后回 0)。依次存 (71,23,73,99,44,79,89),89 存在地址( )

    • A. 9
    • B. 0
    • C. 1
    • D. 2
    参考答案

    D. 2。

    解析(考点):先算落点:71%10=1,23=3,73=3→4,99=9,44=4→5,79=9→0,89=9→0→1→2(1、3、4、9、5、0 已占),故 89 落在 2。

  3. (单选)字符串 S="Olympic" 的非空子串数目是( )

    • A. 29
    • B. 28
    • C. 16
    • D. 17
    参考答案

    B. 28。

    解析(考点):长度 n=7,非空子串数 = n(n+1)/2 = 7×8/2 = 28(按起止位置计,内容相同也计不同)。

  4. (单选)关于字符串,正确的是( )

    • A. 字符串是一种特殊的线性表
    • B. 串的长度必须大于零
    • C. 字符串不可以用数组表示
    • D. 空格字符组成的串就是空串
    参考答案

    A. 字符串是一种特殊的线性表。

    解析(考点):串是元素为字符的线性表;长度可为 0(空串,B 错);串可用字符数组存(C 错);空格串长度大于 0,不是空串(D 错)。

  5. (单选)将 DACFEB 通过相邻两字符交换变成 ABCDEF,最少需要( )次

    • A. 7
    • B. 8
    • C. 9
    • D. 6
    参考答案

    A. 7。

    解析(考点):最少相邻交换次数 = 目标排列相对原串的逆序对数,对 DACFEB→ABCDEF 计算得 7 对逆序。

  6. (单选)链表不具有的特点是( )

    • A. 插入删除不需移动元素
    • B. 不必事先估计存储空间
    • C. 所需空间与表长成正比
    • D. 可随机访问任一元素
    参考答案

    D. 可随机访问任一元素。

    解析(考点):随机访问是数组的特性;链表靠指针链接,必须顺序遍历,不能随机访问(其余三项都是链表优点)。

  7. (单选)含 n 个元素的双向链表,查询关键字 k 最坏时间复杂度是( )

    • A. O(1)
    • B. O(log n)
    • C. O(n)
    • D. O(n log n)
    参考答案

    C. O(n)。

    解析(考点):链表只能从头/尾顺序遍历,最坏查遍全表,复杂度 O(n);O(1) 只有哈希表理想查找或已知结点指针时成立。

  8. (单选)线性表采用链表存储,要求内存可用单元地址( )

    • A. 必须连续
    • B. 部分必须连续
    • C. 一定不连续
    • D. 连续不连续均可
    参考答案

    D. 连续不连续均可。

    解析(考点):链表通过指针链接,不要求连续存储;数组才要求连续。

  9. (单选)入栈顺序 a,b,c,d,e,f,g,下列( )不可能是合法出栈序列

    • A. a,b,c,d,e,f,g
    • B. a,d,c,b,e,g,f
    • C. a,d,b,c,g,f,e
    • D. g,f,e,d,c,b,a
    参考答案

    C. a,d,b,c,g,f,e。

    解析(考点):出 a,d 后栈内剩 b,c;按 LIFO 下一个只能出 c 或继续压 e,不可能先出 b 再出 c(C 中 b 在 c 前,非法);A 为无干扰顺序、B/D 均合法。

  10. (单选)栈 S、队列 Q 初始空,元素 e1..e6 依次入 S,出栈即入 Q;出队顺序为 e2,e4,e3,e6,e5,e1,栈 S 容量至少( )

    • A. 2
    • B. 3
    • C. 4
    • D. 5
    参考答案

    B. 3。

    解析(考点):模拟入/出栈:为让 e2 先出,需先压 e1,e2(深度 2)再弹 e2;继续压 e3,e4 弹 e4,e3(栈内 e1)。过程中栈深峰值 = 3,故最小容量 3。

  11. (单选)(2070)₁₆ + (34)₈ 的结果不正确的是( )

    • A. (8332)₁₀
    • B. (208C)₁₆
    • C. (100000000110)₂
    • D. (20214)₈
    参考答案

    C. (100000000110)₂。

    解析(考点):2070₁₆ = 8296,34₈ = 28,和 = 8324;8324 = 2084₁₆、8324 = 20214₈、二进制应为 10000010000100₂。C 的二进制串对不上,故不正确;注意进制互化需逐位核对。

  12. (多选)把 (2,6,10,17) 存入地址 0~10 的哈希表,哈希函数( )不产生冲突

    • A. x mod 11
    • B. x² mod 11
    • C. 2ˣ mod 11
    • D. ⌊√x⌋ mod 11
    参考答案

    C、D。

    解析(考点):A:2%11=2,6%11=6,10%11=10,17%11=6 → 6 冲突;B:2²=4,6²=36%11=3,10²=100%11=1,17²=289%11=3 → 3 冲突;C/D 映射值互异无冲突。

  13. (多选)入栈 a..g,下列出栈序列不可能出现的有( )

    • A. a,b,c,e,d,f,g
    • B. b,c,a,f,e,g,d
    • C. a,e,c,b,d,f,g
    • D. g,e,f,d,c,b,a
    参考答案

    C、D。

    解析(考点):C 出 a,e 后栈内 b,c,d,下一个必为 d 或继续压 f,不可能先出 c 再 b(违反 LIFO);D:g 最先出说明 a..f 全在栈内,次出应为 f 不是 e。A、B 均合法。

  14. (单选)用栈求中缀表达式值时,通常先转为( )再求值

    • A. 前缀表达式
    • B. 后缀(逆波兰)表达式
    • C. 中序遍历序列
    • D. 哈希表
    参考答案

    B. 后缀(逆波兰)表达式。

    解析(考点):栈实现表达式求值的标准做法:中缀→后缀(逆波兰),再用栈按"遇数压栈、遇运算符取栈顶两操作数计算"求值(见 8.3.6)。

  15. (单选)STL vector 尾部插入的均摊时间复杂度是( )

    • A. O(1)
    • B. O(n)
    • C. O(log n)
    • D. O(n²)
    参考答案

    A. O(1)。

    解析(考点):vector 满时按 2 倍增容(复制 O(n)),但均摊到每次插入为 O(1);随机访问 O(1),中部插入 O(n)。

SShowball OI知识手册