8. Binary Heaps

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
[SQUEAKING] [RUSTLING] [CLICKING] ERIK DEMAINE: All right, welcome back to 006 Data Structures. Today, we're going to cover a different kind of tree-like data structure called a heap-- a binary heap. It's going to let us solve sorting problem in a new way. Let me first remind you of a portion-- the problem we're going to be solving today is called priority queue. This is the interface. We'll see several data structures, but one main data structure for today. And this is a subset of the set interface. And subsets are interesting because, potentially, we can solve them better, faster, simpler, something. And so you'll recognize-- you should recognize all of these operations, except we didn't normally highlight the max operation. So here, we're interested in storing a bunch of items. They have keys which we think of as priorities. And we want to be able to identify the maximum priority item in our set and remove it. And so there's lots of motivations for this. Maybe you have a router, packets going into the router. They have different priorities assigned to them. You want to route the highest priority first. Or you have processes on your computer trying to run on your single threaded-- single core, and you've got to choose which one to run next. And you usually run higher priority processes first. Or you're trying to simulate a system where events happen at different times, and you want to process the next event ordered by time. All of these are examples of the priority queue interface. We'll even see applications within this class when we get to graph algorithms. But the main two things we want to be able to support are inserting an item, which includes a key, and leading the maximum item, and also returning it at the same time. We'll also talk some about being able to build the structure faster than just inserting it. But of course, we could implement build by starting empty and repeatedly inserting. And also the complexity of just finding the max without deleting it, this you could simulate with these two operations by deleting the max and then reinserting it, which works. But often, we can do faster. But the two key, main operations are insert and delete max. And we're going to see a few data structures to do this. Any suggestions among the data structures we've seen in this class? What should we use to solve priority queue interface? Many possible answers. AUDIENCE: Sequence AVL? ERIK DEMAINE: Sequence AVL? Ooh, that's interesting! Sequence AVL is a good answer, but maybe the fancier version. Yeah? AUDIENCE: Set AVL? ERIK DEMAINE: Set AVL sounds good. Set AVL supports these operations and many more. All in log n time except for build, which takes n log n time because you have to sort first. So set AVL a good way to do this. We'll come back to your sequence AVL idea later. This gets log n for operation. Great. I mean, this is-- set AVL is our most powerful data structure. It does all the operations we care about on the set side. And the sequence AVL does all the operations on the sequence side. But note that this is a set, not a sequence. We care about keys. There are hacks to get around that with sequence AVLs, but let's do that later. So great, if we wanted to, for example, speed up find_max in a set AVL. We could add augmentation. We could-- remember subtree property augmentations? We can use that to get constant time find_max by storing in every node the maximum key item within the subtree. And that's a subtree property. It's one we mentioned last class. So we could even improve that to cut some time. Great. So we're done. End of lecture. [CHUCKLES] In some sense, that's true. But what we're going to see today is another data structure called a binary heap, which is, in some sense, a simplification of set AVL. It achieves basically the same time bounds. Build will be faster by a log factor. But that's not the main reason we care about them. The main advantage is that they're simpler and they give us an in-place sorting algorithm. So I have up here three of the operations I've been talking about-- build, insert, and delete_max. So we have set AVL trees there-- n log n build, log n insert, log n delete. So along the way to our heap, I want to mention two other data structures. One is a dynamic but unsorted array. And the other is a dynamic sorted array. These are simpler data structures we've talked about many times before. And they're useful kind of motivations for getting started, because a heap is going to be built on top of arrays instead of-- well, it's sort of a fusion between arrays and trees. So if I have an unsorted array, this is very easy to insert into, right? I just append to the end. This is what we called insert last. So insert is fast, constant amortized. We might have to resize the array, but so that's the amortized part. But delete max is slow. In an unsorted array, I don't know where the maximum is. So I have to scan through the whole array. So I scan through the array, identify that the max is somewhere in the middle, and then, if I want to delete it-- I want to delete that maximum element, well, in a dynamic array, all I can really do is delete the last element efficiently. So I could, for example, swap it with the last element. So I take this element and put it here, and then delete the last element in that array, which is pop in Python or delete_last in our world. So overall, this is linear time, which is bad. But I wanted to highlight exactly how it's done for a reason we'll get to in a moment. A sorted array is sort of the reverse. It's very easy to find the max. Where is it? At the end. delete_max, the maximum element is always the last element in a increasing sorted array. I guess that's constant amortized, because then I have to delete it, which may incur resizing. Insert, though, is going to be linear, because maybe I can binary search to find where the added item belongs. Let's say I just added this item here. I could binary search to find it, but then I'm going to have to do a big shift. So I might as well just swap repeatedly until I find the position where the added item x belongs. And now I've restored sorted order. That takes linear time, which is bad. And what we want is somehow the best of these two worlds. Insert is fast for array. Delete is fast for a sorted array. We can't get constant time for both. But we can get log n time for both. We already know how with set AVL trees. But we're going to see a different way to do it today. And the main motivation for a different way to do this is sorting. So I want to define a priority queue sort. So given any data structure that implements a priority queue interface, in particular insert and delete_max, I can make a sorting algorithm. What do I do? Insert all the items, delete all the items. But because when I delete them they come out largest first, I get them in reverse sorted order. Then I could reverse in linear time and I've sorted my items. So we can insert (x) for x in A, or (build(A)), and then repeatedly delete_max. How much time does this algorithm take? I'm going to introduce some notation here. It takes however long it takes to build n items, call that T sub build (n) plus-- sorry-- plus n times the time to do a delete_max. Or we can write this as n times time to do an insert, plus time to do a delete_max. So I'm using these T functions to just abstract what are the running times provided by my data structure that implements this interface. Interface says what's correct is, and these T functions give me my performance bounds. So if I plug in each of these data structures, I get a sorting algorithm. I get AVL sort, I get array sort, I get assorted array sort. What do those look like? It turns out many of these are familiar. So set AVLs take log n per operation. So we get an n log n sorting algorithm out of them, which is insert all of the items into the AVL tree. I don't want to use AVL build because that uses sort, and not allowed to sort in order to implement sort. But we saw how to insert into an AVL tree and keep the thing balanced. So that takes log n each. And then we can find the max, delete it, rebalance, and so on. Total time will be n log n. This is an algorithm we call AVL sort. It's a bit complicated, because AVL trees are complicated. But it gives us optimal comparison bound and log n. Now, what about array sort? So suppose I use an unsorted array. I insert the item. So if I insert the items-- so I'm doing all the insertions here before all the deletions. So what's going to happen is I just insert the items in the original array order. In other words, I just take the array. And then what I do is repeatedly extract the maximum item by searching for it, moving it to the end of the array, and then repeating that process. That sound familiar? That's selection sort from lecture three. So this-- arrays give us selection sort. This is a new way to think about what we were doing way back then. With a sorted array, what are we doing? We insert all the items. That's actually where all the work happens, because we maintain the sorted array. So we start with an empty array. It's sorted. We add an item. OK, it's still sorted. We add a second item, and we swap if we need to in order to sort. In general, when we add an item, we swap it to the left until it's sorted again. That is insertion sort. Kind of cool, this is a unifying framework for three sorting algorithms that we saw before. We didn't actually talk about AVL sort last time, but it was in the notes. And so that is the right part of this table. So of course, these array data structures are not efficient. They take linear time for some of the operations. So the sorting algorithms are not efficient. But they're ones we've seen before, so it's neat to see how they fit in here. They had the-- selection sort and insertion sort had the advantage that they were in place. You just needed a constant number of pointers or indices beyond the array itself. So they're very space efficient. So that was a plus for them. But they take n squared time, so you should never use them, except for n, at most, 100 or something. AVL tree sort is great and then it gets n log n time, probably more complicated than merge sort and you could stick to merge sort. But neither merge sort nor set AVL tree sort are in place. And so the goal of today is to get the best of all those worlds in sorting to get n log n comparisons, which is optimal in the comparison model, but get it to be in place. And that's what we're going to get with binary heaps. We're going to design a data structure that happens to build a little bit faster-- as I mentioned, linear time building. So it's not representing a sorted order in the same way that AVL trees are. But it will be kind of tree-based. It will also be array-based. We're going to get logarithmic time for insert and delete_max. It happens to be amortized, because we use arrays. But the key thing is that it's an in-place data structure. It only consists of an array of the items. And so, when we plug it into our sorting algorithm-- priority queue sort or generic sorting algorithm-- not only do we get n log n performance, but we also get an in-place sorting algorithm. This will be our first and only-- to this class-- n log n in-place sorting algorithm. Cool. That's the goal. Let's do it. So what we're going to do, because we're in place, basically we have to have an array storing our end items. That's sort of the definition of in-place, just using n slots of memory exactly the size of the number of items in our structure. But we're obviously not going to use a regular unsorted array or a regular sorted array. We're going to use array just as sort of the underlying technology for how things are stored. But we'd really like logarithmic performance, which should make you think tree. Only way to get a log is the binary tree, more or less. So somehow, we want to embed a tree into an array. Let me grab an example. Let me draw a tree. If I got to choose any old tree I want, I would choose a tree that's basically perfectly balanced. Perfectly balanced would be like this, where-- what's the property? That I have all of these levels-- all of these depths are completely filled with nodes. This is depth 0. Remember, this is depth 1, this is depth 2, this is depth 3. So what I'd really like is to have 2 to the i nodes at depth i. That would be a perfect binary tree. But that only works when n is 1 less than a power of 2, right? I can't always achieve that for any n. And so the next best thing I could hope for is 2 to the i at nodes at depth i until the very last i-- the largest depth. And in that level, I'm still going to restrict things. I'm going to force all of the nodes to be as far left as possible. So I want to say, except at max depth where nodes are-- I'll call them left justified. And these two properties together is what I call a complete binary tree. Why is this interesting? Because I claim I can represent a tree like this as an array. I've narrowed things down enough that I can draw an array down here. And what I'm going to do is write these nodes in depth order. So I write A first, because that's step 0. Then B, C, that's step 1. Then, well, they're alphabetical. I made it that way. D, E, F, G is depth 2. And then H, I, J is step 3. This is very different from traversal order of a tree. Traversal order would have been H, D, I, B, J, E, A, F, C, G, OK? But this is what we might call depth order, do the lowest depth nodes first-- very different way to lay things out or to linearize our data. And this is what a heap is going to look like. So the cool thing is, between complete binary trees and arrays is a bijection. For every array, there's a unique complete binary tree. And for every complete binary tree, there's a unique array. Why? Because the complete constraint forces everything-- forces my hand. There's only-- if I give you a number n, there is one tree shape of size n, right? You just fill in the nodes top down until you get to the last level. And then you have to fill them in left to right. It's what you might call reading order for writing down nodes. And the array is telling you which keys go where. This is the first node you write down at the root, this is the next node you write down at the left child of the root, and so on. So here we have a binary tree represented as an array, or array representing a binary tree. The very specific binary tree, it has a clear advantage, which is it is guaranteed balance. No rotations necessary in heaps, because complete binary trees are always balanced. In fact, they have the best height they possibly could, which is ceiling of log n. Balanced, remember, just meant you were big O of log n. This is 1 times log n. So it's the best level of balance you could hope for. So somehow, I claim, we can maintain a complete binary tree for solving priority queues. This would not be possible if you were trying to solve the whole set interface. And that's kind of the cool thing about heaps, is that by just focusing on the subset of the set interface, we can do more. We can maintain this very strong property. And because we have this very strong property, we don't even need to store this tree. We're not going to store left and right pointers and parent pointers, we're just going to store the array. This is what we call an implicit data structure, which basically means no pointers, just an array of the n items. How are we going to get away without storing pointers? I'd still like to treat it like a tree. I'd still like to know the left child of B is D and the right child B is E. We'll see why in a moment. Well, we can do this with index arithmetic. So maybe I should add some labels before I get there. So this array naturally has indices. This is index 0. This is index 1, index 2, index 3, index 4, index 5, index 6, 7, 8, 9, because there are 10 items, 0 through 9. And I can apply those labels up here, too. These are the same nodes, so 0, 1, 2. This is just a depth order. But once I have this labeling, it's going to be a lot easier to figure things out. So if I wanted to know the left child of B is D, somehow, given the number 1, I want to compute the number 3. Add 2, there are all sorts-- multiply by 3, there are all sorts of operations that take 1 and turn it into 3. But there's only one that's going to work in all cases. And the intuition here is, well, I have to 2 the i nodes at level i. If I want to go to the child level, there's 2 to the i plus 1 nodes down there-- exactly double. So it's the very last one, but that won't really matter. If there is a left child, it will behave the same. And so, intuitively, I have this space of size 2 to the i. I have to expand it to a space of size 2 to the i plus 1, So I should multiply by 2. And that's almost right, but then there's some constants. So I'd like to say 2 times i. But if we look at the examples here, 1 times 2 is 2, which is 1 less than 3. 2 times 2 is 4, which is 1 less than 5. Hey, we almost got it right. It's just off by 1. Off by 1 is-- index errors are the most common things in computer science. What about the right child? If the left child is a 2i plus 1, where is the right child? I hear lots of mumbles. 2i plus 2-- one more. Because we're writing things left to right in depth order, the right child is the right sibling of the left child. So it's just one larger, OK? Given those rules, we can also compute parent. It's just whatever is the inverse of both of these functions, which I want to divide by 2 at some point. I want to get back to i given 2i plus 1 or given 2i plus 2. And so if I subtract 1 from i, then I either get 2i or 2i plus 1. And then, if I take an integer division by 2, I get i-- the original i. Sorry, maybe I'll call this j to be clearer. So j is the left or right child. Then I can reconstruct i, which was the parent. So this is constant number arithmetic operations. So I don't have to store left and right pointers. I can just compute them whenever I need them. Whenever I'm at some node like E, and I want to know what's its left child-- sorry, given the node index 4, which happens to contain the item E, and I want to know what's its left child, I just multiply by 2 and add 1. I get 9. And then, I can index into this array at position 9. Because I don't-- this is just in my head, remember. We're just thinking that there's a tree here. But in reality, on the computer, there's just the array. So if we want to go from E to J, we can, from 4 to 9. If we go try to go to the right child, we multiply by 2. 8 add 2-- 10. And we see, oh, 10 is beyond the end of the array. But our array stores its size, so we realize, oh, E does not have a right child. This is something you can only do in a complete binary tree. In a general binary tree you don't have these nice properties. Cool, so this is basically a heap. I just need to add one more property, naturally called the heap property. So there are multiple types of heaps. This type of heap is called a binary heap. We will talk about others in future lectures. I'm going to call it Q. Explicit thing-- this is an array representing a complete binary tree, called the array Q. And we want every node to satisfy the so-called max-heap property, which says Q[i] is greater than or equal to Q[j] for both children left of i and right of i. So we have a node i. And it has two children-- 2i plus 1 and 2i plus 2. These are two values of j. What we want is a greater than or equal to relation here and here. So this node should be bigger than both this one and this one. Which of these is larger? We don't know, and we don't care-- very different from binary search trees or set binary trees, where we said these guys were less than or equal to this one, this one was less than or equal to all the nodes in the subtree here. We're just locally saying, this node is greater than or equal to this node and this node. So the biggest is at the top. So one nice lemma about these heaps-- this is weird. Let me give you some more intuition. If you are a binary heap, if you satisfy this max-heap property everywhere, then in fact, you learn that every node i is greater than or equal to all nodes in its subtree. These are what we call descendants in subtree of i. Let me look at this example. So I haven't written any numbers here. You can imagine. So A here is greater than or equal to both B and C, and B is greater than or equal to D and E, and C is greater than or equal to F and G, D is greater than or equal to H and I, and E is greater than or equal to J. That would make this structure a heap, not just a complete binary tree. So what does that imply? It implies that A must be the maximum. So you look at any node here, like J, A is greater than or equal to B is greater than or equal to E is greater than or equal to J. And in general, what we're saying is that A is greater than or equal to all nodes in the tree. B is greater than or equal to all nodes in its subtree down here. C is greater than or equal to all nodes in its subtree. That's what this lemma is saying. You can prove this lemma by induction. But it's really simple. If you have two nodes, i and j, and j is somewhere in the subtree, that means there's some downward path from i to j. And you know that, for every edge we traverse on a downward path, our key is going down non-strictly. So every child is less than or equal to its parent. i is greater than or equal to this, is greater than or equal to this, is greater than or equal to this, is greater than or equal to j, OK? So by transitivity of less than or equal to, you know that i is, in fact, greater than or equal to j. Or sorry, the key in i is greater than or equal to the key in j. This is what we're calling i, the index. This is what we would call Q of i. This is Index j Q of j. Very different way to organize keys in a tree, but as you might imagine, this is going to be good for priority queues. Because priority queues just need to find the maximum elements. Then they need to delete it. That's going to be harder, because leading the root is, like-- that's the hardest node to delete, intuitively. I'd really prefer to delete leaves. But leading leaves and keeping a complete binary tree is actually kind of hard. If I want to delete H, that doesn't look like a binary tree, or it doesn't look like a complete binary tree anymore. It's not left justified. Similarly, if I want to delete F, that's bad. Because now, I don't have four nodes here. The one node that's easy to delete is J, right? If I remove that node, I still have a complete tree. The last leaf, the last position in my array, is the one that's easy to delete. That's good, because arrays are good at leading the last item. But what I've set up here is it's easy to find the max. It's going to be up here at the root. Deleting it is annoying. I'd like to somehow take that key and put it at position-- at the last position at the last leaf, because that's the one that's easy to delete. And that's indeed what we're going to do in a delete algorithm. Let me first do insert. I guess that's a little simpler, kind of symmetric to what we just said. So if I want to insert a key or an item x which has some key, again, the only thing I really can do in an array-- if I want to add a new item, it has to go at the end. The only thing we know how to do is insert at the end of an array. This is what we called insert_last. this? Corresponds to adding a node containing x-- the item x-- in the very last level of the complete binary tree. Either it goes to the right of all the existing nodes, or starts a new level. But it's always going to be the last leaf. After we do the insertion, it will be at position size of Q minus 1. This is probably not enough, though. We just inserted an arbitrary item in a leaf. And now, it may not satisfy the max-heap property anymore. So let's just check if it does, and if it doesn't, fix it. That's what we know how to do. But this time, we're not even going to need rotations, which is cool. So I'm going to define an operation called max_heapify_up. This will make things more like a max-heap. We're going to start at size of Q minus 1 for our value i. But it's going to be recursive, so what we're going to do is look at a node i, in particular the one that just got inserted. And where could it violate things? Well, with its parent, because we have no idea what key we just put here. Maybe it's less than our parent. Then we're happy. But if it's greater than our parent, we're in trouble and we should fix it. So if the item in the parent's key is less than i's key-- ah, I see I forgot to write key and all these spots. This should be dot key and dot key, because Q[i] is an item that gets its key. So this is the bad case. This is if the parent is smaller than the child. We wanted the parent to always be greater than or equal to its children. So in that case, what could we do? Swap them. Let's swap Q parent of i-- excellent, more chalk-- with Q[i]. Now they're in the right order. Now, we need to think about what about the other child of that node? And what about its parent? So I have some numbers here. Let's say this was 5 and this was 10. What do I know about this picture before? Well, I know that 10 is this newly inserted item. It's the only one that could have caused violations when I first inserted it. So I know that before this-- before I moved 10 around, I knew all the things in this left subtree are less than or equal to 5, and everything up here are created equal to 5. I also know that the nodes in here, in fact, were less than or equal to 5. Other than this node 10 that we just inserted, this was a correct heap. So 5 was a separator between-- things above it on the ancestor chain are greater than or equal to 5, and things in its subtree are less than or equal to it. So after I do this swap, which I'm just going to do-- after I swap the items 5 and 10, 10 is up here, 5 is here. And now, I realize, OK, great, this edge is happy, because now 10 is greater than or equal to 5. But also this edge is happy, because it used to be happy, and we only made its parent larger. Now this edge maybe is bad. And so we need to recurse-- recurse on the parent. But that's it. So we fixed this one edge. Initially, this happens way down at the leaf. But in general, we're taking our item that we inserted, which is x, and it starts at the last leaf, and it may be bubbles up for a while. And maybe it gets all the way to the root if we inserted a new maximum item. But in each step, it goes up one. And so the running time of all this stuff is the height of the tree, which is log n. And because there's only this one item that could potentially be wrong, if it ever stops moving, we've just checked that it satisfies the max-heap property. If it gets to the root, you can also check it satisfies the max-heap property. So there's a base case I didn't write here, which is if i equals 0, we're at the root, we're done. And then you can prove this correct by induction. There's just one item that's in the wrong spot, initially. And we put it into a right spot. There are many places it could go, but we will move it to the, I guess, unique ancestor position that is correct-- that satisfies max-heap property, OK? So that's insert. Delete is going to be almost the same, delete_min, that is-- sorry, delete_max, thank you. You can of course define all of these things for min instead of max. Everything works the same. I just have a hard time remembering which one we're doing. Just don't switch you can't use a max-heap to do delete_min. You can't use a min-heap to do delete_max, but you can use a min-heap to do delete_min. That's fine. So like I said, the only node we really know how to delete is the last leaf on the last level, which is the end of the array. Because that's what arrays can delete efficiently. And what we need to delete is the root item, because that's always the maximum one, which is at the first position in the array. So what do we do? Swap them, our usual trick. I think the cool thing about heaps is we never have to do rotations. We're only going to do swaps, which is something we had to do with trees also-- binary trees. Q[0] with Q of the last item-- great, done. Now we have the last item is the one we want to delete. So we do delete_last, or pop in Python, and boom, we've got-- we've now deleted the maximum item. Of course, we may have also messed up the max-heap property just like we did with insert. So with insert, we were adding a last leaf. Now, what we're doing is swapping the last leaf with the-- I'm pointing at the wrong picture. Let me go back to this tree. What we did is swap item J with A. So the problem is now-- and then we deleted this node. The problem is now that that root node has maybe a very small key. Because the key that's here now is whatever was down here, which is very low in the tree. So intuitively, that's a small value. This is supposed to be the maximum value, and we just put a small value in the root. So what do we do? Heapify down. We're going to take that item and somehow push it down to the tree until the-- down in the tree until max-heap property is satisfied. So this is going to be max_heapify_down. And we will start at position 0, which is the root. And max_heapify_down is going to be a recursive algorithm. So we'll start at some position i. And initially, that's the root. And what we're going to do is look at position i and its two children. So let's say we put a very small value up here, like 0. And let's say we have our children, 5 and 10. We don't know-- maybe I'll swap their order just to be more generic, because that looks like not quite a binary search tree, but we don't know their relative order. But one of them is greater than or equal to the other in some order. And so what would I like to do to fix this local picture? Yeah, I want to swap. And I could swap-- 0 is clearly in the wrong spot. It needs to go lower in the tree. I can swap 0 with 5 or 0 with 10. Which one? 10. I could draw the picture with 5, but it will not be happy. Why 10? We want to do it with the larger one, because then this edge will be happy, and also this edge will be happy. If I swapped 5 up there instead, the 5/10 edge would be unhappy. It wouldn't satisfy the max-heap property. So I can do one swap and fix max-heap property. Except that, again, 0 may be unhappy with its children. 0 was this one item that was in the wrong spot. And so it made it have to go farther down. But 5 will be-- 5 didn't even move. So it's happy. Everything in this subtree is good. What about the parent? Well, if you think about it, because everything was a correct heap before we added 0, or before we put 0 too high, all of these nodes will be greater than or equal to 10 on the ancestor path. And all of these nodes were less than or equal to 10 before, unless you're equal to 5. So that's still true. But you see, this tree is happy. This tree still may be unhappy. 0 still might need to push down farther. That's going to be the recursion. So we check down here. There's a base case, which is if i is a leaf, we're done. Because there's nothing below them. So we satisfy the max-heap property at i because there's no children. Otherwise, let's look at the leaf among the left-- sorry, left not leaf-- among the two children left and right of i. Right if i might not exist. Then ignore it. But among the two children that exist, find the one that has maximum key value, Q[j].key. That was 10 in our example. And then, if these items are out of order, if we do not satisfy-- so greater than would be satisfy. Less than Q[j] would be the opposite of the max-heap property here. If max-heap property is violated, then we fix it by swapping Q[i] with Q[j], and then we recurse on j-- call max_heapify_down of j. That's it. So pretty symmetric. Insert was a little bit simpler, because we only have one parent. Delete_min, because we're pushing down, we have two children. We have to pick one. But there's a clear choice-- the bigger one. And again, this algorithm-- this whole thing-- will take order h time, the height of the tree, which is log n, because our node just sort of bubbles down. At some point, it stops. When it stops, we know the max-heap property was satisfied there. And if you check along the way, by induction, all the other max-heap properties will be satisfied, because they were before. So almost forced what we could do here. The amazing thing is that you can actually maintain a complete binary tree that satisfies the max-heap property. But once you're told that, the algorithm kind of falls out. Because we have an array. The only thing we can do is insert and delete the last item. And so we've got to swap things to there in order-- or out of there in order to make that work. And then, the rest is just checking locally that you can fix the property. Cool. So that's almost it, not quite what we wanted. So we now have log n amortize insert and delete_max in our heap. We did not yet cover linear build. Right now, it's n log n if you insert n times. And we did not yet cover how to make this an in-place sorting algorithm. So let me sketch each of those. I think first is in-place. So how do we make this algorithm in-place? I guess I want that, but I don't need this. So we want to follow priority queue sort. Maybe I do want that. But I don't want to have to grow and shrink my array. I would just like to start with the array itself. So this is in place. So what we're going to do is say, OK, here's my array that I want to sort. That's given to me. That's the input to priority queue sort. And what I'd like is to build a priority queue out of it. Initially, it's empty. And then I want to insert the items one at a time, let's say. So in general, what I'm going to do is maintain that Q is some prefix of A. That's going to be my priority queue. It's going to live in this sub array-- this prefix. So how do I insert a new item? Well, I just increment. So to do an insert, the first step is increment size of Q. Then I will have taken the next item from A and injected into this Q. And conveniently, if we look at our insert code, which is here, the first thing we wanted to do was add an item at the end of the array. So we just did it without any actual work, just conceptual work. We just said, oh, our Q is one bigger. Boom! Now this is at the end of the array. no more amortization, in fact, because we're not ever resizing our array, we're just saying, oh, now Q is a little bit bigger of a prefix. It just absorb the next item of A. Similarly, delete_max is going to, at the end, decrement the size of Q. Why is that OK? Because at the end of our delete_max operation-- not quite at the end, but almost the end-- we deleted the last item from our array. So we just replaced that delete last with a decrement, and that's going to shrink the Q by 1. It has the exact same impact as leading the last item. But now, it's constant time, worst case not amortized. And the result is we never actually build a dynamic array. We just use a portion of A to do it. So what's going to happen is we're going to absorb all the items into the priority queue, and then start kicking them out. As we kick them out, we kick out the largest key item first, and we put it here, then the next largest, then the next largest, and so on. The minimum item is going to be here. And, boom, it's sorted. This is the whole reason I did max-heaps instead of min-heaps, is that in the end, this will be a upward sorted array with the max at the end. Because we always kick out items at the end. We delete the max first. So that is what's normally called heapsort. You can apply this same trick to insertion sort and selection sort, and you actually get the insertion sort and selection sort algorithms that we've seen which operate in prefixes of the array. Cool, so now we have-- we've achieved the y up there, which is n log n sorting algorithm that is in-place. So that was our main goal-- heapsort. Let me very quickly mention you can build a heap in linear time with a clever trick. So if you insert the items one at a time, that would correspond to inserting down the array. And every time I insert an item, I have to walk up the tree. So this would be the sum of the depth of each node. If you do that, you get n log n. This is the sum over i of log i. That turns out to be n log n. It's a log of n factorial. The cool trick is to, instead, imagine adding all the items at once and not heapifying anything, and then heapify up-- sorry, heapify down from the bottom up. So here we're heapifying up. Now, we're going to heapify down. And surprisingly, that's better. Because this is the sum of the heights of the nodes. And that turns out to be linear. It's not obvious. But intuitively, for a depth, this is 0, this is log n, and we've got a whole ton of leaves. So right at the leaf level, you can see, we're paying n log n, right? Because there are n of them, and each one costs log n. Down here, at the leaf level, we're paying constant. Because the height of the leaves are 1. Here, the height of the root is log n. And this is better. Now we're paying a small amount for the thing of which there are many. It's not quite a geometric series, but it turns out this is linear. So that's how you can do linear building heap. To come back to your question about sequence AVL trees, turns out you can get all of the same bounds as heaps, except for the in-place part, by taking a sequence AVL tree, storing the items in an arbitrary order, and augmenting by max, which is a crazy idea. But it also gives you a linear build time. And yeah, there's other fun stuff in your notes. But I'll stop there.
Info
Channel: MIT OpenCourseWare
Views: 975
Rating: 5 out of 5
Keywords: priority queue, AVL tree, array, binary heap, heap insert, heap delete max, heap sort
Id: Xnpo1atN-Iw
Channel Id: undefined
Length: 50min 51sec (3051 seconds)
Published: Mon Sep 13 2021
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.