欢迎访问我的博客首页。
1. 二分查找
适用对象:关键字有序的顺序表。
1.1 查找指定值
在有序序列中查找指定值 target,找到返回其下标,找不到返回 -1。下面分别使用非递归算法和递归算法实现。
int binarySearch(int* nums, int start, int end, int target) {
while (start <= end) {
int middle = (start + end) / 2;
if (nums[middle] == target)
return middle;
else if (nums[middle] < target)
start = middle + 1;
else
end = middle - 1;
}
return -1;
}
int binarySearchRecurse(int* nums, int start, int end, int target) {
if (start > end)
return -1;
int middle = (start + end) / 2;
if (nums[middle] == target)
return middle;
else if (nums[middle] < target)
binarySearchRecurse(nums, middle + 1, end, target);
else
binarySearchRecurse(nums, start, middle - 1, target);
}
1.2 查找最值
问题1:在有序序列中查找小于(小于等于)指定值的最大值或大于(大于等于)指定值的最小值。问题2:求旋转数组的最值。这两个问题都可以使用二分查找,但和《在有序序列中查找指定值》有点差别,我们把这两类问题称为第二类二分查找问题,把《在有序序列中查找指定值》称为第一类二分查找问题。
第一类二分查找问题和第二类二分查找问题都使用边界收缩算法解决。它们的差别是:第一类二分查找问题的的边界最终收缩成 start = end + 1,即循环条件是 while(start <= end)。如果把循环条件写 while(start < end),在循环体结束后还要判断 nums[start] 是否等于 target;第二类二分查找问题的边界最终收缩成 start = end,即循环条件是 while(start < end)。
原因是 middle 由上一轮循环结束时的 start 和 end 计算出来。当 start 与 end 相等时 middle 在下一轮循环中才与 start 和 end 相等。当 start = end 时:对第二类二分查找问题来说,本轮循环中两个边界已经重合,重合处的值 nums[start] 必定是最值,所以 start = end 时循环体无需执行;对第一类二分查找问题来说,虽然 start 和 end 都指向了唯一剩余的 nums[start],但 middle 在下一轮循环中才指向这个值并与 target 比较大小,所以循环体还需执行一次。
第二类二分查找问题的例子有 3.2 查找旋转数组的最值 、非递归算法解决《单调递增最长子序列》过程中用到的 3.2 查找大于等于 target 的最小值。第 1 个例子的中也给出了在旋转数组中查找指定值的算法。在旋转数组中查找指定值属于第一类二分查找问题,但边界收缩过程与在旋转数组中查找最值是相同的。
2. 哈希表
哈希函数算法:
- 直接定址法:取关键字或其线性函数值作为哈希地址。
- 除留余数法:取关键字被某个不大于哈希表长度的数取余的值作为哈希地址。
哈希冲突解决算法:
- 开放地址法:线性探测、再平方探测、伪随机探测。
- 链式地址法:把哈希值相同的对象存储在同一链表中。
- 公共溢出区法。
- 再哈希法。
3. 链表
删除列表结点时,删除链表头结点是特殊情况,需要单独考虑这种情况。
3.1 反转链表
题目来自《剑指 Offer 第二版》的《面试题 24:反转链表》。
struct Node {
Node() = default;
Node(int x) { data = x; }
int data;
Node* next = nullptr;
};
void reverse(Node*& list) {
if (list == nullptr) return;
Node* work1 = list;
Node* work2 = work1->next;
while (work2 != nullptr) {
// 为了把work2移到表头,先用work1指向work2的下一个结点。
work1->next = work2->next;
work2->next = list;
// 更新list和work2。
list = work2;
work2 = work1->next;
}
}
Node* creatList(int n) {
Node* head = new Node(0);
Node* work = head;
for (int i = 1; i < n; i++) {
work->next = new Node(i);
work = work->next;
}
return head;
}
void print(Node* list) {
while (list != nullptr) {
cout << list->data << " ";
list = list->next;
}
cout << endl;
}
int main() {
Node* list = creatList(10);
print(list);
reverse(list);
print(list);
}
下图表示了逆序操作的过程:
work1 指向原始链表的表头,且移动过程中一直指向该结点,不需要更新。循环的操作是把 work2 指向的结点移到表头,list 指向新表头,于是移动过程分为两步:
- 把 work2 指向的结点移到表头,即令 work2->next = list 。为了不丢失 work2 后面的结点,先让 work2 前的结点指向 work2 后的结点,即令 work1->next = work2->next。
- 更新 list 和 work2。list 要始终指向表头,所以令 list = work2。循环的开始条件是 work2 在 work1 后,所以令 work2 = work1->next。
3.2 一个单向链表是否有环
题目来自《剑指 Offer 第二版》的《面试题 23:链表中环的入口节点》。
找出入口节点的算法有三步:
- 使用快慢指针检查是否有环。快慢指针同时从表头出发,遇到 nullptr 则无环,在出发点后相遇则有环。
- 获取环的长度 len。相遇后固定其中一个指针,另一个指针就地开始遍历环求其长度。
- 使用先后指针找环的入口。从表头开始,先走指针走到第 len 个结点再与后走指针同步往后走,它们相遇的地方就是入口节点。
3.3 两个单向链表是否有交点
题目来自《剑指 Offer 第二版》的《面试题 52:两个链表的第一个公共节点》。
该提有四种解法:
- 蛮力法。用一个链表的每个节点与另一个链表的所有节点比较,直到找出第一个相等的节点。时间复杂度是 O(mn),空间复杂度是 O(1)。
- 使用栈。时间复杂度是 O(m + n),空间复杂度也是 O(m + n)。
- 使用先后指针。先获取两个链表的长度再计算长度差 x。然后从表头开始,先走指针走到第 x 个结点再与后走指针同步前进,它们相遇的地方就是入口。这个思路与找一个链表中环的入口相同。时间复杂度是 O(m + n),空间复杂度是 O(1)。
- 交替跑路法。两个指针交替遍历两个链表,同步前进,相遇的节点即是入口。假如把两个指针在出发点算作第一步,则第 m + n + 1 步它们必相遇,所以时间复杂度是 O(m + n),空间复杂度是 O(1)。
4. 树
树形数据结构主要应用于查找。
完全二叉树:前
k
−
1
k-1
k−1层结点数达到最大,第
k
k
k层所有结点都连续集中在最左侧的
k
k
k层二叉树。
满二叉树:结点总数为
−
1
+
2
k
-1 + 2^k
−1+2k的
k
k
k层二叉树。
平衡二叉树是严格平衡的。平衡二叉树左右子树高度差不能大于1,为此,每次插入删除都可能需要耗时的旋转操作。所以平衡二叉树只适用于不经常插入删除的应用环境。
红黑树的5个性质:
- 性质1:结点非红即黑。
- 性质2、3、4:根结点、叶结点(空指针)和红结点的子结点是黑结点。
- 性质5:任一结点到其子孙叶结点的所有路径上的黑结点数相等。